commit 5c36c9d3ebf17181a79a867237ae648f37ca3a16
parent 1553a3fc30aae8c9b20cb59efe88e2eedbbd088c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 13:47:14 -0400
Merge deck/room-r1 (room slice R1) — the hold (tpad clone + apad) and the footage move (perspective, smoothstep, fillborders to bg) on the carrying clip's input where the cut is joined, every length under the deck from the schedule, the hard-cut record carrying holds and moves, verify-build's freeze check; cards that slide in from the frame's edge with an accent flare, rail and platform pill; ferret scratch 360.2 s, QR 7/7 + 17/17; reviewed by contact sheet
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
9 files changed, 915 insertions(+), 110 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 two seconds apart, stack down a column at the footage's top right, and leave together in the change to the next clip; when the column is full the oldest slide up and out. Each card shows the post's date, `@handle · Bluesky` (or X), 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 timing, 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. 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/plans/deck-posts.md b/plans/deck-posts.md
@@ -149,3 +149,59 @@ Rulings, on top of the above:
Core additions (`deck.mjs`): `shiftedFootage`, `postHolds`, `footageMoves` (the schedule's `moves`:
`[{segment, at, segmentAt, seconds, from, to}]`, present only when there are moves), `posts.hold`
and `posts.shift` settings and validation; `resolveDeck` fills `posts.shift` from its default.
+
+### R1, as built
+
+Branch `deck/room-r1` from 69cb37d7.
+
+| Commit | What |
+|---|---|
+| 8d86edc6 | The hold and the move joined on the carrying clip's input (`segmentJoins`, `joinInputChain`, `moveFilter`, `xfadeGraph`, `hardCutFilterArgs`, `cutOffsets`); every length under the deck is the schedule's; the hard-cut record names its joins; verify-build's freeze check; `deck-room.test.mjs` |
+| f5067b04 | The cards: slide in from past the frame's edge, an accent flare that settles, an accent rail, a platform pill; text 24 px |
+| 4fb19848 | README, quirks, the `[Unreleased]` entry |
+
+Where it differs from, or adds to, the rulings above:
+
+- **The move is one `perspective` filter** (`sense=destination`, `eval=frame`): the input frame's
+ corners are eased by smoothstep so the footage box goes from `from` to `to`. It resamples at
+ 1/256 px; `scale` + `overlay` and `zoompan` round to whole pixels. Before the move it is the
+ identity, copied bit for bit. It has no `t` and `in` counts from 1, so the clock is `(in-1)/fps`.
+ It fills the uncovered band by clamping to the input's edge, so `fillborders` pins the outer
+ 2 px to `palette.bg` first. Measured on c06: the box glides 172→22 px over 18 frames with no
+ stepping.
+- **A hold shows `hold − transition` of still frame** under a crossfade, because the dissolve
+ out starts inside it (2.0 s at the defaults).
+- **Text is 24 px, not more.** At 25 px the ferret cut's c06 stack (3 posts) measured 918 px
+ against an 838 px column, and the oldest would have slid out before the hold. 24 px with
+ tighter padding measures 829.
+- **verify-build's freeze check** compares luma outside the deck and the posts column, because
+ the deck's progress fuse keeps moving during a hold. It allows a mean difference of 1.5;
+ ferret measured 0.01–1.02.
+
+Gates on 4fb19848:
+
+- Workspace tsc clean. `test:scripts` 318 pass, 2 skipped. Capped umtool `next build` with the
+ corpus linked exit 0 (58 s, under load from a concurrent encode), link removed, and the
+ server bundle carries `const postsCues = (` (2 files).
+- Byte-identical: `--only c07 --skip-fetch` without `render.chrome` gives md5
+ `6a92235fa12ca181bb81993129c9ee9d`. A deck manifest without posts gives a `schedule.json`
+ byte-equal to the base's, and the base and the branch give the same overlay chain,
+ `applyChromeArgs` and `previewFromSegmentsArgs` (`segmentJoins` returns null).
+- Ferret, scratch copy, `--chrome-only` (crossfade):
+ - 360.2 s, holds on c03, c06, c12 and c17;
+ - posts and moves at the predicted seconds;
+ - verify-build ok, with all four freezes found;
+ - 17 chapters at the schedule's starts;
+ - QR 7/7 posts and 17/17 deck.
+- Transition-0 copy through the concat-filter prerail: 368.2 s, verify-build ok, QR 7/7 posts.
+ The `.segments` record names each join.
+- `--chrome-preview 126 14` gives 14.000 s (420 frames) from the segments: the dissolve in,
+ the move, the stack and the hold.
+
+Left for R2 (umtool):
+
+- `umtool/lib/report/export.mjs`'s fallback, used when there is no `chapters.ffmeta`, still sums
+ `segmentOffsets` without the holds. A build always writes the ffmeta, so it is reached only
+ for a cut that was never fully built.
+- The preview's posts geometry is now the frame's edge (`postsGeometry`). The preview does not
+ show the footage move or the hold; both happen in ffmpeg, at the join.
diff --git a/umtool/docs/quirks.md b/umtool/docs/quirks.md
@@ -232,6 +232,34 @@ first and filling it later an error (`gsap_timeline_registered_before_async_buil
The renderer awaits `document.fonts.ready` before its first seek, so every
frame sees the built timeline.
+**`perspective` has no `t`, and its `in` counts from 1.** The footage move
+for posts animates one `perspective` filter (`eval=frame`), whose expressions
+see only `W`, `H`, `in` and `on`. Measured: the first frame has `in = 1`, and
+the count runs on through frames a timeline `enable` passed by. The segment's
+own clock is therefore `(in-1)/fps`; written as `in/fps` the move starts a
+frame late. An identity map (every corner at its default) copies the frame bit
+for bit, so the frames before the move need no `enable` at all.
+
+**`perspective` fills what a shrink uncovers by CLAMPING to the input's edge.**
+It does not paint a colour: the band the footage leaves behind is the input's
+outermost row or column, smeared. The deck framing puts `palette.bg` there,
+but a coded frame's edge pixels are only approximately that colour, so
+`fillborders=…:mode=fixed:color=<bg>` pins the outer 2 px first. Without the
+deck framing (footage to the frame's edge) the same filter would smear footage
+across the gap.
+
+**A hold no longer than the crossfade is never seen.** The hold is appended to
+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.
+
+**`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
+yuv420p, so a framemd5 of the crossfade's output never equals the segment's
+own. "Untouched" for a crossfaded input means equal to the graph the build ran
+before (the test compares the two graphs), not equal to the file.
+
**`build-video.mjs` must not import a chrome PAGE module statically.**
umtool's server bundles build-video into every report route, and Turbopack
turns `chrome-deck.mjs`'s `new URL("./assets/gsap.min.js", import.meta.url)`
diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md
@@ -223,8 +223,10 @@ whatever a manifest omits, one level deep:
"qr": { "show": true, "size": 150 }, // size 80–380, and at most height − 20
"overCards": "hide", // "hide" | "show"
"motion": { "out": 0.3, "in": 0.45, "pip": 0.7 }, // seconds; out/in 0–2, pip 0–3
- "posts": { "show": true, "seconds": 2, "position": "top-right", // the manifest's `posts` (below); position | "top-left"
- "width": 600, "qrSize": 120, "maxLines": 7, "inset": 24 } // seconds 0.5–10; width 320–900 px; qrSize 80–200 and ≤ width/2; maxLines 2–14; inset 0–80
+ "posts": { "show": true, "seconds": 4, "hold": 2.5, // the manifest's `posts` (below); seconds 0.5–10; hold 0–10 (0: none)
+ "shift": { "scale": 0.86, "seconds": 0.6 }, // the footage makes room: scale 0.5–1, seconds 0–3; or false
+ "position": "top-right", // | "top-left"
+ "width": 600, "qrSize": 120, "maxLines": 7, "inset": 24 } // width 320–900 px; qrSize 80–200 and ≤ width/2; maxLines 2–14; inset 0–80
}
}
}
@@ -282,15 +284,33 @@ footage, so it rides on a clip.
- **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
- of the cut). Each has `seconds` alone before the next stacks on, 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.
+ of the cut). Each has `seconds` (4 by default) alone before the next stacks on,
+ 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.
+- **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
+ it, the total, the chapters and the posts' own timing are measured with it
+ (the schedule's `segments[i].hold`).
+- **The move.** With `shift` on (the default), the footage eases over
+ `shift.seconds` (0.6) from its box to `shift.scale` of it (0.86), its far edge
+ `inset` from the frame's edge away from the column and centred above the deck,
+ as the clip's first post appears; it stays there to the end of the clip, hold
+ included, and the next clip comes in at the normal box through the transition
+ (the schedule's `moves`). At 1920×1080: 1574×886 at (173, 2) → 1354×762 at
+ (24, 64).
- **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 own `url`.
-- **Where.** A column inside the footage box, `inset` from its top and from the
- `position` side, `width` wide. Cards stack top-down; when the next would
- overflow the column, the oldest slide up and out.
+- **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
+ column, the oldest slide up and out.
+- **How they read.** Each card slides in from past the frame's edge on the
+ column's side and its accent rim flares as it lands, then settles to a quiet
+ glow; an accent rail runs down its leading edge and the platform is a pill
+ beside the handle. The QR is fully opaque once the card is in.
`validatePosts()` (`deck.mjs`) is the one validator, unknown keys refused; a
deck build refuses a bad `posts` before a single fetch. Posts are drawn only
@@ -1007,8 +1027,8 @@ node umtool/report-to-video/compose-chrome.mjs <manifest.json> --region posts --
`chrome/posts-<segment>-frames/` with their own `.key` (html, assets, fps,
frames, renderer version — the deck's cache, per window); `--preview`
composes `chrome/posts-preview-<segment>/` and never renders.
-- **The page** is region-local (`postsGeometry`, 600×838 at (1123, 26) by
- default), transparent outside the cards. `?still=<t>` and the preview's
+- **The page** is region-local (`postsGeometry`, 600×838 at (1296, 26) by
+ default; at (1123, 26) with `shift: false`), transparent outside the cards. `?still=<t>` and the preview's
`deck:seek` take CUT seconds; a preview page answers with
`{type: "posts:ready", segment, from, to, ids}`.
- **The stack is planned in the page.** Whether the next card overflows the
@@ -1028,7 +1048,36 @@ node umtool/report-to-video/compose-chrome.mjs <manifest.json> --region posts --
pass, `--chrome-only` and `--chrome-preview` (only the windows it touches)
all lay them; `--no-chrome` lays neither.
- **`verify-build`** also checks each window's `chrome/posts-<segment>-frames`
- holds that window's frame count.
+ 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).
+
+### The hold and the move, where the cut is joined
+
+`segmentJoins(schedule)` turns the schedule's holds and `moves` into one join
+per carrying clip; the segment FILES are never touched, so `--chrome-only`
+changes a hold or a move without rebuilding a clip, and a cut without posts has
+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
+ 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
+ frame's outer 2 px to `palette.bg` first, because perspective fills what the
+ shrink uncovers from the input's edge.
+- **Hard cuts** with a hold or a move concatenate through the concat FILTER
+ (`hardCutFilterArgs`, one encode) rather than the demuxer's stream copy; the
+ prerail's `.segments` record then names each join, so a changed hold is never
+ served from a stale concat. Without joins the stream copy is unchanged.
+- **Every length is the schedule's** under the deck: the xfade offsets, the
+ chapters, a `--chrome-preview` window and `--chapters-only` all add the holds
+ to the probed lengths (`cutOffsets`), the same sum `deckSchedule` makes.
### Two ffmpeg traps that are the deck's alone
diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs
@@ -2105,28 +2105,179 @@ async function buildChartSegment(card, render, outDir, ledger) {
return seg;
}
-// Crossfade every segment into the next. This is a full re-encode of the
-// timeline — the concat demuxer can only stream-copy hard cuts — so --no-xfade
-// stays available for quick iteration.
-async function concatWithXfade(segments, render, outPath, railPlan, chrome = null) {
- const D = render.transition ?? 0.5;
- const durs = [];
- for (const s of segments) durs.push(await probeDuration(s, render.fps));
+// ---- room for posts: the hold and the footage move, where the cut is joined --
+// A clip that carries posts is HELD on its last frame (`segments[i].hold` in
+// the deck's schedule) and its footage MOVES aside as its first post appears
+// (`schedule.moves`). Both happen on that segment's input chain, before the
+// join -- never in the segment file -- so `--chrome-only` changes them
+// without rebuilding a clip, and every other input's chain is exactly what it
+// was. A cut without posts has no joins (`segmentJoins` returns null) and
+// every line below behaves as it always did.
- const inputs = segments.flatMap((s) => ["-i", s]);
+/**
+ * Per-input join work from the deck's schedule, aligned with its segments:
+ * `{ hold, move }` for a carrying clip, null for every other; null overall when
+ * no segment has either -- the switch that keeps a cut without posts on the
+ * paths it always took.
+ *
+ * @returns {Array<{ hold: number, move: object|null } | null> | null}
+ */
+export function segmentJoins(schedule) {
+ if (!schedule?.segments) return null;
+ const moves = new Map((schedule.moves ?? []).map((m) => [m.segment, m]));
+ const joins = schedule.segments.map((s) => {
+ const hold = s.hold > 0 ? s.hold : 0;
+ const move = moves.get(s.id) ?? null;
+ return hold || move ? { hold, move } : null;
+ });
+ return joins.some(Boolean) ? joins : null;
+}
+
+/** A number for an ffmpeg expression or option: at most 4 decimals, no trailing zeros. */
+const exprNum = (v) => {
+ const s = (Math.round(Number(v) * 1e4) / 1e4).toFixed(4).replace(/\.?0+$/, "");
+ return s === "-0" ? "0" : s;
+};
+
+/**
+ * The footage move as one `perspective` filter (destination sense, evaluated
+ * 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 --
+ * 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`.
+ *
+ * Perspective resamples at 1/256 px, so the box glides with no whole-pixel
+ * stepping (scale + overlay and zoompan both round to whole pixels). It has no
+ * `t`, and its `in` counts frames from 1 -- hence `(in-1)/fps`. What the
+ * shrink uncovers is filled from the input's edge (perspective clamps), which
+ * the deck framing made `palette.bg`; `fillborders` pins the outermost pixels
+ * to it exactly, so a coding artefact at the edge cannot be smeared across
+ * the uncovered band.
+ */
+export function moveFilter(move, render) {
+ const { from: F, to: T } = move;
+ const kx = T.width / F.width - 1;
+ const ky = T.height / F.height - 1;
+ const dx = (u) => T.x - F.x + (u - F.x) * kx;
+ const dy = (v) => T.y - F.y + (v - F.y) * ky;
+ const W = render.width ?? 1920;
+ const H = render.height ?? 1080;
+ const t = `(in-1)/${render.fps}`;
+ const a = exprNum(move.segmentAt);
+ const p = move.seconds > 0 ? `clip((${t}-${a})/${exprNum(move.seconds)},0,1)` : `gte(${t},${a})`;
+ // smoothstep, 3p² − 2p³: flat at both ends, so the glide starts and lands without a jolt.
+ const e = (base, d) => `'st(0,${p});${base}+(${exprNum(d)})*ld(0)*ld(0)*(3-2*ld(0))'`;
+ const corners = [
+ ["x0", e("0", dx(0))], ["y0", e("0", dy(0))],
+ ["x1", e("W", dx(W))], ["y1", e("0", dy(0))],
+ ["x2", e("0", dx(0))], ["y2", e("H", dy(H))],
+ ["x3", e("W", dx(W))], ["y3", e("H", dy(H))],
+ ];
+ return [
+ `fillborders=left=2:right=2:top=2:bottom=2:mode=fixed:color=${render.palette.bg}`,
+ `perspective=${corners.map(([k, v]) => `${k}=${v}`).join(":")}:interpolation=linear:sense=destination:eval=frame`,
+ ].join(",");
+}
+
+/** The hold on a segment's picture: its last frame, cloned for `hold` seconds. */
+export const holdVideoFilter = (hold) => `tpad=stop_mode=clone:stop_duration=${exprNum(hold)}`;
+/** The hold on its sound: silence, for the same `hold` seconds. */
+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.
+ *
+ * @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 = [];
- let vlab = "[0:v]";
- let alab = "[0:a]";
- let acc = durs[0];
+ const vf = [join.move ? moveFilter(join.move, render) : null, join.hold > 0 ? holdVideoFilter(join.hold) : null]
+ .filter(Boolean);
+ const v = vf.length ? `[j${i}v]` : `[${i}:v]`;
+ if (vf.length) parts.push(`[${i}:v]${vf.join(",")}${v}`);
+ const a = join.hold > 0 ? `[j${i}a]` : `[${i}:a]`;
+ if (join.hold > 0) parts.push(`[${i}:a]${holdAudioFilter(join.hold)}${a}`);
+ return { parts, v, a };
+}
- for (let i = 1; i < segments.length; i += 1) {
+/**
+ * The segments' lengths IN THE CUT: probed, plus each one's hold -- the sum
+ * `deckSchedule` makes (it is handed the same probed lengths and adds the same
+ * holds), so the xfade offsets, the chapters and a preview's window agree with
+ * the schedule's starts. Without joins this is segmentOffsets, unchanged.
+ */
+export async function cutOffsets(segments, D, fps, joins = null) {
+ const { durs } = await segmentOffsets(segments, D, fps);
+ const full = durs.map((d, i) => d + (joins?.[i]?.hold ?? 0));
+ return { ...scheduleFrom(full, D), durs: full };
+}
+
+/**
+ * The crossfade concat's filtergraph from the cut's segment lengths (`durs`,
+ * holds included) and the per-input joins. Without joins, the graph it always was.
+ */
+export function xfadeGraph(durs, D, joins = null, render = null) {
+ const parts = [];
+ const ins = durs.map((_, i) => {
+ const c = joinInputChain(i, joins?.[i] ?? null, render);
+ parts.push(...c.parts);
+ return c;
+ });
+ let vlab = ins[0].v;
+ let alab = ins[0].a;
+ let acc = durs[0];
+ for (let i = 1; i < durs.length; i += 1) {
const off = acc - D;
- parts.push(`${vlab}[${i}:v]xfade=transition=fade:duration=${D}:offset=${off.toFixed(3)}[v${i}]`);
- parts.push(`${alab}[${i}:a]acrossfade=d=${D}:c1=tri:c2=tri[a${i}]`);
+ parts.push(`${vlab}${ins[i].v}xfade=transition=fade:duration=${D}:offset=${off.toFixed(3)}[v${i}]`);
+ parts.push(`${alab}${ins[i].a}acrossfade=d=${D}:c1=tri:c2=tri[a${i}]`);
vlab = `[v${i}]`;
alab = `[a${i}]`;
acc = acc + durs[i] - D;
}
+ return { parts, vlab, alab };
+}
+
+/**
+ * A hard-cut concat through the concat FILTER, for a cut whose joins need a
+ * filtergraph (the demuxer's stream copy cannot host one): every input's
+ * chain, then `concat`, one encode at the parameters every segment shares.
+ */
+export function hardCutFilterArgs(segments, joins, render, outPath) {
+ const parts = [];
+ const pairs = segments.map((_, i) => {
+ const c = joinInputChain(i, joins?.[i] ?? null, render);
+ parts.push(...c.parts);
+ return `${c.v}${c.a}`;
+ });
+ parts.push(`${pairs.join("")}concat=n=${segments.length}:v=1:a=1[vc][ac]`);
+ return [
+ "-nostdin", "-v", "error", "-y",
+ ...segments.flatMap((s) => ["-i", s]),
+ "-filter_complex", parts.join(";"),
+ "-map", "[vc]", "-map", "[ac]",
+ ...encodeArgs(render),
+ outPath,
+ ];
+}
+
+// Crossfade every segment into the next. This is a full re-encode of the
+// timeline — the concat demuxer can only stream-copy hard cuts — so --no-xfade
+// stays available for quick iteration. `joins` (the deck's holds and moves,
+// `segmentJoins`) go on their inputs before the join; null leaves the graph
+// exactly as it was.
+async function concatWithXfade(segments, render, outPath, railPlan, chrome = null, joins = null) {
+ const D = render.transition ?? 0.5;
+ const { durs } = await cutOffsets(segments, D, render.fps, joins);
+
+ const inputs = segments.flatMap((s) => ["-i", s]);
+ const { parts, vlab, alab } = xfadeGraph(durs, D, joins, render);
// The rail attaches to the LAST xfade node, so it runs after every dissolve
// and sees an absolute, continuous `t`. One encode, not two.
@@ -2281,27 +2432,28 @@ export function windowSegments(starts, durs, at, dur) {
* runs over the whole cut (or a plain concat for hard cuts), trimmed to the
* window, the window's deck frames over it. Seconds, not a whole-cut encode.
*/
-export function previewFromSegmentsArgs({ segments, durs, starts, D, at, dur, render, chromePlan, outPath }) {
+export function previewFromSegmentsArgs({ segments, durs, starts, D, at, dur, render, chromePlan, outPath, joins = null }) {
+ // `durs`/`starts` are the CUT's (cutOffsets: holds included), and the
+ // window's inputs carry their joins as the full concat's do.
const { first, last, offset } = windowSegments(starts, durs, at, dur);
const segs = segments.slice(first, last + 1);
const ds = durs.slice(first, last + 1);
- const parts = [];
- let vlab = "[0:v]";
- let alab = "[0:a]";
+ const js = joins ? joins.slice(first, last + 1) : null;
+ let parts = [];
+ let vlab;
+ let alab;
if (segs.length > 1 && D > 0) {
- let acc = ds[0];
- for (let i = 1; i < segs.length; i += 1) {
- const off = acc - D;
- parts.push(`${vlab}[${i}:v]xfade=transition=fade:duration=${D}:offset=${off.toFixed(3)}[v${i}]`);
- parts.push(`${alab}[${i}:a]acrossfade=d=${D}:c1=tri:c2=tri[a${i}]`);
- vlab = `[v${i}]`;
- alab = `[a${i}]`;
- acc = acc + ds[i] - D;
+ ({ parts, vlab, alab } = xfadeGraph(ds, D, js, render));
+ } else {
+ const ins = segs.map((_, i) => joinInputChain(i, js?.[i] ?? null, render));
+ for (const c of ins) parts.push(...c.parts);
+ vlab = ins[0].v;
+ alab = ins[0].a;
+ if (segs.length > 1) {
+ parts.push(`${ins.map((c) => `${c.v}${c.a}`).join("")}concat=n=${segs.length}:v=1:a=1[vc][ac]`);
+ vlab = "[vc]";
+ alab = "[ac]";
}
- } else if (segs.length > 1) {
- parts.push(`${segs.map((_, i) => `[${i}:v][${i}:a]`).join("")}concat=n=${segs.length}:v=1:a=1[vc][ac]`);
- vlab = "[vc]";
- alab = "[ac]";
}
const S = offset.toFixed(3);
const T = Number(dur).toFixed(3);
@@ -2385,14 +2537,15 @@ async function renderDeck({ manifestPath, render, outDir, variant, schedule, fro
* schedule says AND no segment is newer than it: a re-trimmed clip that kept
* its length would otherwise pass the length check and play the old cut.
*/
-async function freshConcat(file, segments, total, fps) {
+async function freshConcat(file, segments, total, fps, joins = null) {
const st = await stat(file).catch(() => null);
if (!st) return false;
- // Same segments, same order. Length and age alone would take a REORDERED
- // timeline's old concat -- every title, QR and chapter then lands on the
- // wrong footage while the length check still passes.
+ // Same segments, same order, and the same holds and moves on them. Length
+ // and age alone would take a REORDERED timeline's old concat -- every title,
+ // QR and chapter then lands on the wrong footage while the length check
+ // still passes -- or one whose holds were joined differently.
const recorded = await readFile(`${file}.segments`, "utf8").catch(() => null);
- if (!sameConcatList(recorded, segments)) return false;
+ if (!sameConcatList(recorded, segments, joins)) return false;
for (const s of segments) {
if ((await stat(s)).mtimeMs > st.mtimeMs) return false;
}
@@ -2400,9 +2553,12 @@ async function freshConcat(file, segments, total, fps) {
return got != null && Math.abs(got - total) <= 1.5 / fps;
}
-/** Does a recorded concat list name exactly these segments, in this order? */
-export function sameConcatList(recorded, segments) {
- return recorded != null && recorded === concatListText(segments);
+/**
+ * Does a recorded concat list name exactly these segments, in this order,
+ * joined the same way (`joins`, the deck's holds and moves)?
+ */
+export function sameConcatList(recorded, segments, joins = null) {
+ return recorded != null && recorded === concatRecordText(segments, joins);
}
// ---- chapter markers -----------------------------------------------------
@@ -2475,6 +2631,13 @@ export async function chapterTitle(entry, index, provenance, { deck = false } =
* @returns the schedule document (deck.mjs's shape)
*/
export async function writeChromeSchedule({ manifest, entries, segments, D, outDir }) {
+ const doc = await measureChromeSchedule({ manifest, entries, segments, D });
+ await writeFile(path.join(outDir, "schedule.json"), JSON.stringify(doc, null, 2) + "\n");
+ return doc;
+}
+
+/** writeChromeSchedule's document, not written: what `--chapters-only` measures the cut by. */
+export async function measureChromeSchedule({ manifest, entries, segments, D }) {
const { render, provenance = {} } = manifest;
const { durs } = await segmentOffsets(segments, D, render.fps);
const metas = [];
@@ -2491,14 +2654,14 @@ export async function writeChromeSchedule({ manifest, entries, segments, D, outD
}
// The variant's posts, placed on the clips this cut plays with their real
// upload dates. A manifest without posts writes the schedule it always did.
- const doc = deckSchedule({ entries, durs, D, render, provenance, metas, posts: manifest.posts ?? [] });
- await writeFile(path.join(outDir, "schedule.json"), JSON.stringify(doc, null, 2) + "\n");
- return doc;
+ return deckSchedule({ entries, durs, D, render, provenance, metas, posts: manifest.posts ?? [] });
}
-async function muxChapters(finalPath, entries, segments, D, outDir, provenance, fps, deck = false) {
+async function muxChapters(finalPath, entries, segments, D, outDir, provenance, fps, deck = false, joins = null) {
if (segments.length < 2) return;
- const { starts, total } = await segmentOffsets(segments, D, fps);
+ // The cut's own offsets: under the deck a held clip is longer in the cut
+ // than its file, and every chapter after it starts that much later.
+ const { starts, total } = await cutOffsets(segments, D, fps, joins);
const lines = [";FFMETADATA1", ""];
for (let i = 0; i < entries.length; i += 1) {
// Land just PAST the crossfade, so the marker opens on the incoming clip
@@ -2539,7 +2702,31 @@ async function muxChapters(finalPath, entries, segments, D, outDir, provenance,
export const concatListText = (segments) =>
segments.map((s) => `file '${path.resolve(s)}'`).join("\n") + "\n";
-async function concatHardCut(segments, outDir, outPath, { record = false } = {}) {
+/**
+ * What a cached hard-cut concat records it was made from: the list, and --
+ * only when there are joins -- one line per joined input with its hold and
+ * move. Without joins it is the list alone, as it always was.
+ */
+export function concatRecordText(segments, joins = null) {
+ const list = concatListText(segments);
+ if (!joins) return list;
+ const lines = segments.flatMap((s, i) => (joins[i]
+ ? [`# join ${i} ${JSON.stringify({ hold: joins[i].hold, move: joins[i].move })}`]
+ : []));
+ return list + lines.join("\n") + "\n";
+}
+
+/**
+ * The hard-cut concat. Without joins, the demuxer's stream copy, as always;
+ * with them (the deck's holds and moves) the concat filter over each input's
+ * chain, one encode -- the copy cannot host a filtergraph.
+ */
+async function concatHardCut(segments, outDir, outPath, { record = false, joins = null, render = null } = {}) {
+ if (joins) {
+ await execFileP(FFMPEG, hardCutFilterArgs(segments, joins, render, outPath), { maxBuffer: 1 << 26 });
+ if (record) await writeFile(`${outPath}.segments`, concatRecordText(segments, joins), "utf8");
+ return;
+ }
const listPath = path.join(outDir, "concat.txt");
await writeFile(listPath, concatListText(segments), "utf8");
await execFileP(
@@ -2717,7 +2904,9 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
if (!(await exists(seg)))
throw new Error(`--chapters-only needs ${seg}, which is missing — run a full build first`);
}
- await muxChapters(finalPath, entries, segs, D, outDir, provenance, render.fps, deckOn(render));
+ // Under the deck the cut's offsets include the holds: measured, not written.
+ const joins = deck ? segmentJoins(await measureChromeSchedule({ manifest, entries, segments: segs, D })) : null;
+ await muxChapters(finalPath, entries, segs, D, outDir, provenance, render.fps, deckOn(render), joins);
return { out: finalPath, failures: [] };
}
@@ -2772,6 +2961,8 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
}
const schedule = await writeChromeSchedule({ manifest, entries, segments: segs, D, outDir });
EMIT("chrome", { phase: "schedule", total: schedule.total, segments: schedule.segments.length });
+ // The holds and the moves, joined on their inputs (null without posts).
+ const joins = segmentJoins(schedule);
const prerail = prerailPath(outDir, manifest.slug, D);
if (opts.chromePreview) {
@@ -2781,14 +2972,14 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
manifestPath, render, outDir, variant, schedule, from: at, duration: dur,
});
const out = path.join(outDir, `${manifest.slug}.preview.mp4`);
- if (await freshConcat(prerail, segs, schedule.total, render.fps)) {
+ if (await freshConcat(prerail, segs, schedule.total, render.fps, joins)) {
EMIT("chrome", { phase: "overlay", base: path.basename(prerail) });
await applyChrome(prerail, out, render, plan, { start: at, dur });
} else {
- const { starts, durs } = await segmentOffsets(segs, D, render.fps);
+ const { starts, durs } = await cutOffsets(segs, D, render.fps, joins);
EMIT("chrome", { phase: "overlay", base: "segments" });
await execFileP(FFMPEG, previewFromSegmentsArgs({
- segments: segs, durs, starts, D, at, dur, render, chromePlan: plan, outPath: out,
+ segments: segs, durs, starts, D, at, dur, render, chromePlan: plan, outPath: out, joins,
}), { maxBuffer: 1 << 26 });
}
EMIT("done", { out, failures: [] });
@@ -2802,19 +2993,19 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
// The hard-cut concat is a stream copy of these very segments; when
// nothing changed since it was made it is reused, and the overlay is
// the only encode.
- if (await freshConcat(prerail, segs, schedule.total, render.fps)) {
+ if (await freshConcat(prerail, segs, schedule.total, render.fps, joins)) {
EMIT("note", { message: `reusing ${path.basename(prerail)}` });
} else {
- await concatHardCut(segs, outDir, prerail, { record: true });
+ await concatHardCut(segs, outDir, prerail, { record: true, joins, render });
}
await assertConcatLength(prerail, schedule.total, render.fps, "hard-cut concat");
await applyChrome(prerail, dirs.final, render, plan, null);
} else {
- await concatWithXfade(segs, render, dirs.final, null, plan);
+ await concatWithXfade(segs, render, dirs.final, null, plan, joins);
}
await assertConcatLength(dirs.final, schedule.total, render.fps, "deck build");
// The overlay re-encodes, so the chapters on the previous final are gone.
- if (!opts.noChapters) await muxChapters(dirs.final, entries, segs, D, outDir, provenance, render.fps, deckOn(render));
+ if (!opts.noChapters) await muxChapters(dirs.final, entries, segs, D, outDir, provenance, render.fps, deckOn(render), joins);
EMIT("done", { out: dirs.final, failures: [] });
return { out: dirs.final, failures: [] };
}
@@ -2895,6 +3086,9 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
schedule = await writeChromeSchedule({ manifest, entries, segments, D, outDir });
EMIT("chrome", { phase: "schedule", total: schedule.total, segments: schedule.segments.length });
}
+ // The deck's holds and moves, joined on their inputs; null without posts
+ // (and always without the deck), which leaves every concat as it was.
+ const joins = deck ? segmentJoins(schedule) : null;
const railPlan = opts.noRail ? null : await buildRailPlan(manifest, render, entries, segments, D, outDir);
@@ -2947,14 +3141,14 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
// Only the deck reaches here (the band refused above, and the deck
// refuses a rail). Hard-cut concat to the prerail, then ONE overlay
// re-encode to the final.
- await concatHardCut(segments, outDir, prerail, { record: true });
+ await concatHardCut(segments, outDir, prerail, { record: true, joins, render });
await assertConcatLength(prerail, schedule.total, render.fps, "hard-cut concat");
await applyChrome(prerail, final, render, chromePlan, null);
} else {
- await concatHardCut(segments, outDir, final);
+ await concatHardCut(segments, outDir, final, { joins, render });
}
} else {
- await concatWithXfade(segments, render, final, railPlan, chromePlan);
+ await concatWithXfade(segments, render, final, railPlan, chromePlan, joins);
}
// The deck's sequence is laid with shortest=1, so a sequence a frame short
// would shorten the cut without a word; the schedule is the length to hold.
@@ -2965,7 +3159,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
// shortest=1), and a hang means an unbounded -loop 1.
if (railPlan) await assertConcatLength(final, railPlan.total, render.fps, "rail build");
- if (!opts.noChapters) await muxChapters(final, entries, segments, D, outDir, provenance, render.fps, deckOn(render));
+ if (!opts.noChapters) await muxChapters(final, entries, segments, D, outDir, provenance, render.fps, deckOn(render), joins);
// A branded cut with a `thumbnail` gets one beside it. The cut is already
// done, so a thumbnail that cannot be made is said, not thrown.
diff --git a/umtool/report-to-video/chrome-posts.mjs b/umtool/report-to-video/chrome-posts.mjs
@@ -53,10 +53,13 @@ const r4 = (v) => Math.round(v * 10000) / 10000;
export const PLATFORM_LABEL = Object.freeze({ bluesky: "Bluesky", x: "X" });
/**
- * The motion. Seconds; `rise` is how far (px) a card comes up as it fades in,
- * `gap` the space between two cards in the column.
+ * The motion. Seconds. A card slides in from the frame's edge over `enter`;
+ * its accent glow flares to full over `glowUp`, starting `glowAt` into the
+ * entrance, then settles to `glowRest` over `glowDown`. `gap` is the space between two cards in the column.
*/
-export const POSTS_MOTION = Object.freeze({ enter: 0.35, slide: 0.35, rise: 18, gap: 14 });
+export const POSTS_MOTION = Object.freeze({
+ enter: 0.55, slide: 0.35, gap: 14, glowAt: 0.3, glowUp: 0.18, glowDown: 1.1, glowRest: 0.3,
+});
/** The schedule's posts for one window's segment, in slot order. */
export function windowPosts(schedule, segment) {
@@ -82,16 +85,21 @@ export function embedFn(name, fn) {
*
* `posts` are `[{ id, appear, out: [a, b] }]` in slot order (oldest first),
* times in the CUT's clock; `heights` are the cards' heights in px; `column`
- * is the region's height. Card j enters at its `appear` (a fade and a rise of
- * `rise` px over `enter` s); cards stack top-down `gap` apart; when card j
- * would overflow the column, the oldest slide up and out (the whole stack
- * moves up over `slide` s, the departing cards fading as they go); every card
- * still up leaves over its `out` (a front-loaded fade and a slight shrink).
+ * is the region's height. Card j slides in at its `appear` from `enterX` px
+ * to the side (the frame's edge) over `enter` s, and its glow (`g<j>`) flares
+ * as it lands and settles to `glowRest`; cards stack top-down `gap` apart;
+ * when card j would overflow the column, the oldest slide up and out (the
+ * whole stack moves up over `slide` s, the departing cards fading as they go);
+ * every card still up leaves over its `out` (a front-loaded fade and a slight
+ * shrink).
*
* @returns {{ tops: number[], init: Record<string, object>,
* cues: Array<{ k: string, at: number, dur: number, from: object, to: object, ease: string, why: string }> }}
*/
-export function postsCues({ posts, heights, column, gap = 14, enter = 0.35, slide = 0.35, rise = 18 }) {
+export function postsCues({
+ posts, heights, column, gap = 14, enter = 0.55, slide = 0.35, enterX = 624,
+ glowAt = 0.3, glowUp = 0.18, glowDown = 1.1, glowRest = 0.3,
+}) {
const R = (v) => Math.round(v * 10000) / 10000;
const MIN = 0.001;
const tops = [];
@@ -101,7 +109,10 @@ export function postsCues({ posts, heights, column, gap = 14, enter = 0.35, slid
acc += (heights[j] || 0) + gap;
}
const init = { stack: { y: 0 } };
- for (let j = 0; j < posts.length; j += 1) init[`c${j}`] = { autoAlpha: 0, y: rise, scale: 1 };
+ for (let j = 0; j < posts.length; j += 1) {
+ init[`c${j}`] = { autoAlpha: 0, x: enterX, scale: 1 };
+ init[`g${j}`] = { opacity: 0 };
+ }
const ev = [];
const add = (k, at, dur, to, ease, why) => ev.push({ k, at: R(at), dur: R(Math.max(MIN, dur)), to, ease, why });
@@ -120,7 +131,10 @@ export function postsCues({ posts, heights, column, gap = 14, enter = 0.35, slid
add("stack", t, slide, { y: -shift }, "power2.inOut", `slide for ${p.id}`);
for (const g of gone) add(`c${g}`, t, slide, { autoAlpha: 0 }, "power1.in", `slide for ${p.id}`);
}
- add(`c${j}`, t, enter, { autoAlpha: 1, y: 0 }, "power3.out", `enter ${p.id}`);
+ add(`c${j}`, t, enter, { autoAlpha: 1, x: 0 }, "expo.out", `enter ${p.id}`);
+ // The flare, as the card lands; then it settles to a quiet rim.
+ add(`g${j}`, t + glowAt, glowUp, { opacity: 1 }, "power2.out", `glow ${p.id}`);
+ add(`g${j}`, t + glowAt + glowUp, glowDown, { opacity: glowRest }, "power2.inOut", `settle ${p.id}`);
visible.push(j);
}
for (const j of visible) {
@@ -209,11 +223,12 @@ export function postsHtml(schedule, render, window, opts = {}) {
const pad = 18;
const plateW = set.qrSize + 2 * pad;
- const metaSize = 17;
- const textSize = 23;
+ const rail = 6;
+ const metaSize = 18;
+ const textSize = 24;
const lineH = Math.round(textSize * 1.36);
- const top = mix(pal.bg, pal.fg, 0.095);
- const bottom = mix(pal.bg, pal.fg, 0.04);
+ const top = mix(mix(pal.bg, pal.fg, 0.1), pal.accent, 0.07);
+ const bottom = mix(mix(pal.bg, pal.fg, 0.045), pal.accent, 0.04);
const cardHtml = posts
.map((p, j) => {
@@ -221,16 +236,17 @@ export function postsHtml(schedule, render, window, opts = {}) {
const src = qrSrcs[p.id];
return (
`<article class="post" data-post="${esc(p.id)}" data-k="c${j}">` +
- `<div class="edge"></div>` +
`<div class="body">` +
- `<div class="meta"><span class="who"><span class="handle">${esc(name)}</span>` +
- (platform ? `<span class="sep">·</span><span class="platform">${esc(platform)}</span>` : "") +
- `</span><span class="date">${esc(postDate(p, deck.subtitle.dateFormat))}</span></div>` +
+ `<div class="meta">` +
+ (platform ? `<span class="platform">${esc(platform)}</span>` : "") +
+ `<span class="who"><span class="handle">${esc(name)}</span></span>` +
+ `<span class="date">${esc(postDate(p, deck.subtitle.dateFormat))}</span></div>` +
`<div class="text">${postParagraphs(p.text).map((t) => `<p class="para">${esc(t)}</p>`).join("")}</div>` +
`</div>` +
`<div class="plate">` +
(src ? `<img src="${esc(src)}" width="${set.qrSize}" height="${set.qrSize}" alt="">` : "") +
`</div>` +
+ `<div class="glow" data-k="g${j}"></div>` +
`</article>`
);
})
@@ -247,7 +263,13 @@ export function postsHtml(schedule, render, window, opts = {}) {
gap: POSTS_MOTION.gap,
enter: POSTS_MOTION.enter,
slide: POSTS_MOTION.slide,
- rise: POSTS_MOTION.rise,
+ // From just past the frame's own edge on the column's side, so a card
+ // comes in from outside the picture, not out of the region's boundary.
+ enterX: set.position === "top-left" ? -(geo.x + W) : (render.width ?? 1920) - geo.x,
+ glowAt: POSTS_MOTION.glowAt,
+ glowUp: POSTS_MOTION.glowUp,
+ glowDown: POSTS_MOTION.glowDown,
+ glowRest: POSTS_MOTION.glowRest,
ids: posts.map((p) => p.id),
posts: posts.map((p) => ({ id: p.id, appear: p.appear, out: p.out })),
};
@@ -275,30 +297,36 @@ export function postsHtml(schedule, render, window, opts = {}) {
#posts-clip { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; }
.stack { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; }
/* Hidden until the timeline places it: a frame taken before the faces
- are in shows nothing rather than an unplaced card. */
+ are in shows nothing rather than an unplaced card. A card is an
+ interruption, not a caption: lifted off the ground with a touch of
+ the accent, an accent rail down its leading edge. */
.post { position: absolute; left: 0; top: 0; width: ${W}px; min-height: ${set.qrSize + 2 * pad}px;
visibility: hidden; opacity: 0; border-radius: 10px; overflow: hidden;
+ border-left: ${rail}px solid ${pal.accent};
background: linear-gradient(180deg, ${top} 0%, ${bottom} 100%);
- box-shadow: inset 0 0 0 1px ${rgba(pal.fg, 0.1)}; transform-origin: 50% 0%; }
- /* The deck's top edge, the same hairline: the accent in from the left,
- settling to a quiet rule. */
- .edge { position: absolute; left: 0; top: 0; width: ${W}px; height: 2px;
- background: linear-gradient(90deg, ${rgba(pal.accent, 0.95)} 0px, ${rgba(pal.accent, 0.4)} ${Math.round(W * 0.28)}px,
- ${rgba(pal.fg, 0.14)} ${Math.round(W * 0.62)}px, ${rgba(pal.fg, 0.14)} ${W}px); }
- .body { position: relative; width: ${W - plateW}px; padding: ${pad}px ${pad + 4}px ${pad + 2}px ${pad + 4}px; }
- .meta { display: flex; align-items: baseline; justify-content: space-between; gap: 12px;
+ box-shadow: inset 0 0 0 1px ${rgba(pal.fg, 0.12)}; transform-origin: 50% 0%; }
+ /* The flare as a card lands, settling to a quiet accent rim. Last in the
+ card, over the QR's cell -- its blur stays inside the cell's padding,
+ clear of the code. */
+ .glow { position: absolute; left: 0; top: 0; right: 0; bottom: 0; opacity: 0; pointer-events: none;
+ box-shadow: inset 0 0 0 2px ${rgba(pal.accent, 0.95)}, inset 0 0 14px ${rgba(pal.accent, 0.55)}; }
+ .body { position: relative; width: ${W - plateW - rail}px; padding: ${pad - 4}px ${pad + 2}px ${pad - 3}px ${pad + 2}px; }
+ .meta { display: flex; align-items: center; gap: 10px;
font-size: ${metaSize}px; line-height: ${Math.round(metaSize * 1.3)}px; white-space: nowrap; }
- .who { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
+ .who { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.handle { font-family: 'DeckSansBold', sans-serif; color: ${pal.fg}; letter-spacing: 0.005em; }
- .sep { color: ${pal.accent}; padding: 0 0.42em; font-family: 'DeckSansBold', sans-serif; }
- .platform { color: ${pal.muted}; }
+ /* Which platform, as a label you cannot miss. */
+ .platform { flex: none; font-family: 'DeckSansBold', sans-serif; font-size: ${metaSize - 3}px;
+ line-height: ${metaSize + 3}px; letter-spacing: 0.03em; color: ${pal.fg};
+ padding: 1px 10px 2px; border-radius: 999px;
+ background: ${rgba(pal.accent, 0.26)}; box-shadow: inset 0 0 0 1px ${rgba(pal.accent, 0.7)}; }
.date { color: ${pal.muted}; flex: none; font-variant-numeric: tabular-nums; }
- .text { margin-top: 10px; font-size: ${textSize}px; line-height: ${lineH}px; color: ${pal.fg}; }
+ .text { margin-top: 8px; font-size: ${textSize}px; line-height: ${lineH}px; color: ${pal.fg}; }
/* Each paragraph is clamped to what is left of maxLines when the page
measures (clampText), so the ellipsis always ends words, never a blank line. */
.para { white-space: pre-line; overflow-wrap: anywhere; overflow: hidden;
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: ${set.maxLines}; }
- .para + .para { margin-top: ${Math.round(lineH * 0.42)}px; }
+ .para + .para { margin-top: ${Math.round(lineH * 0.36)}px; }
.para.gone { display: none; }
/* 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;
@@ -359,14 +387,15 @@ export function postsHtml(schedule, render, window, opts = {}) {
}
const ready = Promise.all([
- document.fonts.load("23px DeckSans"),
- document.fonts.load("17px DeckSansBold"),
+ document.fonts.load("${textSize}px DeckSans"),
+ document.fonts.load("${metaSize}px DeckSansBold"),
]).catch(() => {}).then(() => {
const cards = P.ids.map((_, j) => byK["c" + j]);
cards.forEach(clampText);
const heights = cards.map((c) => c.offsetHeight);
const plan = postsCues({ posts: P.posts, heights, column: P.column, gap: P.gap,
- enter: P.enter, slide: P.slide, rise: P.rise });
+ enter: P.enter, slide: P.slide, enterX: P.enterX, glowAt: P.glowAt,
+ glowUp: P.glowUp, glowDown: P.glowDown, glowRest: P.glowRest });
cards.forEach((c, j) => { c.style.top = plan.tops[j] + "px"; });
for (const k of Object.keys(plan.init)) if (byK[k]) gsap.set(byK[k], plan.init[k]);
// The cues are in the cut's clock; the window plays [from, from + dur] of it.
diff --git a/umtool/report-to-video/chrome-posts.test.mjs b/umtool/report-to-video/chrome-posts.test.mjs
@@ -140,11 +140,24 @@ test("the page's times are postSchedule's: data, enter and leave cues", () => {
assert.equal(d.from, win.from);
near(d.dur, win.to - win.from, "the window's length");
// Whatever the heights, each card enters at its appear and leaves over its out.
- const plan = postsCues({ posts: d.posts, heights: [180, 220], column: d.column, gap: d.gap, enter: d.enter, slide: d.slide, rise: d.rise });
+ const plan = postsCues({
+ posts: d.posts, heights: [180, 220], column: d.column, gap: d.gap, enter: d.enter, slide: d.slide,
+ enterX: d.enterX, glowAt: d.glowAt, glowUp: d.glowUp, glowDown: d.glowDown, glowRest: d.glowRest,
+ });
+ // The column is at the frame's right edge: a card comes in from past it.
+ assert.equal(d.enterX, 1920 - postsGeometry(RENDER).x);
posts.forEach((p, j) => {
const enter = plan.cues.find((c) => c.k === `c${j}` && c.why === `enter ${p.id}`);
near(enter.at, p.appear, `${p.id} enters`);
- assert.deepEqual(enter.to, { autoAlpha: 1, y: 0 });
+ assert.deepEqual(enter.from, { autoAlpha: 0, x: d.enterX });
+ assert.deepEqual(enter.to, { autoAlpha: 1, x: 0 });
+ // The glow flares as it lands and settles, never to nothing while the card is up.
+ const flare = plan.cues.find((c) => c.k === `g${j}` && c.why === `glow ${p.id}`);
+ const settle = plan.cues.find((c) => c.k === `g${j}` && c.why === `settle ${p.id}`);
+ near(flare.at, p.appear + d.glowAt, `${p.id} flares`);
+ assert.deepEqual([flare.from, flare.to], [{ opacity: 0 }, { opacity: 1 }]);
+ assert.deepEqual([settle.from, settle.to], [{ opacity: 1 }, { opacity: d.glowRest }]);
+ assert.ok(d.glowRest > 0);
const leave = plan.cues.find((c) => c.k === `c${j}` && c.why === `leave ${p.id}`);
near(leave.at, p.out[0], `${p.id} leaves`);
near(leave.at + leave.dur, p.out[1], `${p.id} gone`);
diff --git a/umtool/report-to-video/deck-room.test.mjs b/umtool/report-to-video/deck-room.test.mjs
@@ -0,0 +1,382 @@
+// Tests for "room for posts" in the build (slice R1): the hold and the footage
+// move on a carrying clip's input chain, where the cut is joined; the
+// hard-cut concat that hosts them; the record a cached concat keeps of them;
+// the cut's lengths with holds; and real ffmpeg runs showing a held segment
+// freezes for exactly hold·fps frames in silence while every other input's
+// frames pass through untouched.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import {
+ concatListText, concatRecordText, hardCutFilterArgs, holdAudioFilter, holdVideoFilter, joinInputChain,
+ moveFilter, previewFromSegmentsArgs, sameConcatList, segmentJoins, windowSegments, xfadeGraph,
+} from "./build-video.mjs";
+import { deckGeometry, deckSchedule, scheduleFrom, shiftedFootage } from "./deck.mjs";
+
+const PALETTE = { bg: "#12101a", fg: "#f4f1ea", muted: "#9a93ad", accent: "#a97bff", amber: "#ffc860" };
+const RENDER = {
+ width: 1920, height: 1080, fps: 30, transition: 0.5, palette: PALETTE, crf: 21, preset: "slow",
+ audioRate: 48000, audioChannels: 2, chrome: { engine: "hyperframes", layout: "deck", deck: {} },
+};
+const PROV = { siteOrigin: "https://example.test", channelSlug: "chan" };
+const CLIPS = [
+ { id: "c1", type: "clip", video: "v1", start: 0, end: 10, date: "2024-09-05" },
+ { id: "c2", type: "clip", video: "v2", start: 0, end: 12, date: "2024-10-01" },
+ { id: "c3", type: "clip", video: "v3", start: 0, end: 4, date: "2025-06-01" },
+];
+const POST = (id, date) => ({
+ id, platform: "bluesky", date, text: `post ${id}`, url: `https://bsky.app/profile/a/post/${id}`,
+});
+const MOVE = (segmentAt, extra = {}) => ({
+ segment: "c2", at: 9.5 + segmentAt, segmentAt, seconds: 0.6,
+ from: deckGeometry(RENDER).footage, to: shiftedFootage(RENDER), ...extra,
+});
+
+// ---- the join plan ----------------------------------------------------------
+
+test("segmentJoins: null without posts; a hold and a move on each carrying clip, aligned with the segments", () => {
+ const durs = [10, 12, 4];
+ const plain = deckSchedule({ entries: CLIPS, durs, D: 0.5, render: RENDER, provenance: PROV });
+ assert.equal(segmentJoins(plain), null);
+ assert.equal(segmentJoins(null), null);
+ const s = deckSchedule({ entries: CLIPS, durs, D: 0.5, render: RENDER, provenance: PROV, posts: [POST("a", "2024-10-19")] });
+ const joins = segmentJoins(s);
+ assert.equal(joins.length, 3);
+ assert.equal(joins[0], null);
+ assert.equal(joins[2], null);
+ assert.equal(joins[1].hold, 2.5);
+ assert.deepEqual(joins[1].move, s.moves[0]);
+ // A hold alone (shift off) and a move alone (hold 0) are each a join.
+ const noShift = { ...RENDER, chrome: { ...RENDER.chrome, deck: { posts: { shift: false } } } };
+ const h = segmentJoins(deckSchedule({ entries: CLIPS, durs, D: 0.5, render: noShift, provenance: PROV, posts: [POST("a", "2024-10-19")] }));
+ assert.deepEqual(h[1], { hold: 2.5, move: null });
+ const noHold = { ...RENDER, chrome: { ...RENDER.chrome, deck: { posts: { hold: 0 } } } };
+ const m = segmentJoins(deckSchedule({ entries: CLIPS, durs, D: 0.5, render: noHold, provenance: PROV, posts: [POST("a", "2024-10-19")] }));
+ assert.equal(m[1].hold, 0);
+ assert.ok(m[1].move);
+ // Neither: no joins at all.
+ const neither = { ...RENDER, chrome: { ...RENDER.chrome, deck: { posts: { hold: 0, shift: false } } } };
+ assert.equal(segmentJoins(deckSchedule({ entries: CLIPS, durs, D: 0.5, render: neither, provenance: PROV, posts: [POST("a", "2024-10-19")] })), null);
+});
+
+test("the cut's lengths: the schedule's starts are the probed lengths plus the holds, as the joins carry them", () => {
+ const durs = [10, 12, 4];
+ const s = deckSchedule({ entries: CLIPS, durs, D: 0.5, render: RENDER, provenance: PROV, posts: [POST("a", "2024-10-19")] });
+ const joins = segmentJoins(s);
+ // cutOffsets' sum, done by hand: probed + hold, then scheduleFrom.
+ const cut = scheduleFrom(durs.map((d, i) => d + (joins[i]?.hold ?? 0)), 0.5);
+ assert.deepEqual(cut.starts, s.segments.map((x) => x.start));
+ assert.equal(cut.total, s.total);
+ assert.equal(s.total, 10 + 12 + 4 - 1 + 2.5);
+});
+
+// ---- the chains, as strings -------------------------------------------------
+
+test("joinInputChain: no join, no chain -- the input's own labels", () => {
+ assert.deepEqual(joinInputChain(3, null, RENDER), { parts: [], v: "[3:v]", a: "[3:a]" });
+});
+
+test("joinInputChain: a hold is tpad clone on the picture and apad silence on the sound", () => {
+ assert.equal(holdVideoFilter(2.5), "tpad=stop_mode=clone:stop_duration=2.5");
+ assert.equal(holdAudioFilter(2.5), "apad=pad_dur=2.5");
+ assert.deepEqual(joinInputChain(1, { hold: 2.5, move: null }, RENDER), {
+ parts: ["[1:v]tpad=stop_mode=clone:stop_duration=2.5[j1v]", "[1:a]apad=pad_dur=2.5[j1a]"],
+ v: "[j1v]",
+ a: "[j1a]",
+ });
+});
+
+test("joinInputChain: the move goes before the hold, so the held frame is the moved one; 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[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]" });
+});
+
+test("moveFilter: perspective places the frame's corners so the footage box eases from `from` to `to`", () => {
+ const f = moveFilter(MOVE(6), RENDER);
+ const [fill, persp] = f.split(/,(?=perspective=)/);
+ assert.equal(fill, "fillborders=left=2:right=2:top=2:bottom=2:mode=fixed:color=#12101a");
+ assert.match(persp, /:interpolation=linear:sense=destination:eval=frame$/);
+ // 1574×886 at (173,2) → 1354×762 at (24,64): the input frame's corners at e = 1.
+ const e = "st(0,clip(((in-1)/30-6)/0.6,0,1))";
+ const ease = "*ld(0)*ld(0)*(3-2*ld(0))";
+ assert.equal(
+ persp,
+ "perspective=" + [
+ `x0='${e};0+(-124.8196)${ease}'`, `y0='${e};0+(62.2799)${ease}'`,
+ `x1='${e};W+(-393.1804)${ease}'`, `y1='${e};0+(62.2799)${ease}'`,
+ `x2='${e};0+(-124.8196)${ease}'`, `y2='${e};H+(-88.8713)${ease}'`,
+ `x3='${e};W+(-393.1804)${ease}'`, `y3='${e};H+(-88.8713)${ease}'`,
+ ].join(":") + ":interpolation=linear:sense=destination:eval=frame",
+ );
+ // The corner offsets ARE the box map: the from box's corners land on the to box's.
+ const F = deckGeometry(RENDER).footage;
+ const T = shiftedFootage(RENDER);
+ const X = (u) => -124.8196 + (u * (1920 - 393.1804 - -124.8196)) / 1920;
+ const Y = (v) => 62.2799 + (v * (1080 - 88.8713 - 62.2799)) / 1080;
+ near(X(F.x), T.x, "left");
+ near(X(F.x + F.width), T.x + T.width, "right");
+ near(Y(F.y), T.y, "top");
+ near(Y(F.y + F.height), T.y + T.height, "bottom");
+ // No easing time: a cut.
+ assert.match(moveFilter(MOVE(6, { seconds: 0 }), RENDER), /x0='st\(0,gte\(\(in-1\)\/30,6\)\);0\+/);
+});
+
+function near(a, b, msg) { assert.ok(Math.abs(a - b) < 0.01, `${msg}: ${a} != ${b}`); }
+
+test("xfadeGraph: without joins, the graph the crossfade concat always wrote", () => {
+ const g = xfadeGraph([10, 12, 4], 0.5, null, RENDER);
+ assert.deepEqual(g.parts, [
+ "[0:v][1:v]xfade=transition=fade:duration=0.5:offset=9.500[v1]",
+ "[0:a][1:a]acrossfade=d=0.5:c1=tri:c2=tri[a1]",
+ "[v1][2:v]xfade=transition=fade:duration=0.5:offset=21.000[v2]",
+ "[a1][2:a]acrossfade=d=0.5:c1=tri:c2=tri[a2]",
+ ]);
+ assert.equal(g.vlab, "[v2]");
+ assert.equal(g.alab, "[a2]");
+ // An all-null join list is the same graph.
+ assert.deepEqual(xfadeGraph([10, 12, 4], 0.5, [null, null, null], RENDER), g);
+});
+
+test("xfadeGraph: a held input joins through its chain, and the offsets after it move by the hold", () => {
+ const joins = [null, { hold: 2.5, move: MOVE(6) }, null];
+ const g = xfadeGraph([10, 14.5, 4], 0.5, joins, RENDER);
+ assert.deepEqual(g.parts.slice(2), [
+ "[0:v][j1v]xfade=transition=fade:duration=0.5:offset=9.500[v1]",
+ "[0:a][j1a]acrossfade=d=0.5:c1=tri:c2=tri[a1]",
+ "[v1][2:v]xfade=transition=fade:duration=0.5:offset=23.500[v2]",
+ "[a1][2:a]acrossfade=d=0.5:c1=tri:c2=tri[a2]",
+ ]);
+ assert.deepEqual(g.parts.slice(0, 2), joinInputChain(1, joins[1], RENDER).parts);
+});
+
+test("hardCutFilterArgs: the concat filter over each input's chain, one encode", () => {
+ const joins = [null, { hold: 2.5, move: null }, null];
+ const args = hardCutFilterArgs(["/s/a.mp4", "/s/b.mp4", "/s/c.mp4"], joins, RENDER, "/o/x.prerail-hardcut.mp4");
+ assert.deepEqual(args.filter((_, i) => args[i - 1] === "-i"), ["/s/a.mp4", "/s/b.mp4", "/s/c.mp4"]);
+ assert.equal(
+ args[args.indexOf("-filter_complex") + 1],
+ "[1:v]tpad=stop_mode=clone:stop_duration=2.5[j1v];[1:a]apad=pad_dur=2.5[j1a];" +
+ "[0:v][0:a][j1v][j1a][2:v][2:a]concat=n=3:v=1:a=1[vc][ac]",
+ );
+ assert.deepEqual(args.slice(args.indexOf("-map"), args.indexOf("-map") + 4), ["-map", "[vc]", "-map", "[ac]"]);
+ assert.equal(args[args.indexOf("-c:v") + 1], "libx264");
+ assert.equal(args[args.indexOf("-c:a") + 1], "aac");
+ assert.equal(args.at(-1), "/o/x.prerail-hardcut.mp4");
+});
+
+test("the hard-cut record: the list alone without joins; the joins with them, so a changed hold is never reused", () => {
+ const segs = ["/s/a.mp4", "/s/b.mp4", "/s/c.mp4"];
+ assert.equal(concatRecordText(segs, null), concatListText(segs));
+ const joins = [null, { hold: 2.5, move: MOVE(6) }, null];
+ const rec = concatRecordText(segs, joins);
+ assert.ok(rec.startsWith(concatListText(segs)));
+ assert.match(rec, /^# join 1 \{"hold":2\.5,"move":\{"segment":"c2"/m);
+ assert.equal(sameConcatList(rec, segs, joins), true);
+ // The list a deck without posts records is not the record of a joined one, either way round.
+ assert.equal(sameConcatList(rec, segs), false);
+ assert.equal(sameConcatList(concatListText(segs), segs, joins), false);
+ // A changed hold, a changed move, a hold moved to another clip: all stale.
+ assert.equal(sameConcatList(rec, segs, [null, { hold: 3, move: MOVE(6) }, null]), false);
+ assert.equal(sameConcatList(rec, segs, [null, { hold: 2.5, move: MOVE(6.5) }, null]), false);
+ assert.equal(sameConcatList(rec, segs, [{ hold: 2.5, move: MOVE(6) }, null, null]), false);
+});
+
+// ---- window maths with holds ------------------------------------------------
+
+test("a preview window over a held clip: picked by the cut's lengths, its inputs joined as the full concat's", () => {
+ const joins = [null, { hold: 2.5, move: MOVE(6) }, null];
+ const durs = [10, 14.5, 4]; // c2 is 12 s on disk, 14.5 in the cut
+ const { starts } = scheduleFrom(durs, 0.5);
+ assert.deepEqual(starts, [0, 9.5, 23.5]);
+ // 21.5–23.0 is c2's hold: by the files' own lengths it would reach into c3.
+ assert.deepEqual(windowSegments(starts, durs, 21.5, 1.5), { first: 1, last: 1, offset: 12 });
+ const plan = { regions: [], outLabel: "[hfout]" };
+ const args = previewFromSegmentsArgs({
+ segments: ["/s/a.mp4", "/s/b.mp4", "/s/c.mp4"], durs, starts, D: 0.5, at: 20, dur: 6,
+ render: RENDER, chromePlan: plan, outPath: "/p.mp4", joins,
+ });
+ assert.deepEqual(args.filter((_, i) => args[i - 1] === "-i"), ["/s/b.mp4", "/s/c.mp4"]);
+ const fc = args[args.indexOf("-filter_complex") + 1];
+ // b is input 0 here, joined as input 1 is in the full concat.
+ assert.ok(fc.startsWith(joinInputChain(0, joins[1], RENDER).parts.join(";") + ";"));
+ assert.match(fc, /\[j0v\]\[1:v\]xfade=transition=fade:duration=0\.5:offset=14\.000\[v1\]/);
+ assert.match(fc, /\[v1\]trim=start=10\.500:duration=6\.000/);
+ // One held segment alone: its chain, then the trim.
+ const one = previewFromSegmentsArgs({
+ segments: ["/s/a.mp4", "/s/b.mp4", "/s/c.mp4"], durs, starts, D: 0.5, at: 21.5, dur: 1.5,
+ render: RENDER, chromePlan: plan, outPath: "/p.mp4", joins,
+ });
+ assert.match(one[one.indexOf("-filter_complex") + 1], /\[j0v\]trim=start=12\.000:duration=1\.500,setpts=PTS-STARTPTS\[vw\];\[j0a\]atrim=/);
+ // Without joins, the window's graph is unchanged.
+ const plain = previewFromSegmentsArgs({
+ segments: ["/s/a.mp4", "/s/b.mp4"], durs: [10, 10], starts: [0, 9.5], D: 0.5, at: 8, dur: 4,
+ render: RENDER, chromePlan: plan, outPath: "/p.mp4",
+ });
+ assert.match(plain[plain.indexOf("-filter_complex") + 1], /^\[0:v\]\[1:v\]xfade=transition=fade:duration=0\.5:offset=9\.500\[v1\];\[0:a\]\[1:a\]acrossfade/);
+});
+
+// ---- ffmpeg, for real -------------------------------------------------------
+
+const have = (b, a) => spawnSync(b, a, { stdio: "ignore" }).status === 0;
+const haveFfmpeg = have("ffmpeg", ["-version"]);
+
+const R = {
+ width: 320, height: 180, fps: 30, palette: PALETTE, crf: 21, preset: "veryfast",
+ audioRate: 48000, audioChannels: 2,
+};
+const ff = (args, opts = {}) => {
+ const r = spawnSync("ffmpeg", ["-nostdin", "-v", "error", "-y", ...args], { maxBuffer: 1 << 28, ...opts });
+ assert.equal(r.status, 0, String(r.stderr));
+ return r.stdout;
+};
+/** framemd5's hashes for one output stream (the picture is stream 0). */
+const md5s = (out, stream = 0) => String(out).split("\n").filter((l) => l && !l.startsWith("#"))
+ .map((l) => l.split(",")).filter((f) => Number(f[0]) === stream).map((f) => f.at(-1).trim());
+
+/** Three 2 s segments as the deck frames them: footage in a box over bg, a tone under it. */
+function segments(dir) {
+ const make = (name, src, hz) => {
+ const f = path.join(dir, `${name}.mov`);
+ ff([
+ "-f", "lavfi", "-i", `${src}=s=280x150:r=30:d=2`,
+ "-f", "lavfi", "-i", `sine=frequency=${hz}:sample_rate=48000:duration=2`,
+ "-filter_complex", `[0:v]pad=320:180:20:10:color=${PALETTE.bg},format=yuv420p[v];[1:a]aformat=channel_layouts=stereo[a]`,
+ "-map", "[v]", "-map", "[a]", "-c:v", "ffv1", "-c:a", "pcm_s16le", f,
+ ]);
+ return f;
+ };
+ return [make("a", "testsrc2", 440), make("b", "smptebars", 550), make("c", "rgbtestsrc", 660)];
+}
+
+test("ffmpeg: a held segment's last frame repeats for exactly hold·fps frames, in silence; the other inputs pass through untouched",
+ { skip: !haveFfmpeg }, () => {
+ const dir = mkdtempSync(path.join(tmpdir(), "deck-room-"));
+ try {
+ const segs = segments(dir);
+ const own = segs.map((s) => md5s(ff(["-i", s, "-map", "0:v", "-f", "framemd5", "-"])));
+ own.forEach((m) => assert.equal(m.length, 60));
+ const joins = [null, { hold: 1, move: null }, null];
+ // The hard cut's graph, run to frame hashes instead of an encode.
+ const args = hardCutFilterArgs(segs, joins, R, "-");
+ const fc = args[args.indexOf("-filter_complex") + 1];
+ const inputs = segs.flatMap((s) => ["-i", s]);
+ const out = md5s(ff([...inputs, "-filter_complex", fc, "-map", "[vc]", "-map", "[ac]", "-f", "framemd5", "-"]));
+ assert.equal(out.length, 60 + 60 + 30 + 60, "30 held frames and not one more");
+ // a and c: their own frames, bit for bit.
+ assert.deepEqual(out.slice(0, 60), own[0]);
+ assert.deepEqual(out.slice(150), own[2]);
+ // b, then its last frame 15 more times.
+ assert.deepEqual(out.slice(60, 120), own[1]);
+ assert.deepEqual(out.slice(119, 150), Array(31).fill(own[1][59]));
+ // The sound under the hold is silence; around it, the tones.
+ const pcm = ff([...inputs, "-filter_complex", `${fc};[vc]nullsink`, "-map", "[ac]", "-f", "s16le", "-ac", "1", "-"], { encoding: "buffer" });
+ const at = (s) => pcm.readInt16LE(Math.round(s * 48000) * 2);
+ const peak = (a, b) => {
+ let m = 0;
+ for (let i = Math.round(a * 48000); i < Math.round(b * 48000); i += 1) m = Math.max(m, Math.abs(pcm.readInt16LE(i * 2)));
+ return m;
+ };
+ assert.equal(pcm.length / 2, 5 * 48000 + 96000, "the sound is held as long as the picture");
+ assert.equal(peak(4.0, 5.0), 0, "silence under the hold");
+ assert.ok(peak(3.5, 4.0) > 1000, "b's tone before it");
+ assert.ok(peak(5.0, 5.5) > 1000, "c's tone after it");
+ assert.ok(Number.isFinite(at(0)));
+
+ // The crossfade concat (xfade hands on its own pixel format, so frames
+ // are compared with the graph the build ran before there were joins):
+ // a and c come out exactly as they did -- c 30 frames later -- and b's
+ // last frame is held up to the next dissolve.
+ const D = 0.5;
+ const legacy = md5s(ff([...inputs, "-filter_complex", [
+ "[0:v][1:v]xfade=transition=fade:duration=0.5:offset=1.500[v1]",
+ "[0:a][1:a]acrossfade=d=0.5:c1=tri:c2=tri[a1]",
+ "[v1][2:v]xfade=transition=fade:duration=0.5:offset=3.000[v2]",
+ "[a1][2:a]acrossfade=d=0.5:c1=tri:c2=tri[a2]",
+ ].join(";"), "-map", "[v2]", "-map", "[a2]", "-f", "framemd5", "-"]));
+ assert.equal(legacy.length, 150);
+ const xg = xfadeGraph([2, 3, 2], D, joins, R);
+ const xo = md5s(ff([...inputs, "-filter_complex", xg.parts.join(";"), "-map", xg.vlab, "-map", xg.alab, "-f", "framemd5", "-"]));
+ assert.equal(xo.length, 60 + 90 + 60 - 30);
+ assert.deepEqual(xo.slice(0, 60), legacy.slice(0, 60), "a and the dissolve into b");
+ assert.deepEqual(xo.slice(60, 90), legacy.slice(60, 90), "b, to where the old cut dissolved");
+ assert.deepEqual(xo.slice(104, 120), Array(16).fill(xo[104]), "b's last frame, held to the next dissolve");
+ assert.deepEqual(xo.slice(135), legacy.slice(105), "c after its dissolve, 30 frames later");
+
+ // Without joins the graph is exactly the one above.
+ const plain = xfadeGraph([2, 2, 2], D, null, R);
+ assert.deepEqual(
+ md5s(ff([...inputs, "-filter_complex", plain.parts.join(";"), "-map", plain.vlab, "-map", plain.alab, "-f", "framemd5", "-"])),
+ legacy,
+ );
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+test("ffmpeg: the move is the identity before it starts, lands on the target box, and leaves the uncovered ground bg",
+ { skip: !haveFfmpeg }, () => {
+ const dir = mkdtempSync(path.join(tmpdir(), "deck-room-mv-"));
+ try {
+ const [, b] = segments(dir);
+ const move = { segment: "b", at: 0, segmentAt: 0.5, 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: 0, move }, R);
+ 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 src = (n) => ff(["-i", b, "-vf", `select=eq(n\\,${n})`, "-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)];
+ // Before the move, inside the 2 px border, the frame is the input's.
+ const pre = rgb(10), pin = src(10);
+ for (const [x, y] of [[20, 10], [160, 90], [299, 159], [100, 40]]) assert.deepEqual(px(pre, x, y), px(pin, x, y), `pre ${x},${y}`);
+ // After it (0.5 + 0.6 s → frame 33 on), the footage's top-left corner is
+ // at (8,20) and everything right of and below the target box is ground.
+ const post = rgb(45);
+ const bg = [0x12, 0x10, 0x1a];
+ const close = (p, q, tol = 3) => p.every((v, i) => Math.abs(v - q[i]) <= tol);
+ for (const [x, y] of [[300, 10], [310, 170], [4, 4], [160, 160], [260, 100], [100, 15]]) {
+ assert.ok(close(px(post, x, y), bg), `ground at ${x},${y}: ${px(post, x, y)}`);
+ }
+ // The footage's own first column/row (smptebars' left edge) now starts at the target corner.
+ assert.ok(!close(px(post, 10, 22), bg), "footage at the target box");
+ assert.ok(!close(px(post, 245, 145), bg), "footage to the target box's far corner");
+ } 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-"));
+ try {
+ const render = { ...R, width: 640, height: 360, chrome: { engine: "hyperframes", layout: "deck", deck: { height: 120, posts: { width: 320 } } } };
+ const held = path.join(dir, "held.mp4");
+ const moving = path.join(dir, "moving.mp4");
+ const enc = ["-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "ultrafast", "-crf", "18"];
+ ff(["-f", "lavfi", "-i", "testsrc2=s=640x360:r=30:d=2", "-vf", "tpad=stop_mode=clone:stop_duration=1", ...enc, held]);
+ ff(["-f", "lavfi", "-i", "testsrc2=s=640x360:r=30:d=3", ...enc, moving]);
+ const schedule = { fps: 30, total: 3, segments: [{ id: "c1", start: 0, duration: 3, end: 3, hold: 1 }] };
+ const ok = [];
+ const got = await verifyHolds(held, schedule, render, ok);
+ assert.deepEqual(ok, []);
+ assert.equal(got.length, 1);
+ assert.ok(got[0].diff <= 1.5, `diff ${got[0].diff}`);
+ const bad = [];
+ await verifyHolds(moving, schedule, render, bad);
+ assert.equal(bad.length, 1);
+ assert.match(bad[0], /c1 is held 1s but its picture moves during the hold/);
+ // Nothing held, nothing to check.
+ assert.deepEqual(await verifyHolds(moving, { ...schedule, segments: [{ id: "c1", start: 0, duration: 3, end: 3 }] }, render, bad), []);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
diff --git a/umtool/report-to-video/verify-build.mjs b/umtool/report-to-video/verify-build.mjs
@@ -19,10 +19,11 @@ import { readdir, readFile, stat } from "node:fs/promises";
import path from "node:path";
import { postsRegions, selectVariant, variantPaths } from "./build-video.mjs";
-import { deckOn, frameCount } from "./deck.mjs";
+import { deckGeometry, deckOn, frameCount, postsGeometry, resolveDeck } from "./deck.mjs";
const execFileP = promisify(execFile);
const FFPROBE = process.env.FFPROBE_BIN ?? "ffprobe";
+const FFMPEG = process.env.FFMPEG_BIN ?? "ffmpeg";
export async function verifyBuild(manifestPath, { outDir, variant = "sourced" } = {}) {
// The SAME filter the build ran. Verifying the whole manifest against one
@@ -96,8 +97,9 @@ export async function verifyBuild(manifestPath, { outDir, variant = "sourced" }
/**
* The deck's half of the check: schedule.json is there and is a measured deck
* schedule, `chrome/deck-frames` holds frameCount(total, fps) frames, and the
- * file is as long as the schedule. When the schedule carries posts, each
- * window's `chrome/posts-<segment>-frames` holds that window's frame count.
+ * file is as long as the schedule -- the SCHEDULE's total, holds included.
+ * When the schedule carries posts, each window's `chrome/posts-<segment>-frames`
+ * holds that window's frame count, and each held clip's freeze is in the file.
*/
export async function verifyDeck(variantDir, render, file, problems) {
const schedPath = path.join(variantDir, "schedule.json");
@@ -139,12 +141,61 @@ export async function verifyDeck(variantDir, render, file, problems) {
problems.push(`${r.frames} holds ${got} frames; the posts window on ${r.segment} is ${r.frameCount}`);
}
}
+ const holds = await verifyHolds(file, schedule, render, problems);
return {
total: schedule.total, frames, expectedFrames: want, videoFrames, segments: schedule.segments.length,
...(posts.length ? { posts } : {}),
+ ...(holds.length ? { holds } : {}),
};
}
+/** The mean absolute difference allowed between two frames of one freeze (8-bit luma; re-encoding noise). */
+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.
+ */
+export async function verifyHolds(file, schedule, render, problems) {
+ const held = (schedule.segments ?? []).filter((s) => s.hold > 0);
+ if (!held.length) return [];
+ const fps = Number(schedule.fps ?? render.fps);
+ const g = deckGeometry(render);
+ const col = postsGeometry(render);
+ const left = resolveDeck(render).posts.position === "top-left";
+ const crop = left
+ ? { x: col.x + col.width, y: 0, w: g.W - col.x - col.width, h: g.deck.y }
+ : { x: 0, y: 0, w: col.x, h: g.deck.y };
+ const luma = async (t) => {
+ const { stdout } = await execFileP(FFMPEG, [
+ "-nostdin", "-v", "error", "-ss", t.toFixed(3), "-i", file, "-frames:v", "1",
+ "-vf", `crop=${crop.w}:${crop.h}:${crop.x}:${crop.y},format=gray`, "-f", "rawvideo", "-",
+ ], { encoding: "buffer", maxBuffer: 1 << 26 });
+ return stdout;
+ };
+ const out = [];
+ for (const s of held) {
+ const a = s.end - s.hold + 2 / fps;
+ const b = s.end - s.hold / 2;
+ const [x, y] = await Promise.all([luma(a), luma(b)]);
+ let diff = 0;
+ if (x.length !== y.length || !x.length) diff = Infinity;
+ else {
+ for (let i = 0; i < x.length; i += 1) diff += Math.abs(x[i] - y[i]);
+ diff /= x.length;
+ }
+ out.push({ segment: s.id, hold: s.hold, at: [Number(a.toFixed(3)), Number(b.toFixed(3))], diff: Number(diff.toFixed(3)) });
+ if (!(diff <= FREEZE_TOLERANCE)) {
+ problems.push(`${s.id} is held ${s.hold}s but its picture moves during the hold ` +
+ `(frames at ${a.toFixed(3)}s and ${b.toFixed(3)}s differ by ${diff.toFixed(2)} on average)`);
+ }
+ }
+ return out;
+}
+
async function main() {
const argv = process.argv.slice(2);
const manifestPath = argv.find((a) => !a.startsWith("--"));
@@ -170,6 +221,9 @@ async function main() {
for (const w of res.deck.posts ?? []) {
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})`);
+ }
}
for (const p of res.problems) console.log(` ** ${p}`);
if (res.ok) console.log(" ok");