mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(tools)!: SnapOtter 2.0 phase 4 wave 1: 45 core tools across all modalities (#219)
This commit is contained in:
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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],
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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" };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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" };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user