Archilyzer · Source

archilyzer

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

commit 7751153485035821b7a0309e9bf09cda52a61fa3
parent 68be357ee77942281f6926fb510a07e079b994b6
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 21:21:55 -0400

report-to-video: a dip's black is whole frames at the cut's fps (dipOf snaps it to the nearest frame, keeping a value that already is one as given, 0.6 at 30 fps byte-identical), and the lead, the teaser's length, motion, hits and page, the schedule's dip and the joined cut's black all take the render's fps; tests that the lead, the page, the frame count and dipWindows' until agree; the README and the [Unreleased] bullet say so

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

Diffstat:
Meditor/CHANGELOG.md | 2+-
Mumtool/report-to-video/README.md | 6+++++-
Mumtool/report-to-video/build-video.mjs | 6+++---
Mumtool/report-to-video/chrome-teaser.mjs | 7++++---
Mumtool/report-to-video/compose-chrome.mjs | 2+-
Mumtool/report-to-video/deck.mjs | 50++++++++++++++++++++++++++++++++++----------------
Mumtool/report-to-video/dip.test.mjs | 42++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/verify-build.mjs | 2+-
8 files changed, 91 insertions(+), 26 deletions(-)

diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -8,7 +8,7 @@ - **A report clip can go silent partway through, a report cut can fade out at its end, and the deck's QR names its site in larger type.** A clip's `muteFrom` (in the recording's own seconds, inside the clip) silences it from that second to its end while the picture plays on, after a 40 ms fade that ends there, so nothing clicks and no next word leaks in; a hold on that clip stays silent. `render.endFade` (seconds; 0, the default, is off) fades the cut's last segment, whatever it is — a clip with its hold, a closing card or a teaser — to the background colour and to silence over its final seconds, all of it when the segment is shorter, and the deck stays drawn over it. Both are applied where the cut is joined, so `--chrome-only` changes them without rebuilding a clip, and a value out of range is refused with a sentence before a build fetches anything. Each clip build now writes `<id>.cut.json` beside its segment, saying where in the recording the segment really starts after its cut was snapped to a silence; `muteFrom` is measured from it, and a segment built before this measures from the clip's unsnapped start and says so. The site's name beside the deck's QR is now exactly as long as the code is tall, for any site. Neither key changes a cut that does not set it. - **umtool's clip bench stops exactly where a range ends, and sets a clip's mute mark.** The bench's **play selection**, the edge auditions, the auto-audition and a click on a transcript line now play the window's sound through the browser's Web Audio, from a decode made on the server by ffmpeg — the same timeline the build cuts on — and each stops on the audio clock where its range ends, at every speed. They used to play on the video element and were stopped when it next reported its time, which overran the end by up to a quarter of a second, by a different amount each time. The picture follows, muted. If the sound cannot be decoded, the video element plays as before and the bench says the playback is approximate and why. The mute mark sets the clip's `muteFrom`: `m` puts it at the playhead, **pick on waveform** puts it where you click, `;` and `'` nudge it (with shift, by half a second), and `M` or **clear mute** removes it. It is saved with the window like the edges, every playback goes silent at it with the build's own 40 ms fade, and a window save that would leave it outside the clip is refused unless the same save moves or clears it — or, when it is within 0.02 s of the new edge, moves it onto that edge. The decoded sound is served by a new `GET /api/report/audio`, at most 120 seconds of a cached window at a time, as WAV. - **A report cut can end on a teaser card: a few lines popping in over a dark cinematic ground, with a trailer hit under each.** A report manifest's `teaser` entry (`lines`, `seconds`, an optional `tail`) is a full-frame card drawn from its own words, one to five lines each popping in top to bottom with a scale overshoot, a blur that sharpens, and a flash of the accent; with three or more lines the first is a small overline, the last a mid-size date, and the ones between a big title. A line written as `{ "text": …, "break": … }` draws its ending as a smaller second tier a beat later, and the tail fades in after the last line on its own. Under each pop is a synthesised boom, the title's the biggest, and under the tail a low swell; `"hits": false` makes the card silent. `"beat"` (0.4–2.5 seconds, default 0.7) sets the time from one pop — and its hit — to the next, the second tier and the tail's wait slowing with it; `seconds` may be left out for exactly the length the beats need, and a `seconds` too short for them is refused with that length rather than played faster. Put after the last clip, it joins with the ordinary crossfade and takes the cut's end fade. A line too long to fit the frame at its smallest size is refused with a sentence saying how many characters fit (a title holds 34). It is rendered once and re-rendered when its words change, `--chrome-only` included, and its chapter is its lines. umtool shows it as a card row named by its lines; its words are edited in the manifest. -- **A report cut can go to black before its teaser, and the teaser rises out of the black.** A teaser entry's `"dip": { "fade": …, "black": … }` fades the whole frame before it — the footage, the on-screen deck, the posts feed and anything else drawn over the cut — to black over the previous segment's last `fade` seconds (0.3–4), its sound to silence with it, then holds `black` seconds (0–3) of black. The teaser then opens out of it: the letterbox is already closed, the ground and its light stay dark until the first line slams in and come up with its hit, and a synthesised riser swells under the black into that first hit. The deck and the feed leave under the black instead of sliding away over the crossfade. The black is the start of the teaser's own segment, so the teaser is that much longer and nothing else moves; with a dip, the teaser's `seconds` counts from where the light comes up. The fade is made where the cut is joined, so `--chrome-only` changes it without rebuilding a clip. A dip anywhere but on a teaser, on the first entry, or out of range is refused with a sentence before a build fetches anything. A cut without a dip builds exactly as before. +- **A report cut can go to black before its teaser, and the teaser rises out of the black.** A teaser entry's `"dip": { "fade": …, "black": … }` fades the whole frame before it — the footage, the on-screen deck, the posts feed and anything else drawn over the cut — to black over the previous segment's last `fade` seconds (0.3–4), its sound to silence with it, then holds `black` seconds (0–3) of black, taken to the nearest whole frame at the cut's frame rate. The teaser then opens out of it: the letterbox is already closed, the ground and its light stay dark until the first line slams in and come up with its hit, and a synthesised riser swells under the black into that first hit. The deck and the feed leave under the black instead of sliding away over the crossfade. The black is the start of the teaser's own segment, so the teaser is that much longer and nothing else moves; with a dip, the teaser's `seconds` counts from where the light comes up. The fade is made where the cut is joined, so `--chrome-only` changes it without rebuilding a clip. A dip anywhere but on a teaser, on the first entry, or out of range is refused with a sentence before a build fetches anything. A cut without a dip builds exactly as before. - **A report cut with `transition: 0` builds when its output folder was given as a relative path.** The hard-cut concat listed its segments relative to the working directory, and ffmpeg reads that list relative to the list file's own folder, so every hard-cut build with a relative `--out` failed at the concat. The list now names each segment by its full path. - **`archilyzer doctor` checks the image Build all builds sites in.** When a container engine answers, a new **build image** section says whether the image named under **Settings → Build pipeline** is there, when it was built and how big it is. It warns when the image is missing, or older than the last change to its Dockerfile, and prints the one command that rebuilds it. Build all still builds or refreshes the image itself before it builds any site; the warning tells you ahead of time that the next Build all will spend that time. With no container engine the check is skipped in one line, and with no corpus it is only a note. It never fails the doctor. - **The site build image runs Node 22 and pnpm 11**, the versions the rest of the workspace runs on, instead of Node 20 and pnpm 9, which did not read the workspace's install rules. The next Build all rebuilds the image from its first step, reinstalling every dependency, before it builds any site. diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md @@ -556,7 +556,11 @@ dip on the first entry, which has nothing before it to fade). teaser's black lead and darkened it faster than the deck and the feed, which only the dip fades — the panels were left lit over a darker picture. 2. **The black.** The blend stays fully black from that last frame for - `black` seconds more. Under it, the teaser's own first `transition + black` + `black` seconds more — whole frames at the cut's fps: a `black` that falls + between frames is taken to the nearest one (0.45 at 30 fps is 14 frames, + 0.4667 s; `dipOf`), and one that is already whole frames (0.6 at 30 fps) is + used as given, so the joined cut's black, the teaser's frame count and the + page's rise all end on the same frame. Under it, the teaser's own first `transition + black` seconds — its **lead** (`teaserLead`) — are black and silent but for the riser, so the teaser takes over in black and nothing is held: the teaser segment is simply longer by the lead (`teaserSeconds(entry, D)`), and the diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs @@ -1224,8 +1224,8 @@ export async function buildTeaserSegment(entry, { manifestPath, render, outDir, phase: r.cached ? "cached" : "render", region: "teaser", segment: entry.id, frames: r.frameCount, key: r.key, dir: r.frames, seconds: Number(((Date.now() - t0) / 1000).toFixed(1)), }); - const seconds = teaserSeconds(entry, transition); - const hits = teaserHits(entry, transition); + const seconds = teaserSeconds(entry, transition, render.fps); + const hits = teaserHits(entry, transition, render.fps); const audio = teaserAudioGraph(hits, { seconds, render }); const key = teaserSegmentKey(r.key, audio, render); const seg = path.join(outDir, "segments", `${entry.id}.mp4`); @@ -2620,7 +2620,7 @@ export async function cutJoins({ schedule = null, entries, segments, render }) { // A teaser's dip fades the segment before it, over its last `fade` seconds. const dips = new Map(); for (let i = 1; i < entries.length && i < segments.length; i += 1) { - const dip = dipOf(entries[i]); + const dip = dipOf(entries[i], render.fps); if (!dip) continue; dips.set(i - 1, { seconds: dip.fade, lastFrame: (await cutFrames(i - 1)) - 1, black: dip.black }); } diff --git a/umtool/report-to-video/chrome-teaser.mjs b/umtool/report-to-video/chrome-teaser.mjs @@ -228,16 +228,17 @@ export function teaserHtml(entry, render, opts = {}) { const H = render.height ?? 1080; // The cut's transition, read only for a dip: its lead is the dissolve and the black. const D = opts.transition ?? transitionOf(render); - const seconds = teaserSeconds(entry, D); + const fps = render.fps ?? 30; + const seconds = teaserSeconds(entry, D, fps); if (!(seconds > 0)) throw new Error(`teaser ${entry.id}: seconds must be positive`); const font = opts.font ?? TEASER_FONT_ASSET; const gsapSrc = opts.gsap ?? "assets/gsap.min.js"; const lines = teaserLines(entry); if (!lines.length) throw new Error(`teaser ${entry.id}: no lines`); const tail = teaserTail(entry); - const dipped = !!dipOf(entry); + const dipped = !!dipOf(entry, fps); const { init, cues, beats } = teaserCues({ - lines, tail, seconds, motion: teaserMotionOf(entry, D), dip: dipped ? { lead: teaserLead(entry, D) } : null, + lines, tail, seconds, motion: teaserMotionOf(entry, D, fps), dip: dipped ? { lead: teaserLead(entry, D, fps) } : null, }); // The ground: the palette's bg, lifted a touch toward the accent at the diff --git a/umtool/report-to-video/compose-chrome.mjs b/umtool/report-to-video/compose-chrome.mjs @@ -783,8 +783,8 @@ export async function composeChrome({ throw new Error("the feed region needs a feed's schedule (layout \"feed\": posts.layout \"feed\" and posts to draw)"); } const D = transition ?? transitionOf(manifest.render); - const total = teaser ? teaserSeconds(teaser, D) : keyed ? sched.total : null; const rate = Number(fps ?? sched?.fps ?? manifest.render.fps ?? 30); + const total = teaser ? teaserSeconds(teaser, D, rate) : keyed ? sched.total : null; const windowed = (region === "deck" || region === "feed") && (from > 0 || (duration != null && Math.abs(Number(duration) - total) > 1e-6)); diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs @@ -409,7 +409,7 @@ export function estimatedDuration(entry, render = {}, D = transitionOf(render)) } if (entry.type === "image") return Number(entry.seconds ?? 4); // A teaser's dip makes its segment longer by the dissolve and the black (`teaserLead`). - if (entry.type === "teaser") return teaserSeconds(entry, D); + if (entry.type === "teaser") return teaserSeconds(entry, D, render.fps ?? 30); return Number(entry.seconds ?? 5); } @@ -514,6 +514,7 @@ export function deckSchedule({ entries, durs, D, render, provenance = {}, metas = [], estimated = false, posts = [], }) { const deck = resolveDeck(render); + const fps = render.fps ?? 30; // A clip that carries posts is held on its last frame for `posts.hold`: the // hold is part of the segment's length in the cut, so every start, the total // and the posts' timing below are measured with it. `durs` are the segments' @@ -533,7 +534,7 @@ export function deckSchedule({ version: 1, kind: "deck", estimated, - fps: render.fps ?? 30, + fps, transition: D, total: round(total), multiChannel, @@ -553,7 +554,7 @@ export function deckSchedule({ qrUrl: deck.qr.show ? deckQrUrl(e, provenance) : null, hideDeck: hidesDeck(e, deck), // Only on a teaser that dips, so a cut without one writes the schedule it always did. - ...(dipOf(e) ? { dip: dipOf(e) } : {}), + ...(dipOf(e, fps) ? { dip: dipOf(e, fps) } : {}), }; }), // Present only when there are posts to draw, so a cut without them writes @@ -1321,21 +1322,38 @@ export function validateDip(entry, where = `timeline entry ${entry?.id ?? "?"}`) return errors; } -/** An entry's dip, `{ fade, black }`, or null: only a teaser's, and only a sound one. */ -export function dipOf(entry) { +/** + * Seconds as a whole number of frames at `fps`: unchanged when they already + * are one (0.6 at 30 fps stays 0.6, byte for byte), else the nearest frame, + * to the ten-thousandth. + */ +export function snapToFrames(seconds, fps = 30) { + const n = Math.round(seconds * fps); + if (Math.abs(seconds * fps - n) < 1e-6) return seconds; + return Math.round((n / fps) * 10000) / 10000; +} + +/** + * An entry's dip, `{ fade, black }`, or null: only a teaser's, and only a + * sound one. `black` is snapped to whole frames at `fps` (`snapToFrames`), so + * the teaser's lead, its page's rise, its frame count and the joined cut's + * black (`dipWindows`' `until`) all fall on the same frame. + */ +export function dipOf(entry, fps = 30) { if (entry?.type !== "teaser" || !isObj(entry.dip)) return null; const { fade, black } = entry.dip; if (!numIn(fade, ...DIP_LIMITS.fade) || !numIn(black, ...DIP_LIMITS.black)) return null; - return { fade, black }; + return { fade, black: snapToFrames(black, fps) }; } /** * The dark start of a teaser that dips, in its own clock: the dissolve into it - * (`D`, the cut's transition) plus the dip's black. Its page is black and its - * sound silent (but for the riser) until then; 0 without a dip. + * (`D`, the cut's transition) plus the dip's black (whole frames at `fps`). + * Its page is black and its sound silent (but for the riser) until then; 0 + * without a dip. */ -export function teaserLead(entry, D = 0.5) { - const dip = dipOf(entry); +export function teaserLead(entry, D = 0.5, fps = 30) { + const dip = dipOf(entry, fps); return dip ? Math.round((D + dip.black) * 10000) / 10000 : 0; } @@ -1354,8 +1372,8 @@ export function dipHideAt(seg, D, fps = 30) { * -- after the black, timed to the rise (DIP_RISE) instead of the incoming * dissolve. Without a dip it is `teaserMotion(beat)` itself. */ -export function teaserMotionOf(entry, D = 0.5) { - return dippedMotion(entry, teaserLead(entry, D)); +export function teaserMotionOf(entry, D = 0.5, fps = 30) { + return dippedMotion(entry, teaserLead(entry, D, fps)); } /** `teaserMotion(beat)`, its first line moved to `lead + before − hit` when the entry dips. */ @@ -1384,8 +1402,8 @@ export function teaserCardSeconds(entry) { * the cut's transition, read only for a dip. Without a dip it is * `teaserCardSeconds`, as it always was. Assumes `validateTeaser` passed. */ -export function teaserSeconds(entry, D = 0.5) { - const lead = teaserLead(entry, D); +export function teaserSeconds(entry, D = 0.5, fps = 30) { + const lead = teaserLead(entry, D, fps); return lead ? Math.round((lead + teaserCardSeconds(entry)) * 10000) / 10000 : teaserCardSeconds(entry); } @@ -1406,11 +1424,11 @@ export function teaserSeconds(entry, D = 0.5) { * @returns {Array<{ kind: "hit"|"swell"|"riser", at: number, role: string, gain: number, * decay: number, f0: number, f1: number, dur?: number }>} */ -export function teaserHits(entry, D = 0.5) { +export function teaserHits(entry, D = 0.5, fps = 30) { if (entry?.hits === false) return []; const lines = teaserLines(entry); const tail = teaserTail(entry); - const times = teaserTimes(lines, tail, teaserMotionOf(entry, D)); + const times = teaserTimes(lines, tail, teaserMotionOf(entry, D, fps)); const out = []; if (dipOf(entry) && times.lines.length) { const end = times.lines[0].impact; diff --git a/umtool/report-to-video/dip.test.mjs b/umtool/report-to-video/dip.test.mjs @@ -122,6 +122,48 @@ test("the lead: the dissolve plus the black; the segment is the lead plus the ca assert.equal(estimatedDuration(FIN, RENDER), 7.2); }); +test("the black is whole frames at the cut's fps: the lead, the page's rise, the frame count and the cut's black agree", () => { + const near = (a, b, msg) => assert.ok(Math.abs(a - b) < 0.01, `${msg}: ${a} != ${b}`); + // Already whole frames: the very number given, so nothing built from it changes. + for (const [black, fps] of [[0.6, 30], [0.4, 30], [1, 30], [0, 30], [3, 30], [0.6, 25], [0.45, 60]]) { + assert.ok(Object.is(dipOf(dipped(1.2, black), fps).black, black), `${black} at ${fps}`); + } + assert.deepEqual(dipOf(dipped()), { fade: 1.2, black: 0.6 }); // fps defaults to 30 + // Between frames: the nearest one (13.5 frames → 14 at 30 fps; 11.25 → 11 at 25). + assert.equal(dipOf(dipped(1, 0.45), 30).black, 0.4667); + assert.equal(dipOf(dipped(1, 0.45), 25).black, 0.44); + assert.equal(dipOf(dipped(1, 0.4667), 30).black, 0.4667); // snapping is idempotent + // Only the black is snapped: the fade's frames are endFadeFrames', on the joined segment. + assert.equal(dipOf(dipped(1.05, 0.45), 30).fade, 1.05); + const e = dipped(1, 0.45); + for (const fps of [25, 30]) { + const black = dipOf(e, fps).black; + const lead = teaserLead(e, 0.5, fps); + assert.equal(lead, r4(0.5 + black)); + // The lead is whole frames past the dissolve; at 30 fps (where a 0.5 s + // dissolve and a card in tenths are whole frames too) so is the segment. + near(lead * fps - 0.5 * fps, Math.round(black * fps), `lead at ${fps}`); + const seconds = teaserSeconds(e, 0.5, fps); + assert.equal(seconds, r4(lead + teaserCardSeconds(e))); + if (fps === 30) near(seconds * fps, Math.round(seconds * fps), "segment at 30"); + // The light comes up where the black ends: the motion and the page read the snapped lead. + assert.equal(teaserMotionOf(e, 0.5, fps).first, r4(lead + DIP_RISE.before - teaserMotion(e.beat).hit)); + assert.equal( + teaserHtml(e, { ...RENDER, fps }, { transition: 0.5 }), + teaserHtml(dipped(1, black), { ...RENDER, fps }, { transition: 0.5 }), + ); + // The joined cut's black ends on the frame the teaser's lead does. + const joins = withCutEdits(null, 2, { dips: new Map([[0, { seconds: 1, lastFrame: 89, black }]]) }); + const [w] = dipWindows(joins, [3, seconds], 0.5, fps); + near(w.until - (w.last + 1), black * fps, `until at ${fps}`); + } + // The schedule carries the snapped black, and the estimate counts it. + const s = deckSchedule({ entries: [CLIP, e], durs: [7, teaserSeconds(e, 0.5, 30)], D: 0.5, render: RENDER }); + assert.deepEqual(s.segments[1].dip, { fade: 1, black: 0.4667 }); + assert.equal(estimatedDuration(e, RENDER), teaserSeconds(e, 0.5, 30)); + assert.equal(estimatedDuration(e, { ...RENDER, fps: 25 }), teaserSeconds(e, 0.5, 25)); +}); + test("the motion: every line after the lead, the first's impact DIP_RISE.before after the black; nothing else moves", () => { const plain = teaserTimes(teaserLines(FIN), "?", teaserMotion(1.05)); assert.deepEqual(teaserMotionOf(FIN, 0.5), teaserMotion(1.05)); diff --git a/umtool/report-to-video/verify-build.mjs b/umtool/report-to-video/verify-build.mjs @@ -184,7 +184,7 @@ export async function verifyTeasers(variantDir, manifest, problems) { if (e.type !== "teaser") continue; const dir = path.join(variantDir, "chrome", `teaser-${e.id}-frames`); const frames = await readdir(dir).then((fs) => fs.filter((f) => /^frame_\d+\.png$/.test(f)).length, () => 0); - const seconds = teaserSeconds(e, D); + const seconds = teaserSeconds(e, D, fps); const want = frameCount(seconds, fps); const key = await readFile(path.join(dir, ".key"), "utf8").then((s) => s.trim(), () => null); const seg = path.join(variantDir, "segments", `${e.id}.mp4`);