commit 5b8b43cf0182427e9e42365742a8cac6109b38a5
parent 2e37008f7bdf8e1d6f28cdce52dcf2f275f5ea56
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 16:58:22 -0400
report-to-video: posts.layout "feed" -- the posts as a column beside the footage for the whole cut, ticking in at each clip's start, never held
- deck.mjs: posts.layout ("popup" default | "feed"); feedGeometry (a posts.width
column flush with the side and top down to the deck, the footage box beside
it: 600x890 at (1320,0) and 1272x716 at (24,87) at 1920x1080); feedOn;
postSchedule gives a feed post its tick-in `in` (clip start + D, a clip's
next ones posts.seconds apart, closer on a short clip); no hold, no move,
no windows under the feed; the schedule says layout "feed" only when there
are posts to draw, so popup and no-posts schedules are byte-identical.
- chrome-feed.mjs: one HyperFrames page for the whole cut -- header, empty
state, newest on top with the highlight, older cards pushed down, the
oldest faded out on overflow, the column off the frame's edge with the deck
over hidden segments; the plan (feedCues) embedded by embedFn.
- compose-chrome region "feed", cached like the deck's (feed, feed-preview,
feed-from<s>).
- build-video: every footage segment of a feed cut is framed into the feed's
box; each framed segment's cut.json records its framing, and --chrome-only
refuses segments framed for another layout; the feed's frames are laid
like the deck's.
- verify-build checks the feed's frames and that nothing is held.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
6 files changed, 1185 insertions(+), 42 deletions(-)
diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs
@@ -81,8 +81,8 @@ import { createCueSource, siteOriginFromManifest } from "./cues.mjs";
// and live in deck.mjs. This file only frames segments into its box and writes
// the schedule down -- it never has a copy of the arithmetic.
import {
- assertChrome, deckGeometry, deckOn, deckSchedule, endFadeOf, frameCount, MUTE_FADE, muteSegmentSeconds,
- playWindow, postsGeometry, postWindows, resolveDeck, scheduleFrom, snapWindow, teaserHits, teaserTitle,
+ assertChrome, deckGeometry, deckOn, deckSchedule, endFadeOf, feedGeometry, feedOn, frameCount, hidesDeck, MUTE_FADE,
+ muteSegmentSeconds, playWindow, postsGeometry, postWindows, resolveDeck, scheduleFrom, snapWindow, teaserHits, teaserTitle,
validateCutEdits, validatePosts, validateTeasers,
} from "./deck.mjs";
// The per-platform yt-dlp args (Rumble's `--impersonate chrome`): the ONE table,
@@ -231,8 +231,11 @@ export function headerFilters(render, attribPath, channelPath = null) {
*
* Pure, and exported, so the numbers can be tested without an encoder.
*/
-export function deckFraming(render) {
- const { W, H, footage: f } = deckGeometry(render);
+export function deckFraming(render, { feed = false } = {}) {
+ const { W, H, footage: deckBox } = deckGeometry(render);
+ // The posts feed's footage box stands beside its column (feedGeometry);
+ // every footage segment of a feed cut is framed there, for the whole cut.
+ const f = feed ? feedGeometry(render).footage : deckBox;
const bg = render.palette.bg;
return {
box: f,
@@ -245,12 +248,58 @@ export function deckFraming(render) {
}
/** `deckFraming`'s two runs joined: a whole picture into the box, then the frame. */
-export function deckFramingFilter(render) {
- const { fit, place } = deckFraming(render);
+export function deckFramingFilter(render, opts = {}) {
+ const { fit, place } = deckFraming(render, opts);
return [...fit, ...place].join(",");
}
/**
+ * The framing a cut's footage segments are built with under the deck -- the
+ * layout and its box -- recorded in each one's `<id>.cut.json` (`framing`) so
+ * that `--chrome-only`, which rebuilds no segment, can refuse segments framed
+ * for another layout. `feed` is feedOn's answer for the cut's WHOLE timeline.
+ */
+export function segmentFraming(render, feed = false) {
+ return { layout: feed ? "feed" : "deck", box: deckFraming(render, { feed }).box };
+}
+
+/** Is this entry's segment framed into the footage box under the deck (rather than full frame)? */
+export function framedUnderDeck(entry, render) {
+ if (entry.type === "clip" || entry.type === "image") return true;
+ return entry.type === "card" && !hidesDeck(entry, resolveDeck(render));
+}
+
+/**
+ * Why segments on disk cannot carry this cut's chrome, as sentences: each
+ * framed segment's record names the box it was framed into, and it is not the
+ * one this cut frames footage into (`want`, segmentFraming's). A segment with
+ * no framing record was built before records named it -- framed for the
+ * deck's box -- so it passes for the deck and fails for the feed.
+ *
+ * @param {{ entries: object[], records: Array<object|null>, render: object, want: { layout: string, box: object } }} args
+ * @returns {string[]}
+ */
+export function framingProblems({ entries, records, render, want }) {
+ const deckBox = deckGeometry(render).footage;
+ const same = (a, b) => a && b && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
+ const where = (b) => `${b.width}×${b.height} at (${b.x}, ${b.y})`;
+ const wrong = [];
+ entries.forEach((e, i) => {
+ if (!framedUnderDeck(e, render)) return;
+ const got = records[i]?.framing ?? null;
+ const box = got?.box ?? deckBox;
+ if (same(box, want.box)) return;
+ wrong.push(`${e.id} (${got ? `framed for the ${got.layout}, ${where(box)}` : `no framing record: built for the deck's ${where(box)}`})`);
+ });
+ if (!wrong.length) return [];
+ return [
+ `${wrong.length} segment(s) are framed for another layout than this cut's ` +
+ `${want.layout === "feed" ? "posts feed" : "deck"} (footage ${where(want.box)}): ${wrong.join(", ")}. ` +
+ "Reframing rebuilds them -- run a normal build (with --skip-fetch it re-cuts from the cached windows), not --chrome-only.",
+ ];
+}
+
+/**
* Where a variant's own working files live.
*
* `clips-raw` stays at the ROOT and is shared: it holds the only expensive
@@ -696,7 +745,7 @@ const encodeArgsVideoOnly = (render) => [
"-movflags", "+faststart",
];
-async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes, provenance) {
+async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes, provenance, framing = null) {
const outDir = dirs.dir;
const { path: raw, fetchStart } = await fetchClip(entry, meta, render, dirs.rawDir, opts);
const seg = path.join(outDir, "segments", `${entry.id}.mp4`);
@@ -784,19 +833,21 @@ async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes,
// The composition is overlaid on the whole concat later; nothing here knows
// about it. Same cut, same audio map, same encode as every other segment.
if (deckOn(render)) {
+ const fr = framing ?? segmentFraming(render);
await execFileP(
FFMPEG,
[
"-nostdin", "-v", "error", "-y",
...cutArgs(raw, cutA, cutB),
- "-filter_complex", `[0:v]${deckFramingFilter(render)}[v]`,
+ "-filter_complex", `[0:v]${deckFramingFilter(render, { feed: fr.layout === "feed" })}[v]`,
"-map", "[v]", "-map", "0:a",
...encodeArgs(render),
seg,
],
{ maxBuffer: 1 << 24 },
);
- await writeCutRecord(seg, cutRecord);
+ // The box it was framed into, so --chrome-only can refuse another layout's.
+ await writeCutRecord(seg, { ...cutRecord, framing: fr });
return seg;
}
@@ -974,7 +1025,7 @@ async function qrForEntry(entry, provenance, render, outDir) {
return { png, url };
}
-async function buildCardSegment(card, render, outDir, nodes) {
+async function buildCardSegment(card, render, outDir, nodes, framing = null) {
const png = await renderCard(card, render, outDir, nodes);
const seg = path.join(outDir, "segments", `${card.id}.mp4`);
const dur = String(card.seconds);
@@ -982,8 +1033,10 @@ async function buildCardSegment(card, render, outDir, nodes) {
// deck slides away over it, and the card encodes exactly as it always has --
// or framed into the footage box like a clip, so the deck can stay up over
// it without covering its bottom rows.
- const vf = deckOn(render) && resolveDeck(render).overCards === "show"
- ? deckFramingFilter(render)
+ const framed = deckOn(render) && resolveDeck(render).overCards === "show";
+ const fr = framed ? framing ?? segmentFraming(render) : null;
+ const vf = framed
+ ? deckFramingFilter(render, { feed: fr.layout === "feed" })
: `fps=${render.fps},setsar=1`;
await execFileP(
@@ -1003,6 +1056,7 @@ async function buildCardSegment(card, render, outDir, nodes) {
],
{ maxBuffer: 1 << 24 },
);
+ if (fr) await writeCutRecord(seg, { version: 1, id: card.id, framing: fr });
return seg;
}
@@ -1180,7 +1234,7 @@ async function buildTeaserSegment(entry, { manifestPath, render, outDir, variant
// * It reserves the footer's rows but draws nothing in them. The marker's
// position is a function of `section`, which a still does not have, and a
// timeline strip whose marker vanishes for six seconds reads as a bug.
-async function buildImageSegment(entry, render, outDir, chrome, provenance, baseDir) {
+async function buildImageSegment(entry, render, outDir, chrome, provenance, baseDir, framing = null) {
const seg = path.join(outDir, "segments", `${entry.id}.mp4`);
const pal = render.palette;
const { width, height } = render;
@@ -1217,7 +1271,8 @@ async function buildImageSegment(entry, render, outDir, chrome, provenance, base
// Under the deck the picture area is the deck's footage box -- the box a clip
// is framed into, so a still between two clips does not move either -- and
// there is no header, footer or corner code: the deck carries all three.
- const deck = deckOn(render) ? deckFraming(render) : null;
+ const fr = deckOn(render) ? framing ?? segmentFraming(render) : null;
+ const deck = fr ? deckFraming(render, { feed: fr.layout === "feed" }) : null;
const HH = render.headerHeight ?? 56;
const VW = deck ? deck.box.width : contentWidth(render);
const FH = chrome.footerHeight;
@@ -1389,6 +1444,8 @@ async function buildImageSegment(entry, render, outDir, chrome, provenance, base
const what = resolved.map((r) => r.src).join(", ");
throw new Error(`${entry.id}: ffmpeg failed on ${what}\n${detail}`);
}
+ // Under the deck, the box it was framed into (see segmentFraming).
+ if (fr) await writeCutRecord(seg, { version: 1, id: entry.id, framing: fr });
return seg;
}
@@ -1913,7 +1970,9 @@ export function chromeOverlayChain(render, regions, inLabel, firstInputIdx, opts
// (snapWindow), and nothing past the main's end is drawn: the deck's
// `shortest=1` overlay ahead of it already ends the stream there. Same
// frame-kind trap as the deck, same two guards.
- const deck = r.name === "deck";
+ // The posts feed (`name: "feed"`) is a whole-cut sequence like the deck's,
+ // laid exactly as the deck's is.
+ const deck = r.name === "deck" || r.name === "feed";
const posts = r.name === "posts";
inputs.push(
...(deck || posts ? ["-reinit_filter", "0"] : []),
@@ -1973,6 +2032,11 @@ export function postsRegions(render, outDir, schedule, { shift = 0, clip = null
}));
}
+/** The posts feed as an overlay region: its frames at feedGeometry's column. */
+export function feedRegion(render, frames) {
+ return { name: "feed", frames, ...feedGeometry(render).column };
+}
+
/**
* Where each rendered chrome region sits in the frame.
*
@@ -2828,6 +2892,29 @@ async function renderDeck({ manifestPath, render, outDir, variant, schedule, fro
});
const regions = chromeRegions(render, outDir).map((g) => ({ ...g, frames: r.frames }));
+ // The posts FEED (a schedule with `layout: "feed"`): one sequence for the
+ // whole cut -- or the same window as the deck's -- at feedGeometry's column,
+ // laid like the deck's. postWindows has none for a feed, so the loop below
+ // adds nothing.
+ if (schedule.layout === "feed") {
+ EMIT("chrome", { phase: "compose", region: "feed", ...(duration != null ? { from, duration } : {}) });
+ const t1 = Date.now();
+ const f = await composeChrome({
+ manifestPath, outDir, variant, region: "feed", doRender: true,
+ fps: render.fps, workers: 4, quality: "high", format: "png-sequence",
+ ...(duration != null ? { from, duration } : {}),
+ });
+ if (f.frameCount !== want) {
+ throw new Error(`the feed's sequence is ${f.frameCount} frames but the deck's is ${want}`);
+ }
+ EMIT("chrome", {
+ phase: f.cached ? "cached" : "render", region: "feed",
+ frames: f.frameCount, key: f.key, dir: f.frames,
+ seconds: Number(((Date.now() - t1) / 1000).toFixed(1)),
+ });
+ regions.push(feedRegion(render, f.frames));
+ }
+
// Then the posts: one short sequence per clip that carries them, each with
// its own cache, laid over the deck at its own second. A preview window
// (`duration` given) takes only the windows it intersects, shifted into its
@@ -3218,6 +3305,12 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
const entries = manifest.timeline.filter((e) => !only || e.id === only);
if (only && !entries.length) throw new Error(`no timeline entry with id ${only}`);
+ // Under the deck, the box every footage segment is framed into: the posts
+ // feed's when this cut is a feed -- decided on the cut's WHOLE timeline, so
+ // an `--only` rebuild frames a clip as the full build would.
+ const framing = deck
+ ? segmentFraming(render, feedOn({ render, posts: manifest.posts ?? [], entries: manifest.timeline ?? [] }))
+ : null;
// Read once, at the ROOT: a source's state is a fact about the manifest, not
// about a variant. A stacked ledger card says why each claim is text rather
@@ -3308,6 +3401,13 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
if (!(await exists(seg)))
throw new Error(`${what} needs ${seg}, which is missing — run a full build first`);
}
+ // Segments framed for another layout (the posts feed switched on or off
+ // since they were built) cannot take this cut's chrome: refused, by name.
+ {
+ const records = await Promise.all(segs.map((sg) => readCutRecord(sg)));
+ const problems = framingProblems({ entries, records, render, want: framing });
+ if (problems.length) throw new Error(`${what}: ${problems.join("; ")}`);
+ }
const schedule = await writeChromeSchedule({ manifest, entries, segments: segs, D, outDir });
EMIT("chrome", { phase: "schedule", total: schedule.total, segments: schedule.segments.length });
// The holds, the moves, the mutes and the end fade, joined on their
@@ -3366,7 +3466,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
try {
if (entry.type === "card") {
EMIT("card", { id: entry.id, i, n: entries.length });
- segments.push(await buildCardSegment(entry, render, outDir, manifest.timelineNodes));
+ segments.push(await buildCardSegment(entry, render, outDir, manifest.timelineNodes, framing));
} else if (entry.type === "teaser") {
EMIT("card", { id: entry.id, i, n: entries.length });
segments.push(await buildTeaserSegment(entry, { manifestPath, render, outDir, variant }));
@@ -3376,7 +3476,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
// third word there would show as an unknown step rather than as work.
EMIT("card", { id: entry.id, i, n: entries.length });
segments.push(
- await buildImageSegment(entry, render, outDir, chrome, provenance, manifestDir),
+ await buildImageSegment(entry, render, outDir, chrome, provenance, manifestDir, framing),
);
} else if (entry.type === "scroll" || entry.type === "chart" || entry.type === "ledger") {
EMIT("card", { id: entry.id, i, n: entries.length });
@@ -3397,7 +3497,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
});
segments.push(
await buildClipSegment(
- entry, meta, render, dirs, opts, chrome, manifest.timelineNodes, provenance,
+ entry, meta, render, dirs, opts, chrome, manifest.timelineNodes, provenance, framing,
),
);
}
diff --git a/umtool/report-to-video/chrome-feed.mjs b/umtool/report-to-video/chrome-feed.mjs
@@ -0,0 +1,500 @@
+// The posts FEED's composition (`posts.layout: "feed"`): one HyperFrames page
+// for the whole cut -- a column of posts beside the footage, standing on the
+// deck, so the two make an L around the picture.
+//
+// PURE, like chrome-deck.mjs and chrome-posts.mjs: a schedule and a render
+// block in, an HTML string out. compose-chrome.mjs copies the assets in beside
+// it, writes it and renders it; nothing here touches a file.
+//
+// ---------------------------------------------------------------------------
+// What the column does
+// ---------------------------------------------------------------------------
+// Before the first post it shows its header (who posted, on what) and an
+// empty state. Each post TICKS IN at its `in` (deck.mjs postSchedule: its
+// clip's start, after the incoming dissolve; several on one clip a
+// `posts.seconds` apart): it lands at the TOP, newest first, as a timeline
+// reads, and every card already in slides DOWN by its height. The newest
+// wears the highlight -- an accent flare that settles to a lit rail and rim --
+// until the next one takes it, then rests. A card that no longer fits the
+// column fades out as the stack pushes it past the bottom: the oldest scroll
+// out. Over a segment the deck hides for (a full-frame card, the teaser) the
+// whole column slides off the frame's side edge with it, and back after.
+//
+// The cut is never paused for it, and nothing moves the footage: the feed's
+// footage box is fixed for the whole cut (deck.mjs feedGeometry).
+//
+// ---------------------------------------------------------------------------
+// Why the stack is planned in the page, by a function that lives here
+// ---------------------------------------------------------------------------
+// How far a card pushes the others, and which ones fall out of the column,
+// depend on how tall each card is -- how its words wrap in the deck's own
+// face. So the page measures every card once the faces are in and hands the
+// heights to `feedCues`, THIS module's function written into the page under a
+// fixed name (`embedFn`, docs/quirks.md); the tests call the same function
+// with heights of their choosing. Every cue is a fromTo whose FROM is stated:
+// a render is a seek per frame, from parallel workers, in any order.
+import { deckChoreography, feedGeometry, resolveDeck } from "./deck.mjs";
+import { mix, rgba } from "./chrome-deck.mjs";
+import { embedFn, PLATFORM_LABEL, postDate, postParagraphs, postWho } from "./chrome-posts.mjs";
+
+const esc = (s) =>
+ String(s ?? "")
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+
+const r4 = (v) => Math.round(v * 10000) / 10000;
+
+/**
+ * The feed's motion, in seconds (and the gap between cards, in px). A new
+ * card starts entering `lag` after its `in`, from the column's outer edge,
+ * over `enter`; the cards below are pushed down over `push` from the `in`
+ * itself, so the room opens as the card arrives. Its highlight flares `glowAt`
+ * into the entrance over `glowUp`, settles to `newest` over `glowDown`, and
+ * goes out over `calm` when the next post arrives. A card pushed past the
+ * column's bottom fades over `leave`. The empty state fades over `emptyOut`.
+ */
+export const FEED_MOTION = Object.freeze({
+ gap: 16, push: 0.55, lag: 0.12, enter: 0.6, glowAt: 0.2, glowUp: 0.2, glowDown: 1.2, newest: 0.55, calm: 0.8,
+ leave: 0.45, emptyOut: 0.35,
+});
+
+/**
+ * The column's inner layout, region-local px: the padding, the header's rows
+ * and the stack area under it (`stack`: where cards are, and its height --
+ * what `feedCues` fits them to). The card's own sizes: its text, meta and QR
+ * cell.
+ */
+export function feedLayout(render) {
+ const g = feedGeometry(render);
+ const p = resolveDeck(render).posts;
+ const W = g.column.width, H = g.column.height;
+ const padX = 22;
+ const headerTop = 26;
+ const headerH = 66;
+ const ruleY = headerTop + headerH + 10;
+ const stackY = ruleY + 18;
+ const bottom = 22;
+ const textSize = 24;
+ return {
+ width: W,
+ height: H,
+ padX,
+ header: { top: headerTop, height: headerH, ruleY },
+ stack: { x: padX, y: stackY, width: W - 2 * padX, height: H - stackY - bottom },
+ card: {
+ rail: 6, pad: 16, qrSize: p.qrSize, plate: p.qrSize + 28, metaSize: 18,
+ textSize, lineH: Math.round(textSize * 1.36), maxLines: p.maxLines,
+ },
+ };
+}
+
+/**
+ * Everything the feed's timeline does, as data. PURE and SELF-CONTAINED: the
+ * page carries this function's own source text (`embedFn`) and calls it with
+ * the heights it measured, so it may reference nothing outside its own body.
+ *
+ * `posts` are `[{ id, in }]` oldest first, `in` in the CUT's clock; `heights`
+ * the cards' heights in px; `column` the stack area's height. `visibility` is
+ * deckChoreography's (`[{ hide, at: [a, b] }]`): the column slides `slideX` px
+ * sideways out of the frame with the deck and back. `enterX` is where a card
+ * enters from (the column's outer edge).
+ *
+ * Card j of n sits at y = Σ (height + gap) of the cards newer than it that
+ * are in; it is `c<j>` (autoAlpha, x, y), its highlight `h<j>` (opacity), the
+ * count `n<k>` (k posts in; autoAlpha), the empty state `empty`, the whole
+ * column `col` (x).
+ *
+ * @returns {{ init: Record<string, object>, gone: Array<number|null>,
+ * cues: Array<{ k: string, at: number, dur: number, from: object, to: object, ease: string, why: string }> }}
+ */
+export function feedCues({
+ posts, heights, column, visibility = [], startsHidden = false, slideX = 600, enterX = 600,
+ gap = 16, push = 0.55, lag = 0.12, enter = 0.6, glowAt = 0.2, glowUp = 0.2, glowDown = 1.2, newest = 0.55,
+ calm = 0.8, leave = 0.45, emptyOut = 0.35,
+}) {
+ const R = (v) => Math.round(v * 10000) / 10000;
+ const MIN = 0.001;
+ const n = posts.length;
+ const init = { col: { x: startsHidden ? slideX : 0 }, empty: { autoAlpha: 1 } };
+ for (let j = 0; j < n; j += 1) {
+ init[`c${j}`] = { autoAlpha: 0, x: enterX, y: 0 };
+ init[`h${j}`] = { opacity: 0 };
+ }
+ for (let k = 0; k <= n; k += 1) init[`n${k}`] = { autoAlpha: k === 0 ? 1 : 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 });
+ const y = new Array(n).fill(0);
+ const visible = [];
+ const gone = new Array(n).fill(null);
+ for (let m = 0; m < n; m += 1) {
+ const t = posts[m].in;
+ const id = posts[m].id;
+ const room = (heights[m] || 0) + gap;
+ // Room at the top: every card in moves down by the new one's height, and
+ // the oldest that no longer fit fade as they are pushed out of the column
+ // (one cue each, so the fade rides the push).
+ for (let v = visible.length - 1; v >= 0; v -= 1) {
+ const j = visible[v];
+ y[j] += room;
+ if (y[j] + (heights[j] || 0) <= column) {
+ add(`c${j}`, t, push, { y: y[j] }, "power2.inOut", `push for ${id}`);
+ continue;
+ }
+ add(`c${j}`, t, Math.max(push, leave), { y: y[j], autoAlpha: 0 }, "power2.inOut", `out for ${id}`);
+ gone[j] = t;
+ visible.splice(v, 1);
+ }
+ // The newest hands its highlight on.
+ if (m > 0 && gone[m - 1] === null) add(`h${m - 1}`, t, calm, { opacity: 0 }, "power1.out", `calm for ${id}`);
+ if (m === 0) add("empty", t, emptyOut, { autoAlpha: 0 }, "power1.out", `first ${id}`);
+ add(`n${m}`, t + lag, MIN, { autoAlpha: 0 }, "none", `count ${id}`);
+ add(`n${m + 1}`, t + lag, MIN, { autoAlpha: 1 }, "none", `count ${id}`);
+ add(`c${m}`, t + lag, enter, { autoAlpha: 1, x: 0 }, "expo.out", `enter ${id}`);
+ add(`h${m}`, t + lag + glowAt, glowUp, { opacity: 1 }, "power2.out", `glow ${id}`);
+ add(`h${m}`, t + lag + glowAt + glowUp, glowDown, { opacity: newest }, "power2.inOut", `settle ${id}`);
+ visible.unshift(m);
+ }
+ for (const v of visibility) {
+ if (v.at[1] - v.at[0] <= 0 && v.i === 0) continue; // hidden from the first frame: that is init
+ add("col", v.at[0], v.at[1] - v.at[0], { x: v.hide ? slideX : 0 }, v.hide ? "power2.in" : "power3.out",
+ `${v.hide ? "hide" : "show"} @${v.i}`);
+ }
+
+ // Order, clamp (a cue never starts before the last one on its element has
+ // ended), and state every from.
+ ev.forEach((e, i) => { e.n = i; });
+ ev.sort((a, b) => a.at - b.at || a.n - b.n);
+ const state = {};
+ for (const k of Object.keys(init)) state[k] = { ...init[k] };
+ const freeAt = {};
+ const cues = [];
+ for (const e of ev) {
+ let { at, dur } = e;
+ const free = freeAt[e.k] ?? -Infinity;
+ if (at < free) {
+ const end = at + dur;
+ at = R(free);
+ dur = R(Math.max(MIN, end - at));
+ }
+ const cur = state[e.k] ?? (state[e.k] = {});
+ const from = {};
+ for (const q of Object.keys(e.to)) from[q] = cur[q];
+ Object.assign(cur, e.to);
+ freeAt[e.k] = R(at + dur);
+ cues.push({ k: e.k, at, dur, from, to: e.to, ease: e.ease, why: e.why, i: cues.length });
+ }
+ cues.sort((a, b) => a.at - b.at || a.i - b.i);
+ for (const c of cues) delete c.i;
+ return { init, gone, cues };
+}
+
+/**
+ * Who the feed is, for its header: the one author's name and platform when
+ * every post is theirs, else "Posts" and every platform there is.
+ */
+export function feedWho(posts) {
+ const names = [...new Set(posts.map((p) => postWho(p).name).filter(Boolean))];
+ const platforms = [...new Set(posts.map((p) => PLATFORM_LABEL[p.platform] ?? String(p.platform ?? "")).filter(Boolean))];
+ const single = names.length === 1 && platforms.length === 1;
+ return { title: single ? names[0] : "Posts", platforms, single };
+}
+
+/**
+ * The feed composition's HTML, for the whole cut -- or a window of it
+ * (`from`/`duration`, a `--chrome-preview`): the root then declares
+ * `duration` seconds and the timeline plays the cut's [from, from + duration].
+ *
+ * `fonts` = `{ regular, bold }` asset-relative paths (DeckSans / DeckSansBold),
+ * `qrSrcs` = `{ [postId]: "assets/qrNN.png" }`, `gsap` = the vendored script.
+ * The region is `feedGeometry(render).column`, region-local, transparent
+ * outside the panel. `?still=<t>` and the preview's `deck:seek` take CUT seconds.
+ */
+export function feedHtml(schedule, render, opts = {}) {
+ if (schedule.layout !== "feed") throw new Error("feed: the schedule is not a feed's (layout \"feed\")");
+ const deck = resolveDeck(render);
+ const lay = feedLayout(render);
+ const g = feedGeometry(render);
+ const pal = render.palette;
+ const W = lay.width, H = lay.height;
+ const fonts = opts.fonts ?? {};
+ const qrSrcs = opts.qrSrcs ?? {};
+ const gsapSrc = opts.gsap ?? "assets/gsap.min.js";
+ const total = schedule.total;
+ const from = Number(opts.from ?? 0);
+ const dur = opts.duration != null ? r4(Number(opts.duration)) : r4(total - from);
+ if (!(dur > 0)) throw new Error(`feed: nothing to render from ${from}s of a ${total}s cut`);
+ const windowed = from > 0 || Math.abs(dur - total) > 1e-6;
+ const posts = [...(schedule.posts ?? [])].sort((a, b) => a.in - b.in || a.slot - b.slot);
+ if (!posts.length) throw new Error("feed: the schedule places no posts");
+ const left = deck.posts.position === "top-left";
+ const c = lay.card;
+ const who = feedWho(posts);
+ const panel = deck.background === "panel";
+
+ // The column's ground meets the deck's top edge in the deck's own colour,
+ // so the two read as one surface; the cards stand a step up from it.
+ const deckTop = panel ? mix(pal.bg, pal.fg, 0.095) : pal.bg;
+ const groundTop = panel ? mix(pal.bg, pal.fg, 0.05) : pal.bg;
+ const cardTop = mix(mix(pal.bg, pal.fg, 0.14), pal.accent, 0.06);
+ const cardBottom = mix(mix(pal.bg, pal.fg, 0.11), pal.accent, 0.03);
+
+ const cardHtml = posts
+ .map((p, j) => {
+ const { name, platform } = postWho(p);
+ const src = qrSrcs[p.id];
+ return (
+ `<article class="post" data-post="${esc(p.id)}" data-k="c${j}">` +
+ `<div class="body">` +
+ `<div class="meta">` +
+ (who.single ? "" : (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="${c.qrSize}" height="${c.qrSize}" alt="">` : "") +
+ `</div>` +
+ `<div class="hl" data-k="h${j}"></div>` +
+ `</article>`
+ );
+ })
+ .join("\n ");
+ const countHtml = Array.from({ length: posts.length + 1 }, (_, k) =>
+ `<span class="n" data-k="n${k}">${k} of ${posts.length}</span>`).join("");
+
+ const vis = deckChoreography(schedule, render).visibility;
+ const data = {
+ total: r4(total),
+ window: windowed ? { from: r4(from), dur } : null,
+ column: lay.stack.height,
+ maxLines: c.maxLines,
+ lineH: c.lineH,
+ textSize: c.textSize,
+ metaSize: c.metaSize,
+ startsHidden: !!schedule.segments?.[0]?.hideDeck,
+ // Off the frame's own side edge, a little past it.
+ slideX: left ? -(W + 24) : W + 24,
+ enterX: left ? -(lay.stack.width + lay.padX) : lay.stack.width + lay.padX,
+ visibility: vis.map((v) => ({ i: v.i, hide: v.hide, at: v.at.map(r4) })),
+ motion: FEED_MOTION,
+ ids: posts.map((p) => p.id),
+ posts: posts.map((p) => ({ id: p.id, in: p.in })),
+ };
+ // `</script>` inside a JSON string would close the tag; an id is the manifest's.
+ const json = JSON.stringify(data).replace(/</g, "\\u003c");
+
+ return `<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=${W}, height=${H}" />
+ <script src="${esc(gsapSrc)}"></script>
+ <style>
+ /* The deck's faces, under the deck's private names -- see docs/quirks.md. */
+ @font-face { font-family: 'DeckSans'; font-weight: 400; font-style: normal;
+ src: url('${esc(fonts.regular ?? "")}'); }
+ @font-face { font-family: 'DeckSansBold'; font-weight: 400; font-style: normal;
+ src: url('${esc(fonts.bold ?? "")}'); }
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+ html, body { width: ${W}px; height: ${H}px; overflow: hidden; background: transparent; }
+ body { font-family: 'DeckSans', sans-serif; font-synthesis: none; color: ${pal.fg};
+ -webkit-font-smoothing: antialiased; text-rendering: geometricPrecision; }
+ #root { position: relative; width: ${W}px; height: ${H}px; overflow: hidden; }
+ #feed-clip { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; }
+ .col { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; }
+ /* The column's ground: the deck's surface carried up the side. */
+ .ground { position: absolute; inset: 0;
+ background: linear-gradient(180deg, ${groundTop} 0%, ${deckTop} 100%); }
+ ${panel ? `.edge { position: absolute; ${left ? "right" : "left"}: 0; top: 0; width: 1px; height: ${H}px;
+ background: linear-gradient(0deg, ${rgba(pal.accent, 0.8)} 0px, ${rgba(pal.accent, 0.3)} ${Math.round(H * 0.16)}px,
+ ${rgba(pal.fg, 0.14)} ${Math.round(H * 0.4)}px, ${rgba(pal.fg, 0.14)} ${H}px); }` : ""}
+ .head { position: absolute; left: ${lay.padX}px; top: ${lay.header.top}px; width: ${W - 2 * lay.padX}px;
+ height: ${lay.header.height}px; }
+ .who-row { display: flex; align-items: center; gap: 12px; height: 36px; white-space: nowrap; }
+ .platform { flex: none; font-family: 'DeckSansBold', sans-serif; font-size: 16px; line-height: 23px;
+ letter-spacing: 0.03em; color: ${pal.fg}; padding: 1px 11px 2px; border-radius: 999px;
+ background: ${rgba(pal.accent, 0.26)}; box-shadow: inset 0 0 0 1px ${rgba(pal.accent, 0.7)}; }
+ .title { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis;
+ font-family: 'DeckSansBold', sans-serif; font-size: 26px; line-height: 36px; letter-spacing: -0.005em; }
+ .count { flex: none; position: relative; width: 84px; height: 36px; font-size: 16px; line-height: 36px;
+ color: ${pal.muted}; font-variant-numeric: tabular-nums; }
+ .count .n { position: absolute; right: 0; top: 0; visibility: hidden; opacity: 0; }
+ .caption { margin-top: 8px; font-size: 14px; line-height: 20px; letter-spacing: 0.12em; text-transform: uppercase;
+ color: ${rgba(pal.muted, 0.9)}; white-space: nowrap; }
+ .rule { position: absolute; left: ${lay.padX}px; top: ${lay.header.ruleY}px; width: ${W - 2 * lay.padX}px;
+ height: 1px; background: ${rgba(pal.fg, 0.12)}; }
+ .stack { position: absolute; left: ${lay.stack.x}px; top: ${lay.stack.y}px; width: ${lay.stack.width}px;
+ height: ${lay.stack.height}px; overflow: hidden; }
+ .empty { position: absolute; left: 0; top: 0; width: ${lay.stack.width}px; height: ${c.plate}px;
+ border-radius: 10px; border: 1px dashed ${rgba(pal.fg, 0.22)};
+ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px;
+ visibility: hidden; opacity: 0; }
+ .empty b { font-family: 'DeckSansBold', sans-serif; font-weight: 400; font-size: 20px; color: ${rgba(pal.fg, 0.7)}; }
+ .empty span { font-size: 16px; color: ${pal.muted}; }
+ /* A card: lifted off the column with a touch of the accent, its rail
+ dim until it is the newest. Hidden until the timeline places it. */
+ .post { position: absolute; left: 0; top: 0; width: ${lay.stack.width}px; min-height: ${c.plate}px;
+ visibility: hidden; opacity: 0; border-radius: 10px; overflow: hidden;
+ border-${left ? "right" : "left"}: ${c.rail}px solid ${rgba(pal.accent, 0.38)};
+ background: linear-gradient(180deg, ${cardTop} 0%, ${cardBottom} 100%);
+ box-shadow: inset 0 0 0 1px ${rgba(pal.fg, 0.1)}; }
+ /* The newest's highlight: a lit rail and an accent rim that flare as it
+ lands and settle; out when the next one arrives. Clear of the code. */
+ .hl { 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 16px ${rgba(pal.accent, 0.5)}; }
+ .hl::before { content: ""; position: absolute; ${left ? "right" : "left"}: 0; top: 0; bottom: 0; width: 4px;
+ background: ${pal.accent}; box-shadow: 0 0 14px 2px ${rgba(pal.accent, 0.6)}; }
+ .body { position: relative; width: ${lay.stack.width - c.plate - c.rail}px;${left ? ` margin-left: ${c.plate}px;` : ""}
+ padding: ${c.pad - 3}px ${c.pad + 2}px ${c.pad - 2}px ${c.pad + 2}px; }
+ .meta { display: flex; align-items: center; gap: 10px;
+ font-size: ${c.metaSize}px; line-height: ${Math.round(c.metaSize * 1.3)}px; white-space: nowrap; }
+ .meta .platform { font-size: ${c.metaSize - 3}px; line-height: ${c.metaSize + 3}px; padding: 1px 9px 2px; }
+ .who { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
+ .handle { font-family: 'DeckSansBold', sans-serif; color: ${pal.fg}; }
+ .date { color: ${mix(pal.muted, pal.fg, 0.35)}; flex: none; font-variant-numeric: tabular-nums; letter-spacing: 0.01em; }
+ .text { margin-top: 6px; font-size: ${c.textSize}px; line-height: ${c.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: ${c.maxLines}; }
+ .para + .para { margin-top: ${Math.round(c.lineH * 0.34)}px; }
+ .para.gone { display: none; }
+ /* The source cell: the QR in a cell a shade down, as on the deck. */
+ .plate { position: absolute; ${left ? "left" : "right"}: 0; top: 0; bottom: 0; width: ${c.plate}px;
+ display: flex; align-items: center; justify-content: center;
+ background: linear-gradient(180deg, ${mix(cardTop, pal.bg, 0.55)} 0%, ${mix(cardBottom, pal.bg, 0.6)} 100%);
+ border-${left ? "right" : "left"}: 1px solid ${rgba(pal.fg, 0.07)}; }
+ .plate img { display: block; width: ${c.qrSize}px; height: ${c.qrSize}px; border-radius: 6px;
+ image-rendering: pixelated;
+ box-shadow: 0 0 0 1px ${rgba(pal.fg, 0.25)}, 0 6px 18px rgba(0, 0, 0, 0.35); }
+ </style>
+ </head>
+ <body>
+ <div id="root" data-composition-id="feed" data-start="0" data-duration="${dur}"
+ data-width="${W}" data-height="${H}">
+ <div id="feed-clip" class="clip" data-start="0" data-duration="${dur}" data-track-index="1">
+ <div class="col" data-k="col">
+ <div class="ground"></div>
+ ${panel ? `<div class="edge"></div>` : ""}
+ <div class="head">
+ <div class="who-row">
+ ${who.platforms.map((p) => `<span class="platform">${esc(p)}</span>`).join("")}
+ <span class="title">${esc(who.title)}</span>
+ <span class="count">${countHtml}</span>
+ </div>
+ <div class="caption">Posts as the timeline reaches them</div>
+ </div>
+ <div class="rule"></div>
+ <div class="stack">
+ <div class="empty" data-k="empty"><b>No posts yet</b><span>They appear here as the cut reaches them</span></div>
+ ${cardHtml}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <script id="feed-data" type="application/json">${json}</script>
+ <script>
+ const F = JSON.parse(document.getElementById("feed-data").textContent);
+ const byK = {};
+ for (const el of document.querySelectorAll("[data-k]")) byK[el.dataset.k] = el;
+ ${embedFn("feedCues", feedCues)}
+
+ const params = new URLSearchParams(location.search);
+ // A window plays the cut's [from, from + dur]; the page is seeked in its own seconds.
+ const local = (t) => {
+ const v = Number(t) || 0;
+ return F.window ? Math.max(0, Math.min(F.window.dur, v - F.window.from)) : Math.max(0, v);
+ };
+ let tl = null;
+ let pending = null;
+
+ // maxLines across a card's paragraphs: each is clamped to the lines
+ // still unspent; once they are spent the rest are dropped, and the last
+ // one drawn ends in an ellipsis when anything was dropped.
+ function clampText(card) {
+ let left = F.maxLines;
+ let shown = null;
+ let cut = false;
+ for (const p of card.querySelectorAll(".para")) {
+ if (left <= 0) { p.classList.add("gone"); cut = true; continue; }
+ p.style.webkitLineClamp = String(left);
+ const lines = Math.round(p.getBoundingClientRect().height / F.lineH);
+ if (p.scrollHeight > p.clientHeight + 1) cut = true;
+ left -= lines;
+ shown = { p, lines };
+ }
+ if (cut && shown && !(shown.p.scrollHeight > shown.p.clientHeight + 1)) {
+ shown.p.textContent = shown.p.textContent.replace(/\\s+$/, "") + " …";
+ shown.p.style.webkitLineClamp = String(shown.lines);
+ }
+ }
+
+ // Measure once the faces are in, then plan, place, build and register.
+ // The renderer awaits document.fonts.ready before it seeks a frame.
+ const ready = Promise.all([
+ document.fonts.load(F.textSize + "px DeckSans"),
+ document.fonts.load(F.metaSize + "px DeckSansBold"),
+ ]).catch(() => {}).then(() => {
+ const cards = F.ids.map((_, j) => byK["c" + j]);
+ cards.forEach(clampText);
+ const heights = cards.map((c) => c.offsetHeight);
+ const M = F.motion;
+ const plan = feedCues({
+ posts: F.posts, heights, column: F.column, visibility: F.visibility, startsHidden: F.startsHidden,
+ slideX: F.slideX, enterX: F.enterX, gap: M.gap, push: M.push, lag: M.lag, enter: M.enter,
+ glowAt: M.glowAt, glowUp: M.glowUp, glowDown: M.glowDown, newest: M.newest, calm: M.calm,
+ leave: M.leave, emptyOut: M.emptyOut,
+ });
+ for (const k of Object.keys(plan.init)) if (byK[k]) gsap.set(byK[k], plan.init[k]);
+ const inner = gsap.timeline({ paused: true });
+ for (const c of plan.cues) {
+ const el = byK[c.k];
+ if (!el) continue;
+ inner.fromTo(el, c.from, { ...c.to, duration: c.dur, ease: c.ease, immediateRender: false }, c.at);
+ }
+ // The timeline runs to the cut's end, whatever its last cue.
+ inner.set({}, {}, F.total);
+ tl = inner;
+ if (F.window) {
+ tl = gsap.timeline({ paused: true });
+ tl.add(inner.tweenFromTo(F.window.from, F.window.from + F.window.dur, { duration: F.window.dur, ease: "none" }), 0);
+ }
+ window.__timelines = window.__timelines || {};
+ window.__timelines["feed"] = tl;
+ if (typeof window.__hfForceTimelineRebind === "function") window.__hfForceTimelineRebind();
+ document.documentElement.dataset.heights = heights.join(",");
+ tl.seek(pending ?? 0, false);
+ document.documentElement.dataset.ready = "1";
+ });
+
+ // The review still, in cut seconds.
+ const still = params.get("still");
+ if (still !== null) pending = local(still);
+
+ // umtool's live preview: the parent seeks in cut seconds, as it seeks the deck.
+ if (params.get("preview") === "1") {
+ window.addEventListener("message", (e) => {
+ const m = e.data || {};
+ if (m.type !== "deck:seek") return;
+ pending = local(m.t);
+ if (tl) tl.seek(pending, false);
+ });
+ ready.then(() => {
+ if (window.parent !== window) {
+ window.parent.postMessage({ type: "feed:ready", total: F.total, ids: F.ids }, "*");
+ }
+ });
+ }
+ </script>
+ </body>
+</html>
+`;
+}
+
+/** The feed's region in the frame, for the overlay: feedGeometry's column. */
+export const feedRegion = (render) => feedGeometry(render).column;
diff --git a/umtool/report-to-video/chrome-feed.test.mjs b/umtool/report-to-video/chrome-feed.test.mjs
@@ -0,0 +1,394 @@
+// Tests for the posts FEED (slice F1, `posts.layout: "feed"`): the core's
+// geometry and schedule for it (deck.mjs), the page that draws it
+// (chrome-feed.mjs) and the plan it runs, compose-chrome's feed region, the
+// build's framing record and its refusal, and the overlay of the feed's frames.
+// The popup and the deck without posts are held to what they always were.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import { FEED_MOTION, feedCues, feedHtml, feedLayout, feedWho } from "./chrome-feed.mjs";
+import {
+ deckChoreography, deckGeometry, deckSchedule, estimateSchedule, feedGeometry, feedOn, postHolds, postSchedule,
+ postsGeometry, postWindows, validateChrome, validatePosts,
+} from "./deck.mjs";
+import {
+ chromeOverlayChain, chromeRegions, deckFraming, deckFramingFilter, feedRegion, framedUnderDeck, framingProblems,
+ postsRegions, segmentFraming, segmentJoins,
+} from "./build-video.mjs";
+
+const PALETTE = { bg: "#12101a", fg: "#f4f1ea", muted: "#9a93ad", accent: "#a97bff", amber: "#ffc860" };
+const CHROME = { engine: "hyperframes", layout: "deck" };
+const BASE = { width: 1920, height: 1080, fps: 30, transition: 0.5, palette: PALETTE };
+const RENDER = { ...BASE, chrome: { ...CHROME, deck: {} } };
+const FEED = { ...BASE, chrome: { ...CHROME, deck: { posts: { layout: "feed" } } } };
+const PROV = { siteOrigin: "https://example.test", channelSlug: "chan" };
+
+const CLIPS = [
+ { id: "c1", type: "clip", video: "a", start: 0, end: 10, date: "2024-09-05" },
+ { id: "c2", type: "clip", video: "b", start: 0, end: 20, date: "2024-10-01" },
+ { id: "k1", type: "card", seconds: 4 },
+ { id: "c3", type: "clip", video: "c", start: 0, end: 6, date: "2025-12-08" },
+];
+const DURS = [10, 20, 4, 6];
+const POST = (id, date, extra = {}) => ({
+ id, platform: "bluesky", author: "Pirate Software", handle: "piratesoftware.live", date,
+ text: `words of ${id}`, url: `https://bsky.app/profile/piratesoftware.live/post/${id}`, ...extra,
+});
+// c2 carries a, b, c (October/November 2024); c3 carries z.
+const POSTS = [POST("a", "2024-10-19"), POST("b", "2024-11-27"), POST("c", "2024-12-01"), POST("z", "2026-01-22")];
+const sched = (render = FEED, posts = POSTS, durs = DURS, D = 0.5) =>
+ deckSchedule({ entries: CLIPS, durs, D, render, provenance: PROV, posts });
+
+// ---- the core ---------------------------------------------------------------
+
+test("feedGeometry: a column flush with the side and top down to the deck, the footage beside it", () => {
+ const g = feedGeometry(FEED);
+ assert.deepEqual(g.column, { x: 1320, y: 0, width: 600, height: 890 });
+ // 1320 × 890 left of the column; inset 24 all round; the frame's aspect; centred.
+ assert.deepEqual(g.footage, { x: 24, y: 87, width: 1272, height: 716 });
+ assert.deepEqual(g.deck, deckGeometry(FEED).deck);
+ // Nothing overlaps: footage, gap, column; footage above the deck.
+ assert.equal(g.column.x - (g.footage.x + g.footage.width), 24);
+ assert.ok(g.footage.y + g.footage.height <= g.deck.y);
+ // top-left mirrors it; a narrower column grows the footage.
+ const left = { ...BASE, chrome: { ...CHROME, deck: { posts: { layout: "feed", position: "top-left", width: 520 } } } };
+ const l = feedGeometry(left);
+ assert.deepEqual(l.column, { x: 0, y: 0, width: 520, height: 890 });
+ assert.deepEqual(l.footage, { x: 520 + 24, y: 65, width: 1352, height: 760 });
+ // The posts region IS the column under the feed; the popup's is unchanged.
+ assert.deepEqual(postsGeometry(FEED), g.column);
+ assert.deepEqual(postsGeometry(RENDER), { x: 1920 - 24 - 600, y: 26, width: 600, height: 838 });
+});
+
+test("validation: layout is popup or feed; the feed is held to the footage left beside it", () => {
+ assert.deepEqual(validateChrome(FEED.chrome, FEED), []);
+ assert.match(validateChrome({ ...CHROME, deck: { posts: { layout: "sidebar" } } }, BASE)[0], /posts.layout must be "popup" or "feed"/);
+ // 900 + 2·24 at 1920 leaves 972 px: allowed. At 1280×720 a 600 column leaves 632 < 640.
+ assert.deepEqual(validateChrome({ ...CHROME, deck: { posts: { layout: "feed", width: 900 } } }, BASE), []);
+ const small = { ...BASE, width: 1280, height: 720, chrome: { ...CHROME, deck: { posts: { layout: "feed" } } } };
+ assert.match(validateChrome(small.chrome, small).join(" "), /leaves the footage 632px wide beside the feed/);
+ assert.match(validatePosts([POST("p", "2024-01-01")], CLIPS, small).join(" "), /beside the feed/);
+});
+
+test("feedOn: the deck, the feed layout, shown, and a post to draw on a clip", () => {
+ assert.equal(feedOn({ render: FEED, posts: POSTS, entries: CLIPS }), true);
+ assert.equal(feedOn({ render: RENDER, posts: POSTS, entries: CLIPS }), false);
+ assert.equal(feedOn({ render: FEED, posts: [], entries: CLIPS }), false);
+ assert.equal(feedOn({ render: FEED, posts: [POST("h", "2024-01-01", { hide: true })], entries: CLIPS }), false);
+ assert.equal(feedOn({ render: FEED, posts: POSTS, entries: [{ id: "k", type: "card" }] }), false);
+ const off = { ...BASE, chrome: { ...CHROME, deck: { posts: { layout: "feed", show: false } } } };
+ assert.equal(feedOn({ render: off, posts: POSTS, entries: CLIPS }), false);
+ assert.equal(feedOn({ render: { ...FEED, chrome: undefined }, posts: POSTS, entries: CLIPS }), false);
+});
+
+test("the feed schedule: each post ticks in at its clip's start + D, a clip's next ones `seconds` apart", () => {
+ const s = sched();
+ assert.equal(s.layout, "feed");
+ // No hold, no move: the segments are their own lengths.
+ assert.deepEqual(s.segments.map((x) => x.duration), DURS);
+ assert.equal(s.segments.some((x) => "hold" in x), false);
+ assert.equal("moves" in s, false);
+ assert.equal(postHolds({ posts: POSTS, entries: CLIPS, render: FEED }).size, 0);
+ // c2 starts at 9.5: a at 10 (after the dissolve), b and c 4 s apart.
+ const at = Object.fromEntries(s.posts.map((p) => [p.id, [p.segment, p.slot, p.of, p.in]]));
+ assert.deepEqual(at, { a: ["c2", 0, 3, 10], b: ["c2", 1, 3, 14], c: ["c2", 2, 3, 18], z: ["c3", 0, 1, 33] });
+ // A feed post has `in`, not the popup's appear/out.
+ for (const p of s.posts) assert.equal("appear" in p || "out" in p, false);
+ // No windows: the feed is one composition.
+ assert.deepEqual(postWindows(s), []);
+ assert.deepEqual(postsRegions(FEED, "/o", s), []);
+ assert.equal(segmentJoins(s), null);
+ // A clip too short for k × seconds shares what it has before its leave.
+ const short = sched(FEED, POSTS, [10, 8, 4, 6]);
+ assert.deepEqual(short.posts.filter((p) => p.segment === "c2").map((p) => p.in), [10, 12.333, 14.667]);
+ // A hard cut: D = 0, the post is in at the clip's first frame.
+ const hard = sched(FEED, POSTS, DURS, 0);
+ assert.deepEqual(hard.posts.map((p) => p.in), [10, 14, 18, 34]);
+ // The core function, unrounded, says the same.
+ const segs = s.segments.map((x) => ({ id: x.id, start: x.start, duration: x.duration }));
+ assert.equal(postSchedule({ posts: POSTS, entries: CLIPS, segments: segs, D: 0.5, total: s.total, render: FEED })[0].in, 10);
+});
+
+test("the popup and the deck without posts write the schedule they always did", () => {
+ // A feed with nothing to draw is the deck alone, byte for byte.
+ const plain = JSON.stringify(sched(RENDER, []));
+ assert.equal(JSON.stringify(sched(FEED, [])), plain);
+ assert.equal(JSON.stringify(sched(FEED, [POST("h", "2024-10-19", { hide: true })])), plain);
+ const noShow = { ...BASE, chrome: { ...CHROME, deck: { posts: { layout: "feed", show: false } } } };
+ assert.equal(JSON.stringify(sched(noShow, POSTS)), plain);
+ // "popup", named, is the default: the same schedule, holds and moves and all.
+ const named = { ...BASE, chrome: { ...CHROME, deck: { posts: { layout: "popup" } } } };
+ assert.equal(JSON.stringify(sched(named)), JSON.stringify(sched(RENDER)));
+ const popup = sched(RENDER);
+ assert.equal("layout" in popup, false);
+ assert.ok(popup.moves.length > 0);
+ assert.ok(popup.posts.every((p) => "appear" in p && "out" in p && !("in" in p)));
+ assert.equal(estimateSchedule({ render: FEED, provenance: PROV, timeline: CLIPS, posts: POSTS }).layout, "feed");
+});
+
+// ---- the plan the page runs --------------------------------------------------
+
+const P = (posts = sched().posts) => posts.map((p) => ({ id: p.id, in: p.in }));
+const byWhy = (cues, re) => cues.filter((c) => re.test(c.why));
+
+test("feedCues: each card ticks in at its time, at the top, and the ones in move down by its height", () => {
+ const plan = feedCues({ posts: P(), heights: [150, 200, 160, 180], column: 1000, ...FEED_MOTION });
+ const g = FEED_MOTION.gap;
+ // Every card enters `lag` after its `in`, from the side, to x 0.
+ P().forEach((p, j) => {
+ const [e] = byWhy(plan.cues, new RegExp(`^enter ${p.id}$`));
+ assert.equal(e.k, `c${j}`);
+ assert.equal(e.at, Math.round((p.in + FEED_MOTION.lag) * 1e4) / 1e4);
+ assert.deepEqual(e.to, { autoAlpha: 1, x: 0 });
+ });
+ // b pushes a down by b's height; c pushes a and b by c's.
+ assert.deepEqual(byWhy(plan.cues, /^push for b$/).map((c) => [c.k, c.at, c.to.y]), [["c0", 14, 200 + g]]);
+ assert.deepEqual(byWhy(plan.cues, /^push for c$/).map((c) => [c.k, c.to.y]).sort(), [["c0", 200 + 160 + 2 * g], ["c1", 160 + g]]);
+ // The highlight: the newest flares and settles; the one before it goes out.
+ assert.deepEqual(byWhy(plan.cues, /^calm for b$/).map((c) => [c.k, c.to.opacity]), [["h0", 0]]);
+ assert.deepEqual(byWhy(plan.cues, /^settle z$/).map((c) => [c.k, c.to.opacity]), [["h3", FEED_MOTION.newest]]);
+ // The empty state leaves with the first post; the count follows each one.
+ assert.deepEqual(byWhy(plan.cues, /^first a$/).map((c) => [c.k, c.at, c.to.autoAlpha]), [["empty", 10, 0]]);
+ assert.deepEqual(byWhy(plan.cues, /^count z$/).map((c) => [c.k, c.to.autoAlpha]), [["n3", 0], ["n4", 1]]);
+ assert.equal(plan.gone.every((x) => x === null), true);
+});
+
+test("feedCues: when the column overflows the oldest fade out as they are pushed past it", () => {
+ // A column of 400: a (150) and b (200) fit; c (160) pushes a to 376 + 150 > 400.
+ const plan = feedCues({ posts: P(), heights: [150, 200, 160, 180], column: 400, ...FEED_MOTION });
+ const out = byWhy(plan.cues, /^out for /);
+ assert.deepEqual(out.map((c) => [c.k, c.why, c.to.autoAlpha]), [["c0", "out for c", 0], ["c1", "out for z", 0]]);
+ assert.deepEqual(plan.gone, [18, 33, null, null]);
+ // A gone card is never moved again.
+ assert.equal(plan.cues.some((c) => c.k === "c0" && c.at > 18), false);
+});
+
+test("feedCues: every cue states its from, the last to on its element -- seek-safe", () => {
+ const vis = deckChoreography({ ...sched(), segments: sched().segments.map((s) => ({ ...s, hideDeck: s.id === "k1" })) }, FEED).visibility;
+ const plan = feedCues({ posts: P(), heights: [150, 200, 160, 180], column: 400, visibility: vis, slideX: 624, ...FEED_MOTION });
+ const state = Object.fromEntries(Object.entries(plan.init).map(([k, v]) => [k, { ...v }]));
+ let last = -Infinity;
+ const ends = {};
+ for (const c of plan.cues) {
+ assert.ok(c.at >= last, "cues in time order");
+ last = c.at;
+ for (const [q, v] of Object.entries(c.from)) assert.equal(v, state[c.k][q], `${c.k}.${q} from at ${c.at}`);
+ assert.ok(c.at >= (ends[c.k] ?? -Infinity) - 1e-9, `${c.k} overlaps itself at ${c.at}`);
+ ends[c.k] = c.at + c.dur;
+ Object.assign(state[c.k], c.to);
+ }
+ // The column slides off with the deck over the card and back after it.
+ const col = plan.cues.filter((c) => c.k === "col");
+ assert.deepEqual(col.map((c) => c.to.x), [624, 0]);
+ assert.deepEqual(col.map((c) => c.at), vis.map((v) => v.at[0]));
+ // Hidden from the first frame: that is the init, no cue.
+ const first = feedCues({ posts: P(), heights: [1, 1, 1, 1], column: 400, startsHidden: true, slideX: 624,
+ visibility: [{ i: 0, hide: true, at: [0, 0] }] });
+ assert.deepEqual(first.init.col, { x: 624 });
+ assert.equal(first.cues.some((c) => c.k === "col"), false);
+});
+
+// ---- the page ------------------------------------------------------------------
+
+const FONTS = { regular: "assets/DeckSans.ttf", bold: "assets/DeckSansBold.ttf" };
+const HOSTILE = POST("evil", "2024-11-27T02:36:13.745Z", {
+ platform: "x", handle: '"><img src=x onerror=alert(1)>', author: "<b>bold</b>",
+ text: "</script><script>alert(1)</script>\nsecond line & 'quotes'\n\nnew paragraph",
+});
+const dataOf = (html) => JSON.parse(/<script id="feed-data" type="application\/json">(.*?)<\/script>/s.exec(html)[1]);
+const page = (posts = POSTS, opts = {}) => {
+ const s = sched(FEED, posts);
+ const qrSrcs = Object.fromEntries(s.posts.map((p, i) => [p.id, `assets/qr0${i}.png`]));
+ return { s, qrSrcs, html: feedHtml(s, FEED, { fonts: FONTS, qrSrcs, ...opts }) };
+};
+
+test("the page: one card per post with its date, words and QR; the header; the composition contract", () => {
+ const { s, html, qrSrcs } = page();
+ const col = feedGeometry(FEED).column;
+ assert.match(html, new RegExp(`data-composition-id="feed" data-start="0" data-duration="${s.total}"\\s+data-width="${col.width}" data-height="${col.height}"`));
+ assert.match(html, /window\.__timelines\["feed"\] = tl/);
+ s.posts.forEach((p, j) => {
+ const open = html.indexOf(`data-post="${p.id}" data-k="c${j}"`);
+ assert.ok(open > 0, `no card for ${p.id}`);
+ const next = html.indexOf("<article", open + 1);
+ const card = html.slice(open, next > 0 ? next : undefined);
+ assert.match(card, /class="date"/);
+ assert.match(card, /class="para">words of /);
+ assert.ok(card.includes(`<img src="${qrSrcs[p.id]}" width="120" height="120"`), `${p.id} qr`);
+ assert.ok(card.includes(`data-k="h${j}"`));
+ });
+ // One author: the header names them once, and the cards only date themselves.
+ assert.ok(html.includes('<span class="title">@piratesoftware.live</span>'));
+ assert.ok(html.includes('<span class="platform">Bluesky</span>'));
+ assert.equal((html.match(/class="handle"/g) ?? []).length, 0);
+ assert.ok(html.includes('<span class="date">Oct 19, 2024</span>'));
+ assert.ok(html.includes('data-k="empty"'));
+ assert.ok(html.includes('<span class="n" data-k="n4">4 of 4</span>'));
+ // The cue times are the schedule's.
+ assert.deepEqual(dataOf(html).posts, s.posts.map((p) => ({ id: p.id, in: p.in })));
+ assert.equal(dataOf(html).column, feedLayout(FEED).stack.height);
+ // The planner is in the page under its fixed name.
+ assert.ok(html.includes("const feedCues = (function feedCues("));
+ // Not a feed's schedule: refused.
+ assert.throws(() => feedHtml(sched(RENDER), FEED, { fonts: FONTS }), /not a feed/);
+});
+
+test("the page: post strings are text, escaped in elements, attributes and the inline JSON", () => {
+ const { html } = page([...POSTS, HOSTILE]);
+ assert.ok(html.includes("</script><script>alert(1)</script>\nsecond line & 'quotes'"));
+ assert.ok(html.includes("@"><img src=x onerror=alert(1)>"));
+ assert.doesNotMatch(html, /<img src=x/);
+ assert.doesNotMatch(html, /<b>bold<\/b>/);
+ // Two authors, two platforms: the header says "Posts" and each card its own.
+ assert.ok(html.includes('<span class="title">Posts</span>'));
+ assert.ok(html.includes('<span class="platform">X</span>'));
+ assert.equal((html.match(/<\/script>/g) ?? []).length, 3);
+ const json = /<script id="feed-data" type="application\/json">(.*?)<\/script>/s.exec(html)[1];
+ assert.doesNotMatch(json, /</);
+ assert.doesNotMatch(json, /alert|words of/);
+ assert.deepEqual(feedWho([POST("a", "2024-01-01")]), { title: "@piratesoftware.live", platforms: ["Bluesky"], single: true });
+});
+
+test("the page: nothing leaves the machine; the posts' links are only in their QRs", () => {
+ const { html } = page([POST("a", "2024-10-19", { text: "see https://example.com/x and ferrets.live" })]);
+ // The words may name a link; no element loads one.
+ assert.doesNotMatch(html, /(?:src|href)="(?:https?:)?\/\//, "a remote src or href");
+ assert.doesNotMatch(html, /url\('(?:https?:)?\/\//, "a remote font");
+ assert.doesNotMatch(html, /@import|fonts\.googleapis|cdn\.|<link/);
+ assert.match(html, /<script src="assets\/gsap\.min\.js"><\/script>/);
+ for (const m of html.matchAll(/font-family: ([^;]+);/g)) {
+ assert.match(m[1], /^'DeckSans(?:Bold)?'(?:, sans-serif)?$/, m[1]);
+ }
+});
+
+test("the page: a window of the cut plays the cut's own clock; visibility is the deck's", () => {
+ const { html } = page(POSTS, { from: 12, duration: 5 });
+ const d = dataOf(html);
+ assert.deepEqual(d.window, { from: 12, dur: 5 });
+ assert.match(html, /data-composition-id="feed" data-start="0" data-duration="5"/);
+ const hidden = { ...sched(), segments: sched().segments.map((s) => ({ ...s, hideDeck: s.id === "k1" })) };
+ const v = dataOf(feedHtml(hidden, FEED, { fonts: FONTS })).visibility;
+ assert.deepEqual(v.map((x) => [x.hide, x.at[0]]), deckChoreography(hidden, FEED).visibility.map((x) => [x.hide, x.at[0]]));
+ assert.equal(dataOf(feedHtml(hidden, FEED, { fonts: FONTS })).slideX, 600 + 24);
+});
+
+// ---- the build: framing, its record and the refusal ------------------------------
+
+test("deckFraming: a feed frames footage into feedGeometry's box; the deck's is unchanged", () => {
+ const f = feedGeometry(FEED).footage;
+ assert.deepEqual(deckFraming(FEED, { feed: true }).box, f);
+ assert.equal(
+ deckFramingFilter(FEED, { feed: true }),
+ "scale=1272:716:force_original_aspect_ratio=decrease,pad=1272:716:(ow-iw)/2:(oh-ih)/2:color=#12101a," +
+ "pad=1920:1080:24:87:color=#12101a,setsar=1,fps=30",
+ );
+ // Without the feed (or under the popup) it is the deck's box, the filter it always was.
+ assert.equal(deckFramingFilter(FEED), deckFramingFilter(RENDER));
+ assert.deepEqual(segmentFraming(FEED, true), { layout: "feed", box: f });
+ assert.deepEqual(segmentFraming(RENDER), { layout: "deck", box: deckGeometry(RENDER).footage });
+ assert.equal(framedUnderDeck({ type: "clip" }, FEED), true);
+ assert.equal(framedUnderDeck({ type: "image" }, FEED), true);
+ assert.equal(framedUnderDeck({ type: "card" }, FEED), false);
+ assert.equal(framedUnderDeck({ type: "teaser" }, FEED), false);
+ const shown = { ...BASE, chrome: { ...CHROME, deck: { overCards: "show" } } };
+ assert.equal(framedUnderDeck({ type: "card" }, shown), true);
+ assert.equal(framedUnderDeck({ type: "teaser" }, shown), false);
+});
+
+test("framingProblems: segments framed for another layout are named; a record-less one is the deck's", () => {
+ const entries = [...CLIPS, { id: "t", type: "teaser" }];
+ const feed = segmentFraming(FEED, true);
+ const deck = segmentFraming(FEED, false);
+ const rec = (fr) => ({ version: 1, framing: fr });
+ // All framed for the feed, wanted for the feed: nothing to say. Cards and teasers are full frame.
+ assert.deepEqual(framingProblems({ entries, records: [rec(feed), rec(feed), null, rec(feed), null], render: FEED, want: feed }), []);
+ // Built under the deck (with records, or before records named it) and now a feed: refused, by name.
+ const [msg] = framingProblems({ entries, records: [rec(deck), null, null, rec(feed), null], render: FEED, want: feed });
+ assert.match(msg, /^2 segment\(s\) are framed for another layout than this cut's posts feed \(footage 1272×716 at \(24, 87\)\)/);
+ assert.match(msg, /c1 \(framed for the deck, 1574×886 at \(173, 2\)\)/);
+ assert.match(msg, /c2 \(no framing record: built for the deck's 1574×886 at \(173, 2\)\)/);
+ assert.match(msg, /run a normal build \(with --skip-fetch/);
+ // The other way: feed-framed segments under a popup deck.
+ assert.match(framingProblems({ entries, records: [rec(feed), rec(deck), null, rec(deck), null], render: RENDER, want: deck })[0],
+ /1 segment\(s\) are framed for another layout than this cut's deck .*c1 \(framed for the feed, 1272×716 at \(24, 87\)\)/);
+ // Legacy segments with no record pass under the deck.
+ assert.deepEqual(framingProblems({ entries, records: entries.map(() => null), render: RENDER, want: deck }), []);
+});
+
+test("the feed's overlay is laid like the deck's: whole cut, reinit off, rgba, shortest", () => {
+ const regions = [...chromeRegions(FEED, "/o"), feedRegion(FEED, "/o/chrome/feed-frames")];
+ assert.deepEqual(regions[1], { name: "feed", frames: "/o/chrome/feed-frames", x: 1320, y: 0, width: 600, height: 890 });
+ const hf = chromeOverlayChain(FEED, regions, "[v]", 3);
+ assert.deepEqual(hf.inputs.slice(8), [
+ "-reinit_filter", "0", "-framerate", "30", "-start_number", "1", "-i", "/o/chrome/feed-frames/frame_%06d.png",
+ ]);
+ assert.match(hf.chain, /\[4:v\]format=rgba\[hfa1\];\[hf0\]\[hfa1\]overlay=x=1320:y=0:format=yuv444:shortest=1\[hf1\]/);
+ // The deck alone writes the chain it always did.
+ assert.equal(chromeOverlayChain(RENDER, chromeRegions(RENDER, "/o"), "[v]", 3).chain,
+ "[3:v]format=rgba[hfa0];[v][hfa0]overlay=x=0:y=890:format=yuv444:shortest=1[hf0];[hf0]format=yuv420p[vout]");
+});
+
+// ---- compose-chrome's feed region, with the renderer stubbed ----------------------
+
+test("compose-chrome: the feed region composes, renders through the stub and caches by its key", async () => {
+ const dir = mkdtempSync(path.join(tmpdir(), "feed-compose-"));
+ const env = { HYPERFRAMES_BIN: process.env.HYPERFRAMES_BIN, QRENCODE_BIN: process.env.QRENCODE_BIN };
+ try {
+ // A renderer that writes the frames the root declares, and a qrencode
+ // that writes a PNG: what the e2e stubs do.
+ const stub = path.join(dir, "hf-stub.mjs");
+ writeFileSync(stub, `#!/usr/bin/env node
+import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
+const a = process.argv.slice(2);
+const out = a[a.indexOf("--output") + 1];
+const fps = Number(a[a.indexOf("--fps") + 1]);
+const html = readFileSync(a.at(-1) + "/index.html", "utf8");
+const dur = Number(/data-composition-id="[^"]*"[^>]*data-duration="([\\d.]+)"/.exec(html)[1]);
+mkdirSync(out, { recursive: true });
+for (let i = 1; i <= Math.round(dur * fps); i += 1) writeFileSync(out + "/frame_" + String(i).padStart(6, "0") + ".png", "x");
+`);
+ chmodSync(stub, 0o755);
+ process.env.HYPERFRAMES_BIN = stub;
+ const s = sched();
+ const font = "/usr/share/fonts/TTF/FiraSans-Regular.ttf";
+ const manifest = { slug: "f", render: { ...FEED, fontRegular: font, fontBold: font, qr: { scale: 3, quiet: 3 } }, timeline: CLIPS, posts: POSTS };
+ const mp = path.join(dir, "video.manifest.json");
+ writeFileSync(mp, JSON.stringify(manifest));
+ const out = path.join(dir, "out", "sourced");
+ mkdirSync(out, { recursive: true });
+ writeFileSync(path.join(out, "schedule.json"), JSON.stringify(s));
+ const { composeChrome } = await import("./compose-chrome.mjs");
+ let r;
+ try {
+ r = await composeChrome({ manifestPath: mp, region: "feed", doRender: true, fps: 30 });
+ } catch (e) {
+ // No qrencode or font on this machine: the page is what the tests above hold.
+ if (/qrencode|ENOENT|face/.test(String(e.message))) return;
+ throw e;
+ }
+ assert.equal(r.projDir, path.join(out, "chrome", "feed"));
+ assert.equal(r.frames, path.join(out, "chrome", "feed-frames"));
+ assert.equal(r.frameCount, Math.round(s.total * 30));
+ assert.equal(r.cached, false);
+ const html = readFileSync(path.join(r.projDir, "index.html"), "utf8");
+ assert.match(html, /src="assets\/qr00\.png"/);
+ const again = await composeChrome({ manifestPath: mp, region: "feed", doRender: true, fps: 30 });
+ assert.equal(again.cached, true);
+ // The preview project and a window each have their own name.
+ const pv = await composeChrome({ manifestPath: mp, region: "feed", preview: true });
+ assert.equal(pv.projDir, path.join(out, "chrome", "feed-preview"));
+ const w = await composeChrome({ manifestPath: mp, region: "feed", from: 9, duration: 4 });
+ assert.equal(w.projDir, path.join(out, "chrome", "feed-from9"));
+ // A popup's schedule is not a feed's.
+ writeFileSync(path.join(out, "schedule.json"), JSON.stringify(sched(RENDER)));
+ await assert.rejects(composeChrome({ manifestPath: mp, region: "feed" }), /needs a feed's schedule/);
+ } finally {
+ for (const [k, v] of Object.entries(env)) if (v === undefined) delete process.env[k]; else process.env[k] = v;
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
diff --git a/umtool/report-to-video/compose-chrome.mjs b/umtool/report-to-video/compose-chrome.mjs
@@ -43,6 +43,7 @@ import {
} from "./deck.mjs";
import { deckHtml, GSAP_FILE } from "./chrome-deck.mjs";
import { postsHtml, snapWindow, windowPosts } from "./chrome-posts.mjs";
+import { feedHtml } from "./chrome-feed.mjs";
const run = promisify(execFile);
@@ -642,6 +643,16 @@ async function regionHtml(region, { manifest, base, projDir, assetsDir, schedule
for (const p of posts) if (p.qrUrl) qrSrcs[p.id] = byUrl.get(p.qrUrl);
return postsHtml(schedule, render, window, { fonts, qrSrcs });
}
+ if (region === "feed") {
+ // The posts feed: the deck's faces and QR maker, one page for the whole cut.
+ const render = manifest.render;
+ const fonts = await copyFonts(render, assetsDir, { regular: "DeckSans", bold: "DeckSansBold" }, { strict: true });
+ const posts = schedule.posts ?? [];
+ const byUrl = await qrPngsFor(posts.map((p) => p.qrUrl), render, resolveDeck(render).posts.qrSize, assetsDir);
+ const qrSrcs = {};
+ for (const p of posts) if (p.qrUrl) qrSrcs[p.id] = byUrl.get(p.qrUrl);
+ return feedHtml(schedule, render, { fonts, qrSrcs, from, duration });
+ }
if (region === "teaser") {
// A page module reached by a dynamic import, so nothing that imports this
// file -- umtool's preview helper, the build -- loads its face's URL
@@ -711,6 +722,11 @@ function runRenderer(cmd, args) {
* their `.key`, cached exactly as the deck's are;
* - `still` is in CUT seconds.
*
+ * Feed (`region: "feed"`, a schedule with `layout: "feed"`): the posts column
+ * for the whole cut, exactly as the deck -- project `chrome/feed/`
+ * (`feed-preview/` when `preview`), frames `chrome/feed-frames/` and their
+ * `.key`, a window `feed-from<s>[-frames]`, cached as the deck's are.
+ *
* Teaser (`region: "teaser"`, `segment` = the entry's id): the whole frame,
* `seconds` long, drawn from the entry alone (no schedule):
* - project `chrome/teaser-<id>/` (`teaser-preview-<id>/` when `preview`),
@@ -741,7 +757,7 @@ export async function composeChrome({
// The regions keyed by the render cache: the two drawn from the deck's
// schedule, and a teaser, drawn from its own timeline entry.
- const keyed = region === "deck" || region === "posts" || region === "teaser";
+ const keyed = region === "deck" || region === "feed" || region === "posts" || region === "teaser";
let teaser = null;
if (region === "teaser") {
teaser = (manifest.timeline ?? []).find((e) => e.id === segment && e.type === "teaser") ?? null;
@@ -750,7 +766,7 @@ export async function composeChrome({
if (errors.length) throw new Error(`teaser ${teaser.id}: ${errors.join("; ")}`);
}
let sched = schedule;
- if ((region === "deck" || region === "posts") && !sched) {
+ if ((region === "deck" || region === "feed" || region === "posts") && !sched) {
const p = path.join(base, "schedule.json");
try {
sched = JSON.parse(await readFile(p, "utf8"));
@@ -759,10 +775,14 @@ export async function composeChrome({
}
if (sched.kind !== "deck") throw new Error(`${p} is not a deck schedule (kind ${sched.kind ?? "missing"})`);
}
+ if (region === "feed" && sched.layout !== "feed") {
+ throw new Error("the feed region needs a feed's schedule (layout \"feed\": posts.layout \"feed\" and posts to draw)");
+ }
const total = teaser ? Number(teaser.seconds) : keyed ? sched.total : null;
const rate = Number(fps ?? sched?.fps ?? manifest.render.fps ?? 30);
const windowed =
- region === "deck" && (from > 0 || (duration != null && Math.abs(Number(duration) - total) > 1e-6));
+ (region === "deck" || region === "feed") &&
+ (from > 0 || (duration != null && Math.abs(Number(duration) - total) > 1e-6));
const suffix = windowed ? `-from${fmtSeconds(from)}` : "";
// A posts window: the one asked for, or the segment's from the schedule.
@@ -783,7 +803,7 @@ export async function composeChrome({
? `posts-${preview ? "preview-" : ""}${win.segment}`
: region === "teaser"
? `teaser-${preview ? "preview-" : ""}${teaser.id}`
- : region === "deck" && preview ? "deck-preview" : `${region}${suffix}`;
+ : (region === "deck" || region === "feed") && preview ? `${region}-preview` : `${region}${suffix}`;
const projDir = path.join(base, "chrome", projName);
const assetsDir = path.join(projDir, "assets");
// The deck's assets are rebuilt every time: a QR from a clip that has since
@@ -882,7 +902,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {
const manifestPath = argv.find((a, i) => !a.startsWith("--") && !VALUED.has(argv[i - 1]));
if (!manifestPath) {
console.error(
- "usage: compose-chrome.mjs <manifest.json> [--region chart|deck|posts|teaser] [--variant sourced|full]\n" +
+ "usage: compose-chrome.mjs <manifest.json> [--region chart|deck|feed|posts|teaser] [--variant sourced|full]\n" +
" [--segment <id>] (posts: the clip whose window to compose; teaser: its entry)\n" +
" [--from <s>] [--duration <s>] [--out <dir>] [--preview]\n" +
" [--still <s> --png <path>]\n" +
@@ -905,7 +925,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {
still: num("--still"),
png: flag("--png"),
// The deck renders four-wide by default; the band keeps the renderer's own default.
- workers: num("--workers") ?? (region === "deck" || region === "teaser" ? 4 : region === "posts" ? 2 : null),
+ workers: num("--workers") ?? (region === "deck" || region === "feed" || region === "teaser" ? 4 : region === "posts" ? 2 : null),
quality: flag("--quality") ?? "high",
format: flag("--format") ?? "png-sequence",
fps: num("--fps"),
diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs
@@ -42,9 +42,12 @@ export const DECK_DEFAULTS = Object.freeze({
// so the last of them can be read before the next clip; `shift` moves the
// footage away from the posts column (scaled to `scale`, over `seconds`)
// while they are up, or is `false`.
+ // `layout` "popup" draws them as above; "feed" keeps them in a column of
+ // their own beside the footage for the whole cut (feedGeometry), each one
+ // ticking in as its clip starts -- no hold, no move.
posts: Object.freeze({
- show: true, seconds: 4, hold: 2.5, position: "top-right", width: 600, qrSize: 120, maxLines: 7, inset: 24,
- shift: Object.freeze({ scale: 0.86, seconds: 0.6 }),
+ show: true, layout: "popup", seconds: 4, hold: 2.5, position: "top-right", width: 600, qrSize: 120, maxLines: 7,
+ inset: 24, shift: Object.freeze({ scale: 0.86, seconds: 0.6 }),
}),
});
@@ -187,7 +190,10 @@ export function validateChrome(chrome, render = {}) {
errors.push(`${w}.qr.size ${size} does not fit a ${h}px deck (at most ${h - 20})`);
}
});
- sub("posts", ["show", "seconds", "hold", "position", "width", "qrSize", "maxLines", "inset", "shift"], (p) => {
+ sub("posts", ["show", "layout", "seconds", "hold", "position", "width", "qrSize", "maxLines", "inset", "shift"], (p) => {
+ if (p.layout !== undefined && !POST_LAYOUTS.includes(p.layout)) {
+ errors.push(`${w}.posts.layout must be ${POST_LAYOUTS.map((l) => `"${l}"`).join(" or ")}`);
+ }
if (p.hold !== undefined && !numIn(p.hold, 0, 10)) errors.push(`${w}.posts.hold must be from 0 to 10 seconds`);
if (p.shift !== undefined && p.shift !== false) {
if (!isObj(p.shift)) errors.push(`${w}.posts.shift must be false or { scale, seconds }`);
@@ -509,7 +515,7 @@ export function deckSchedule({
// A clip that carries posts is held on its last frame for `posts.hold`: the
// hold is part of the segment's length in the cut, so every start, the total
// and the posts' timing below are measured with it. `durs` are the segments'
- // own (probed or estimated) lengths.
+ // own (probed or estimated) lengths. (The feed holds nothing: postHolds.)
const holds = deck.posts.show ? postHolds({ posts, entries, metas, render }) : new Map();
const full = durs.map((d, i) => d + (holds.get(entries[i].id) ?? 0));
const { starts, total } = scheduleFrom(full, D);
@@ -517,7 +523,10 @@ export function deckSchedule({
const round = (v) => Math.round(v * 1000) / 1000;
const segs = entries.map((e, i) => ({ id: e.id, start: starts[i], duration: full[i] }));
const placed = deck.posts.show ? postSchedule({ posts, entries, metas, segments: segs, D, total, render }) : [];
- const moves = placed.length ? footageMoves({ posts: placed, segments: segs, render }) : [];
+ // The feed is a layout only when it has posts to draw: a feed with none is
+ // the deck alone, and writes the schedule a deck without posts always did.
+ const feed = placed.length > 0 && deck.posts.layout === "feed";
+ const moves = placed.length && !feed ? footageMoves({ posts: placed, segments: segs, render }) : [];
return {
version: 1,
kind: "deck",
@@ -526,6 +535,7 @@ export function deckSchedule({
transition: D,
total: round(total),
multiChannel,
+ ...(feed ? { layout: "feed" } : {}),
segments: entries.map((e, i) => {
const { title, subtitle } = deckText(e, metas[i] ?? null, provenance, deck, multiChannel);
return {
@@ -544,11 +554,21 @@ export function deckSchedule({
}),
// Present only when there are posts to draw, so a cut without them writes
// the schedule it always did.
- ...(placed.length ? { posts: placed.map((p) => ({ ...p, appear: round(p.appear), out: p.out.map(round) })) } : {}),
+ ...(placed.length ? { posts: roundPosts(placed) } : {}),
...(moves.length ? { moves: moves.map((m) => ({ ...m, at: round(m.at), segmentAt: round(m.segmentAt) })) } : {}),
};
}
+/**
+ * `postSchedule`'s placements as a schedule writes them: their seconds to the
+ * millisecond -- a popup's `appear` and `out`, a feed's `in`. umtool's preview
+ * re-places posts and writes them through this too.
+ */
+export function roundPosts(placed) {
+ const round = (v) => Math.round(v * 1000) / 1000;
+ return placed.map((p) => ("in" in p ? { ...p, in: round(p.in) } : { ...p, appear: round(p.appear), out: p.out.map(round) }));
+}
+
/** `deckSchedule` from the manifest alone, for a preview before any build. */
export function estimateSchedule(manifest, { metas = [], noXfade = false } = {}) {
const render = manifest.render ?? {};
@@ -638,6 +658,8 @@ export function pipSegments(schedule) {
// ---------------------------------------------------------------------------
export const POST_PLATFORMS = Object.freeze(["bluesky", "x"]);
+/** How posts are drawn (`posts.layout`): cards at the end of a clip, or a column for the whole cut. */
+export const POST_LAYOUTS = Object.freeze(["popup", "feed"]);
const POST_KEYS = ["id", "platform", "author", "handle", "date", "text", "url", "attachTo", "hide"];
/**
@@ -688,7 +710,17 @@ export function postsFitErrors(render) {
const g = deckGeometry(render);
const pp = resolveDeck(render).posts;
const errors = [];
- if (pp.width + 2 * pp.inset > g.footage.width) {
+ if (pp.layout === "feed") {
+ // The feed's column stands beside the footage, not over it: what has to
+ // fit is the footage left beside it.
+ const f = feedGeometry(render);
+ if (f.footage.width < g.W / 2) {
+ errors.push(
+ `${w}.posts.width ${pp.width} with inset ${pp.inset} leaves the footage ${f.footage.width}px wide beside the feed ` +
+ `(at least half the frame, ${g.W / 2}px)`,
+ );
+ }
+ } else if (pp.width + 2 * pp.inset > g.footage.width) {
errors.push(`${w}.posts.width ${pp.width} with inset ${pp.inset} does not fit the ${g.footage.width}px footage box`);
}
if (pp.qrSize > pp.width / 2) errors.push(`${w}.posts.qrSize ${pp.qrSize} is more than half the card's width`);
@@ -739,7 +771,15 @@ export function attachPosts({ posts = [], entries = [], metas = [] }) {
/**
* When each placed post is on screen, in the cut's clock.
*
- * A clip's posts (oldest first) share an anchor A: the start of the outgoing
+ * In the FEED (`posts.layout: "feed"`) a post has one time, `in`: when it
+ * ticks into the column, as early as it can be read -- its clip's start, after
+ * the incoming dissolve (start + D). A clip's posts (oldest first) follow one
+ * every `posts.seconds`, or closer when the clip is too short for that: post j
+ * of k ticks in at start + D + step·j, step = min(seconds, (A − start − D)/k),
+ * A the start of the outgoing transition as below -- so the last one is in at
+ * least `step` before its clip leaves. Once in, a post stays to the end.
+ *
+ * In the POPUP, a clip's posts (oldest first) share an anchor A: the start of the outgoing
* transition -- the next segment's start with a crossfade, 0.3 s before the cut
* on a hard cut, 0.3 s before the end on the last segment. Post j of k appears
* at A − step·(k − j), step = `posts.seconds`, so each has its seconds alone
@@ -752,6 +792,7 @@ export function attachPosts({ posts = [], entries = [], metas = [] }) {
*/
export function postSchedule({ posts = [], entries, metas = [], segments, D, total, render }) {
const settings = resolveDeck(render).posts;
+ const feed = settings.layout === "feed";
const byId = new Map(posts.map((p) => [p.id, p]));
const groups = new Map();
for (const a of attachPosts({ posts, entries, metas })) {
@@ -770,8 +811,14 @@ export function postSchedule({ posts = [], entries, metas = [], segments, D, tot
? [segments[i + 1].start, segments[i + 1].start + D]
: [segments[i + 1].start - 0.3, segments[i + 1].start];
const A = leave[0];
- const from = seg.start + (i > 0 ? D : 0);
const k = group.length;
+ if (feed) {
+ const from = seg.start + D;
+ const step = Math.min(settings.seconds, Math.max(0, A - from) / k);
+ group.forEach((p, j) => out.push({ id: p.id, segment: seg.id, slot: j, of: k, in: from + step * j, ...postFields(p) }));
+ return;
+ }
+ const from = seg.start + (i > 0 ? D : 0);
const step = Math.min(settings.seconds, Math.max(0, A - from) / k);
group.forEach((p, j) => {
out.push({
@@ -781,25 +828,34 @@ export function postSchedule({ posts = [], entries, metas = [], segments, D, tot
of: k,
appear: A - step * (k - j),
out: leave,
- date: p.date,
- text: p.text,
- author: p.author ?? "",
- handle: p.handle ?? "",
- platform: p.platform,
- url: p.url,
- qrUrl: p.url,
+ ...postFields(p),
});
});
});
return out;
}
+/** What a placed post carries for the page that draws it. */
+function postFields(p) {
+ return {
+ date: p.date,
+ text: p.text,
+ author: p.author ?? "",
+ handle: p.handle ?? "",
+ platform: p.platform,
+ url: p.url,
+ qrUrl: p.url,
+ };
+}
+
/**
* The windows the posts region is rendered for: one per clip that carries
* posts, from its first post's appearance to the end of its leave. Frames are
* only made for these seconds; the overlay places each at its `from`.
*/
export function postWindows(schedule) {
+ // The feed is one composition for the whole cut, not windows.
+ if (schedule.layout === "feed") return [];
const by = new Map();
for (const p of schedule.posts ?? []) {
const w = by.get(p.segment) ?? { segment: p.segment, from: Infinity, to: -Infinity };
@@ -834,6 +890,7 @@ export function snapWindow(window, { fps, total }) {
export function postsGeometry(render) {
const { W, footage: f } = deckGeometry(render);
const p = resolveDeck(render).posts;
+ if (p.layout === "feed") return feedGeometry(render).column;
const width = even(p.width);
const height = even(f.height - 2 * p.inset);
const left = p.position === "top-left";
@@ -862,6 +919,59 @@ export function shiftedFootage(render) {
}
/**
+ * The FEED's frame (`posts.layout: "feed"`): a column of posts for the whole
+ * cut on the `position` side, standing on the deck from the frame's top, and
+ * the footage box beside it. The deck stays full width at the bottom, so the
+ * column and the deck read as one L-shaped surface around the picture.
+ *
+ * - The column (`column`, the feed region) is `posts.width` wide, flush with
+ * the frame's side edge and top, down to the deck's top edge.
+ * - The footage keeps the frame's aspect and is as large as fits in what is
+ * left above the deck with `posts.inset` clear on every side (evened), and
+ * is centred there. At 1920×1080 with the defaults (190 px deck, 600 px
+ * column, inset 24): the column is 600×890 at (1320, 0) and the footage
+ * 1272×716 at (24, 87) -- 66 % of the frame's width, against the deck
+ * alone's 82 %.
+ *
+ * Nothing in it moves: every footage segment of a feed cut is framed into
+ * this box when it is built (build-video's deckFraming), and stays there.
+ *
+ * @returns {{ W: number, H: number, column: {x:number,y:number,width:number,height:number},
+ * footage: {x:number,y:number,width:number,height:number}, deck: {x:number,y:number,width:number,height:number} }}
+ */
+export function feedGeometry(render) {
+ const { W, H, deck } = deckGeometry(render);
+ const p = resolveDeck(render).posts;
+ const left = p.position === "top-left";
+ const cw = even(p.width);
+ const room = { x: left ? cw : 0, width: W - cw, height: H - deck.height };
+ const fit = Math.min(room.width - 2 * p.inset, ((room.height - 2 * p.inset) * W) / H);
+ const fw = even(Math.max(2, fit));
+ const fh = even((fw * H) / W);
+ return {
+ W, H,
+ column: { x: left ? 0 : W - cw, y: 0, width: cw, height: room.height },
+ footage: {
+ x: room.x + Math.floor((room.width - fw) / 2), y: Math.floor((room.height - fh) / 2), width: fw, height: fh,
+ },
+ deck,
+ };
+}
+
+/**
+ * Is this cut a FEED: the deck on, its posts shown in the `feed` layout, and
+ * at least one post to draw on a clip of `entries` (the cut's whole timeline,
+ * never an `--only` slice of it)? The build frames its footage into
+ * `feedGeometry` exactly when this is true, and the schedule says
+ * `layout: "feed"` exactly then -- a feed with nothing to draw is the deck alone.
+ */
+export function feedOn({ render, posts = [], entries = [] }) {
+ if (!deckOn(render)) return false;
+ const p = resolveDeck(render).posts;
+ return p.show && p.layout === "feed" && attachPosts({ posts: posts ?? [], entries }).length > 0;
+}
+
+/**
* 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
@@ -869,9 +979,11 @@ export function shiftedFootage(render) {
*/
export function postHolds({ posts = [], entries = [], metas = [], render }) {
const fps = render?.fps ?? 30;
- const hold = Math.round(resolveDeck(render).posts.hold * fps) / fps;
+ const settings = resolveDeck(render).posts;
+ const hold = Math.round(settings.hold * fps) / fps;
const out = new Map();
- if (!(hold > 0)) return out;
+ // The feed never pauses the cut: a post is in its column while the clip plays on.
+ if (!(hold > 0) || settings.layout === "feed") return out;
for (const a of attachPosts({ posts, entries, metas })) out.set(a.entryId, hold);
return out;
}
diff --git a/umtool/report-to-video/verify-build.mjs b/umtool/report-to-video/verify-build.mjs
@@ -136,6 +136,19 @@ export async function verifyDeck(variantDir, render, file, problems) {
if (!(Math.abs(videoFrames - schedule.total * fps) <= 1.5)) {
problems.push(`the picture is ${videoFrames} frames for a ${schedule.total.toFixed(3)}s schedule (${want} frames)`);
}
+ // The posts feed: one sequence for the whole cut, as long as the deck's --
+ // and a cut that never pauses: no hold and no footage move in its schedule.
+ let feed = null;
+ if (schedule.layout === "feed") {
+ const dir = path.join(variantDir, "chrome", "feed-frames");
+ const got = await countFrames(dir);
+ feed = { frames: got, expectedFrames: want, posts: (schedule.posts ?? []).length };
+ if (got !== want) problems.push(`${dir} holds ${got} frames; the posts feed runs the whole cut, ${want}`);
+ const held = (schedule.segments ?? []).filter((s) => s.hold > 0).map((s) => s.id);
+ if (held.length || schedule.moves?.length) {
+ problems.push(`the posts feed never pauses the cut, but the schedule holds ${held.join(", ") || "nothing"} and moves ${schedule.moves?.length ?? 0}`);
+ }
+ }
// The posts windows: each laid at its own second, each as long as snapWindow says.
const posts = [];
for (const r of postsRegions(render, variantDir, schedule)) {
@@ -148,6 +161,7 @@ export async function verifyDeck(variantDir, render, file, problems) {
const holds = await verifyHolds(file, schedule, render, problems);
return {
total: schedule.total, frames, expectedFrames: want, videoFrames, segments: schedule.segments.length,
+ ...(feed ? { feed } : {}),
...(posts.length ? { posts } : {}),
...(holds.length ? { holds } : {}),
};
@@ -282,6 +296,9 @@ async function main() {
);
if (res.deck) {
console.log(` deck: ${res.deck.frames}/${res.deck.expectedFrames} frame(s) over ${res.deck.segments} segment(s), ${res.deck.total}s`);
+ if (res.deck.feed) {
+ console.log(` posts feed: ${res.deck.feed.frames}/${res.deck.feed.expectedFrames} frame(s), ${res.deck.feed.posts} post(s), no hold`);
+ }
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`);
}