feat(tools): 2.0 phase 5 wave 3a - video depth (22 tools) (#221)

This commit is contained in:
SnapOtter
2026-06-13 10:19:00 +08:00
parent 2f39e38162
commit 5f98b48593
109 changed files with 12086 additions and 838 deletions
+40 -11
View File
@@ -37,6 +37,40 @@ export interface MediaRunResult {
durationS: number | null;
}
/** Stages every ctx input into scratch (in-<i>-<sanitized>) and returns the paths. */
export async function stageMediaInputs(ctx: ToolProcessCtxV2): Promise<string[]> {
const dir = join(ctx.scratchDir, "media");
await mkdir(dir, { recursive: true });
const paths: string[] = [];
for (let i = 0; i < ctx.inputs.length; i++) {
const safe = ctx.inputs[i].filename.replace(/[^A-Za-z0-9._-]/g, "_");
const p = join(dir, `in-${i}-${safe}`);
await writeFile(p, ctx.inputs[i].buffer);
paths.push(p);
}
return paths;
}
/** Progress-mapped runFfmpeg (5..95%) against a known duration; 30 min default timeout. */
export async function runFfmpegWithProgress(
ctx: ToolProcessCtxV2,
args: string[],
durationS: number | null,
opts: { timeoutMs?: number } = {},
): Promise<void> {
ctx.report(5, "Preparing");
await runFfmpeg(args, {
signal: ctx.signal,
timeoutMs: opts.timeoutMs ?? 30 * 60_000,
onProgress: (p) => {
if (p.outTimeMs !== null && durationS) {
const pct = Math.min(95, 5 + Math.round((p.outTimeMs / (durationS * 1000)) * 90));
ctx.report(pct, "Processing");
}
},
});
}
/**
* 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
@@ -54,16 +88,11 @@ export async function runMediaTool(
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");
}
},
});
await runFfmpegWithProgress(
ctx,
argsFor(inPath, outPath, { durationS: info.durationS }),
info.durationS,
opts,
);
return { outPath, durationS: info.durationS };
}
+51 -14
View File
@@ -1,22 +1,34 @@
import { randomUUID } from "node:crypto";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { extname, join } from "node:path";
import { probeMedia } from "@snapotter/media-engine";
import { SUBTITLE_INPUTS } from "@snapotter/shared";
import { env } from "../config.js";
import { type InputHandler, InputValidationError, type PreparedInput } from "./contract.js";
export type MediaInputKind = "video" | "audio" | "image" | "subtitle";
const SUBTITLE_EXT_SET = new Set<string>(SUBTITLE_INPUTS);
const MAX_SUBTITLE_BYTES = 1 * 1024 * 1024; // 1 MiB
/**
* Video/audio validation via capped ffprobe (spec 4.7). ffprobe needs a real
* file (mp4 moov atoms may trail), so the buffer lands in the scratch dir.
* Video/audio/image/subtitle validation via capped ffprobe (spec 4.7).
* ffprobe needs a real file (mp4 moov atoms may trail), so the buffer
* lands in the scratch dir for video/audio/image kinds. Subtitle kind
* skips ffprobe entirely (text-based, extension + content checks only).
*/
export class MediaInputHandler implements InputHandler {
constructor(private kind: "video" | "audio") {}
constructor(private kind: MediaInputKind) {}
async prepare(
raw: Buffer,
filename: string,
opts: { scratchDir: string },
): Promise<PreparedInput> {
if (this.kind === "subtitle") {
return this.prepareSubtitle(raw, filename);
}
const probeDir = join(opts.scratchDir, `probe-${randomUUID()}`);
await mkdir(probeDir, { recursive: true });
const probePath = join(probeDir, "input");
@@ -32,19 +44,28 @@ export class MediaInputHandler implements InputHandler {
}
const hasVideo = info.streams.some((s) => s.type === "video");
const hasAudio = info.streams.some((s) => s.type === "audio");
// ffprobe reports still images as single-frame video streams in
// *_pipe/image2 containers with no duration.
const IMAGE_CONTAINER_RE =
/(^|,)(png_pipe|image2|bmp_pipe|gif_pipe|jpeg_pipe|tiff_pipe|webp_pipe|svg_pipe)($|,)/;
const isStillImage = IMAGE_CONTAINER_RE.test(info.container) && info.durationS === null;
if (this.kind === "image") {
// Image kind: accept still images only; reject audio-only and real videos
if (!hasVideo) {
throw new InputValidationError("File is not a still image");
}
if (!isStillImage) {
throw new InputValidationError("File is not a still image");
}
return { buffer: raw, filename };
}
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
) {
if (this.kind === "video" && isStillImage) {
throw new InputValidationError("File is a still image, not a video");
}
if (this.kind === "audio" && !hasAudio) {
@@ -72,4 +93,20 @@ export class MediaInputHandler implements InputHandler {
await rm(probeDir, { recursive: true, force: true }).catch(() => {});
}
}
private prepareSubtitle(raw: Buffer, filename: string): PreparedInput {
const ext = extname(filename).toLowerCase();
if (!SUBTITLE_EXT_SET.has(ext)) {
throw new InputValidationError("Not a valid subtitle file (.srt, .vtt, .ass)");
}
if (raw.length > MAX_SUBTITLE_BYTES) {
throw new InputValidationError("Not a valid subtitle file (.srt, .vtt, .ass)");
}
const text = new TextDecoder("utf-8", { fatal: false }).decode(raw);
const looksLikeSubtitle = /-->/.test(text) || /\[Script Info\]/i.test(text);
if (!looksLikeSubtitle) {
throw new InputValidationError("Not a valid subtitle file (.srt, .vtt, .ass)");
}
return { buffer: raw, filename };
}
}
+26 -1
View File
@@ -15,6 +15,7 @@ import { resolveToolPool, shouldSkipSyncWindow } from "../lib/pool.js";
import { type ReceivedUpload, receiveUpload } from "../lib/upload-stream.js";
import { InputValidationError } from "../modality/contract.js";
import { inputHandlerFor } from "../modality/input-handler.js";
import { MediaInputHandler, type MediaInputKind } from "../modality/media-input.js";
import { getAuthUser } from "../plugins/auth.js";
import { updateSingleFileProgress } from "./progress.js";
@@ -69,6 +70,12 @@ export interface ToolRouteConfig<T> {
* inputRefs in arrival order.
*/
maxInputs?: number;
/**
* Per-position input kind overrides for mixed-input tools (e.g. video +
* subtitle). Input i validates with kind inputKinds[Math.min(i, len-1)].
* When absent, the tool's modality drives a single handler as before.
*/
inputKinds?: ("video" | "audio" | "image" | "subtitle")[];
/** 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. */
@@ -100,6 +107,7 @@ export interface ToolRouteConfig<T> {
export interface AnyToolRouteConfig {
toolId: string;
maxInputs?: number;
inputKinds?: ("video" | "audio" | "image" | "subtitle")[];
settingsSchema: z.ZodType<unknown, z.ZodTypeDef, unknown>;
process: (
inputBuffer: Buffer,
@@ -294,6 +302,23 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
}
}
// Build per-position input handlers: when inputKinds is present,
// each position gets a MediaInputHandler for its kind; otherwise
// the tool's modality drives a single shared handler as before.
const kindHandlers: Map<MediaInputKind, MediaInputHandler> = new Map();
function handlerForPosition(idx: number) {
if (config.inputKinds) {
const kind = config.inputKinds[Math.min(idx, config.inputKinds.length - 1)];
let h = kindHandlers.get(kind);
if (!h) {
h = new MediaInputHandler(kind);
kindHandlers.set(kind, h);
}
return h;
}
return inputHandlerFor(modality);
}
// Prepare all files through the modality input handler
const inputRefs: string[] = [];
for (let i = 0; i < received.length; i++) {
@@ -303,7 +328,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
let fname = upload.filename;
try {
const prepared = await inputHandlerFor(modality).prepare(fileBuffer, fname, {
const prepared = await handlerForPosition(i).prepare(fileBuffer, fname, {
scratchDir,
lenient: config.skipStructuralValidation,
});
+97
View File
@@ -0,0 +1,97 @@
import { extname, join } from "node:path";
import { probeMedia, resolveEncoder } from "@snapotter/media-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js";
import { createToolRoute } from "../tool-factory.js";
const TARGETS = {
"16:9": [16, 9],
"9:16": [9, 16],
"1:1": [1, 1],
"4:3": [4, 3],
"3:4": [3, 4],
} as const;
type TargetKey = keyof typeof TARGETS;
/** Output canvas for a source WxH fitted into ratio rw:rh (even dims). */
export function canvasFor(
w: number,
h: number,
rw: number,
rh: number,
): { cw: number; ch: number } {
let cw: number;
let ch: number;
if (w * rh >= h * rw) {
cw = w;
ch = Math.round((w * rh) / rw);
} else {
ch = h;
cw = Math.round((h * rw) / rh);
}
cw += cw % 2;
ch += ch % 2;
return { cw, ch };
}
const settingsSchema = z.object({
target: z.enum(["16:9", "9:16", "1:1", "4:3", "3:4"]).default("9:16"),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.default("#000000"),
});
export function registerAspectPad(app: FastifyInstance) {
createToolRoute(app, {
toolId: "aspect-pad",
settingsSchema,
process: async () => {
throw new Error("aspect-pad 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}_padded${origExt}`;
const contentType = videoContentType(origExt);
const [inPath] = await stageMediaInputs(ctx);
const info = await probeMedia(inPath);
const v = info.streams.find((s) => s.type === "video");
const srcW = v?.width ?? 0;
const srcH = v?.height ?? 0;
const [rw, rh] = TARGETS[settings.target as TargetKey];
const { cw, ch } = canvasFor(srcW, srcH, rw, rh);
const c = settings.color.replace("#", "0x");
const outPath = join(ctx.scratchDir, "media", outName);
await runFfmpegWithProgress(
ctx,
[
"-i",
inPath,
"-vf",
`pad=${cw}:${ch}:(ow-iw)/2:(oh-ih)/2:color=${c}`,
"-c:v",
resolveEncoder("h264"),
"-crf",
"20",
"-preset",
"medium",
"-pix_fmt",
"yuv420p",
"-c:a",
"copy",
outPath,
],
info.durationS,
);
return { scratchPath: outPath, filename: outName, contentType };
},
});
}
+98
View File
@@ -0,0 +1,98 @@
import { extname, join } from "node:path";
import { probeMedia, resolveEncoder } from "@snapotter/media-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js";
import { createToolRoute } from "../tool-factory.js";
const TARGETS = {
"16:9": [16, 9],
"9:16": [9, 16],
"1:1": [1, 1],
"4:3": [4, 3],
"3:4": [3, 4],
} as const;
type TargetKey = keyof typeof TARGETS;
/** Output canvas for a source WxH fitted into ratio rw:rh (even dims). */
export function canvasFor(
w: number,
h: number,
rw: number,
rh: number,
): { cw: number; ch: number } {
let cw: number;
let ch: number;
if (w * rh >= h * rw) {
cw = w;
ch = Math.round((w * rh) / rw);
} else {
ch = h;
cw = Math.round((h * rw) / rh);
}
cw += cw % 2;
ch += ch % 2;
return { cw, ch };
}
const settingsSchema = z.object({
target: z.enum(["16:9", "9:16", "1:1", "4:3", "3:4"]).default("16:9"),
blur: z.number().min(2).max(50).default(20),
});
export function registerBlurPad(app: FastifyInstance) {
createToolRoute(app, {
toolId: "blur-pad",
settingsSchema,
process: async () => {
throw new Error("blur-pad 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}_blurpad${origExt}`;
const contentType = videoContentType(origExt);
const [inPath] = await stageMediaInputs(ctx);
const info = await probeMedia(inPath);
const v = info.streams.find((s) => s.type === "video");
const srcW = v?.width ?? 0;
const srcH = v?.height ?? 0;
const [rw, rh] = TARGETS[settings.target as TargetKey];
const { cw, ch } = canvasFor(srcW, srcH, rw, rh);
const filter = `[0:v]split[bg][fg];[bg]scale=${cw}:${ch}:force_original_aspect_ratio=increase,crop=${cw}:${ch},gblur=sigma=${settings.blur}[b];[b][fg]overlay=(W-w)/2:(H-h)/2[v]`;
const outPath = join(ctx.scratchDir, "media", outName);
await runFfmpegWithProgress(
ctx,
[
"-i",
inPath,
"-filter_complex",
filter,
"-map",
"[v]",
"-map",
"0:a?",
"-c:v",
resolveEncoder("h264"),
"-crf",
"20",
"-preset",
"medium",
"-pix_fmt",
"yuv420p",
"-c:a",
"copy",
outPath,
],
info.durationS,
);
return { scratchPath: outPath, filename: outName, contentType };
},
});
}
@@ -0,0 +1,73 @@
import { dirname, join } from "node:path";
import { probeMedia, resolveEncoder, resolveFontFile } from "@snapotter/media-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { runFfmpegWithProgress, stageMediaInputs } from "../../lib/media-tool.js";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
fontSize: z.number().int().min(8).max(72).default(24),
});
export function registerBurnSubtitles(app: FastifyInstance) {
createToolRoute(app, {
toolId: "burn-subtitles",
maxInputs: 2,
inputKinds: ["video", "subtitle"],
settingsSchema,
process: async () => {
throw new Error("burn-subtitles is v2-only");
},
processV2: async (ctx) => {
if (ctx.inputs.length !== 2) {
throw new InputValidationError("Provide a video file and a subtitle file");
}
const settings = settingsSchema.parse(ctx.settings);
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
const outName = `${base}_subtitled.mp4`;
const font = resolveFontFile();
if (!font) {
throw new Error("No usable font found for subtitles (set SNAPOTTER_FONT_FILE)");
}
const paths = await stageMediaInputs(ctx);
const videoPath = paths[0];
const subPath = paths[1];
const info = await probeMedia(videoPath);
const vf = `subtitles=${subPath}:fontsdir=${dirname(font.file)}:force_style='FontName=${font.family},FontSize=${settings.fontSize}'`;
const outPath = join(ctx.scratchDir, "media", outName);
await runFfmpegWithProgress(
ctx,
[
"-i",
videoPath,
"-vf",
vf,
"-c:v",
resolveEncoder("h264"),
"-crf",
"20",
"-preset",
"medium",
"-pix_fmt",
"yuv420p",
"-c:a",
"copy",
outPath,
],
info.durationS,
);
return {
scratchPath: outPath,
filename: outName,
contentType: "video/mp4",
};
},
});
}
+46
View File
@@ -0,0 +1,46 @@
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({
fps: z.number().min(1).max(120).default(30),
});
export function registerChangeFps(app: FastifyInstance) {
createToolRoute(app, {
toolId: "change-fps",
settingsSchema,
process: async () => {
throw new Error("change-fps 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}_fps${origExt}`;
const contentType = videoContentType(origExt);
const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => [
"-i",
inPath,
"-vf",
`fps=${settings.fps}`,
"-c:v",
resolveEncoder("h264"),
"-crf",
"20",
"-preset",
"medium",
"-pix_fmt",
"yuv420p",
"-c:a",
"copy",
out,
]);
return { scratchPath: outPath, filename: outName, contentType };
},
});
}
+68
View File
@@ -0,0 +1,68 @@
import { extname, join } from "node:path";
import { probeMedia, resolveEncoder } from "@snapotter/media-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
width: z.number().int().min(16),
height: z.number().int().min(16),
x: z.number().int().min(0).default(0),
y: z.number().int().min(0).default(0),
});
export function registerCropVideo(app: FastifyInstance) {
createToolRoute(app, {
toolId: "crop-video",
settingsSchema,
process: async () => {
throw new Error("crop-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}_cropped${origExt}`;
const contentType = videoContentType(origExt);
const [inPath] = await stageMediaInputs(ctx);
const info = await probeMedia(inPath);
const v = info.streams.find((s) => s.type === "video");
const W = v?.width ?? 0;
const H = v?.height ?? 0;
if (settings.x + settings.width > W || settings.y + settings.height > H) {
throw new InputValidationError(
`Crop rectangle ${settings.width}x${settings.height}+${settings.x}+${settings.y} exceeds video size ${W}x${H}`,
);
}
const outPath = join(ctx.scratchDir, "media", outName);
await runFfmpegWithProgress(
ctx,
[
"-i",
inPath,
"-vf",
`crop=${settings.width}:${settings.height}:${settings.x}:${settings.y}`,
"-c:v",
resolveEncoder("h264"),
"-crf",
"20",
"-preset",
"medium",
"-pix_fmt",
"yuv420p",
"-c:a",
"copy",
outPath,
],
info.durationS,
);
return { scratchPath: outPath, filename: outName, contentType };
},
});
}
@@ -0,0 +1,76 @@
import { basename, extname, join } from "node:path";
import { probeMedia } from "@snapotter/media-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
language: z
.string()
.regex(/^[a-z]{3}$/)
.default("eng"),
});
export function registerEmbedSubtitles(app: FastifyInstance) {
createToolRoute(app, {
toolId: "embed-subtitles",
maxInputs: 2,
inputKinds: ["video", "subtitle"],
settingsSchema,
process: async () => {
throw new Error("embed-subtitles is v2-only");
},
processV2: async (ctx) => {
if (ctx.inputs.length !== 2) {
throw new InputValidationError("Provide a video file and a subtitle file");
}
const settings = settingsSchema.parse(ctx.settings);
const base = basename(ctx.inputs[0].filename, extname(ctx.inputs[0].filename));
const srcExt = extname(ctx.inputs[0].filename).toLowerCase();
const toMp4 = [".mp4", ".mov", ".m4v"].includes(srcExt);
const outExt = toMp4 ? ".mp4" : ".mkv";
const scodec = toMp4 ? "mov_text" : "srt";
const outName = `${base}_subs${outExt}`;
const contentType = videoContentType(outExt);
const paths = await stageMediaInputs(ctx);
const videoPath = paths[0];
const subPath = paths[1];
const info = await probeMedia(videoPath);
const outPath = join(ctx.scratchDir, "media", outName);
await runFfmpegWithProgress(
ctx,
[
"-i",
videoPath,
"-i",
subPath,
"-map",
"0",
"-map",
"1:0",
"-c",
"copy",
"-c:s",
scodec,
"-metadata:s:s:0",
`language=${settings.language}`,
outPath,
],
info.durationS,
);
return {
scratchPath: outPath,
filename: outName,
contentType,
};
},
});
}
@@ -0,0 +1,42 @@
import { join } from "node:path";
import { probeMedia } from "@snapotter/media-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { runFfmpegWithProgress, stageMediaInputs } from "../../lib/media-tool.js";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerExtractSubtitles(app: FastifyInstance) {
createToolRoute(app, {
toolId: "extract-subtitles",
settingsSchema,
process: async () => {
throw new Error("extract-subtitles is v2-only");
},
processV2: async (ctx) => {
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
const outName = `${base}.srt`;
const [inPath] = await stageMediaInputs(ctx);
const info = await probeMedia(inPath);
const outPath = join(ctx.scratchDir, "media", outName);
try {
await runFfmpegWithProgress(ctx, ["-i", inPath, "-map", "0:s:0", outPath], info.durationS);
} catch (err) {
// A canceled job must not masquerade as a missing subtitle track
if (ctx.signal.aborted) throw err;
throw new InputValidationError("No subtitle track found in this video");
}
return {
scratchPath: outPath,
filename: outName,
contentType: "application/x-subrip",
};
},
});
}
+63
View File
@@ -0,0 +1,63 @@
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", "webm"]).default("mp4"),
});
export function registerGifToVideo(app: FastifyInstance) {
createToolRoute(app, {
toolId: "gif-to-video",
settingsSchema,
process: async () => {
throw new Error("gif-to-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 contentType = settings.format === "mp4" ? "video/mp4" : "video/webm";
const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => {
if (settings.format === "webm") {
return [
"-i",
inPath,
"-vf",
"scale=trunc(iw/2)*2:trunc(ih/2)*2",
"-c:v",
resolveEncoder("vp9"),
"-b:v",
"0",
"-crf",
"32",
"-an",
out,
];
}
return [
"-i",
inPath,
"-vf",
"scale=trunc(iw/2)*2:trunc(ih/2)*2",
"-c:v",
resolveEncoder("h264"),
"-crf",
"20",
"-preset",
"medium",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
"-an",
out,
];
});
return { scratchPath: outPath, filename: outName, contentType };
},
});
}
@@ -0,0 +1,79 @@
import { join } from "node:path";
import { resolveEncoder } from "@snapotter/media-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { runFfmpegWithProgress, stageMediaInputs } from "../../lib/media-tool.js";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const DIMS: Record<string, { w: number; h: number }> = {
"1080p": { w: 1920, h: 1080 },
"720p": { w: 1280, h: 720 },
square: { w: 1080, h: 1080 },
};
const settingsSchema = z.object({
secondsPerImage: z.number().min(0.5).max(10).default(2),
resolution: z.enum(["1080p", "720p", "square"]).default("720p"),
fps: z.number().int().min(10).max(60).default(30),
});
export function registerImagesToVideo(app: FastifyInstance) {
createToolRoute(app, {
toolId: "images-to-video",
maxInputs: 60,
inputKinds: ["image"],
settingsSchema,
process: async () => {
throw new Error("images-to-video is v2-only");
},
processV2: async (ctx) => {
if (ctx.inputs.length < 2) {
throw new InputValidationError("Provide at least two images");
}
const settings = settingsSchema.parse(ctx.settings);
const { w: W, h: H } = DIMS[settings.resolution];
const paths = await stageMediaInputs(ctx);
const parts: string[] = [];
const refs: string[] = [];
for (let i = 0; i < paths.length; i++) {
parts.push(
`[${i}:v]scale=${W}:${H}:force_original_aspect_ratio=decrease,pad=${W}:${H}:(ow-iw)/2:(oh-ih)/2,setsar=1[v${i}]`,
);
refs.push(`[v${i}]`);
}
const filter = `${parts.join(";")};${refs.join("")}concat=n=${paths.length}:v=1:a=0[v]`;
const args = [
...paths.flatMap((p) => ["-loop", "1", "-t", String(settings.secondsPerImage), "-i", p]),
"-filter_complex",
filter,
"-map",
"[v]",
"-r",
String(settings.fps),
"-c:v",
resolveEncoder("h264"),
"-crf",
"20",
"-preset",
"medium",
"-pix_fmt",
"yuv420p",
join(ctx.scratchDir, "media", "slideshow.mp4"),
];
const progressDuration = paths.length * settings.secondsPerImage;
await runFfmpegWithProgress(ctx, args, progressDuration);
return {
scratchPath: join(ctx.scratchDir, "media", "slideshow.mp4"),
filename: "slideshow.mp4",
contentType: "video/mp4",
};
},
});
}
+46 -2
View File
@@ -4,12 +4,16 @@ import type { FastifyInstance } from "fastify";
import { db, schema } from "../../db/index.js";
import { registerColorAdjustments } from "./adjust-colors.js";
import { registerAiCanvasExpand } from "./ai-canvas-expand.js";
import { registerAspectPad } from "./aspect-pad.js";
import { registerBarcodeRead } from "./barcode-read.js";
import { registerBeautify } from "./beautify.js";
import { registerBlurFaces } from "./blur-faces.js";
import { registerBlurPad } from "./blur-pad.js";
import { registerBookletPdf } from "./booklet-pdf.js";
import { registerBorder } from "./border.js";
import { registerBulkRename } from "./bulk-rename.js";
import { registerBurnSubtitles } from "./burn-subtitles.js";
import { registerChangeFps } from "./change-fps.js";
import { registerCollage } from "./collage.js";
import { registerColorBlindness } from "./color-blindness.js";
import { registerColorPalette } from "./color-palette.js";
@@ -25,16 +29,20 @@ import { registerConvertAudio } from "./convert-audio.js";
import { registerConvertVideo } from "./convert-video.js";
import { registerCrop } from "./crop.js";
import { registerCropPdf } from "./crop-pdf.js";
import { registerCropVideo } from "./crop-video.js";
import { registerCsvExcel } from "./csv-excel.js";
import { registerCsvJson } from "./csv-json.js";
import { registerEditMetadata } from "./edit-metadata.js";
import { registerEmbedSubtitles } from "./embed-subtitles.js";
import { registerEnhanceFaces } from "./enhance-faces.js";
import { registerEraseObject } from "./erase-object.js";
import { registerExtractAudio } from "./extract-audio.js";
import { registerExtractPages } from "./extract-pages.js";
import { registerExtractSubtitles } from "./extract-subtitles.js";
import { registerFavicon } from "./favicon.js";
import { registerFindDuplicates } from "./find-duplicates.js";
import { registerFlattenPdf } from "./flatten-pdf.js";
import { registerGifToVideo } from "./gif-to-video.js";
import { registerGifTools } from "./gif-tools.js";
import { registerGrayscalePdf } from "./grayscale-pdf.js";
import { registerHtmlToImage } from "./html-to-image.js";
@@ -42,12 +50,14 @@ import { registerHtmlToPdf } from "./html-to-pdf.js";
import { registerImageEnhancement } from "./image-enhancement.js";
import { registerImageToBase64 } from "./image-to-base64.js";
import { registerImageToPdf } from "./image-to-pdf.js";
import { registerImagesToVideo } from "./images-to-video.js";
import { registerInfo } from "./info.js";
import { registerJsonXml } from "./json-xml.js";
import { registerLinearizePdf } from "./linearize-pdf.js";
import { registerMarkdownToPdf } from "./markdown-to-pdf.js";
import { registerMemeGenerator } from "./meme-generator.js";
import { registerMergePdf } from "./merge-pdf.js";
import { registerMergeVideos } from "./merge-videos.js";
import { registerMuteVideo } from "./mute-video.js";
import { registerNoiseRemoval } from "./noise-removal.js";
import { registerNupPdf } from "./nup-pdf.js";
@@ -68,16 +78,21 @@ import { registerRedactPdf } from "./redact-pdf.js";
import { registerRemoveBackground } from "./remove-background.js";
import { registerRemovePages } from "./remove-pages.js";
import { registerRepairPdf } from "./repair-pdf.js";
import { registerReplaceAudio } from "./replace-audio.js";
import { registerReplaceColor } from "./replace-color.js";
import { registerResize } from "./resize.js";
import { registerResizeVideo } from "./resize-video.js";
import { registerRestorePhoto } from "./restore-photo.js";
import { registerReverseVideo } from "./reverse-video.js";
import { registerRotate } from "./rotate.js";
import { registerRotatePdf } from "./rotate-pdf.js";
import { registerRotateVideo } from "./rotate-video.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 { registerStabilizeVideo } from "./stabilize-video.js";
import { registerStitch } from "./stitch.js";
import { registerStripMetadata } from "./strip-metadata.js";
import { registerSvgToRaster } from "./svg-to-raster.js";
@@ -88,10 +103,17 @@ import { registerTrimVideo } from "./trim-video.js";
import { registerUnlockPdf } from "./unlock-pdf.js";
import { registerUpscale } from "./upscale.js";
import { registerVectorize } from "./vectorize.js";
import { registerVideoColor } from "./video-color.js";
import { registerVideoLoudnorm } from "./video-loudnorm.js";
import { registerVideoMetadata } from "./video-metadata.js";
import { registerVideoSpeed } from "./video-speed.js";
import { registerVideoToFrames } from "./video-to-frames.js";
import { registerVideoToGif } from "./video-to-gif.js";
import { registerVideoToWebp } from "./video-to-webp.js";
import { registerWatermarkImage } from "./watermark-image.js";
import { registerWatermarkPdf } from "./watermark-pdf.js";
import { registerWatermarkText } from "./watermark-text.js";
import { registerWatermarkVideo } from "./watermark-video.js";
import { registerWordToPdf } from "./word-to-pdf.js";
/**
@@ -178,11 +200,33 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
{ id: "color-blindness", register: registerColorBlindness },
// Video
{ id: "convert-video", register: registerConvertVideo },
{ id: "aspect-pad", register: registerAspectPad },
{ id: "blur-pad", register: registerBlurPad },
{ id: "burn-subtitles", register: registerBurnSubtitles },
{ id: "change-fps", register: registerChangeFps },
{ id: "compress-video", register: registerCompressVideo },
{ id: "trim-video", register: registerTrimVideo },
{ id: "convert-video", register: registerConvertVideo },
{ id: "crop-video", register: registerCropVideo },
{ id: "embed-subtitles", register: registerEmbedSubtitles },
{ id: "extract-subtitles", register: registerExtractSubtitles },
{ id: "gif-to-video", register: registerGifToVideo },
{ id: "images-to-video", register: registerImagesToVideo },
{ id: "merge-videos", register: registerMergeVideos },
{ id: "mute-video", register: registerMuteVideo },
{ id: "replace-audio", register: registerReplaceAudio },
{ id: "resize-video", register: registerResizeVideo },
{ id: "reverse-video", register: registerReverseVideo },
{ id: "rotate-video", register: registerRotateVideo },
{ id: "stabilize-video", register: registerStabilizeVideo },
{ id: "trim-video", register: registerTrimVideo },
{ id: "video-color", register: registerVideoColor },
{ id: "video-loudnorm", register: registerVideoLoudnorm },
{ id: "video-metadata", register: registerVideoMetadata },
{ id: "video-speed", register: registerVideoSpeed },
{ id: "video-to-frames", register: registerVideoToFrames },
{ id: "video-to-gif", register: registerVideoToGif },
{ id: "video-to-webp", register: registerVideoToWebp },
{ id: "watermark-video", register: registerWatermarkVideo },
// Audio
{ id: "convert-audio", register: registerConvertAudio },
+90
View File
@@ -0,0 +1,90 @@
import { join } from "node:path";
import { probeMedia, resolveEncoder } from "@snapotter/media-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { runFfmpegWithProgress, stageMediaInputs } from "../../lib/media-tool.js";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerMergeVideos(app: FastifyInstance) {
createToolRoute(app, {
toolId: "merge-videos",
maxInputs: 10,
settingsSchema,
process: async () => {
throw new Error("merge-videos is v2-only");
},
processV2: async (ctx) => {
if (ctx.inputs.length < 2) {
throw new InputValidationError("Merging needs at least two videos");
}
const paths = await stageMediaInputs(ctx);
const probes = [];
for (const p of paths) {
probes.push(await probeMedia(p));
}
const first = probes[0].streams.find((s) => s.type === "video");
if (!first?.width || !first?.height) {
throw new InputValidationError("First input has no video stream");
}
// Canvas: first video's dims, rounded to even
const W = first.width - (first.width % 2);
const H = first.height - (first.height % 2);
const totalS = probes.reduce((a, p) => a + (p.durationS ?? 0), 0) || null;
// Build per-input normalize chains + concat
const parts: string[] = [];
const refs: string[] = [];
for (let i = 0; i < paths.length; i++) {
parts.push(
`[${i}:v]scale=${W}:${H}:force_original_aspect_ratio=decrease,pad=${W}:${H}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30[v${i}]`,
);
const hasAudio = probes[i].streams.some((s) => s.type === "audio");
if (hasAudio) {
parts.push(`[${i}:a]aresample=48000,aformat=channel_layouts=stereo[a${i}]`);
} else {
const d = probes[i].durationS ?? 1;
parts.push(`anullsrc=r=48000:cl=stereo,atrim=duration=${d}[a${i}]`);
}
refs.push(`[v${i}][a${i}]`);
}
const filter = `${parts.join(";")};${refs.join("")}concat=n=${paths.length}:v=1:a=1[v][a]`;
const outPath = join(ctx.scratchDir, "media", "merged.mp4");
const args = [
...paths.flatMap((p) => ["-i", p]),
"-filter_complex",
filter,
"-map",
"[v]",
"-map",
"[a]",
"-c:v",
resolveEncoder("h264"),
"-crf",
"20",
"-preset",
"medium",
"-pix_fmt",
"yuv420p",
"-c:a",
resolveEncoder("aac"),
outPath,
];
await runFfmpegWithProgress(ctx, args, totalS);
return {
scratchPath: outPath,
filename: "merged.mp4",
contentType: "video/mp4",
};
},
});
}
@@ -0,0 +1,73 @@
import { basename, extname, join } from "node:path";
import { probeMedia, resolveEncoder } from "@snapotter/media-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerReplaceAudio(app: FastifyInstance) {
createToolRoute(app, {
toolId: "replace-audio",
maxInputs: 2,
inputKinds: ["video", "audio"],
settingsSchema,
process: async () => {
throw new Error("replace-audio is v2-only");
},
processV2: async (ctx) => {
if (ctx.inputs.length !== 2) {
throw new InputValidationError("Provide a video and an audio file");
}
const paths = await stageMediaInputs(ctx);
const videoProbe = await probeMedia(paths[0]);
const audioProbe = await probeMedia(paths[1]);
const hasVideo = videoProbe.streams.some((s) => s.type === "video");
if (!hasVideo) {
throw new InputValidationError("First file must be a video");
}
const hasAudio = audioProbe.streams.some((s) => s.type === "audio");
if (!hasAudio) {
throw new InputValidationError("Second file must be an audio file");
}
// vp8/vp9 in webm cannot be muxed into mp4; keep the source container
const vcodec = videoProbe.streams.find((s) => s.type === "video")?.codec ?? "";
const ext = ["vp8", "vp9"].includes(vcodec) ? ".webm" : ".mp4";
const videoBase = basename(ctx.inputs[0].filename, extname(ctx.inputs[0].filename));
const outName = `${videoBase}_newaudio${ext}`;
const outPath = join(ctx.scratchDir, "media", outName);
const args = [
"-i",
paths[0],
"-i",
paths[1],
"-map",
"0:v:0",
"-map",
"1:a:0",
"-c:v",
"copy",
"-c:a",
resolveEncoder("aac"),
"-shortest",
outPath,
];
const totalS = videoProbe.durationS;
await runFfmpegWithProgress(ctx, args, totalS);
return {
scratchPath: outPath,
filename: outName,
contentType: videoContentType(ext),
};
},
});
}
+71
View File
@@ -0,0 +1,71 @@
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 PRESET_HEIGHTS: Record<string, number> = {
"2160p": 2160,
"1440p": 1440,
"1080p": 1080,
"720p": 720,
"480p": 480,
"360p": 360,
};
const settingsSchema = z
.object({
width: z.number().int().min(16).max(7680).optional(),
height: z.number().int().min(16).max(4320).optional(),
preset: z.enum(["custom", "2160p", "1440p", "1080p", "720p", "480p", "360p"]).default("custom"),
})
.refine((s) => s.preset !== "custom" || s.width !== undefined || s.height !== undefined, {
message: "Set a width, height, or preset",
});
export function registerResizeVideo(app: FastifyInstance) {
createToolRoute(app, {
toolId: "resize-video",
settingsSchema,
process: async () => {
throw new Error("resize-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}_resized${origExt}`;
const contentType = videoContentType(origExt);
let w: number | string;
let h: number | string;
if (settings.preset !== "custom") {
w = -2;
h = PRESET_HEIGHTS[settings.preset];
} else {
w = settings.width ?? -2;
h = settings.height ?? -2;
}
const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => [
"-i",
inPath,
"-vf",
`scale=${w}:${h}:flags=lanczos`,
"-c:v",
resolveEncoder("h264"),
"-crf",
"20",
"-preset",
"medium",
"-pix_fmt",
"yuv420p",
"-c:a",
"copy",
out,
]);
return { scratchPath: outPath, filename: outName, contentType };
},
});
}
@@ -0,0 +1,80 @@
import { extname, join } from "node:path";
import { probeMedia, resolveEncoder } from "@snapotter/media-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerReverseVideo(app: FastifyInstance) {
createToolRoute(app, {
toolId: "reverse-video",
settingsSchema,
process: async () => {
throw new Error("reverse-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}_reversed${origExt}`;
const contentType = videoContentType(origExt);
const [inPath] = await stageMediaInputs(ctx);
const info = await probeMedia(inPath);
if ((info.durationS ?? 0) > 300) {
throw new InputValidationError("Reverse is limited to clips up to 5 minutes");
}
const hasAudio = info.streams.some((s) => s.type === "audio");
const outPath = join(ctx.scratchDir, "media", outName);
let args: string[];
if (hasAudio) {
args = [
"-i",
inPath,
"-vf",
"reverse",
"-af",
"areverse",
"-c:v",
resolveEncoder("h264"),
"-crf",
"20",
"-preset",
"medium",
"-pix_fmt",
"yuv420p",
"-c:a",
resolveEncoder("aac"),
outPath,
];
} else {
args = [
"-i",
inPath,
"-vf",
"reverse",
"-an",
"-c:v",
resolveEncoder("h264"),
"-crf",
"20",
"-preset",
"medium",
"-pix_fmt",
"yuv420p",
outPath,
];
}
await runFfmpegWithProgress(ctx, args, info.durationS);
return { scratchPath: outPath, filename: outName, contentType };
},
});
}
+54
View File
@@ -0,0 +1,54 @@
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 VF_MAP: Record<string, string> = {
cw90: "transpose=1",
ccw90: "transpose=2",
"180": "transpose=1,transpose=1",
hflip: "hflip",
vflip: "vflip",
};
const settingsSchema = z.object({
transform: z.enum(["cw90", "ccw90", "180", "hflip", "vflip"]),
});
export function registerRotateVideo(app: FastifyInstance) {
createToolRoute(app, {
toolId: "rotate-video",
settingsSchema,
process: async () => {
throw new Error("rotate-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}_rotated${origExt}`;
const contentType = videoContentType(origExt);
const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => [
"-i",
inPath,
"-vf",
VF_MAP[settings.transform],
"-c:v",
resolveEncoder("h264"),
"-crf",
"20",
"-preset",
"medium",
"-pix_fmt",
"yuv420p",
"-c:a",
"copy",
out,
]);
return { scratchPath: outPath, filename: outName, contentType };
},
});
}
@@ -0,0 +1,69 @@
import { extname, join } from "node:path";
import { probeMedia, resolveEncoder, runFfmpeg } from "@snapotter/media-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
smoothing: z.number().int().min(5).max(60).default(15),
});
export function registerStabilizeVideo(app: FastifyInstance) {
createToolRoute(app, {
toolId: "stabilize-video",
settingsSchema,
process: async () => {
throw new Error("stabilize-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}_stabilized${origExt}`;
const contentType = videoContentType(origExt);
const [inPath] = await stageMediaInputs(ctx);
const info = await probeMedia(inPath);
const trf = join(ctx.scratchDir, "media", "stab.trf");
const nullOut = join(ctx.scratchDir, "media", "null.out");
// Pass 1: motion analysis (no progress mapping; stdout carries -progress pipe:1)
ctx.report(5, "Analyzing");
await runFfmpeg(
["-i", inPath, "-an", "-vf", `vidstabdetect=result=${trf}`, "-f", "null", nullOut],
{
signal: ctx.signal,
timeoutMs: 30 * 60_000,
},
);
// Pass 2: stabilization with re-encode
ctx.report(50, "Stabilizing");
const outPath = join(ctx.scratchDir, "media", outName);
await runFfmpegWithProgress(
ctx,
[
"-i",
inPath,
"-vf",
`vidstabtransform=input=${trf}:smoothing=${settings.smoothing}`,
"-c:v",
resolveEncoder("h264"),
"-crf",
"20",
"-preset",
"medium",
"-pix_fmt",
"yuv420p",
"-c:a",
"copy",
outPath,
],
info.durationS,
);
return { scratchPath: outPath, filename: outName, contentType };
},
});
}
+49
View File
@@ -0,0 +1,49 @@
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({
brightness: z.number().min(-1).max(1).default(0),
contrast: z.number().min(0).max(4).default(1),
saturation: z.number().min(0).max(3).default(1),
gamma: z.number().min(0.1).max(10).default(1),
});
export function registerVideoColor(app: FastifyInstance) {
createToolRoute(app, {
toolId: "video-color",
settingsSchema,
process: async () => {
throw new Error("video-color 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}_color${origExt}`;
const contentType = videoContentType(origExt);
const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => [
"-i",
inPath,
"-vf",
`eq=brightness=${settings.brightness}:contrast=${settings.contrast}:saturation=${settings.saturation}:gamma=${settings.gamma}`,
"-c:v",
resolveEncoder("h264"),
"-crf",
"20",
"-preset",
"medium",
"-pix_fmt",
"yuv420p",
"-c:a",
"copy",
out,
]);
return { scratchPath: outPath, filename: outName, contentType };
},
});
}
@@ -0,0 +1,52 @@
import { extname, join } from "node:path";
import { probeMedia, resolveEncoder } from "@snapotter/media-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerVideoLoudnorm(app: FastifyInstance) {
createToolRoute(app, {
toolId: "video-loudnorm",
settingsSchema,
process: async () => {
throw new Error("video-loudnorm is v2-only");
},
processV2: async (ctx) => {
const origExt = extname(ctx.inputs[0].filename) || ".mp4";
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
const outName = `${base}_loudnorm${origExt}`;
const contentType = videoContentType(origExt);
const [inPath] = await stageMediaInputs(ctx);
const info = await probeMedia(inPath);
if (!info.streams.some((s) => s.type === "audio")) {
throw new InputValidationError("This video has no audio track to normalize");
}
const outPath = join(ctx.scratchDir, "media", outName);
const args = [
"-i",
inPath,
"-af",
"loudnorm=I=-16:TP=-1.5:LRA=11",
"-c:v",
"copy",
"-c:a",
resolveEncoder("aac"),
"-b:a",
"192k",
outPath,
];
await runFfmpegWithProgress(ctx, args, info.durationS);
return { scratchPath: outPath, filename: outName, contentType };
},
});
}
@@ -0,0 +1,48 @@
import { extname, join } from "node:path";
import { probeMedia } from "@snapotter/media-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerVideoMetadata(app: FastifyInstance) {
createToolRoute(app, {
toolId: "video-metadata",
settingsSchema,
process: async () => {
throw new Error("video-metadata is v2-only");
},
processV2: async (ctx) => {
const origExt = extname(ctx.inputs[0].filename) || ".mp4";
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
const outName = `${base}_clean${origExt}`;
const contentType = videoContentType(origExt);
const [inPath] = await stageMediaInputs(ctx);
const info = await probeMedia(inPath);
const outPath = join(ctx.scratchDir, "media", outName);
await runFfmpegWithProgress(
ctx,
["-i", inPath, "-map", "0", "-map_metadata", "-1", "-c", "copy", outPath],
info.durationS,
);
return {
scratchPath: outPath,
filename: outName,
contentType,
resultPayload: {
metadata: {
container: info.container,
durationS: info.durationS,
bitrateKbps: info.bitrateKbps,
streams: info.streams,
},
},
};
},
});
}
+101
View File
@@ -0,0 +1,101 @@
import { extname, join } from "node:path";
import { probeMedia, resolveEncoder } from "@snapotter/media-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
factor: z.number().min(0.25).max(4).default(2),
keepPitch: z.boolean().default(true),
});
/** atempo only accepts 0.5..100; chain factors of 0.5 until in range. */
export function buildAtempoChain(factor: number): string {
const parts: string[] = [];
let f = factor;
while (f < 0.5) {
parts.push("atempo=0.5");
f /= 0.5;
}
parts.push(`atempo=${f}`);
return parts.join(",");
}
export function registerVideoSpeed(app: FastifyInstance) {
createToolRoute(app, {
toolId: "video-speed",
settingsSchema,
process: async () => {
throw new Error("video-speed 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}_speed${origExt}`;
const contentType = videoContentType(origExt);
const [inPath] = await stageMediaInputs(ctx);
const info = await probeMedia(inPath);
const hasAudio = info.streams.some((s) => s.type === "audio");
let args: string[];
if (hasAudio) {
const audioStream = info.streams.find((s) => s.type === "audio");
const sr = audioStream?.sampleRate ?? 48000;
let audioChain: string;
if (settings.keepPitch) {
audioChain = buildAtempoChain(settings.factor);
} else {
audioChain = `asetrate=${sr}*${settings.factor},aresample=${sr}`;
}
args = [
"-i",
inPath,
"-filter_complex",
`[0:v]setpts=PTS/${settings.factor}[v];[0:a]${audioChain}[a]`,
"-map",
"[v]",
"-map",
"[a]",
"-c:v",
resolveEncoder("h264"),
"-crf",
"20",
"-preset",
"medium",
"-pix_fmt",
"yuv420p",
"-c:a",
resolveEncoder("aac"),
];
} else {
args = [
"-i",
inPath,
"-vf",
`setpts=PTS/${settings.factor}`,
"-an",
"-c:v",
resolveEncoder("h264"),
"-crf",
"20",
"-preset",
"medium",
"-pix_fmt",
"yuv420p",
];
}
const outPath = join(ctx.scratchDir, "media", outName);
args.push(outPath);
await runFfmpegWithProgress(ctx, args, info.durationS);
return { scratchPath: outPath, filename: outName, contentType };
},
});
}
@@ -0,0 +1,126 @@
import { createWriteStream } from "node:fs";
import { mkdir, readdir } from "node:fs/promises";
import { join } from "node:path";
import { probeMedia, runFfmpeg } from "@snapotter/media-engine";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { stageMediaInputs } from "../../lib/media-tool.js";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z
.object({
mode: z.enum(["all", "nth", "timestamps"]).default("all"),
n: z.number().int().min(2).max(1000).default(10),
timestamps: z.string().max(500).default(""),
format: z.enum(["png", "jpg"]).default("png"),
})
.refine((s) => s.mode !== "timestamps" || s.timestamps.trim().length > 0, {
message: "Provide timestamps",
});
export function registerVideoToFrames(app: FastifyInstance) {
createToolRoute(app, {
toolId: "video-to-frames",
settingsSchema,
process: async () => {
throw new Error("video-to-frames is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
const [inPath] = await stageMediaInputs(ctx);
const info = await probeMedia(inPath);
const durationS = info.durationS ?? 0;
const framesDir = join(ctx.scratchDir, "media", "frames");
await mkdir(framesDir, { recursive: true });
if (settings.mode === "all") {
// Guard: estimate frames as durationS * 30 (safe ceiling; probe lacks fps)
if (durationS * 30 > 2000) {
throw new InputValidationError("Too many frames; use every-Nth or timestamps mode");
}
ctx.report(5, "Extracting frames");
await runFfmpeg(["-i", inPath, join(framesDir, `frame-%06d.${settings.format}`)], {
signal: ctx.signal,
timeoutMs: 30 * 60_000,
});
} else if (settings.mode === "nth") {
ctx.report(5, "Extracting frames");
await runFfmpeg(
[
"-i",
inPath,
"-vf",
`select=not(mod(n\\,${settings.n}))`,
"-fps_mode",
"vfr",
join(framesDir, `frame-%06d.${settings.format}`),
],
{ signal: ctx.signal, timeoutMs: 30 * 60_000 },
);
} else {
// timestamps mode
const stamps = settings.timestamps.split(",").map((t) => Number(t.trim()));
for (const t of stamps) {
if (Number.isNaN(t) || t < 0) {
throw new InputValidationError(
`Invalid timestamp "${t}": each timestamp must be a non-negative number`,
);
}
if (t > durationS) {
throw new InputValidationError(
`Timestamp ${t}s is beyond the video duration of ${durationS.toFixed(1)}s`,
);
}
}
if (stamps.length > 50) {
throw new InputValidationError("Too many timestamps; maximum is 50 per extraction");
}
for (let idx = 0; idx < stamps.length; idx++) {
const t = stamps[idx];
const frameName = `frame-${String(idx).padStart(3, "0")}.${settings.format}`;
ctx.report(
Math.min(90, 5 + Math.round(((idx + 1) / stamps.length) * 85)),
`Extracting frame ${idx + 1}/${stamps.length}`,
);
await runFfmpeg(
["-ss", String(t), "-i", inPath, "-frames:v", "1", join(framesDir, frameName)],
{ signal: ctx.signal, timeoutMs: 60_000 },
);
}
}
// Collect produced frames
const files = (await readdir(framesDir)).filter((f) => f.endsWith(`.${settings.format}`));
files.sort();
if (files.length === 0) {
throw new Error("No frames extracted");
}
// Zip frames (same pattern as split-pdf.ts)
ctx.report(92, "Creating archive");
const zipPath = join(ctx.scratchDir, "media", `${base}_frames.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 (const f of files) {
archive.file(join(framesDir, f), { name: f });
}
void archive.finalize();
});
return {
scratchPath: zipPath,
filename: `${base}_frames.zip`,
contentType: "application/zip",
};
},
});
}
@@ -0,0 +1,44 @@
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(16).max(1920).default(480),
quality: z.number().int().min(1).max(100).default(75),
loop: z.boolean().default(true),
});
export function registerVideoToWebp(app: FastifyInstance) {
createToolRoute(app, {
toolId: "video-to-webp",
settingsSchema,
process: async () => {
throw new Error("video-to-webp is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
const outName = `${base}.webp`;
const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => {
return [
"-i",
inPath,
"-vf",
`fps=${settings.fps},scale=${settings.width}:-2:flags=lanczos`,
"-c:v",
"libwebp_anim",
"-quality",
String(settings.quality),
"-loop",
settings.loop ? "0" : "1",
"-an",
out,
];
});
return { scratchPath: outPath, filename: outName, contentType: "image/webp" };
},
});
}
@@ -0,0 +1,80 @@
import { writeFile } from "node:fs/promises";
import { extname, join } from "node:path";
import { resolveEncoder, resolveFontFile } 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({
text: z.string().min(1).max(200),
position: z.enum(["tl", "tc", "tr", "l", "c", "r", "bl", "bc", "br"]).default("br"),
fontSize: z.number().int().min(8).max(120).default(36),
opacity: z.number().min(0.05).max(1).default(0.5),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.default("#ffffff"),
});
const POS: Record<string, string> = {
tl: "x=24:y=24",
tc: "x=(w-tw)/2:y=24",
tr: "x=w-tw-24:y=24",
l: "x=24:y=(h-th)/2",
c: "x=(w-tw)/2:y=(h-th)/2",
r: "x=w-tw-24:y=(h-th)/2",
bl: "x=24:y=h-th-24",
bc: "x=(w-tw)/2:y=h-th-24",
br: "x=w-tw-24:y=h-th-24",
};
export function registerWatermarkVideo(app: FastifyInstance) {
createToolRoute(app, {
toolId: "watermark-video",
settingsSchema,
process: async () => {
throw new Error("watermark-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}_watermarked${origExt}`;
const contentType = videoContentType(origExt);
const font = resolveFontFile();
if (!font) {
throw new Error("No usable font found for watermarking (set SNAPOTTER_FONT_FILE)");
}
const textFile = join(ctx.scratchDir, "wm-text.txt");
await writeFile(textFile, settings.text, "utf8");
const color = `${settings.color.replace("#", "0x")}@${settings.opacity.toFixed(2)}`;
// expansion=none: user text is literal; %{...} sequences must not expand
const vf = `drawtext=fontfile=${font.file}:textfile=${textFile}:fontsize=${settings.fontSize}:fontcolor=${color}:${POS[settings.position]}:expansion=none`;
const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => [
"-i",
inPath,
"-vf",
vf,
"-c:v",
resolveEncoder("h264"),
"-crf",
"20",
"-preset",
"medium",
"-pix_fmt",
"yuv420p",
"-c:a",
"copy",
out,
]);
return { scratchPath: outPath, filename: outName, contentType };
},
});
}