commit bec4abf988be523ecd8a701e95dfba50df5abc4d
parent afa055a1f1cf9beb9a6485bcec4eb7382fdbb3d5
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 18 Sep 2026 18:10:53 -0400
report-to-video: `redact` — fill, because a blurred QR still scans
Solid boxes in `palette.bg`, in the SOURCE file's pixels, drawn before the crop
so both sets of numbers come straight off the file the author measured in an
image viewer rather than off a cropped intermediate whose origin moved.
Blur was never an option. A QR carries enough error correction to survive the
amount of blur that reads as "censored" on screen, so a pixelated recruitment
link still scans off a paused frame -- a redaction that looks done and is not.
destinys-child's i00b is a poster with two live codes: zbarimg reads both off
the source file and none off the rendered frame.
Out of bounds is an error rather than ffmpeg's clamp, for the same reason: a
redaction that silently became a smaller redaction is precisely the failure the
field exists to prevent. The geometry is a pure exported function with unit
tests, so it is checkable without an encoder.
Docs for the image entry (README + report-video.md), and three entries in
"Discovered by getting it wrong once": the blur one, the channel display name
living on the cue record rather than in config.json, and `built null`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
4 files changed, 257 insertions(+), 22 deletions(-)
diff --git a/umtool/docs/report-video.md b/umtool/docs/report-video.md
@@ -133,6 +133,49 @@ project page and crashed `umtool show`, on the one manifest of six that has any.
The fixture now carries an entry of a type nothing in the code knows about, so
this cannot come back.
+### An image entry
+
+```jsonc
+{ "type": "image", "id": "i01a",
+ "src": "nathan-pictures/IMG_7095.jpeg", // relative to the MANIFEST's directory
+ "seconds": 6, // how long it is on screen; default 4
+ "redact": [[630, 925, 180, 145], // optional: solid boxes, in SOURCE pixels,
+ [615, 1055, 185, 175]], // filled BEFORE the crop
+ "crop": [0, 0, 1260, 1020], // optional [x, y, w, h], also source pixels
+ "title": "Bx (@bx_on_x) on X, September 2026 — 1/4",
+ "date": "2026-08-19", // optional; appended as a clip's is
+ "citeUrl": "https://…", // optional; the ONLY thing that draws a QR
+ "note": "…" }
+```
+
+A still is the receipts a clip cannot say out loud — a post, a thread, a DM, a
+poster. Its header is `${title} · ${date}` and carries **no clock**: there is no
+moment inside a screenshot to cite, so a `@ 0:00` would be a number pointing at
+nothing. It has no window, no source and nothing to fetch, so `--fetch-only` on
+one is a no-op that says so rather than "not a clip", and `updateClip` refuses it
+like any other non-clip entry.
+
+**`crop` and `redact` are both expressed in the ORIGINAL file's pixels**, and
+redaction runs first so the numbers come straight off the file the author
+measured in an image viewer — not off a cropped intermediate whose origin moved.
+Both are validated against the source's real dimensions, because ffmpeg would
+clamp instead: a crop that silently became a different framing looks like a
+decision somebody made, and a redaction that silently became a smaller
+redaction is the exact failure the field exists to prevent.
+
+> **Redaction fills; it does not blur.** A screenshot can carry a live QR code, a
+> phone number or an address that must not ship in the video, and **a QR survives
+> mild blur** — a pixelated code still scans, which is a redaction that looks done
+> and is not. A solid box in `palette.bg` cannot be undone. `destinys-child`'s
+> `i00b` is a recruitment poster with two working codes, and it is checkable:
+> `zbarimg -q nathan-pictures/HScWQQWXEAE3Ie_.jpg` reads both off the source and
+> `zbarimg -q` on a frame grabbed from `out/sourced/segments/i00b.mp4` reads
+> none.
+
+A still **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 that resolves to the
+wrong place is worse than no code — so only an explicit `citeUrl` draws one.
+
## The three-stage window model
A clip's window passes through three different notions of "where the cut is", and
@@ -333,6 +376,19 @@ at a frame** was the only thing that found it.
and the timeline disagree about what is in it.
- **`--chapters-only` is cheap and separate.** Retitling chapters does not need a
re-encode; the per-clip segments on disk are all the offsets need.
+- **The channel's display name is on the CUE RECORD, not in `config.json`.** A
+ corpus channel's `config.json` carries `name`, not `title`; the field the
+ header wants is `channel` on the transcript record itself — which a published
+ shard carries too, so naming the channel costs a build no lookup and works
+ with no corpus at all.
+- **Blurring a QR code does not redact it.** Codes carry enough error correction
+ to survive the sort of blur that reads as "censored" on screen, so a blurred
+ recruitment link still scans off a paused video. `redact` fills solid, and the
+ test for it is `zbarimg`, not an eyeball.
+- **A no-op still has to be a sentence.** `--fetch-only` on an image emits a
+ `done` event with no file, and the human formatter used to print `built null`
+ under it. An event a consumer needs is not an excuse for a line a reader does
+ not.
## Sources are a view of the project
diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md
@@ -172,7 +172,8 @@ the manifest names the MCP video id while the cue file lives under the URL slug.
## Manifest shape
-`timeline` is an ordered list; entries are `card` or `clip`.
+`timeline` is an ordered list; entries are `card`, `clip` or `image` (plus
+`scroll`, `chart` and `ledger` — the vocabulary is open).
```jsonc
{ "type": "card", "id": "ch3", "style": "chapter", "seconds": 4.0,
@@ -200,6 +201,51 @@ window:
handles the reverse case — a clip whose lead-in would drag in seconds of some
*other* audio (a news package playing before the speaker starts).
+### The `image` entry type
+
+A still: the receipts a clip cannot say out loud — a post, a thread, a DM, a
+poster — shown for `seconds` and then gone.
+
+```jsonc
+{ "type": "image", "id": "i01a",
+ "src": "nathan-pictures/IMG_7095.jpeg", // relative to the MANIFEST's directory
+ "seconds": 6, // default 4
+ "redact": [[630, 925, 180, 145]], // optional: filled solid, before the crop
+ "crop": [0, 0, 1260, 1020], // optional [x, y, w, h] in SOURCE pixels
+ "title": "Bx (@bx_on_x) on X, Sept 2026 — 1/4",
+ "date": "2026-08-19", // optional; appended like a clip's
+ "citeUrl": "https://…", // optional; the ONLY thing that draws a QR
+ "note": "…" }
+```
+
+It is **framed like a clip and encoded like a card**: the picture area is the
+frame less the header, less the footer, less the rail column, so dropping a still
+between two clips does not move the letterbox; the stream is a still plus silent
+stereo at the same fps and encode args, so concat and xfade cannot tell the three
+kinds apart. The header is the same tick and the same drawtext, reading
+`${title} · ${date}` — **no clock**, because there is no moment inside a
+screenshot to cite. Each still is one chapter, like every other entry.
+
+- **No QR is ever derived.** A clip's code comes from the archive that serves it;
+ a screenshot has no such archive, and a code resolving to the wrong place is
+ worse than none. An explicit `citeUrl` draws one, in a clip's position.
+- **`crop` and `redact` are in the ORIGINAL file's pixels**, and redaction runs
+ first, so both sets of numbers come straight off the file the author measured.
+- **Redaction fills; it does not blur.** A screenshot can carry a live QR code, a
+ phone number or an address that must not ship in the video — and a QR survives
+ mild blur, so a pixelated code still scans. `destinys-child`'s `i00b` is a
+ recruitment poster with two working codes: `zbarimg` reads both off the source
+ file and nothing off the rendered frame.
+- **Out of bounds is an error, not a clamp.** ffmpeg would quietly shrink either
+ box to fit, and a redaction that silently became a smaller redaction is the
+ failure the field exists to prevent.
+- **`--fetch-only` on a still is a no-op that says so**, not an error: a bench
+ walking the timeline should not have to know which kinds have a window.
+
+The footer's rows are reserved but left empty under a still. 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.
+
Clips also carry `section` and (auto-set) `sectionEnter`. Card styles — `title`,
`timeline`, `status`, `bullets`, `sources` — still work, but the ferret-rescue cut
uses none of them. `render` holds resolution, fps, fonts, palette and the knobs
diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs
@@ -762,26 +762,12 @@ async function buildImageSegment(entry, render, outDir, chrome, provenance, base
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}`;
- }
+ // `redact` and `crop` are both in SOURCE pixels, so both are checked against
+ // the source before ffmpeg sees them.
+ const sourceFilters =
+ entry.redact === undefined && entry.crop === undefined
+ ? []
+ : sourcePixelFilters(entry, { ...(await imageDims(src, entry.id)), label: entry.src }, pal.bg);
const HH = render.headerHeight ?? 56;
const VW = contentWidth(render);
@@ -796,7 +782,7 @@ async function buildImageSegment(entry, render, outDir, chrome, provenance, base
if (hasHeader) await writeFile(attribPath, line, "utf8");
const base = [
- ...(cropFilter ? [cropFilter] : []),
+ ...sourceFilters,
`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.
@@ -865,6 +851,59 @@ async function buildImageSegment(entry, render, outDir, chrome, provenance, base
return seg;
}
+/**
+ * The head of a still's filter chain: everything expressed in SOURCE pixels.
+ *
+ * Pure, and exported, so the geometry can be tested without an encoder.
+ *
+ * REDACTION COMES FIRST, AND IT FILLS RATHER THAN BLURS. A screenshot can carry
+ * a live QR code, a phone number or an address that must not ship in a video,
+ * and a QR survives mild blur -- a pixelated code still scans, which is a
+ * redaction that looks done and is not. A solid box in the frame's own
+ * background colour cannot be undone by anybody.
+ *
+ * Both are in the ORIGINAL file's pixels, and redaction runs before the crop so
+ * the numbers come straight off the file the author measured -- not off a
+ * cropped intermediate whose origin moved.
+ *
+ * Out of bounds is an ERROR, not a clamp. ffmpeg would quietly shrink either
+ * box to fit, and a redaction that silently became a smaller redaction is the
+ * exact failure the field exists to prevent.
+ *
+ * @param {{ id: string, crop?: number[]|null, redact?: number[][]|null }} entry
+ * @param {{ width: number, height: number, label?: string }} dims
+ * @param {string} bg the palette background the boxes are filled with
+ */
+export function sourcePixelFilters(entry, dims, bg) {
+ const where = dims.label ? `${dims.label} (${dims.width}x${dims.height})` : `${dims.width}x${dims.height}`;
+ const box = (b, what) => {
+ if (!Array.isArray(b) || b.length !== 4 || !b.every((n) => Number.isFinite(Number(n)))) {
+ throw new Error(`${entry.id}: ${what} must be [x, y, w, h] in source pixels`);
+ }
+ const [x, y, w, h] = b.map(Number);
+ if (w <= 0 || h <= 0 || x < 0 || y < 0 || x + w > dims.width || y + h > dims.height) {
+ throw new Error(`${entry.id}: ${what} [${x}, ${y}, ${w}, ${h}] falls outside ${where}`);
+ }
+ return [x, y, w, h];
+ };
+
+ const out = [];
+ if (entry.redact !== undefined && entry.redact !== null) {
+ if (!Array.isArray(entry.redact)) {
+ throw new Error(`${entry.id}: redact must be a list of [x, y, w, h] rectangles`);
+ }
+ entry.redact.forEach((r, i) => {
+ const [x, y, w, h] = box(r, `redact[${i}]`);
+ out.push(`drawbox=x=${x}:y=${y}:w=${w}:h=${h}:color=${bg}:t=fill`);
+ });
+ }
+ if (entry.crop !== undefined && entry.crop !== null) {
+ const [x, y, w, h] = box(entry.crop, "crop");
+ out.push(`crop=${w}:${h}:${x}:${y}`);
+ }
+ return out;
+}
+
/** The source's own pixels, which is the only frame a `crop` is expressed in. */
async function imageDims(file, id) {
try {
diff --git a/umtool/report-to-video/image-entry.test.mjs b/umtool/report-to-video/image-entry.test.mjs
@@ -0,0 +1,94 @@
+// Tests for the geometry of an `image` entry — the part of a still's filter
+// chain that is expressed in the SOURCE file's own pixels.
+//
+// Pure, so it is tested without an encoder. The rest of buildImageSegment is
+// ffmpeg arguments and is exercised by building a real entry.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { sourcePixelFilters } from "./build-video.mjs";
+
+const DIMS = { width: 1260, height: 2038, label: "pic.jpeg" };
+const BG = "#000000";
+
+test("nothing to do is an empty chain", () => {
+ assert.deepEqual(sourcePixelFilters({ id: "i01" }, DIMS, BG), []);
+ assert.deepEqual(sourcePixelFilters({ id: "i01", crop: null, redact: null }, DIMS, BG), []);
+});
+
+test("a crop is [x, y, w, h] and ffmpeg's crop is w:h:x:y", () => {
+ assert.deepEqual(
+ sourcePixelFilters({ id: "i01", crop: [0, 1018, 1260, 1020] }, DIMS, BG),
+ ["crop=1260:1020:0:1018"],
+ );
+ // Flush against the far edge is INSIDE. The real manifest splits a 2038-tall
+ // screenshot into two 1020s, and the second one ends exactly at the bottom.
+ assert.deepEqual(
+ sourcePixelFilters({ id: "i01", crop: [0, 2037, 1260, 1] }, DIMS, BG),
+ ["crop=1260:1:0:2037"],
+ );
+});
+
+test("redaction fills, and it happens BEFORE the crop", () => {
+ // Blurring is not enough for a QR -- a pixelated code still scans -- so the
+ // box is solid, in the frame's own background colour.
+ assert.deepEqual(
+ sourcePixelFilters(
+ { id: "i00b", redact: [[10, 20, 100, 100], [500, 600, 40, 40]], crop: [0, 0, 1260, 1000] },
+ DIMS,
+ BG,
+ ),
+ [
+ "drawbox=x=10:y=20:w=100:h=100:color=#000000:t=fill",
+ "drawbox=x=500:y=600:w=40:h=40:color=#000000:t=fill",
+ "crop=1260:1000:0:0",
+ ],
+ );
+});
+
+test("a redaction's numbers come off the ORIGINAL file, not the cropped one", () => {
+ // Same rectangle, with and without a crop that moves the origin: the filter
+ // is identical, because it runs first.
+ const boxes = [[0, 1500, 200, 200]];
+ const withCrop = sourcePixelFilters({ id: "i", redact: boxes, crop: [0, 1000, 1260, 1038] }, DIMS, BG);
+ const without = sourcePixelFilters({ id: "i", redact: boxes }, DIMS, BG);
+ assert.equal(withCrop[0], without[0]);
+});
+
+test("out of bounds is an error, not a clamp", () => {
+ // ffmpeg would quietly shrink either box to fit. A redaction that silently
+ // became a smaller redaction is the exact failure the field exists to stop.
+ assert.throws(
+ () => sourcePixelFilters({ id: "i01", crop: [0, 0, 5000, 5000] }, DIMS, BG),
+ /i01: crop \[0, 0, 5000, 5000\] falls outside pic\.jpeg \(1260x2038\)/,
+ );
+ assert.throws(
+ () => sourcePixelFilters({ id: "i01", redact: [[0, 0, 10, 10], [1200, 0, 100, 10]] }, DIMS, BG),
+ /i01: redact\[1\] \[1200, 0, 100, 10\] falls outside/,
+ );
+ assert.throws(
+ () => sourcePixelFilters({ id: "i01", redact: [[-1, 0, 10, 10]] }, DIMS, BG),
+ /redact\[0\] \[-1, 0, 10, 10\] falls outside/,
+ );
+ assert.throws(
+ () => sourcePixelFilters({ id: "i01", redact: [[0, 0, 0, 10]] }, DIMS, BG),
+ /redact\[0\] \[0, 0, 0, 10\] falls outside/,
+ );
+});
+
+test("the shapes themselves are checked", () => {
+ assert.throws(
+ () => sourcePixelFilters({ id: "i01", redact: [0, 0, 10, 10] }, DIMS, BG),
+ /redact\[0\] must be \[x, y, w, h\] in source pixels/,
+ );
+ assert.throws(
+ () => sourcePixelFilters({ id: "i01", redact: "all of it" }, DIMS, BG),
+ /redact must be a list of \[x, y, w, h\] rectangles/,
+ );
+ assert.throws(
+ () => sourcePixelFilters({ id: "i01", crop: [0, 0, 100] }, DIMS, BG),
+ /crop must be \[x, y, w, h\] in source pixels/,
+ );
+});