Archilyzer · Source

archilyzer

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

commit d266643195b134b41819480ec985af2500e92f93
parent 80cd07c5035bea095063356145123df278e7dcdf
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 18 Sep 2026 18:46:07 -0400

report-to-video: `scroll` — a crawl for a capture that does not fit

A whole tweet thread in a 1920x1024 hole leaves two bad options: shrink it until
the text is unreadable, or slice it into four stills and make the viewer
reassemble the thread. So a still can be scaled to the picture area's WIDTH --
which is what makes a phone capture's text large -- and the frame walks down it:
hold the top, travel linearly, hold the bottom.

Two caps, both about what happens without them: never wider than the area, and
never past 2x, since a small capture blown up to 1920 wide is a wall of
artefacts (it is centred at 2x on the background instead). A picture that fits
after all is held still -- `scroll` on a short capture describes an intent, not
a demand for motion -- and `seconds <= holdStart + holdEnd` is refused, because
that is two holds with a jump cut between them.

THE MOTION IS `crop`'s `y`. crop's w/h are config-time but its x/y are per-frame
in `t`; drawbox has no time variable at all, which is what left the footer's
fill bar drawn at its final width for weeks. `clip()` parks the crawl at both
ends, so the holds cost no extra filter.

Verified on the real captures rather than asserted. destinys-child t01
(1096x3214 -> 1920x5630, 34s, holds 2/3): frames at 0.5s and 2.0s are the same
picture (RMSE 0.13%), 2 -> 8 -> 16.5 -> 29s each move about a third of the
frame, 31s and 33.9s are the same again (0.49%), and the endpoints match
reference crops of the scaled source's top and bottom rows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mumtool/docs/report-video.md | 23+++++++++++++++++++++++
Mumtool/report-to-video/README.md | 17+++++++++++++++++
Mumtool/report-to-video/build-video.mjs | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mumtool/report-to-video/image-entry.test.mjs | 84++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
4 files changed, 257 insertions(+), 8 deletions(-)

