Archilyzer · Source

archilyzer

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

commit c0bb74dd317c4e351838f642298814fb398f22f9
parent 24bcfbca9d181e42f16422a98f8fcd0655f4eddf
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 01:29:55 -0400

report-to-video: the posts page names its planner itself, a deck without posts is never refused for the column, and qrencode takes -- before a URL

Review fixes. The posts page embedded postsCues by toString() and called it by
name; umtool's production build renames the module function, so the live
preview's page threw and drew nothing. embedFn writes it as a named const of a
function expression, so the page's name never depends on the module's.
validateChrome holds a deck to the posts column's fit only when the deck sets
deck.posts; validatePosts(posts, timeline, render) holds drawn posts to it, so
a narrow deck with no posts builds again. All three qrencode callers pass --
before the URL. Records corrected (As built, quirks, the spec's header).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mplans/deck-posts.md | 11+++++++++--
Mumtool/docs/quirks.md | 11+++++++----
Mumtool/e2e/onscreen-posts.spec.ts | 7+++----
Mumtool/lib/report/manifest.mjs | 2+-
Mumtool/report-to-video/build-video.mjs | 5+++--
Mumtool/report-to-video/chrome-posts.mjs | 14+++++++++++++-
Mumtool/report-to-video/chrome-posts.test.mjs | 11+++++++++++
Mumtool/report-to-video/compose-chrome.mjs | 2+-
Mumtool/report-to-video/deck.mjs | 27+++++++++++++++++++++------
Mumtool/report-to-video/deck.test.mjs | 15+++++++++++++++
Mumtool/report-to-video/render-cards.mjs | 2+-
11 files changed, 85 insertions(+), 22 deletions(-)

diff --git a/plans/deck-posts.md b/plans/deck-posts.md @@ -79,8 +79,15 @@ Branch `deck/posts` from `main` 2cf43a69; slices merged `--no-ff` after review. build importing the page module pulled the composition's asset URL into umtool's bundle and failed its `next build` while collecting page data — a failure no unit test could see. - **The stack is planned in the page,** after the fonts load: whether a card overflows the column - depends on measured heights. The planner's source is embedded in the page, so the page and the - tests run one implementation. + depends on measured heights. The planner's source is embedded in the page under a fixed name + (`embedFn`: `const postsCues = (<source>)`), so the page and the tests run one implementation + even after umtool's production build renames the module function (the review's F1: the bare + `toString()` declared the minified name and the page threw, drawing nothing in umtool's live + preview; builds were unaffected). +- **The column's fit is checked where posts are known** (review F2): `validateChrome` holds a deck + to it only when the deck sets `deck.posts`, and `validatePosts(posts, timeline, render)` holds + drawn posts to it. A deck with no posts is never refused for a column it does not draw. +- `qrencode` takes `--` before the URL in all three callers: a URL is data, never an option. - **A post's date is drawn as the day in its own string** (UTC as archived). - The overlay of a window uses `-itsoffset`, `-reinit_filter 0`, `format=rgba` and `eof_action=pass`, never `shortest=1`; a framemd5 test shows every frame outside a window is diff --git a/umtool/docs/quirks.md b/umtool/docs/quirks.md @@ -236,10 +236,13 @@ frame sees the built timeline. umtool's server bundles build-video into every report route, and Turbopack turns `chrome-deck.mjs`'s `new URL("./assets/gsap.min.js", import.meta.url)` into an asset URL that `fileURLToPath` refuses at module load ("Received an -instance of URL"), failing `next build` while it collects page data. The page -modules are reached only through compose-chrome's dynamic import; the posts -windows' frame arithmetic the build needs (`snapWindow`) lives in `deck.mjs` -for that reason. A capped umtool build is the gate that catches it — the unit +instance of URL"), failing `next build` while it collects page data. So +build-video reaches the page modules only through a dynamic import of +compose-chrome, and the posts windows' frame arithmetic it needs +(`snapWindow`) lives in `deck.mjs`. This is about build-video's import chain, +not a wall around the page modules: umtool's preview helper +(`lib/report/onscreen.mjs`) imports compose-chrome statically, as it did before +posts, and its routes build. A capped umtool build is the gate that catches it — the unit tests run in plain Node and pass either way. ## Rail strips and rolling counters diff --git a/umtool/e2e/onscreen-posts.spec.ts b/umtool/e2e/onscreen-posts.spec.ts @@ -14,10 +14,9 @@ import { postWindows } from "umtool-report-to-video/deck"; // c02 ("date"). Never built, so every timing is the estimate's. Each test puts // the posts back to automatic and shown through the route before it starts. // -// The posts region's COMPOSITION is compose-chrome's (`region: "posts"`, slice -// P1 of plans/deck-posts.md). When it is there, the preview test asserts the -// window's page reports `posts:ready`; when it is not, the route says so per -// window and the test asserts that refusal instead, and annotates the run. +// The posts region's COMPOSITION is compose-chrome's (`region: "posts"`): the +// preview test asserts every window composes and its page reports +// `posts:ready` in the live preview. // --------------------------------------------------------------------------- const HERE = path.dirname(fileURLToPath(import.meta.url)); diff --git a/umtool/lib/report/manifest.mjs b/umtool/lib/report/manifest.mjs @@ -655,7 +655,7 @@ export async function updatePosts(dir, posts, { token = null } = {}) { else delete post.hide; } } - const errors = validatePosts(manifest.posts, manifest.timeline ?? []); + const errors = validatePosts(manifest.posts, manifest.timeline ?? [], manifest.render); if (errors.length) throw new PostsRefused(errors); const nextToken = await writeManifestAtomic(dir, manifest); diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs @@ -946,7 +946,8 @@ async function qrForEntry(entry, provenance, render, outDir) { "-s", String(q.scale ?? 4), "-m", String(q.quiet ?? 3), "-l", q.ecc ?? "M", - url, + // `--`: a URL is data, never an option, whatever it starts with. + "--", url, ]); return { png, url }; } @@ -2596,7 +2597,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly // Posts are drawn only under the deck, so only the deck refuses bad ones -- // against the WHOLE timeline, where an `attachTo` has to name a clip. if (deck) { - const errors = validatePosts(whole.posts, whole.timeline ?? []); + const errors = validatePosts(whole.posts, whole.timeline ?? [], whole.render); if (errors.length) throw new Error(`posts: ${errors.join("; ")}`); } // An `image` entry's `src` is relative to the MANIFEST, which is checked in diff --git a/umtool/report-to-video/chrome-posts.mjs b/umtool/report-to-video/chrome-posts.mjs @@ -79,6 +79,18 @@ export function windowPosts(schedule, segment) { * @returns {{ tops: number[], init: Record<string, object>, * cues: Array<{ k: string, at: number, dur: number, from: object, to: object, ease: string, why: string }> }} */ +/** + * A module function written into the page under a FIXED name. The page calls + * it by that name, and `fn.toString()` alone would declare whatever name the + * function has here -- which a bundler minifying server code renames (umtool's + * production build turned `postsCues` into `d`, and the page then threw a + * ReferenceError and drew nothing). As a named const of a parenthesised + * function expression, the page's name never depends on the module's. + */ +export function embedFn(name, fn) { + return `const ${name} = (${fn.toString()});`; +} + export function postsCues({ posts, heights, column, gap = 14, enter = 0.35, slide = 0.35, rise = 18 }) { const R = (v) => Math.round(v * 10000) / 10000; const MIN = 0.001; @@ -313,7 +325,7 @@ export function postsHtml(schedule, render, window, opts = {}) { const P = JSON.parse(document.getElementById("posts-data").textContent); const byK = {}; for (const el of document.querySelectorAll("[data-k]")) byK[el.dataset.k] = el; - ${postsCues.toString()} + ${embedFn("postsCues", postsCues)} const params = new URLSearchParams(location.search); const local = (t) => Math.max(0, Math.min(P.dur, (Number(t) || 0) - P.from)); diff --git a/umtool/report-to-video/chrome-posts.test.mjs b/umtool/report-to-video/chrome-posts.test.mjs @@ -420,3 +420,14 @@ appendFileSync(${JSON.stringify(path.join(dir, "runs.log"))}, a.join(" ") + "\\n rmSync(dir, { recursive: true, force: true }); } }); + +test("embedFn: the page declares the planner under its own name, whatever the bundler called it", async () => { + const { embedFn } = await import("./chrome-posts.mjs"); + // A minifier renames the module function; the page must still find `postsCues`. + const renamed = function d(a) { return a * 2; }; + const src = embedFn("postsCues", renamed); + assert.equal(new Function(`${src}; return postsCues(21);`)(), 42); + // And the page the module writes uses it, not a bare toString(). + const { postsCues } = await import("./chrome-posts.mjs"); + assert.match(embedFn("postsCues", postsCues), /^const postsCues = \(function postsCues\(/); +}); diff --git a/umtool/report-to-video/compose-chrome.mjs b/umtool/report-to-video/compose-chrome.mjs @@ -548,7 +548,7 @@ export function chartBandHtml(manifest, totals, schedule, opts = {}) { export async function qrPng(url, render, outPath, size) { const q = render.qr ?? {}; const raw = `${outPath}.raw.png`; - await run(QRENCODE, ["-o", raw, "-s", String(q.scale ?? 4), "-m", String(q.quiet ?? 3), "-l", q.ecc ?? "M", url]); + await run(QRENCODE, ["-o", raw, "-s", String(q.scale ?? 4), "-m", String(q.quiet ?? 3), "-l", q.ecc ?? "M", "--", url]); await run(MAGICK, [raw, "-filter", "point", "-resize", `${size}x${size}!`, "-strip", outPath]); await rm(raw, { force: true }); return outPath; diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs @@ -193,11 +193,9 @@ export function validateChrome(chrome, render = {}) { // make the setting lie about what was drawn. if (!errors.length) { const g = deckGeometry({ ...render, chrome }); - const pp = resolveDeck({ chrome }).posts; - if (pp.width + 2 * pp.inset > g.footage.width) { - errors.push(`${w}.posts.width ${pp.width} with inset ${pp.inset} does not fit the ${g.footage.width}px footage box`); - } - if (pp.qrSize > pp.width / 2) errors.push(`${w}.posts.qrSize ${pp.qrSize} is more than half the card's width`); + // Only a deck that sets `posts` is held to the column's fit here; one with + // posts on the defaults is checked by validatePosts, which sees the posts. + if (d.posts !== undefined) errors.push(...postsFitErrors({ ...render, chrome })); const room = g.H - g.deck.height; if (g.footage.height > room) { const max = Math.floor((room / g.H) * 1000) / 1000; @@ -588,7 +586,7 @@ const POST_KEYS = ["id", "platform", "author", "handle", "date", "text", "url", * * @returns {string[]} */ -export function validatePosts(posts, timeline = []) { +export function validatePosts(posts, timeline = [], render = null) { if (posts === undefined || posts === null) return []; if (!Array.isArray(posts)) return ["posts must be a list"]; const errors = []; @@ -617,6 +615,23 @@ export function validatePosts(posts, timeline = []) { } if (p.hide !== undefined && typeof p.hide !== "boolean") errors.push(`${w}.hide must be true or false`); }); + // Posts that will be drawn need a column that fits the footage box. + if (render && deckOn(render) && resolveDeck(render).posts.show && posts.some((p) => isObj(p) && !p.hide)) { + errors.push(...postsFitErrors(render)); + } + return errors; +} + +/** Why the posts column cannot be drawn in this frame, as sentences (empty: it can). */ +export function postsFitErrors(render) { + const w = "render.chrome.deck"; + const g = deckGeometry(render); + const pp = resolveDeck(render).posts; + const errors = []; + if (pp.width + 2 * pp.inset > g.footage.width) { + errors.push(`${w}.posts.width ${pp.width} with inset ${pp.inset} does not fit the ${g.footage.width}px footage box`); + } + if (pp.qrSize > pp.width / 2) errors.push(`${w}.posts.qrSize ${pp.qrSize} is more than half the card's width`); return errors; } diff --git a/umtool/report-to-video/deck.test.mjs b/umtool/report-to-video/deck.test.mjs @@ -349,3 +349,18 @@ test("validatePosts and the posts settings refuse in sentences", () => { assert.match(validateChrome({ ...CHROME, deck: { posts: { position: "middle" } } }, RENDER)[0], /position/); assert.deepEqual(validateChrome({ ...CHROME, deck: { posts: { show: false } } }, RENDER), []); }); + +test("posts fit: a deck without posts is never refused for the column; drawn posts are", () => { + // 1280×720 at 0.5: a 640 px footage box, narrower than the default column. + const narrow = { width: 1280, height: 720, fps: 30, transition: 0.5, chrome: { ...CHROME, deck: { footageScale: 0.5 } } }; + assert.deepEqual(validateChrome(narrow.chrome, narrow), []); + // Posts that will be drawn there are refused, with the column's sentence. + assert.match(validatePosts([POST("p", "2024-01-01")], CLIPS, narrow).join(" "), /does not fit the 640px footage box/); + // Hidden, switched off, or no render to check against: nothing to fit. + assert.deepEqual(validatePosts([POST("p", "2024-01-01", { hide: true })], CLIPS, narrow), []); + const off = { ...narrow, chrome: { ...CHROME, deck: { footageScale: 0.5, posts: { show: false } } } }; + assert.deepEqual(validatePosts([POST("p", "2024-01-01")], CLIPS, off), []); + assert.deepEqual(validatePosts([POST("p", "2024-01-01")], CLIPS), []); + // A deck that SETS posts is still held to the fit by validateChrome. + assert.match(validateChrome({ ...CHROME, deck: { footageScale: 0.5, posts: { width: 600 } } }, narrow).join(" "), /does not fit/); +}); diff --git a/umtool/report-to-video/render-cards.mjs b/umtool/report-to-video/render-cards.mjs @@ -764,7 +764,7 @@ async function qrTileStrip(entries, provenance, render, g, outDir) { const png = path.join(qrDir, `q${seen.size.toString().padStart(2, "0")}.png`); await execFileP(QRENCODE, [ "-o", png, "-s", String(q.scale ?? 4), "-m", String(q.quiet ?? 3), - "-l", q.ecc ?? "M", url, + "-l", q.ecc ?? "M", "--", url, ]); // Nearest-neighbour to an exact box: a resampled QR blurs its module edges // and stops scanning, and the geometry has to be known before this runs.