Repository Public
1import { execFile } from "node:child_process";2import { mkdir, rm, writeFile } from "node:fs/promises";3import path from "node:path";45const BIN_PATH = `/opt/homebrew/bin:/usr/local/bin:${process.env.PATH ?? ""}`;67export function run(bin: "ffmpeg" | "ffprobe", args: string[], timeoutMs = 600_000): Promise<{ stdout: string; stderr: string }> {8 return new Promise((resolve, reject) => {9 execFile(bin, args, { env: { ...process.env, PATH: BIN_PATH }, maxBuffer: 64 * 1024 * 1024, timeout: timeoutMs }, (err, stdout, stderr) => {10 if (err) reject(new Error(`${bin} failed: ${(stderr || err.message).split("\n").filter(Boolean).slice(-4).join(" | ")}`));11 else resolve({ stdout, stderr });12 });13 });14}15const ff = (args: string[]) => run("ffmpeg", ["-hide_banner", "-nostdin", "-y", ...args]);1617export interface Probe { duration: number; width: number; height: number; fps: number; hasVideo: boolean; hasAudio: boolean; videoCodec?: string; audioCodec?: string }18export async function probe(file: string): Promise<Probe> {19 const { stdout } = await run("ffprobe", ["-v", "error", "-print_format", "json", "-show_format", "-show_streams", file]);20 const d = JSON.parse(stdout) as { format?: { duration?: string }; streams?: { codec_type: string; codec_name: string; width?: number; height?: number; avg_frame_rate?: string; disposition?: { attached_pic?: number } }[] };21 const v = d.streams?.find((s) => s.codec_type === "video" && !s.disposition?.attached_pic);22 const a = d.streams?.find((s) => s.codec_type === "audio");23 const [n, den] = (v?.avg_frame_rate ?? "0/1").split("/").map(Number);24 return { duration: Number(d.format?.duration ?? 0), width: v?.width ?? 0, height: v?.height ?? 0, fps: den ? n / den : 0, hasVideo: !!v, hasAudio: !!a, videoCodec: v?.codec_name, audioCodec: a?.codec_name };25}2627/** Grok Imagine clips are 24 fps; the master keeps that cadence (converting to 30 would duplicate frames and judder). */28export const FRAME = { fps: 24 } as const;2930/** Conform one generated shot: exact duration, target frame, constant fps, stereo 48 kHz bed (silence if the clip has none). */31export async function conformShot(o: { input: string; output: string; duration: number; width: number; height: number; fadeIn?: number; fadeOut?: number; hasAudio: boolean }) {32 const d = o.duration.toFixed(3);33 const vf = [34 `scale=${o.width}:${o.height}:force_original_aspect_ratio=increase`, `crop=${o.width}:${o.height}`, `fps=${FRAME.fps}`,35 `tpad=stop_mode=clone:stop_duration=2`, `trim=duration=${d}`, "setpts=PTS-STARTPTS", "format=yuv420p",36 ...(o.fadeIn ? [`fade=t=in:st=0:d=${o.fadeIn}`] : []),37 ...(o.fadeOut ? [`fade=t=out:st=${(o.duration - o.fadeOut).toFixed(3)}:d=${o.fadeOut}`] : []),38 ].join(",");39 const edge = 0.08;40 const af = [`apad=pad_dur=2`, `atrim=duration=${d}`, "asetpts=PTS-STARTPTS", `afade=t=in:st=0:d=${edge}`, `afade=t=out:st=${(o.duration - edge).toFixed(3)}:d=${edge}`].join(",");41 const args = o.hasAudio42 ? ["-i", o.input, "-filter_complex", `[0:v:0]${vf}[v];[0:a:0]aresample=48000,aformat=channel_layouts=stereo,${af}[a]`]43 : ["-i", o.input, "-f", "lavfi", "-t", d, "-i", "anullsrc=r=48000:cl=stereo", "-filter_complex", `[0:v:0]${vf}[v];[1:a:0]${af}[a]`];44 await ff([...args, "-map", "[v]", "-map", "[a]", "-c:v", "libx264", "-preset", "medium", "-crf", "14", "-g", "24", "-c:a", "pcm_s16le", "-ar", "48000", "-f", "matroska", o.output]);45}4647export interface NarrationClip { file: string; at: number }4849/** Concatenate conformed shots, mix narration over a ducked bed, normalise loudness, write the master. */50export async function assembleMaster(o: { shots: string[]; narration: NarrationClip[]; total: number; output: string; workDir: string; metadata: Record<string, string> }) {51 await mkdir(o.workDir, { recursive: true });52 const list = path.join(o.workDir, "concat.txt");53 await writeFile(list, o.shots.map((s) => `file '${s.replace(/'/g, "'\\''")}'`).join("\n"));54 const total = o.total.toFixed(3);55 const inputs = ["-f", "concat", "-safe", "0", "-i", list, ...o.narration.flatMap((n) => ["-i", n.file])];56 let graph: string;57 if (o.narration.length) {58 const delayed = o.narration.map((n, i) => `[${i + 1}:a:0]aresample=48000,aformat=channel_layouts=stereo,adelay=${Math.round(n.at * 1000)}:all=1[n${i}]`).join(";");59 const mixIn = o.narration.map((_, i) => `[n${i}]`).join("");60 graph = [61 delayed,62 `${mixIn}amix=inputs=${o.narration.length}:normalize=0:dropout_transition=0,apad,atrim=duration=${total},asplit=2[narr][key]`,63 `[0:a:0]volume=0.5[bed]`,64 `[bed][key]sidechaincompress=threshold=0.02:ratio=10:attack=15:release=350[ducked]`,65 `[ducked][narr]amix=inputs=2:normalize=0:dropout_transition=0,loudnorm=I=-14:TP=-1.5:LRA=11,aresample=48000[a]`,66 ].join(";");67 } else {68 graph = `[0:a:0]loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000[a]`;69 }70 const meta = Object.entries(o.metadata).flatMap(([k, v]) => ["-metadata", `${k}=${v}`]);71 await ff([...inputs, "-filter_complex", graph, "-map", "0:v:0", "-map", "[a]", "-t", total,72 "-c:v", "libx264", "-preset", "slow", "-crf", "20", "-profile:v", "high", "-pix_fmt", "yuv420p", "-g", "48", "-r", String(FRAME.fps),73 "-c:a", "aac", "-b:a", "160k", "-ar", "48000", "-movflags", "+faststart", ...meta, o.output]);74}7576/** Low-bandwidth delivery rendition. */77export async function renderLow(input: string, output: string, metadata: Record<string, string>) {78 const meta = Object.entries(metadata).flatMap(([k, v]) => ["-metadata", `${k}=${v}`]);79 await ff(["-i", input, "-vf", "scale=-2:854", "-c:v", "libx264", "-preset", "slow", "-crf", "26", "-profile:v", "main", "-pix_fmt", "yuv420p", "-g", "48",80 "-c:a", "aac", "-b:a", "96k", "-movflags", "+faststart", ...meta, output]);81}8283export async function stillFromImage(input: string, output: string, width: number, height: number, quality = 3) {84 await ff(["-i", input, "-vf", `scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height}`, "-frames:v", "1", "-q:v", String(quality), output]);85}86export async function frameAt(input: string, at: number, output: string, width = 360) {87 await ff(["-ss", at.toFixed(2), "-i", input, "-frames:v", "1", "-vf", `scale=${width}:-2`, "-q:v", "5", output]);88}8990/** Run an analysis filter and return ffmpeg's log (filters report on stderr). */91export async function analyse(input: string, args: string[]): Promise<string> {92 const { stderr } = await run("ffmpeg", ["-hide_banner", "-nostdin", "-i", input, ...args, "-f", "null", "-"]);93 return stderr;94}95export const cleanup = (dir: string) => rm(dir, { recursive: true, force: true });96