From 80f248cbd1de7a9aebfdcfb36aba9ad6a3e3c294 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 9 Apr 2026 16:18:56 -0500 Subject: [PATCH 01/13] feat: add create-render task for PATCH /api/content edit endpoint Adds the Trigger.dev task that the API's PATCH /api/content endpoint triggers. Accepts video or audio input with an array of edit operations (trim, crop, resize, overlay_text, mux_audio) and processes them sequentially via ffmpeg. Uploads result to fal.ai storage. - createRenderPayloadSchema: Zod schema matching the API's edit body - createRenderTask: schemaTask with ffmpeg processing pipeline - 12 tests covering schema validation and task configuration All 296 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/schemas/createRenderSchema.ts | 44 +++++ src/tasks/__tests__/createRenderTask.test.ts | 152 ++++++++++++++++ src/tasks/createRenderTask.ts | 181 +++++++++++++++++++ 3 files changed, 377 insertions(+) create mode 100644 src/schemas/createRenderSchema.ts create mode 100644 src/tasks/__tests__/createRenderTask.test.ts create mode 100644 src/tasks/createRenderTask.ts diff --git a/src/schemas/createRenderSchema.ts b/src/schemas/createRenderSchema.ts new file mode 100644 index 0000000..771264a --- /dev/null +++ b/src/schemas/createRenderSchema.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; + +export const editOperationSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("trim"), + start: z.number().nonnegative(), + duration: z.number().positive(), + }), + z.object({ + type: z.literal("crop"), + aspect: z.string().optional(), + width: z.number().int().positive().optional(), + height: z.number().int().positive().optional(), + }), + z.object({ + type: z.literal("resize"), + width: z.number().int().positive().optional(), + height: z.number().int().positive().optional(), + }), + z.object({ + type: z.literal("overlay_text"), + content: z.string().min(1), + font: z.string().optional(), + color: z.string().optional().default("white"), + stroke_color: z.string().optional().default("black"), + max_font_size: z.number().positive().optional().default(42), + position: z.enum(["top", "center", "bottom"]).optional().default("bottom"), + }), + z.object({ + type: z.literal("mux_audio"), + audio_url: z.string().url(), + replace: z.boolean().optional().default(true), + }), +]); + +export const createRenderPayloadSchema = z.object({ + accountId: z.string().min(1, "accountId is required"), + video_url: z.string().url().optional(), + audio_url: z.string().url().optional(), + operations: z.array(editOperationSchema), + output_format: z.enum(["mp4", "webm", "mov"]).default("mp4"), +}); + +export type CreateRenderPayload = z.infer; diff --git a/src/tasks/__tests__/createRenderTask.test.ts b/src/tasks/__tests__/createRenderTask.test.ts new file mode 100644 index 0000000..c9ad861 --- /dev/null +++ b/src/tasks/__tests__/createRenderTask.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createRenderPayloadSchema } from "../../schemas/createRenderSchema"; + +// Mock fal.ai +vi.mock("@fal-ai/client", () => ({ + fal: { + config: vi.fn(), + storage: { upload: vi.fn() }, + }, +})); + +// Mock trigger.dev +vi.mock("@trigger.dev/sdk/v3", () => ({ + schemaTask: vi.fn((config) => config), + tags: { add: vi.fn() }, +})); + +// Mock logStep +vi.mock("../../sandboxes/logStep", () => ({ + logStep: vi.fn(), +})); + +describe("createRenderPayloadSchema", () => { + it("validates a payload with video_url and trim operation", () => { + const result = createRenderPayloadSchema.safeParse({ + accountId: "acc-123", + video_url: "https://example.com/video.mp4", + operations: [{ type: "trim", start: 0, duration: 5 }], + }); + expect(result.success).toBe(true); + }); + + it("validates a payload with audio_url and mux_audio operation", () => { + const result = createRenderPayloadSchema.safeParse({ + accountId: "acc-123", + audio_url: "https://example.com/audio.mp3", + operations: [ + { type: "mux_audio", audio_url: "https://example.com/track.mp3" }, + ], + }); + expect(result.success).toBe(true); + }); + + it("validates a payload with crop operation", () => { + const result = createRenderPayloadSchema.safeParse({ + accountId: "acc-123", + video_url: "https://example.com/video.mp4", + operations: [{ type: "crop", aspect: "9:16" }], + }); + expect(result.success).toBe(true); + }); + + it("validates a payload with overlay_text operation and defaults", () => { + const result = createRenderPayloadSchema.safeParse({ + accountId: "acc-123", + video_url: "https://example.com/video.mp4", + operations: [{ type: "overlay_text", content: "hello world" }], + }); + expect(result.success).toBe(true); + if (result.success) { + const op = result.data.operations[0]; + if (op.type === "overlay_text") { + expect(op.color).toBe("white"); + expect(op.stroke_color).toBe("black"); + expect(op.max_font_size).toBe(42); + expect(op.position).toBe("bottom"); + } + } + }); + + it("validates a payload with multiple operations in order", () => { + const result = createRenderPayloadSchema.safeParse({ + accountId: "acc-123", + video_url: "https://example.com/video.mp4", + operations: [ + { type: "crop", aspect: "9:16" }, + { type: "overlay_text", content: "caption text" }, + { + type: "mux_audio", + audio_url: "https://example.com/song.mp3", + replace: true, + }, + ], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.operations).toHaveLength(3); + } + }); + + it("defaults output_format to mp4", () => { + const result = createRenderPayloadSchema.safeParse({ + accountId: "acc-123", + video_url: "https://example.com/video.mp4", + operations: [{ type: "trim", start: 0, duration: 5 }], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.output_format).toBe("mp4"); + } + }); + + it("rejects missing accountId", () => { + const result = createRenderPayloadSchema.safeParse({ + video_url: "https://example.com/video.mp4", + operations: [{ type: "trim", start: 0, duration: 5 }], + }); + expect(result.success).toBe(false); + }); + + it("rejects empty operations array", () => { + const result = createRenderPayloadSchema.safeParse({ + accountId: "acc-123", + video_url: "https://example.com/video.mp4", + operations: [], + }); + // Empty array is valid per schema — API validates template OR operations + expect(result.success).toBe(true); + }); + + it("rejects invalid operation type", () => { + const result = createRenderPayloadSchema.safeParse({ + accountId: "acc-123", + video_url: "https://example.com/video.mp4", + operations: [{ type: "invalid_op" }], + }); + expect(result.success).toBe(false); + }); +}); + +describe("createRenderTask", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.FAL_KEY = "test-key"; + }); + + it("exports a task with id create-render", async () => { + const { createRenderTask } = await import("../createRenderTask"); + expect(createRenderTask.id).toBe("create-render"); + }); + + it("uses the createRenderPayloadSchema", async () => { + const { createRenderTask } = await import("../createRenderTask"); + expect(createRenderTask.schema).toBe(createRenderPayloadSchema); + }); + + it("has medium-1x machine and 10 min max duration", async () => { + const { createRenderTask } = await import("../createRenderTask"); + expect(createRenderTask.machine).toBe("medium-1x"); + expect(createRenderTask.maxDuration).toBe(600); + }); +}); diff --git a/src/tasks/createRenderTask.ts b/src/tasks/createRenderTask.ts new file mode 100644 index 0000000..e63b91e --- /dev/null +++ b/src/tasks/createRenderTask.ts @@ -0,0 +1,181 @@ +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { readFile, writeFile, unlink, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { fal } from "@fal-ai/client"; +import { schemaTask, tags } from "@trigger.dev/sdk/v3"; +import { createRenderPayloadSchema } from "../schemas/createRenderSchema"; +import { logStep } from "../sandboxes/logStep"; + +const execFileAsync = promisify(execFile); + +/** + * Edit/render task — applies a sequence of edit operations to media. + * + * Triggered by PATCH /api/content. Accepts video or audio input and + * runs operations (trim, crop, resize, overlay_text, mux_audio) in + * order using ffmpeg. Uploads the result to fal.ai storage. + */ +export const createRenderTask = schemaTask({ + id: "create-render", + schema: createRenderPayloadSchema, + maxDuration: 600, + machine: "medium-1x", + retry: { + maxAttempts: 0, + }, + run: async (payload) => { + await tags.add(`account:${payload.accountId}`); + logStep("create-render task started", true, { + accountId: payload.accountId, + operationCount: payload.operations.length, + outputFormat: payload.output_format, + }); + + const falKey = process.env.FAL_KEY; + if (!falKey) throw new Error("FAL_KEY environment variable is required"); + fal.config({ credentials: falKey }); + + const tempDir = join(tmpdir(), `render-${randomUUID()}`); + await mkdir(tempDir, { recursive: true }); + + const inputPath = join(tempDir, "input.mp4"); + const outputPath = join(tempDir, `output.${payload.output_format}`); + + try { + // Download input media + const inputUrl = payload.video_url ?? payload.audio_url; + if (!inputUrl) throw new Error("No input media URL provided"); + + logStep("Downloading input media"); + const response = await fetch(inputUrl); + if (!response.ok) throw new Error(`Failed to download input: ${response.status}`); + await writeFile(inputPath, Buffer.from(await response.arrayBuffer())); + + // Build ffmpeg args from operations + const ffmpegArgs = buildFfmpegArgs(inputPath, outputPath, payload.operations); + + logStep("Running ffmpeg", true, { args: ffmpegArgs.join(" ") }); + await execFileAsync("ffmpeg", ffmpegArgs); + + // Upload result + const outputBuffer = await readFile(outputPath); + const mimeType = `video/${payload.output_format}`; + const outputFile = new File([outputBuffer], `rendered.${payload.output_format}`, { type: mimeType }); + const resultUrl = await fal.storage.upload(outputFile); + + logStep("Render complete", true, { + url: resultUrl, + sizeBytes: outputBuffer.length, + }); + + return { + status: "completed", + url: resultUrl, + mimeType, + sizeBytes: outputBuffer.length, + }; + } finally { + await Promise.all( + [inputPath, outputPath].map((p) => unlink(p).catch(() => undefined)), + ); + } + }, +}); + +/** + * Builds ffmpeg arguments from a list of edit operations. + * + * @param inputPath - Path to the input media file. + * @param outputPath - Path for the output file. + * @param operations - Array of edit operations to apply in order. + * @returns Array of ffmpeg CLI arguments. + */ +function buildFfmpegArgs( + inputPath: string, + outputPath: string, + operations: CreateRenderOperations, +): string[] { + const args = ["-y", "-i", inputPath]; + const videoFilters: string[] = []; + const extraInputs: string[] = []; + let audioMapping: string[] = []; + + for (const op of operations) { + switch (op.type) { + case "trim": + args.splice(1, 0, "-ss", String(op.start), "-t", String(op.duration)); + break; + + case "crop": + if (op.aspect) { + const [w, h] = op.aspect.split(":").map(Number); + if (w && h) { + if (w > h) { + videoFilters.push(`crop=ih*${w}/${h}:ih`); + } else { + videoFilters.push(`crop=iw:iw*${h}/${w}`); + } + } + } else if (op.width || op.height) { + const cw = op.width ?? -1; + const ch = op.height ?? -1; + videoFilters.push(`crop=${cw}:${ch}`); + } + break; + + case "resize": + videoFilters.push(`scale=${op.width ?? -1}:${op.height ?? -1}`); + break; + + case "overlay_text": { + const escaped = op.content.replace(/'/g, "'\\''").replace(/:/g, "\\:"); + const filter = [ + `drawtext=text='${escaped}'`, + `fontsize=${op.max_font_size}`, + `fontcolor=${op.color}`, + `borderw=${Math.max(2, Math.round(op.max_font_size / 14))}`, + `bordercolor=${op.stroke_color}`, + "x=(w-tw)/2", + op.position === "top" ? "y=180" : op.position === "center" ? "y=(h-th)/2" : `y=h-th-120`, + ].join(":"); + videoFilters.push(filter); + break; + } + + case "mux_audio": + extraInputs.push("-i", op.audio_url); + if (op.replace) { + audioMapping = ["-map", "0:v:0", "-map", "1:a:0"]; + } else { + audioMapping = ["-map", "0:v:0", "-filter_complex", "amix=inputs=2", "-map", "0:a", "-map", "1:a"]; + } + break; + } + } + + if (videoFilters.length > 0) { + args.push("-vf", videoFilters.join(",")); + } + + args.push(...extraInputs); + + if (audioMapping.length > 0) { + args.push(...audioMapping); + } + + args.push( + "-c:v", "libx264", + "-c:a", "aac", + "-pix_fmt", "yuv420p", + "-movflags", "+faststart", + "-shortest", + outputPath, + ); + + return args; +} + +type CreateRenderOperations = typeof createRenderPayloadSchema extends { _output: { operations: infer O } } ? O : never; From 693753b03359f4e2c1eaa7f4722e6f2ab767f0ac Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 9 Apr 2026 16:28:09 -0500 Subject: [PATCH 02/13] =?UTF-8?q?refactor:=20DRY=20=E2=80=94=20extract=20b?= =?UTF-8?q?uildRenderFfmpegArgs,=20reuse=20escapeDrawtext=20and=20stripEmo?= =?UTF-8?q?ji?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract ffmpeg arg builder from task into content/buildRenderFfmpegArgs.ts (SRP) - Reuse escapeDrawtext for text escaping (was reimplemented inline) - Reuse stripEmoji for cleaning overlay text (was missing) - Task file now only handles download → delegate → upload (no ffmpeg logic) - 12 new tests for buildRenderFfmpegArgs covering all operation types - Zero changes to existing pipeline code All 308 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/buildRenderFfmpegArgs.test.ts | 158 ++++++++++++++++++ src/content/buildRenderFfmpegArgs.ts | 106 ++++++++++++ src/tasks/createRenderTask.ts | 103 +----------- 3 files changed, 268 insertions(+), 99 deletions(-) create mode 100644 src/content/__tests__/buildRenderFfmpegArgs.test.ts create mode 100644 src/content/buildRenderFfmpegArgs.ts diff --git a/src/content/__tests__/buildRenderFfmpegArgs.test.ts b/src/content/__tests__/buildRenderFfmpegArgs.test.ts new file mode 100644 index 0000000..8da3df0 --- /dev/null +++ b/src/content/__tests__/buildRenderFfmpegArgs.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect } from "vitest"; +import { buildRenderFfmpegArgs } from "../buildRenderFfmpegArgs"; + +describe("buildRenderFfmpegArgs", () => { + it("builds trim args with -ss and -t", () => { + const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ + { type: "trim", start: 5, duration: 10 }, + ]); + expect(args).toContain("-ss"); + expect(args).toContain("5"); + expect(args).toContain("-t"); + expect(args).toContain("10"); + }); + + it("builds crop filter for aspect ratio", () => { + const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ + { type: "crop", aspect: "9:16" }, + ]); + const vfIndex = args.indexOf("-vf"); + expect(vfIndex).toBeGreaterThan(-1); + expect(args[vfIndex + 1]).toContain("crop="); + }); + + it("builds crop filter for portrait aspect (h > w)", () => { + const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ + { type: "crop", aspect: "9:16" }, + ]); + const vf = args[args.indexOf("-vf") + 1]; + expect(vf).toContain("iw:iw*16/9"); + }); + + it("builds crop filter for landscape aspect (w > h)", () => { + const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ + { type: "crop", aspect: "16:9" }, + ]); + const vf = args[args.indexOf("-vf") + 1]; + expect(vf).toContain("ih*16/9:ih"); + }); + + it("builds resize filter with scale", () => { + const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ + { type: "resize", width: 1080, height: 1920 }, + ]); + const vf = args[args.indexOf("-vf") + 1]; + expect(vf).toContain("scale=1080:1920"); + }); + + it("builds overlay_text with drawtext and uses escapeDrawtext", () => { + const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ + { + type: "overlay_text", + content: "hello world", + color: "white", + stroke_color: "black", + max_font_size: 42, + position: "bottom" as const, + }, + ]); + const vf = args[args.indexOf("-vf") + 1]; + expect(vf).toContain("drawtext="); + expect(vf).toContain("fontsize=42"); + expect(vf).toContain("fontcolor=white"); + expect(vf).toContain("bordercolor=black"); + expect(vf).toContain("y=h-th-120"); + }); + + it("positions overlay_text at top", () => { + const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ + { + type: "overlay_text", + content: "top text", + color: "white", + stroke_color: "black", + max_font_size: 42, + position: "top" as const, + }, + ]); + const vf = args[args.indexOf("-vf") + 1]; + expect(vf).toContain("y=180"); + }); + + it("positions overlay_text at center", () => { + const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ + { + type: "overlay_text", + content: "center text", + color: "white", + stroke_color: "black", + max_font_size: 42, + position: "center" as const, + }, + ]); + const vf = args[args.indexOf("-vf") + 1]; + expect(vf).toContain("y=(h-th)/2"); + }); + + it("strips emoji from overlay_text content", () => { + const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ + { + type: "overlay_text", + content: "hello 🔥 world", + color: "white", + stroke_color: "black", + max_font_size: 42, + position: "bottom" as const, + }, + ]); + const vf = args[args.indexOf("-vf") + 1]; + // Emoji should be stripped, leaving "hello world" + expect(vf).not.toContain("🔥"); + }); + + it("builds mux_audio with replace=true", () => { + const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ + { type: "mux_audio", audio_url: "https://example.com/song.mp3", replace: true }, + ]); + expect(args).toContain("https://example.com/song.mp3"); + expect(args).toContain("-map"); + const mapIndices = args.reduce((acc: number[], v, i) => (v === "-map" ? [...acc, i] : acc), []); + expect(args[mapIndices[0] + 1]).toBe("0:v:0"); + expect(args[mapIndices[1] + 1]).toBe("1:a:0"); + }); + + it("chains multiple operations in order", () => { + const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ + { type: "crop", aspect: "9:16" }, + { + type: "overlay_text", + content: "caption", + color: "white", + stroke_color: "black", + max_font_size: 42, + position: "bottom" as const, + }, + { type: "mux_audio", audio_url: "https://example.com/song.mp3", replace: true }, + ]); + const vf = args[args.indexOf("-vf") + 1]; + // Video filters should be chained with comma + expect(vf).toContain("crop="); + expect(vf).toContain(","); + expect(vf).toContain("drawtext="); + // Audio should be added as extra input + expect(args).toContain("https://example.com/song.mp3"); + }); + + it("always includes output encoding flags", () => { + const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ + { type: "trim", start: 0, duration: 5 }, + ]); + expect(args).toContain("-c:v"); + expect(args).toContain("libx264"); + expect(args).toContain("-c:a"); + expect(args).toContain("aac"); + expect(args).toContain("-pix_fmt"); + expect(args).toContain("yuv420p"); + expect(args[args.length - 1]).toBe("out.mp4"); + }); +}); diff --git a/src/content/buildRenderFfmpegArgs.ts b/src/content/buildRenderFfmpegArgs.ts new file mode 100644 index 0000000..04461c0 --- /dev/null +++ b/src/content/buildRenderFfmpegArgs.ts @@ -0,0 +1,106 @@ +import { escapeDrawtext } from "./escapeDrawtext"; +import { stripEmoji } from "./stripEmoji"; +import type { CreateRenderPayload } from "../schemas/createRenderSchema"; + +type Operations = CreateRenderPayload["operations"]; + +/** + * Builds ffmpeg arguments from a list of edit operations. + * + * Reuses escapeDrawtext and stripEmoji from the content pipeline for + * text processing. Each operation maps to ffmpeg flags: + * - trim → -ss / -t + * - crop → crop= filter + * - resize → scale= filter + * - overlay_text → drawtext= filter + * - mux_audio → extra -i + -map + * + * @param inputPath - Path to the input media file. + * @param outputPath - Path for the output file. + * @param operations - Array of edit operations to apply in order. + * @returns Array of ffmpeg CLI arguments. + */ +export function buildRenderFfmpegArgs( + inputPath: string, + outputPath: string, + operations: Operations, +): string[] { + const args = ["-y", "-i", inputPath]; + const videoFilters: string[] = []; + const extraInputs: string[] = []; + let audioMapping: string[] = []; + + for (const op of operations) { + switch (op.type) { + case "trim": + args.splice(1, 0, "-ss", String(op.start), "-t", String(op.duration)); + break; + + case "crop": + if (op.aspect) { + const [w, h] = op.aspect.split(":").map(Number); + if (w && h) { + videoFilters.push(w > h ? `crop=ih*${w}/${h}:ih` : `crop=iw:iw*${h}/${w}`); + } + } else if (op.width || op.height) { + videoFilters.push(`crop=${op.width ?? -1}:${op.height ?? -1}`); + } + break; + + case "resize": + videoFilters.push(`scale=${op.width ?? -1}:${op.height ?? -1}`); + break; + + case "overlay_text": { + const cleanText = stripEmoji(op.content); + const escaped = escapeDrawtext(cleanText); + const borderWidth = Math.max(2, Math.round(op.max_font_size / 14)); + const yExpr = + op.position === "top" ? "y=180" : + op.position === "center" ? "y=(h-th)/2" : + "y=h-th-120"; + + videoFilters.push( + [ + `drawtext=text='${escaped}'`, + `fontsize=${op.max_font_size}`, + `fontcolor=${op.color}`, + `borderw=${borderWidth}`, + `bordercolor=${op.stroke_color}`, + "x=(w-tw)/2", + yExpr, + ].join(":"), + ); + break; + } + + case "mux_audio": + extraInputs.push("-i", op.audio_url); + audioMapping = op.replace + ? ["-map", "0:v:0", "-map", "1:a:0"] + : ["-map", "0:v:0", "-filter_complex", "amix=inputs=2", "-map", "0:a", "-map", "1:a"]; + break; + } + } + + if (videoFilters.length > 0) { + args.push("-vf", videoFilters.join(",")); + } + + args.push(...extraInputs); + + if (audioMapping.length > 0) { + args.push(...audioMapping); + } + + args.push( + "-c:v", "libx264", + "-c:a", "aac", + "-pix_fmt", "yuv420p", + "-movflags", "+faststart", + "-shortest", + outputPath, + ); + + return args; +} diff --git a/src/tasks/createRenderTask.ts b/src/tasks/createRenderTask.ts index e63b91e..148d2bc 100644 --- a/src/tasks/createRenderTask.ts +++ b/src/tasks/createRenderTask.ts @@ -8,6 +8,9 @@ import { fal } from "@fal-ai/client"; import { schemaTask, tags } from "@trigger.dev/sdk/v3"; import { createRenderPayloadSchema } from "../schemas/createRenderSchema"; import { logStep } from "../sandboxes/logStep"; +import { escapeDrawtext } from "../content/escapeDrawtext"; +import { stripEmoji } from "../content/stripEmoji"; +import { buildRenderFfmpegArgs } from "../content/buildRenderFfmpegArgs"; const execFileAsync = promisify(execFile); @@ -45,7 +48,6 @@ export const createRenderTask = schemaTask({ const outputPath = join(tempDir, `output.${payload.output_format}`); try { - // Download input media const inputUrl = payload.video_url ?? payload.audio_url; if (!inputUrl) throw new Error("No input media URL provided"); @@ -54,13 +56,11 @@ export const createRenderTask = schemaTask({ if (!response.ok) throw new Error(`Failed to download input: ${response.status}`); await writeFile(inputPath, Buffer.from(await response.arrayBuffer())); - // Build ffmpeg args from operations - const ffmpegArgs = buildFfmpegArgs(inputPath, outputPath, payload.operations); + const ffmpegArgs = buildRenderFfmpegArgs(inputPath, outputPath, payload.operations); logStep("Running ffmpeg", true, { args: ffmpegArgs.join(" ") }); await execFileAsync("ffmpeg", ffmpegArgs); - // Upload result const outputBuffer = await readFile(outputPath); const mimeType = `video/${payload.output_format}`; const outputFile = new File([outputBuffer], `rendered.${payload.output_format}`, { type: mimeType }); @@ -84,98 +84,3 @@ export const createRenderTask = schemaTask({ } }, }); - -/** - * Builds ffmpeg arguments from a list of edit operations. - * - * @param inputPath - Path to the input media file. - * @param outputPath - Path for the output file. - * @param operations - Array of edit operations to apply in order. - * @returns Array of ffmpeg CLI arguments. - */ -function buildFfmpegArgs( - inputPath: string, - outputPath: string, - operations: CreateRenderOperations, -): string[] { - const args = ["-y", "-i", inputPath]; - const videoFilters: string[] = []; - const extraInputs: string[] = []; - let audioMapping: string[] = []; - - for (const op of operations) { - switch (op.type) { - case "trim": - args.splice(1, 0, "-ss", String(op.start), "-t", String(op.duration)); - break; - - case "crop": - if (op.aspect) { - const [w, h] = op.aspect.split(":").map(Number); - if (w && h) { - if (w > h) { - videoFilters.push(`crop=ih*${w}/${h}:ih`); - } else { - videoFilters.push(`crop=iw:iw*${h}/${w}`); - } - } - } else if (op.width || op.height) { - const cw = op.width ?? -1; - const ch = op.height ?? -1; - videoFilters.push(`crop=${cw}:${ch}`); - } - break; - - case "resize": - videoFilters.push(`scale=${op.width ?? -1}:${op.height ?? -1}`); - break; - - case "overlay_text": { - const escaped = op.content.replace(/'/g, "'\\''").replace(/:/g, "\\:"); - const filter = [ - `drawtext=text='${escaped}'`, - `fontsize=${op.max_font_size}`, - `fontcolor=${op.color}`, - `borderw=${Math.max(2, Math.round(op.max_font_size / 14))}`, - `bordercolor=${op.stroke_color}`, - "x=(w-tw)/2", - op.position === "top" ? "y=180" : op.position === "center" ? "y=(h-th)/2" : `y=h-th-120`, - ].join(":"); - videoFilters.push(filter); - break; - } - - case "mux_audio": - extraInputs.push("-i", op.audio_url); - if (op.replace) { - audioMapping = ["-map", "0:v:0", "-map", "1:a:0"]; - } else { - audioMapping = ["-map", "0:v:0", "-filter_complex", "amix=inputs=2", "-map", "0:a", "-map", "1:a"]; - } - break; - } - } - - if (videoFilters.length > 0) { - args.push("-vf", videoFilters.join(",")); - } - - args.push(...extraInputs); - - if (audioMapping.length > 0) { - args.push(...audioMapping); - } - - args.push( - "-c:v", "libx264", - "-c:a", "aac", - "-pix_fmt", "yuv420p", - "-movflags", "+faststart", - "-shortest", - outputPath, - ); - - return args; -} - -type CreateRenderOperations = typeof createRenderPayloadSchema extends { _output: { operations: infer O } } ? O : never; From c6e1b4afcf92ed2e4b1f9ef775b2daa8ef401268 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 9 Apr 2026 16:33:11 -0500 Subject: [PATCH 03/13] refactor: extract shared media primitives used by both render tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract 3 shared utilities from renderFinalVideo so both the existing content pipeline and the new create-render task use the same code: - downloadMediaToFile(url, path) — fetch + write to disk - runFfmpeg(args) — execFile wrapper - uploadToFalStorage(path, name, mime) — read + upload to fal.ai renderFinalVideo now composes these instead of inlining the logic. createRenderTask uses the same primitives. Existing pipeline behavior is unchanged — same inputs, same outputs. All 308 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/content/downloadMediaToFile.ts | 16 ++++++++++++++ src/content/renderFinalVideo.ts | 30 +++++++++----------------- src/content/runFfmpeg.ts | 14 ++++++++++++ src/content/uploadToFalStorage.ts | 27 ++++++++++++++++++++++++ src/tasks/createRenderTask.ts | 34 +++++++++++------------------- 5 files changed, 79 insertions(+), 42 deletions(-) create mode 100644 src/content/downloadMediaToFile.ts create mode 100644 src/content/runFfmpeg.ts create mode 100644 src/content/uploadToFalStorage.ts diff --git a/src/content/downloadMediaToFile.ts b/src/content/downloadMediaToFile.ts new file mode 100644 index 0000000..e0e70b5 --- /dev/null +++ b/src/content/downloadMediaToFile.ts @@ -0,0 +1,16 @@ +import { writeFile } from "node:fs/promises"; + +/** + * Download media from a URL and write it to a local file. + * + * @param url - Public URL of the media to download. + * @param filePath - Local path to write the downloaded file. + * @throws Error if the download fails. + */ +export async function downloadMediaToFile(url: string, filePath: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to download media: ${response.status}`); + } + await writeFile(filePath, Buffer.from(await response.arrayBuffer())); +} diff --git a/src/content/renderFinalVideo.ts b/src/content/renderFinalVideo.ts index 79a7464..063ad47 100644 --- a/src/content/renderFinalVideo.ts +++ b/src/content/renderFinalVideo.ts @@ -1,17 +1,15 @@ -import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { readFile, writeFile, unlink, mkdir } from "node:fs/promises"; +import { writeFile, unlink, mkdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { promisify } from "node:util"; import { logStep } from "../sandboxes/logStep"; -import { fal } from "@fal-ai/client"; import { buildFfmpegArgs } from "./buildFfmpegArgs"; import { calculateCaptionLayout } from "./calculateCaptionLayout"; import { stripEmoji } from "./stripEmoji"; import { downloadOverlayImages } from "./downloadOverlayImages"; - -const execFileAsync = promisify(execFile); +import { downloadMediaToFile } from "./downloadMediaToFile"; +import { runFfmpeg } from "./runFfmpeg"; +import { uploadToFalStorage } from "./uploadToFalStorage"; export interface RenderFinalVideoInput { videoUrl: string; @@ -46,11 +44,7 @@ export async function renderFinalVideo( try { logStep("Downloading video for final render"); - const videoResponse = await fetch(input.videoUrl); - if (!videoResponse.ok) { - throw new Error(`Failed to download video: ${videoResponse.status}`); - } - await writeFile(videoPath, Buffer.from(await videoResponse.arrayBuffer())); + await downloadMediaToFile(input.videoUrl, videoPath); await writeFile(audioPath, input.songBuffer); overlayPaths = await downloadOverlayImages(input.overlayImageUrls ?? [], tempDir); @@ -74,17 +68,13 @@ export async function renderFinalVideo( overlayCount: overlayPaths.length, }); - await execFileAsync("ffmpeg", ffmpegArgs); - - const finalBuffer = await readFile(outputPath); - const sizeBytes = finalBuffer.length; - logStep("Final video rendered, uploading to fal.ai storage", true, { sizeBytes }); + await runFfmpeg(ffmpegArgs); - const videoFile = new File([finalBuffer], "final-video.mp4", { type: "video/mp4" }); - const videoUrl = await fal.storage.upload(videoFile); - logStep("Final video uploaded to fal.ai storage", false, { videoUrl, sizeBytes }); + logStep("Final video rendered, uploading to fal.ai storage"); + const result = await uploadToFalStorage(outputPath, "final-video.mp4", "video/mp4"); + logStep("Final video uploaded to fal.ai storage", false, { videoUrl: result.url, sizeBytes: result.sizeBytes }); - return { videoUrl, mimeType: "video/mp4", sizeBytes }; + return { videoUrl: result.url, mimeType: result.mimeType, sizeBytes: result.sizeBytes }; } finally { const cleanupPaths = [videoPath, audioPath, outputPath, ...overlayPaths]; await Promise.all(cleanupPaths.map((p) => unlink(p).catch(() => undefined))); diff --git a/src/content/runFfmpeg.ts b/src/content/runFfmpeg.ts new file mode 100644 index 0000000..6888754 --- /dev/null +++ b/src/content/runFfmpeg.ts @@ -0,0 +1,14 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +/** + * Execute ffmpeg with the given arguments. + * + * @param args - Array of ffmpeg CLI arguments. + * @throws Error if ffmpeg exits with a non-zero code. + */ +export async function runFfmpeg(args: string[]): Promise { + await execFileAsync("ffmpeg", args); +} diff --git a/src/content/uploadToFalStorage.ts b/src/content/uploadToFalStorage.ts new file mode 100644 index 0000000..f0a52da --- /dev/null +++ b/src/content/uploadToFalStorage.ts @@ -0,0 +1,27 @@ +import { readFile } from "node:fs/promises"; +import { fal } from "@fal-ai/client"; + +export interface UploadResult { + url: string; + mimeType: string; + sizeBytes: number; +} + +/** + * Read a local file and upload it to fal.ai storage. + * + * @param filePath - Local path of the file to upload. + * @param filename - Name for the uploaded file. + * @param mimeType - MIME type of the file. + * @returns Object with the uploaded URL, MIME type, and file size. + */ +export async function uploadToFalStorage( + filePath: string, + filename: string, + mimeType: string, +): Promise { + const buffer = await readFile(filePath); + const file = new File([buffer], filename, { type: mimeType }); + const url = await fal.storage.upload(file); + return { url, mimeType, sizeBytes: buffer.length }; +} diff --git a/src/tasks/createRenderTask.ts b/src/tasks/createRenderTask.ts index 148d2bc..db92685 100644 --- a/src/tasks/createRenderTask.ts +++ b/src/tasks/createRenderTask.ts @@ -1,19 +1,16 @@ -import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { readFile, writeFile, unlink, mkdir } from "node:fs/promises"; +import { unlink, mkdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { promisify } from "node:util"; import { fal } from "@fal-ai/client"; import { schemaTask, tags } from "@trigger.dev/sdk/v3"; import { createRenderPayloadSchema } from "../schemas/createRenderSchema"; import { logStep } from "../sandboxes/logStep"; -import { escapeDrawtext } from "../content/escapeDrawtext"; -import { stripEmoji } from "../content/stripEmoji"; +import { downloadMediaToFile } from "../content/downloadMediaToFile"; +import { runFfmpeg } from "../content/runFfmpeg"; +import { uploadToFalStorage } from "../content/uploadToFalStorage"; import { buildRenderFfmpegArgs } from "../content/buildRenderFfmpegArgs"; -const execFileAsync = promisify(execFile); - /** * Edit/render task — applies a sequence of edit operations to media. * @@ -52,30 +49,23 @@ export const createRenderTask = schemaTask({ if (!inputUrl) throw new Error("No input media URL provided"); logStep("Downloading input media"); - const response = await fetch(inputUrl); - if (!response.ok) throw new Error(`Failed to download input: ${response.status}`); - await writeFile(inputPath, Buffer.from(await response.arrayBuffer())); + await downloadMediaToFile(inputUrl, inputPath); const ffmpegArgs = buildRenderFfmpegArgs(inputPath, outputPath, payload.operations); logStep("Running ffmpeg", true, { args: ffmpegArgs.join(" ") }); - await execFileAsync("ffmpeg", ffmpegArgs); + await runFfmpeg(ffmpegArgs); - const outputBuffer = await readFile(outputPath); - const mimeType = `video/${payload.output_format}`; - const outputFile = new File([outputBuffer], `rendered.${payload.output_format}`, { type: mimeType }); - const resultUrl = await fal.storage.upload(outputFile); + logStep("Uploading rendered output"); + const result = await uploadToFalStorage(outputPath, `rendered.${payload.output_format}`, `video/${payload.output_format}`); - logStep("Render complete", true, { - url: resultUrl, - sizeBytes: outputBuffer.length, - }); + logStep("Render complete", true, { url: result.url, sizeBytes: result.sizeBytes }); return { status: "completed", - url: resultUrl, - mimeType, - sizeBytes: outputBuffer.length, + url: result.url, + mimeType: result.mimeType, + sizeBytes: result.sizeBytes, }; } finally { await Promise.all( From 9734807b2cabfc40ca729f9cff8017fba76c1e77 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 9 Apr 2026 16:36:00 -0500 Subject: [PATCH 04/13] refactor: reuse downloadImageBuffer in downloadMediaToFile (DRY) downloadMediaToFile now delegates to the existing downloadImageBuffer for fetch + error handling, instead of reimplementing the same logic. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/content/downloadMediaToFile.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/content/downloadMediaToFile.ts b/src/content/downloadMediaToFile.ts index e0e70b5..a246353 100644 --- a/src/content/downloadMediaToFile.ts +++ b/src/content/downloadMediaToFile.ts @@ -1,16 +1,14 @@ import { writeFile } from "node:fs/promises"; +import { downloadImageBuffer } from "./downloadImageBuffer"; /** * Download media from a URL and write it to a local file. + * Reuses downloadImageBuffer for the fetch + error handling. * * @param url - Public URL of the media to download. * @param filePath - Local path to write the downloaded file. - * @throws Error if the download fails. */ export async function downloadMediaToFile(url: string, filePath: string): Promise { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Failed to download media: ${response.status}`); - } - await writeFile(filePath, Buffer.from(await response.arrayBuffer())); + const { buffer } = await downloadImageBuffer(url); + await writeFile(filePath, buffer); } From 2b05031922fe9cb7a77946fae364443da784f41c Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 9 Apr 2026 16:43:44 -0500 Subject: [PATCH 05/13] fix: address all PR review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Replace z.discriminatedUnion with z.union (Zod v4 compatibility) 2. Add .refine() to crop/resize requiring at least one dimension 3. Validate color values with regex (prevent ffmpeg injection) 4. Fix mux_audio replace:false — use labeled amix filter outputs 5. Add falServer.ts config (matches API's lib/fal/server.ts pattern) 6. Remove unused imports from createRenderTask 7. Fix test name "rejects" → "accepts" for empty operations 8. Add tests for crop/resize validation and mux_audio mixing 9. uploadToFalStorage uses falServer instead of raw @fal-ai/client All 312 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/buildRenderFfmpegArgs.test.ts | 11 ++++++ src/content/buildRenderFfmpegArgs.ts | 8 +++-- src/content/falServer.ts | 13 +++++++ src/content/uploadToFalStorage.ts | 2 +- src/schemas/createRenderSchema.ts | 10 ++++-- src/tasks/__tests__/createRenderTask.test.ts | 36 ++++++++++++++++--- src/tasks/createRenderTask.ts | 5 --- 7 files changed, 68 insertions(+), 17 deletions(-) create mode 100644 src/content/falServer.ts diff --git a/src/content/__tests__/buildRenderFfmpegArgs.test.ts b/src/content/__tests__/buildRenderFfmpegArgs.test.ts index 8da3df0..7ff5e9f 100644 --- a/src/content/__tests__/buildRenderFfmpegArgs.test.ts +++ b/src/content/__tests__/buildRenderFfmpegArgs.test.ts @@ -121,6 +121,17 @@ describe("buildRenderFfmpegArgs", () => { expect(args[mapIndices[1] + 1]).toBe("1:a:0"); }); + it("builds mux_audio with replace=false using amix filter", () => { + const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ + { type: "mux_audio", audio_url: "https://example.com/song.mp3", replace: false }, + ]); + expect(args).toContain("-filter_complex"); + const fcIndex = args.indexOf("-filter_complex"); + expect(args[fcIndex + 1]).toContain("amix=inputs=2"); + expect(args[fcIndex + 1]).toContain("[aout]"); + expect(args).toContain("[aout]"); + }); + it("chains multiple operations in order", () => { const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ { type: "crop", aspect: "9:16" }, diff --git a/src/content/buildRenderFfmpegArgs.ts b/src/content/buildRenderFfmpegArgs.ts index 04461c0..3ca0eb2 100644 --- a/src/content/buildRenderFfmpegArgs.ts +++ b/src/content/buildRenderFfmpegArgs.ts @@ -54,6 +54,8 @@ export function buildRenderFfmpegArgs( case "overlay_text": { const cleanText = stripEmoji(op.content); const escaped = escapeDrawtext(cleanText); + const safeColor = op.color.replace(/:/g, "\\\\:"); + const safeStrokeColor = op.stroke_color.replace(/:/g, "\\\\:"); const borderWidth = Math.max(2, Math.round(op.max_font_size / 14)); const yExpr = op.position === "top" ? "y=180" : @@ -64,9 +66,9 @@ export function buildRenderFfmpegArgs( [ `drawtext=text='${escaped}'`, `fontsize=${op.max_font_size}`, - `fontcolor=${op.color}`, + `fontcolor=${safeColor}`, `borderw=${borderWidth}`, - `bordercolor=${op.stroke_color}`, + `bordercolor=${safeStrokeColor}`, "x=(w-tw)/2", yExpr, ].join(":"), @@ -78,7 +80,7 @@ export function buildRenderFfmpegArgs( extraInputs.push("-i", op.audio_url); audioMapping = op.replace ? ["-map", "0:v:0", "-map", "1:a:0"] - : ["-map", "0:v:0", "-filter_complex", "amix=inputs=2", "-map", "0:a", "-map", "1:a"]; + : ["-map", "0:v:0", "-filter_complex", "[0:a][1:a]amix=inputs=2[aout]", "-map", "[aout]"]; break; } } diff --git a/src/content/falServer.ts b/src/content/falServer.ts new file mode 100644 index 0000000..73174af --- /dev/null +++ b/src/content/falServer.ts @@ -0,0 +1,13 @@ +import { fal as falClient } from "@fal-ai/client"; + +const FAL_KEY = process.env.FAL_KEY as string; + +if (!FAL_KEY) { + throw new Error("FAL_KEY must be set"); +} + +falClient.config({ credentials: FAL_KEY }); + +const fal = falClient; + +export default fal; diff --git a/src/content/uploadToFalStorage.ts b/src/content/uploadToFalStorage.ts index f0a52da..4b59293 100644 --- a/src/content/uploadToFalStorage.ts +++ b/src/content/uploadToFalStorage.ts @@ -1,5 +1,5 @@ import { readFile } from "node:fs/promises"; -import { fal } from "@fal-ai/client"; +import fal from "./falServer"; export interface UploadResult { url: string; diff --git a/src/schemas/createRenderSchema.ts b/src/schemas/createRenderSchema.ts index 771264a..c52609e 100644 --- a/src/schemas/createRenderSchema.ts +++ b/src/schemas/createRenderSchema.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -export const editOperationSchema = z.discriminatedUnion("type", [ +export const editOperationSchema = z.union([ z.object({ type: z.literal("trim"), start: z.number().nonnegative(), @@ -11,18 +11,22 @@ export const editOperationSchema = z.discriminatedUnion("type", [ aspect: z.string().optional(), width: z.number().int().positive().optional(), height: z.number().int().positive().optional(), + }).refine(data => data.aspect || data.width || data.height, { + message: "crop requires at least one of: aspect, width, height", }), z.object({ type: z.literal("resize"), width: z.number().int().positive().optional(), height: z.number().int().positive().optional(), + }).refine(data => data.width || data.height, { + message: "resize requires at least one of: width, height", }), z.object({ type: z.literal("overlay_text"), content: z.string().min(1), font: z.string().optional(), - color: z.string().optional().default("white"), - stroke_color: z.string().optional().default("black"), + color: z.string().regex(/^[a-zA-Z]+$|^#[0-9a-fA-F]{3,8}$/, "color must be a CSS color name or hex value").optional().default("white"), + stroke_color: z.string().regex(/^[a-zA-Z]+$|^#[0-9a-fA-F]{3,8}$/, "stroke_color must be a CSS color name or hex value").optional().default("black"), max_font_size: z.number().positive().optional().default(42), position: z.enum(["top", "center", "bottom"]).optional().default("bottom"), }), diff --git a/src/tasks/__tests__/createRenderTask.test.ts b/src/tasks/__tests__/createRenderTask.test.ts index c9ad861..51af736 100644 --- a/src/tasks/__tests__/createRenderTask.test.ts +++ b/src/tasks/__tests__/createRenderTask.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { createRenderPayloadSchema } from "../../schemas/createRenderSchema"; -// Mock fal.ai -vi.mock("@fal-ai/client", () => ({ - fal: { +// Mock fal.ai server config +vi.mock("../../content/falServer", () => ({ + default: { config: vi.fn(), storage: { upload: vi.fn() }, }, @@ -108,16 +108,42 @@ describe("createRenderPayloadSchema", () => { expect(result.success).toBe(false); }); - it("rejects empty operations array", () => { + it("accepts empty operations array", () => { const result = createRenderPayloadSchema.safeParse({ accountId: "acc-123", video_url: "https://example.com/video.mp4", operations: [], }); - // Empty array is valid per schema — API validates template OR operations expect(result.success).toBe(true); }); + it("rejects crop with no dimensions or aspect", () => { + const result = createRenderPayloadSchema.safeParse({ + accountId: "acc-123", + video_url: "https://example.com/video.mp4", + operations: [{ type: "crop" }], + }); + expect(result.success).toBe(false); + }); + + it("rejects resize with no dimensions", () => { + const result = createRenderPayloadSchema.safeParse({ + accountId: "acc-123", + video_url: "https://example.com/video.mp4", + operations: [{ type: "resize" }], + }); + expect(result.success).toBe(false); + }); + + it("rejects color values with special characters", () => { + const result = createRenderPayloadSchema.safeParse({ + accountId: "acc-123", + video_url: "https://example.com/video.mp4", + operations: [{ type: "overlay_text", content: "test", color: "white:enable=0" }], + }); + expect(result.success).toBe(false); + }); + it("rejects invalid operation type", () => { const result = createRenderPayloadSchema.safeParse({ accountId: "acc-123", diff --git a/src/tasks/createRenderTask.ts b/src/tasks/createRenderTask.ts index db92685..97ed972 100644 --- a/src/tasks/createRenderTask.ts +++ b/src/tasks/createRenderTask.ts @@ -2,7 +2,6 @@ import { randomUUID } from "node:crypto"; import { unlink, mkdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { fal } from "@fal-ai/client"; import { schemaTask, tags } from "@trigger.dev/sdk/v3"; import { createRenderPayloadSchema } from "../schemas/createRenderSchema"; import { logStep } from "../sandboxes/logStep"; @@ -34,10 +33,6 @@ export const createRenderTask = schemaTask({ outputFormat: payload.output_format, }); - const falKey = process.env.FAL_KEY; - if (!falKey) throw new Error("FAL_KEY environment variable is required"); - fal.config({ credentials: falKey }); - const tempDir = join(tmpdir(), `render-${randomUUID()}`); await mkdir(tempDir, { recursive: true }); From 2a57c335c249e8c07a23f4cabac5f0b5511885a7 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 9 Apr 2026 16:50:03 -0500 Subject: [PATCH 06/13] fix: handle template operations with missing content and audio_url MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Template operations are incomplete by design — overlay_text has styling but no content (filled dynamically), mux_audio has replace flag but no audio_url (uses top-level audio_url from request). Changes: - Make overlay_text.content optional in schema, skip if missing - Make mux_audio.audio_url optional in schema, fall back to payload audio_url - buildRenderFfmpegArgs accepts fallbackAudioUrl parameter - 3 new tests for template operation handling All 315 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/buildRenderFfmpegArgs.test.ts | 23 +++++++++++++++++++ src/content/buildRenderFfmpegArgs.ts | 9 ++++++-- src/schemas/createRenderSchema.ts | 4 ++-- src/tasks/createRenderTask.ts | 2 +- 4 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/content/__tests__/buildRenderFfmpegArgs.test.ts b/src/content/__tests__/buildRenderFfmpegArgs.test.ts index 7ff5e9f..115ab3e 100644 --- a/src/content/__tests__/buildRenderFfmpegArgs.test.ts +++ b/src/content/__tests__/buildRenderFfmpegArgs.test.ts @@ -154,6 +154,29 @@ describe("buildRenderFfmpegArgs", () => { expect(args).toContain("https://example.com/song.mp3"); }); + it("skips overlay_text when content is missing (template mode)", () => { + const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ + { type: "overlay_text", color: "white", stroke_color: "black", max_font_size: 42, position: "bottom" as const }, + ]); + const hasVf = args.includes("-vf"); + expect(hasVf).toBe(false); + }); + + it("skips mux_audio when audio_url is missing and no fallback", () => { + const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ + { type: "mux_audio", replace: true }, + ]); + expect(args).not.toContain("-map"); + }); + + it("uses fallback audio_url for mux_audio when op has no audio_url", () => { + const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ + { type: "mux_audio", replace: true }, + ], "https://example.com/fallback.mp3"); + expect(args).toContain("https://example.com/fallback.mp3"); + expect(args).toContain("-map"); + }); + it("always includes output encoding flags", () => { const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ { type: "trim", start: 0, duration: 5 }, diff --git a/src/content/buildRenderFfmpegArgs.ts b/src/content/buildRenderFfmpegArgs.ts index 3ca0eb2..3fd9470 100644 --- a/src/content/buildRenderFfmpegArgs.ts +++ b/src/content/buildRenderFfmpegArgs.ts @@ -24,6 +24,7 @@ export function buildRenderFfmpegArgs( inputPath: string, outputPath: string, operations: Operations, + fallbackAudioUrl?: string, ): string[] { const args = ["-y", "-i", inputPath]; const videoFilters: string[] = []; @@ -52,6 +53,7 @@ export function buildRenderFfmpegArgs( break; case "overlay_text": { + if (!op.content) break; const cleanText = stripEmoji(op.content); const escaped = escapeDrawtext(cleanText); const safeColor = op.color.replace(/:/g, "\\\\:"); @@ -76,12 +78,15 @@ export function buildRenderFfmpegArgs( break; } - case "mux_audio": - extraInputs.push("-i", op.audio_url); + case "mux_audio": { + const audioUrl = op.audio_url ?? fallbackAudioUrl; + if (!audioUrl) break; + extraInputs.push("-i", audioUrl); audioMapping = op.replace ? ["-map", "0:v:0", "-map", "1:a:0"] : ["-map", "0:v:0", "-filter_complex", "[0:a][1:a]amix=inputs=2[aout]", "-map", "[aout]"]; break; + } } } diff --git a/src/schemas/createRenderSchema.ts b/src/schemas/createRenderSchema.ts index c52609e..feeefb5 100644 --- a/src/schemas/createRenderSchema.ts +++ b/src/schemas/createRenderSchema.ts @@ -23,7 +23,7 @@ export const editOperationSchema = z.union([ }), z.object({ type: z.literal("overlay_text"), - content: z.string().min(1), + content: z.string().optional(), font: z.string().optional(), color: z.string().regex(/^[a-zA-Z]+$|^#[0-9a-fA-F]{3,8}$/, "color must be a CSS color name or hex value").optional().default("white"), stroke_color: z.string().regex(/^[a-zA-Z]+$|^#[0-9a-fA-F]{3,8}$/, "stroke_color must be a CSS color name or hex value").optional().default("black"), @@ -32,7 +32,7 @@ export const editOperationSchema = z.union([ }), z.object({ type: z.literal("mux_audio"), - audio_url: z.string().url(), + audio_url: z.string().url().optional(), replace: z.boolean().optional().default(true), }), ]); diff --git a/src/tasks/createRenderTask.ts b/src/tasks/createRenderTask.ts index 97ed972..1b99137 100644 --- a/src/tasks/createRenderTask.ts +++ b/src/tasks/createRenderTask.ts @@ -46,7 +46,7 @@ export const createRenderTask = schemaTask({ logStep("Downloading input media"); await downloadMediaToFile(inputUrl, inputPath); - const ffmpegArgs = buildRenderFfmpegArgs(inputPath, outputPath, payload.operations); + const ffmpegArgs = buildRenderFfmpegArgs(inputPath, outputPath, payload.operations, payload.audio_url); logStep("Running ffmpeg", true, { args: ffmpegArgs.join(" ") }); await runFfmpeg(ffmpegArgs); From 2ffb1119e24220c8735f72cd1d091630ebdfb65c Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 9 Apr 2026 16:54:55 -0500 Subject: [PATCH 07/13] =?UTF-8?q?fix:=20ffmpeg=20arg=20ordering=20?= =?UTF-8?q?=E2=80=94=20all=20inputs=20before=20filters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extra -i inputs (mux_audio URL) must come before -vf filters, otherwise ffmpeg tries to apply the video filter to the audio input. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/content/buildRenderFfmpegArgs.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/content/buildRenderFfmpegArgs.ts b/src/content/buildRenderFfmpegArgs.ts index 3fd9470..4b365bc 100644 --- a/src/content/buildRenderFfmpegArgs.ts +++ b/src/content/buildRenderFfmpegArgs.ts @@ -90,12 +90,13 @@ export function buildRenderFfmpegArgs( } } + // All -i inputs must come before filters and mappings + args.push(...extraInputs); + if (videoFilters.length > 0) { args.push("-vf", videoFilters.join(",")); } - args.push(...extraInputs); - if (audioMapping.length > 0) { args.push(...audioMapping); } From f4b2bfb30e7f757b580f9dec61ad2442d1e4c9d3 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 9 Apr 2026 16:55:20 -0500 Subject: [PATCH 08/13] =?UTF-8?q?Revert=20"fix:=20ffmpeg=20arg=20ordering?= =?UTF-8?q?=20=E2=80=94=20all=20inputs=20before=20filters"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 2ffb1119e24220c8735f72cd1d091630ebdfb65c. --- src/content/buildRenderFfmpegArgs.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/content/buildRenderFfmpegArgs.ts b/src/content/buildRenderFfmpegArgs.ts index 4b365bc..3fd9470 100644 --- a/src/content/buildRenderFfmpegArgs.ts +++ b/src/content/buildRenderFfmpegArgs.ts @@ -90,13 +90,12 @@ export function buildRenderFfmpegArgs( } } - // All -i inputs must come before filters and mappings - args.push(...extraInputs); - if (videoFilters.length > 0) { args.push("-vf", videoFilters.join(",")); } + args.push(...extraInputs); + if (audioMapping.length > 0) { args.push(...audioMapping); } From 964d678e7170378b64ebd96a0d8900964eecf56b Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 9 Apr 2026 16:56:40 -0500 Subject: [PATCH 09/13] =?UTF-8?q?fix:=20ffmpeg=20arg=20ordering=20?= =?UTF-8?q?=E2=80=94=20all=20-i=20inputs=20must=20come=20before=20-vf=20fi?= =?UTF-8?q?lters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: when crop + mux_audio were combined, -vf was placed before the audio -i input, causing ffmpeg to error with "Option vf cannot be applied to input url". Test reproduces the exact failure from Trigger.dev run run_cmns08hb901730hk6s9vskc4a. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/content/__tests__/buildRenderFfmpegArgs.test.ts | 13 +++++++++++++ src/content/buildRenderFfmpegArgs.ts | 5 +++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/content/__tests__/buildRenderFfmpegArgs.test.ts b/src/content/__tests__/buildRenderFfmpegArgs.test.ts index 115ab3e..3e5dad9 100644 --- a/src/content/__tests__/buildRenderFfmpegArgs.test.ts +++ b/src/content/__tests__/buildRenderFfmpegArgs.test.ts @@ -177,6 +177,19 @@ describe("buildRenderFfmpegArgs", () => { expect(args).toContain("-map"); }); + it("places all -i inputs before -vf filters", () => { + const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ + { type: "crop", aspect: "9:16" }, + { type: "mux_audio", replace: true }, + ], "https://example.com/song.mp3"); + + const vfIndex = args.indexOf("-vf"); + const lastInputIndex = args.lastIndexOf("-i"); + expect(vfIndex).toBeGreaterThan(-1); + expect(lastInputIndex).toBeGreaterThan(-1); + expect(lastInputIndex).toBeLessThan(vfIndex); + }); + it("always includes output encoding flags", () => { const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ { type: "trim", start: 0, duration: 5 }, diff --git a/src/content/buildRenderFfmpegArgs.ts b/src/content/buildRenderFfmpegArgs.ts index 3fd9470..4b365bc 100644 --- a/src/content/buildRenderFfmpegArgs.ts +++ b/src/content/buildRenderFfmpegArgs.ts @@ -90,12 +90,13 @@ export function buildRenderFfmpegArgs( } } + // All -i inputs must come before filters and mappings + args.push(...extraInputs); + if (videoFilters.length > 0) { args.push("-vf", videoFilters.join(",")); } - args.push(...extraInputs); - if (audioMapping.length > 0) { args.push(...audioMapping); } From 9cd4a66bef410382d15f96b1765f76e526b56711 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 9 Apr 2026 17:00:27 -0500 Subject: [PATCH 10/13] =?UTF-8?q?fix:=20handle=20audio-only=20inputs=20?= =?UTF-8?q?=E2=80=94=20skip=20video=20operations=20and=20codecs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: when only audio_url is provided (no video_url), the input is an audio file. Video operations (crop, resize, overlay_text) and video codec flags (-c:v, -map 0:v:0) fail because there's no video stream. Reproduces Trigger.dev run failure: "Stream map '0:v:0' matches no streams" when template operations include crop on an audio-only input. Changes: - buildRenderFfmpegArgs accepts audioOnly option - Skips crop, resize, overlay_text when audioOnly - mux_audio omits video mapping when audioOnly - Output encoding skips video codec when audioOnly - Task detects audioOnly from payload (no video_url, has audio_url) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/buildRenderFfmpegArgs.test.ts | 19 ++++++++++ src/content/buildRenderFfmpegArgs.ts | 35 ++++++++++++++----- src/tasks/createRenderTask.ts | 3 +- 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/content/__tests__/buildRenderFfmpegArgs.test.ts b/src/content/__tests__/buildRenderFfmpegArgs.test.ts index 3e5dad9..5f18a0d 100644 --- a/src/content/__tests__/buildRenderFfmpegArgs.test.ts +++ b/src/content/__tests__/buildRenderFfmpegArgs.test.ts @@ -190,6 +190,25 @@ describe("buildRenderFfmpegArgs", () => { expect(lastInputIndex).toBeLessThan(vfIndex); }); + it("skips video filters when input is audio-only", () => { + const args = buildRenderFfmpegArgs("in.mp3", "out.mp4", [ + { type: "crop", aspect: "9:16" }, + { type: "mux_audio", replace: true }, + ], "https://example.com/song.mp3", { audioOnly: true }); + // Should not have -vf or -map 0:v:0 since input has no video stream + expect(args).not.toContain("-vf"); + expect(args.join(" ")).not.toContain("0:v:0"); + }); + + it("skips -map v when input is audio-only with mux_audio replace", () => { + const args = buildRenderFfmpegArgs("in.mp3", "out.mp4", [ + { type: "mux_audio", replace: true }, + ], "https://example.com/song.mp3", { audioOnly: true }); + // Should just map the new audio, no video mapping + expect(args).toContain("-map"); + expect(args.join(" ")).not.toContain("0:v:0"); + }); + it("always includes output encoding flags", () => { const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ { type: "trim", start: 0, duration: 5 }, diff --git a/src/content/buildRenderFfmpegArgs.ts b/src/content/buildRenderFfmpegArgs.ts index 4b365bc..1c39714 100644 --- a/src/content/buildRenderFfmpegArgs.ts +++ b/src/content/buildRenderFfmpegArgs.ts @@ -25,11 +25,13 @@ export function buildRenderFfmpegArgs( outputPath: string, operations: Operations, fallbackAudioUrl?: string, + options?: { audioOnly?: boolean }, ): string[] { const args = ["-y", "-i", inputPath]; const videoFilters: string[] = []; const extraInputs: string[] = []; let audioMapping: string[] = []; + const audioOnly = options?.audioOnly ?? false; for (const op of operations) { switch (op.type) { @@ -38,6 +40,7 @@ export function buildRenderFfmpegArgs( break; case "crop": + if (audioOnly) break; if (op.aspect) { const [w, h] = op.aspect.split(":").map(Number); if (w && h) { @@ -49,11 +52,12 @@ export function buildRenderFfmpegArgs( break; case "resize": + if (audioOnly) break; videoFilters.push(`scale=${op.width ?? -1}:${op.height ?? -1}`); break; case "overlay_text": { - if (!op.content) break; + if (audioOnly || !op.content) break; const cleanText = stripEmoji(op.content); const escaped = escapeDrawtext(cleanText); const safeColor = op.color.replace(/:/g, "\\\\:"); @@ -82,9 +86,15 @@ export function buildRenderFfmpegArgs( const audioUrl = op.audio_url ?? fallbackAudioUrl; if (!audioUrl) break; extraInputs.push("-i", audioUrl); - audioMapping = op.replace - ? ["-map", "0:v:0", "-map", "1:a:0"] - : ["-map", "0:v:0", "-filter_complex", "[0:a][1:a]amix=inputs=2[aout]", "-map", "[aout]"]; + if (audioOnly) { + audioMapping = op.replace + ? ["-map", "1:a:0"] + : ["-filter_complex", "[0:a][1:a]amix=inputs=2[aout]", "-map", "[aout]"]; + } else { + audioMapping = op.replace + ? ["-map", "0:v:0", "-map", "1:a:0"] + : ["-map", "0:v:0", "-filter_complex", "[0:a][1:a]amix=inputs=2[aout]", "-map", "[aout]"]; + } break; } } @@ -101,12 +111,19 @@ export function buildRenderFfmpegArgs( args.push(...audioMapping); } + if (audioOnly) { + args.push("-c:a", "aac"); + } else { + args.push( + "-c:v", "libx264", + "-c:a", "aac", + "-pix_fmt", "yuv420p", + "-movflags", "+faststart", + "-shortest", + ); + } + args.push( - "-c:v", "libx264", - "-c:a", "aac", - "-pix_fmt", "yuv420p", - "-movflags", "+faststart", - "-shortest", outputPath, ); diff --git a/src/tasks/createRenderTask.ts b/src/tasks/createRenderTask.ts index 1b99137..b38817e 100644 --- a/src/tasks/createRenderTask.ts +++ b/src/tasks/createRenderTask.ts @@ -40,13 +40,14 @@ export const createRenderTask = schemaTask({ const outputPath = join(tempDir, `output.${payload.output_format}`); try { + const audioOnly = !payload.video_url && !!payload.audio_url; const inputUrl = payload.video_url ?? payload.audio_url; if (!inputUrl) throw new Error("No input media URL provided"); logStep("Downloading input media"); await downloadMediaToFile(inputUrl, inputPath); - const ffmpegArgs = buildRenderFfmpegArgs(inputPath, outputPath, payload.operations, payload.audio_url); + const ffmpegArgs = buildRenderFfmpegArgs(inputPath, outputPath, payload.operations, payload.audio_url, { audioOnly }); logStep("Running ffmpeg", true, { args: ffmpegArgs.join(" ") }); await runFfmpeg(ffmpegArgs); From 9c4d8ef1311b3663a8ad475cdc11f30056937fb5 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 9 Apr 2026 17:37:36 -0500 Subject: [PATCH 11/13] =?UTF-8?q?rename:=20create-render=20=E2=86=92=20ffm?= =?UTF-8?q?peg-edit=20across=20task=20and=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createRenderTask.ts → ffmpegEditTask.ts - createRenderSchema.ts → ffmpegEditSchema.ts - Task ID: "create-render" → "ffmpeg-edit" - All exports and test references updated All 318 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/content/buildRenderFfmpegArgs.ts | 2 +- ...ateRenderSchema.ts => ffmpegEditSchema.ts} | 0 ...derTask.test.ts => ffmpegEditTask.test.ts} | 20 +++++++++---------- ...{createRenderTask.ts => ffmpegEditTask.ts} | 8 ++++---- 4 files changed, 15 insertions(+), 15 deletions(-) rename src/schemas/{createRenderSchema.ts => ffmpegEditSchema.ts} (100%) rename src/tasks/__tests__/{createRenderTask.test.ts => ffmpegEditTask.test.ts} (89%) rename src/tasks/{createRenderTask.ts => ffmpegEditTask.ts} (92%) diff --git a/src/content/buildRenderFfmpegArgs.ts b/src/content/buildRenderFfmpegArgs.ts index 1c39714..ac71695 100644 --- a/src/content/buildRenderFfmpegArgs.ts +++ b/src/content/buildRenderFfmpegArgs.ts @@ -1,6 +1,6 @@ import { escapeDrawtext } from "./escapeDrawtext"; import { stripEmoji } from "./stripEmoji"; -import type { CreateRenderPayload } from "../schemas/createRenderSchema"; +import type { CreateRenderPayload } from "../schemas/ffmpegEditSchema"; type Operations = CreateRenderPayload["operations"]; diff --git a/src/schemas/createRenderSchema.ts b/src/schemas/ffmpegEditSchema.ts similarity index 100% rename from src/schemas/createRenderSchema.ts rename to src/schemas/ffmpegEditSchema.ts diff --git a/src/tasks/__tests__/createRenderTask.test.ts b/src/tasks/__tests__/ffmpegEditTask.test.ts similarity index 89% rename from src/tasks/__tests__/createRenderTask.test.ts rename to src/tasks/__tests__/ffmpegEditTask.test.ts index 51af736..8475001 100644 --- a/src/tasks/__tests__/createRenderTask.test.ts +++ b/src/tasks/__tests__/ffmpegEditTask.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { createRenderPayloadSchema } from "../../schemas/createRenderSchema"; +import { createRenderPayloadSchema } from "../../schemas/ffmpegEditSchema"; // Mock fal.ai server config vi.mock("../../content/falServer", () => ({ @@ -154,25 +154,25 @@ describe("createRenderPayloadSchema", () => { }); }); -describe("createRenderTask", () => { +describe("ffmpegEditTask", () => { beforeEach(() => { vi.clearAllMocks(); process.env.FAL_KEY = "test-key"; }); - it("exports a task with id create-render", async () => { - const { createRenderTask } = await import("../createRenderTask"); - expect(createRenderTask.id).toBe("create-render"); + it("exports a task with id ffmpeg-edit", async () => { + const { ffmpegEditTask } = await import("../ffmpegEditTask"); + expect(ffmpegEditTask.id).toBe("ffmpeg-edit"); }); it("uses the createRenderPayloadSchema", async () => { - const { createRenderTask } = await import("../createRenderTask"); - expect(createRenderTask.schema).toBe(createRenderPayloadSchema); + const { ffmpegEditTask } = await import("../ffmpegEditTask"); + expect(ffmpegEditTask.schema).toBe(createRenderPayloadSchema); }); it("has medium-1x machine and 10 min max duration", async () => { - const { createRenderTask } = await import("../createRenderTask"); - expect(createRenderTask.machine).toBe("medium-1x"); - expect(createRenderTask.maxDuration).toBe(600); + const { ffmpegEditTask } = await import("../ffmpegEditTask"); + expect(ffmpegEditTask.machine).toBe("medium-1x"); + expect(ffmpegEditTask.maxDuration).toBe(600); }); }); diff --git a/src/tasks/createRenderTask.ts b/src/tasks/ffmpegEditTask.ts similarity index 92% rename from src/tasks/createRenderTask.ts rename to src/tasks/ffmpegEditTask.ts index b38817e..ee82272 100644 --- a/src/tasks/createRenderTask.ts +++ b/src/tasks/ffmpegEditTask.ts @@ -3,7 +3,7 @@ import { unlink, mkdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { schemaTask, tags } from "@trigger.dev/sdk/v3"; -import { createRenderPayloadSchema } from "../schemas/createRenderSchema"; +import { createRenderPayloadSchema } from "../schemas/ffmpegEditSchema"; import { logStep } from "../sandboxes/logStep"; import { downloadMediaToFile } from "../content/downloadMediaToFile"; import { runFfmpeg } from "../content/runFfmpeg"; @@ -17,8 +17,8 @@ import { buildRenderFfmpegArgs } from "../content/buildRenderFfmpegArgs"; * runs operations (trim, crop, resize, overlay_text, mux_audio) in * order using ffmpeg. Uploads the result to fal.ai storage. */ -export const createRenderTask = schemaTask({ - id: "create-render", +export const ffmpegEditTask = schemaTask({ + id: "ffmpeg-edit", schema: createRenderPayloadSchema, maxDuration: 600, machine: "medium-1x", @@ -27,7 +27,7 @@ export const createRenderTask = schemaTask({ }, run: async (payload) => { await tags.add(`account:${payload.accountId}`); - logStep("create-render task started", true, { + logStep("ffmpeg-edit task started", true, { accountId: payload.accountId, operationCount: payload.operations.length, outputFormat: payload.output_format, From 4010d8d4f60b0afaae0264301ac64a13f28be2f8 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 9 Apr 2026 17:49:24 -0500 Subject: [PATCH 12/13] fix: video-only, fix crop math, maxBuffer, color regex, SRP split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses all 5 PR review comments: 1. Remove mux_audio and audio_url — video-only endpoint 2. Remove audioOnly handling — no longer needed 3. Fix crop aspect math — 9:16 now correctly narrows width (ih*9/16:ih) instead of expanding height. Fixes "Invalid too big size" ffmpeg error. 4. runFfmpeg maxBuffer increased to 10MB for verbose ffmpeg stderr 5. Color regex restricted to valid CSS hex lengths (3/4/6/8 digits) SRP: buildRenderFfmpegArgs split into sub-functions: - buildCropFilter() — aspect ratio and dimension crop logic - buildOverlayTextFilter() — text escaping, positioning, styling Schema renamed: createRenderPayloadSchema → ffmpegEditPayloadSchema (deprecated alias kept for backwards compat) All 316 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/buildRenderFfmpegArgs.test.ts | 100 +++--------- src/content/__tests__/runFfmpeg.test.ts | 25 +++ src/content/buildRenderFfmpegArgs.ts | 145 ++++++++---------- src/content/runFfmpeg.ts | 2 +- src/schemas/ffmpegEditSchema.ts | 23 +-- src/tasks/__tests__/ffmpegEditTask.test.ts | 66 +++++--- src/tasks/ffmpegEditTask.ts | 22 ++- 7 files changed, 174 insertions(+), 209 deletions(-) create mode 100644 src/content/__tests__/runFfmpeg.test.ts diff --git a/src/content/__tests__/buildRenderFfmpegArgs.test.ts b/src/content/__tests__/buildRenderFfmpegArgs.test.ts index 5f18a0d..81df1c8 100644 --- a/src/content/__tests__/buildRenderFfmpegArgs.test.ts +++ b/src/content/__tests__/buildRenderFfmpegArgs.test.ts @@ -21,20 +21,23 @@ describe("buildRenderFfmpegArgs", () => { expect(args[vfIndex + 1]).toContain("crop="); }); - it("builds crop filter for portrait aspect (h > w)", () => { + // Fix #5: crop 9:16 on a landscape video should crop width, not expand height + it("builds crop 9:16 as portrait crop (narrows width from source)", () => { const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ { type: "crop", aspect: "9:16" }, ]); const vf = args[args.indexOf("-vf") + 1]; - expect(vf).toContain("iw:iw*16/9"); + // 9:16 means w < h, so crop width to ih*9/16 and keep height + expect(vf).toContain("crop=ih*9/16:ih"); }); - it("builds crop filter for landscape aspect (w > h)", () => { + it("builds crop 16:9 as landscape crop (narrows height from source)", () => { const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ { type: "crop", aspect: "16:9" }, ]); const vf = args[args.indexOf("-vf") + 1]; - expect(vf).toContain("ih*16/9:ih"); + // 16:9 means w > h, so crop height to iw*9/16 and keep width + expect(vf).toContain("crop=iw:iw*9/16"); }); it("builds resize filter with scale", () => { @@ -106,33 +109,17 @@ describe("buildRenderFfmpegArgs", () => { }, ]); const vf = args[args.indexOf("-vf") + 1]; - // Emoji should be stripped, leaving "hello world" expect(vf).not.toContain("🔥"); }); - it("builds mux_audio with replace=true", () => { - const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ - { type: "mux_audio", audio_url: "https://example.com/song.mp3", replace: true }, - ]); - expect(args).toContain("https://example.com/song.mp3"); - expect(args).toContain("-map"); - const mapIndices = args.reduce((acc: number[], v, i) => (v === "-map" ? [...acc, i] : acc), []); - expect(args[mapIndices[0] + 1]).toBe("0:v:0"); - expect(args[mapIndices[1] + 1]).toBe("1:a:0"); - }); - - it("builds mux_audio with replace=false using amix filter", () => { + it("skips overlay_text when content is missing (template mode)", () => { const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ - { type: "mux_audio", audio_url: "https://example.com/song.mp3", replace: false }, + { type: "overlay_text", color: "white", stroke_color: "black", max_font_size: 42, position: "bottom" as const }, ]); - expect(args).toContain("-filter_complex"); - const fcIndex = args.indexOf("-filter_complex"); - expect(args[fcIndex + 1]).toContain("amix=inputs=2"); - expect(args[fcIndex + 1]).toContain("[aout]"); - expect(args).toContain("[aout]"); + expect(args).not.toContain("-vf"); }); - it("chains multiple operations in order", () => { + it("chains multiple video operations in order", () => { const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ { type: "crop", aspect: "9:16" }, { @@ -143,73 +130,34 @@ describe("buildRenderFfmpegArgs", () => { max_font_size: 42, position: "bottom" as const, }, - { type: "mux_audio", audio_url: "https://example.com/song.mp3", replace: true }, ]); const vf = args[args.indexOf("-vf") + 1]; - // Video filters should be chained with comma expect(vf).toContain("crop="); expect(vf).toContain(","); expect(vf).toContain("drawtext="); - // Audio should be added as extra input - expect(args).toContain("https://example.com/song.mp3"); }); - it("skips overlay_text when content is missing (template mode)", () => { + // Fix #1: no mux_audio — should not accept mux_audio operations + it("does not handle mux_audio operations (video-only)", () => { + // buildRenderFfmpegArgs should not have mux_audio case + // Passing an unknown type should result in no extra inputs const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ - { type: "overlay_text", color: "white", stroke_color: "black", max_font_size: 42, position: "bottom" as const }, - ]); - const hasVf = args.includes("-vf"); - expect(hasVf).toBe(false); - }); - - it("skips mux_audio when audio_url is missing and no fallback", () => { - const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ - { type: "mux_audio", replace: true }, + { type: "trim", start: 0, duration: 5 }, ]); expect(args).not.toContain("-map"); + expect(args.join(" ")).not.toContain("amix"); }); - it("uses fallback audio_url for mux_audio when op has no audio_url", () => { + // Fix #2: no audioOnly, no fallbackAudioUrl params + it("function signature has no audioOnly or fallbackAudioUrl params", () => { + // Should work with just 3 args — no 4th or 5th param needed const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ - { type: "mux_audio", replace: true }, - ], "https://example.com/fallback.mp3"); - expect(args).toContain("https://example.com/fallback.mp3"); - expect(args).toContain("-map"); - }); - - it("places all -i inputs before -vf filters", () => { - const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ - { type: "crop", aspect: "9:16" }, - { type: "mux_audio", replace: true }, - ], "https://example.com/song.mp3"); - - const vfIndex = args.indexOf("-vf"); - const lastInputIndex = args.lastIndexOf("-i"); - expect(vfIndex).toBeGreaterThan(-1); - expect(lastInputIndex).toBeGreaterThan(-1); - expect(lastInputIndex).toBeLessThan(vfIndex); - }); - - it("skips video filters when input is audio-only", () => { - const args = buildRenderFfmpegArgs("in.mp3", "out.mp4", [ - { type: "crop", aspect: "9:16" }, - { type: "mux_audio", replace: true }, - ], "https://example.com/song.mp3", { audioOnly: true }); - // Should not have -vf or -map 0:v:0 since input has no video stream - expect(args).not.toContain("-vf"); - expect(args.join(" ")).not.toContain("0:v:0"); - }); - - it("skips -map v when input is audio-only with mux_audio replace", () => { - const args = buildRenderFfmpegArgs("in.mp3", "out.mp4", [ - { type: "mux_audio", replace: true }, - ], "https://example.com/song.mp3", { audioOnly: true }); - // Should just map the new audio, no video mapping - expect(args).toContain("-map"); - expect(args.join(" ")).not.toContain("0:v:0"); + { type: "trim", start: 0, duration: 5 }, + ]); + expect(args.length).toBeGreaterThan(0); }); - it("always includes output encoding flags", () => { + it("always includes video output encoding flags", () => { const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ { type: "trim", start: 0, duration: 5 }, ]); diff --git a/src/content/__tests__/runFfmpeg.test.ts b/src/content/__tests__/runFfmpeg.test.ts new file mode 100644 index 0000000..f402688 --- /dev/null +++ b/src/content/__tests__/runFfmpeg.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("node:child_process", () => ({ + execFile: vi.fn((_cmd, _args, options, cb) => { + // Capture the options passed to execFile + if (typeof options === "function") { + cb = options; + options = {}; + } + // Store options for assertion + (globalThis as Record).__lastExecFileOptions = options; + cb(null, "", ""); + return {}; + }), +})); + +describe("runFfmpeg", () => { + it("sets maxBuffer to at least 10MB to handle ffmpeg stderr", async () => { + const { runFfmpeg } = await import("../runFfmpeg"); + await runFfmpeg(["-version"]); + + const options = (globalThis as Record).__lastExecFileOptions as { maxBuffer?: number }; + expect(options?.maxBuffer).toBeGreaterThanOrEqual(10 * 1024 * 1024); + }); +}); diff --git a/src/content/buildRenderFfmpegArgs.ts b/src/content/buildRenderFfmpegArgs.ts index ac71695..8ebc133 100644 --- a/src/content/buildRenderFfmpegArgs.ts +++ b/src/content/buildRenderFfmpegArgs.ts @@ -1,21 +1,19 @@ import { escapeDrawtext } from "./escapeDrawtext"; import { stripEmoji } from "./stripEmoji"; -import type { CreateRenderPayload } from "../schemas/ffmpegEditSchema"; +import type { FfmpegEditPayload } from "../schemas/ffmpegEditSchema"; -type Operations = CreateRenderPayload["operations"]; +type Operations = FfmpegEditPayload["operations"]; /** - * Builds ffmpeg arguments from a list of edit operations. + * Builds ffmpeg arguments from a list of video edit operations. * - * Reuses escapeDrawtext and stripEmoji from the content pipeline for - * text processing. Each operation maps to ffmpeg flags: + * Each operation maps to ffmpeg flags: * - trim → -ss / -t * - crop → crop= filter * - resize → scale= filter * - overlay_text → drawtext= filter - * - mux_audio → extra -i + -map * - * @param inputPath - Path to the input media file. + * @param inputPath - Path to the input video file. * @param outputPath - Path for the output file. * @param operations - Array of edit operations to apply in order. * @returns Array of ffmpeg CLI arguments. @@ -24,108 +22,87 @@ export function buildRenderFfmpegArgs( inputPath: string, outputPath: string, operations: Operations, - fallbackAudioUrl?: string, - options?: { audioOnly?: boolean }, ): string[] { const args = ["-y", "-i", inputPath]; const videoFilters: string[] = []; - const extraInputs: string[] = []; - let audioMapping: string[] = []; - const audioOnly = options?.audioOnly ?? false; for (const op of operations) { switch (op.type) { case "trim": args.splice(1, 0, "-ss", String(op.start), "-t", String(op.duration)); break; - case "crop": - if (audioOnly) break; - if (op.aspect) { - const [w, h] = op.aspect.split(":").map(Number); - if (w && h) { - videoFilters.push(w > h ? `crop=ih*${w}/${h}:ih` : `crop=iw:iw*${h}/${w}`); - } - } else if (op.width || op.height) { - videoFilters.push(`crop=${op.width ?? -1}:${op.height ?? -1}`); - } + videoFilters.push(buildCropFilter(op)); break; - case "resize": - if (audioOnly) break; videoFilters.push(`scale=${op.width ?? -1}:${op.height ?? -1}`); break; - - case "overlay_text": { - if (audioOnly || !op.content) break; - const cleanText = stripEmoji(op.content); - const escaped = escapeDrawtext(cleanText); - const safeColor = op.color.replace(/:/g, "\\\\:"); - const safeStrokeColor = op.stroke_color.replace(/:/g, "\\\\:"); - const borderWidth = Math.max(2, Math.round(op.max_font_size / 14)); - const yExpr = - op.position === "top" ? "y=180" : - op.position === "center" ? "y=(h-th)/2" : - "y=h-th-120"; - - videoFilters.push( - [ - `drawtext=text='${escaped}'`, - `fontsize=${op.max_font_size}`, - `fontcolor=${safeColor}`, - `borderw=${borderWidth}`, - `bordercolor=${safeStrokeColor}`, - "x=(w-tw)/2", - yExpr, - ].join(":"), - ); - break; - } - - case "mux_audio": { - const audioUrl = op.audio_url ?? fallbackAudioUrl; - if (!audioUrl) break; - extraInputs.push("-i", audioUrl); - if (audioOnly) { - audioMapping = op.replace - ? ["-map", "1:a:0"] - : ["-filter_complex", "[0:a][1:a]amix=inputs=2[aout]", "-map", "[aout]"]; - } else { - audioMapping = op.replace - ? ["-map", "0:v:0", "-map", "1:a:0"] - : ["-map", "0:v:0", "-filter_complex", "[0:a][1:a]amix=inputs=2[aout]", "-map", "[aout]"]; - } + case "overlay_text": + if (op.content) videoFilters.push(buildOverlayTextFilter(op)); break; - } } } - // All -i inputs must come before filters and mappings - args.push(...extraInputs); - if (videoFilters.length > 0) { args.push("-vf", videoFilters.join(",")); } - if (audioMapping.length > 0) { - args.push(...audioMapping); - } - - if (audioOnly) { - args.push("-c:a", "aac"); - } else { - args.push( - "-c:v", "libx264", - "-c:a", "aac", - "-pix_fmt", "yuv420p", - "-movflags", "+faststart", - "-shortest", - ); - } - args.push( + "-c:v", "libx264", + "-c:a", "aac", + "-pix_fmt", "yuv420p", + "-movflags", "+faststart", + "-shortest", outputPath, ); return args; } + +/** + * Build the ffmpeg crop= filter from aspect ratio or explicit dimensions. + * + * For aspect ratio: calculates which dimension to constrain. + * - 9:16 (portrait): keep full height, narrow width → crop=ih*9/16:ih + * - 16:9 (landscape): keep full width, narrow height → crop=iw:iw*9/16 + */ +function buildCropFilter(op: { aspect?: string; width?: number; height?: number }): string { + if (op.aspect) { + const [w, h] = op.aspect.split(":").map(Number); + if (w && h) { + return w >= h ? `crop=iw:iw*${h}/${w}` : `crop=ih*${w}/${h}:ih`; + } + } + return `crop=${op.width ?? -1}:${op.height ?? -1}`; +} + +/** + * Build the ffmpeg drawtext= filter for text overlay. + */ +function buildOverlayTextFilter(op: { + content: string; + color: string; + stroke_color: string; + max_font_size: number; + position: "top" | "center" | "bottom"; +}): string { + const cleanText = stripEmoji(op.content); + const escaped = escapeDrawtext(cleanText); + const safeColor = op.color.replace(/:/g, "\\\\:"); + const safeStrokeColor = op.stroke_color.replace(/:/g, "\\\\:"); + const borderWidth = Math.max(2, Math.round(op.max_font_size / 14)); + const yExpr = + op.position === "top" ? "y=180" : + op.position === "center" ? "y=(h-th)/2" : + "y=h-th-120"; + + return [ + `drawtext=text='${escaped}'`, + `fontsize=${op.max_font_size}`, + `fontcolor=${safeColor}`, + `borderw=${borderWidth}`, + `bordercolor=${safeStrokeColor}`, + "x=(w-tw)/2", + yExpr, + ].join(":"); +} diff --git a/src/content/runFfmpeg.ts b/src/content/runFfmpeg.ts index 6888754..0da73aa 100644 --- a/src/content/runFfmpeg.ts +++ b/src/content/runFfmpeg.ts @@ -10,5 +10,5 @@ const execFileAsync = promisify(execFile); * @throws Error if ffmpeg exits with a non-zero code. */ export async function runFfmpeg(args: string[]): Promise { - await execFileAsync("ffmpeg", args); + await execFileAsync("ffmpeg", args, { maxBuffer: 10 * 1024 * 1024 }); } diff --git a/src/schemas/ffmpegEditSchema.ts b/src/schemas/ffmpegEditSchema.ts index feeefb5..4cbcb36 100644 --- a/src/schemas/ffmpegEditSchema.ts +++ b/src/schemas/ffmpegEditSchema.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +const cssColorRegex = /^[a-zA-Z]+$|^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; + export const editOperationSchema = z.union([ z.object({ type: z.literal("trim"), @@ -25,24 +27,23 @@ export const editOperationSchema = z.union([ type: z.literal("overlay_text"), content: z.string().optional(), font: z.string().optional(), - color: z.string().regex(/^[a-zA-Z]+$|^#[0-9a-fA-F]{3,8}$/, "color must be a CSS color name or hex value").optional().default("white"), - stroke_color: z.string().regex(/^[a-zA-Z]+$|^#[0-9a-fA-F]{3,8}$/, "stroke_color must be a CSS color name or hex value").optional().default("black"), + color: z.string().regex(cssColorRegex, "color must be a CSS color name or hex value").optional().default("white"), + stroke_color: z.string().regex(cssColorRegex, "stroke_color must be a CSS color name or hex value").optional().default("black"), max_font_size: z.number().positive().optional().default(42), position: z.enum(["top", "center", "bottom"]).optional().default("bottom"), }), - z.object({ - type: z.literal("mux_audio"), - audio_url: z.string().url().optional(), - replace: z.boolean().optional().default(true), - }), ]); -export const createRenderPayloadSchema = z.object({ +export const ffmpegEditPayloadSchema = z.object({ accountId: z.string().min(1, "accountId is required"), - video_url: z.string().url().optional(), - audio_url: z.string().url().optional(), + video_url: z.string().url(), operations: z.array(editOperationSchema), output_format: z.enum(["mp4", "webm", "mov"]).default("mp4"), }); -export type CreateRenderPayload = z.infer; +export type FfmpegEditPayload = z.infer; + +/** @deprecated Use ffmpegEditPayloadSchema */ +export const createRenderPayloadSchema = ffmpegEditPayloadSchema; +/** @deprecated Use FfmpegEditPayload */ +export type CreateRenderPayload = FfmpegEditPayload; diff --git a/src/tasks/__tests__/ffmpegEditTask.test.ts b/src/tasks/__tests__/ffmpegEditTask.test.ts index 8475001..0f5e20c 100644 --- a/src/tasks/__tests__/ffmpegEditTask.test.ts +++ b/src/tasks/__tests__/ffmpegEditTask.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { createRenderPayloadSchema } from "../../schemas/ffmpegEditSchema"; +import { ffmpegEditPayloadSchema as createRenderPayloadSchema } from "../../schemas/ffmpegEditSchema"; // Mock fal.ai server config vi.mock("../../content/falServer", () => ({ @@ -21,22 +21,19 @@ vi.mock("../../sandboxes/logStep", () => ({ })); describe("createRenderPayloadSchema", () => { - it("validates a payload with video_url and trim operation", () => { + it("requires video_url", () => { const result = createRenderPayloadSchema.safeParse({ accountId: "acc-123", - video_url: "https://example.com/video.mp4", operations: [{ type: "trim", start: 0, duration: 5 }], }); - expect(result.success).toBe(true); + expect(result.success).toBe(false); }); - it("validates a payload with audio_url and mux_audio operation", () => { + it("validates a payload with video_url and trim operation", () => { const result = createRenderPayloadSchema.safeParse({ accountId: "acc-123", - audio_url: "https://example.com/audio.mp3", - operations: [ - { type: "mux_audio", audio_url: "https://example.com/track.mp3" }, - ], + video_url: "https://example.com/video.mp4", + operations: [{ type: "trim", start: 0, duration: 5 }], }); expect(result.success).toBe(true); }); @@ -68,23 +65,18 @@ describe("createRenderPayloadSchema", () => { } }); - it("validates a payload with multiple operations in order", () => { + it("validates multiple video operations", () => { const result = createRenderPayloadSchema.safeParse({ accountId: "acc-123", video_url: "https://example.com/video.mp4", operations: [ { type: "crop", aspect: "9:16" }, { type: "overlay_text", content: "caption text" }, - { - type: "mux_audio", - audio_url: "https://example.com/song.mp3", - replace: true, - }, ], }); expect(result.success).toBe(true); if (result.success) { - expect(result.data.operations).toHaveLength(3); + expect(result.data.operations).toHaveLength(2); } }); @@ -108,13 +100,25 @@ describe("createRenderPayloadSchema", () => { expect(result.success).toBe(false); }); - it("accepts empty operations array", () => { + it("rejects mux_audio operation (removed)", () => { const result = createRenderPayloadSchema.safeParse({ accountId: "acc-123", video_url: "https://example.com/video.mp4", - operations: [], + operations: [{ type: "mux_audio", audio_url: "https://example.com/a.mp3" }], }); - expect(result.success).toBe(true); + expect(result.success).toBe(false); + }); + + it("does not accept audio_url param", () => { + const result = createRenderPayloadSchema.safeParse({ + accountId: "acc-123", + video_url: "https://example.com/video.mp4", + audio_url: "https://example.com/audio.mp3", + operations: [{ type: "trim", start: 0, duration: 5 }], + }); + if (result.success) { + expect(result.data).not.toHaveProperty("audio_url"); + } }); it("rejects crop with no dimensions or aspect", () => { @@ -144,6 +148,25 @@ describe("createRenderPayloadSchema", () => { expect(result.success).toBe(false); }); + // Fix #4: color regex should reject 5/7-digit hex + it("rejects invalid 5-digit hex color", () => { + const result = createRenderPayloadSchema.safeParse({ + accountId: "acc-123", + video_url: "https://example.com/video.mp4", + operations: [{ type: "overlay_text", content: "test", color: "#12345" }], + }); + expect(result.success).toBe(false); + }); + + it("accepts valid 6-digit hex color", () => { + const result = createRenderPayloadSchema.safeParse({ + accountId: "acc-123", + video_url: "https://example.com/video.mp4", + operations: [{ type: "overlay_text", content: "test", color: "#FF5500" }], + }); + expect(result.success).toBe(true); + }); + it("rejects invalid operation type", () => { const result = createRenderPayloadSchema.safeParse({ accountId: "acc-123", @@ -165,11 +188,6 @@ describe("ffmpegEditTask", () => { expect(ffmpegEditTask.id).toBe("ffmpeg-edit"); }); - it("uses the createRenderPayloadSchema", async () => { - const { ffmpegEditTask } = await import("../ffmpegEditTask"); - expect(ffmpegEditTask.schema).toBe(createRenderPayloadSchema); - }); - it("has medium-1x machine and 10 min max duration", async () => { const { ffmpegEditTask } = await import("../ffmpegEditTask"); expect(ffmpegEditTask.machine).toBe("medium-1x"); diff --git a/src/tasks/ffmpegEditTask.ts b/src/tasks/ffmpegEditTask.ts index ee82272..31ef1c1 100644 --- a/src/tasks/ffmpegEditTask.ts +++ b/src/tasks/ffmpegEditTask.ts @@ -3,7 +3,7 @@ import { unlink, mkdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { schemaTask, tags } from "@trigger.dev/sdk/v3"; -import { createRenderPayloadSchema } from "../schemas/ffmpegEditSchema"; +import { ffmpegEditPayloadSchema } from "../schemas/ffmpegEditSchema"; import { logStep } from "../sandboxes/logStep"; import { downloadMediaToFile } from "../content/downloadMediaToFile"; import { runFfmpeg } from "../content/runFfmpeg"; @@ -11,15 +11,15 @@ import { uploadToFalStorage } from "../content/uploadToFalStorage"; import { buildRenderFfmpegArgs } from "../content/buildRenderFfmpegArgs"; /** - * Edit/render task — applies a sequence of edit operations to media. + * FFmpeg edit task — applies video edit operations via ffmpeg. * - * Triggered by PATCH /api/content. Accepts video or audio input and - * runs operations (trim, crop, resize, overlay_text, mux_audio) in - * order using ffmpeg. Uploads the result to fal.ai storage. + * Triggered by PATCH /api/content. Accepts a video URL and runs + * operations (trim, crop, resize, overlay_text) in order using ffmpeg. + * Uploads the result to fal.ai storage. */ export const ffmpegEditTask = schemaTask({ id: "ffmpeg-edit", - schema: createRenderPayloadSchema, + schema: ffmpegEditPayloadSchema, maxDuration: 600, machine: "medium-1x", retry: { @@ -40,14 +40,10 @@ export const ffmpegEditTask = schemaTask({ const outputPath = join(tempDir, `output.${payload.output_format}`); try { - const audioOnly = !payload.video_url && !!payload.audio_url; - const inputUrl = payload.video_url ?? payload.audio_url; - if (!inputUrl) throw new Error("No input media URL provided"); + logStep("Downloading input video"); + await downloadMediaToFile(payload.video_url, inputPath); - logStep("Downloading input media"); - await downloadMediaToFile(inputUrl, inputPath); - - const ffmpegArgs = buildRenderFfmpegArgs(inputPath, outputPath, payload.operations, payload.audio_url, { audioOnly }); + const ffmpegArgs = buildRenderFfmpegArgs(inputPath, outputPath, payload.operations); logStep("Running ffmpeg", true, { args: ffmpegArgs.join(" ") }); await runFfmpeg(ffmpegArgs); From 3f25b26d3869efd663ead129604b207a570ab54a Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 9 Apr 2026 17:58:16 -0500 Subject: [PATCH 13/13] refactor: SRP split buildCropFilter and buildOverlayTextFilter, clean up tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review comments: 1. buildCropFilter.ts — own file, guards against malformed aspect strings 2. buildOverlayTextFilter.ts — own file with text escaping and positioning 3. buildRenderFfmpegArgs.ts — now under 65 LOC, composes sub-functions 4. Remove weak/placeholder tests, add malformed aspect test 5. Remove all mux_audio and audioOnly references from tests All 316 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/buildRenderFfmpegArgs.test.ts | 69 ++++--------------- src/content/buildCropFilter.ts | 23 +++++++ src/content/buildOverlayTextFilter.ts | 36 ++++++++++ src/content/buildRenderFfmpegArgs.ts | 60 ++-------------- 4 files changed, 81 insertions(+), 107 deletions(-) create mode 100644 src/content/buildCropFilter.ts create mode 100644 src/content/buildOverlayTextFilter.ts diff --git a/src/content/__tests__/buildRenderFfmpegArgs.test.ts b/src/content/__tests__/buildRenderFfmpegArgs.test.ts index 81df1c8..9d4df02 100644 --- a/src/content/__tests__/buildRenderFfmpegArgs.test.ts +++ b/src/content/__tests__/buildRenderFfmpegArgs.test.ts @@ -21,13 +21,11 @@ describe("buildRenderFfmpegArgs", () => { expect(args[vfIndex + 1]).toContain("crop="); }); - // Fix #5: crop 9:16 on a landscape video should crop width, not expand height it("builds crop 9:16 as portrait crop (narrows width from source)", () => { const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ { type: "crop", aspect: "9:16" }, ]); const vf = args[args.indexOf("-vf") + 1]; - // 9:16 means w < h, so crop width to ih*9/16 and keep height expect(vf).toContain("crop=ih*9/16:ih"); }); @@ -36,10 +34,16 @@ describe("buildRenderFfmpegArgs", () => { { type: "crop", aspect: "16:9" }, ]); const vf = args[args.indexOf("-vf") + 1]; - // 16:9 means w > h, so crop height to iw*9/16 and keep width expect(vf).toContain("crop=iw:iw*9/16"); }); + it("skips crop with malformed aspect string", () => { + const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ + { type: "crop", aspect: "invalid" }, + ]); + expect(args).not.toContain("-vf"); + }); + it("builds resize filter with scale", () => { const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ { type: "resize", width: 1080, height: 1920 }, @@ -48,7 +52,7 @@ describe("buildRenderFfmpegArgs", () => { expect(vf).toContain("scale=1080:1920"); }); - it("builds overlay_text with drawtext and uses escapeDrawtext", () => { + it("builds overlay_text with drawtext", () => { const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ { type: "overlay_text", @@ -69,14 +73,7 @@ describe("buildRenderFfmpegArgs", () => { it("positions overlay_text at top", () => { const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ - { - type: "overlay_text", - content: "top text", - color: "white", - stroke_color: "black", - max_font_size: 42, - position: "top" as const, - }, + { type: "overlay_text", content: "top text", color: "white", stroke_color: "black", max_font_size: 42, position: "top" as const }, ]); const vf = args[args.indexOf("-vf") + 1]; expect(vf).toContain("y=180"); @@ -84,14 +81,7 @@ describe("buildRenderFfmpegArgs", () => { it("positions overlay_text at center", () => { const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ - { - type: "overlay_text", - content: "center text", - color: "white", - stroke_color: "black", - max_font_size: 42, - position: "center" as const, - }, + { type: "overlay_text", content: "center text", color: "white", stroke_color: "black", max_font_size: 42, position: "center" as const }, ]); const vf = args[args.indexOf("-vf") + 1]; expect(vf).toContain("y=(h-th)/2"); @@ -99,14 +89,7 @@ describe("buildRenderFfmpegArgs", () => { it("strips emoji from overlay_text content", () => { const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ - { - type: "overlay_text", - content: "hello 🔥 world", - color: "white", - stroke_color: "black", - max_font_size: 42, - position: "bottom" as const, - }, + { type: "overlay_text", content: "hello 🔥 world", color: "white", stroke_color: "black", max_font_size: 42, position: "bottom" as const }, ]); const vf = args[args.indexOf("-vf") + 1]; expect(vf).not.toContain("🔥"); @@ -122,14 +105,7 @@ describe("buildRenderFfmpegArgs", () => { it("chains multiple video operations in order", () => { const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ { type: "crop", aspect: "9:16" }, - { - type: "overlay_text", - content: "caption", - color: "white", - stroke_color: "black", - max_font_size: 42, - position: "bottom" as const, - }, + { type: "overlay_text", content: "caption", color: "white", stroke_color: "black", max_font_size: 42, position: "bottom" as const }, ]); const vf = args[args.indexOf("-vf") + 1]; expect(vf).toContain("crop="); @@ -137,24 +113,9 @@ describe("buildRenderFfmpegArgs", () => { expect(vf).toContain("drawtext="); }); - // Fix #1: no mux_audio — should not accept mux_audio operations - it("does not handle mux_audio operations (video-only)", () => { - // buildRenderFfmpegArgs should not have mux_audio case - // Passing an unknown type should result in no extra inputs - const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ - { type: "trim", start: 0, duration: 5 }, - ]); - expect(args).not.toContain("-map"); - expect(args.join(" ")).not.toContain("amix"); - }); - - // Fix #2: no audioOnly, no fallbackAudioUrl params - it("function signature has no audioOnly or fallbackAudioUrl params", () => { - // Should work with just 3 args — no 4th or 5th param needed - const args = buildRenderFfmpegArgs("in.mp4", "out.mp4", [ - { type: "trim", start: 0, duration: 5 }, - ]); - expect(args.length).toBeGreaterThan(0); + it("only accepts 3 arguments (no audioOnly or fallback params)", () => { + // TypeScript compile check — function should work with exactly 3 args + expect(buildRenderFfmpegArgs.length).toBe(3); }); it("always includes video output encoding flags", () => { diff --git a/src/content/buildCropFilter.ts b/src/content/buildCropFilter.ts new file mode 100644 index 0000000..218108e --- /dev/null +++ b/src/content/buildCropFilter.ts @@ -0,0 +1,23 @@ +/** + * Build the ffmpeg crop= filter from aspect ratio or explicit dimensions. + * + * For aspect ratio: calculates which dimension to constrain. + * - 9:16 (portrait): keep full height, narrow width → crop=ih*9/16:ih + * - 16:9 (landscape): keep full width, narrow height → crop=iw:iw*9/16 + * + * @param op - Crop operation with aspect, width, or height. + * @returns The ffmpeg crop filter string, or null if the input is invalid. + */ +export function buildCropFilter(op: { aspect?: string; width?: number; height?: number }): string | null { + if (op.aspect) { + const parts = op.aspect.split(":"); + if (parts.length !== 2) return null; + const [w, h] = parts.map(Number); + if (!w || !h || isNaN(w) || isNaN(h)) return null; + return w >= h ? `crop=iw:iw*${h}/${w}` : `crop=ih*${w}/${h}:ih`; + } + if (op.width || op.height) { + return `crop=${op.width ?? -1}:${op.height ?? -1}`; + } + return null; +} diff --git a/src/content/buildOverlayTextFilter.ts b/src/content/buildOverlayTextFilter.ts new file mode 100644 index 0000000..971d689 --- /dev/null +++ b/src/content/buildOverlayTextFilter.ts @@ -0,0 +1,36 @@ +import { escapeDrawtext } from "./escapeDrawtext"; +import { stripEmoji } from "./stripEmoji"; + +/** + * Build the ffmpeg drawtext= filter for text overlay. + * + * @param op - Overlay text operation with content, color, position, etc. + * @returns The ffmpeg drawtext filter string. + */ +export function buildOverlayTextFilter(op: { + content: string; + color: string; + stroke_color: string; + max_font_size: number; + position: "top" | "center" | "bottom"; +}): string { + const cleanText = stripEmoji(op.content); + const escaped = escapeDrawtext(cleanText); + const safeColor = op.color.replace(/:/g, "\\\\:"); + const safeStrokeColor = op.stroke_color.replace(/:/g, "\\\\:"); + const borderWidth = Math.max(2, Math.round(op.max_font_size / 14)); + const yExpr = + op.position === "top" ? "y=180" : + op.position === "center" ? "y=(h-th)/2" : + "y=h-th-120"; + + return [ + `drawtext=text='${escaped}'`, + `fontsize=${op.max_font_size}`, + `fontcolor=${safeColor}`, + `borderw=${borderWidth}`, + `bordercolor=${safeStrokeColor}`, + "x=(w-tw)/2", + yExpr, + ].join(":"); +} diff --git a/src/content/buildRenderFfmpegArgs.ts b/src/content/buildRenderFfmpegArgs.ts index 8ebc133..0009851 100644 --- a/src/content/buildRenderFfmpegArgs.ts +++ b/src/content/buildRenderFfmpegArgs.ts @@ -1,6 +1,6 @@ -import { escapeDrawtext } from "./escapeDrawtext"; -import { stripEmoji } from "./stripEmoji"; import type { FfmpegEditPayload } from "../schemas/ffmpegEditSchema"; +import { buildCropFilter } from "./buildCropFilter"; +import { buildOverlayTextFilter } from "./buildOverlayTextFilter"; type Operations = FfmpegEditPayload["operations"]; @@ -31,14 +31,16 @@ export function buildRenderFfmpegArgs( case "trim": args.splice(1, 0, "-ss", String(op.start), "-t", String(op.duration)); break; - case "crop": - videoFilters.push(buildCropFilter(op)); + case "crop": { + const filter = buildCropFilter(op); + if (filter) videoFilters.push(filter); break; + } case "resize": videoFilters.push(`scale=${op.width ?? -1}:${op.height ?? -1}`); break; case "overlay_text": - if (op.content) videoFilters.push(buildOverlayTextFilter(op)); + if (op.content) videoFilters.push(buildOverlayTextFilter(op as Parameters[0])); break; } } @@ -58,51 +60,3 @@ export function buildRenderFfmpegArgs( return args; } - -/** - * Build the ffmpeg crop= filter from aspect ratio or explicit dimensions. - * - * For aspect ratio: calculates which dimension to constrain. - * - 9:16 (portrait): keep full height, narrow width → crop=ih*9/16:ih - * - 16:9 (landscape): keep full width, narrow height → crop=iw:iw*9/16 - */ -function buildCropFilter(op: { aspect?: string; width?: number; height?: number }): string { - if (op.aspect) { - const [w, h] = op.aspect.split(":").map(Number); - if (w && h) { - return w >= h ? `crop=iw:iw*${h}/${w}` : `crop=ih*${w}/${h}:ih`; - } - } - return `crop=${op.width ?? -1}:${op.height ?? -1}`; -} - -/** - * Build the ffmpeg drawtext= filter for text overlay. - */ -function buildOverlayTextFilter(op: { - content: string; - color: string; - stroke_color: string; - max_font_size: number; - position: "top" | "center" | "bottom"; -}): string { - const cleanText = stripEmoji(op.content); - const escaped = escapeDrawtext(cleanText); - const safeColor = op.color.replace(/:/g, "\\\\:"); - const safeStrokeColor = op.stroke_color.replace(/:/g, "\\\\:"); - const borderWidth = Math.max(2, Math.round(op.max_font_size / 14)); - const yExpr = - op.position === "top" ? "y=180" : - op.position === "center" ? "y=(h-th)/2" : - "y=h-th-120"; - - return [ - `drawtext=text='${escaped}'`, - `fontsize=${op.max_font_size}`, - `fontcolor=${safeColor}`, - `borderw=${borderWidth}`, - `bordercolor=${safeStrokeColor}`, - "x=(w-tw)/2", - yExpr, - ].join(":"); -}