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'; 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; expectedAudioSampleRate?: 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; } 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.probe.audioSampleRate !== input.expectedAudioSampleRate ) { 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}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, }; } } export async function runAssembleMediaQc( input: RunAssembleMediaQcInput, ): Promise { const probe = input.probe ?? probeMedia; const expectedVideoCodec = input.expectedVideoCodec ?? 'h264'; const expectedAudioCodec = input.expectedAudioCodec ?? 'aac'; const expectedAudioSampleRate = input.expectedAudioSampleRate ?? 44100; const driftToleranceMs = input.driftToleranceMs ?? 500; const avMismatchToleranceMs = input.avMismatchToleranceMs ?? 120; const clipEntries = input.manifest.filter((entry) => entry.kind === 'slide-animation'); 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.`, }); } 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, }; }