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:
SnapOtter
2026-06-15 22:26:24 +08:00
committed by GitHub
parent 25babfae15
commit d8cf979d4b
83 changed files with 16014 additions and 112 deletions
+4 -2
View File
@@ -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
+12 -1
View File
@@ -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",
};
}
+52 -12
View File
@@ -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,
+8 -2
View File
@@ -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,