Archilyzer · Source

archilyzer

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

commit 3e077dc084c158c86afb0e698269a9ba1b4bbba9
parent a80a177f047bb97bd19a3a469e97ff13ab5c77af
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed, 30 Sep 2026 21:35:14 -0400

deck S2: tests for the deck composition; an absolute out dir for the still

chrome-deck.test.mjs: every segment has its own title, subtitle and QR nodes;
the cue times equal deckChoreography's handovers and slides; each cue's from is
the previous cue's to and no two overlap on one element; the page holds no
URL, CDN or real font family; a window declares its own duration; and
composeChrome's deck path renders, hits the cache on identical inputs,
re-renders on a changed title, keeps a preview and a window in their own
directories, through a stub HYPERFRAMES_BIN.

composeChrome resolves its out directory: a relative one made the still's
file:// URL point at nothing.

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

Diffstat:
Aumtool/report-to-video/chrome-deck.test.mjs | 309+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/compose-chrome.mjs | 3++-
2 files changed, 311 insertions(+), 1 deletion(-)

diff --git a/umtool/report-to-video/chrome-deck.test.mjs b/umtool/report-to-video/chrome-deck.test.mjs @@ -0,0 +1,309 @@ +// Tests for the deck composition (chrome-deck.mjs) and compose-chrome's deck +// path: the page has every segment's nodes, the timeline's times are +// deckChoreography's, nothing leaves the machine, and the render cache skips +// what it should. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { deckCues, deckHtml, GSAP_FILE, hostOf, mix, subtitleMarkup } from "./chrome-deck.mjs"; +import { deckChoreography, deckLayout } from "./deck.mjs"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); + +const RENDER = { + width: 1920, + height: 1080, + fps: 30, + transition: 0.5, + palette: { bg: "#12101a", fg: "#f4f1ea", muted: "#9a93ad", accent: "#a97bff", amber: "#ffc860" }, + chrome: { engine: "hyperframes", layout: "deck", deck: {} }, +}; + +/** A cut that opens and closes on a card, with an untitled clip and a hostile title. */ +function schedule() { + const seg = (id, type, start, duration, extra = {}) => ({ + id, type, start, duration, end: start + duration, + title: "", subtitle: "", qrUrl: null, hideDeck: false, ...extra, + }); + return { + version: 1, kind: "deck", estimated: true, fps: 30, transition: 0.5, total: 40, multiChannel: false, + segments: [ + seg("t00", "card", 0, 5, { title: "Opening", subtitle: "a card", hideDeck: true }), + seg("c01", "clip", 4.5, 10, { title: 'He said "largest" </script><b>', subtitle: "Ferret Rescue · Sep 5, 2024", qrUrl: "https://jasolyzer.pages.dev/?v=a&t=51" }), + seg("c02", "clip", 14, 8, { title: "A sanctuary", subtitle: "Ferret Rescue · Sep 5, 2024", qrUrl: "https://jasolyzer.pages.dev/?v=a&t=62" }), + seg("c03", "clip", 21.5, 13, { subtitle: "Untitled source · Sep 29, 2024", qrUrl: "https://www.youtube.com/watch?v=b" }), + seg("end", "card", 34, 6, { title: "Close", subtitle: "", hideDeck: true }), + ], + }; +} + +const QRS = { c01: "assets/qr00.png", c02: "assets/qr01.png", c03: "assets/qr02.png" }; +const FONTS = { regular: "assets/DeckSans.ttf", bold: "assets/DeckSansBold.ttf" }; + +const dataOf = (html) => JSON.parse(/<script id="deck-data" type="application\/json">(.*?)<\/script>/s.exec(html)[1]); +const near = (a, b, msg) => assert.ok(Math.abs(a - b) < 1e-3, `${msg}: ${a} != ${b}`); + +test("every segment has its own nodes: title, subtitle, QR", () => { + const s = schedule(); + const html = deckHtml(s, RENDER, { fonts: FONTS, qrSrcs: QRS }); + for (const seg of s.segments) { + const open = html.indexOf(`data-seg="${seg.id}"`); + assert.ok(open > 0, `no node for ${seg.id}`); + const body = html.slice(open, html.indexOf('<div class="seg', open + 1) > 0 ? html.indexOf('<div class="seg', open + 1) : undefined); + assert.match(body, /class="deck-title"/, `${seg.id} title`); + assert.match(body, /class="deck-sub"/, `${seg.id} subtitle`); + assert.match(body, /class="deck-qr/, `${seg.id} qr`); + if (QRS[seg.id]) assert.ok(body.includes(`<img src="${QRS[seg.id]}"`), `${seg.id} qr img`); + } + // The title is text, not markup, and cannot close the page's script. + assert.ok(html.includes("He said &quot;largest&quot; &lt;/script&gt;&lt;b&gt;")); + assert.equal((html.match(/<\/script>/g) ?? []).length, 3, "gsap, data, runtime -- and no fourth"); + // An untitled clip is marked, so its source line takes the title's band. + assert.match(html, /class="seg untitled" data-seg="c03"/); + // The composition contract. + assert.match(html, /data-composition-id="deck" data-start="0" data-duration="40"/); + assert.match(html, /data-width="1920" data-height="190"/); + assert.match(html, /window\.__timelines\["deck"\] = tl/); + assert.match(html, /font-family: 'DeckSans'/); + assert.match(html, /font-family: 'DeckSansBold'/); +}); + +test("nothing on the page leaves the machine", () => { + const html = deckHtml(schedule(), RENDER, { fonts: FONTS, qrSrcs: QRS }); + assert.doesNotMatch(html, /https?:\/\//, "a URL in the page"); + assert.doesNotMatch(html, /(?:src|href)="\/\//, "a protocol-relative URL"); + assert.doesNotMatch(html, /@import|fonts\.googleapis|cdn\./); + assert.match(html, /<script src="assets\/gsap\.min\.js"><\/script>/); + // Every font stack ends in sans-serif and names no real family. + for (const m of html.matchAll(/font-family: ([^;]+);/g)) { + assert.match(m[1], /^'DeckSans(?:Bold)?'(?:, sans-serif)?$/, m[1]); + } + // The QR's host is printed as a host, not a link. + assert.ok(html.includes(">jasolyzer.pages.dev<")); + assert.ok(html.includes(">youtube.com<")); +}); + +test("the chart band's GSAP is vendored too", () => { + const src = readFileSync(path.join(HERE, "compose-chrome.mjs"), "utf8"); + assert.doesNotMatch(src, /cdn\.jsdelivr|unpkg|cdnjs/); + assert.match(src, /<script src="assets\/gsap\.min\.js"><\/script>/); + assert.match(readFileSync(GSAP_FILE, "utf8").slice(0, 200), /GSAP 3\.14\.2/); +}); + +test("the timeline's times are deckChoreography's", () => { + const s = schedule(); + const ch = deckChoreography(s, RENDER); + const { cues } = deckCues(s, RENDER); + const ids = s.segments.map((x) => x.id); + const at = (k, why) => cues.filter((c) => c.k === k && c.why.startsWith(why)); + assert.equal(ch.handovers.length, 2); + for (const h of ch.handovers) { + const a = ids.indexOf(h.from), b = ids.indexOf(h.to); + const why = `handover ${h.from}->${h.to}`; + const [out] = at(`s${a}.tc`, `${why} out`); + near(out.at, h.out[0], "title out starts"); + near(out.at + out.dur, h.out[1], "title out ends"); + const [inn] = at(`s${b}.tc`, `${why} in`); + near(inn.at, h.in[0], "title in starts"); + near(inn.at + inn.dur, h.in[1], "title in ends"); + assert.equal(inn.ease, "power3.out"); + const [sub] = at(`s${b}.sc`, `${why} sub`); + near(sub.at, h.sub[0], "subtitle trails"); + near(sub.at + sub.dur, h.sub[1], "subtitle ends"); + const [rule] = at(`s${b}.rule`, `${why} in`); + near(rule.at, h.in[0], "the rule grows with the title"); + const [qrOut] = at(`s${a}.qr`, `${why} qr`); + const [qrIn] = at(`s${b}.qr`, `${why} qr`); + near(qrOut.at, h.qr[0], "qr flips out"); + near(qrOut.at + qrOut.dur, h.m, "qr edge-on at m"); + near(qrIn.at, h.m, "qr flips in from m"); + near(qrIn.at + qrIn.dur, h.qr[1], "qr square again"); + const [pip] = at("marker", why); + near(pip.at, h.pip[0], "marker leaves"); + near(pip.at + pip.dur, h.pip[1], "marker arrives"); + } + // The slides: down off the first card from the first frame, up into c01, down into the end card. + const { init } = deckCues(s, RENDER); + assert.equal(init.panel.y, 202); + const slides = cues.filter((c) => c.k === "panel"); + const vis = ch.visibility.filter((v) => v.at[1] > v.at[0]); + assert.equal(slides.length, vis.length); + slides.forEach((c, n) => { + near(c.at, vis[n].at[0], "slide starts"); + near(c.at + c.dur, vis[n].at[1], "slide ends"); + assert.equal(c.to.y, vis[n].hide ? 202 : 0); + }); +}); + +test("the page carries exactly deckCues' list", () => { + const s = schedule(); + const { init, cues } = deckCues(s, RENDER); + const d = dataOf(deckHtml(s, RENDER, { fonts: FONTS, qrSrcs: QRS })); + assert.deepEqual(d.init, init); + assert.deepEqual(d.cues, cues.map(({ why, ...c }) => c)); + assert.equal(d.window, null); + assert.deepEqual(d.ids, s.segments.map((x) => x.id)); + assert.equal(d.floor, Math.ceil(54 * 0.6)); +}); + +test("every cue states the from the cue before it left, and none overlaps another on one element", () => { + const { init, cues } = deckCues(schedule(), RENDER); + const state = JSON.parse(JSON.stringify(init)); + const busy = new Map(); + for (const c of cues) { + for (const [p, v] of Object.entries(c.from)) assert.deepEqual(v, state[c.k]?.[p], `${c.k}.${p} at ${c.at}`); + Object.assign((state[c.k] ??= {}), c.to); + assert.ok(c.at >= (busy.get(c.k) ?? 0) - 1e-9, `${c.k} overlaps at ${c.at}`); + busy.set(c.k, c.at + c.dur); + } + for (let i = 1; i < cues.length; i += 1) assert.ok(cues[i].at >= cues[i - 1].at, "in time order"); +}); + +test("pips: one per segment shown with the deck, none for cards, all lit by the end", () => { + const s = schedule(); + const { pips, cues, init } = deckCues(s, RENDER); + assert.deepEqual(pips.map((p) => p.id), ["c01", "c02", "c03"]); + const lay = deckLayout(RENDER); + assert.equal(pips[0].x, lay.pipTrack.x0); + assert.equal(pips[2].x, lay.pipTrack.x1); + for (let k = 0; k < 2; k += 1) { + assert.equal(init[`pip${k}`].opacity, 0); + assert.ok(cues.some((c) => c.k === `pip${k}` && c.to.opacity === 1), `pip ${k} is lit once passed`); + } + // The fuse reaches each next pip as the marker sets off for it. + const fuse = cues.filter((c) => c.k === "fuse"); + const marker = cues.filter((c) => c.k === "marker" && c.dur > 0.01); + assert.equal(fuse.length, 2); + near(fuse[0].at + fuse[0].dur, marker[0].at, "fuse meets the marker"); + assert.equal(fuse[1].to.scaleX, 1); +}); + +test("a window plays the cut's own clock from `from`", () => { + const s = schedule(); + const html = deckHtml(s, RENDER, { fonts: FONTS, qrSrcs: QRS, from: 12, duration: 6 }); + assert.match(html, /data-composition-id="deck" data-start="0" data-duration="6"/); + assert.deepEqual(dataOf(html).window, { from: 12, dur: 6 }); + assert.throws(() => deckHtml(s, RENDER, { from: 40 }), /nothing to render/); +}); + +test("flush has no lift; panel does", () => { + const flush = { ...RENDER, chrome: { ...RENDER.chrome, deck: { background: "flush" } } }; + const f = deckHtml(schedule(), flush, { fonts: FONTS, qrSrcs: QRS }); + assert.match(f, new RegExp(`linear-gradient\\(180deg, ${RENDER.palette.bg} 0%, ${RENDER.palette.bg} 100%\\)`)); + assert.doesNotMatch(f, /class="edge"/); + const p = deckHtml(schedule(), RENDER, { fonts: FONTS, qrSrcs: QRS }); + assert.match(p, /class="edge"/); + assert.ok(p.includes(mix(RENDER.palette.bg, RENDER.palette.fg, 0.095))); +}); + +test("qr.show false: no codes anywhere", () => { + const off = { ...RENDER, chrome: { ...RENDER.chrome, deck: { qr: { show: false } } } }; + const html = deckHtml(schedule(), off, { fonts: FONTS, qrSrcs: {} }); + assert.doesNotMatch(html, /<img/); + assert.doesNotMatch(html, /class="plate"/); +}); + +test("subtitleMarkup and hostOf", () => { + assert.equal( + subtitleMarkup("A <b> · Sep 5, 2024"), + '<span class="part">A &lt;b&gt;</span><span class="sep">·</span><span class="part">Sep 5, 2024</span>', + ); + assert.equal(subtitleMarkup(""), '<span class="part"></span>'); + assert.equal(hostOf("https://www.youtube.com/watch?v=x"), "youtube.com"); + assert.equal(hostOf("not a url"), ""); +}); + +// --------------------------------------------------------------------------- +// compose-chrome's deck path, end to end with a stub renderer. +// --------------------------------------------------------------------------- + +const haveTools = ["qrencode", "magick"].every( + (b) => spawnSync(b, [b === "magick" ? "-version" : "-V"], { stdio: "ignore" }).status === 0, +); + +test("composeChrome(deck): project, frames, cache hit, and a changed title re-renders", { skip: !haveTools }, async () => { + const { composeChrome } = await import("./compose-chrome.mjs"); + const dir = mkdtempSync(path.join(tmpdir(), "deck-s2-")); + const prevBin = process.env.HYPERFRAMES_BIN; + try { + // A renderer stub: writes as many frames as the root declares and logs each run. + const stub = path.join(dir, "hf-stub.mjs"); + writeFileSync(stub, `#!/usr/bin/env node +import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +const a = process.argv.slice(2); +const out = a[a.indexOf("--output") + 1], fps = Number(a[a.indexOf("--fps") + 1]), proj = a[a.length - 1]; +const dur = Number(/data-duration="([\\d.]+)"/.exec(readFileSync(path.join(proj, "index.html"), "utf8"))[1]); +mkdirSync(out, { recursive: true }); +for (let i = 1; i <= Math.round(dur * fps); i += 1) writeFileSync(path.join(out, "frame_" + String(i).padStart(6, "0") + ".png"), ""); +appendFileSync(${JSON.stringify(path.join(dir, "runs.log"))}, a.join(" ") + "\\n"); +`); + chmodSync(stub, 0o755); + process.env.HYPERFRAMES_BIN = stub; + + const manifest = { + slug: "t", title: "t", provenance: {}, + render: { + ...RENDER, + fontRegular: path.join(HERE, "fonts", "IBMPlexMono-Regular.ttf"), + fontBold: path.join(HERE, "fonts", "IBMPlexMono-Bold.ttf"), + }, + timeline: [], + }; + const manifestPath = path.join(dir, "video.manifest.json"); + writeFileSync(manifestPath, JSON.stringify(manifest)); + const sched = { ...schedule(), total: 2, segments: schedule().segments.slice(1, 3).map((x, i) => ({ ...x, start: i * 1, end: i + 1, duration: 1 })) }; + const runs = () => { try { return readFileSync(path.join(dir, "runs.log"), "utf8").trim().split("\n").length; } catch { return 0; } }; + + const first = await composeChrome({ manifestPath, region: "deck", schedule: sched, doRender: true, workers: 2 }); + assert.equal(first.cached, false); + assert.equal(first.frameCount, 60); + assert.equal(first.projDir, path.join(dir, "out", "sourced", "chrome", "deck")); + assert.equal(first.frames, path.join(dir, "out", "sourced", "chrome", "deck-frames")); + assert.equal(readFileSync(path.join(first.frames, ".key"), "utf8").trim(), first.key); + assert.equal(runs(), 1); + const html = readFileSync(path.join(first.projDir, "index.html"), "utf8"); + assert.match(html, /src="assets\/qr00\.png"/); + assert.match(html, /url\('assets\/DeckSansBold\.ttf'\)/); + assert.match(readFileSync(path.join(dir, "runs.log"), "utf8"), /render --format png-sequence --quality high --fps 30 -w 2 --no-browser-gpu --output/); + + const again = await composeChrome({ manifestPath, region: "deck", schedule: sched, doRender: true, workers: 2 }); + assert.equal(again.cached, true); + assert.equal(again.key, first.key, "same inputs, same key (the QR PNGs are byte-stable)"); + assert.equal(runs(), 1); + + const edited = { ...sched, segments: sched.segments.map((x, i) => (i ? x : { ...x, title: "Edited" })) }; + const third = await composeChrome({ manifestPath, region: "deck", schedule: edited, doRender: true, workers: 2 }); + assert.equal(third.cached, false); + assert.notEqual(third.key, first.key); + assert.equal(runs(), 2); + + // A preview composes elsewhere and never renders. + const prev = await composeChrome({ manifestPath, region: "deck", schedule: sched, preview: true, doRender: true }); + assert.equal(prev.projDir, path.join(dir, "out", "sourced", "chrome", "deck-preview")); + assert.equal(prev.frames, null); + assert.equal(runs(), 2); + + // A window is its own project and frames. + const win = await composeChrome({ manifestPath, region: "deck", schedule: sched, doRender: true, from: 0.5, duration: 1 }); + assert.equal(win.frames, path.join(dir, "out", "sourced", "chrome", "deck-from0.5-frames")); + assert.equal(win.frameCount, 30); + + // Without a schedule passed in, it reads out/<variant>/schedule.json -- and says so when there is none. + await assert.rejects( + composeChrome({ manifestPath, region: "deck", outDir: path.join(dir, "nowhere") }), + /the deck needs .*schedule\.json/, + ); + } finally { + if (prevBin === undefined) delete process.env.HYPERFRAMES_BIN; + else process.env.HYPERFRAMES_BIN = prevBin; + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/umtool/report-to-video/compose-chrome.mjs b/umtool/report-to-video/compose-chrome.mjs @@ -695,7 +695,8 @@ export async function composeChrome({ // the band would draw claims this cut never makes, and the deck would name // clips it does not play. const manifest = selectVariant(JSON.parse(await readFile(manifestPath, "utf8")), variant); - const base = outDir ?? path.join(path.dirname(path.resolve(manifestPath)), "out", variant); + // Absolute: the still is a file:// URL, and a relative one is no page at all. + const base = path.resolve(outDir ?? path.join(path.dirname(path.resolve(manifestPath)), "out", variant)); from = Number(from ?? 0); let sched = schedule;