feat(tools)!: SnapOtter 2.0 phase 4 wave 1: 45 core tools across all modalities (#219)

This commit is contained in:
SnapOtter
2026-06-13 10:18:49 +08:00
parent d647d8ed19
commit ae1337901d
115 changed files with 10026 additions and 924 deletions
+2 -1
View File
@@ -29,6 +29,7 @@ import { eq } from "drizzle-orm";
import { env } from "../config.js";
import { db, schema } from "../db/index.js";
import { resolveConcurrency } from "../lib/env.js";
import { stripInternalPaths } from "../lib/errors.js";
import { jobDuration, jobsTotal } from "../lib/metrics.js";
import { getObjectBuffer, putObject } from "../lib/object-storage.js";
import { publishEphemeral, updateSingleFileProgress } from "../routes/progress.js";
@@ -341,7 +342,7 @@ async function processToolJob(job: Job<ToolJobData>): Promise<ToolJobResult> {
jobId: progressJobId,
phase: "failed",
percent: 0,
error: finalError,
error: stripInternalPaths(finalError),
});
}
}
+69
View File
@@ -0,0 +1,69 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { probeMedia, runFfmpeg } from "@snapotter/media-engine";
import type { ToolProcessCtxV2 } from "../routes/tool-factory.js";
const EXT_VIDEO_CONTENT_TYPES: Record<string, string> = {
".mp4": "video/mp4",
".mov": "video/quicktime",
".webm": "video/webm",
".mkv": "video/x-matroska",
};
/** Content type for a preserved-container video output; mp4 fallback. */
export function videoContentType(ext: string): string {
return EXT_VIDEO_CONTENT_TYPES[ext.toLowerCase()] || "video/mp4";
}
const EXT_AUDIO_CONTENT_TYPES: Record<string, string> = {
".mp3": "audio/mpeg",
".wav": "audio/wav",
".ogg": "audio/ogg",
".flac": "audio/flac",
".m4a": "audio/mp4",
".aac": "audio/aac",
".opus": "audio/opus",
".wma": "audio/x-ms-wma",
".aiff": "audio/aiff",
};
/** Content type for a preserved-container audio output; mpeg fallback. */
export function audioContentType(ext: string): string {
return EXT_AUDIO_CONTENT_TYPES[ext.toLowerCase()] || "audio/mpeg";
}
export interface MediaRunResult {
outPath: string;
durationS: number | null;
}
/**
* Stages the primary input in the scratch dir, probes it, runs ffmpeg with
* progress mapped onto ctx.report (5..95%), and returns the output path for
* a scratchPath result. argsFor receives the staged input/output paths.
*/
export async function runMediaTool(
ctx: ToolProcessCtxV2,
outName: string,
argsFor: (inPath: string, outPath: string, info: { durationS: number | null }) => string[],
opts: { timeoutMs?: number } = {},
): Promise<MediaRunResult> {
const dir = join(ctx.scratchDir, "media");
await mkdir(dir, { recursive: true });
const inPath = join(dir, `in-${ctx.inputs[0].filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, ctx.inputs[0].buffer);
const info = await probeMedia(inPath);
const outPath = join(dir, outName);
ctx.report(5, "Preparing");
await runFfmpeg(argsFor(inPath, outPath, { durationS: info.durationS }), {
signal: ctx.signal,
timeoutMs: opts.timeoutMs ?? 30 * 60_000,
onProgress: (p) => {
if (p.outTimeMs !== null && info.durationS) {
const pct = Math.min(95, 5 + Math.round((p.outTimeMs / (info.durationS * 1000)) * 90));
ctx.report(pct, "Processing");
}
},
});
return { outPath, durationS: info.durationS };
}
+12
View File
@@ -35,6 +35,18 @@ export class MediaInputHandler implements InputHandler {
if (this.kind === "video" && !hasVideo) {
throw new InputValidationError("File contains no video stream");
}
// ffprobe reports still images as single-frame video streams in
// *_pipe/image2 containers with no duration; a video tool must
// reject those (carry-forward from phase-3 input-handler review).
const IMAGE_CONTAINER_RE =
/(^|,)(png_pipe|image2|bmp_pipe|gif_pipe|jpeg_pipe|tiff_pipe|webp_pipe|svg_pipe)($|,)/;
if (
this.kind === "video" &&
IMAGE_CONTAINER_RE.test(info.container) &&
info.durationS === null
) {
throw new InputValidationError("File is a still image, not a video");
}
if (this.kind === "audio" && !hasAudio) {
throw new InputValidationError("File contains no audio stream");
}
+80 -41
View File
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import { mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { extname, join } from "node:path";
import { ANALYTICS_EVENTS, getBundleForTool, TOOL_BUNDLE_MAP, TOOLS } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import type { z } from "zod";
@@ -12,7 +12,7 @@ import { formatZodErrors, stripInternalPaths } from "../lib/errors.js";
import { isToolInstalled } from "../lib/feature-status.js";
import { getObjectBuffer, putObject } from "../lib/object-storage.js";
import { resolveToolPool, shouldSkipSyncWindow } from "../lib/pool.js";
import { receiveUpload } from "../lib/upload-stream.js";
import { type ReceivedUpload, receiveUpload } from "../lib/upload-stream.js";
import { InputValidationError } from "../modality/contract.js";
import { inputHandlerFor } from "../modality/input-handler.js";
import { getAuthUser } from "../plugins/auth.js";
@@ -63,6 +63,12 @@ export type ToolProcessV2 = (ctx: ToolProcessCtxV2) => Promise<ToolProcessResult
export interface ToolRouteConfig<T> {
/** Unique tool identifier, used as the URL path segment. */
toolId: string;
/**
* How many file parts the route accepts (default 1). Inputs beyond the
* first are validated by the same modality handler and appended to
* inputRefs in arrival order.
*/
maxInputs?: number;
/** Zod schema that validates the settings JSON from the request. */
settingsSchema: z.ZodType<T, z.ZodTypeDef, unknown>;
/** The processing function: takes input buffer + validated settings, returns output. */
@@ -79,6 +85,7 @@ export interface ToolRouteConfig<T> {
/** Type-erased config stored in the registry (settings type is widened to avoid variance issues). */
export interface AnyToolRouteConfig {
toolId: string;
maxInputs?: number;
settingsSchema: z.ZodType<unknown, z.ZodTypeDef, unknown>;
process: (
inputBuffer: Buffer,
@@ -171,12 +178,13 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
{ config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (request: FastifyRequest, reply: FastifyReply) => {
const jobId = randomUUID();
const maxInputs = config.maxInputs ?? 1;
let filename = "image";
let settingsRaw: string | null = null;
let fileId: string | null = null;
let clientJobId: string | null = null;
let fileCount = 0;
let inputKey: string | null = null;
const received: ReceivedUpload[] = [];
// Parse multipart parts (file parts stream to object storage)
try {
@@ -185,7 +193,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
for await (const part of parts) {
if (part.type === "file") {
fileCount++;
if (fileCount > 1) {
if (fileCount > maxInputs) {
// Drain remaining parts to avoid hanging the connection
for await (const _ of part.file) {
/* drain */
@@ -196,8 +204,10 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
maxBytes:
env.MAX_UPLOAD_SIZE_MB > 0 ? env.MAX_UPLOAD_SIZE_MB * 1024 * 1024 : undefined,
});
inputKey = upload.key;
filename = upload.filename;
received.push(upload);
if (fileCount === 1) {
filename = upload.filename;
}
} else {
// Field part
if (part.fieldname === "settings") {
@@ -221,21 +231,17 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
});
}
if (fileCount > 1) {
if (fileCount > maxInputs) {
return reply.status(400).send({
error: `This endpoint processes one image at a time. Use /api/v1/tools/${config.toolId}/batch for multiple files.`,
error: `Too many files (max ${maxInputs})`,
});
}
// Require a file
if (!inputKey) {
// Require at least one file
if (received.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
// Read back the uploaded file for validation/decode chain
let fileBuffer = await getObjectBuffer(inputKey);
const originalBuffer = fileBuffer;
const reportProgress = (percent: number, stage?: string) => {
if (!clientJobId) return;
updateSingleFileProgress({
@@ -256,20 +262,66 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
const scratchDir = join(tmpdir(), "snapotter-scratch", jobId);
await mkdir(scratchDir, { recursive: true });
try {
// Modality-specific input validation and normalization
try {
const prepared = await inputHandlerFor(modality).prepare(fileBuffer, filename, {
scratchDir,
});
fileBuffer = prepared.buffer;
filename = prepared.filename;
} catch (err) {
if (err instanceof InputValidationError) {
const body: Record<string, string> = { error: err.message };
if (err.details) body.details = err.details;
return reply.status(err.statusCode).send(body);
// Reject files whose extension is not in the tool's acceptedInputs.
// Image and media modalities validate content via their input handlers
// (sharp decode, ffprobe); document/file modalities need an explicit
// extension gate because their handlers pass unrecognized types through.
const accepted = toolMeta?.acceptedInputs;
if (accepted?.length && (modality === "file" || modality === "document")) {
for (const upload of received) {
const ext = extname(upload.filename).toLowerCase();
if (!accepted.includes(ext)) {
return reply.status(415).send({
error: `Unsupported file type "${ext || "(none)"}" for this tool`,
});
}
}
}
// Prepare all files through the modality input handler
const inputRefs: string[] = [];
for (let i = 0; i < received.length; i++) {
const upload = received[i];
let fileBuffer = await getObjectBuffer(upload.key);
const originalBuffer = fileBuffer;
let fname = upload.filename;
try {
const prepared = await inputHandlerFor(modality).prepare(fileBuffer, fname, {
scratchDir,
});
fileBuffer = prepared.buffer;
fname = prepared.filename;
} catch (err) {
if (err instanceof InputValidationError) {
const errorMsg = maxInputs > 1 ? `${fname}: ${err.message}` : err.message;
const body: Record<string, string> = { error: errorMsg };
if (err.details) body.details = err.details;
// Orphaned uploads/<jobId>/ dir will be cleaned by T10 TTL sweeper
return reply.status(err.statusCode).send(body);
}
throw err;
}
// If decode/orient transformed the buffer or changed the filename,
// write the final version so the worker processes the correct data.
// Skip re-upload when the buffer is reference-identical to the
// originally streamed bytes and the filename hasn't changed.
const decodedKey = `uploads/${jobId}/${fname}`;
if (decodedKey !== upload.key) {
await putObject(decodedKey, fileBuffer);
inputRefs.push(decodedKey);
} else if (fileBuffer !== originalBuffer) {
await putObject(upload.key, fileBuffer);
inputRefs.push(upload.key);
} else {
inputRefs.push(upload.key);
}
// Primary file keeps the existing variable roles
if (i === 0) {
filename = fname;
}
throw err;
}
reportProgress(15, "Preparing...");
@@ -310,19 +362,6 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
});
}
// If decode/orient transformed the buffer or changed the filename,
// write the final version so the worker processes the correct data.
// Skip re-upload when the buffer is reference-identical to the
// originally streamed bytes and the filename hasn't changed.
const decodedName = filename;
const decodedKey = `uploads/${jobId}/${decodedName}`;
if (decodedKey !== inputKey) {
await putObject(decodedKey, fileBuffer);
inputKey = decodedKey;
} else if (fileBuffer !== originalBuffer) {
await putObject(inputKey, fileBuffer);
}
const startTime = Date.now();
const pool = resolveToolPool(config.toolId);
@@ -332,7 +371,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
toolId: config.toolId,
userId: getAuthUser(request)?.id ?? null,
pool,
inputRefs: [inputKey],
inputRefs,
filename,
settings,
fileId: fileId ?? undefined,
+38
View File
@@ -0,0 +1,38 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { gsCompressPdf } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
preset: z.enum(["screen", "ebook", "printer"]).default("ebook"),
});
export function registerCompressPdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "compress-pdf",
settingsSchema,
process: async () => {
throw new Error("compress-pdf is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_compressed.pdf`);
ctx.report(10, "Compressing");
await gsCompressPdf(inPath, outPath, settings.preset);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_compressed.pdf`,
contentType: "application/pdf",
};
},
});
}
@@ -0,0 +1,57 @@
import { resolveEncoder } from "@snapotter/media-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { runMediaTool } from "../../lib/media-tool.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
quality: z.enum(["light", "balanced", "strong"]).default("balanced"),
resolution: z.enum(["original", "1080p", "720p", "480p"]).default("original"),
});
const CRF: Record<string, string> = {
light: "23",
balanced: "28",
strong: "33",
};
const SCALE: Record<string, string> = {
"1080p": "scale=-2:1080",
"720p": "scale=-2:720",
"480p": "scale=-2:480",
};
export function registerCompressVideo(app: FastifyInstance) {
createToolRoute(app, {
toolId: "compress-video",
settingsSchema,
process: async () => {
throw new Error("compress-video is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
const outName = `${base}_compressed.mp4`;
const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => {
const args = [
"-i",
inPath,
"-c:v",
resolveEncoder("h264"),
"-crf",
CRF[settings.quality],
"-preset",
"medium",
"-pix_fmt",
"yuv420p",
];
if (settings.resolution !== "original") {
args.push("-vf", SCALE[settings.resolution]);
}
args.push("-c:a", resolveEncoder("aac"), "-b:a", "96k", "-movflags", "+faststart", out);
return args;
});
return { scratchPath: outPath, filename: outName, contentType: "video/mp4" };
},
});
}
@@ -0,0 +1,81 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { runMediaTool } from "../../lib/media-tool.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
format: z.enum(["mp3", "wav", "ogg", "flac", "m4a"]).default("mp3"),
bitrateKbps: z.number().int().min(32).max(320).default(192),
});
const CONTENT_TYPES: Record<string, string> = {
mp3: "audio/mpeg",
wav: "audio/wav",
ogg: "audio/ogg",
flac: "audio/flac",
m4a: "audio/mp4",
};
export function registerConvertAudio(app: FastifyInstance) {
createToolRoute(app, {
toolId: "convert-audio",
settingsSchema,
process: async () => {
throw new Error("convert-audio is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
const outName = `${base}.${settings.format}`;
const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => {
switch (settings.format) {
case "mp3":
return [
"-i",
inPath,
"-vn",
"-c:a",
"libmp3lame",
"-b:a",
`${settings.bitrateKbps}k`,
out,
];
case "wav":
return ["-i", inPath, "-vn", "-c:a", "pcm_s16le", out];
case "ogg":
return [
"-i",
inPath,
"-vn",
"-c:a",
"libvorbis",
"-b:a",
`${settings.bitrateKbps}k`,
out,
];
case "flac":
return ["-i", inPath, "-vn", "-c:a", "flac", out];
case "m4a":
return ["-i", inPath, "-vn", "-c:a", "aac", "-b:a", `${settings.bitrateKbps}k`, out];
default:
return [
"-i",
inPath,
"-vn",
"-c:a",
"libmp3lame",
"-b:a",
`${settings.bitrateKbps}k`,
out,
];
}
});
return {
scratchPath: outPath,
filename: outName,
contentType: CONTENT_TYPES[settings.format],
};
},
});
}
@@ -0,0 +1,78 @@
import { resolveEncoder } from "@snapotter/media-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { runMediaTool } from "../../lib/media-tool.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
format: z.enum(["mp4", "mov", "webm"]).default("mp4"),
quality: z.enum(["high", "balanced", "small"]).default("balanced"),
});
const CRF: Record<string, { h264: string; vp9: string }> = {
high: { h264: "18", vp9: "24" },
balanced: { h264: "23", vp9: "32" },
small: { h264: "28", vp9: "40" },
};
const CONTENT_TYPES: Record<string, string> = {
mp4: "video/mp4",
mov: "video/quicktime",
webm: "video/webm",
};
export function registerConvertVideo(app: FastifyInstance) {
createToolRoute(app, {
toolId: "convert-video",
settingsSchema,
process: async () => {
throw new Error("convert-video is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
const outName = `${base}.${settings.format}`;
const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => {
if (settings.format === "webm") {
return [
"-i",
inPath,
"-c:v",
resolveEncoder("vp9"),
"-crf",
CRF[settings.quality].vp9,
"-b:v",
"0",
"-c:a",
resolveEncoder("opus"),
out,
];
}
return [
"-i",
inPath,
"-c:v",
resolveEncoder("h264"),
"-crf",
CRF[settings.quality].h264,
"-preset",
"medium",
"-pix_fmt",
"yuv420p",
"-c:a",
resolveEncoder("aac"),
"-b:a",
"128k",
"-movflags",
"+faststart",
out,
];
});
return {
scratchPath: outPath,
filename: outName,
contentType: CONTENT_TYPES[settings.format],
};
},
});
}
+71
View File
@@ -0,0 +1,71 @@
import type { FastifyInstance } from "fastify";
import Papa from "papaparse";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
sheet: z.number().int().min(1).default(1),
});
export function registerCsvExcel(app: FastifyInstance) {
createToolRoute(app, {
toolId: "csv-excel",
settingsSchema,
process: async () => {
throw new Error("csv-excel is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const input = ctx.inputs[0];
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");
if (lower.endsWith(".xlsx")) {
// xlsx -> csv: load workbook, pick the Nth worksheet, extract rows
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(input.buffer as unknown as ArrayBuffer);
const ws = workbook.worksheets[settings.sheet - 1];
if (!ws) {
throw new Error(
`Worksheet ${settings.sheet} not found (workbook has ${workbook.worksheets.length} sheets)`,
);
}
const rows: string[][] = [];
ws.eachRow((row) => {
const cells: string[] = [];
row.eachCell({ includeEmpty: true }, (cell) => {
cells.push(cell.text);
});
rows.push(cells);
});
const csv = Papa.unparse(rows);
return {
buffer: Buffer.from(csv, "utf8"),
filename: `${base}.csv`,
contentType: "text/csv",
};
}
// csv -> xlsx: parse CSV rows, build a workbook
const parsed = Papa.parse<string[]>(input.buffer.toString("utf8"), {
header: false,
skipEmptyLines: true,
});
if (parsed.errors.length > 0) {
throw new Error(`CSV parse failed: ${parsed.errors[0].message}`);
}
const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet("Sheet1");
ws.addRows(parsed.data);
const xlsxBuffer = Buffer.from(await workbook.xlsx.writeBuffer());
return {
buffer: xlsxBuffer,
filename: `${base}.xlsx`,
contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
};
},
});
}
+51
View File
@@ -0,0 +1,51 @@
import type { FastifyInstance } from "fastify";
import Papa from "papaparse";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
pretty: z.boolean().default(true),
});
export function registerCsvJson(app: FastifyInstance) {
createToolRoute(app, {
toolId: "csv-json",
settingsSchema,
process: async () => {
throw new Error("csv-json is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const lower = input.filename.toLowerCase();
if (lower.endsWith(".json")) {
const data: unknown = JSON.parse(input.buffer.toString("utf8"));
if (!Array.isArray(data)) {
throw new Error("JSON input must be an array of objects to convert to CSV");
}
const csv = Papa.unparse(data as Record<string, unknown>[]);
return {
buffer: Buffer.from(csv, "utf8"),
filename: `${base}.csv`,
contentType: "text/csv",
};
}
const parsed = Papa.parse<Record<string, unknown>>(input.buffer.toString("utf8"), {
header: true,
skipEmptyLines: true,
});
if (parsed.errors.length > 0) {
throw new Error(`CSV parse failed: ${parsed.errors[0].message}`);
}
const json = JSON.stringify(parsed.data, null, settings.pretty ? 2 : 0);
return {
buffer: Buffer.from(json, "utf8"),
filename: `${base}.json`,
contentType: "application/json",
};
},
});
}
@@ -0,0 +1,47 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { runMediaTool } from "../../lib/media-tool.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
format: z.enum(["mp3", "wav", "m4a"]).default("mp3"),
});
const CONTENT_TYPES: Record<string, string> = {
mp3: "audio/mpeg",
wav: "audio/wav",
m4a: "audio/mp4",
};
export function registerExtractAudio(app: FastifyInstance) {
createToolRoute(app, {
toolId: "extract-audio",
settingsSchema,
process: async () => {
throw new Error("extract-audio is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
const outName = `${base}.${settings.format}`;
const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => {
switch (settings.format) {
case "mp3":
return ["-i", inPath, "-vn", "-c:a", "libmp3lame", "-b:a", "192k", out];
case "wav":
return ["-i", inPath, "-vn", "-c:a", "pcm_s16le", out];
case "m4a":
return ["-i", inPath, "-vn", "-c:a", "aac", "-b:a", "192k", out];
default:
return ["-i", inPath, "-vn", "-c:a", "libmp3lame", "-b:a", "192k", out];
}
});
return {
scratchPath: outPath,
filename: outName,
contentType: CONTENT_TYPES[settings.format],
};
},
});
}
+42
View File
@@ -16,12 +16,19 @@ import { registerColorize } from "./colorize.js";
import { registerCompare } from "./compare.js";
import { registerCompose } from "./compose.js";
import { registerCompress } from "./compress.js";
import { registerCompressPdf } from "./compress-pdf.js";
import { registerCompressVideo } from "./compress-video.js";
import { registerContentAwareResize } from "./content-aware-resize.js";
import { registerConvert } from "./convert.js";
import { registerConvertAudio } from "./convert-audio.js";
import { registerConvertVideo } from "./convert-video.js";
import { registerCrop } from "./crop.js";
import { registerCsvExcel } from "./csv-excel.js";
import { registerCsvJson } from "./csv-json.js";
import { registerEditMetadata } from "./edit-metadata.js";
import { registerEnhanceFaces } from "./enhance-faces.js";
import { registerEraseObject } from "./erase-object.js";
import { registerExtractAudio } from "./extract-audio.js";
import { registerFavicon } from "./favicon.js";
import { registerFindDuplicates } from "./find-duplicates.js";
import { registerGifTools } from "./gif-tools.js";
@@ -30,7 +37,10 @@ import { registerImageEnhancement } from "./image-enhancement.js";
import { registerImageToBase64 } from "./image-to-base64.js";
import { registerImageToPdf } from "./image-to-pdf.js";
import { registerInfo } from "./info.js";
import { registerJsonXml } from "./json-xml.js";
import { registerMemeGenerator } from "./meme-generator.js";
import { registerMergePdf } from "./merge-pdf.js";
import { registerMuteVideo } from "./mute-video.js";
import { registerNoiseRemoval } from "./noise-removal.js";
import { registerOcr } from "./ocr.js";
import { registerOptimizeForWeb } from "./optimize-for-web.js";
@@ -43,18 +53,25 @@ import { registerReplaceColor } from "./replace-color.js";
import { registerResize } from "./resize.js";
import { registerRestorePhoto } from "./restore-photo.js";
import { registerRotate } from "./rotate.js";
import { registerRotatePdf } from "./rotate-pdf.js";
import { registerSharpening } from "./sharpening.js";
import { registerSmartCrop } from "./smart-crop.js";
import { registerSplit } from "./split.js";
import { registerSplitCsv } from "./split-csv.js";
import { registerSplitPdf } from "./split-pdf.js";
import { registerStitch } from "./stitch.js";
import { registerStripMetadata } from "./strip-metadata.js";
import { registerSvgToRaster } from "./svg-to-raster.js";
import { registerTextOverlay } from "./text-overlay.js";
import { registerTransparencyFixer } from "./transparency-fixer.js";
import { registerTrimAudio } from "./trim-audio.js";
import { registerTrimVideo } from "./trim-video.js";
import { registerUpscale } from "./upscale.js";
import { registerVectorize } from "./vectorize.js";
import { registerVideoToGif } from "./video-to-gif.js";
import { registerWatermarkImage } from "./watermark-image.js";
import { registerWatermarkText } from "./watermark-text.js";
import { registerWordToPdf } from "./word-to-pdf.js";
/**
* Registry that imports and registers all tool routes.
@@ -139,6 +156,31 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
{ id: "replace-color", register: registerReplaceColor },
{ id: "color-blindness", register: registerColorBlindness },
// Video
{ id: "convert-video", register: registerConvertVideo },
{ id: "compress-video", register: registerCompressVideo },
{ id: "trim-video", register: registerTrimVideo },
{ id: "mute-video", register: registerMuteVideo },
{ id: "video-to-gif", register: registerVideoToGif },
// Audio
{ id: "convert-audio", register: registerConvertAudio },
{ id: "trim-audio", register: registerTrimAudio },
{ id: "extract-audio", register: registerExtractAudio },
// PDF & Documents
{ id: "merge-pdf", register: registerMergePdf },
{ id: "split-pdf", register: registerSplitPdf },
{ id: "compress-pdf", register: registerCompressPdf },
{ id: "rotate-pdf", register: registerRotatePdf },
{ id: "word-to-pdf", register: registerWordToPdf },
// Data Files
{ id: "csv-excel", register: registerCsvExcel },
{ id: "csv-json", register: registerCsvJson },
{ id: "json-xml", register: registerJsonXml },
{ id: "split-csv", register: registerSplitCsv },
// AI Tools
{ id: "remove-background", register: registerRemoveBackground },
{ id: "upscale", register: registerUpscale },
+54
View File
@@ -0,0 +1,54 @@
import { XMLBuilder, XMLParser } from "fast-xml-parser";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
pretty: z.boolean().default(true),
});
export function registerJsonXml(app: FastifyInstance) {
createToolRoute(app, {
toolId: "json-xml",
settingsSchema,
process: async () => {
throw new Error("json-xml is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const lower = input.filename.toLowerCase();
const text = input.buffer.toString("utf8");
if (lower.endsWith(".xml")) {
// xml -> json
const parser = new XMLParser({ ignoreAttributes: false });
const parsed = parser.parse(text);
const json = JSON.stringify(parsed, null, settings.pretty ? 2 : 0);
return {
buffer: Buffer.from(json, "utf8"),
filename: `${base}.json`,
contentType: "application/json",
};
}
// json -> xml
const data: unknown = JSON.parse(text);
// Wrap in a root element when the top level is an array or has
// multiple keys, so the XML is well-formed with a single root.
const wrapped =
Array.isArray(data) ||
(typeof data === "object" && data !== null && Object.keys(data).length !== 1)
? { root: data }
: data;
const builder = new XMLBuilder({ format: settings.pretty, ignoreAttributes: false });
const xml = builder.build(wrapped) as string;
return {
buffer: Buffer.from(xml, "utf8"),
filename: `${base}.xml`,
contentType: "application/xml",
};
},
});
}
+35
View File
@@ -0,0 +1,35 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { qpdfMerge } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerMergePdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "merge-pdf",
maxInputs: 20,
settingsSchema,
process: async () => {
throw new Error("merge-pdf is v2-only");
},
processV2: async (ctx) => {
if (ctx.inputs.length < 2) {
throw new Error("Merging needs at least two PDFs");
}
ctx.report(10, "Staging");
const paths: string[] = [];
for (let i = 0; i < ctx.inputs.length; i++) {
const p = join(ctx.scratchDir, `in-${i}.pdf`);
await writeFile(p, ctx.inputs[i].buffer);
paths.push(p);
}
const outPath = join(ctx.scratchDir, "merged.pdf");
ctx.report(40, "Merging");
await qpdfMerge(paths, outPath);
return { scratchPath: outPath, filename: "merged.pdf", contentType: "application/pdf" };
},
});
}
+28
View File
@@ -0,0 +1,28 @@
import { extname } from "node:path";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { runMediaTool, videoContentType } from "../../lib/media-tool.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerMuteVideo(app: FastifyInstance) {
createToolRoute(app, {
toolId: "mute-video",
settingsSchema,
process: async () => {
throw new Error("mute-video is v2-only");
},
processV2: async (ctx) => {
const origExt = extname(ctx.inputs[0].filename) || ".mp4";
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
const outName = `${base}_muted${origExt}`;
const contentType = videoContentType(origExt);
const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => {
return ["-i", inPath, "-c", "copy", "-an", out];
});
return { scratchPath: outPath, filename: outName, contentType };
},
});
}
+43
View File
@@ -0,0 +1,43 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { qpdfRotate } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
angle: z.union([z.literal(90), z.literal(180), z.literal(270)]).default(90),
range: z
.string()
.max(200)
.regex(/^[0-9rz][0-9rz,-]*$/i, "Invalid page range")
.default("1-z"),
});
export function registerRotatePdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "rotate-pdf",
settingsSchema,
process: async () => {
throw new Error("rotate-pdf is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_rotated.pdf`);
ctx.report(10, "Rotating");
await qpdfRotate(inPath, settings.angle, settings.range, outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_rotated.pdf`,
contentType: "application/pdf",
};
},
});
}
+82
View File
@@ -0,0 +1,82 @@
import { createWriteStream } from "node:fs";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import Papa from "papaparse";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
rowsPerFile: z.number().int().min(1).max(1_000_000).default(1000),
keepHeader: z.boolean().default(true),
});
export function registerSplitCsv(app: FastifyInstance) {
createToolRoute(app, {
toolId: "split-csv",
settingsSchema,
process: async () => {
throw new Error("split-csv is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const parsed = Papa.parse<string[]>(input.buffer.toString("utf8"), {
header: false,
skipEmptyLines: true,
});
if (parsed.errors.length > 0) {
throw new Error(`CSV parse failed: ${parsed.errors[0].message}`);
}
const allRows = parsed.data;
if (allRows.length === 0) {
throw new Error("CSV file is empty");
}
const header = settings.keepHeader ? allRows[0] : null;
const dataRows = settings.keepHeader ? allRows.slice(1) : allRows;
// Chunk data rows
const chunks: string[][][] = [];
for (let i = 0; i < dataRows.length; i += settings.rowsPerFile) {
chunks.push(dataRows.slice(i, i + settings.rowsPerFile));
}
// Write part files to scratch
const partPaths: string[] = [];
for (let i = 0; i < chunks.length; i++) {
const rows = header ? [header, ...chunks[i]] : chunks[i];
const csv = Papa.unparse(rows);
const partPath = join(ctx.scratchDir, `part-${i + 1}.csv`);
await writeFile(partPath, csv, "utf8");
partPaths.push(partPath);
const pct = Math.min(80, 10 + Math.round(((i + 1) / chunks.length) * 70));
ctx.report(pct, `Writing part ${i + 1} of ${chunks.length}`);
}
// Zip the parts (mirrors split-pdf archiver pattern)
ctx.report(85, "Creating archive");
const zipPath = join(ctx.scratchDir, `${base}_parts.zip`);
await new Promise<void>((resolve, reject) => {
const output = createWriteStream(zipPath);
const archive = archiver("zip", { zlib: { level: 5 } });
output.on("close", () => resolve());
archive.on("error", (err: Error) => reject(err));
archive.pipe(output);
for (let i = 0; i < partPaths.length; i++) {
archive.file(partPaths[i], { name: `part-${i + 1}.csv` });
}
void archive.finalize();
});
return {
scratchPath: zipPath,
filename: `${base}_parts.zip`,
contentType: "application/zip",
};
},
});
}
+89
View File
@@ -0,0 +1,89 @@
import { createWriteStream } from "node:fs";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { qpdfPageCount, qpdfSplitRanges } from "@snapotter/doc-engine";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z
.object({
mode: z.enum(["range", "every"]).default("range"),
range: z
.string()
.max(200)
.regex(/^[0-9rz][0-9rz,-]*$/i, "Invalid page range")
.optional(),
everyN: z.number().int().min(1).max(500).optional(),
})
.refine(
(s) => {
if (s.mode === "range") return !!s.range;
return s.everyN !== undefined;
},
{ message: "range required for range mode; everyN required for every mode" },
);
export function registerSplitPdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "split-pdf",
settingsSchema,
process: async () => {
throw new Error("split-pdf is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
if (settings.mode === "range") {
const outPath = join(ctx.scratchDir, `${base}_pages.pdf`);
ctx.report(20, "Extracting pages");
await qpdfSplitRanges(inPath, settings.range ?? "", outPath);
return {
scratchPath: outPath,
filename: `${base}_pages.pdf`,
contentType: "application/pdf",
};
}
// mode === "every": split into chunks of everyN pages
const totalPages = await qpdfPageCount(inPath);
const n = settings.everyN ?? 1;
const parts: string[] = [];
for (let start = 1; start <= totalPages; start += n) {
const end = Math.min(start + n - 1, totalPages);
const partPath = join(ctx.scratchDir, `part-${parts.length + 1}.pdf`);
const range = `${start}-${end}`;
await qpdfSplitRanges(inPath, range, partPath);
parts.push(partPath);
const pct = Math.min(80, 10 + Math.round((start / totalPages) * 70));
ctx.report(pct, `Splitting part ${parts.length}`);
}
// Zip the parts
ctx.report(85, "Creating archive");
const zipPath = join(ctx.scratchDir, `${base}_parts.zip`);
await new Promise<void>((resolve, reject) => {
const output = createWriteStream(zipPath);
const archive = archiver("zip", { zlib: { level: 5 } });
output.on("close", () => resolve());
archive.on("error", (err: Error) => reject(err));
archive.pipe(output);
for (let i = 0; i < parts.length; i++) {
archive.file(parts[i], { name: `part-${i + 1}.pdf` });
}
void archive.finalize();
});
return {
scratchPath: zipPath,
filename: `${base}_parts.zip`,
contentType: "application/zip",
};
},
});
}
+45
View File
@@ -0,0 +1,45 @@
import { extname } from "node:path";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { audioContentType, runMediaTool } from "../../lib/media-tool.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z
.object({
startS: z.number().min(0).default(0),
endS: z.number().positive(),
})
.refine((s) => s.endS > s.startS, { message: "End must be after start" });
export function registerTrimAudio(app: FastifyInstance) {
createToolRoute(app, {
toolId: "trim-audio",
settingsSchema,
process: async () => {
throw new Error("trim-audio is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const origExt = extname(ctx.inputs[0].filename) || ".mp3";
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
const outName = `${base}_trimmed${origExt}`;
const contentType = audioContentType(origExt);
const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => {
// Fast seek with stream-copy for audio
return [
"-ss",
String(settings.startS),
"-to",
String(settings.endS),
"-i",
inPath,
"-c",
"copy",
out,
];
});
return { scratchPath: outPath, filename: outName, contentType };
},
});
}
+70
View File
@@ -0,0 +1,70 @@
import { extname } from "node:path";
import { resolveEncoder } from "@snapotter/media-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { runMediaTool, videoContentType } from "../../lib/media-tool.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z
.object({
startS: z.number().min(0).default(0),
endS: z.number().positive(),
precise: z.boolean().default(false),
})
.refine((s) => s.endS > s.startS, { message: "End must be after start" });
export function registerTrimVideo(app: FastifyInstance) {
createToolRoute(app, {
toolId: "trim-video",
settingsSchema,
process: async () => {
throw new Error("trim-video is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const origExt = extname(ctx.inputs[0].filename) || ".mp4";
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
const outName = `${base}_trimmed${origExt}`;
const contentType = videoContentType(origExt);
const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => {
if (settings.precise) {
return [
"-i",
inPath,
"-ss",
String(settings.startS),
"-to",
String(settings.endS),
"-c:v",
resolveEncoder("h264"),
"-crf",
"20",
"-preset",
"medium",
"-pix_fmt",
"yuv420p",
"-c:a",
resolveEncoder("aac"),
out,
];
}
// Fast seek: -ss before -i for stream-copy
return [
"-ss",
String(settings.startS),
"-to",
String(settings.endS),
"-i",
inPath,
"-c",
"copy",
"-avoid_negative_ts",
"make_zero",
out,
];
});
return { scratchPath: outPath, filename: outName, contentType };
},
});
}
+41
View File
@@ -0,0 +1,41 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { runMediaTool } from "../../lib/media-tool.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
fps: z.number().int().min(1).max(30).default(12),
width: z.number().int().min(64).max(1280).default(480),
startS: z.number().min(0).default(0),
durationS: z.number().positive().max(60).default(5),
});
export function registerVideoToGif(app: FastifyInstance) {
createToolRoute(app, {
toolId: "video-to-gif",
settingsSchema,
process: async () => {
throw new Error("video-to-gif is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
const outName = `${base}.gif`;
const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => {
return [
"-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,
];
});
return { scratchPath: outPath, filename: outName, contentType: "image/gif" };
},
});
}
+40
View File
@@ -0,0 +1,40 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { convertDocument } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { env } from "../../config.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerWordToPdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "word-to-pdf",
settingsSchema,
process: async () => {
throw new Error("word-to-pdf is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
// Sanitize the basename but keep the real extension so LibreOffice
// can sniff the input format (e.g. .docx vs .odt vs .rtf).
const sanitized = input.filename.replace(/[^A-Za-z0-9._-]/g, "_");
const inPath = join(ctx.scratchDir, `in-${sanitized}`);
await writeFile(inPath, input.buffer);
ctx.report(10, "Converting");
const outPath = await convertDocument(inPath, ctx.scratchDir, "pdf", {
timeoutMs: (env.LIBREOFFICE_TIMEOUT_S || 120) * 1000,
});
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}.pdf`,
contentType: "application/pdf",
};
},
});
}