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:
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);
+});