import { probeMedia, type VideoMediaProbe } from '../final-media.js'; import type { AssembleManifestEntry } from './types.js'; export type AssembleMediaQcStatus = 'pass' | 'warning' | 'fail' | 'skipped'; export type AssembleMediaQcIssueCode = | 'probe-failed' | 'missing-audio' | 'nonstandard-audio-codec' | 'nonstandard-audio-sample-rate' | 'nonstandard-video-codec' | 'duration-drift' | 'av-duration-mismatch' | 'audio-peak-over-ceiling' | 'frozen-video' | 'black-video'; /** * True-peak ceiling in dBFS for a delivered master. Anything above this is * clipping — audible distortion on good speakers. * * Production-learned: muxing a commercially-mastered track (already sitting at * 0 dBFS) under a cut and letting AAC encode it produced a **+4.7 dBFS** master. * `ffprobe` reads container metadata only and saw nothing wrong; the whole QC * layer was blind to it, and it was caught by hand. Measuring costs one decode * pass over the master, which is cheap next to shipping a distorted deliverable. */ export const AUDIO_PEAK_CEILING_DBFS = -1.0; /** Highest `Peak level dB` astats reports across channels, or null if absent. */ export function parsePeakDbfs(ffmpegStderr: string): number | null { const peaks: number[] = []; for (const match of ffmpegStderr.matchAll(/Peak level dB:\s*(-?\d+(?:\.\d+)?|-?inf)/gi)) { const raw = match[1]; if (!raw) continue; if (/^-?inf$/i.test(raw)) { // Digital silence on this channel — not a peak, and never a ceiling breach. continue; } const value = Number(raw); if (Number.isFinite(value)) peaks.push(value); } return peaks.length > 0 ? Math.max(...peaks) : null; } /** * Longest run of identical frames tolerated in a delivered master, in seconds. * * Production-learned: a master built from a resumable driver played the SAME * clip twice because a re-render silently no-opped, and a second one held a * frozen frame where a segment was shorter than its window. Both files probed * perfectly — right codec, right duration, no drift — because a frozen picture * is a valid H.264 stream. The whole QC layer was blind to it and it was caught * by watching. */ export const FREEZE_TOLERANCE_SEC = 0.8; /** Longest run of near-black frames tolerated. Frame 0 is the thumbnail. */ export const BLACK_TOLERANCE_SEC = 0.4; /** * Seconds of frozen picture reported by ffmpeg's `freezedetect`, summed. * * `freezedetect` only emits `freeze_duration` when a freeze ENDS. A file that is * frozen through to EOF emits `freeze_start` and nothing else — so summing * durations alone scores a completely frozen master as zero, which is the exact * false pass this check exists to prevent. Pass `totalDurationSec` and an unclosed * run is counted to the end of the file. * * Both observed shapes: * lavfi.freezedetect.freeze_start: 0 * lavfi.freezedetect.freeze_duration: 2 <- only when it ends * lavfi.freezedetect.freeze_end: 2 */ export function parseFreezeSeconds(ffmpegStderr: string, totalDurationSec?: number): number { const starts: number[] = []; for (const match of ffmpegStderr.matchAll(/freeze_start[:=]\s*(\d+(?:\.\d+)?)/gi)) { const value = Number(match[1]); if (Number.isFinite(value)) starts.push(value); } const durations: number[] = []; for (const match of ffmpegStderr.matchAll(/freeze_duration[:=]\s*(\d+(?:\.\d+)?)/gi)) { const value = Number(match[1]); if (Number.isFinite(value)) durations.push(value); } let total = durations.reduce((sum, value) => sum + value, 0); // One more start than durations means the last run never closed. if (starts.length > durations.length && totalDurationSec !== undefined) { const openStart = starts[starts.length - 1]; if (openStart !== undefined && totalDurationSec > openStart) { total += totalDurationSec - openStart; } } return total; } /** Seconds of near-black picture reported by ffmpeg's `blackdetect`, summed. */ export function parseBlackSeconds(ffmpegStderr: string): number { let total = 0; for (const match of ffmpegStderr.matchAll(/black_duration[:=]\s*(\d+(?:\.\d+)?)/gi)) { const value = Number(match[1]); if (Number.isFinite(value)) total += value; } return total; } export interface AssembleMediaQcIssue { severity: 'warning' | 'error'; code: AssembleMediaQcIssueCode; scope: string; message: string; } export interface AssembleMediaQcClip { path: string; sceneIndex?: number; status: Exclude; durationMs?: number; probe?: VideoMediaProbe; issues: AssembleMediaQcIssue[]; } export interface AssembleMediaQcMaster { path: string; status: Exclude; durationMs?: number; expectedDurationMs?: number; driftMs?: number; probe?: VideoMediaProbe; issues: AssembleMediaQcIssue[]; } export interface AssembleMediaQcReport { status: AssembleMediaQcStatus; clips: AssembleMediaQcClip[]; master?: AssembleMediaQcMaster; issues: AssembleMediaQcIssue[]; } export interface RunAssembleMediaQcInput { manifest: AssembleManifestEntry[]; outputPath?: string; probe?: (path: string) => Promise; expectedVideoCodec?: string; expectedAudioCodec?: string; /** * Accepted audio sample rate(s). Defaults to BOTH standard delivery rates. * * This was an exact match on 44100, which flagged every 48kHz master as * `nonstandard-audio-sample-rate` — and 48kHz is the normal rate for video, * so the warning fired on correct files. A check that cries wolf is a check * that gets ignored. Pass a single number to pin one rate. */ expectedAudioSampleRate?: number | number[]; driftToleranceMs?: number; /** * Max allowed per-stream audio-vs-video duration delta (ms) before a clip is * flagged `av-duration-mismatch`. A demuxer `-c copy` concat of A/V-misaligned * segments accumulates audible drift (the root of the "echo"/overlapping- * narration bug). Default 120ms (a few frames). Pad audio==video per segment * (stitch's `padAudioToVideoDuration`) to keep clips aligned. */ avMismatchToleranceMs?: number; /** * Measure the master's true peak in dBFS. Injectable so unit tests never * spawn ffmpeg. Defaults to a single `astats` decode pass over the master * only — per-clip decoding would be far more expensive for no extra signal, * since clipping is introduced at the mux. */ measurePeakDbfs?: (path: string) => Promise; /** * Measure frozen and black picture in the master. Injectable so unit tests * never spawn ffmpeg. Defaults to one `freezedetect,blackdetect` pass over the * master — the two filters share a decode, so this costs one pass, not two. */ measureStillness?: ( path: string, totalDurationSec?: number, ) => Promise<{ freezeSec: number; blackSec: number }>; /** Overrides FREEZE_TOLERANCE_SEC. */ freezeToleranceSec?: number; /** Overrides BLACK_TOLERANCE_SEC. */ blackToleranceSec?: number; /** Overrides AUDIO_PEAK_CEILING_DBFS. */ audioPeakCeilingDbfs?: number; } /** Default stillness measurement: one pass carrying both detectors. */ async function defaultMeasureStillness( path: string, totalDurationSec?: number, ): Promise<{ freezeSec: number; blackSec: number }> { const { runFfmpeg } = await import('./ffmpeg.js'); const { stderr } = await runFfmpeg([ '-i', path, '-vf', `freezedetect=n=-60dB:d=${FREEZE_TOLERANCE_SEC},blackdetect=d=${BLACK_TOLERANCE_SEC}:pic_th=0.98`, '-map', '0:v:0', '-f', 'null', '-', ]); return { freezeSec: parseFreezeSeconds(stderr, totalDurationSec), blackSec: parseBlackSeconds(stderr), }; } /** Default peak measurement: one `astats` pass, stderr parsed for Peak level dB. */ async function defaultMeasurePeakDbfs(path: string): Promise { const { runFfmpeg } = await import('./ffmpeg.js'); const { stderr } = await runFfmpeg(['-i', path, '-af', 'astats', '-f', 'null', '-']); return parsePeakDbfs(stderr); } function statusForIssues(issues: AssembleMediaQcIssue[]): Exclude { if (issues.some((issue) => issue.severity === 'error')) return 'fail'; if (issues.length > 0) return 'warning'; return 'pass'; } function mergeStatus(statuses: AssembleMediaQcStatus[]): AssembleMediaQcStatus { if (statuses.includes('fail')) return 'fail'; if (statuses.includes('warning')) return 'warning'; if (statuses.includes('pass')) return 'pass'; return 'skipped'; } function roundedDurationMs(probe: VideoMediaProbe): number | undefined { return Number.isFinite(probe.durationSeconds) ? Math.round(Number(probe.durationSeconds) * 1000) : undefined; } function clipIssues(input: { scope: string; probe: VideoMediaProbe; expectedVideoCodec: string; expectedAudioCodec: string; expectedAudioSampleRate: number[]; avMismatchToleranceMs: number; }): AssembleMediaQcIssue[] { const issues: AssembleMediaQcIssue[] = []; if (!input.probe.audioPresent) { issues.push({ severity: 'error', code: 'missing-audio', scope: input.scope, message: `${input.scope} is missing an audio stream.`, }); } else { if (input.probe.audioCodec && input.probe.audioCodec !== input.expectedAudioCodec) { issues.push({ severity: 'warning', code: 'nonstandard-audio-codec', scope: input.scope, message: `${input.scope} audio codec is ${input.probe.audioCodec}; expected ${input.expectedAudioCodec}.`, }); } if ( input.probe.audioSampleRate !== undefined && !input.expectedAudioSampleRate.includes(input.probe.audioSampleRate) ) { issues.push({ severity: 'warning', code: 'nonstandard-audio-sample-rate', scope: input.scope, message: `${input.scope} audio sample rate is ${input.probe.audioSampleRate}Hz; expected ${input.expectedAudioSampleRate.join(' or ')}Hz.`, }); } } if (input.probe.videoCodec && input.probe.videoCodec !== input.expectedVideoCodec) { issues.push({ severity: 'warning', code: 'nonstandard-video-codec', scope: input.scope, message: `${input.scope} video codec is ${input.probe.videoCodec}; expected ${input.expectedVideoCodec}.`, }); } if ( input.probe.audioPresent && input.probe.videoDurationSeconds !== undefined && input.probe.audioDurationSeconds !== undefined ) { const videoMs = Math.round(input.probe.videoDurationSeconds * 1000); const audioMs = Math.round(input.probe.audioDurationSeconds * 1000); const deltaMs = Math.abs(videoMs - audioMs); if (deltaMs > input.avMismatchToleranceMs) { issues.push({ severity: 'warning', code: 'av-duration-mismatch', scope: input.scope, message: `${input.scope} audio (${audioMs}ms) and video (${videoMs}ms) durations differ by ${deltaMs}ms (tolerance ${input.avMismatchToleranceMs}ms); a demuxer -c copy concat of A/V-misaligned segments accumulates audible drift. Pad audio==video per segment (stitch padAudioToVideoDuration).`, }); } } return issues; } async function probeClip(input: { entry: AssembleManifestEntry; probe: (path: string) => Promise; expectedVideoCodec: string; expectedAudioCodec: string; expectedAudioSampleRate: number[]; avMismatchToleranceMs: number; }): Promise { const scope = input.entry.sceneIndex !== undefined ? `scene:${input.entry.sceneIndex}` : `clip:${input.entry.path}`; try { const mediaProbe = await input.probe(input.entry.path); const issues = clipIssues({ scope, probe: mediaProbe, expectedVideoCodec: input.expectedVideoCodec, expectedAudioCodec: input.expectedAudioCodec, expectedAudioSampleRate: input.expectedAudioSampleRate, avMismatchToleranceMs: input.avMismatchToleranceMs, }); const durationMs = roundedDurationMs(mediaProbe) ?? input.entry.durationMs; return { path: input.entry.path, ...(input.entry.sceneIndex !== undefined ? { sceneIndex: input.entry.sceneIndex } : {}), status: statusForIssues(issues), durationMs, probe: mediaProbe, issues, }; } catch (error) { const issues: AssembleMediaQcIssue[] = [{ severity: 'error', code: 'probe-failed', scope, message: `${scope} media probe failed: ${(error as Error).message}`, }]; return { path: input.entry.path, ...(input.entry.sceneIndex !== undefined ? { sceneIndex: input.entry.sceneIndex } : {}), status: 'fail', issues, }; } } /** Both standard delivery rates: 44.1kHz (audio-first) and 48kHz (video-first). */ export const STANDARD_AUDIO_SAMPLE_RATES = [44100, 48000]; function normaliseRates(value: number | number[] | undefined): number[] { if (value === undefined) return STANDARD_AUDIO_SAMPLE_RATES; return Array.isArray(value) ? value : [value]; } export async function runAssembleMediaQc( input: RunAssembleMediaQcInput, ): Promise { const probe = input.probe ?? probeMedia; const expectedVideoCodec = input.expectedVideoCodec ?? 'h264'; const expectedAudioCodec = input.expectedAudioCodec ?? 'aac'; const expectedAudioSampleRate = normaliseRates(input.expectedAudioSampleRate); const driftToleranceMs = input.driftToleranceMs ?? 500; const avMismatchToleranceMs = input.avMismatchToleranceMs ?? 120; const measurePeakDbfs = input.measurePeakDbfs ?? defaultMeasurePeakDbfs; const audioPeakCeilingDbfs = input.audioPeakCeilingDbfs ?? AUDIO_PEAK_CEILING_DBFS; const measureStillness = input.measureStillness ?? defaultMeasureStillness; const freezeToleranceSec = input.freezeToleranceSec ?? FREEZE_TOLERANCE_SEC; const blackToleranceSec = input.blackToleranceSec ?? BLACK_TOLERANCE_SEC; // Both body-segment kinds: presenter slide animations AND clip-stitch // rendered clips. rendered-clip was previously excluded, which left the // master expected-duration undefined in --from-clips mode — the QC was // blind to a short master assembled from a partial clip set (ep57). const clipEntries = input.manifest.filter( (entry) => entry.kind === 'slide-animation' || entry.kind === 'rendered-clip', ); const masterEntry = [...input.manifest].reverse().find((entry) => entry.kind === 'final-video'); if (clipEntries.length === 0 && !masterEntry && !input.outputPath) { return { status: 'skipped', clips: [], issues: [] }; } const clips = await Promise.all(clipEntries.map((entry) => probeClip({ entry, probe, expectedVideoCodec, expectedAudioCodec, expectedAudioSampleRate, avMismatchToleranceMs, }))); let master: AssembleMediaQcMaster | undefined; const masterPath = masterEntry?.path ?? input.outputPath; if (masterPath) { const scope = 'master'; try { const mediaProbe = await probe(masterPath); const issues = clipIssues({ scope, probe: mediaProbe, expectedVideoCodec, expectedAudioCodec, expectedAudioSampleRate, avMismatchToleranceMs, }); const durationMs = roundedDurationMs(mediaProbe) ?? masterEntry?.durationMs; const expectedDurationMs = clips.length > 0 ? clips.reduce((sum, clip) => sum + (clip.durationMs ?? 0), 0) : undefined; const driftMs = durationMs !== undefined && expectedDurationMs !== undefined ? Math.abs(durationMs - expectedDurationMs) : undefined; if (driftMs !== undefined && driftMs > driftToleranceMs) { issues.push({ severity: 'warning', code: 'duration-drift', scope, message: `Master duration drifts from scene sum by ${driftMs}ms; tolerance is ${driftToleranceMs}ms.`, }); } // Clipping check. Only meaningful when there IS audio, and skipped // silently when the measurement is unavailable (no ffmpeg in a sandbox) // rather than failing a master for an absent tool. if (mediaProbe.audioPresent) { try { const peakDbfs = await measurePeakDbfs(masterPath); if (peakDbfs !== null && peakDbfs > audioPeakCeilingDbfs) { issues.push({ severity: 'error', code: 'audio-peak-over-ceiling', scope, message: `Master audio peaks at ${peakDbfs.toFixed(1)} dBFS, above the ${audioPeakCeilingDbfs} dBFS ` + 'ceiling — this is clipping and will distort. Attenuate the bed before the limiter ' + '(e.g. volume=-8dB then alimiter=limit=0.7) and re-mux.', }); } } catch { // Measurement unavailable — leave the other checks intact. } } // Frozen or black picture. A held frame is a valid H.264 stream with the // right codec and the right duration, so every other check above passes // it; this is the only one that can see a master that stopped moving. // Skipped silently when ffmpeg is unavailable, like the peak check. try { const { freezeSec, blackSec } = await measureStillness( masterPath, durationMs !== undefined ? durationMs / 1000 : undefined, ); if (freezeSec > freezeToleranceSec) { issues.push({ severity: 'error', code: 'frozen-video', scope, message: `Master holds a frozen picture for ${freezeSec.toFixed(1)}s (tolerance ${freezeToleranceSec}s). ` + 'A segment is shorter than its window, or a re-render silently no-opped and the ' + 'previous frame was held. Check per-scene statuses, not the file count.', }); } if (blackSec > blackToleranceSec) { issues.push({ severity: 'error', code: 'black-video', scope, message: `Master holds near-black picture for ${blackSec.toFixed(1)}s (tolerance ${blackToleranceSec}s). ` + 'Frame 0 is the thumbnail, so a black head is a delivery defect as well as a gap.', }); } } catch { // Measurement unavailable — leave the other checks intact. } master = { path: masterPath, status: statusForIssues(issues), ...(durationMs !== undefined ? { durationMs } : {}), ...(expectedDurationMs !== undefined ? { expectedDurationMs } : {}), ...(driftMs !== undefined ? { driftMs } : {}), probe: mediaProbe, issues, }; } catch (error) { const issues: AssembleMediaQcIssue[] = [{ severity: 'error', code: 'probe-failed', scope, message: `Master media probe failed: ${(error as Error).message}`, }]; master = { path: masterPath, status: 'fail', issues, }; } } const issues = [ ...clips.flatMap((clip) => clip.issues), ...(master?.issues ?? []), ]; return { status: mergeStatus([...clips.map((clip) => clip.status), master?.status ?? 'skipped']), clips, ...(master ? { master } : {}), issues, }; }