commit 9f06f2e1addde115449ab73e5735aa795797579a
parent b80edbeddd56875575592576441537a98f5cda9c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 5 Oct 2026 03:11:17 -0400
common: the evidence cutter (lib/evidenceClip-server.ts): a cited span's media from the corpus tiers, cut accurately to 720p or .m4a, cached by source identity + span + profile, refused over 24 MiB
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 661 insertions(+), 0 deletions(-)
diff --git a/common/lib/evidenceClip-server.test.ts b/common/lib/evidenceClip-server.test.ts
@@ -0,0 +1,240 @@
+// The evidence cutter: where a cited span's media is found, the span it cuts,
+// the cut itself and the cache in front of it.
+//
+// Real ffmpeg over tiny lavfi containers (a few frames at 160×90, a second of
+// sine), so a run costs little memory and a second or two.
+//
+// Run with: pnpm --filter yt-dlp-transcript-common exec tsx --test lib/evidenceClip-server.test.ts
+
+import { after, before, test } from "node:test";
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+import { copyFile, mkdir, mkdtemp, readdir, rm, symlink, utimes, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import {
+ evidenceCutArgs,
+ evidenceHash,
+ evidenceSpan,
+ prepareEvidenceClip,
+ probeMedia,
+ resolveEvidenceSource,
+ widerPad,
+} from "./evidenceClip-server";
+
+const SLUG = "demo-channel";
+const ID = "abc123";
+
+let ROOT = "";
+// A 6 s 160×90 container with sound, a 1 s 1440×810 one, and 6 s of sound.
+let SMALL = "";
+let WIDE = "";
+let SOUND = "";
+
+function ff(args: string[]): void {
+ execFileSync("ffmpeg", ["-nostdin", "-v", "error", "-y", ...args]);
+}
+
+before(async () => {
+ ROOT = await mkdtemp(path.join(tmpdir(), "evidence-clip-"));
+ const fx = path.join(ROOT, "fixtures");
+ await mkdir(fx, { recursive: true });
+ SMALL = path.join(fx, "small.mp4");
+ WIDE = path.join(fx, "wide.mp4");
+ SOUND = path.join(fx, "sound.m4a");
+ ff([
+ "-f", "lavfi", "-i", "testsrc=size=160x90:rate=10:duration=6",
+ "-f", "lavfi", "-i", "sine=frequency=440:duration=6",
+ "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest", SMALL,
+ ]);
+ ff([
+ "-f", "lavfi", "-i", "testsrc=size=1440x810:rate=5:duration=1",
+ "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", WIDE,
+ ]);
+ ff(["-f", "lavfi", "-i", "sine=frequency=220:duration=6", "-c:a", "aac", SOUND]);
+});
+
+after(() => rm(ROOT, { recursive: true, force: true }));
+
+// A fresh channels tree per test: `<root>/channels/<slug>/data/<id>/`.
+async function corpus(): Promise<{ channelsDir: string; videoDir: string; cacheDir: string; root: string }> {
+ const root = await mkdtemp(path.join(ROOT, "t-"));
+ const channelsDir = path.join(root, "channels");
+ const videoDir = path.join(channelsDir, SLUG, "data", ID);
+ await mkdir(videoDir, { recursive: true });
+ return { channelsDir, videoDir, cacheDir: path.join(root, "report-media"), root };
+}
+
+test("evidenceSpan: the moment's rounded seconds, widened by the pad, clamped at 0", () => {
+ assert.deepEqual(evidenceSpan({ start: 12.345, end: 20 }), { from: 12.35, to: 20 });
+ assert.deepEqual(evidenceSpan({ start: 12, end: 20, pad: { before: 5, after: 2.5 } }), { from: 7, to: 22.5 });
+ assert.deepEqual(evidenceSpan({ start: 3, end: 9, pad: { before: 5 } }), { from: 0, to: 9 });
+ // A negative pad is no pad.
+ assert.deepEqual(evidenceSpan({ start: 3, end: 9, pad: { before: -2, after: -1 } }), { from: 3, to: 9 });
+});
+
+test("widerPad: two citations of one moment get the wider context on each side", () => {
+ assert.equal(widerPad(undefined, undefined), undefined);
+ assert.deepEqual(widerPad({ before: 2 }, undefined), { before: 2 });
+ assert.deepEqual(widerPad({ before: 2, after: 1 }, { before: 1, after: 4 }), { before: 2, after: 4 });
+});
+
+test("tiers: a clip window, else the saved container, else nothing; the window wins when both hold the span", async () => {
+ const { channelsDir, videoDir, root } = await corpus();
+ const span = { from: 11, to: 13 };
+ const ask = (audio = false) => resolveEvidenceSource({ channelsDir, slug: SLUG, id: ID, span, audio });
+
+ assert.equal(await ask(), null);
+
+ // The saved-video store's pointer: the container is [0, its duration].
+ const store = path.join(root, "saved-videos", SLUG, ID);
+ await mkdir(store, { recursive: true });
+ await copyFile(SMALL, path.join(store, "source-media.mp4"));
+ await writeFile(path.join(videoDir, "saved-video.json"), JSON.stringify({ dir: store, file: "source-media.mp4" }));
+ // 6 s does not reach 11–13.
+ assert.equal(await ask(), null);
+ const early = await resolveEvidenceSource({ channelsDir, slug: SLUG, id: ID, span: { from: 1, to: 3 }, audio: false });
+ assert.equal(early?.kind, "saved-video");
+ assert.equal(early?.windowStart, 0);
+
+ // A window named for the seconds it holds.
+ await mkdir(path.join(videoDir, "clips"), { recursive: true });
+ await copyFile(SMALL, path.join(videoDir, "clips", "10.00-16.00.mp4"));
+ const win = await ask();
+ assert.equal(win?.kind, "corpus-window");
+ assert.equal(win?.windowStart, 10);
+ // Both hold 1–3 once a window does: the window wins.
+ await copyFile(SMALL, path.join(videoDir, "clips", "0.00-6.00.mp4"));
+ const both = await resolveEvidenceSource({ channelsDir, slug: SLUG, id: ID, span: { from: 1, to: 3 }, audio: false });
+ assert.equal(both?.kind, "corpus-window");
+});
+
+test("tiers: the sound is asked only when allowed, and never beats a picture", async () => {
+ const { channelsDir, videoDir } = await corpus();
+ const span = { from: 1, to: 3 };
+ await copyFile(SOUND, path.join(videoDir, "audio.m4a"));
+ assert.equal(await resolveEvidenceSource({ channelsDir, slug: SLUG, id: ID, span, audio: false }), null);
+ assert.equal((await resolveEvidenceSource({ channelsDir, slug: SLUG, id: ID, span, audio: true }))?.kind, "audio");
+ await mkdir(path.join(videoDir, "clips"), { recursive: true });
+ await copyFile(SMALL, path.join(videoDir, "clips", "0.00-6.00.mp4"));
+ assert.equal(
+ (await resolveEvidenceSource({ channelsDir, slug: SLUG, id: ID, span, audio: true }))?.kind,
+ "corpus-window",
+ );
+});
+
+test("tiers: a dangling link (an unmounted drive) is not there — never a throw — and the next tier answers", async () => {
+ const { channelsDir, videoDir, root } = await corpus();
+ const span = { from: 1, to: 3 };
+ await mkdir(path.join(videoDir, "clips"), { recursive: true });
+ await symlink(path.join(root, "gone", "0.00-6.00.mp4"), path.join(videoDir, "clips", "0.00-6.00.mp4"));
+ // The media tier's relative link into media/, itself pointing nowhere.
+ await symlink("../../media/abc123/source-media.mp4", path.join(videoDir, "source-media.mp4"));
+ assert.equal(await resolveEvidenceSource({ channelsDir, slug: SLUG, id: ID, span, audio: false }), null);
+ // A live relative link into media/ is followed.
+ const media = path.join(channelsDir, SLUG, "media", ID);
+ await mkdir(media, { recursive: true });
+ await copyFile(SMALL, path.join(media, "source-media.mp4"));
+ const hit = await resolveEvidenceSource({ channelsDir, slug: SLUG, id: ID, span, audio: false });
+ assert.equal(hit?.kind, "saved-video");
+ assert.equal(hit?.name, "source-media.mp4");
+});
+
+test("the cut: the span exactly, a small source keeps its size, metadata dropped", async () => {
+ const { channelsDir, videoDir, cacheDir } = await corpus();
+ await mkdir(path.join(videoDir, "clips"), { recursive: true });
+ await copyFile(SMALL, path.join(videoDir, "clips", "10.00-16.00.mp4"));
+ const r = await prepareEvidenceClip({
+ channelsDir, slug: SLUG, id: ID, kind: "video", span: { from: 11, to: 13.5 }, cacheDir,
+ });
+ assert.ok(r.ok, JSON.stringify(r));
+ assert.equal(r.cached, false);
+ assert.equal(r.media.kind, "video");
+ assert.match(r.media.file, /^[0-9a-f]{32}\.mp4$/);
+ assert.equal(r.media.width, 160);
+ assert.equal(r.media.height, 90);
+ assert.ok(Math.abs((r.media.durationSec ?? 0) - 2.5) < 0.15, `duration ${r.media.durationSec}`);
+ assert.match(r.media.sha256, /^[0-9a-f]{64}$/);
+ const probe = await probeMedia(path.join(cacheDir, r.media.file));
+ assert.equal(probe?.hasAudio, true);
+ // The clip and its sidecar, and no temp file left behind.
+ assert.deepEqual((await readdir(cacheDir)).sort(), [r.media.file, r.media.file.replace(/\.mp4$/, ".json")].sort());
+});
+
+test("the cut: a larger source is fitted inside 1280×720, sides even", async () => {
+ const { channelsDir, videoDir, cacheDir } = await corpus();
+ await mkdir(path.join(videoDir, "clips"), { recursive: true });
+ await copyFile(WIDE, path.join(videoDir, "clips", "0.00-1.00.mp4"));
+ const r = await prepareEvidenceClip({ channelsDir, slug: SLUG, id: ID, kind: "video", span: { from: 0, to: 1 }, cacheDir });
+ assert.ok(r.ok, JSON.stringify(r));
+ assert.equal(r.media.width, 1280);
+ assert.equal(r.media.height, 720);
+ const args = evidenceCutArgs("in.mp4", 1, 2, "video", "out");
+ assert.ok(args.includes("libx264") && args.includes("23") && args.includes("+faststart"));
+ assert.match(args[args.indexOf("-vf") + 1], /min\(1280,iw\).*min\(720,ih\).*force_divisible_by=2/);
+});
+
+test("audio: an audio citation is an .m4a with no picture; a video citation of a sound-only record too", async () => {
+ const { channelsDir, videoDir, cacheDir } = await corpus();
+ await copyFile(SOUND, path.join(videoDir, "audio.m4a"));
+ const span = { from: 1, to: 3 };
+ const a = await prepareEvidenceClip({ channelsDir, slug: SLUG, id: ID, kind: "audio", span, cacheDir });
+ assert.ok(a.ok, JSON.stringify(a));
+ assert.equal(a.media.kind, "audio");
+ assert.match(a.media.file, /\.m4a$/);
+ assert.equal(a.media.width, null);
+ assert.equal((await probeMedia(path.join(cacheDir, a.media.file)))?.hasVideo, false);
+
+ // A video citation does not reach the sound unless the record has no picture.
+ const v = await prepareEvidenceClip({ channelsDir, slug: SLUG, id: ID, kind: "video", span, cacheDir });
+ assert.equal(v.ok, false);
+ assert.equal(!v.ok && v.reason, "missing");
+ const p = await prepareEvidenceClip({ channelsDir, slug: SLUG, id: ID, kind: "video", span, cacheDir, audioOnlyRecord: true });
+ assert.ok(p.ok, JSON.stringify(p));
+ assert.equal(p.media.kind, "audio");
+ // The same source, span and kind: the same file.
+ assert.equal(p.media.file, a.media.file);
+});
+
+test("cache: a hit by source identity + span; a changed source or span is a new cut", async () => {
+ const { channelsDir, videoDir, cacheDir } = await corpus();
+ await mkdir(path.join(videoDir, "clips"), { recursive: true });
+ const win = path.join(videoDir, "clips", "0.00-6.00.mp4");
+ await copyFile(SMALL, win);
+ const span = { from: 1, to: 2 };
+ const first = await prepareEvidenceClip({ channelsDir, slug: SLUG, id: ID, kind: "video", span, cacheDir });
+ const again = await prepareEvidenceClip({ channelsDir, slug: SLUG, id: ID, kind: "video", span, cacheDir });
+ assert.ok(first.ok && again.ok);
+ assert.equal(again.cached, true);
+ assert.deepEqual(again.media, first.media);
+
+ const other = await prepareEvidenceClip({ channelsDir, slug: SLUG, id: ID, kind: "video", span: { from: 1, to: 2.5 }, cacheDir });
+ assert.ok(other.ok);
+ assert.equal(other.cached, false);
+ assert.notEqual(other.media.file, first.media.file);
+
+ // A re-fetched window (a new mtime) is a new identity.
+ await utimes(win, new Date(), new Date(Date.now() + 60_000));
+ const refetched = await prepareEvidenceClip({ channelsDir, slug: SLUG, id: ID, kind: "video", span, cacheDir });
+ assert.ok(refetched.ok);
+ assert.equal(refetched.cached, false);
+ assert.notEqual(refetched.media.file, first.media.file);
+
+ const id = { path: "/x/a.mp4", bytes: 10, mtimeMs: 5 };
+ assert.equal(evidenceHash(id, span, "video"), evidenceHash({ ...id }, { ...span }, "video"));
+ assert.notEqual(evidenceHash(id, span, "video"), evidenceHash(id, span, "audio"));
+ assert.notEqual(evidenceHash(id, span, "video"), evidenceHash({ ...id, bytes: 11 }, span, "video"));
+});
+
+test("size: a clip over the limit is refused, with its size, and nothing is kept", async () => {
+ const { channelsDir, videoDir, cacheDir } = await corpus();
+ await mkdir(path.join(videoDir, "clips"), { recursive: true });
+ await copyFile(SMALL, path.join(videoDir, "clips", "0.00-6.00.mp4"));
+ const r = await prepareEvidenceClip({
+ channelsDir, slug: SLUG, id: ID, kind: "video", span: { from: 0, to: 4 }, cacheDir, maxBytes: 1000,
+ });
+ assert.equal(r.ok, false);
+ assert.equal(!r.ok && r.reason, "too-big");
+ assert.ok(!r.ok && (r.bytes ?? 0) > 1000);
+ assert.deepEqual(await readdir(cacheDir), []);
+});
diff --git a/common/lib/evidenceClip-server.ts b/common/lib/evidenceClip-server.ts
@@ -0,0 +1,421 @@
+// AN EVIDENCE CLIP: the self-hosted cut of one cited span that a report site's
+// moment page plays — the span, a little context either side, and nothing
+// else (plans/report-sites.md, "Evidence media").
+//
+// Clips are cut ON THE HOST, before a site's build, because the build (a
+// docker container with read-only mounts and no ffmpeg) only copies them. So
+// this module is the whole of "cut": find the span's media on disk, cut it
+// once, keep it in a cache keyed by what it was cut from.
+//
+// WHERE THE MEDIA COMES FROM is ./evidenceClip.mjs — the corpus tiers umtool's
+// report-to-video build asks too (clip window, saved-video store, audio), so
+// the two cannot disagree about what is local. A tier hit through a dangling
+// link (an unmounted drive) is "not here", never a throw. Nothing here fetches:
+// a span with no media on disk is MISSING, and the editor's fetch-window (or
+// persist) is what fills it.
+//
+// THE CUT is accurate — the input is seeked and re-encoded, so the first frame
+// is the asked-for second, never the keyframe before it:
+// video fitted inside 1280×720 (never upscaled; a smaller source keeps its
+// size; both sides even), H.264 crf 23 + AAC 128k, faststart.
+// audio an `.m4a`: AAC 128k, faststart, no picture. Chosen over an mp4
+// with a still poster frame because the moment page draws the poster
+// itself (the record's thumbnail): a still encoded into every audio
+// clip would cost bytes for a picture the page already has. An
+// `audio` citation is always cut this way, and so is a `video`
+// citation whose only media on disk has no picture stream.
+// Container metadata is dropped (`-map_metadata -1`): a clip carries its
+// seconds, not its source file's tags.
+//
+// THE CACHE is `<dir>/<hash>.<ext>` + `<hash>.json`, the hash a function of the
+// source file's identity (path, size, mtime), the padded span, the output kind
+// and the profile. Same source, same span → the same file, never re-cut; a
+// re-fetched window or a re-persisted container is a new identity and a new
+// cut. The sidecar holds what the manifest needs (bytes, sha256, geometry,
+// duration), so a hit costs a stat and a small read, not a probe and a hash.
+//
+// THE SIZE LIMIT: a static host refuses a file over 25 MiB, so a clip over
+// EVIDENCE_MAX_BYTES (24 MiB) is refused — removed, and reported as too big
+// with its size. A 120 s span at 720p is well under it; one that is not is a
+// span to shorten.
+//
+// SERVER-ONLY (node:fs, ffmpeg).
+
+import { createHash } from "node:crypto";
+import { createReadStream } from "node:fs";
+import { mkdir, readFile, rename, rm, stat } from "node:fs/promises";
+import path from "node:path";
+import { execa } from "execa";
+import { tmpPathFor, writeJsonAtomic } from "./jsonFile-server";
+import { roundMomentSeconds } from "./citations/moments";
+import type { CitationPad } from "./citations/schema";
+import {
+ AUDIO_ONLY_PLATFORMS,
+ cutArgs,
+ ffprobeSource,
+ resolveCorpusSource,
+} from "./evidenceClip.mjs";
+
+// Bumped whenever the arguments below change what a clip looks like: it is in
+// every hash, so a new profile re-cuts rather than serving an old file.
+export const EVIDENCE_PROFILE = "evidence-v1";
+
+export const EVIDENCE_MAX_WIDTH = 1280;
+export const EVIDENCE_MAX_HEIGHT = 720;
+export const EVIDENCE_CRF = 23;
+export const EVIDENCE_AUDIO_BITRATE = "128k";
+
+// 24 MiB: a Pages file limit is 25 MiB, and a clip is published as one file.
+export const EVIDENCE_MAX_BYTES = 24 * 1024 * 1024;
+
+// A cut of a cached window is seconds of work; a whole saved container on a
+// platter seeks once. Generous, so only a wedged ffmpeg reaches it.
+const CUT_TIMEOUT_MS = 10 * 60_000;
+
+export type EvidenceKind = "video" | "audio";
+
+export const EVIDENCE_EXT: Record<EvidenceKind, string> = { video: ".mp4", audio: ".m4a" };
+
+// The seconds a clip covers, in the record's clock.
+export type EvidenceSpan = { from: number; to: number };
+
+// The span a citation's clip covers: its moment's (rounded) start and end,
+// widened by its pad, the start clamped at 0. Rounded to the millisecond so a
+// float's last digits never change a hash.
+export function evidenceSpan(c: { start: number; end: number; pad?: CitationPad }): EvidenceSpan {
+ const start = roundMomentSeconds(c.start);
+ const end = roundMomentSeconds(c.end);
+ const before = Math.max(0, c.pad?.before ?? 0);
+ const after = Math.max(0, c.pad?.after ?? 0);
+ return {
+ from: Number(Math.max(0, start - before).toFixed(3)),
+ to: Number((end + after).toFixed(3)),
+ };
+}
+
+// Two citations of one moment share its page and so its clip: the clip gets
+// the WIDER pad on each side, so neither citation loses the context it asked for.
+export function widerPad(a: CitationPad | undefined, b: CitationPad | undefined): CitationPad | undefined {
+ if (!a) return b;
+ if (!b) return a;
+ return {
+ before: Math.max(a.before ?? 0, b.before ?? 0),
+ after: Math.max(a.after ?? 0, b.after ?? 0),
+ };
+}
+
+export type EvidenceSourceKind = "corpus-window" | "saved-video" | "audio";
+
+export type EvidenceSource = {
+ kind: EvidenceSourceKind;
+ // The file as the tier names it (a `data/<id>/…` path may be a link).
+ path: string;
+ name: string;
+ // The record seconds the file holds.
+ windowStart: number;
+ windowEnd: number;
+};
+
+// The corpus file holding [from, to] of a record, or null. `audio` admits the
+// sound-only tier: always for an audio citation, and for a video citation of a
+// record whose platform has no picture.
+export async function resolveEvidenceSource(opts: {
+ channelsDir: string;
+ slug: string;
+ id: string;
+ span: EvidenceSpan;
+ audio: boolean;
+ ffprobeBin?: string;
+}): Promise<EvidenceSource | null> {
+ const probe = (file: string) => ffprobeSource(file, opts.ffprobeBin);
+ const hit = await resolveCorpusSource(
+ { video: opts.id, slug: opts.slug, from: opts.span.from, to: opts.span.to },
+ { channelsDir: opts.channelsDir, probe, audio: opts.audio },
+ );
+ if (!hit) return null;
+ return {
+ kind: hit.kind as EvidenceSourceKind,
+ path: hit.path,
+ name: hit.name,
+ windowStart: hit.windowStart,
+ windowEnd: hit.windowEnd,
+ };
+}
+
+// Whether a channel's records have no picture (a feed of episodes).
+export function isAudioOnlyPlatform(platform: string | null | undefined): boolean {
+ return AUDIO_ONLY_PLATFORMS.has(String(platform ?? "").toLowerCase());
+}
+
+export type SourceIdentity = { path: string; bytes: number; mtimeMs: number };
+
+// The source file's identity, through its links; null when it is not there.
+export async function sourceIdentity(file: string): Promise<SourceIdentity | null> {
+ try {
+ const st = await stat(file);
+ if (!st.isFile()) return null;
+ return { path: file, bytes: st.size, mtimeMs: Math.round(st.mtimeMs) };
+ } catch {
+ return null;
+ }
+}
+
+// The cache key: source identity + span + output kind + profile.
+export function evidenceHash(source: SourceIdentity, span: EvidenceSpan, kind: EvidenceKind): string {
+ const key = JSON.stringify([
+ EVIDENCE_PROFILE,
+ kind,
+ source.path,
+ source.bytes,
+ source.mtimeMs,
+ span.from.toFixed(3),
+ span.to.toFixed(3),
+ ]);
+ return createHash("sha256").update(key).digest("hex").slice(0, 32);
+}
+
+// The video filter: fit inside the box, never upscale, even sides.
+const FIT_FILTER =
+ `scale='min(${EVIDENCE_MAX_WIDTH},iw)':'min(${EVIDENCE_MAX_HEIGHT},ih)'` +
+ ":force_original_aspect_ratio=decrease:force_divisible_by=2,format=yuv420p";
+
+// The ffmpeg arguments that cut [a, b] seconds INTO `file` as `kind`, writing
+// `out` (whose name need not carry an extension: the muxer is named).
+export function evidenceCutArgs(file: string, a: number, b: number, kind: EvidenceKind, out: string): string[] {
+ const common = ["-map_metadata", "-1", "-sn", "-dn"];
+ const audio = ["-c:a", "aac", "-b:a", EVIDENCE_AUDIO_BITRATE, "-ac", "2"];
+ const tail = ["-movflags", "+faststart", "-f", "mp4", out];
+ const head = ["-nostdin", "-v", "error", "-y", ...cutArgs(file, a, b)];
+ if (kind === "audio") {
+ return [...head, "-map", "0:a:0", "-vn", ...audio, ...common, ...tail];
+ }
+ return [
+ ...head,
+ "-map", "0:v:0",
+ "-map", "0:a:0?",
+ "-vf", FIT_FILTER,
+ "-c:v", "libx264", "-preset", "medium", "-profile:v", "high", "-crf", String(EVIDENCE_CRF),
+ ...audio,
+ ...common,
+ ...tail,
+ ];
+}
+
+export type MediaProbe = {
+ durationSec: number | null;
+ width: number | null;
+ height: number | null;
+ hasVideo: boolean;
+ hasAudio: boolean;
+};
+
+// One ffprobe: the container's duration and its first picture's size, and
+// which kinds of stream it has. Null when ffprobe cannot read it. An attached
+// cover picture (`disposition.attached_pic`, an mp3's album art) is not a
+// picture stream.
+export async function probeMedia(file: string, ffprobeBin = "ffprobe"): Promise<MediaProbe | null> {
+ const r = await execa(
+ ffprobeBin,
+ [
+ "-v", "error",
+ "-show_entries", "stream=codec_type,width,height:stream_disposition=attached_pic:format=duration",
+ "-of", "json",
+ file,
+ ],
+ { reject: false },
+ );
+ if (r.exitCode !== 0) return null;
+ let doc: {
+ streams?: { codec_type?: string; width?: number; height?: number; disposition?: { attached_pic?: number } }[];
+ format?: { duration?: string };
+ };
+ try {
+ doc = JSON.parse(String(r.stdout));
+ } catch {
+ return null;
+ }
+ const streams = doc.streams ?? [];
+ const video = streams.find((s) => s.codec_type === "video" && !s.disposition?.attached_pic);
+ const duration = Number(doc.format?.duration);
+ return {
+ durationSec: Number.isFinite(duration) && duration > 0 ? Number(duration.toFixed(3)) : null,
+ width: Number.isInteger(video?.width) ? (video!.width as number) : null,
+ height: Number.isInteger(video?.height) ? (video!.height as number) : null,
+ hasVideo: video !== undefined,
+ hasAudio: streams.some((s) => s.codec_type === "audio"),
+ };
+}
+
+export async function sha256File(file: string): Promise<string> {
+ const hash = createHash("sha256");
+ for await (const chunk of createReadStream(file)) hash.update(chunk as Buffer);
+ return hash.digest("hex");
+}
+
+// One prepared clip, as the manifest lists it. `file` is relative to the cache
+// directory.
+export type EvidenceMedia = {
+ kind: EvidenceKind;
+ file: string;
+ bytes: number;
+ sha256: string;
+ width: number | null;
+ height: number | null;
+ durationSec: number | null;
+};
+
+type Sidecar = EvidenceMedia & {
+ profile: string;
+ span: EvidenceSpan;
+ source: { kind: EvidenceSourceKind; name: string };
+};
+
+export type EvidenceClipResult =
+ | { ok: true; media: EvidenceMedia; cached: boolean; source: EvidenceSource }
+ | {
+ ok: false;
+ reason: "missing" | "no-audio" | "too-big" | "cut-failed";
+ message: string;
+ bytes?: number;
+ };
+
+async function readSidecar(file: string): Promise<Sidecar | null> {
+ try {
+ const raw = JSON.parse(await readFile(file, "utf8")) as Partial<Sidecar>;
+ if (raw.profile !== EVIDENCE_PROFILE || typeof raw.file !== "string") return null;
+ if (typeof raw.bytes !== "number" || typeof raw.sha256 !== "string") return null;
+ return raw as Sidecar;
+ } catch {
+ return null;
+ }
+}
+
+export function sidecarPathFor(cacheDir: string, hash: string): string {
+ return path.join(cacheDir, `${hash}.json`);
+}
+
+// Find, cut and cache the clip of one span of one record.
+//
+// `kind` is what the citation asks for; the clip is cut as audio when the
+// citation is audio or when the source on disk has no picture. The result
+// names the cache file (relative to `cacheDir`) — or why there is none: no
+// media on disk (`missing`), a source with no sound to cut for an audio clip
+// (`no-audio`), a clip over the limit (`too-big`, with its size), or an
+// ffmpeg failure (`cut-failed`).
+export async function prepareEvidenceClip(opts: {
+ channelsDir: string;
+ slug: string;
+ id: string;
+ kind: EvidenceKind;
+ span: EvidenceSpan;
+ cacheDir: string;
+ // The record's platform has no picture: a video citation may be served by
+ // its sound.
+ audioOnlyRecord?: boolean;
+ ffmpegBin?: string;
+ ffprobeBin?: string;
+ maxBytes?: number;
+ signal?: AbortSignal;
+}): Promise<EvidenceClipResult> {
+ const ffmpegBin = opts.ffmpegBin ?? "ffmpeg";
+ const ffprobeBin = opts.ffprobeBin ?? "ffprobe";
+ const maxBytes = opts.maxBytes ?? EVIDENCE_MAX_BYTES;
+ const { span } = opts;
+
+ const source = await resolveEvidenceSource({
+ channelsDir: opts.channelsDir,
+ slug: opts.slug,
+ id: opts.id,
+ span,
+ audio: opts.kind === "audio" || opts.audioOnlyRecord === true,
+ ffprobeBin,
+ });
+ const identity = source ? await sourceIdentity(source.path) : null;
+ if (!source || !identity) {
+ return {
+ ok: false,
+ reason: "missing",
+ message: `no media on disk holds ${span.from.toFixed(2)}–${span.to.toFixed(2)} s (no clip window, saved video${opts.kind === "audio" || opts.audioOnlyRecord ? " or audio" : ""} covers it)`,
+ };
+ }
+
+ // The output kind decides the hash, and a video citation over a sound-only
+ // source is an audio clip — so the source is probed first. An audio-tier
+ // source is sound by definition and is not probed.
+ let outKind: EvidenceKind = opts.kind;
+ if (outKind === "video") {
+ if (source.kind === "audio") outKind = "audio";
+ else {
+ const p = await probeMedia(source.path, ffprobeBin);
+ if (p && !p.hasVideo) outKind = "audio";
+ }
+ }
+
+ const hash = evidenceHash(identity, span, outKind);
+ const name = `${hash}${EVIDENCE_EXT[outKind]}`;
+ const out = path.join(opts.cacheDir, name);
+ const sidecarFile = sidecarPathFor(opts.cacheDir, hash);
+
+ // A hit: the sidecar describes a file of exactly its size.
+ const cached = await readSidecar(sidecarFile);
+ if (cached && cached.file === name) {
+ const st = await stat(out).catch(() => null);
+ if (st?.isFile() && st.size === cached.bytes) {
+ const { kind, file, bytes, sha256, width, height, durationSec } = cached;
+ return { ok: true, cached: true, source, media: { kind, file, bytes, sha256, width, height, durationSec } };
+ }
+ }
+
+ const a = Math.max(0, span.from - source.windowStart);
+ const b = Math.min(span.to, source.windowEnd) - source.windowStart;
+ await mkdir(opts.cacheDir, { recursive: true });
+ const tmp = tmpPathFor(out);
+ try {
+ const r = await execa(ffmpegBin, evidenceCutArgs(source.path, a, b, outKind, tmp), {
+ reject: false,
+ timeout: CUT_TIMEOUT_MS,
+ cancelSignal: opts.signal,
+ });
+ if (r.exitCode !== 0) {
+ const err = String(r.stderr ?? "").trim().split("\n").slice(-3).join(" | ");
+ if (outKind === "audio" && /matches no streams|does not contain any stream/i.test(err)) {
+ return { ok: false, reason: "no-audio", message: `${source.name} has no sound to cut` };
+ }
+ return {
+ ok: false,
+ reason: "cut-failed",
+ message: `ffmpeg failed cutting ${source.name}${err ? `: ${err}` : ` (exit ${r.exitCode ?? "?"})`}`,
+ };
+ }
+ const bytes = (await stat(tmp)).size;
+ if (bytes > maxBytes) {
+ return {
+ ok: false,
+ reason: "too-big",
+ bytes,
+ message: `the clip is ${(bytes / 1024 / 1024).toFixed(1)} MiB, over the ${(maxBytes / 1024 / 1024).toFixed(0)} MiB limit — shorten the span or its pad`,
+ };
+ }
+ const probe = await probeMedia(tmp, ffprobeBin);
+ const media: EvidenceMedia = {
+ kind: outKind,
+ file: name,
+ bytes,
+ sha256: await sha256File(tmp),
+ width: outKind === "video" ? (probe?.width ?? null) : null,
+ height: outKind === "video" ? (probe?.height ?? null) : null,
+ durationSec: probe?.durationSec ?? null,
+ };
+ await rename(tmp, out);
+ const sidecar: Sidecar = {
+ ...media,
+ profile: EVIDENCE_PROFILE,
+ span,
+ source: { kind: source.kind, name: source.name },
+ };
+ await writeJsonAtomic(sidecarFile, sidecar);
+ return { ok: true, cached: false, source, media };
+ } finally {
+ await rm(tmp, { force: true }).catch(() => {});
+ }
+}