import { mkdir, stat } from 'node:fs/promises'; import { resolveWorkspaceRootFromEnv } from './workspace-root.js'; import { dirname, join } from 'node:path'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { probeMedia, resolveProjectFinalPath } from './final-media.js'; import { runAssembleMediaQc, type AssembleMediaQcReport } from './assemble/media-qc.js'; import type { AssembleManifestEntry } from './assemble/types.js'; const execFileAsync = promisify(execFile); export interface VideoFinalVerificationReport { projectSlug?: string; sourcePath: string; durationSeconds?: number; sizeBytes?: number; videoCodec?: string; width?: number; height?: number; frameRate?: string; audioPresent: boolean; audioCodec?: string; framePath: string; framePaths: string[]; contactSheetPath: string; mediaQc: AssembleMediaQcReport; } async function extractFrame(sourcePath: string, timestampSeconds: number, framePath: string): Promise { await execFileAsync('ffmpeg', [ '-y', '-ss', String(Math.max(0, timestampSeconds)), '-i', sourcePath, '-frames:v', '1', '-update', '1', framePath, ], { encoding: 'utf-8' }); return framePath; } async function extractEvidence( sourcePath: string, outputDir: string, durationSeconds: number, frameRate: number, ): Promise<{ framePath: string; framePaths: string[]; contactSheetPath: string }> { await mkdir(outputDir, { recursive: true }); const framePaths = [ await extractFrame(sourcePath, 0, join(outputDir, 'post-check-first.jpg')), await extractFrame(sourcePath, durationSeconds / 2, join(outputDir, 'post-check-frame.jpg')), await extractFrame(sourcePath, Math.max(0, durationSeconds - 1 / frameRate), join(outputDir, 'post-check-last.jpg')), ]; const sampleCount = Math.max(1, Math.min(24, Math.ceil(durationSeconds))); const columns = Math.min(6, sampleCount); const rows = Math.ceil(sampleCount / columns); const sampleRate = durationSeconds > 0 ? sampleCount / durationSeconds : 1; const contactSheetPath = join(outputDir, 'post-check-contact-sheet.jpg'); await execFileAsync('ffmpeg', [ '-y', '-i', sourcePath, '-vf', `fps=${sampleRate},scale=240:-2,tile=${columns}x${rows}:padding=4:margin=4:color=black`, '-frames:v', '1', '-update', '1', contactSheetPath, ], { encoding: 'utf-8' }); return { framePath: framePaths[1] as string, framePaths, contactSheetPath }; } export async function verifyFinalOutput(options: { projectSlug?: string; filePath?: string; root?: string; outputDir?: string; }): Promise { const root = options.root ?? resolveWorkspaceRootFromEnv(); const sourcePath = options.filePath ?? (options.projectSlug ? await resolveProjectFinalPath(options.projectSlug, root) : undefined); if (!sourcePath) { throw new Error('verifyFinalOutput requires either projectSlug or filePath'); } const outputDir = options.outputDir ?? join(dirname(sourcePath), 'verification'); const metadata = await probeMedia(sourcePath); const frameRateParts = metadata.frameRate?.split('/').map(Number) ?? []; const frameRate = frameRateParts.length === 2 && frameRateParts[1] ? (frameRateParts[0] ?? 0) / frameRateParts[1] : Number(metadata.frameRate ?? 25); const evidence = await extractEvidence( sourcePath, outputDir, metadata.durationSeconds ?? 0, Number.isFinite(frameRate) && frameRate > 0 ? frameRate : 25, ); const file = await stat(sourcePath); const manifest: AssembleManifestEntry[] = [{ kind: 'final-video', path: sourcePath, durationMs: Math.round((metadata.durationSeconds ?? 0) * 1000), sizeBytes: file.size, generator: 'verify-final', }]; const mediaQc = await runAssembleMediaQc({ manifest, outputPath: sourcePath }); return { ...(options.projectSlug ? { projectSlug: options.projectSlug } : {}), sourcePath, ...metadata, ...evidence, mediaQc, }; }