Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Acommon/lib/evidenceClip-server.test.ts | 240+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/evidenceClip-server.ts | 421+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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(() => {}); + } +}