commit 51cb39d0a00427331be8b56ed07a55e6108e370a
parent b070be886ce6454d48a9255d8becc1f2d4956615
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 14:08:31 -0400
umtool: the footage move runs after the hold, the freeze check samples only still picture, holds are whole frames
The move's clock counts the frames that reach it, so placed before the tpad a
first post that appeared inside the hold never moved the footage and one in a
clip's last half-second froze part-way, while the preview showed it moving. It
now runs after the hold and the frozen frame glides too.
verify-build compares two frames between the hold's start (or the move's
landing) and the outgoing dissolve, and reports a hold with under three frames
of still picture as not checked instead of failing a correct build.
postHolds rounds a hold to whole frames: tpad clones whole frames while apad
pads exact seconds, so a hard cut with several held clips outgrew its schedule
at 25 fps. The README and the changelog say that --no-chrome still holds and
moves the footage.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
7 files changed, 187 insertions(+), 30 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -2,7 +2,7 @@
## [Unreleased]
- **umtool's report videos can wear an on-screen deck: one panel under the footage for the whole cut, with a pip timeline, a title per clip, its source and date, and its QR.** A report manifest whose `render` says `"chrome": { "engine": "hyperframes", "layout": "deck" }` scales the footage into a box above a 190 px panel (both sizes are settings) and draws, over the whole cut, one unlabelled pip per clip on a track that fills as the cut plays, the clip's own title from `onscreen.title`, a subtitle naming the recording and its date (the channel too when the cut spans more than one; `onscreen.subtitle` replaces it), and the clip's QR. At each clip change the marker travels to the next pip and the title, subtitle and QR hand over; over a card the panel slides away and comes back after. The citation header, the corner QR and the section footer are not drawn on such a cut, and chapters take the clip's on-screen title. Every setting (sizes, spacing, date format, what the subtitle names, whether cards keep the panel, the motion's timings) is in `render.chrome.deck` and checked when it is saved; an unknown or out-of-range one is refused with a sentence saying why. The panel is rendered once per cut by HyperFrames (pinned to 0.8.24; `HYPERFRAMES_PKG` or `HYPERFRAMES_BIN` override it) and reused until its text or settings change. `build-video.mjs --chrome-only` redraws it over the built segments without rebuilding or fetching anything, `--no-chrome` builds the framed cut without it, and `--chrome-preview <at> <dur>` renders a short window. In umtool, the report page has an **On-screen** section — a switch, the settings, a table of every entry's title and subtitle with the automatic subtitle as its placeholder and a character counter, a live preview with a scrubber, a true still, **Re-render on-screen** and the built video — and the clip bench has on-screen title and subtitle fields with the panel previewed over the clip. A manifest without `render.chrome` builds exactly as before, byte for byte.
-- **A report cut that wears the on-screen deck can show posts — Bluesky or X statements — as cards over the footage.** A report manifest's `posts` list (each with its platform, handle, date, words and link) is drawn near the end of the clip each post belongs with: the clip whose recording most closely precedes it by date, unless the post names one with `attachTo`; `hide` leaves one out. A clip's posts appear four seconds apart and stack down a column at the frame's top right; as the first appears, the footage eases aside (to 86 % of its box, at the far side) to make room, and the clip's last frame is held, in silence, for 2.5 seconds so the last post can be read; then they all leave together in the change to the next clip, which comes in at the normal size. When the column is full the oldest slide up and out. Each card slides in from the edge of the frame and flares in the deck's accent as it lands; it has an accent rail down its edge and shows the post's date, a platform label ("Bluesky" or "X") beside `@handle`, its words in paragraphs up to seven lines with an ellipsis, and a QR of the post's link, in the deck's colours and faces. The hold and the move are made where the cut is joined, not in a clip, so `--chrome-only` changes them without rebuilding one; chapters and the deck's timing count the hold. The timing, the hold (`hold`, 0 turns it off), the move (`shift`: its scale and seconds, or `false`), the column's side, width and inset, the QR size and the line limit are settings under `render.chrome.deck.posts`, and a bad post or setting is refused with a sentence before a build fetches anything. Only the seconds the cards are up are rendered, one short sequence per clip, cached like the deck; `--chrome-only`, `--chrome-preview` and a hard-cut cut lay them as they lay the deck, and `--no-chrome` draws neither. A cut without `posts` builds exactly as before, and without the deck `posts` is not drawn at all.
+- **A report cut that wears the on-screen deck can show posts — Bluesky or X statements — as cards over the footage.** A report manifest's `posts` list (each with its platform, handle, date, words and link) is drawn near the end of the clip each post belongs with: the clip whose recording most closely precedes it by date, unless the post names one with `attachTo`; `hide` leaves one out. A clip's posts appear four seconds apart and stack down a column at the frame's top right; as the first appears, the footage eases aside (to 86 % of its box, at the far side) to make room, and the clip's last frame is held, in silence, for 2.5 seconds so the last post can be read; then they all leave together in the change to the next clip, which comes in at the normal size. When the column is full the oldest slide up and out. Each card slides in from the edge of the frame and flares in the deck's accent as it lands; it has an accent rail down its edge and shows the post's date, a platform label ("Bluesky" or "X") beside `@handle`, its words in paragraphs up to seven lines with an ellipsis, and a QR of the post's link, in the deck's colours and faces. The hold and the move are made where the cut is joined, not in a clip, so `--chrome-only` changes them without rebuilding one; chapters and the deck's timing count the hold. The timing, the hold (`hold`, 0 turns it off), the move (`shift`: its scale and seconds, or `false`), the column's side, width and inset, the QR size and the line limit are settings under `render.chrome.deck.posts`, and a bad post or setting is refused with a sentence before a build fetches anything. Only the seconds the cards are up are rendered, one short sequence per clip, cached like the deck; `--chrome-only`, `--chrome-preview` and a hard-cut cut lay them as they lay the deck, and `--no-chrome` draws neither — though it still holds and moves the footage, which are part of the cut rather than the chrome. A first post that appears inside the hold still moves the footage, and a hold is a whole number of frames. A cut without `posts` builds exactly as before, and without the deck `posts` is not drawn at all.
- **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/docs/quirks.md b/umtool/docs/quirks.md
@@ -252,7 +252,26 @@ across the gap.
the outgoing clip and the dissolve into the next one starts `transition`
seconds before that clip's end, so with `transition: 0.5` a 2.5 s hold shows
2.0 s of still frame and then dissolves out of it; a 0.5 s hold is consumed
-entirely. The real-ffmpeg test holds 1 s for this reason.
+entirely. The real-ffmpeg test holds 1 s for this reason. verify-build's
+freeze check samples only between the hold's start (or the move's landing)
+and the dissolve, and skips a hold with under three frames of still picture
+there, saying so.
+
+**A footage move placed BEFORE the hold never reaches the held frames.** The
+move's clock is `perspective`'s `in`, which counts the frames that reach it.
+Before `tpad` that is the clip's own frames only: a first post that appears
+inside the hold (`posts.seconds: 2` with the 2.5 s hold puts it exactly at
+the clip's last frame) never moved the footage, and one in the clip's last
+half-second froze part-way, while umtool's preview showed it moving. The
+move runs after `tpad`, so its clock counts the clones and the frozen frame
+glides too.
+
+**`tpad` holds whole frames; `apad` holds exact seconds.** `stop_duration=2.5`
+at 25 fps clones 63 frames (2.52 s) while `apad=pad_dur=2.5` adds exactly
+2.5 s, and the concat filter pads the short stream to the long one, so each
+held clip in a hard cut grew by up to half a frame and several of them failed
+the length check. `postHolds` rounds a hold to whole frames before either
+filter sees it.
**`xfade` hands on its own pixel format.** Even the frames before its offset,
which are the first input's, come out as yuv444 rather than the input's
diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md
@@ -990,7 +990,10 @@ node umtool/report-to-video/compose-chrome.mjs <manifest.json> [--region chart|d
rebuilt and nothing is fetched.** The driver labels this "re-render
on-screen".
- **`--no-chrome`** — the deck's framing with no overlay: a fast picture check
- of the letterboxing, with nothing composed or rendered.
+ of the letterboxing, with nothing composed or rendered. Holds and footage
+ moves are part of the CUT, not the chrome, so `--no-chrome` still applies
+ them (the footage moves aside for cards it does not draw), and its length is
+ the schedule's.
- **`--chrome-preview <at> <dur>`** — renders only that window of the deck and
writes `out/<variant>/<slug>.preview.mp4` of it, from a cached concat when
there is one, else built straight from the segments the window touches.
@@ -1049,8 +1052,11 @@ node umtool/report-to-video/compose-chrome.mjs <manifest.json> --region posts --
all lay them; `--no-chrome` lays neither.
- **`verify-build`** also checks each window's `chrome/posts-<segment>-frames`
holds that window's frame count, and that each held clip's freeze is in the
- file (the frame at `end − hold/2` is the frame at `end − hold + ε`, outside
- the deck and the column, within re-encoding noise).
+ file: two frames inside the still part of the hold -- after the hold starts
+ and the move lands, before the outgoing dissolve (or the end fade) -- are the
+ same, outside the deck and the column, within re-encoding noise. A hold with
+ under three frames of still picture there (0.5 s under a 0.5 s crossfade) is
+ reported as not checked.
### The hold and the move, where the cut is joined
@@ -1061,11 +1067,15 @@ no joins and runs the graphs it always did.
- **The hold** is `tpad=stop_mode=clone:stop_duration=<hold>` on the input's
picture and `apad=pad_dur=<hold>` (silence) on its sound, before the
- xfade/acrossfade.
-- **The move** is one `perspective` filter on that input (`sense=destination`,
- `eval=frame`): the input frame's corners are placed so the footage box goes
- from `from` to `to`, eased by smoothstep over `[segmentAt, segmentAt +
- seconds]` in the segment's own clock, then held there. Perspective resamples at
+ xfade/acrossfade. A hold is a whole number of frames (`postHolds` rounds it:
+ 2.5 s at 25 fps is 2.52 s), because `tpad` clones whole frames and `apad`
+ pads exact seconds.
+- **The move** is one `perspective` filter on that input, AFTER the hold
+ (`sense=destination`, `eval=frame`): the input frame's corners are placed so
+ the footage box goes from `from` to `to`, eased by smoothstep over
+ `[segmentAt, segmentAt + seconds]` in the segment's own clock, hold included,
+ then held there. A first post that appears inside the hold therefore still
+ moves the footage, the frozen frame with it. Perspective resamples at
1/256 px, so the box glides with no whole-pixel stepping, where `scale` +
`overlay` and `zoompan` round to whole pixels. Before the move the map is the
identity, which perspective copies bit for bit. `fillborders` pins the
diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs
@@ -2144,7 +2144,8 @@ const exprNum = (v) => {
* per frame): an affine map of the WHOLE frame that takes the `from` box to
* the box eased toward `to`. Each corner of the input frame is placed at
* `base + d·e(t)`, where e is smoothstep over [segmentAt, segmentAt + seconds]
- * in the segment's own clock and d is where that corner has gone at e = 1 --
+ * in the segment's own clock (its hold included: the move runs after the
+ * `tpad`) and d is where that corner has gone at e = 1 --
* scale `to.width / from.width` (and height), then translate. Before the move
* e = 0 and the map is the identity, which perspective copies bit for bit;
* after it e = 1 and the frame holds at `to`.
@@ -2190,15 +2191,20 @@ export const holdAudioFilter = (hold) => `apad=pad_dur=${exprNum(hold)}`;
/**
* Input `i`'s chains before the join. Without a join the labels are the
* input's own (`[i:v]`, `[i:a]`) and there is no chain at all, so a cut
- * without posts writes the graph it always did. The move goes before the hold,
- * so the held frame is the moved one.
+ * without posts writes the graph it always did.
+ *
+ * The move goes AFTER the hold: its clock (`in`) then counts the held frames
+ * too, so a first post that appears inside the hold -- or so late that the
+ * glide runs past the clip's own last frame -- still moves the footage, the
+ * frozen frame with it, exactly when the schedule (and umtool's preview) say.
+ * Before the hold, such a move never started or froze part-way.
*
* @returns {{ parts: string[], v: string, a: string }}
*/
export function joinInputChain(i, join, render) {
if (!join) return { parts: [], v: `[${i}:v]`, a: `[${i}:a]` };
const parts = [];
- const vf = [join.move ? moveFilter(join.move, render) : null, join.hold > 0 ? holdVideoFilter(join.hold) : null]
+ const vf = [join.hold > 0 ? holdVideoFilter(join.hold) : null, join.move ? moveFilter(join.move, render) : null]
.filter(Boolean);
const v = vf.length ? `[j${i}v]` : `[${i}:v]`;
if (vf.length) parts.push(`[${i}:v]${vf.join(",")}${v}`);
diff --git a/umtool/report-to-video/deck-room.test.mjs b/umtool/report-to-video/deck-room.test.mjs
@@ -76,6 +76,53 @@ test("the cut's lengths: the schedule's starts are the probed lengths plus the h
assert.equal(s.total, 10 + 12 + 4 - 1 + 2.5);
});
+test("a first post inside the hold: the move's segmentAt is past the clip's own last frame, in the held clock", () => {
+ // c2 is 12 s on disk; one post at 2 s with the 2.5 s hold appears 12.0 s
+ // into the segment -- inside the hold. The move runs after the hold, so
+ // that is a moment its clock reaches.
+ const two = { ...RENDER, chrome: { ...RENDER.chrome, deck: { posts: { seconds: 2 } } } };
+ const s = deckSchedule({ entries: CLIPS, durs: [10, 12, 4], D: 0.5, render: two, provenance: PROV, posts: [POST("a", "2024-10-19")] });
+ const [m] = s.moves;
+ assert.equal(m.segment, "c2");
+ assert.equal(m.segmentAt, 12);
+ assert.equal(s.segments[1].duration, 14.5);
+ assert.ok(m.segmentAt + m.seconds <= s.segments[1].duration - 0.5, "the glide lands before the dissolve out");
+ const c = joinInputChain(1, segmentJoins(s)[1], two);
+ assert.ok(c.parts[0].indexOf("tpad=") < c.parts[0].indexOf("perspective="), "hold, then move");
+});
+
+test("holds are whole frames: 2.5 s at 25 fps is 63 frames (2.52 s), and the cut's length counts that", () => {
+ const r25 = { ...RENDER, fps: 25 };
+ const s = deckSchedule({
+ entries: CLIPS, durs: [10, 12, 4], D: 0, render: r25, provenance: PROV,
+ posts: [POST("a", "2024-10-19"), POST("b", "2025-07-01")],
+ });
+ assert.equal(s.segments[1].hold, 2.52);
+ assert.equal(s.segments[2].hold, 2.52);
+ assert.equal(s.total, 10 + 12 + 4 + 2 * 2.52);
+ assert.equal(holdVideoFilter(segmentJoins(s)[1].hold), "tpad=stop_mode=clone:stop_duration=2.52");
+ // At 30 fps 2.5 s is already 75 frames: unchanged.
+ const s30 = deckSchedule({ entries: CLIPS, durs: [10, 12, 4], D: 0, render: RENDER, provenance: PROV, posts: [POST("a", "2024-10-19")] });
+ assert.equal(s30.segments[1].hold, 2.5);
+});
+
+test("freezeSamples: between the hold's start (or the move's end) and the dissolve; skipped when under three frames", async () => {
+ const { freezeSamples } = await import("./verify-build.mjs");
+ const seg = { id: "c2", start: 9.5, end: 24, hold: 2.5 };
+ const close = (a, b) => assert.ok(Math.abs(a - b) < 1e-9, `${a} != ${b}`);
+ const a = freezeSamples(seg, { fps: 30, D: 0.5 });
+ close(a.at[0], 21.5 + 0.05);
+ close(a.at[1], 23.5 - 0.05);
+ // A move that ends inside the hold: the still span starts where it lands.
+ close(freezeSamples(seg, { fps: 30, D: 0.5, moveEnd: 22.1 }).at[0], 22.1 + 0.05);
+ // Hold 0.5 under a 0.5 s crossfade: all of it is the dissolve.
+ assert.match(freezeSamples({ ...seg, hold: 0.5 }, { fps: 30, D: 0.5 }).skip, /^0\.000s of still picture/);
+ // Hard cut: up to the end. The last segment: up to its end fade.
+ close(freezeSamples({ ...seg, hold: 0.5 }, { fps: 30, D: 0 }).at[1], 24 - 0.05);
+ close(freezeSamples(seg, { fps: 30, D: 0.5, last: true, endFade: 1 }).at[1], 23 - 0.05);
+ assert.ok(freezeSamples({ ...seg, hold: 1 }, { fps: 30, D: 0.5, last: true, endFade: 1 }).skip);
+});
+
// ---- the chains, as strings -------------------------------------------------
test("joinInputChain: no join, no chain -- the input's own labels", () => {
@@ -92,10 +139,10 @@ test("joinInputChain: a hold is tpad clone on the picture and apad silence on th
});
});
-test("joinInputChain: the move goes before the hold, so the held frame is the moved one; a move alone leaves the sound alone", () => {
+test("joinInputChain: the move goes after the hold, so its clock counts the held frames; a move alone leaves the sound alone", () => {
const both = joinInputChain(1, { hold: 2.5, move: MOVE(6) }, RENDER);
assert.equal(both.parts.length, 2);
- assert.equal(both.parts[0], `[1:v]${moveFilter(MOVE(6), RENDER)},tpad=stop_mode=clone:stop_duration=2.5[j1v]`);
+ assert.equal(both.parts[0], `[1:v]tpad=stop_mode=clone:stop_duration=2.5,${moveFilter(MOVE(6), RENDER)}[j1v]`);
assert.equal(both.parts[1], "[1:a]apad=pad_dur=2.5[j1a]");
const move = joinInputChain(1, { hold: 0, move: MOVE(6) }, RENDER);
assert.deepEqual(move, { parts: [`[1:v]${moveFilter(MOVE(6), RENDER)}[j1v]`], v: "[j1v]", a: "[1:a]" });
@@ -354,6 +401,36 @@ test("ffmpeg: the move is the identity before it starts, lands on the target box
}
});
+test("ffmpeg: a move that starts inside the hold glides the frozen frame to the target box",
+ { skip: !haveFfmpeg }, () => {
+ const dir = mkdtempSync(path.join(tmpdir(), "deck-room-mh-"));
+ try {
+ const [, b] = segments(dir);
+ // b is 2 s (60 frames), held 1 s; the move starts at 2.1 s -- inside the hold.
+ const move = { segment: "b", at: 0, segmentAt: 2.1, seconds: 0.6,
+ from: { x: 20, y: 10, width: 280, height: 150 }, to: { x: 8, y: 20, width: 240, height: 128 } };
+ const c = joinInputChain(0, { hold: 1, move }, R);
+ c.parts.push(`${c.a}anullsink`);
+ const all = md5s(ff(["-i", b, "-filter_complex", c.parts.join(";"), "-map", c.v, "-f", "framemd5", "-"]));
+ assert.equal(all.length, 90, "the hold's 30 frames are all there");
+ const rgb = (n) => ff(["-i", b, "-filter_complex", `${c.parts.join(";")};${c.v}select=eq(n\\,${n})[o]`, "-map", "[o]",
+ "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], { encoding: "buffer" });
+ const px = (buf, x, y) => [...buf.subarray((y * 320 + x) * 3, (y * 320 + x) * 3 + 3)];
+ const bg = [0x12, 0x10, 0x1a];
+ const close = (p, q, tol = 3) => p.every((v, i) => Math.abs(v - q[i]) <= tol);
+ // Frozen and not yet moved at 2.0 s; moving from 2.1 s; landed by 2.7 s.
+ assert.equal(all[60], all[62], "held, before the move");
+ assert.ok(!close(px(rgb(62), 290, 150), bg), "footage still at the from box");
+ assert.notEqual(all[66], all[62], "the held frame moves");
+ const post = rgb(85);
+ for (const [x, y] of [[300, 10], [310, 170], [260, 100]]) assert.ok(close(px(post, x, y), bg), `ground at ${x},${y}`);
+ assert.ok(!close(px(post, 245, 145), bg), "footage to the target box's far corner");
+ assert.deepEqual(all.slice(82), Array(8).fill(all[82]), "landed, then still");
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
test("verify-build: a held clip's freeze is found in the file, and a cut that dropped it is refused", { skip: !haveFfmpeg }, async () => {
const { verifyHolds } = await import("./verify-build.mjs");
const dir = mkdtempSync(path.join(tmpdir(), "deck-room-vb-"));
diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs
@@ -834,9 +834,15 @@ export function shiftedFootage(render) {
return { x: Math.round(x), y: Math.floor((H - d.height - height) / 2), width, height };
}
-/** How long each clip that carries posts is held on its last frame (entry id → seconds). */
+/**
+ * How long each clip that carries posts is held on its last frame (entry id →
+ * seconds), in WHOLE FRAMES: `tpad` clones a whole number of frames (2.5 s at
+ * 25 fps is 63, not 62.5) while `apad` pads exactly, so an unrounded hold
+ * would make a hard cut with several held clips longer than its schedule.
+ */
export function postHolds({ posts = [], entries = [], metas = [], render }) {
- const hold = resolveDeck(render).posts.hold;
+ const fps = render?.fps ?? 30;
+ const hold = Math.round(resolveDeck(render).posts.hold * fps) / fps;
const out = new Map();
if (!(hold > 0)) return out;
for (const a of attachPosts({ posts, entries, metas })) out.set(a.entryId, hold);
@@ -846,9 +852,11 @@ export function postHolds({ posts = [], entries = [], metas = [], render }) {
/**
* When the footage moves aside for a clip's posts: one move per carrying clip,
* starting as its first post appears (`at`, cut clock; `segmentAt`, the
- * segment's own clock) and easing over `shift.seconds` from the footage box
- * to `shiftedFootage`. It stays there to the end of the segment; the next
- * segment comes in at the normal box through the transition.
+ * segment's own clock, its hold included) and easing over `shift.seconds`
+ * from the footage box to `shiftedFootage`. It stays there to the end of the
+ * segment; the next segment comes in at the normal box through the
+ * transition. `segmentAt` may fall inside the hold: the build runs the move
+ * after the hold, so the frozen frame moves too.
*
* @returns {Array<{ segment, at, segmentAt, seconds, from, to }>}
*/
diff --git a/umtool/report-to-video/verify-build.mjs b/umtool/report-to-video/verify-build.mjs
@@ -153,16 +153,45 @@ export async function verifyDeck(variantDir, render, file, problems) {
export const FREEZE_TOLERANCE = 1.5;
/**
- * Each held segment's freeze is in the file: the frame at `end − hold/2` is
- * the frame at `end − hold + ε`. Compared over the picture outside the deck's
- * panel and the posts column -- both still move during a hold (the deck's
- * progress fuse burns on) -- on luma, within FREEZE_TOLERANCE of re-encoding
- * noise. A cut whose holds were dropped plays on there and differs by far more.
+ * Where a held segment's freeze can be sampled: the still span is from the
+ * later of the hold's start and the end of the footage move (the move runs
+ * after the hold, so a late one glides over the frozen frame) to the start of
+ * the outgoing dissolve (`end − D`), or the end fade on the last segment
+ * (`end − endFade`), or the segment's end. The two samples sit a frame and a
+ * half inside it. A span under three frames has nothing still to compare --
+ * at hold 0.5 under a 0.5 s crossfade the dissolve takes all of it -- and is
+ * skipped, with the reason.
+ *
+ * @returns {{ at: [number, number] } | { skip: string }}
+ */
+export function freezeSamples(segment, { fps, D = 0, last = false, endFade = 0, moveEnd = -Infinity }) {
+ const lo = Math.max(segment.end - segment.hold, moveEnd);
+ const hi = last ? segment.end - (endFade > 0 ? endFade : 0) : segment.end - D;
+ if (!(hi - lo >= 3 / fps)) {
+ return {
+ skip: `${Math.max(0, hi - lo).toFixed(3)}s of still picture between ${lo.toFixed(3)}s and ${hi.toFixed(3)}s ` +
+ `(the rest of the hold is under the ${last ? "end fade" : "dissolve"}${moveEnd > segment.end - segment.hold ? " or the move" : ""})`,
+ };
+ }
+ return { at: [lo + 1.5 / fps, hi - 1.5 / fps] };
+}
+
+/**
+ * Each held segment's freeze is in the file: two frames inside its still span
+ * (`freezeSamples`) are the same frame. Compared over the picture outside the
+ * deck's panel and the posts column -- both still move during a hold (the
+ * deck's progress fuse burns on) -- on luma, within FREEZE_TOLERANCE of
+ * re-encoding noise. A cut whose holds were dropped plays on there and differs
+ * by far more.
*/
export async function verifyHolds(file, schedule, render, problems) {
- const held = (schedule.segments ?? []).filter((s) => s.hold > 0);
+ const segs = schedule.segments ?? [];
+ const held = segs.filter((s) => s.hold > 0);
if (!held.length) return [];
const fps = Number(schedule.fps ?? render.fps);
+ const D = Number(schedule.transition ?? 0);
+ const endFade = Number(render.endFade ?? 0);
+ const moveEnd = new Map((schedule.moves ?? []).map((m) => [m.segment, m.at + m.seconds]));
const g = deckGeometry(render);
const col = postsGeometry(render);
const left = resolveDeck(render).posts.position === "top-left";
@@ -178,8 +207,14 @@ export async function verifyHolds(file, schedule, render, problems) {
};
const out = [];
for (const s of held) {
- const a = s.end - s.hold + 2 / fps;
- const b = s.end - s.hold / 2;
+ const span = freezeSamples(s, {
+ fps, D, last: s === segs.at(-1), endFade, moveEnd: moveEnd.get(s.id) ?? -Infinity,
+ });
+ if (span.skip) {
+ out.push({ segment: s.id, hold: s.hold, skipped: span.skip });
+ continue;
+ }
+ const [a, b] = span.at;
const [x, y] = await Promise.all([luma(a), luma(b)]);
let diff = 0;
if (x.length !== y.length || !x.length) diff = Infinity;
@@ -222,7 +257,9 @@ async function main() {
console.log(` posts on ${w.segment}: ${w.frames}/${w.expectedFrames} frame(s) at ${w.at.toFixed(3)}s`);
}
for (const h of res.deck.holds ?? []) {
- console.log(` hold on ${h.segment}: ${h.hold}s, frozen (${h.at.join("s ≈ ")}s, mean diff ${h.diff})`);
+ console.log(h.skipped
+ ? ` hold on ${h.segment}: ${h.hold}s, not checked — ${h.skipped}`
+ : ` hold on ${h.segment}: ${h.hold}s, frozen (${h.at.join("s ≈ ")}s, mean diff ${h.diff})`);
}
}
for (const p of res.problems) console.log(` ** ${p}`);