// 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 `/.` + `.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 { PUBLISH_MAX_FILE_BYTES } from "./builtExport"; import { readJsonFile, 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 = PUBLISH_MAX_FILE_BYTES; // 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 = { video: ".mp4", audio: ".m4a" }; // The seconds a clip covers, in the record's clock. export type EvidenceSpan = { from: number; to: number }; // A span whose padding runs past the recording's end, cut at the end: the // file holds every second the citation names, and a clip simply stops where the // recording does. Only the end moves, and only for a span that starts inside // the recording; with no known duration the span is as given. (report-to-video // keeps the strict rule — its timeline needs every padded second.) export function clampSpanToDuration(span: EvidenceSpan, duration: number | null | undefined): EvidenceSpan { if (!Number.isFinite(duration) || !duration || duration <= 0) return span; if (span.to <= duration || span.from >= duration) return span; return { from: span.from, to: Number(duration.toFixed(3)) }; } // A record's duration (its metadata.info.json), or null when it is not recorded. export async function recordDuration(channelsDir: string, slug: string, id: string): Promise { const read = await readJsonFile(path.join(channelsDir, slug, "data", id, "metadata.info.json")); const d = read.ok ? Number((read.value as { duration?: unknown } | null)?.duration) : NaN; return Number.isFinite(d) && d > 0 ? d : null; } // The span a cited moment's clip is cut for: `evidenceSpan`, its end clamped to // the record's duration. Prepare and compose both use it, so they agree. export async function citedEvidenceSpan( channelsDir: string, slug: string, id: string, c: { start: number; end: number; pad?: CitationPad }, ): Promise { return clampSpanToDuration(evidenceSpan(c), await recordDuration(channelsDir, slug, id)); } // 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//…` 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 { 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 { 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 { 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 { 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 { try { const raw = JSON.parse(await readFile(file, "utf8")) as Partial; 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 { 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(() => {}); } }