Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions src/content/__tests__/buildRenderFfmpegArgs.test.ts
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");
});
});
25 changes: 25 additions & 0 deletions src/content/__tests__/runFfmpeg.test.ts
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);
});
});
23 changes: 23 additions & 0 deletions src/content/buildCropFilter.ts
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;
}
36 changes: 36 additions & 0 deletions src/content/buildOverlayTextFilter.ts
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(":");
}
62 changes: 62 additions & 0 deletions src/content/buildRenderFfmpegArgs.ts
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;
}
14 changes: 14 additions & 0 deletions src/content/downloadMediaToFile.ts
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);
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Downloading large video/audio files entirely into an in-memory Buffer before writing to disk risks high memory usage or OOM in the task worker. Consider streaming the response body directly to the file using node:stream/promises pipeline with fs.createWriteStream, which avoids holding the full file in memory.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/content/downloadMediaToFile.ts, line 12:

<comment>Downloading large video/audio files entirely into an in-memory `Buffer` before writing to disk risks high memory usage or OOM in the task worker. Consider streaming the response body directly to the file using `node:stream/promises` `pipeline` with `fs.createWriteStream`, which avoids holding the full file in memory.</comment>

<file context>
@@ -0,0 +1,14 @@
+ * @param filePath - Local path to write the downloaded file.
+ */
+export async function downloadMediaToFile(url: string, filePath: string): Promise<void> {
+  const { buffer } = await downloadImageBuffer(url);
+  await writeFile(filePath, buffer);
+}
</file context>
Fix with Cubic

await writeFile(filePath, buffer);
}
13 changes: 13 additions & 0 deletions src/content/falServer.ts
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;
30 changes: 10 additions & 20 deletions src/content/renderFinalVideo.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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)));
Expand Down
14 changes: 14 additions & 0 deletions src/content/runFfmpeg.ts
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 });
}
Loading
Loading