import { mkdir, readFile } from 'node:fs/promises'; import { resolveWorkspaceRootFromEnv } from './workspace-root.js'; import { execFile } from 'node:child_process'; import { basename, dirname, extname, join } from 'node:path'; import { promisify } from 'node:util'; import { probeMedia, resolveProjectFinalPath } from './final-media.js'; import { VclawError } from './errors.js'; import { executeReframe } from './reframe/index.js'; import { normalizeReframePlan } from './reframe/plan.js'; import type { ReframeCaptionProfile, ReframePlan } from './reframe/types.js'; import type { ReframePlanQcReport } from './reframe/qc.js'; import type { AssembleMediaQcReport } from './assemble/media-qc.js'; import { resolveCapableFfmpeg, resolveFfmpegBin, ffmpegBinSupportsFilters, } from './assemble/ffmpeg.js'; const execFileAsync = promisify(execFile); export type ProjectOrFileOptions = { projectSlug?: string; filePath?: string; root?: string; }; export interface VideoVariantResult { projectSlug?: string; variant: 'vertical' | 'square' | 'loop'; sourcePath: string; outputPath: string; durationSeconds?: number; sizeBytes?: number; videoCodec?: string; width?: number; height?: number; frameRate?: string; audioPresent: boolean; audioCodec?: string; reframeMode?: 'shot-plan' | 'quick-center-crop'; warnings?: string[]; reframe?: { plan: ReframePlan; planQc: ReframePlanQcReport; mediaQc: AssembleMediaQcReport; qcReceiptPath: string; }; } export interface VideoThumbnailResult { projectSlug?: string; sourcePath: string; outputPath: string; timestampSeconds: number; text?: string; overlayApplied: boolean; } export interface VideoSubtitleBurnResult { projectSlug?: string; sourcePath: string; subtitlePath: string; outputPath: string; burnedIn: boolean; durationSeconds?: number; sizeBytes?: number; videoCodec?: string; width?: number; height?: number; frameRate?: string; audioPresent: boolean; audioCodec?: string; } function buildDefaultOutputPath(sourcePath: string, suffix: string, extension = extname(sourcePath)): string { return join(dirname(sourcePath), `${basename(sourcePath, extname(sourcePath))}-${suffix}${extension}`); } export async function resolvePostProductionSourcePath(options: ProjectOrFileOptions): Promise { const root = options.root ?? resolveWorkspaceRootFromEnv(); return options.filePath ?? (options.projectSlug ? await resolveProjectFinalPath(options.projectSlug, root) : '') ?? ''; } function assertSourcePath(path: string): string { if (!path) { throw new Error('post-production requires either projectSlug or filePath'); } return path; } function escapeDrawtextText(value: string): string { return value .replace(/\\/g, '\\\\') .replace(/:/g, '\\:') .replace(/'/g, "\\'") .replace(/%/g, '\\%') .replace(/\[/g, '\\[') .replace(/\]/g, '\\]') .replace(/,/g, '\\,'); } function escapeSubtitleFilterPath(value: string): string { return value .replace(/\\/g, '\\\\') .replace(/:/g, '\\:') .replace(/'/g, "\\'"); } /** * Does the ffmpeg we would actually run provide `filterName`? * * Previously this probed the literal `ffmpeg` on PATH while the exec used a * different binary, so it could answer about the wrong build in both directions. * It now probes whatever `resolveFfmpegBin` resolves to. */ export async function ffmpegSupportsFilter(filterName: string): Promise { return ffmpegBinSupportsFilters(resolveFfmpegBin(), [filterName]); } export async function makeVideoVariant(options: ProjectOrFileOptions & { variant: 'vertical' | 'square' | 'loop'; outputPath?: string; reframePlanPath?: string; quickCenterCrop?: boolean; captionsPath?: string; captionProfile?: ReframeCaptionProfile; strict?: boolean; }): Promise { const sourcePath = assertSourcePath(await resolvePostProductionSourcePath(options)); const outputPath = options.outputPath ?? buildDefaultOutputPath(sourcePath, options.variant); await mkdir(dirname(outputPath), { recursive: true }); if (options.variant === 'vertical') { if (options.strict && !options.reframePlanPath && !options.quickCenterCrop) { throw new VclawError( 'invalid_flag_value', 'Strict landscape-to-portrait export requires --reframe-plan or explicit --quick-center-crop.', ); } if (options.reframePlanPath) { const sourceMetadata = await probeMedia(sourcePath); if (!sourceMetadata.durationSeconds || !sourceMetadata.width || !sourceMetadata.height) { throw new VclawError('invalid_video_format', 'Source duration and dimensions are required for shot-planned reframing.'); } const frameRateParts = sourceMetadata.frameRate?.split('/').map(Number) ?? []; const frameRate = frameRateParts.length === 2 && frameRateParts[1] ? (frameRateParts[0] ?? 0) / frameRateParts[1] : Number(sourceMetadata.frameRate ?? 25); let rawPlan: Record; try { rawPlan = JSON.parse(await readFile(options.reframePlanPath, 'utf8')) as Record; } catch (error) { throw new VclawError( 'invalid_flag_value', `Could not read reframe plan ${options.reframePlanPath}: ${(error as Error).message}`, ); } rawPlan = { ...rawPlan, sourcePath }; if (options.captionsPath || options.captionProfile) { const existingCaptions = rawPlan.captions && typeof rawPlan.captions === 'object' ? rawPlan.captions as Record : {}; rawPlan.captions = { ...existingCaptions, ...(options.captionsPath ? { sourcePath: options.captionsPath, burnIn: true } : {}), ...(options.captionProfile ? { profile: options.captionProfile } : {}), }; } const context = { sourceDurationSeconds: sourceMetadata.durationSeconds, sourceWidth: sourceMetadata.width, sourceHeight: sourceMetadata.height, frameRate: Number.isFinite(frameRate) && frameRate > 0 ? frameRate : 25, }; let plan: ReframePlan; try { plan = normalizeReframePlan(rawPlan, context); } catch (error) { throw new VclawError('invalid_flag_value', `Invalid reframe plan: ${(error as Error).message}`); } try { const result = await executeReframe({ plan, context, outputPath, strict: options.strict ?? false }); return { ...(options.projectSlug ? { projectSlug: options.projectSlug } : {}), variant: options.variant, ...result.render, reframeMode: 'shot-plan', reframe: { plan, planQc: result.planQc, mediaQc: result.mediaQc, qcReceiptPath: result.qcReceiptPath }, }; } catch (error) { if (error instanceof VclawError) throw error; throw new VclawError('execution_blocked_by_readiness', `Reframe QC failed: ${(error as Error).message}`); } } await execFileAsync(resolveFfmpegBin(), [ '-y', '-i', sourcePath, '-vf', 'scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920', '-c:v', 'libx264', '-preset', 'medium', '-crf', '18', '-c:a', 'aac', '-b:a', '192k', outputPath, ], { encoding: 'utf-8' }); } else if (options.variant === 'square') { await execFileAsync(resolveFfmpegBin(), [ '-y', '-i', sourcePath, '-vf', 'scale=1080:1080:force_original_aspect_ratio=increase,crop=1080:1080', '-c:v', 'libx264', '-preset', 'medium', '-crf', '18', '-c:a', 'aac', '-b:a', '192k', outputPath, ], { encoding: 'utf-8' }); } else { const sourceMetadata = await probeMedia(sourcePath); const filterComplex = sourceMetadata.audioPresent ? '[0:v]split[vf][vr];[vr]reverse[rev];[vf][rev]concat=n=2:v=1:a=0[vout];[0:a]asplit[af][ar];[ar]areverse[arev];[af][arev]concat=n=2:v=0:a=1[aout]' : '[0:v]split[vf][vr];[vr]reverse[rev];[vf][rev]concat=n=2:v=1:a=0[vout]'; const args = [ '-y', '-i', sourcePath, '-filter_complex', filterComplex, '-map', '[vout]', ]; if (sourceMetadata.audioPresent) { args.push('-map', '[aout]', '-c:a', 'aac', '-b:a', '192k'); } args.push('-c:v', 'libx264', '-preset', 'medium', '-crf', '18', outputPath); await execFileAsync(resolveFfmpegBin(), args, { encoding: 'utf-8' }); } const metadata = await probeMedia(outputPath); return { ...(options.projectSlug ? { projectSlug: options.projectSlug } : {}), variant: options.variant, sourcePath, outputPath, ...metadata, ...(options.variant === 'vertical' ? { reframeMode: 'quick-center-crop' as const, ...(!options.quickCenterCrop ? { warnings: ['reframe_plan_missing: using legacy blind centre crop; pass --reframe-plan for quality output.'], } : {}), } : {}), }; } export async function createVideoThumbnail(options: ProjectOrFileOptions & { outputPath?: string; text?: string; }): Promise { const sourcePath = assertSourcePath(await resolvePostProductionSourcePath(options)); const outputPath = options.outputPath ?? buildDefaultOutputPath(sourcePath, 'thumbnail', '.jpg'); await mkdir(dirname(outputPath), { recursive: true }); const metadata = await probeMedia(sourcePath); const timestampSeconds = Math.max(0, (metadata.durationSeconds ?? 0) / 2); const text = options.text?.trim() ? options.text.trim() : undefined; if (!text) { await execFileAsync(resolveFfmpegBin(), [ '-y', '-ss', String(timestampSeconds), '-i', sourcePath, '-frames:v', '1', '-update', '1', outputPath, ], { encoding: 'utf-8' }); return { ...(options.projectSlug ? { projectSlug: options.projectSlug } : {}), sourcePath, outputPath, timestampSeconds, overlayApplied: false, }; } // Best-effort: find a binary with drawtext (PATH ffmpeg is slim and lacks it). // Unlike the subtitle burn this is NOT fatal — the caller falls back to a plain // frame grab below, which is a reasonable thumbnail, just without the caption. const drawtextFfmpeg = await resolveCapableFfmpeg(['drawtext']).catch(() => null); if (drawtextFfmpeg) { try { await execFileAsync(drawtextFfmpeg.bin, [ '-y', '-ss', String(timestampSeconds), '-i', sourcePath, '-vf', `drawbox=x=0:y=ih-180:w=iw:h=180:color=black@0.45:t=fill,drawtext=text='${escapeDrawtextText(text)}':x=60:y=h-120:fontsize=56:fontcolor=white`, '-frames:v', '1', '-update', '1', outputPath, ], { encoding: 'utf-8' }); return { ...(options.projectSlug ? { projectSlug: options.projectSlug } : {}), sourcePath, outputPath, timestampSeconds, text, overlayApplied: true, }; } catch { // Fall back to a plain frame when drawtext is unavailable at runtime. } } await execFileAsync(resolveFfmpegBin(), [ '-y', '-ss', String(timestampSeconds), '-i', sourcePath, '-frames:v', '1', '-update', '1', outputPath, ], { encoding: 'utf-8' }); return { ...(options.projectSlug ? { projectSlug: options.projectSlug } : {}), sourcePath, outputPath, timestampSeconds, text, overlayApplied: false, }; } export async function burnVideoSubtitles(options: ProjectOrFileOptions & { subtitlePath: string; outputPath?: string; }): Promise { const sourcePath = assertSourcePath(await resolvePostProductionSourcePath(options)); const subtitlePath = options.subtitlePath; const outputPath = options.outputPath ?? buildDefaultOutputPath(sourcePath, 'subtitled'); await mkdir(dirname(outputPath), { recursive: true }); // A burn was explicitly requested. Auto-attach an ffmpeg that can do it (the // Homebrew PATH build is slim and has no libass); if none exists, FAIL rather // than silently soft-muxing a `mov_text` track — that ships a video whose words // are not in the pixels, with exit 0 and no warning. const { bin: ffmpegBin, source } = await resolveCapableFfmpeg(['subtitles']); if (source === 'fallback') { process.stderr.write( `[burn-subtitles] PATH ffmpeg lacks the 'subtitles' filter; using ${ffmpegBin}\n`, ); } await execFileAsync(ffmpegBin, [ '-y', '-i', sourcePath, '-vf', `subtitles='${escapeSubtitleFilterPath(subtitlePath)}'`, '-c:v', 'libx264', '-preset', 'medium', '-crf', '18', '-c:a', 'aac', '-b:a', '192k', outputPath, ], { encoding: 'utf-8' }); const metadata = await probeMedia(outputPath); return { ...(options.projectSlug ? { projectSlug: options.projectSlug } : {}), sourcePath, subtitlePath, outputPath, burnedIn: true, ...metadata, }; }