commit 6e391d13390333bdfae1a52c0baa8a132cb0516f
parent 24caf1ef46902b2810e47859060f7072f1c9b055
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 18 Sep 2026 18:02:03 -0400
report-to-video: an `image` entry, for the receipts a clip cannot say out loud
A still is framed like a clip (the picture area is the frame less the header,
less the footer, less the rail column, so dropping one between two clips does
not move the letterbox) and encoded like a card (still video plus silent
stereo, same fps/setsar/encode args, so concat and xfade cannot tell the three
kinds apart). `seconds` is its length, `crop` is in SOURCE pixels and is
checked against the source rather than left to clamp, and `src` resolves
against the MANIFEST's directory -- a manifest is checked in beside the
pictures it cites and built from wherever the operator happens to be.
It never DERIVES a QR target: a clip's code comes from the archive that serves
it, and a screenshot has no such archive, so only an explicit `citeUrl` draws
one. It reserves the footer's rows but draws nothing in them -- the marker's
position is a function of `section`, which a still does not have.
--fetch-only on a still is a no-op that says so, rather than "not a clip": a
bench walking the timeline should not have to know which kinds have a window.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
1 file changed, 192 insertions(+), 4 deletions(-)
diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs
@@ -65,7 +65,7 @@ import { createCueSource, siteOriginFromManifest } from "./cues.mjs";
// The header line, the clock in it and the title cleaner live in one module
// the clip bench imports too -- a preview that shows a line this renderer
// would never draw is worse than no preview.
-import { attributionLine, hms, uploadDateToIso } from "./attribution.mjs";
+import { attributionLine, hms, imageAttributionLine, uploadDateToIso } from "./attribution.mjs";
const execFileP = promisify(execFile);
@@ -181,9 +181,14 @@ const HUMAN = {
chapters: (e) => `chapters: ${e.n} marker(s) -> ${e.file}`,
note: (e) => e.message,
done: (e) =>
- e.duration === undefined
- ? `built ${e.out}`
- : `\n${e.out}\nduration=${e.duration}\nsize=${e.size}`,
+ // A run that produced no file still emits `done` -- a consumer of the
+ // ndjson needs its terminator either way -- but "built null" is not a
+ // sentence, and the note before it already said what happened.
+ e.out == null
+ ? null
+ : e.duration === undefined
+ ? `built ${e.out}`
+ : `\n${e.out}\nduration=${e.duration}\nsize=${e.size}`,
};
let EMIT = (ev, fields = {}) => {
@@ -706,6 +711,164 @@ async function buildCardSegment(card, render, outDir, nodes) {
return seg;
}
+// ---- stills --------------------------------------------------------------
+// An `image` entry is a screenshot in the cut: the receipts a clip cannot say
+// out loud -- a post, a thread, a DM -- shown for `seconds` and then gone.
+//
+// It is built as a CLIP would be framed and as a CARD is encoded. The picture
+// area is a clip's exactly (the frame less the header, less the footer, less
+// the rail column), so a still dropped between two clips does not move the
+// letterbox; the stream layout is a card's exactly (still video plus silent
+// stereo, the same fps/setsar/encode args), so concat and xfade cannot tell the
+// three kinds apart.
+//
+// Two things it deliberately does NOT do:
+//
+// * It never derives a QR target. A clip's code is derived from the archive
+// that serves it; a screenshot has no such archive, and a code resolving to
+// the wrong place is worse than no code. `citeUrl` -- an explicit promise
+// about where this picture came from -- is the only thing that draws one.
+// * It reserves the footer's rows but draws nothing in them. The marker's
+// position is a function of `section`, which a still does not have, and a
+// timeline strip whose marker vanishes for six seconds reads as a bug.
+async function buildImageSegment(entry, render, outDir, chrome, provenance, baseDir) {
+ const seg = path.join(outDir, "segments", `${entry.id}.mp4`);
+ const pal = render.palette;
+ const { width, height } = render;
+
+ if (!entry.src) throw new Error(`${entry.id}: an image entry needs \`src\``);
+ // Relative to the MANIFEST, not to the cwd. A manifest is checked in beside
+ // the pictures it cites, and is built from wherever the operator happens to be.
+ const src = path.resolve(baseDir, entry.src);
+ if (!(await exists(src))) {
+ throw new Error(`${entry.id}: no image at ${src} (src: ${entry.src})`);
+ }
+
+ const dur = Number(entry.seconds ?? 4);
+ if (!Number.isFinite(dur) || dur <= 0) {
+ throw new Error(`${entry.id}: seconds must be a positive number, got ${entry.seconds}`);
+ }
+
+ // A crop is in SOURCE pixels, so it has to be checked against the source. An
+ // out-of-bounds crop is not an ffmpeg error -- the filter clamps and produces
+ // a smaller picture than the manifest asked for, which looks like a framing
+ // decision somebody made on purpose.
+ let cropFilter = null;
+ if (entry.crop) {
+ const c = entry.crop;
+ if (!Array.isArray(c) || c.length !== 4 || !c.every((n) => Number.isFinite(Number(n)))) {
+ throw new Error(`${entry.id}: crop must be [x, y, w, h] in source pixels`);
+ }
+ const [x, y, w, h] = c.map(Number);
+ const dims = await imageDims(src, entry.id);
+ if (w <= 0 || h <= 0 || x < 0 || y < 0 || x + w > dims.width || y + h > dims.height) {
+ throw new Error(
+ `${entry.id}: crop [${x}, ${y}, ${w}, ${h}] falls outside ${entry.src} ` +
+ `(${dims.width}x${dims.height})`,
+ );
+ }
+ cropFilter = `crop=${w}:${h}:${x}:${y}`;
+ }
+
+ const HH = render.headerHeight ?? 56;
+ const VW = contentWidth(render);
+ const FH = chrome.footerHeight;
+ const VH = height - HH - FH;
+ // The line is the author's caption, not a record's title, so it comes from
+ // the entry alone. Nothing to say means no header at all: drawtext refuses an
+ // empty textfile outright.
+ const line = imageAttributionLine(entry);
+ const hasHeader = HH > 0 && line.length > 0;
+ const attribPath = path.join(outDir, "segments", `${entry.id}.attrib.txt`);
+ if (hasHeader) await writeFile(attribPath, line, "utf8");
+
+ const base = [
+ ...(cropFilter ? [cropFilter] : []),
+ `scale=${VW}:${VH}:force_original_aspect_ratio=decrease`,
+ `pad=${VW}:${VH}:(ow-iw)/2:(oh-ih)/2:color=${pal.bg}`,
+ // Widen back to the full frame, leaving the rail column (if any) as ground.
+ `pad=${width}:${VH}:0:0:color=${pal.bg}`,
+ `pad=${width}:${height}:0:${HH}:color=${pal.bg}`,
+ "setsar=1",
+ `fps=${render.fps}`,
+ ...(hasHeader
+ ? [
+ `drawbox=x=90:y=${Math.round((HH - 24) / 2)}:w=4:h=24:color=${pal.accent}:t=fill`,
+ [
+ `drawtext=textfile='${attribPath}'`,
+ `fontfile='${render.fontRegular}'`,
+ "fontsize=22",
+ `fontcolor=${pal.muted}`,
+ "x=118",
+ `y=${Math.round((HH - 26) / 2)}`,
+ ].join(":"),
+ ]
+ : []),
+ ].join(",");
+
+ // `qrForEntry` already prefers `citeUrl`; the guard is that we never reach it
+ // without one, so no still can be given a derived code.
+ const qr =
+ render.qr === false || render.rail || !entry.citeUrl
+ ? null
+ : await qrForEntry(entry, provenance, render, outDir);
+ const qrM = render.qr?.margin ?? 28;
+
+ const secs = dur.toFixed(3);
+ const parts = [`[0:v]${base}[q]`];
+ parts.push(
+ qr
+ ? `[q][2:v]overlay=x=${VW}-w-${qrM}:y=H-h-${FH + qrM}[v]`
+ : `[q]null[v]`,
+ );
+
+ try {
+ await execFileP(
+ FFMPEG,
+ [
+ "-nostdin", "-v", "error", "-y",
+ // Without -framerate the image demuxer runs at its 25 fps default and
+ // the `fps=30` above DUPLICATES a frame -- at the segment's first frame,
+ // which is exactly where the next xfade seam lands.
+ "-loop", "1", "-framerate", String(render.fps), "-t", secs, "-i", src,
+ "-f", "lavfi", "-t", secs,
+ "-i", `anullsrc=channel_layout=stereo:sample_rate=${render.audioRate}`,
+ ...(qr ? ["-i", qr.png] : []),
+ "-filter_complex", parts.join(";"),
+ "-map", "[v]", "-map", "1:a",
+ ...encodeArgs(render),
+ "-shortest",
+ seg,
+ ],
+ { maxBuffer: 1 << 24 },
+ );
+ } catch (err) {
+ // An unreadable or unsupported picture is ffmpeg's answer to give, not ours
+ // to guess at -- but an id has to be on it, or a 27-entry build reports a
+ // decoder error belonging to nothing.
+ const detail = String(err?.stderr ?? "").trim() || err?.message || String(err);
+ throw new Error(`${entry.id}: ffmpeg failed on ${entry.src}\n${detail}`);
+ }
+ return seg;
+}
+
+/** The source's own pixels, which is the only frame a `crop` is expressed in. */
+async function imageDims(file, id) {
+ try {
+ const { stdout } = await execFileP(FFPROBE, [
+ "-v", "error", "-select_streams", "v:0",
+ "-show_entries", "stream=width,height",
+ "-of", "csv=p=0:s=x", file,
+ ]);
+ const [w, h] = stdout.trim().split("\n")[0].split("x").map(Number);
+ if (!(w > 0 && h > 0)) throw new Error(`ffprobe reported ${stdout.trim() || "nothing"}`);
+ return { width: w, height: h };
+ } catch (err) {
+ const detail = String(err?.stderr ?? "").trim() || err?.message || String(err);
+ throw new Error(`${id}: cannot read the dimensions of ${file}\n${detail}`);
+ }
+}
+
// ===========================================================================
// The claim rail
// ===========================================================================
@@ -1406,6 +1569,12 @@ export async function segmentOffsets(segments, D, fps) {
async function chapterTitle(entry, index, provenance) {
if (entry.chapter) return entry.chapter;
+ // A still's chapter is the SAME line it burns into the header, for the reason
+ // a clip's is: the chapter list and the picture are two views of one cut, and
+ // a viewer jumping by chapter should land on the words they were shown.
+ if (entry.type === "image") {
+ return imageAttributionLine(entry) || `Image ${index + 1}`;
+ }
if (entry.type !== "clip") return entry.title ?? entry.heading ?? `Card ${index + 1}`;
try {
const meta = await videoMeta(entry.video, entry.channel ?? provenance.channelSlug, { siteChannel: entry.siteChannel, siteVideo: entry.siteVideo });
@@ -1501,6 +1670,9 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
const whole = JSON.parse(await readFile(manifestPath, "utf8"));
const manifest = selectVariant(whole, variant);
const { render, provenance } = manifest;
+ // An `image` entry's `src` is relative to the MANIFEST, which is checked in
+ // beside the pictures it cites -- not to the cwd the build was started from.
+ const manifestDir = path.dirname(path.resolve(manifestPath));
// The manifest already records which archive it was built against, so a clone
// with no corpus needs no extra configuration to read cue windows.
@@ -1527,6 +1699,14 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
// beside the point.
if (fetchOnly) {
let entry = whole.timeline.find((e) => e.id === fetchOnly);
+ // A still has nothing to fetch and is already on disk, so this is a no-op
+ // rather than an error: a bench that walks the timeline asking for each
+ // entry's window should not have to know which kinds have one.
+ if (entry?.type === "image") {
+ EMIT("note", { message: `${fetchOnly} is an image entry — nothing to fetch` });
+ EMIT("done", { out: null, nothingToFetch: true });
+ return { out: null, failures: [] };
+ }
// `!== "clip"`, not `=== "card"`. The timeline's vocabulary is OPEN -- one
// real manifest carries `scroll` and `chart` entries -- and the card-only
// check sent `undefined` into the fetcher for either of those.
@@ -1641,6 +1821,14 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
if (entry.type === "card") {
EMIT("card", { id: entry.id, i, n: entries.length });
segments.push(await buildCardSegment(entry, render, outDir, manifest.timelineNodes));
+ } else if (entry.type === "image") {
+ // `card`, not a new event name: umtool's activity feed and build chain
+ // key off this one to mean "a segment that needs no network", and a
+ // third word there would show as an unknown step rather than as work.
+ EMIT("card", { id: entry.id, i, n: entries.length });
+ segments.push(
+ await buildImageSegment(entry, render, outDir, chrome, provenance, manifestDir),
+ );
} else if (entry.type === "scroll" || entry.type === "chart" || entry.type === "ledger") {
EMIT("card", { id: entry.id, i, n: entries.length });
if (!manifest.ledger?.length)