mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(tools): 2.0 phase 5 wave 3a - video depth (22 tools) (#221)
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user