// 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(() => {});
}
}