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:
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 "largest" </script><b>"));
+ 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 <b></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;