Archilyzer · Source

archilyzer

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

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:
Mumtool/docs/report-video.md | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/README.md | 48+++++++++++++++++++++++++++++++++++++++++++++++-
Mumtool/report-to-video/build-video.mjs | 81++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Aumtool/report-to-video/image-entry.test.mjs | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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/, + ); +});