Archilyzer · Source

archilyzer

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

commit ee7b10ed12cb0ada9c3990770ad86072f1f9d89e
parent 8b61a65f1c2719efccf02401a003b01a07bde694
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon,  5 Oct 2026 10:17:02 -0400

umtool: posts can ride the whole clip (posts.at "start"), shotMaxHeight, a "web" platform and per-post variant; snapLead/snapTail keep a pause's breath at the cut

- deck posts.at: "end" (default, unchanged) | "start" -- a popup's posts come up with
  their clip, in manifest order, sharing it evenly; with hold 0 the cut never freezes
- deck posts.shotMaxHeight: caps a screenshot in px (null = a full card of words, as before)
- posts[].platform "web" (a page's words); posts[].variant keeps a post to one cut
  (selectVariant filters posts like entries)
- render.snapLead / render.snapTail: how much of the pause a snapped cut keeps, never more
  than the pause holds (defaults 0.10 / 0.18 unchanged), so a crossfade fades over the
  breath rather than the last word

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

Diffstat:
Mumtool/report-to-video/README.md | 25++++++++++++++++++++-----
Mumtool/report-to-video/build-video.mjs | 27+++++++++++++++++++++------
Mumtool/report-to-video/chrome-posts.mjs | 6+++---
Mumtool/report-to-video/chrome-posts.test.mjs | 9+++++++++
Mumtool/report-to-video/deck.mjs | 47+++++++++++++++++++++++++++++++++++++++++------
Mumtool/report-to-video/deck.test.mjs | 32++++++++++++++++++++++++++++++++
Mumtool/report-to-video/ledger-totals.test.mjs | 7+++++++
Aumtool/report-to-video/snap.test.mjs | 24++++++++++++++++++++++++
8 files changed, 157 insertions(+), 20 deletions(-)

diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md @@ -78,6 +78,13 @@ Three stages, because a cue span is the wrong answer twice over. tight cut beats a cut in the wrong place. The build logs `start✓ end✓` per clip so you can see which snapped. + How much of the pause is kept: 0.10 s before speech resumes and 0.18 s after + it stops by default. `render.snapLead` / `render.snapTail` (seconds) set them, + and then never more than the pause holds — so with a crossfade, a tail as long + as the `transition` fades out over the breath instead of the last word, and a + long lead lets the next clip fade in before its first word. Pair them with a + `silenceMinDur` that finds real pauses (~0.3 s), not the gaps between words. + **The silence threshold is relative, and it has to be.** These are game streams: the gaps between words are full of game audio and music — quiet, but nowhere near silent. A fixed absolute threshold sits below the noise floor and @@ -423,7 +430,7 @@ footage, so it rides on a clip. ```jsonc "posts": [ - { "id": "bs-3msydljwjis2a", "platform": "bluesky", // "bluesky" | "x" + { "id": "bs-3msydljwjis2a", "platform": "bluesky", // "bluesky" | "x" | "web" (a page's words) "author": "Pirate Software", "handle": "piratesoftware.live", "date": "2026-08-13T19:05:26.424Z", // ISO date or date-time "text": "We just signed off on 51 page document …", // newlines kept; ≤ 3000 characters @@ -433,15 +440,16 @@ footage, so it rides on a clip. "siteChannel": "piratesoftware-bsky", // optional: the archive channel that keeps it "siteUrl": null, // optional: an http(s) page the QR links instead "postId": null, // optional: its id, when `url` does not carry one - "shot": "shots/post-1.png" } ] // optional: a screenshot drawn instead of the text card + "shot": "shots/post-1.png", // optional: a screenshot drawn instead of the text card + "variant": "full" } ] // optional: in that cut only, as an entry's `variant` ``` - **Which clip.** The one whose recording most closely PRECEDES the post: the latest day on or before the post's (a clip's day is its own `date`, else its record's upload date — the build reads the real one), ties to the later clip in the cut; a post older than every clip goes on the first. `attachTo` overrides; - `hide: true` leaves a post out. A variant that drops the named clip falls back - to the date rule. + `hide: true` leaves a post out; `variant` keeps it to one cut. A variant that + drops the named clip falls back to the date rule. - **When.** A clip's posts, oldest first, stack: post j of k appears at A − seconds·(k − j), where A is the start of the outgoing transition (the next segment's start under a crossfade, 0.3 s before a hard cut, 0.3 s before the end @@ -449,6 +457,11 @@ footage, so it rides on a clip. and the last has the clip's final `seconds`; a clip too short for that shares what it has after its incoming dissolve. They all leave together over the transition. + + `at: "start"` brings them up with the clip instead: in the manifest's order + (not by date), sharing the whole clip after its incoming dissolve — post j of + k at from + j·(A − from)/k. A card beside the words it goes with needs no + hold; set `hold: 0`. - **The hold.** A clip that carries posts is held on its last frame, in silence, for `hold` seconds (2.5) before its outgoing transition, so the last post can be read. The hold is part of the clip's length in the CUT: every start after @@ -464,7 +477,9 @@ footage, so it rides on a clip. - **What.** The post's date (as the deck writes dates; a date-time is drawn as its day), `@handle · Bluesky` (or X), the words — paragraphs kept, clamped to `maxLines` with an ellipsis — and a QR of the post's page on the archive - (below), or of its own `url`. + (below), or of its own `url`. A post with a `shot` draws the screenshot in + place of the words, as wide as the card's text and no taller than a full card + of words, or than `shotMaxHeight` px when that is set. - **Where.** A column `inset` from the top of the footage box and from the FRAME's edge on the `position` side (inside the footage box when `shift` is `false`), `width` wide. Cards stack top-down; when the next would overflow the diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs @@ -199,11 +199,15 @@ export function selectVariant(manifest, variant = DEFAULT_VARIANT) { }); const kept = new Set(timeline.map((e) => e.id)); const ledger = (manifest.ledger ?? []).filter((c) => c.entryId && kept.has(c.entryId)); + // A post may be in one cut only, as an entry may. + const posts = Array.isArray(manifest.posts) + ? manifest.posts.filter((p) => !p?.variant || p.variant === variant) + : manifest.posts; // A brand preset resolves here, at the one door every reader of a cut goes // through, so the build, verify-build, compose-chrome and umtool's export // all see the same render block and the same appended end card. No brand: // the object as built above. - return brandManifest({ ...manifest, variant, timeline, ledger }); + return brandManifest({ ...manifest, variant, timeline, ledger, ...(posts !== undefined ? { posts } : {}) }); } /** @@ -964,7 +968,13 @@ async function detectSilence(file, render, scan = null) { // Snap a desired cut to the nearest silence, so the clip begins and ends between // words instead of through one. Returns the desired point unchanged when no // silence is close enough — better a tight cut than a cut in the wrong place. -function snap(desired, intervals, kind, window) { +// +// How much of the pause is kept: 0.10 s before speech resumes, 0.18 s after it +// stops, unless the render says `snapLead` / `snapTail` -- and then never more +// than the pause holds, so a long one keeps its breath (room for the +// crossfade to fade over silence, not the last word) without reaching the +// next word. Exported for the tests. +export function snap(desired, intervals, kind, window, render = {}) { let best = null; for (const iv of intervals) { // Starting: we want to resume just before speech does -> the silence's END. @@ -972,10 +982,15 @@ function snap(desired, intervals, kind, window) { const point = kind === "start" ? iv.e : iv.s; const d = Math.abs(point - desired); if (d > window) continue; - if (!best || d < best.d) best = { d, point }; + if (!best || d < best.d) best = { d, point, iv }; } if (!best) return { at: desired, snapped: false }; - const lead = kind === "start" ? -0.10 : 0.18; + const set = kind === "start" ? render.snapLead : render.snapTail; + let lead = kind === "start" ? -0.10 : 0.18; + if (Number.isFinite(set) && set >= 0) { + const room = best.iv.e - best.iv.s; + lead = (kind === "start" ? -1 : 1) * Math.min(set, room); + } return { at: Math.max(0, best.point + lead), snapped: true }; } @@ -1054,8 +1069,8 @@ async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes, const win = render.snapWindow ?? 1.6; const sil = await detectSilence(raw, render, scan); - const a = snap(wantA, sil, "start", win); - const b = snap(wantB, sil, "end", win); + const a = snap(wantA, sil, "start", win, render); + const b = snap(wantB, sil, "end", win, render); // Never let snapping invert or collapse the window. const cutA = Math.min(a.at, wantB - 1); const cutB = Math.max(b.at, cutA + 1); diff --git a/umtool/report-to-video/chrome-posts.mjs b/umtool/report-to-video/chrome-posts.mjs @@ -50,7 +50,7 @@ const esc = (s) => const r4 = (v) => Math.round(v * 10000) / 10000; /** How a platform is named on a card. */ -export const PLATFORM_LABEL = Object.freeze({ bluesky: "Bluesky", x: "X" }); +export const PLATFORM_LABEL = Object.freeze({ bluesky: "Bluesky", x: "X", web: "Web" }); /** * The motion. Seconds. A card slides in from the frame's edge over `enter`; @@ -335,9 +335,9 @@ export function postsHtml(schedule, render, window, opts = {}) { .para + .para { margin-top: ${Math.round(lineH * 0.36)}px; } .para.gone { display: none; } /* A post's screenshot in place of its words: as wide as the words would - be, no taller than a full card of them. */ + be, no taller than a full card of them -- or than \`shotMaxHeight\`. */ .shot-body { padding: ${pad - 6}px; } - .shot { display: block; width: 100%; height: auto; max-height: ${Math.round(metaSize * 1.3) + 8 + set.maxLines * lineH + 2 * pad}px; + .shot { display: block; width: 100%; height: auto; max-height: ${set.shotMaxHeight ?? Math.round(metaSize * 1.3) + 8 + set.maxLines * lineH + 2 * pad}px; object-fit: contain; object-position: left top; border-radius: 6px; } /* The source cell: the QR in a cell a shade down, as on the deck. */ .plate { position: absolute; right: 0; top: 0; bottom: 0; width: ${plateW}px; diff --git a/umtool/report-to-video/chrome-posts.test.mjs b/umtool/report-to-video/chrome-posts.test.mjs @@ -478,3 +478,12 @@ test("a post with a screenshot draws it in place of its text card, its QR cell k // Without shotSrcs the page is the text cards, as before. assert.doesNotMatch(postsHtml(sched, RENDER, win, { fonts: FONTS }), /class="shot"/); }); + +test("shotMaxHeight caps a screenshot in px; unset, a full card of words does", () => { + const sched = schedule([POST("a", "2024-10-19T17:01:17.640Z", { shot: "shots/a.png" })]); + const win = snapWindow(postWindows(sched)[0], { fps: 30, total: sched.total }); + const cap = (render) => /\.shot \{[^}]*max-height: (\d+)px/.exec(postsHtml(sched, render, win, { fonts: FONTS, shotSrcs: { a: "assets/shot00.png" } }))[1]; + const tall = { ...RENDER, chrome: { ...RENDER.chrome, deck: { ...RENDER.chrome.deck, posts: { ...RENDER.chrome.deck?.posts, shotMaxHeight: 820 } } } }; + assert.equal(cap(tall), "820"); + assert.notEqual(cap(RENDER), "820"); +}); diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs @@ -55,9 +55,14 @@ export const DECK_DEFAULTS = Object.freeze({ // archive the manifest names, `provenance.siteOrigin`, which survives the // post or the platform going away, and links on to the original) or // "original" (the bsky.app / x.com link itself). + // `at` is when a popup's posts come up: "end" (the last `seconds` of the + // clip, as above) or "start" (from the clip's start, sharing the whole clip + // in the order the manifest lists them -- the post beside the words it goes + // with, so no hold is needed). `shotMaxHeight` caps a post's screenshot in + // px; null keeps it to the height of a full card of words (`maxLines`). posts: Object.freeze({ show: true, layout: "popup", seconds: 4, hold: 2.5, position: "top-right", width: 600, qrSize: 120, maxLines: 7, - inset: 24, shift: Object.freeze({ scale: 0.86, seconds: 0.6 }), links: "archive", + inset: 24, shift: Object.freeze({ scale: 0.86, seconds: 0.6 }), links: "archive", at: "end", shotMaxHeight: null, }), }); @@ -208,7 +213,14 @@ export function validateChrome(chrome, render = {}) { errors.push(`${w}.qr.size ${size} does not fit a ${h}px deck (at most ${h - 20})`); } }); - sub("posts", ["show", "layout", "seconds", "hold", "position", "width", "qrSize", "maxLines", "inset", "shift", "links"], (p) => { + sub("posts", ["show", "layout", "seconds", "hold", "position", "width", "qrSize", "maxLines", "inset", "shift", "links", "at", "shotMaxHeight"], (p) => { + if (p.at !== undefined && !POST_AT.includes(p.at)) { + errors.push(`${w}.posts.at must be ${POST_AT.map((a) => `"${a}"`).join(" or ")}`); + } + if (p.shotMaxHeight !== undefined && p.shotMaxHeight !== null && + !(Number.isInteger(p.shotMaxHeight) && numIn(p.shotMaxHeight, 120, 1080))) { + errors.push(`${w}.posts.shotMaxHeight must be null or a whole number of pixels from 120 to 1080`); + } if (p.layout !== undefined && !POST_LAYOUTS.includes(p.layout)) { errors.push(`${w}.posts.layout must be ${POST_LAYOUTS.map((l) => `"${l}"`).join(" or ")}`); } @@ -737,12 +749,16 @@ export function pipSegments(schedule) { // `seconds`; all of them leave in the transition to the next segment. // --------------------------------------------------------------------------- -export const POST_PLATFORMS = Object.freeze(["bluesky", "x"]); +export const POST_PLATFORMS = Object.freeze(["bluesky", "x", "web"]); +/** When a popup's posts come up (`posts.at`): the end of their clip, or its start. */ +export const POST_AT = Object.freeze(["end", "start"]); +/** The cuts a post may be limited to (`posts[].variant`) -- build-video's VARIANTS. */ +const POST_VARIANTS = Object.freeze(["sourced", "full"]); /** How posts are drawn (`posts.layout`): cards at the end of a clip, or a column for the whole cut. */ export const POST_LAYOUTS = Object.freeze(["popup", "feed"]); /** Where a post's QR links (`posts.links`): its page on the archive, or the platform's own link. */ export const POST_LINKS = Object.freeze(["archive", "original"]); -const POST_KEYS = ["id", "platform", "author", "handle", "date", "text", "url", "attachTo", "hide", "siteChannel", "siteUrl", "postId", "shot"]; +const POST_KEYS = ["id", "platform", "author", "handle", "date", "text", "url", "attachTo", "hide", "siteChannel", "siteUrl", "postId", "shot", "variant"]; /** The pictures a post's `shot` may be. */ export const POST_SHOT_EXTS = Object.freeze([".png", ".jpg", ".jpeg", ".webp"]); @@ -798,6 +814,10 @@ export function validatePosts(posts, timeline = [], render = null) { errors.push(`${w}.attachTo ${JSON.stringify(p.attachTo)} is not a clip in the timeline`); } if (p.hide !== undefined && typeof p.hide !== "boolean") errors.push(`${w}.hide must be true or false`); + // Like an entry's: a post in one cut only. Its clip should be in that cut too. + if (p.variant !== undefined && !POST_VARIANTS.includes(p.variant)) { + errors.push(`${w}.variant must be ${POST_VARIANTS.map((v) => `"${v}"`).join(" or ")}`); + } // Where the archive keeps the post: its own channel's slug (a post channel // is not the video channel -- `piratesoftware-bsky`, not `piratesoftware`), // a page to link instead, or the post's id when its url does not carry one. @@ -897,7 +917,12 @@ export function attachPosts({ posts = [], entries = [], metas = [] }) { * A the start of the outgoing transition as below -- so the last one is in at * least `step` before its clip leaves. Once in, a post stays to the end. * - * In the POPUP, a clip's posts (oldest first) share an anchor A: the start of the outgoing + * In the POPUP at the clip's start (`posts.at: "start"`), a clip's posts keep + * the manifest's order and share the whole clip: post j of k appears at + * from + share·j, from = the clip's start after its incoming dissolve, share = + * (A − from)/k with A as below; they leave together over `out`. + * + * In the POPUP (at the end, the default), a clip's posts (oldest first) share an anchor A: the start of the outgoing * transition -- the next segment's start with a crossfade, 0.3 s before the cut * on a hard cut, 0.3 s before the end on the last segment. Post j of k appears * at A − step·(k − j), step = `posts.seconds`, so each has its seconds alone @@ -925,7 +950,9 @@ export function postSchedule({ posts = [], entries, metas = [], segments, D, tot segments.forEach((seg, i) => { const group = groups.get(seg.id); if (!group) return; - group.sort((x, y) => Date.parse(x.date) - Date.parse(y.date)); + // Oldest first -- except a popup at the clip's start, which keeps the + // manifest's order (a claim, then the post that bears on it). + if (feed || settings.at !== "start") group.sort((x, y) => Date.parse(x.date) - Date.parse(y.date)); const last = i === segments.length - 1; const leave = last ? [total - 0.3, total] @@ -941,6 +968,14 @@ export function postSchedule({ posts = [], entries, metas = [], segments, D, tot return; } const from = seg.start + (i > 0 ? D : 0); + if (settings.at === "start") { + // The whole clip, shared evenly: post j comes up at from + step·j. + const share = Math.max(0, A - from) / k; + group.forEach((p, j) => { + out.push({ id: p.id, segment: seg.id, slot: j, of: k, appear: from + share * j, out: leave, ...postFields(p) }); + }); + return; + } const step = Math.min(settings.seconds, Math.max(0, A - from) / k); group.forEach((p, j) => { out.push({ diff --git a/umtool/report-to-video/deck.test.mjs b/umtool/report-to-video/deck.test.mjs @@ -320,6 +320,38 @@ test("postSchedule: stacked from the end, one every `seconds`, leaving in the tr ]); }); +test("postSchedule at the clip's start: manifest order, the whole clip shared", () => { + const segments = [ + { id: "c1", start: 0, duration: 10 }, + { id: "c2", start: 9.5, duration: 12 }, + { id: "k1", start: 21, duration: 4 }, + { id: "c3", start: 24.5, duration: 4 }, + ]; + const AT_START = { ...RENDER, chrome: { ...CHROME, deck: { posts: { at: "start", hold: 0, shift: false } } } }; + // Listed newest first: a popup at the start keeps that order. + const posts = [POST("w", "2026-01-01", { attachTo: "c2", platform: "web", url: "https://example.org/a" }), + POST("x", "2024-10-19", { attachTo: "c2" }), POST("y", "2024-01-01", { attachTo: "c1" })]; + const s = postSchedule({ posts, entries: CLIPS, metas: METAS, segments, D: 0.5, total: 28.5, render: AT_START }); + // c2 runs 10 (after its dissolve) to 21 (its leave): 11 s, two posts, 5.5 s each. + assert.deepEqual(s.filter((p) => p.segment === "c2").map((p) => [p.id, p.slot, p.appear, p.out]), [ + ["w", 0, 10, [21, 21.5]], + ["x", 1, 15.5, [21, 21.5]], + ]); + // The first clip has no incoming dissolve: its post is up from 0. + assert.deepEqual(s.filter((p) => p.segment === "c1").map((p) => [p.id, p.appear]), [["y", 0]]); + assert.equal(s.find((p) => p.id === "w").platform, "web"); +}); + +test("posts: at, shotMaxHeight, variant and the web platform are validated", () => { + const chrome = (posts) => validateChrome({ ...CHROME, deck: { posts } }, RENDER); + assert.deepEqual(chrome({ at: "start", shotMaxHeight: 800 }), []); + assert.deepEqual(chrome({ shotMaxHeight: null }), []); + assert.match(chrome({ at: "middle" }).join(), /posts\.at must be "end" or "start"/); + assert.match(chrome({ shotMaxHeight: 40 }).join(), /shotMaxHeight must be null or a whole number/); + assert.deepEqual(validatePosts([POST("a", "2026-01-01", { platform: "web", url: "https://example.org/a", variant: "full" })]), []); + assert.match(validatePosts([POST("a", "2026-01-01", { variant: "short" })]).join(), /variant must be "sourced" or "full"/); +}); + test("deckSchedule carries posts only when there are some; posts.show false drops them", () => { const timeline = CLIPS; const base = estimateSchedule({ render: RENDER, provenance: PROV, timeline }); diff --git a/umtool/report-to-video/ledger-totals.test.mjs b/umtool/report-to-video/ledger-totals.test.mjs @@ -639,6 +639,13 @@ test("selectVariant merges a card's per-variant copy and drops the override key" assert.equal(selectVariant(VARIANT_MANIFEST, "full").timeline[0].heading, "all 50"); }); +test("selectVariant keeps a post with no variant in every cut and a variant post in its own", () => { + const m = { ...VARIANT_MANIFEST, posts: [{ id: "p-all" }, { id: "p-full", variant: "full" }, { id: "p-src", variant: "sourced" }] }; + assert.deepEqual(selectVariant(m, "sourced").posts.map((p) => p.id), ["p-all", "p-src"]); + assert.deepEqual(selectVariant(m, "full").posts.map((p) => p.id), ["p-all", "p-full"]); + assert.equal("posts" in selectVariant(VARIANT_MANIFEST, "full"), "posts" in VARIANT_MANIFEST); +}); + test("selectVariant refuses a variant nobody defined", () => { assert.throws(() => selectVariant(VARIANT_MANIFEST, "director's cut"), /unknown variant/); }); diff --git a/umtool/report-to-video/snap.test.mjs b/umtool/report-to-video/snap.test.mjs @@ -0,0 +1,24 @@ +// Tests for build-video's snap: where a cut lands in a pause. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import test from "node:test"; + +import { snap } from "./build-video.mjs"; + +const SIL = [{ s: 10, e: 10.8 }, { s: 20, e: 20.12 }]; + +test("snap: the default keeps 0.10 s before speech and 0.18 s after it", () => { + assert.deepEqual(snap(10.5, SIL, "end", 1.6), { at: 10.18, snapped: true }); + assert.equal(Number(snap(10.5, SIL, "start", 1.6).at.toFixed(3)), 10.7); + assert.deepEqual(snap(15, SIL, "end", 1.6), { at: 15, snapped: false }); +}); + +test("snap: snapTail / snapLead keep more of the pause, never more than it holds", () => { + const render = { snapTail: 0.55, snapLead: 0.45 }; + assert.equal(snap(10.5, SIL, "end", 1.6, render).at, 10.55); + assert.equal(Number(snap(10.5, SIL, "start", 1.6, render).at.toFixed(3)), 10.35); + // A 0.12 s gap: the whole gap and no more. + assert.equal(Number(snap(20, SIL, "end", 1, render).at.toFixed(3)), 20.12); + assert.equal(snap(20.1, SIL, "start", 1, render).at, 20); +});