Archilyzer · Source

archilyzer

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

commit ee8e96e668c1fb2818988dc71516d39dd816aa99
parent d266643195b134b41819480ec985af2500e92f93
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 18 Sep 2026 19:09:12 -0400

report-to-video: `panels` — two to four captures, side by side

The same still with several exhibits in it: two phone captures that argue with
each other. `panels` replaces `src` (both is an error, and so is `panels` with
`scroll` -- those are two different answers to "it does not fit", and crawling a
row would also have to decide which panel the crawl follows).

Each panel carries its OWN redact and crop, applied in its own source pixels,
and is then scaled to the picture area's HEIGHT. Matching heights is what makes
two differently-sized captures read as one exhibit rather than as two pictures
that happen to be near each other.

A row too wide for the frame shrinks by ONE factor for every panel. A per-panel
fit would keep the row fitting too, and would quietly restate the relative sizes
of the exhibits -- a claim about them nobody made; it is also the only rule that
cannot slide a redaction box off the pixels it was measured against. The gaps
are ground rather than picture, so they keep their width while the pictures give
way: the space between two exhibits means "these are two things", and that
reading should not get weaker as the exhibits get bigger.

Everything after the layout is a single still's exactly -- header, date, QR,
seconds, chapter -- and the single-picture path is byte-identical after the
refactor (p01's frame compares equal; t01's crawl differs only by a retitled
header, 0.57% RMSE below it).

destinys-child p05: 327x675 post + 853x1280 poster at gap 48 lays out as
496x1024 + 682x1024 = 1226px of 1920, centred, poster codes filled, zbarimg
reads nothing off the frame.

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

Diffstat:
Mumtool/docs/report-video.md | 25+++++++++++++++++++++++++
Mumtool/report-to-video/README.md | 16++++++++++++++++
Mumtool/report-to-video/build-video.mjs | 209+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mumtool/report-to-video/image-entry.test.mjs | 63++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
4 files changed, 277 insertions(+), 36 deletions(-)