diff --git a/umtool/docs/report-video.md b/umtool/docs/report-video.md @@ -142,6 +142,7 @@ this cannot come back. "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 + "scroll": { "holdStart": 2, "holdEnd": 3 }, // optional: crawl it; `true` = 1.5 / 2.0 "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 @@ -172,6 +173,28 @@ redaction is the exact failure the field exists to prevent. > `zbarimg -q` on a frame grabbed from `out/sourced/segments/i00b.mp4` reads > none. +**`scroll` is for a capture that does not fit.** A full tweet thread in a +1920x1024 hole leaves two bad options — shrink it until the text is unreadable, +or slice it into four stills and make the viewer reassemble the thread — so the +picture is scaled to the picture area's **width**, which is what makes a phone +capture's text large, and the frame walks down it: hold the top for `holdStart`, +travel linearly over `seconds - holdStart - holdEnd`, hold the bottom for +`holdEnd`. Two caps: never wider than the area, and never more than 2x (a small +capture blown up to 1920 wide is a wall of artefacts, so it is centred at 2x on +`palette.bg` instead). A picture that fits after all is held still — a manifest +saying `scroll` on a short capture is describing an intent, not demanding +motion — and `seconds <= holdStart + holdEnd` is refused, because that is two +holds with a jump cut between them rather than a slow crawl. + +> **The motion has to be `crop`'s `y`.** crop's `w`/`h` are config-time (`t` is +> undefined there) but its `x`/`y` are evaluated per frame, which is exactly the +> asymmetry that left the footer's fill bar drawn at its final width for weeks: +> `drawbox` has no time variable at all. The input is `-loop 1 -framerate <fps> +> -t <seconds>`, because a PNG on a plain `-i` through an animated crop is +> FROZEN — the crop sees one frame at `t=0` and repeatlast repeats the +> already-cropped result. Both rules are the rail's, and a crawl is checkable +> the same way: grab frames either side of each hold and compare them. + 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. diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md @@ -212,6 +212,7 @@ poster — shown for `seconds` and then gone. "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 + "scroll": { "holdStart": 2, "holdEnd": 3 }, // optional: crawl a tall capture "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 @@ -239,6 +240,22 @@ screenshot to cite. Each still is one chapter, like every other entry. - **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. +- **`scroll` crawls a capture too tall to fit** — a whole tweet thread. The + picture is scaled to the picture area's **width** (which makes phone-capture + text large), capped at the area's width and at 2× (a smaller picture is centred + at 2× rather than blown up into artefacts), and then the frame walks from the + top edge to the bottom: hold `holdStart`, travel over + `seconds - holdStart - holdEnd`, hold `holdEnd`. `true` takes the defaults + (1.5 / 2.0). A capture that fits after all is simply held still, and + `seconds <= holdStart + holdEnd` is refused — that is not a slow crawl, it is + two holds with a jump cut between them. + **It has to be `crop`'s `y`**: crop's `w`/`h` are config-time but its `x`/`y` + are per-frame in `t`, which is the same asymmetry the fill-bar note below is + about — `drawbox` has no time variable at all. Measured on `destinys-child`'s + `t01` (1096×3214 → 1920×5630, 34 s, holds 2/3): frames at 0.5 s and 2.0 s are + the same picture (RMSE 0.13 %), 2→8→16.5→29 s each move a third of the frame, + and 31 s and 33.9 s are the same again (0.49 %) — holds at both ends, linear + travel between. - **`--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. diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs @@ -763,11 +763,12 @@ async function buildImageSegment(entry, render, outDir, chrome, provenance, base } // `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); + // the source before ffmpeg sees them. A crawl needs the same measurement to + // know whether the picture is even taller than the frame. + const needDims = + entry.redact !== undefined || entry.crop !== undefined || entry.scroll !== undefined; + const dims = needDims ? { ...(await imageDims(src, entry.id)), label: entry.src } : null; + const sourceFilters = needDims ? sourcePixelFilters(entry, dims, pal.bg) : []; const HH = render.headerHeight ?? 56; const VW = contentWidth(render); @@ -781,10 +782,23 @@ async function buildImageSegment(entry, render, outDir, chrome, provenance, base const attribPath = path.join(outDir, "segments", `${entry.id}.attrib.txt`); if (hasHeader) await writeFile(attribPath, line, "utf8"); + // A crawl measures the picture AFTER the crop: the crop is the framing + // decision, and whether what is left is taller than the frame is a fact about + // what was kept, not about the file. + const kept = entry.crop + ? { width: Number(entry.crop[2]), height: Number(entry.crop[3]) } + : dims; + const crawl = scrollPlan(entry, kept, { areaW: VW, areaH: VH, seconds: dur, bg: pal.bg }); + if (crawl) EMIT("note", { message: `${entry.id}: ${crawl.describe}` }); + const base = [ ...sourceFilters, - `scale=${VW}:${VH}:force_original_aspect_ratio=decrease`, - `pad=${VW}:${VH}:(ow-iw)/2:(oh-ih)/2:color=${pal.bg}`, + ...(crawl + ? crawl.filters + : [ + `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}`, @@ -904,6 +918,119 @@ export function sourcePixelFilters(entry, dims, bg) { return out; } +/** A crawl's defaults: long enough to read the first line, and to finish it. */ +export const SCROLL_HOLDS = { holdStart: 1.5, holdEnd: 2.0 }; + +/** + * The `scroll` field, normalised and checked. + * + * `true` means the defaults, which is the common case -- an author asking for a + * crawl is not usually asking for particular hold times. + * + * @param {{ id: string, scroll?: unknown }} entry + * @param {number} seconds the still's own duration + */ +export function scrollHolds(entry, seconds) { + const raw = entry.scroll; + if (raw === undefined || raw === null || raw === false) return null; + const spec = raw === true ? {} : raw; + if (typeof spec !== "object" || Array.isArray(spec)) { + throw new Error(`${entry.id}: scroll must be true or { holdStart, holdEnd } in seconds`); + } + const holdStart = Number(spec.holdStart ?? SCROLL_HOLDS.holdStart); + const holdEnd = Number(spec.holdEnd ?? SCROLL_HOLDS.holdEnd); + for (const [k, v] of [["holdStart", holdStart], ["holdEnd", holdEnd]]) { + if (!Number.isFinite(v) || v < 0) throw new Error(`${entry.id}: scroll.${k} must be a number ≥ 0`); + } + // A crawl with no time left to move in is not a slow crawl, it is two holds + // and a jump cut between them. Say so rather than render it. + if (seconds <= holdStart + holdEnd) { + throw new Error( + `${entry.id}: seconds is ${seconds} but the holds alone are ` + + `${holdStart} + ${holdEnd} = ${holdStart + holdEnd}s — there is no time left to scroll in`, + ); + } + return { holdStart, holdEnd }; +} + +/** + * Where the top of the window sits, per frame. + * + * `clip()` is what parks the crawl at both ends, so the holds cost no extra + * filter: before `holdStart` the ratio is negative and clamps to 0, after the + * travel it is over 1 and clamps to the last row. `(ih-<areaH>)` is the whole + * distance there is to travel, measured on the scaled picture. + * + * Commas inside a filter option have to survive filtergraph parsing, and single + * quotes around the expression is what protects them -- the same rule the + * footer's fill bar is written under. + */ +export function scrollYExpr({ holdStart, holdEnd, seconds, areaH }) { + const travel = seconds - holdStart - holdEnd; + const n = (v) => String(Number(v.toFixed(3))); + return `'clip((t-${n(holdStart)})/${n(travel)},0,1)*(ih-${areaH})'`; +} + +/** + * A tall screenshot, crawled. + * + * A full tweet thread does not fit in a 1920x1024 hole, and the two ways out + * are both worse: shrink it until the text is unreadable, or cut it into four + * stills and make the reader reassemble the thread. So the picture is scaled to + * the AREA'S WIDTH -- which for a phone capture makes the text large -- and the + * frame walks down it. + * + * Two caps, both learned from what happens without them: never wider than the + * area (there is nowhere to put the overflow), and never more than 2x (a small + * picture blown up to 1920 wide is a wall of artefacts, so it is centred at 2x + * on the background instead). + * + * A picture that fits after all is NOT a crawl. It renders exactly as a plain + * still would, because that is what it is -- a manifest that says `scroll` on a + * short capture is describing an intent, not demanding motion. + * + * The motion is `crop`'s `y`, which IS per-frame in `t`. `drawbox` is not, and + * the fill bar in the footer was drawn at its final width for weeks because of + * exactly that difference. + * + * @returns {{ filters: string[], moving: boolean, describe: string } | null} + */ +export function scrollPlan(entry, kept, { areaW, areaH, seconds, bg }) { + const holds = scrollHolds(entry, seconds); + if (!holds) return null; + if (!kept || !(kept.width > 0) || !(kept.height > 0)) { + throw new Error(`${entry.id}: a scrolling still needs the source's dimensions`); + } + + const factor = Math.min(areaW / kept.width, 2); + const w = Math.max(2, Math.round(kept.width * factor)); + const h = Math.max(2, Math.round(kept.height * factor)); + + const filters = [`scale=${w}:${h}`]; + // Narrower than the area (a small capture at its 2x cap): centre it, rather + // than leaving the picture pinned to the left edge. + if (w < areaW) filters.push(`pad=${areaW}:${h}:(ow-iw)/2:0:color=${bg}`); + + if (h <= areaH) { + filters.push(`pad=${areaW}:${areaH}:0:(oh-ih)/2:color=${bg}`); + return { + filters, + moving: false, + describe: `scroll asked for, but the picture fits (${w}x${h} in ${areaW}x${areaH}) — held still`, + }; + } + + filters.push(`crop=w=${areaW}:h=${areaH}:x=0:y=${scrollYExpr({ ...holds, seconds, areaH })}`); + const travel = seconds - holds.holdStart - holds.holdEnd; + return { + filters, + moving: true, + describe: + `crawling ${h - areaH}px over ${travel.toFixed(2)}s ` + + `(hold ${holds.holdStart}s / ${holds.holdEnd}s, picture ${w}x${h})`, + }; +} + /** 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 @@ -8,7 +8,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { sourcePixelFilters } from "./build-video.mjs"; +import { scrollHolds, scrollPlan, scrollYExpr, sourcePixelFilters } from "./build-video.mjs"; const DIMS = { width: 1260, height: 2038, label: "pic.jpeg" }; const BG = "#000000"; @@ -92,3 +92,85 @@ test("the shapes themselves are checked", () => { /crop must be \[x, y, w, h\] in source pixels/, ); }); + +// --------------------------------------------------------------------------- +// The crawl. A full tweet thread does not fit in a 1920x1024 hole, and both +// ways out without one are worse: shrink until the text is unreadable, or cut +// the thread into stills and make the reader reassemble it. +// --------------------------------------------------------------------------- + +const AREA = { areaW: 1920, areaH: 1024, bg: "#000000" }; + +test("scrollHolds: true means the defaults", () => { + assert.deepEqual(scrollHolds({ id: "i", scroll: true }, 10), { holdStart: 1.5, holdEnd: 2.0 }); + assert.deepEqual(scrollHolds({ id: "i", scroll: { holdStart: 0 } }, 10), { holdStart: 0, holdEnd: 2.0 }); + assert.equal(scrollHolds({ id: "i" }, 10), null); + assert.equal(scrollHolds({ id: "i", scroll: false }, 10), null); +}); + +test("scrollHolds: holds that leave no time to move in are refused", () => { + // Not a slow crawl -- two holds and a jump cut between them. + assert.throws( + () => scrollHolds({ id: "i07", scroll: true }, 3), + /i07: seconds is 3 but the holds alone are 1\.5 \+ 2 = 3\.5s/, + ); + assert.throws(() => scrollHolds({ id: "i", scroll: { holdStart: -1 } }, 10), /holdStart must be a number ≥ 0/); + assert.throws(() => scrollHolds({ id: "i", scroll: [1, 2] }, 10), /scroll must be true or \{ holdStart, holdEnd \}/); +}); + +test("scrollYExpr: clip() parks the crawl at both ends", () => { + // crop's y IS per-frame in `t`; drawbox's is not, which is why the footer's + // fill bar was drawn at its final width for weeks. + assert.equal( + scrollYExpr({ holdStart: 1.5, holdEnd: 2, seconds: 12, areaH: 1024 }), + "'clip((t-1.5)/8.5,0,1)*(ih-1024)'", + ); + // No holds: the whole duration is travel. + assert.equal( + scrollYExpr({ holdStart: 0, holdEnd: 0, seconds: 6, areaH: 1024 }), + "'clip((t-0)/6,0,1)*(ih-1024)'", + ); +}); + +test("scrollPlan: a tall capture is scaled to the area's WIDTH and walked", () => { + const plan = scrollPlan( + { id: "i07", scroll: true }, + { width: 1260, height: 5000 }, + { ...AREA, seconds: 12 }, + ); + assert.equal(plan.moving, true); + // 1920/1260 = 1.5238…, under the 2x cap, so the picture fills the width. + assert.deepEqual(plan.filters, [ + "scale=1920:7619", + "crop=w=1920:h=1024:x=0:y='clip((t-1.5)/8.5,0,1)*(ih-1024)'", + ]); +}); + +test("scrollPlan: never upscales past 2x, and centres what is left over", () => { + const plan = scrollPlan( + { id: "i", scroll: true }, + { width: 400, height: 3000 }, + { ...AREA, seconds: 10 }, + ); + assert.deepEqual(plan.filters.slice(0, 2), [ + "scale=800:6000", + "pad=1920:6000:(ow-iw)/2:0:color=#000000", + ]); + assert.match(plan.filters[2], /^crop=w=1920:h=1024:x=0:y='clip/); +}); + +test("scrollPlan: a picture that fits is not a crawl", () => { + // `scroll` on a short capture describes an intent, not a demand for motion. + const plan = scrollPlan( + { id: "i", scroll: true }, + { width: 1260, height: 600 }, + { ...AREA, seconds: 10 }, + ); + assert.equal(plan.moving, false); + assert.ok(!plan.filters.some((f) => f.startsWith("crop="))); + assert.equal(plan.filters.at(-1), "pad=1920:1024:0:(oh-ih)/2:color=#000000"); +}); + +test("scrollPlan: nothing asked for, nothing planned", () => { + assert.equal(scrollPlan({ id: "i" }, { width: 100, height: 100 }, { ...AREA, seconds: 4 }), null); +});