commit 5af43e79c98a2245bb1f2ea28ce6299ac46fb984
parent 1869615e0c4dc8fa0d59f1e016fd9a13ca7b6d11
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 22:43:40 -0400
report-to-video: every chrome page states pageDuration -- its whole frames floored to 4 decimals -- so HyperFrames' ceil(duration x fps) is the build's frame count; a cut of 434 frames (schedule 14.467 s) rendered 435 deck frames and the build refused it. A duration on the grid (14.4, 7.9, 356.7) is written as before; tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
6 files changed, 53 insertions(+), 12 deletions(-)
diff --git a/umtool/report-to-video/chrome-deck.mjs b/umtool/report-to-video/chrome-deck.mjs
@@ -37,7 +37,7 @@
// second clock or a label.
import { fileURLToPath } from "node:url";
-import { deckChoreography, deckLayout, pipSegments, pipXs, resolveDeck } from "./deck.mjs";
+import { deckChoreography, deckLayout, pageDuration, pipSegments, pipXs, resolveDeck } from "./deck.mjs";
/**
* GSAP, vendored, for every chrome region.
@@ -474,9 +474,9 @@ export function deckHtml(schedule, render, opts = {}) {
</style>
</head>
<body>
- <div id="root" data-composition-id="deck" data-start="0" data-duration="${r4(dur)}"
+ <div id="root" data-composition-id="deck" data-start="0" data-duration="${pageDuration(dur, schedule.fps ?? render.fps ?? 30)}"
data-width="${W}" data-height="${H}">
- <div id="deck-clip" class="clip" data-start="0" data-duration="${r4(dur)}" data-track-index="1">
+ <div id="deck-clip" class="clip" data-start="0" data-duration="${pageDuration(dur, schedule.fps ?? render.fps ?? 30)}" data-track-index="1">
<div class="panel" data-k="panel">
<div class="ground"></div>
${panel && qr ? `<div class="plate"></div>` : ""}
diff --git a/umtool/report-to-video/chrome-feed.mjs b/umtool/report-to-video/chrome-feed.mjs
@@ -33,7 +33,7 @@
// fixed name (`embedFn`, docs/quirks.md); the tests call the same function
// with heights of their choosing. Every cue is a fromTo whose FROM is stated:
// a render is a seek per frame, from parallel workers, in any order.
-import { deckChoreography, feedGeometry, resolveDeck } from "./deck.mjs";
+import { deckChoreography, feedGeometry, pageDuration, resolveDeck } from "./deck.mjs";
import { mix, rgba } from "./chrome-deck.mjs";
import { embedFn, PLATFORM_LABEL, postDate, postParagraphs, postWho } from "./chrome-posts.mjs";
@@ -375,9 +375,9 @@ export function feedHtml(schedule, render, opts = {}) {
</style>
</head>
<body>
- <div id="root" data-composition-id="feed" data-start="0" data-duration="${dur}"
+ <div id="root" data-composition-id="feed" data-start="0" data-duration="${pageDuration(dur, schedule.fps ?? render.fps ?? 30)}"
data-width="${W}" data-height="${H}">
- <div id="feed-clip" class="clip" data-start="0" data-duration="${dur}" data-track-index="1">
+ <div id="feed-clip" class="clip" data-start="0" data-duration="${pageDuration(dur, schedule.fps ?? render.fps ?? 30)}" data-track-index="1">
<div class="col" data-k="col">
<div class="ground"></div>
${panel ? `<div class="edge"></div>` : ""}
diff --git a/umtool/report-to-video/chrome-posts.mjs b/umtool/report-to-video/chrome-posts.mjs
@@ -32,7 +32,7 @@
// Every cue is a fromTo whose FROM is stated, for the deck's reason: a render
// is a seek per frame, from parallel workers, in any order.
import { formatDeckDate } from "./attribution.mjs";
-import { postsGeometry, resolveDeck, snapWindow } from "./deck.mjs";
+import { pageDuration, postsGeometry, resolveDeck, snapWindow } from "./deck.mjs";
import { mix, rgba } from "./chrome-deck.mjs";
// The window arithmetic is deck.mjs's (pure, and loaded by the build without
@@ -339,9 +339,9 @@ export function postsHtml(schedule, render, window, opts = {}) {
</style>
</head>
<body>
- <div id="root" data-composition-id="posts" data-start="0" data-duration="${dur}"
+ <div id="root" data-composition-id="posts" data-start="0" data-duration="${pageDuration(dur, schedule.fps ?? render.fps ?? 30)}"
data-width="${W}" data-height="${H}" data-segment="${esc(window.segment)}">
- <div id="posts-clip" class="clip" data-start="0" data-duration="${dur}" data-track-index="1">
+ <div id="posts-clip" class="clip" data-start="0" data-duration="${pageDuration(dur, schedule.fps ?? render.fps ?? 30)}" data-track-index="1">
<div class="stack" data-k="stack">
${cardHtml}
</div>
diff --git a/umtool/report-to-video/chrome-teaser.mjs b/umtool/report-to-video/chrome-teaser.mjs
@@ -25,7 +25,7 @@
import { fileURLToPath } from "node:url";
import {
- DIP_RISE, dipOf, TEASER_MOTION, teaserLead, teaserLines, teaserMotionOf, teaserSeconds, teaserTail, teaserTimes,
+ DIP_RISE, dipOf, pageDuration, TEASER_MOTION, teaserLead, teaserLines, teaserMotionOf, teaserSeconds, teaserTail, teaserTimes,
transitionOf,
} from "./deck.mjs";
@@ -368,9 +368,9 @@ export function teaserHtml(entry, render, opts = {}) {
</style>
</head>
<body>
- <div id="root" data-composition-id="teaser" data-start="0" data-duration="${r4(seconds)}"
+ <div id="root" data-composition-id="teaser" data-start="0" data-duration="${pageDuration(seconds, render.fps ?? 30)}"
data-width="${W}" data-height="${H}" data-entry="${esc(entry.id)}">
- <div id="teaser-clip" class="clip" data-start="0" data-duration="${r4(seconds)}" data-track-index="1">
+ <div id="teaser-clip" class="clip" data-start="0" data-duration="${pageDuration(seconds, render.fps ?? 30)}" data-track-index="1">
<div class="ground"></div>
<div class="stage" data-k="stage">
<div class="leak" data-k="leak"></div>${dipped ? '\n <div class="veil" data-k="veil"></div>' : ""}
diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs
@@ -1590,6 +1590,18 @@ export function chromeCacheKey({ html, assets = [], fps, frames, version }) {
export const frameCount = (total, fps) => Math.round(total * fps);
/**
+ * The `data-duration` a page states for `seconds` at `fps`: its whole frames
+ * (`frameCount`) back in seconds, FLOORED to 4 decimals. HyperFrames renders
+ * ceil(duration × fps) frames, so a duration rounded UP past a frame boundary
+ * -- a cut of 434 frames is 14.4667 s, and its schedule's 14.467 -- rendered
+ * one frame more than the build expects (435) and the build refused it. A
+ * duration already on the 4-decimal grid of whole frames (14.4, 7.9, 356.7) is
+ * written as it always was.
+ */
+export const pageDuration = (seconds, fps) =>
+ Math.floor((frameCount(seconds, fps) / fps) * 10000 + 1e-6) / 10000;
+
+/**
* How to invoke HyperFrames. `HYPERFRAMES_BIN` (an executable taking the CLI's
* own arguments, e.g. an e2e stub) wins; else `npx --yes <HYPERFRAMES_PKG>`,
* pinned by default. `version` is what the cache key records.
diff --git a/umtool/report-to-video/deck.test.mjs b/umtool/report-to-video/deck.test.mjs
@@ -413,3 +413,32 @@ test("posts shift: the footage moves aside from the column while a clip's posts
assert.match(validateChrome({ ...CHROME, deck: { posts: { shift: { speed: 1 } } } }, RENDER)[0], /speed is not a deck setting/);
assert.match(validateChrome({ ...CHROME, deck: { posts: { hold: 20 } } }, RENDER)[0], /hold/);
});
+
+import { pageDuration } from "./deck.mjs";
+import { deckHtml } from "./chrome-deck.mjs";
+
+test("pageDuration: whole frames, floored to 4 decimals, so HyperFrames' ceil(duration × fps) is the build's frame count", () => {
+ // On the grid: written as before.
+ for (const v of [14.4, 7.9, 356.7, 7, 0.5, 14.2]) assert.equal(pageDuration(v, 30), v);
+ // 434 frames is 14.4667 s, and a schedule writes 14.467: both read as 434 frames, never 435.
+ for (const v of [14.467, 14.4667, 434 / 30]) {
+ const d = pageDuration(v, 30);
+ assert.equal(d, 14.4666);
+ assert.equal(Math.ceil(d * 30), 434);
+ assert.equal(frameCount(d, 30), frameCount(v, 30));
+ }
+ assert.equal(pageDuration(14.767, 30), 14.7666);
+ assert.equal(Math.ceil(pageDuration(14.633, 30) * 30), 439);
+ assert.equal(pageDuration(10.04, 25), 10.04);
+});
+
+test("the deck page states pageDuration of the cut, not its millisecond total", () => {
+ const timeline = [
+ { id: "a", type: "clip", video: "v", start: 0, end: 7.0667 },
+ { id: "b", type: "clip", video: "v", start: 10, end: 17.9 },
+ ];
+ const R = { ...RENDER, palette: { bg: "#12101a", fg: "#f4f1ea", muted: "#9a93ad", accent: "#a97bff", amber: "#ffc860" } };
+ const s = deckSchedule({ entries: timeline, durs: [212 / 30, 7.9], D: 0.5, render: R, provenance: PROV });
+ assert.equal(s.total, 14.467);
+ assert.match(deckHtml(s, R), /data-composition-id="deck" data-start="0" data-duration="14\.4666"/);
+});