mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: resolve 18 QA-discovered bugs across tools, previews, and the AI pipeline (#242)
Exhaustive QA sweep of all 157 tools. Fixes: CSP blob media, csv-excel ExcelJS interop, ocr-pdf segfault, chart-maker upload, non-PDF doc preview, RAW decode, merge-tool multi-file path, html-to-image chromium, ogv/wma/amr/ac3 preview fallbacks, meme/gif/stabilize codecs, nav+home a11y. Plus orphan-format and test-debt cleanup, the AI bundle build script, and a reusable Playwright QA harness under tests/qa/.
This commit is contained in:
@@ -13,17 +13,17 @@ const SCALAR_FONT_ORIGIN = "https://fonts.scalar.com";
|
||||
* would require forking the Scalar plugin, which is not practical.
|
||||
*/
|
||||
export function buildCsp(isDocs: boolean): string {
|
||||
const connectSrc = ["'self'", "data:", ...POSTHOG_ORIGINS, ...SENTRY_ORIGINS].join(" ");
|
||||
const connectSrc = ["'self'", "blob:", "data:", ...POSTHOG_ORIGINS, ...SENTRY_ORIGINS].join(" ");
|
||||
const fontSrc = isDocs ? `'self' data: ${SCALAR_FONT_ORIGIN}` : "'self' data:";
|
||||
const scriptSrc = isDocs
|
||||
? "'self' 'unsafe-inline' https://us-assets.i.posthog.com"
|
||||
: "'self' https://us-assets.i.posthog.com";
|
||||
|
||||
if (isDocs) {
|
||||
return `default-src 'self'; script-src ${scriptSrc}; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; connect-src ${connectSrc}; font-src ${fontSrc}; object-src 'none'; base-uri 'self'; form-action 'self'`;
|
||||
return `default-src 'self'; script-src ${scriptSrc}; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; media-src 'self' blob:; connect-src ${connectSrc}; font-src ${fontSrc}; object-src 'none'; base-uri 'self'; form-action 'self'`;
|
||||
}
|
||||
|
||||
return `default-src 'self'; script-src ${scriptSrc}; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data: https://tile.openstreetmap.org; connect-src ${connectSrc}; font-src ${fontSrc}; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'`;
|
||||
return `default-src 'self'; script-src ${scriptSrc}; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data: https://tile.openstreetmap.org; media-src 'self' blob:; connect-src ${connectSrc}; font-src ${fontSrc}; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'`;
|
||||
}
|
||||
|
||||
export function getSecurityHeaders(): Record<string, string> {
|
||||
|
||||
@@ -243,6 +243,13 @@ export async function validateImageBuffer(
|
||||
detectedFormat = "png";
|
||||
}
|
||||
|
||||
// RAW formats with non-TIFF magic bytes (Panasonic RW2, some Olympus ORF,
|
||||
// Pentax PEF, etc.) are not caught by MAGIC_BYTES. Fall back to
|
||||
// extension-based detection for known Camera RAW extensions.
|
||||
if (!detectedFormat && ext && isRawExtension(ext)) {
|
||||
detectedFormat = "raw";
|
||||
}
|
||||
|
||||
if (!detectedFormat) {
|
||||
return { valid: false, reason: "Unrecognized image format" };
|
||||
}
|
||||
|
||||
@@ -211,7 +211,41 @@ async function decodeRaw(buffer: Buffer, ext?: string): Promise<Buffer> {
|
||||
// ExifTool not available or no embedded JPEG -- fall through
|
||||
}
|
||||
|
||||
// Attempt 2: ImageMagick + LibRaw delegate (full decode)
|
||||
// Attempt 1b: ExifTool PreviewImage extraction (some formats store
|
||||
// preview under a different tag than JpgFromRaw).
|
||||
try {
|
||||
const { stdout } = await execFileAsync("exiftool", ["-b", "-PreviewImage", inputPath], {
|
||||
encoding: "buffer",
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
timeout: 30_000,
|
||||
} as never);
|
||||
const previewBuf = stdout as unknown as Buffer;
|
||||
if (previewBuf && previewBuf.length > 1000) {
|
||||
if (previewBuf[0] === 0xff && previewBuf[1] === 0xd8) {
|
||||
return previewBuf;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
|
||||
// Attempt 2: dcraw_emu from libraw-bin (direct LibRaw decode to TIFF).
|
||||
// More reliable than ImageMagick's delegate chain for Camera RAW.
|
||||
try {
|
||||
await execFileAsync("dcraw_emu", ["-T", "-w", "-o", "1", inputPath], { timeout: 120_000 });
|
||||
// dcraw_emu writes output next to the input with a .tiff extension
|
||||
const dcrawOutput = inputPath.replace(/\.[^.]+$/, ".tiff");
|
||||
const tiffBuf = await readFile(dcrawOutput);
|
||||
await rm(dcrawOutput, { force: true }).catch(() => {});
|
||||
if (tiffBuf.length > 0) {
|
||||
// Convert TIFF to PNG via Sharp (Sharp handles TIFF natively)
|
||||
return await sharp(tiffBuf).png().toBuffer();
|
||||
}
|
||||
} catch {
|
||||
// dcraw_emu not available or unsupported format -- fall through
|
||||
}
|
||||
|
||||
// Attempt 3: ImageMagick + LibRaw delegate (full decode)
|
||||
const cmd = await findMagickCmd();
|
||||
await execFileAsync(
|
||||
cmd,
|
||||
|
||||
@@ -20,8 +20,10 @@ export function registerCsvExcel(app: FastifyInstance) {
|
||||
const base = input.filename.replace(/\.[^.]+$/, "");
|
||||
const lower = input.filename.toLowerCase();
|
||||
|
||||
// Dynamic import: exceljs is heavy; load it only when this tool runs
|
||||
const ExcelJS = await import("exceljs");
|
||||
// Dynamic import: exceljs is heavy; load it only when this tool runs.
|
||||
// exceljs is CJS, so under ESM the constructor lives on the default export
|
||||
// (namespace `.Workbook` is undefined -> "is not a constructor").
|
||||
const { default: ExcelJS } = await import("exceljs");
|
||||
|
||||
if (lower.endsWith(".xlsx")) {
|
||||
// xlsx -> csv: load workbook, pick the Nth worksheet, extract rows
|
||||
|
||||
@@ -170,9 +170,20 @@ async function processMeme(
|
||||
gif: "image/gif",
|
||||
};
|
||||
|
||||
// Ensure the output filename extension matches the actual raster format
|
||||
// (e.g. SVG input produces a PNG buffer, so the name must end in .png).
|
||||
const extMap: Record<string, string> = {
|
||||
jpeg: ".jpg",
|
||||
png: ".png",
|
||||
webp: ".webp",
|
||||
gif: ".gif",
|
||||
};
|
||||
const correctExt = extMap[detectedFormat] ?? ".png";
|
||||
const outFilename = filename.replace(/\.[^.]+$/, correctExt);
|
||||
|
||||
return {
|
||||
buffer: result,
|
||||
filename,
|
||||
filename: outFilename,
|
||||
contentType: mimeMap[detectedFormat] ?? "image/png",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { extname, join } from "node:path";
|
||||
import { probeMedia, resolveEncoder, runFfmpeg } from "@snapotter/media-engine";
|
||||
import { type EncoderTarget, probeMedia, resolveEncoder, runFfmpeg } from "@snapotter/media-engine";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js";
|
||||
@@ -9,6 +9,44 @@ const settingsSchema = z.object({
|
||||
smoothing: z.number().int().min(5).max(60).default(15),
|
||||
});
|
||||
|
||||
/**
|
||||
* Choose a codec that is valid for the given container extension.
|
||||
* webm requires VP9 (or VP8/AV1); ogv requires Theora; everything
|
||||
* else gets H.264 (mp4/mov/mkv/avi/ts).
|
||||
*/
|
||||
function codecForContainer(ext: string): {
|
||||
target: EncoderTarget;
|
||||
encodeArgs: string[];
|
||||
} {
|
||||
const lower = ext.toLowerCase();
|
||||
if (lower === ".webm") {
|
||||
return {
|
||||
target: "vp9",
|
||||
encodeArgs: ["-c:v", resolveEncoder("vp9"), "-crf", "30", "-b:v", "0", "-row-mt", "1"],
|
||||
};
|
||||
}
|
||||
if (lower === ".ogv" || lower === ".ogg") {
|
||||
// Theora has no HW-accel path; use libtheora directly.
|
||||
return {
|
||||
target: "h264", // unused, just for the type
|
||||
encodeArgs: ["-c:v", "libtheora", "-q:v", "7"],
|
||||
};
|
||||
}
|
||||
return {
|
||||
target: "h264",
|
||||
encodeArgs: [
|
||||
"-c:v",
|
||||
resolveEncoder("h264"),
|
||||
"-crf",
|
||||
"20",
|
||||
"-preset",
|
||||
"medium",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function registerStabilizeVideo(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "stabilize-video",
|
||||
@@ -38,9 +76,19 @@ export function registerStabilizeVideo(app: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
// Pass 2: stabilization with re-encode
|
||||
// Pass 2: stabilization with re-encode using a container-appropriate codec.
|
||||
ctx.report(50, "Stabilizing");
|
||||
const outPath = join(ctx.scratchDir, "media", outName);
|
||||
const { encodeArgs } = codecForContainer(origExt);
|
||||
|
||||
// Audio: webm/ogv need Opus/Vorbis; for other containers just copy.
|
||||
const audioArgs =
|
||||
origExt.toLowerCase() === ".webm"
|
||||
? ["-c:a", resolveEncoder("opus")]
|
||||
: origExt.toLowerCase() === ".ogv" || origExt.toLowerCase() === ".ogg"
|
||||
? ["-c:a", "libvorbis"]
|
||||
: ["-c:a", "copy"];
|
||||
|
||||
await runFfmpegWithProgress(
|
||||
ctx,
|
||||
[
|
||||
@@ -48,16 +96,8 @@ export function registerStabilizeVideo(app: FastifyInstance) {
|
||||
inPath,
|
||||
"-vf",
|
||||
`vidstabtransform=input=${trf}:smoothing=${settings.smoothing}`,
|
||||
"-c:v",
|
||||
resolveEncoder("h264"),
|
||||
"-crf",
|
||||
"20",
|
||||
"-preset",
|
||||
"medium",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
"copy",
|
||||
...encodeArgs,
|
||||
...audioArgs,
|
||||
outPath,
|
||||
],
|
||||
info.durationS,
|
||||
|
||||
@@ -23,13 +23,19 @@ export function registerVideoToGif(app: FastifyInstance) {
|
||||
const outName = `${base}.gif`;
|
||||
|
||||
const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => {
|
||||
// Place -ss/-t AFTER -i (output-side seeking) so ffmpeg decodes
|
||||
// from the start. Input-side seeking relies on a keyframe index
|
||||
// which FLV (and some other legacy containers) often lack, causing
|
||||
// zero decoded frames and exit 234.
|
||||
return [
|
||||
"-fflags",
|
||||
"+genpts",
|
||||
"-i",
|
||||
inPath,
|
||||
"-ss",
|
||||
String(settings.startS),
|
||||
"-t",
|
||||
String(settings.durationS),
|
||||
"-i",
|
||||
inPath,
|
||||
"-vf",
|
||||
`fps=${settings.fps},scale=${settings.width}:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse`,
|
||||
out,
|
||||
|
||||
Reference in New Issue
Block a user