-
Notifications
You must be signed in to change notification settings - Fork 2
feat: add ffmpeg-edit task for PATCH /api/content #128
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
80f248c
693753b
c6e1b4a
9734807
2b05031
2a57c33
2ffb111
f4b2bfb
964d678
9cd4a66
9c4d8ef
4010d8d
3f25b26
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| 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 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("crop=ih*9/16:ih"); | ||
| }); | ||
|
|
||
| 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("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 }, | ||
| ]); | ||
| const vf = args[args.indexOf("-vf") + 1]; | ||
| expect(vf).toContain("scale=1080:1920"); | ||
| }); | ||
|
|
||
| it("builds overlay_text with drawtext", () => { | ||
| 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]; | ||
| expect(vf).not.toContain("🔥"); | ||
| }); | ||
|
|
||
| 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 }, | ||
| ]); | ||
| expect(args).not.toContain("-vf"); | ||
| }); | ||
|
|
||
| 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 }, | ||
| ]); | ||
| const vf = args[args.indexOf("-vf") + 1]; | ||
| expect(vf).toContain("crop="); | ||
| expect(vf).toContain(","); | ||
| expect(vf).toContain("drawtext="); | ||
| }); | ||
|
|
||
| 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", () => { | ||
| 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"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, unknown>).__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<string, unknown>).__lastExecFileOptions as { maxBuffer?: number }; | ||
| expect(options?.maxBuffer).toBeGreaterThanOrEqual(10 * 1024 * 1024); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(":"); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import type { FfmpegEditPayload } from "../schemas/ffmpegEditSchema"; | ||
| import { buildCropFilter } from "./buildCropFilter"; | ||
| import { buildOverlayTextFilter } from "./buildOverlayTextFilter"; | ||
|
|
||
| type Operations = FfmpegEditPayload["operations"]; | ||
|
|
||
| /** | ||
| * Builds ffmpeg arguments from a list of video edit operations. | ||
| * | ||
| * Each operation maps to ffmpeg flags: | ||
| * - trim → -ss / -t | ||
| * - crop → crop= filter | ||
| * - resize → scale= filter | ||
| * - overlay_text → drawtext= filter | ||
| * | ||
| * @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. | ||
| */ | ||
| export function buildRenderFfmpegArgs( | ||
| inputPath: string, | ||
| outputPath: string, | ||
| operations: Operations, | ||
| ): string[] { | ||
| const args = ["-y", "-i", inputPath]; | ||
| const videoFilters: 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": { | ||
| 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 as Parameters<typeof buildOverlayTextFilter>[0])); | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| if (videoFilters.length > 0) { | ||
| args.push("-vf", videoFilters.join(",")); | ||
| } | ||
|
|
||
| args.push( | ||
| "-c:v", "libx264", | ||
| "-c:a", "aac", | ||
| "-pix_fmt", "yuv420p", | ||
| "-movflags", "+faststart", | ||
| "-shortest", | ||
| outputPath, | ||
| ); | ||
|
|
||
| return args; | ||
| } | ||
sweetmantech marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +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. | ||
| */ | ||
| export async function downloadMediaToFile(url: string, filePath: string): Promise<void> { | ||
| const { buffer } = await downloadImageBuffer(url); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Downloading large video/audio files entirely into an in-memory Prompt for AI agents |
||
| await writeFile(filePath, buffer); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<void> { | ||
| await execFileAsync("ffmpeg", args, { maxBuffer: 10 * 1024 * 1024 }); | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.