import { readFile, readdir } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { execFile } from 'node:child_process'; import { join } from 'node:path'; import { promisify } from 'node:util'; import { ensureProjectWorkspace } from './workspace.js'; const execFileAsync = promisify(execFile); export interface VideoMediaProbe { container?: string; durationSeconds?: number; sizeBytes?: number; videoCodec?: string; width?: number; height?: number; frameRate?: string; progressive?: boolean; videoBitrateMbps?: number; /** Duration of the video stream alone, seconds (per-stream; for A/V-sync QC). */ videoDurationSeconds?: number; audioPresent: boolean; audioCodec?: string; audioSampleRate?: number; audioChannels?: number; /** Duration of the audio stream alone, seconds (per-stream; for A/V-sync QC). */ audioDurationSeconds?: number; } /** The mp4s directly inside one directory, sorted. Missing dir -> empty. */ async function mp4sIn(dir: string): Promise { if (!existsSync(dir)) return []; const entries = await readdir(dir, { withFileTypes: true }); return entries .filter((entry) => entry.isFile() && entry.name.endsWith('.mp4')) .map((entry) => join(dir, entry.name)) .sort(); } /** * Locate the project's delivered master. * * Searched in precedence order, top level FIRST so any project that already * resolved keeps resolving to exactly the same file: * * final/ — the original location * final/videos/ — where the portal looks, and what the lanes document * final/videos/film/ — a wrapped film (ident/end-card around the body) * * The subdirectories were the gap: this read `final/` one level deep only, * while `preview-portal/discovery.ts` scans `final/{videos,images,audio}` and * the rap lane documents "masters in final/videos/". So a correctly delivered * film showed up fine in the preview and then failed `verify-final` with * "No final output found" — two commands disagreeing about where a deliverable * lives. Found on a finished short, 2026-08-06. */ async function resolveFromFinalDirectory(projectSlug: string, root: string): Promise { const workspace = await ensureProjectWorkspace(projectSlug, root); const finalDir = join(workspace.projectDir, 'final'); if (!existsSync(finalDir)) { return null; } for (const dir of [finalDir, join(finalDir, 'videos'), join(finalDir, 'videos', 'film')]) { const files = await mp4sIn(dir); const preferred = files.find((file) => file.endsWith('narrated-fixed.mp4')) ?? files[0]; if (preferred) return preferred; } return null; } async function resolveFromPublishReport(projectSlug: string, root: string): Promise { const workspace = await ensureProjectWorkspace(projectSlug, root); const publishReportPath = join(workspace.artifactsDir, 'publish-report.json'); if (!existsSync(publishReportPath)) { return null; } const publishReport = JSON.parse(await readFile(publishReportPath, 'utf-8')) as { finalOutputPath?: string; }; const finalOutputPath = publishReport.finalOutputPath?.trim(); if (!finalOutputPath) { return null; } if (!existsSync(finalOutputPath)) { throw new Error(`Publish report final output is missing: ${finalOutputPath}`); } return finalOutputPath; } export async function resolveProjectFinalPath(projectSlug: string, root: string): Promise { const resolvedFromFinalDir = await resolveFromFinalDirectory(projectSlug, root); if (resolvedFromFinalDir) { return resolvedFromFinalDir; } const resolvedFromPublishReport = await resolveFromPublishReport(projectSlug, root); if (resolvedFromPublishReport) { return resolvedFromPublishReport; } throw new Error(`No final output found for project ${projectSlug}. Expected project final/ mp4 or artifacts/publish-report.json.finalOutputPath.`); } export async function probeMedia( path: string, opts: { ffprobeBin?: string } = {}, ): Promise { // Same resolution order as assemble/ffmpeg.ts resolveFfprobeBin: explicit // option → VCLAW_FFPROBE_BIN → PATH `ffprobe` (previously hardcoded here). const bin = opts.ffprobeBin ?? process.env.VCLAW_FFPROBE_BIN ?? 'ffprobe'; const { stdout } = await execFileAsync(bin, [ '-v', 'error', '-show_streams', '-show_format', '-of', 'json', path, ], { encoding: 'utf-8' }); const payload = JSON.parse(stdout) as { streams?: Array<{ codec_type?: string; codec_name?: string; width?: number; height?: number; r_frame_rate?: string; field_order?: string; bit_rate?: string; sample_rate?: string; channels?: number; duration?: string; }>; format?: { format_name?: string; duration?: string; size?: string; bit_rate?: string; tags?: { major_brand?: string; }; }; }; const video = payload.streams?.find((stream) => stream.codec_type === 'video'); const audio = payload.streams?.find((stream) => stream.codec_type === 'audio'); const durationSeconds = payload.format?.duration ? Number(payload.format.duration) : undefined; const sizeBytes = payload.format?.size ? Number(payload.format.size) : undefined; const videoDurationSeconds = video?.duration ? Number(video.duration) : undefined; const audioDurationSeconds = audio?.duration ? Number(audio.duration) : undefined; const videoBitrate = video?.bit_rate ? Number(video.bit_rate) : undefined; const container = normalizeProbedContainer(payload.format?.format_name, payload.format?.tags?.major_brand); return { ...(container ? { container } : {}), ...(Number.isFinite(durationSeconds) ? { durationSeconds } : {}), ...(Number.isFinite(sizeBytes) ? { sizeBytes } : {}), ...(video?.codec_name ? { videoCodec: video.codec_name } : {}), ...(typeof video?.width === 'number' ? { width: video.width } : {}), ...(typeof video?.height === 'number' ? { height: video.height } : {}), ...(video?.r_frame_rate ? { frameRate: video.r_frame_rate } : {}), ...(video?.field_order ? { progressive: video.field_order === 'progressive' } : {}), ...(typeof videoBitrate === 'number' && Number.isFinite(videoBitrate) ? { videoBitrateMbps: videoBitrate / 1_000_000 } : {}), ...(Number.isFinite(videoDurationSeconds) ? { videoDurationSeconds } : {}), audioPresent: Boolean(audio), ...(audio?.codec_name ? { audioCodec: audio.codec_name } : {}), ...(audio?.sample_rate ? { audioSampleRate: Number(audio.sample_rate) } : {}), ...(typeof audio?.channels === 'number' ? { audioChannels: audio.channels } : {}), ...(Number.isFinite(audioDurationSeconds) ? { audioDurationSeconds } : {}), }; } function normalizeProbedContainer(formatName: string | undefined, majorBrand: string | undefined): string | undefined { const names = (formatName ?? '').toLowerCase().split(',').map((name) => name.trim()); if (names.includes('webm')) return 'webm'; if (names.includes('matroska')) return 'mkv'; if (names.includes('avi')) return 'avi'; if (names.includes('mov') || names.includes('mp4')) { return majorBrand?.trim().toLowerCase() === 'qt' ? 'mov' : 'mp4'; } return names.find(Boolean); }