import { execFile } from 'node:child_process'; import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname } from 'node:path'; import { promisify } from 'node:util'; import { probeMedia, type VideoMediaProbe } from '../final-media.js'; import { parseTranscriptCues } from '../mograph/beats.js'; import { layoutReframeCaptions, renderReframeCaptionImages, type RenderedReframeCaption, } from './captions.js'; import { calculateCropWindow } from './plan.js'; import type { ReframePlan, ReframeShot, ReframeSourceContext } from './types.js'; const execFileAsync = promisify(execFile); export interface ReframeRenderResult extends VideoMediaProbe { sourcePath: string; outputPath: string; shotCount: number; captionCount: number; } function evenFloor(value: number): number { return Math.max(2, Math.floor(value / 2) * 2); } function n(value: number): string { return Number(value.toFixed(6)).toString(); } function buildShotFilter( shot: ReframeShot, index: number, plan: ReframePlan, context: ReframeSourceContext, ): string { const sourceStart = shot.sourceStartSeconds ?? shot.startSeconds; const sourceEnd = shot.sourceEndSeconds ?? shot.endSeconds; const outputDuration = shot.endSeconds - shot.startSeconds; const sourceDuration = sourceEnd - sourceStart; const timingScale = outputDuration / sourceDuration; const setpts = Math.abs(timingScale - 1) < 0.000001 ? 'setpts=PTS-STARTPTS' : `setpts=(PTS-STARTPTS)*${n(timingScale)}`; const prefix = `[source${index}]trim=start=${n(sourceStart)}:end=${n(sourceEnd)},${setpts}`; if (shot.crop.mode === 'contain') { return `${prefix},scale=${plan.target.width}:${plan.target.height}:force_original_aspect_ratio=decrease,` + `pad=${plan.target.width}:${plan.target.height}:(ow-iw)/2:(oh-ih)/2:black,setsar=1[v${index}]`; } const cropWindow = calculateCropWindow(context.sourceWidth, context.sourceHeight, plan.target); const cropWidth = evenFloor(cropWindow.width); const cropHeight = evenFloor(cropWindow.height); let x: string; let y: string; if (shot.crop.mode === 'fixed') { x = n(shot.crop.x); y = n(shot.crop.y); } else { const duration = shot.endSeconds - shot.startSeconds; x = `'${n(shot.crop.startX)}+(${n(shot.crop.endX - shot.crop.startX)})*t/${n(duration)}'`; y = `'${n(shot.crop.startY)}+(${n(shot.crop.endY - shot.crop.startY)})*t/${n(duration)}'`; } return `${prefix},crop=${cropWidth}:${cropHeight}:x=${x}:y=${y},` + `scale=${plan.target.width}:${plan.target.height},setsar=1[v${index}]`; } export function buildReframeFilterComplex( plan: ReframePlan, context: ReframeSourceContext, captions: RenderedReframeCaption[] = [], ): string { const splitLabels = plan.shots.map((_, index) => `[source${index}]`).join(''); const filters = [`[0:v]split=${plan.shots.length}${splitLabels}`]; plan.shots.forEach((shot, index) => filters.push(buildShotFilter(shot, index, plan, context))); const inputs = plan.shots.map((_, index) => `[v${index}]`).join(''); if (captions.length === 0) { filters.push(`${inputs}concat=n=${plan.shots.length}:v=1:a=0[vout]`); } else { filters.push(`${inputs}concat=n=${plan.shots.length}:v=1:a=0[basev]`); captions.forEach((caption, index) => { const previous = index === 0 ? 'basev' : `captioned${index - 1}`; const next = index === captions.length - 1 ? 'vout' : `captioned${index}`; filters.push( `[${previous}][${index + 1}:v]overlay=x=0:y=0:enable='between(t,${n(caption.startSeconds)},${n(caption.endSeconds)})'[${next}]`, ); }); } return filters.join(';'); } export async function renderReframeVideo(input: { plan: ReframePlan; context: ReframeSourceContext; outputPath: string; ffmpegPath?: string; }): Promise { await mkdir(dirname(input.outputPath), { recursive: true }); const ffmpegPath = input.ffmpegPath ?? 'ffmpeg'; let captionDir: string | undefined; let captions: RenderedReframeCaption[] = []; if (input.plan.captions.burnIn) { const source = input.plan.captions.sourcePath; if (!source) throw new Error('captions.burnIn requires captions.sourcePath'); const content = await readFile(source, 'utf8'); const cues = parseTranscriptCues(content, source); const layouts = layoutReframeCaptions( cues, input.plan.target, input.plan.captions.profile, input.context.frameRate ?? 25, ); captionDir = await mkdtemp(`${tmpdir()}/vclaw-reframe-captions-`); captions = await renderReframeCaptionImages(layouts, input.plan.target, captionDir); } const filterComplex = buildReframeFilterComplex(input.plan, input.context, captions); const args = ['-hide_banner', '-loglevel', 'error', '-y', '-i', input.plan.sourcePath]; for (const caption of captions) { args.push( '-loop', '1', '-framerate', String(input.context.frameRate ?? 25), '-t', n(input.context.sourceDurationSeconds), '-i', caption.path, ); } args.push( '-filter_complex', filterComplex, '-map', '[vout]', '-map', '0:a:0?', '-t', n(input.context.sourceDurationSeconds), '-shortest', '-c:v', 'libx264', '-preset', 'medium', '-crf', '18', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-movflags', '+faststart', input.outputPath, ); try { await execFileAsync(ffmpegPath, args, { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 }); } finally { if (captionDir) await rm(captionDir, { recursive: true, force: true }); } const metadata = await probeMedia(input.outputPath); return { sourcePath: input.plan.sourcePath, outputPath: input.outputPath, shotCount: input.plan.shots.length, captionCount: captions.length, ...metadata, }; }