SPB Git

Repository Public

qa.ts 4.4 KB 53 lines typescript

1import type { TechnicalCheck } from "../lib/contracts";2import { analyse, probe } from "./ffmpeg";3import type { Cue } from "./captions";45export interface QaInput {6  master: string; expectedDuration: number; width: number; height: number;7  shots: { start: number; duration: number; narrationStart: number; narrationEnd: number; moderationOk: boolean }[];8  captions: Record<string, Cue[]>;9}1011const num = (s: string | undefined) => (s === undefined ? NaN : Number(s));1213/** Deterministic technical checks on the rendered master. */14export async function technicalChecks(i: QaInput): Promise<TechnicalCheck[]> {15  const out: TechnicalCheck[] = [];16  const add = (id: string, label: string, ok: boolean, severity: TechnicalCheck["severity"], detail: string) => out.push({ id, label, ok, severity: ok ? "info" : severity, detail });1718  const p = await probe(i.master);19  add("streams", "Video and audio streams", p.hasVideo && p.hasAudio, "fail", `video ${p.videoCodec ?? "none"}, audio ${p.audioCodec ?? "none"}`);20  add("duration", "Duration matches the plan", Math.abs(p.duration - i.expectedDuration) <= 0.35, "fail", `${p.duration.toFixed(2)} s rendered, ${i.expectedDuration.toFixed(2)} s planned`);21  add("resolution", "Resolution matches the target", p.width === i.width && p.height === i.height, "fail", `${p.width}×${p.height} (target ${i.width}×${i.height})`);22  add("fps", "Constant 24 fps", Math.abs(p.fps - 24) < 0.5, "warn", `${p.fps.toFixed(2)} fps`);2324  const v = await analyse(i.master, ["-vf", "blackdetect=d=0.5:pic_th=0.98,freezedetect=n=0.002:d=2", "-an"]);25  const blacks = [...v.matchAll(/black_start:([\d.]+) black_end:([\d.]+)/g)].map((m) => ({ s: num(m[1]), e: num(m[2]) }))26    .filter((b) => b.s > 0.3 && b.e < p.duration - 0.3 && b.e - b.s > 0.6);27  add("black", "No blank frames", blacks.length === 0, "fail", blacks.length ? blacks.map((b) => `${b.s.toFixed(1)}–${b.e.toFixed(1)} s`).join(", ") : "none detected");28  const freezes = [...v.matchAll(/freeze_duration: ([\d.]+)/g)].map((m) => num(m[1]));29  const frozen = freezes.reduce((a, b) => a + b, 0);30  add("freeze", "No frozen shots", frozen < Math.max(2.5, p.duration * 0.12), "warn", frozen ? `${frozen.toFixed(1)} s without motion (${freezes.length} segment${freezes.length > 1 ? "s" : ""})` : "none detected");3132  const a = await analyse(i.master, ["-af", "silencedetect=noise=-50dB:d=1.5,volumedetect,ebur128=peak=true", "-vn"]);33  const silence = [...a.matchAll(/silence_duration: ([\d.]+)/g)].reduce((n, m) => n + num(m[1]), 0);34  add("silence", "No unexpected silence", silence < p.duration * 0.25, "fail", `${silence.toFixed(1)} s below −50 dB`);35  const maxVol = num(a.match(/max_volume: (-?[\d.]+) dB/)?.[1]);36  add("clipping", "No clipped audio", !(maxVol >= -0.05), "fail", Number.isNaN(maxVol) ? "not measured" : `peak ${maxVol.toFixed(1)} dBFS`);37  const lufs = num([...a.matchAll(/I:\s+(-?[\d.]+) LUFS/g)].at(-1)?.[1]);38  add("loudness", "Loudness near −14 LUFS", !Number.isNaN(lufs) && Math.abs(lufs + 14) <= 2.5, "warn", Number.isNaN(lufs) ? "not measured" : `${lufs.toFixed(1)} LUFS integrated`);3940  const late = i.shots.map((s, n) => ({ n: n + 1, over: s.narrationEnd - (s.start + s.duration) })).filter((x) => x.over > 0.05);41  add("narration_fit", "Narration fits inside each shot", late.length === 0, "fail", late.length ? late.map((x) => `shot ${x.n} overruns by ${x.over.toFixed(2)} s`).join(", ") : "all lines end before their shot");42  for (const [lang, cues] of Object.entries(i.captions)) {43    const covered = i.shots.every((s) => s.narrationEnd <= s.narrationStart || cues.some((c) => c.start < s.narrationEnd && c.end > s.narrationStart));44    const inRange = cues.every((c) => c.end <= p.duration + 0.3 && c.start >= 0 && c.end > c.start);45    add(`captions_${lang}`, `Captions (${lang}) cover every line`, cues.length > 0 && covered && inRange, "fail", `${cues.length} cues`);46  }47  const flagged = i.shots.map((s, n) => (s.moderationOk ? 0 : n + 1)).filter(Boolean);48  add("moderation", "Provider moderation passed on every shot", flagged.length === 0, "fail", flagged.length ? `flagged: shot ${flagged.join(", ")}` : "all shots passed");49  return out;50}51export const technicalPassed = (checks: TechnicalCheck[]) => !checks.some((c) => !c.ok && c.severity === "fail");52export const technicalReport = (checks: TechnicalCheck[]) => checks.map((c) => `${c.ok ? "OK  " : c.severity.toUpperCase().padEnd(4)} ${c.label}: ${c.detail}`).join("\n");53
Files main
Menu

Theme