diff --git a/umtool/docs/report-video.md b/umtool/docs/report-video.md @@ -143,6 +143,10 @@ this cannot come back. [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 + // …or, INSTEAD of `src`, a row of 2-4 captures, each with its own redact/crop: + "panels": [{ "src": "post.png" }, + { "src": "poster.jpg", "redact": [[630, 925, 180, 145]] }], + "gap": 48, // px between panels; default 40 "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 @@ -195,6 +199,27 @@ holds with a jump cut between them rather than a slow crawl. > 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. +**`panels` is the same still with several exhibits in it** — two phone captures +that argue with each other, side by side. It replaces `src` (an entry with both +is an error, and so is `panels` with `scroll`: those are two different answers to +"it does not fit", and crawling a row would also have to decide which panel the +crawl follows). Each panel goes through **its own** `redact` → `crop`, in its own +source pixels, and is then scaled to the picture area's **height** — matching +heights is what makes two differently-sized captures read as one exhibit rather +than as two pictures that happen to be near each other. + +A row wider than the frame shrinks by **one factor applied to every panel**. A +per-panel fit would also keep the row fitting, and would quietly restate the +relative sizes of the exhibits — a claim about them nobody made. The gaps are +ground rather than picture, so they keep their width while the pictures give way. +Everything after the layout — header, `date`, `citeUrl`/QR, `seconds`, the +chapter — is a single still's exactly. + +`destinys-child`'s `p05` is the real case: a 327x675 expulsion post beside an +853x1280 poster at `gap: 48` lays out as `496x1024 + 682x1024 = 1226px of 1920`, +centred with 347px of background either side, and the poster's two recruitment +codes filled black. + 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 @@ -213,6 +213,9 @@ poster — shown for `seconds` and then gone. "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 + // …or, INSTEAD of `src`, two to four captures side by side: + "panels": [{ "src": "a.png" }, { "src": "b.jpg", "redact": [[630, 925, 180, 145]] }], + "gap": 48, // px between panels; default 40 "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 @@ -256,6 +259,19 @@ screenshot to cite. Each still is one chapter, like every other entry. 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. +- **`panels` puts two to four captures side by side**, instead of `src` (both is + an error, and so is `panels` with `scroll` — they are different answers to "it + does not fit"). Each panel carries its own `redact` and `crop`, applied in its + own source pixels, and is then scaled to the picture area's **height**: + matching heights is what makes two differently-sized phone captures read as one + exhibit. A row too wide for the frame shrinks by **one factor for every panel** + — a per-panel fit would keep the row fitting and quietly restate the relative + sizes of the exhibits — and the gaps keep their width while the pictures give + way, because the space between two exhibits means "these are two things". + Measured on `destinys-child`'s `p05`: a 327×675 expulsion post and an 853×1280 + poster become `496x1024 + 682x1024 with 48px gaps = 1226px of 1920`, centred, + with the poster's two recruitment codes filled black (`zbarimg` reads nothing + off the frame). - **`--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 @@ -749,12 +749,28 @@ async function buildImageSegment(entry, render, outDir, chrome, provenance, base 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})`); + // ONE picture or a ROW of them. `panels` is the same still with several + // exhibits in it -- two phone captures that argue with each other side by + // side -- and everything after the layout is identical, which is why it is a + // form of this entry rather than a type of its own. + const multi = entry.panels !== undefined && entry.panels !== null; + const wantsScroll = entry.scroll !== undefined && entry.scroll !== null && entry.scroll !== false; + if (multi && entry.src) { + throw new Error(`${entry.id}: an image entry takes \`src\` or \`panels\`, not both`); + } + if (multi && wantsScroll) { + // Both are answers to "it does not fit", and they are different answers. + // Crawling a row would also have to decide which panel the crawl follows. + throw new Error( + `${entry.id}: \`scroll\` walks down ONE tall picture and \`panels\` puts several side by side — not both`, + ); + } + if (!multi && !entry.src) throw new Error(`${entry.id}: an image entry needs \`src\` (or \`panels\`)`); + if (multi && (!Array.isArray(entry.panels) || entry.panels.length < 2 || entry.panels.length > 4)) { + throw new Error( + `${entry.id}: panels must be a list of 2 to 4 images ` + + `(got ${Array.isArray(entry.panels) ? entry.panels.length : typeof entry.panels})`, + ); } const dur = Number(entry.seconds ?? 4); @@ -762,18 +778,44 @@ async function buildImageSegment(entry, render, outDir, chrome, provenance, base throw new Error(`${entry.id}: seconds must be a positive number, got ${entry.seconds}`); } - // `redact` and `crop` are both in SOURCE pixels, so both are checked against - // 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); const FH = chrome.footerHeight; const VH = height - HH - FH; + + // Each picture carries its OWN redactions and crop, measured in its own + // source pixels -- a panel's boxes were taken off that file in an image + // viewer, and nothing in the layout is allowed to move them. + const pieces = multi + ? entry.panels.map((p, i) => ({ ...p, id: `${entry.id}.panels[${i}]` })) + : [{ src: entry.src, crop: entry.crop, redact: entry.redact, id: entry.id }]; + + const resolved = []; + for (const piece of pieces) { + if (!piece.src) throw new Error(`${piece.id}: 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 file = path.resolve(baseDir, piece.src); + if (!(await exists(file))) { + throw new Error(`${piece.id}: no image at ${file} (src: ${piece.src})`); + } + // A row needs every panel measured to lay them out; a lone still only when + // something is expressed in its source pixels. + const needDims = + multi || piece.redact !== undefined || piece.crop !== undefined || wantsScroll; + const dims = needDims ? { ...(await imageDims(file, piece.id)), label: piece.src } : null; + const filters = + piece.redact !== undefined || piece.crop !== undefined + ? sourcePixelFilters(piece, dims, pal.bg) + : []; + // What is LEFT after the crop. The crop is the framing decision; the layout + // and the crawl are both facts about what was kept, not about the file. + const kept = piece.crop + ? { width: Number(piece.crop[2]), height: Number(piece.crop[3]) } + : dims; + resolved.push({ ...piece, file, filters, kept }); + } + // 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. @@ -782,23 +824,59 @@ 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}` }); + // ---- the picture, or the row of them ------------------------------------ + const panelParts = []; + let head; + let source = "[0:v]"; + if (multi) { + let layout; + try { + layout = panelLayout(resolved.map((r) => r.kept), { + areaW: VW, areaH: VH, gap: entry.gap ?? PANEL_GAP, + }); + } catch (err) { + throw new Error(`${entry.id}: ${err.message}`); + } + EMIT("note", { + message: + `${entry.id}: ${layout.boxes.length} panels ` + + `${layout.boxes.map((b) => `${b.w}x${b.h}`).join(" + ")} with ${layout.gap}px gaps ` + + `= ${layout.totalWidth}px of ${VW}` + + (layout.factor < 1 ? ` (scaled to ${(layout.factor * 100).toFixed(1)}% to fit)` : ""), + }); + resolved.forEach((r, i) => { + const box = layout.boxes[i]; + const chain = [...r.filters, `scale=${box.w}:${box.h}`, "setsar=1"]; + // The gap is ground, carried on the left panel's right edge. hstack has + // no spacing of its own. + if (i < resolved.length - 1) { + chain.push(`pad=${box.w + layout.gap}:${box.h}:0:0:color=${pal.bg}`); + } + panelParts.push(`[${i}:v]${chain.join(",")}[pan${i}]`); + }); + panelParts.push( + `${resolved.map((_, i) => `[pan${i}]`).join("")}hstack=inputs=${resolved.length}[row]`, + ); + source = "[row]"; + head = [`pad=${VW}:${VH}:(ow-iw)/2:(oh-ih)/2:color=${pal.bg}`]; + } else { + const crawl = scrollPlan(entry, resolved[0].kept, { + areaW: VW, areaH: VH, seconds: dur, bg: pal.bg, + }); + if (crawl) EMIT("note", { message: `${entry.id}: ${crawl.describe}` }); + head = [ + ...resolved[0].filters, + ...(crawl + ? crawl.filters + : [ + `scale=${VW}:${VH}:force_original_aspect_ratio=decrease`, + `pad=${VW}:${VH}:(ow-iw)/2:(oh-ih)/2:color=${pal.bg}`, + ]), + ]; + } const base = [ - ...sourceFilters, - ...(crawl - ? crawl.filters - : [ - `scale=${VW}:${VH}:force_original_aspect_ratio=decrease`, - `pad=${VW}:${VH}:(ow-iw)/2:(oh-ih)/2:color=${pal.bg}`, - ]), + ...head, // 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}`, @@ -828,10 +906,13 @@ async function buildImageSegment(entry, render, outDir, chrome, provenance, base const qrM = render.qr?.margin ?? 28; const secs = dur.toFixed(3); - const parts = [`[0:v]${base}[q]`]; + // Inputs: one per picture, then the silence, then the code. + const silenceIdx = resolved.length; + const qrIdx = silenceIdx + 1; + const parts = [...panelParts, `${source}${base}[q]`]; parts.push( qr - ? `[q][2:v]overlay=x=${VW}-w-${qrM}:y=H-h-${FH + qrM}[v]` + ? `[q][${qrIdx}:v]overlay=x=${VW}-w-${qrM}:y=H-h-${FH + qrM}[v]` : `[q]null[v]`, ); @@ -842,13 +923,16 @@ async function buildImageSegment(entry, render, outDir, chrome, provenance, base "-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, + // which is exactly where the next xfade seam lands. It is also what + // makes an animated crop evaluate per frame rather than freezing. + ...resolved.flatMap((r) => [ + "-loop", "1", "-framerate", String(render.fps), "-t", secs, "-i", r.file, + ]), "-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", + "-map", "[v]", "-map", `${silenceIdx}:a`, ...encodeArgs(render), "-shortest", seg, @@ -860,7 +944,8 @@ async function buildImageSegment(entry, render, outDir, chrome, provenance, base // 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}`); + const what = resolved.map((r) => r.src).join(", "); + throw new Error(`${entry.id}: ffmpeg failed on ${what}\n${detail}`); } return seg; } @@ -918,6 +1003,60 @@ export function sourcePixelFilters(entry, dims, bg) { return out; } +/** The default breathing room between two panels, in pixels. */ +export const PANEL_GAP = 40; + +/** + * Two to four vertical screenshots, side by side. + * + * The layout is driven by HEIGHT, not width: these are phone captures of + * different sizes, and matching their heights is what makes them read as one + * exhibit rather than as two pictures that happen to be near each other. So + * every panel is scaled to the picture area's height, and the widths fall out + * of the aspect ratios. + * + * When the row is too wide for the frame, every panel shrinks by THE SAME + * factor -- not each to its own share. A per-panel factor would keep the row + * fitting and quietly change the relative sizes, which is a claim about the + * exhibits nobody made; and because a panel's redaction is applied in its own + * source pixels before any of this, a uniform factor is also the only one that + * cannot slide a redaction box off what it was measured against. + * + * The gaps are ground, not picture, so they keep their width while the pictures + * give way: the space between two exhibits means "these are two things", and + * that reading should not get weaker as the exhibits get bigger. + * + * @param {{ width: number, height: number }[]} sizes the panels AFTER their crops + * @returns {{ boxes: {w:number,h:number}[], gap:number, factor:number, totalWidth:number }} + */ +export function panelLayout(sizes, { areaW, areaH, gap = PANEL_GAP }) { + if (!Array.isArray(sizes) || sizes.length < 2 || sizes.length > 4) { + throw new Error(`panels must be a list of 2 to 4 images (got ${Array.isArray(sizes) ? sizes.length : typeof sizes})`); + } + sizes.forEach((s, i) => { + if (!(s?.width > 0) || !(s?.height > 0)) throw new Error(`panels[${i}]: no usable dimensions`); + }); + if (!Number.isFinite(gap) || gap < 0) throw new Error(`gap must be a number ≥ 0 (got ${gap})`); + + const gaps = gap * (sizes.length - 1); + const room = areaW - gaps; + if (room <= 0) { + throw new Error(`gap ${gap} leaves no room for ${sizes.length} panels across ${areaW}px`); + } + // Every panel at the area's full height first; the row's width is then a fact + // rather than a choice, and the only question left is whether it fits. + const atHeight = sizes.map((s) => Math.max(2, Math.round(s.width * (areaH / s.height)))); + const factor = Math.min(1, room / atHeight.reduce((a, b) => a + b, 0)); + const h = Math.max(2, Math.round(areaH * factor)); + const boxes = atHeight.map((w) => ({ w: Math.max(2, Math.round(w * factor)), h })); + return { + boxes, + gap, + factor, + totalWidth: boxes.reduce((a, b) => a + b.w, 0) + gaps, + }; +} + /** 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 }; diff --git a/umtool/report-to-video/image-entry.test.mjs b/umtool/report-to-video/image-entry.test.mjs @@ -8,7 +8,9 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { scrollHolds, scrollPlan, scrollYExpr, sourcePixelFilters } from "./build-video.mjs"; +import { + panelLayout, scrollHolds, scrollPlan, scrollYExpr, sourcePixelFilters, +} from "./build-video.mjs"; const DIMS = { width: 1260, height: 2038, label: "pic.jpeg" }; const BG = "#000000"; @@ -174,3 +176,62 @@ test("scrollPlan: a picture that fits is not a crawl", () => { test("scrollPlan: nothing asked for, nothing planned", () => { assert.equal(scrollPlan({ id: "i" }, { width: 100, height: 100 }, { ...AREA, seconds: 4 }), null); }); + +// --------------------------------------------------------------------------- +// A row of panels. Two phone captures that argue with each other, side by side. +// --------------------------------------------------------------------------- + +test("panelLayout: panels are matched by HEIGHT, and the widths fall out", () => { + // destinys-child p05: a 327x675 expulsion post beside an 853x1280 poster. + const plan = panelLayout( + [{ width: 327, height: 675 }, { width: 853, height: 1280 }], + { areaW: 1920, areaH: 1024, gap: 48 }, + ); + assert.equal(plan.factor, 1); + assert.deepEqual(plan.boxes, [{ w: 496, h: 1024 }, { w: 682, h: 1024 }]); + assert.equal(plan.totalWidth, 496 + 682 + 48); + assert.ok(plan.totalWidth <= 1920); +}); + +test("panelLayout: an overflowing row shrinks by ONE factor", () => { + // Four wide panels at full height would be 4 x 1024 = 4096px of picture. + const plan = panelLayout( + [1, 1, 1, 1].map(() => ({ width: 1000, height: 1000 })), + { areaW: 1920, areaH: 1024, gap: 40 }, + ); + // room = 1920 - 120 = 1800, wanted = 4 x 1024 = 4096. + assert.ok(Math.abs(plan.factor - 1800 / 4096) < 1e-9); + assert.ok(plan.totalWidth <= 1920); + // The SAME factor everywhere: a per-panel fit would keep the row fitting and + // quietly restate the relative sizes of the exhibits. + assert.equal(new Set(plan.boxes.map((b) => b.w)).size, 1); + assert.equal(new Set(plan.boxes.map((b) => b.h)).size, 1); + assert.ok(plan.boxes[0].h < 1024); +}); + +test("panelLayout: the gaps keep their width while the pictures give way", () => { + const plan = panelLayout( + [{ width: 2000, height: 1000 }, { width: 2000, height: 1000 }], + { areaW: 1920, areaH: 1024, gap: 48 }, + ); + assert.equal(plan.gap, 48); + assert.equal(plan.totalWidth - plan.boxes.reduce((a, b) => a + b.w, 0), 48); + assert.ok(plan.totalWidth <= 1920); +}); + +test("panelLayout: two to four, and a gap that leaves room", () => { + const area = { areaW: 1920, areaH: 1024, gap: 40 }; + assert.throws(() => panelLayout([{ width: 1, height: 1 }], area), /2 to 4 images/); + assert.throws( + () => panelLayout(new Array(5).fill({ width: 1, height: 1 }), area), + /2 to 4 images \(got 5\)/, + ); + assert.throws( + () => panelLayout([{ width: 0, height: 1 }, { width: 1, height: 1 }], area), + /panels\[0\]: no usable dimensions/, + ); + assert.throws( + () => panelLayout([{ width: 1, height: 1 }, { width: 1, height: 1 }], { ...area, gap: 2000 }), + /gap 2000 leaves no room for 2 panels across 1920px/, + ); +});