Archilyzer · Source

archilyzer

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

commit 0ca2039e4d1828b1418ac82979223ddefce8db9e
parent a210dace521bb77bc17e488119c7275a44fbdef1
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 20:13:05 -0400

Merge branch 'deck/feed-f1' into deck/finale-dip

# Conflicts:
#	plans/deck-posts.md

Diffstat:
Meditor/CHANGELOG.md | 1+
Mplans/deck-posts.md | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/app/api/report/chrome/preview/route.ts | 31+++++++++++++++++++++++++++++--
Mumtool/components/projects/OnscreenSection.tsx | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mumtool/docs/quirks.md | 33+++++++++++++++++++++++++++++++++
Mumtool/e2e/fixtures/make-fixture.mjs | 31++++++++++++++++++++++++++++++-
Mumtool/e2e/onscreen-posts.spec.ts | 187+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mumtool/lib/report/onscreen.mjs | 73++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mumtool/lib/report/onscreen.test.mjs | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/lib/report/serve.mjs | 15+++++++++++++++
Mumtool/lib/report/serve.test.mjs | 11+++++++++++
Mumtool/report-to-video/README.md | 63++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mumtool/report-to-video/build-video.mjs | 140++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Aumtool/report-to-video/chrome-feed.mjs | 501+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/report-to-video/chrome-feed.test.mjs | 394+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/compose-chrome.mjs | 32++++++++++++++++++++++++++------
Mumtool/report-to-video/deck.mjs | 148+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mumtool/report-to-video/verify-build.mjs | 17+++++++++++++++++
18 files changed, 1926 insertions(+), 63 deletions(-)

diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -4,6 +4,7 @@ - **umtool's report videos keep every clip's sound on its picture.** In a crossfaded cut each clip's audio was placed by the audio's own length and its picture by the picture's, and an encoded clip's audio is routinely a few to twenty milliseconds shorter or longer than its video, so the sound drifted further ahead clip by clip: by the end of a seventeen-clip cut it was a third of a second early, and two seconds on one with title and sources cards. Each clip's sound is now padded or trimmed to exactly its picture's length before the crossfade. Every crossfaded report video changes when it is rebuilt, and is in sync; a hard-cut video was not affected. - **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. The deck changes nothing, byte for byte, in a cut whose manifest has no `render.chrome`. - **A report cut that wears the on-screen deck can show posts — Bluesky or X statements — as cards over the footage.** A report manifest's `posts` list (each with its platform, handle, date, words and link) is drawn near the end of the clip each post belongs with: the clip whose recording most closely precedes it by date, unless the post names one with `attachTo`; `hide` leaves one out. A clip's posts appear four seconds apart and stack down a column at the frame's top right; as the first appears, the footage eases aside (to 86 % of its box, at the far side) to make room, and the clip's last frame is held, in silence, for 2.5 seconds so the last post can be read; then they all leave together in the change to the next clip, which comes in at the normal size. When the column is full the oldest slide up and out. Each card slides in from the edge of the frame and flares in the deck's accent as it lands; it has an accent rail down its edge and shows the post's date, a platform label ("Bluesky" or "X") beside `@handle`, its words in paragraphs up to seven lines with an ellipsis, and a QR of the post's link, in the deck's colours and faces. The hold and the move are made where the cut is joined, not in a clip, so `--chrome-only` changes them without rebuilding one; chapters and the deck's timing count the hold. The timing, the hold (`hold`, 0 turns it off), the move (`shift`: its scale and seconds, or `false`), the column's side, width and inset, the QR size and the line limit are settings under `render.chrome.deck.posts`, and a bad post or setting is refused with a sentence before a build fetches anything. Only the seconds the cards are up are rendered, one short sequence per clip, cached like the deck; `--chrome-only`, `--chrome-preview` and a hard-cut cut lay them as they lay the deck, and `--no-chrome` draws neither — though it still holds and moves the footage, which are part of the cut rather than the chrome. A first post that appears inside the hold still moves the footage, and a hold is a whole number of frames. `posts` changes nothing in a cut that has none, and without the deck it is not drawn at all. +- **A report cut's posts can be a feed: a column beside the footage for the whole cut, each post ticking in as its clip starts, with no pause.** `render.chrome.deck.posts.layout: "feed"` (the default, `"popup"`, is the cards described above) puts every post in one column on the right, standing on the deck so the two read as one L-shaped panel around the picture. The footage of every clip and still is framed, for the whole cut, into the box left beside the column (1272×716 at 1920×1080 with the default 600 px column, against 1574×886 under the deck alone); nothing moves and nothing is held, so the cut is as long as its clips. Before the first post the column shows its header — the platform and the handle, or "Posts" when there are several authors — and an empty state. Each post ticks in at the start of the clip it belongs with, just after the crossfade into it, a clip's next ones `posts.seconds` apart (closer on a short clip): it lands at the top with an accent flare and keeps a lit rail while it is the newest, and the posts already up slide down to make room; when the column is full the oldest fade out at the bottom. Each card shows the post's date, its words up to `maxLines`, and its QR. Over a card or the teaser the column slides out of the frame with the deck and comes back after. The column is drawn by one composition for the whole cut, cached like the deck's. Switching the layout reframes every segment, so it takes a normal build (with `--skip-fetch` it re-cuts from the cached windows); `--chrome-only` over segments framed for the other layout is refused with a sentence naming them, because each segment's `<id>.cut.json` now records the box it was framed into. In umtool, the posts settings have a **layout** switch, and the live preview shows the column for the whole scrub with the footage in its box. A cut in the popup layout, or without posts, builds exactly as before. - **A report clip can go silent partway through, a report cut can fade out at its end, and the deck's QR names its site in larger type.** A clip's `muteFrom` (in the recording's own seconds, inside the clip) silences it from that second to its end while the picture plays on, after a 40 ms fade that ends there, so nothing clicks and no next word leaks in; a hold on that clip stays silent. `render.endFade` (seconds; 0, the default, is off) fades the cut's last segment, whatever it is — a clip with its hold, a closing card or a teaser — to the background colour and to silence over its final seconds, all of it when the segment is shorter, and the deck stays drawn over it. Both are applied where the cut is joined, so `--chrome-only` changes them without rebuilding a clip, and a value out of range is refused with a sentence before a build fetches anything. Each clip build now writes `<id>.cut.json` beside its segment, saying where in the recording the segment really starts after its cut was snapped to a silence; `muteFrom` is measured from it, and a segment built before this measures from the clip's unsnapped start and says so. The site's name beside the deck's QR is now exactly as long as the code is tall, for any site. Neither key changes a cut that does not set it. - **umtool's clip bench stops exactly where a range ends, and sets a clip's mute mark.** The bench's **play selection**, the edge auditions, the auto-audition and a click on a transcript line now play the window's sound through the browser's Web Audio, from a decode made on the server by ffmpeg — the same timeline the build cuts on — and each stops on the audio clock where its range ends, at every speed. They used to play on the video element and were stopped when it next reported its time, which overran the end by up to a quarter of a second, by a different amount each time. The picture follows, muted. If the sound cannot be decoded, the video element plays as before and the bench says the playback is approximate and why. The mute mark sets the clip's `muteFrom`: `m` puts it at the playhead, **pick on waveform** puts it where you click, `;` and `'` nudge it (with shift, by half a second), and `M` or **clear mute** removes it. It is saved with the window like the edges, every playback goes silent at it with the build's own 40 ms fade, and a window save that would leave it outside the clip is refused unless the same save moves or clears it — or, when it is within 0.02 s of the new edge, moves it onto that edge. The decoded sound is served by a new `GET /api/report/audio`, at most 120 seconds of a cached window at a time, as WAV. - **A report cut can end on a teaser card: a few lines popping in over a dark cinematic ground, with a trailer hit under each.** A report manifest's `teaser` entry (`lines`, `seconds`, an optional `tail`) is a full-frame card drawn from its own words, one to five lines each popping in top to bottom with a scale overshoot, a blur that sharpens, and a flash of the accent; with three or more lines the first is a small overline, the last a mid-size date, and the ones between a big title. A line written as `{ "text": …, "break": … }` draws its ending as a smaller second tier a beat later, and the tail fades in after the last line on its own. Under each pop is a synthesised boom, the title's the biggest, and under the tail a low swell; `"hits": false` makes the card silent. Put after the last clip, it joins with the ordinary crossfade and takes the cut's end fade. A line too long to fit the frame at its smallest size is refused with a sentence saying how many characters fit (a title holds 34). It is rendered once and re-rendered when its words change, `--chrome-only` included, and its chapter is its lines. umtool shows it as a card row named by its lines; its words are edited in the manifest. diff --git a/plans/deck-posts.md b/plans/deck-posts.md @@ -400,3 +400,93 @@ Gates at 07d1fa08: `render.chrome` gives md5 `6a92235fa12ca181bb81993129c9ee9d`. - umtool e2e `onscreen-posts onscreen clip-bench build projects report-fetch-via-editor`: 97 passed (4.9 min). + +## Feed F1, as built + +Branch `deck/feed-f1` from 07d1fa08. The operator's ask: the posts as a persistent, ticking feed +in a column on the right for the whole cut — with the deck, an L-shaped interface — and no +pausing. `render.chrome.deck.posts.layout: "popup" | "feed"`, default `"popup"` (everything +above, unchanged). + +| Commit | What | +|---|---| +| 17c5d446 | `deck.mjs`: `posts.layout`, `POST_LAYOUTS`, `feedGeometry`, `feedOn`, the feed's `in` in `postSchedule`, `roundPosts`; no hold, move or windows under the feed; the schedule's `layout: "feed"`. `chrome-feed.mjs` (the page, `feedCues`, `feedLayout`, `feedWho`, `FEED_MOTION`). compose-chrome region `feed`. build-video: feed framing (`deckFraming(render, {feed})`, `segmentFraming`, `framedUnderDeck`), each framed segment's `cut.json` records `framing`, `framingProblems` refuses `--chrome-only`/`--chrome-preview` over another layout's segments, `feedRegion` laid like the deck. verify-build's feed check. `chrome-feed.test.mjs` | +| 220a84f5 | umtool: the `layout` switch; the preview route composes `chrome/feed-preview` and returns `feed: {src, geometry, footage, boxes}` (`composeFeedPreview`, `segmentBoxes`, `feedPreviewDir/Src`, the files route's `feed-preview/`); `previewSchedule` follows the layout; `FeedOverlay`; the backdrop carried into the feed's box; the posts table's jump uses `in`; e2e (the posts fixture in feed, and a built `onscreen-feed-fixture`) | +| 55ac8ab4 | The room opens before a post comes in over it (push 0.4 s front-loaded, entrance 0.22 s after `in`): the first ferret render's +0.3 s still showed the new card over the ones it was pushing | +| 211f0d87 | `framingProblems` builds its sentence without a template nested in a template's `${}`: the build-trace check (`scripts/next-build-trace.test.mjs`) read the rest of build-video as one call reaching the CLI guard's `import.meta.url` (69 false findings; quirk recorded) | +| (this) | README, quirks, one `[Unreleased]` bullet, this section | + +Rulings as built: + +- **Geometry** (`feedGeometry`, numbers at 1920×1080 with the defaults): the column is + `posts.width` 600 wide, flush with the right edge and the top, down to the deck — 600×890 at + (1320, 0). The footage is as large as fits in the 1320×890 left of it with `posts.inset` 24 + clear on every side, the frame's aspect, centred: 1272×716 at (24, 87) — 66 % of the frame's + width (the deck alone: 1574×886, 82 %). Gap footage → column 24 px. `top-left` mirrors it. A + feed whose footage would be under half the frame's width is refused. Inside the column: 22 px + side padding, a 66 px header from y 26 (platform pill, the handle at 26 px or "Posts" over + several authors, an "n of N" count, "posts as the timeline reaches them"), a rule at y 102, + the stack from y 120 to 22 px off the bottom (748 px). Cards are the popup's design sized for + the column: 556 wide, 6 px rail, 24 px words on 33 px lines (≈ 30 characters a line), + `maxLines`, the date at 18 px (handle and platform only with several authors), the + `qrSize` 120 QR in a 148 px cell. +- **Timing:** post j of k on a clip ticks in at start + D + step·j, step = min(`seconds`, + (A − start − D)/k), A the clip's outgoing transition. The schedule carries `in` per post and + `layout: "feed"`, only when there are posts to draw. +- **Motion** (`FEED_MOTION`): at `in` the cards already in move down by the new card's height + plus 16 px over 0.4 s (power3.out); the new card enters from the column's outer edge 0.22 s + later over 0.6 s (expo.out); its rim and lit rail flare to 1 and settle to 0.55 while it is the + newest, and go out over 0.8 s when the next one arrives. A card pushed past the stack's bottom + fades as it moves (0.45 s) and is gone: a card is either whole in the column or out of it. + The empty state fades with the first post. Over a hidden-deck segment the column slides + 624 px out of the frame's right edge on the deck's own times (`deckChoreography`'s visibility). +- **Framing is the build's, per segment, and recorded:** a feed cut frames clips, stills and + shown cards into the feed's box; `framing: {layout, box}` in each framed segment's + `<id>.cut.json`. `--chrome-only` over segments whose box is not the cut's is refused, naming + each; a record-less segment counts as the deck's. Switching layouts is a normal build with + `--skip-fetch`. +- **The feed is one region for the whole cut**, `chrome/feed[-frames]` (`feed-preview`, + `feed-from<s>`), cached like the deck's, laid like the deck's (`-reinit_filter 0`, + `format=rgba`, `shortest=1`) after it; the build checks it is as many frames as the deck's. +- **umtool:** the preview's backdrop is a built segment carried from the box its record names + into the feed's (or the neutral frame drawn in the feed's box); the feed's iframe is up for + the whole scrub. + +Gates: + +- Unit: `chrome-feed.test.mjs` 16 (geometry, validation, `feedOn`, the feed schedule's tick-in + times, popup/no-posts schedule identity, `feedCues` stagger/push/overflow/highlight/visibility + and seek-safety, the page's nodes, escaping, cue times and no remote URL, the window, framing + and `framingProblems`, the overlay chain, compose-chrome's feed region through a stub), and 6 + in `onscreen.test.mjs`/`serve.test.mjs`. +- Identity, against 07d1fa08's modules over the ferret manifest: the popup, the hard-cut popup, + no posts, and a feed with no posts give byte-equal schedules, xfade graphs, overlay chains and + framing filters. `--only c07 --skip-fetch` without `render.chrome`: md5 + `6a92235fa12ca181bb81993129c9ee9d`. +- Ferret, scratch copy with `posts.layout: "feed"`, a FULL `--skip-fetch` build (segments + reframed) from cached windows: 356.7 s (no holds; the popup cut was 366.7 s), video 356.700 s + and audio 356.700 s; verify-build ok — deck 10,701/10,701, feed 10,701/10,701, 7 posts, no + hold, teaser 210/210; 18 chapters. Posts in at 25.7 / 29.7 (c03), 124.6 / 128.367 / 132.133 + (c06, 3.77 s apart: c06 is short), 219.333 (c12), 285.533 (c17). QR 7/7 posts (each read off + its settled frame) and 17/17 deck. The column overflows at c06's posts (the c03 cards fade out + at the bottom). Build 52.5 min wall clock under a load average of ~25: the deck rendered in + 309 s, the feed in 487 s (1.2 GB of frames), the teaser in 159 s. +- Repo gates on 211f0d87: workspace tsc clean (69 s); `test:scripts` 389 pass, 2 skipped (391); + capped umtool `next build` with the corpus linked exit 0 (41 s; 50 s on 220a84f5), link + removed. umtool e2e `onscreen-posts onscreen clip-bench build projects`: 96 passed (4.9 min, + after 27 min in the queue). A first run on 220a84f5 while the ferret build encoded (load ~25) + gave 87 passed, 9 failed — clip-bench and projects timeouts, none in the onscreen specs, both + feed tests green — and passed whole once the encode was done. + +Found and left: + +- **The popup page's clamp regex reaches the page as `/s+$/`** (the template literal eats the + backslash; quirk recorded). It runs only for a clamped card whose later paragraphs were dropped + after one that fit exactly, and strips trailing letters "s" rather than spaces. Not fixed here: + it would change the popup page and its cached windows; the feed's page writes `\\s`. +- A card is in the feed whole or not at all, so an overflowing column can show a gap at its + bottom (on ferret at 133 s: 216 px free under two cards, the third needing 288). +- The umtool preview shows the feed's composition and frames the backdrop in the feed's box; it + does not show the segments reframed (that is the build's), so a backdrop built for the deck is + carried, scaled, into the feed's box until a build reframes it. + diff --git a/umtool/app/api/report/chrome/preview/route.ts b/umtool/app/api/report/chrome/preview/route.ts @@ -1,12 +1,14 @@ import { composeDeckPreview, + composeFeedPreview, composePostsPreviews, normalizeDraft, normalizePostsDraft, scheduleForPreview, + segmentBoxes, } from "@/lib/report/onscreen.mjs"; -import { deckPreviewSrc, postsPreviewSrc, resolveReport } from "@/lib/report/serve.mjs"; -import { deckGeometry, deckLayout, deckOn, postsGeometry, validateChrome } from "umtool-report-to-video/deck"; +import { deckPreviewSrc, feedPreviewSrc, postsPreviewSrc, resolveReport } from "@/lib/report/serve.mjs"; +import { deckGeometry, deckLayout, deckOn, feedGeometry, postsGeometry, validateChrome } from "umtool-report-to-video/deck"; export const dynamic = "force-dynamic"; @@ -36,6 +38,13 @@ export const dynamic = "force-dynamic"; // is applied first. A window that does not compose says why in its row; the // deck's preview is returned either way. // +// The posts FEED (`posts.layout: "feed"`, when the schedule says `layout: +// "feed"`) is one more composition for the whole cut, `feed.src`, loaded at +// `feed.geometry` (the column) for the whole scrub; the footage of a feed cut +// sits in `feed.footage`, and `feed.boxes` says which box each built segment +// was framed into, so a backdrop built for the deck is carried into the +// feed's box. A feed has no windows. +// // The client sends a project id, a variant and the drafts. Never a path. export async function POST(request: Request) { let body: Record<string, unknown>; @@ -84,6 +93,23 @@ export async function POST(request: Request) { // `posts: false` -- the clip bench's strip, which has no footage to lay them on. const windows = body.posts === false ? [] : await composePostsPreviews(r.project, r.variant, schedule); const stamp = Date.now(); + const isFeed = body.posts !== false && (schedule as { layout?: string }).layout === "feed"; + let feed: Record<string, unknown> | null = null; + if (isFeed) { + const g = feedGeometry(render); + let error: string | null = null; + try { + await composeFeedPreview(r.project, r.variant, schedule); + } catch (e) { + error = e instanceof Error ? e.message : String(e); + } + feed = { + geometry: g.column, + footage: g.footage, + boxes: await segmentBoxes(r.project, r.variant, (variantManifest.timeline ?? []) as { id: string }[], render), + ...(error ? { error } : { src: `${feedPreviewSrc(r.project.id, r.variant)}?v=${stamp}` }), + }; + } return Response.json( { @@ -105,6 +131,7 @@ export async function POST(request: Request) { ...(w.ok ? { src: `${postsPreviewSrc(r.project.id, r.variant, w.segment)}?v=${stamp}` } : { error: w.error }), })), }, + ...(feed ? { feed } : {}), }, { headers: { "cache-control": "no-store" } }, ); diff --git a/umtool/components/projects/OnscreenSection.tsx b/umtool/components/projects/OnscreenSection.tsx @@ -59,6 +59,8 @@ export type DeckSegment = { export type FootageMove = { segment: string; at: number; segmentAt: number; seconds: number; from: Rect; to: Rect }; export type DeckSchedule = { estimated?: boolean; + /** "feed" when the posts are a column for the whole cut (`posts.layout: "feed"`, posts to draw). */ + layout?: "feed"; fps: number; transition: number; total: number; @@ -66,7 +68,10 @@ export type DeckSchedule = { segments: DeckSegment[]; moves?: FootageMove[]; }; -export type PostSlot = { id: string; segment: string; slot: number; of: number; appear: number; out: [number, number] }; +/** A placed post: the popup's `appear` and `out`, or the feed's tick-in, `in`. */ +export type PostSlot = { id: string; segment: string; slot: number; of: number; appear?: number; out?: [number, number]; in?: number }; +/** The posts feed's preview: the column, the footage box beside it, each built segment's framing box, the composition. */ +export type FeedPreview = { geometry: Rect; footage: Rect; boxes: Record<string, Rect>; src?: string; error?: string }; /** One posts window's preview composition: `src` when it composed, `error` when it did not. */ export type PostsWindow = { segment: string; from: number; to: number; src?: string; error?: string }; export type DeckPreviewDoc = { @@ -78,6 +83,8 @@ export type DeckPreviewDoc = { schedule: DeckSchedule & { posts?: PostSlot[] }; /** The posts region: where it sits in the frame, and one composition per window. */ posts?: { geometry: Rect; windows: PostsWindow[] }; + /** The posts feed, when the cut is one: a single composition for the whole cut. */ + feed?: FeedPreview; }; /** An unsaved change to a post, as PUT /api/report/posts takes it. */ export type PostPatch = { attachTo?: string | null; hide?: boolean }; @@ -251,6 +258,7 @@ export function DeckFrame({ /** Where the footage goes, drawn as a box: the backdrop when there is no picture. */ export function NeutralFrame({ geometry: g, label = "footage" }: { geometry: DeckGeometry; label?: string }) { + // (`geometry.footage` is the feed's box for a feed cut: the caller passes it.) const pct = (n: number, of: number) => `${(n / of) * 100}%`; return ( <div className="absolute inset-0 bg-[#0d0f14]" data-testid="onscreen-neutral-frame"> @@ -378,6 +386,94 @@ export function PostsOverlay({ } // --------------------------------------------------------------------------- +// FeedOverlay: the posts FEED's composition (`posts.layout: "feed"`), one for +// the whole cut, at the column's rect inside the 16:9 frame for every moment +// of the scrub. PostsOverlay's contract with one message renamed: the page +// posts `{type: "feed:ready"}` once it can be seeked and takes `deck:seek` in +// the cut's clock. Laid out at its own pixel size and scaled. +// --------------------------------------------------------------------------- +export function FeedOverlay({ feed, frame: g, t }: { feed: FeedPreview; frame: DeckGeometry; t: number }) { + const box = useRef<HTMLDivElement | null>(null); + const el = useRef<HTMLIFrameElement | null>(null); + const [width, setWidth] = useState(0); + const [readySrc, setReadySrc] = useState<string | null>(null); + const src = feed.src ? `${feed.src}${feed.src.includes("?") ? "&" : "?"}preview=1` : null; + const geometry = feed.geometry; + const pct = (n: number, of: number) => `${(n / of) * 100}%`; + + useEffect(() => { + const node = box.current; + if (!node) return; + const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width)); + ro.observe(node); + return () => ro.disconnect(); + }, []); + + useEffect(() => { + const onMsg = (e: MessageEvent) => { + if (e.source !== el.current?.contentWindow || e.origin !== window.location.origin) return; + if ((e.data as { type?: string } | null)?.type === "feed:ready") setReadySrc(src); + }; + window.addEventListener("message", onMsg); + return () => window.removeEventListener("message", onMsg); + }, [src]); + + const live = !!src && readySrc === src; + useEffect(() => { + if (live) el.current?.contentWindow?.postMessage({ type: "deck:seek", t }, window.location.origin); + }, [live, t]); + + const scale = width > 0 ? width / geometry.width : 0; + return ( + <div + ref={box} + data-testid="onscreen-feed-preview" + data-feed-ready={live ? "1" : "0"} + className="pointer-events-none absolute" + style={{ + left: pct(geometry.x, g.W), + top: pct(geometry.y, g.H), + width: pct(geometry.width, g.W), + height: pct(geometry.height, g.H), + }} + > + {src ? ( + <iframe + ref={el} + key={src} + src={src} + title="posts feed preview" + data-testid="onscreen-feed-preview-iframe" + tabIndex={-1} + aria-hidden + style={{ + position: "absolute", + left: 0, + top: 0, + width: geometry.width, + height: geometry.height, + transform: `scale(${scale})`, + transformOrigin: "0 0", + border: 0, + background: "transparent", + colorScheme: "normal", + visibility: scale > 0 ? "visible" : "hidden", + }} + /> + ) : ( + <div + data-testid="onscreen-feed-preview-error" + className="absolute inset-0 flex items-start justify-center border border-dashed border-[var(--color-dirty)] p-2 text-center text-[11px] text-[var(--color-dirty)]" + title={feed.error} + > + <span className="rounded bg-black/70 px-1.5 py-0.5">posts feed: not composed — {feed.error}</span> + </div> + )} + </div> + ); +} + +// --------------------------------------------------------------------------- // The settings form: every key of render.chrome.deck, flattened. // --------------------------------------------------------------------------- @@ -393,6 +489,7 @@ type DeckSettings = { motion: { out: number; in: number; pip: number }; posts: { show: boolean; + layout: string; seconds: number; hold: number; position: string; @@ -456,6 +553,7 @@ const GROUPS: { name: string; fields: Field[] }[] = [ name: "posts", fields: [ { key: "posts.show", label: "show", kind: "bool", hint: "off leaves every post out of the cut" }, + { key: "posts.layout", label: "layout", kind: "select", options: ["popup", "feed"], hint: "popup: cards at the end of each clip that carries them (held, footage moved aside); feed: a column beside the footage for the whole cut, each post ticking in as its clip starts — never held; segments are rebuilt into the feed's box" }, { key: "posts.seconds", label: "seconds", kind: "num", step: 0.5, hint: "s, 0.5–10: each post alone before the next stacks on" }, { key: "posts.hold", label: "hold", kind: "num", step: 0.5, hint: "s, 0–10: the clip that carries posts is held on its last frame, silent, so the last post can be read; part of the cut's length" }, { key: "posts.position", label: "side", kind: "select", options: ["top-right", "top-left"], hint: "the side the column hangs from: the frame's edge when making room, else the footage's" }, @@ -582,7 +680,7 @@ type PostRow = { hide: boolean; auto: PostWhere | null; effective: PostWhere | null; - timing: { segment: string; slot: number; of: number; appear: number; out: [number, number] } | null; + timing: { segment: string; slot: number; of: number; appear?: number; out?: [number, number]; in?: number } | null; }; type ClipOption = { id: string; label: string; day: string | null }; /** A post's row as the form holds it: `attachTo` "" is the automatic clip. */ @@ -1056,14 +1154,24 @@ export default function OnscreenSection({ // footage in its box -- is clipped to that box and carried to where the // build puts it at this moment, on the build's curve. const move = schedule ? footageAt(schedule, t) : null; + // The posts FEED: the footage sits in the feed's box for the whole cut. A + // backdrop built for another box (the deck's, before the feed was switched + // on) is carried into it, as a move would be; the neutral frame is drawn there. + const feed = preview?.feed ?? null; + const feedFrom = feed && backdropId ? feed.boxes[backdropId] ?? preview!.geometry.footage : null; + const reframe = + feed && feedFrom && backdropTransform(feedFrom, feed.footage, preview!.geometry) !== "none" + ? { from: feedFrom, rect: feed.footage } + : null; + const shiftBox = move ?? reframe; const backdropStyle: React.CSSProperties | undefined = - move && preview + shiftBox && preview ? (() => { const { W, H } = preview.geometry; - const f = move.from; + const f = shiftBox.from; const pc = (v: number, of: number) => `${(v / of) * 100}%`; return { - transform: backdropTransform(move.from, move.rect, preview.geometry), + transform: backdropTransform(shiftBox.from, shiftBox.rect, preview.geometry), transformOrigin: "0 0", clipPath: `inset(${pc(f.y, H)} ${pc(W - f.x - f.width, W)} ${pc(H - f.y - f.height, H)} ${pc(f.x, W)})`, }; @@ -1370,10 +1478,10 @@ export default function OnscreenSection({ type="button" data-testid="onscreen-post-jump" className="num font-mono text-[var(--color-sel)] hover:underline" - onClick={() => setT(Math.min(schedule.total, Math.round((p.timing!.appear + 0.25) * 1000) / 1000))} + onClick={() => setT(Math.min(schedule.total, Math.round(((p.timing!.in ?? p.timing!.appear ?? 0) + (p.timing!.in != null ? 1.5 : 0.25)) * 1000) / 1000))} title="show this post in the preview" > - at {clock(p.timing.appear)} + at {clock(p.timing.in ?? p.timing.appear ?? 0)} </button> )} </> @@ -1475,12 +1583,13 @@ export default function OnscreenSection({ <div className="min-w-0 space-y-2"> {preview ? ( <DeckFrame preview={preview} t={t} texts={texts} testid="onscreen-preview"> - {move && <div className="absolute inset-0" style={{ background: preview.background ?? "#000" }} />} + {shiftBox && <div className="absolute inset-0" style={{ background: preview.background ?? "#000" }} />} <div className="absolute inset-0" data-testid="onscreen-backdrop-frame" data-move={move ? move.segment : ""} data-move-progress={move ? String(Math.round(move.progress * 1000) / 1000) : ""} + data-reframed={reframe ? "1" : "0"} style={backdropStyle} > {backdropId ? ( @@ -1495,9 +1604,13 @@ export default function OnscreenSection({ className="absolute inset-0 h-full w-full object-contain" /> ) : ( - <NeutralFrame geometry={preview.geometry} label={current ? `${current.id} · no segment built` : "footage"} /> + <NeutralFrame + geometry={feed ? { ...preview.geometry, footage: feed.footage } : preview.geometry} + label={current ? `${current.id} · no segment built` : "footage"} + /> )} </div> + {feed && <FeedOverlay key={feed.src ?? "none"} feed={feed} frame={preview.geometry} t={t} />} {postsWin && preview.posts && ( <PostsOverlay key={`${postsWin.segment}:${postsWin.src ?? "none"}`} diff --git a/umtool/docs/quirks.md b/umtool/docs/quirks.md @@ -244,6 +244,39 @@ 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. +**A backslash in a page's script is a template literal's first.** The page +modules write their runtime script inside a JS template literal, where `\s` +is not an escape and becomes a plain `s`: the popup's `replace(/\s+$/, "")` +reaches the page as `replace(/s+$/, "")` (it strips trailing letters s, not +spaces, in the one case it runs: a clamped card whose paragraphs were dropped +after one that fit exactly). The feed's page writes `\\s`. Read in the +module the regex looks right; only the composed page shows the wrong one. + +**Switching the posts layout moves the footage, so it is a rebuild, not a +re-render.** The feed frames every footage segment into its own box when the +segment is built; the popup frames into the deck's. `--chrome-only` lays +chrome over the segments on disk, so over segments framed for the other +layout it would draw the column over footage (or leave a gap where it +expected some). Each framed segment's `<id>.cut.json` records `framing: +{ layout, box }`, and `--chrome-only` / `--chrome-preview` refuse, by name, +any segment whose box is not the cut's; a record-less segment was built for +the deck's box. A normal build with `--skip-fetch` reframes from the cached +windows. + +**The build-trace check reads a template literal nested in another's `${}` as +the end of the string.** `scripts/next-build-trace.test.mjs` scans each module +for path and fs calls on `import.meta.url`-derived values. One +`` `${a} (${ok ? `x ${b}` : `y`})` `` in build-video threw its string tracking +out of step, and it then read the rest of the file as one call reaching the +`import.meta.url` of the CLI guard: 69 false findings. Hoist the inner +template to a const; nothing at run time changes. + +**A whole-cut overlay is laid like the deck's, whatever it draws.** The feed's +column is a second full-length sequence; it takes the deck's `-reinit_filter +0`, `format=rgba` and `shortest=1` (its frames mix RGB and RGBA like every +HyperFrames sequence), not the posts windows' `-itsoffset` and +`eof_action=pass`, and it must be exactly as many frames as the deck's. + **`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 diff --git a/umtool/e2e/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs @@ -1547,6 +1547,34 @@ const ONSCREEN_POSTS = writeProject( }, ); +// onscreen-feed-fixture: the posts FEED (`posts.layout: "feed"`), BUILT by +// onscreen-posts.spec.ts -- every segment framed into the feed's box, one feed +// sequence for the whole cut from the stub renderer, no hold -- then refused +// a --chrome-only once the layout says popup. Clips only, as the build +// fixture above: a card needs Pango. +const ONSCREEN_FEED = (() => { + const m = deckManifest("onscreen-feed-fixture", "The On-screen Feed Fixture", [ + { type: "clip", id: "c01", video: "vid1", start: 3.0, end: 6.0, cite: 3, section: 0, lock: true, quote: "and because", date: "2024-09-03" }, + { type: "clip", id: "c02", video: "vid1", start: 9.0, end: 12.0, cite: 9, section: 0, lock: true, quote: "another whole sentence", date: "2024-09-10" }, + ]); + m.render.chrome.deck = { posts: { layout: "feed" } }; + return writeProject("onscreen-feed-fixture", { + ...m, + posts: [ + { + id: "f-one", platform: "bluesky", author: "Fixture Author", handle: "fixture.example", + date: "2024-09-05T09:30:00.000Z", text: "Rides on the first clip, in from its start.", + url: "https://bsky.app/profile/fixture.example/post/one", + }, + { + id: "f-two", platform: "bluesky", author: "Fixture Author", handle: "fixture.example", + date: "2024-09-12T18:00:00.000Z", text: "Rides on the second clip.", + url: "https://bsky.app/profile/fixture.example/post/two", + }, + ], + }); +})(); + mkdirSync(path.join(reports, "bike-fixture"), { recursive: true }); writeFileSync( path.join(reports, "bike-fixture", "sweep-report.md"), @@ -1586,7 +1614,7 @@ ff([ // intermediates and are excluded by name. mkdirSync(path.join(reports, "no-origin-fixture", "out"), { recursive: true }); -for (const dir of [BENCH, BUILD, ONSCREEN, ONSCREEN_BUILD, ONSCREEN_POSTS]) { +for (const dir of [BENCH, BUILD, ONSCREEN, ONSCREEN_BUILD, ONSCREEN_POSTS, ONSCREEN_FEED]) { mkdirSync(path.join(dir, "out", "clips-raw"), { recursive: true }); copyFileSync( path.join(REPORT, "out", "clips-raw", "vid1_0.00-9.00.mp4"), @@ -1708,5 +1736,6 @@ console.log(` longform-fixture (cue gap, legacy .bak, ffmeta), longfo console.log(` deliver-fixture (writable: a01/a02 to cut, a03 unfetched, b01 shared, b02 incorrect, b03 unjudged)`); console.log(` onscreen-fixture (writable, deck on, unbuilt), onscreen-build-fixture (built with the deck)`); console.log(` onscreen-posts-fixture (writable, deck on, three posts, unbuilt)`); +console.log(` onscreen-feed-fixture (writable, posts feed, built by the spec)`); console.log(` deliver-stop-fixture (writable: six confirmed clips to cut, for Stop and resume)`); console.log(` ${taken} candidate files copied, 2 mix tracks synthesised`); diff --git a/umtool/e2e/onscreen-posts.spec.ts b/umtool/e2e/onscreen-posts.spec.ts @@ -1,8 +1,9 @@ import { test, expect, type APIRequestContext, type Locator, type Page } from "@playwright/test"; -import { readFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { deckGeometry, postWindows, postsGeometry, shiftedFootage } from "umtool-report-to-video/deck"; +import { deckGeometry, feedGeometry, postWindows, postsGeometry, shiftedFootage } from "umtool-report-to-video/deck"; // --------------------------------------------------------------------------- // POSTS on the on-screen deck, as umtool edits them: the Posts table under the @@ -82,12 +83,14 @@ const reset = async (request: APIRequestContext) => { type Rect = { x: number; y: number; width: number; height: number }; type Preview = { schedule: { + layout?: string; total: number; segments: { id: string; start: number; duration: number; end: number; hold?: number }[]; - posts: { id: string; segment: string; appear: number; out: [number, number] }[]; + posts: { id: string; segment: string; appear: number; out: [number, number]; in?: number }[]; moves?: { segment: string; at: number; seconds: number; from: Rect; to: Rect }[]; }; posts: { geometry: Rect; windows: { segment: string; from: number; to: number; src?: string; error?: string }[] }; + feed?: { geometry: Rect; footage: Rect; boxes: Record<string, Rect>; src?: string; error?: string }; }; const previewOf = async (request: APIRequestContext, extra: Record<string, unknown> = {}) => (await (await request.post("/api/report/chrome/preview", { data: { project: PROJECT, ...extra } })).json()) as Preview; @@ -445,3 +448,181 @@ test("make room off in the settings writes shift: false, the form keeps it, and await page.getByTestId("onscreen-settings-save").click(); await expect.poll(() => (readManifest().render.chrome as { deck: unknown }).deck).toEqual({ posts: { hold: 1.5 } }); }); + +// ---- the posts FEED (`posts.layout: "feed"`) ----------------------------------- +// +// The same fixture with the layout switched to feed: no hold and no move, each +// post in at its clip's start after the 0.2 s dissolve (two on c01 share what +// c01 has before its leave), and ONE composition for the whole cut, laid at +// the column for every moment of the scrub, with the footage in the feed's +// box beside it. Estimated: +// c01 0 → 3 p-early in at 0.2, p-mid at 1.5 (2.6 s shared by two) +// c02 2.8 → 5.8 p-late in at 3.0 +// k01 5.6 → 8.6 + +test("the feed layout: the switch writes posts.layout, posts tick in at their clip's start, and the preview lays the feed over the whole cut", async ({ + page, + request, +}) => { + await openSection(page); + await expect(page.getByTestId("onscreen-preview")).toHaveAttribute("data-deck-ready", "1", { timeout: 30_000 }); + const layout = page.getByTestId("onscreen-setting-posts.layout"); + await expect(layout).toHaveValue("popup"); + await layout.selectOption("feed"); + await page.getByTestId("onscreen-settings-save").click(); + await expect.poll(() => (readManifest().render.chrome as { deck: unknown }).deck).toEqual({ posts: { layout: "feed" } }); + + // The route: the feed's schedule and its composition, no windows. + const render = readManifest().render; + const g = feedGeometry(render); + const pv = await previewOf(request); + expect(pv.schedule.layout).toBe("feed"); + expect(pv.schedule.segments.map((s) => [s.id, s.start, s.duration, s.hold ?? 0])).toEqual([ + ["c01", 0, 3, 0], + ["c02", 2.8, 3, 0], + ["k01", 5.6, 3, 0], + ]); + expect(pv.schedule.total).toBe(8.6); + expect(pv.schedule.posts.map((p) => [p.id, p.segment, p.in])).toEqual([ + ["p-early", "c01", 0.2], + ["p-mid", "c01", 1.5], + ["p-late", "c02", 3], + ]); + expect(pv.schedule.moves).toBeUndefined(); + expect(pv.posts.windows).toEqual([]); + expect(pv.feed?.error).toBeUndefined(); + expect(pv.feed?.src).toMatch(/\/feed-preview\/index\.html\?v=\d+$/); + expect(pv.feed?.geometry).toEqual(g.column); + expect(pv.feed?.footage).toEqual(g.footage); + expect(g.column).toEqual({ x: 1320, y: 0, width: 600, height: 890 }); + // Never built: every segment's box is the deck's, which the preview carries into the feed's. + expect(pv.feed?.boxes.c01).toEqual(deckGeometry(render).footage); + // The page is served and draws a card per post. + const html = await (await request.get(pv.feed!.src!)).text(); + expect(html).toContain('data-composition-id="feed"'); + expect((html.match(/<article class="post"/g) ?? []).length).toBe(3); + + // "rides on" still moves a post, and its tick-in with it. + await putPosts(request, { "p-mid": { attachTo: "c02" } }); + const moved = await previewOf(request); + expect(moved.schedule.posts.map((p) => [p.id, p.segment, p.in])).toEqual([ + ["p-early", "c01", 0.2], + ["p-mid", "c02", 3], + ["p-late", "c02", 4.3], + ]); + const r = await rows(request); + expect(r["p-mid"].effective).toMatchObject({ entryId: "c02", rule: "attachTo" }); + await putPosts(request, { "p-mid": { attachTo: null } }); + + // The page: the feed's overlay for the whole cut -- before any post, among + // them, and over the card -- at the column's rect, ready. + await page.reload(); + await expect(page.getByTestId("onscreen-preview")).toHaveAttribute("data-deck-ready", "1", { timeout: 30_000 }); + const feed = page.getByTestId("onscreen-feed-preview"); + await expect(feed).toHaveAttribute("data-feed-ready", "1", { timeout: 30_000 }); + await expect(page.getByTestId("onscreen-feed-preview-iframe")).toHaveAttribute("src", /feed-preview\/index\.html/); + await expect(page.getByTestId("onscreen-posts-preview")).toHaveCount(0); + await expect(page.locator("[data-posts-window]")).toHaveCount(0); + await expect(page.getByTestId("onscreen-segment-hold")).toHaveCount(0); + await expect(page.getByTestId("onscreen-scrubber")).toHaveAttribute("max", "8.6"); + const W = 1920, H = 1080; + for (const t of [0.05, 2, 7]) { + await seek(page, t); + await expect(feed).toBeVisible(); + const frame = (await page.getByTestId("onscreen-preview").boundingBox())!; + const box = (await feed.boundingBox())!; + expect(Math.abs((box.x - frame.x) / frame.width - g.column.x / W)).toBeLessThan(0.01); + expect(Math.abs(box.width / frame.width - g.column.width / W)).toBeLessThan(0.01); + expect(Math.abs(box.height / frame.height - g.column.height / H)).toBeLessThan(0.01); + } + // The posts table's jump lands just after the post is in. + await expect(postRow(page, "p-late").getByTestId("onscreen-post-jump")).toHaveText("at 0:03.0"); + // The footage is drawn in the feed's box: no segment is built, so the + // neutral frame's box sits beside the column. + await seek(page, 2); + const neutral = page.getByTestId("onscreen-neutral-frame").locator("div").first(); + const frame = (await page.getByTestId("onscreen-preview").boundingBox())!; + const nb = (await neutral.boundingBox())!; + expect(Math.abs((nb.x - frame.x) / frame.width - g.footage.x / W)).toBeLessThan(0.01); + expect(Math.abs(nb.width / frame.width - g.footage.width / W)).toBeLessThan(0.01); + expect(Math.abs((nb.y - frame.y) / frame.height - g.footage.y / H)).toBeLessThan(0.01); +}); + +// ---- a feed cut, built ------------------------------------------------------------- + +const FEED_PROJECT = "reports/onscreen-feed-fixture"; +const FEED_DIR = path.join(FIXTURE, "reports", "onscreen-feed-fixture"); +const FEED_OUT = path.join(FEED_DIR, "out", "sourced"); +type Job = { id: string; state: string; error: string | null; log?: string[]; events: { ev: string; phase?: string; region?: string }[] }; + +async function waitForJob(request: APIRequestContext, id: string, ms = 180_000): Promise<Job> { + const until = Date.now() + ms; + while (Date.now() < until) { + const j = (await (await request.get(`/api/report/build?job=${id}`)).json()) as Job; + if (j.state !== "running") return j; + await new Promise((r) => setTimeout(r, 400)); + } + throw new Error("the job never finished"); +} + +test("a feed cut builds: every clip framed into the feed's box, one feed sequence for the whole cut, never held; --chrome-only over another layout's segments is refused", async ({ + request, +}) => { + test.setTimeout(300_000); + const feedManifest = () => JSON.parse(readFileSync(path.join(FEED_DIR, "video.manifest.json"), "utf8")) as Manifest; + const tokenOf = async () => + ((await (await request.get(`/api/report/chrome?project=${enc(FEED_PROJECT)}`)).json()) as { token: string }).token; + const putFeedChrome = async (chrome: unknown) => { + const r = await request.put("/api/report/chrome", { data: { project: FEED_PROJECT, chrome, token: await tokenOf() } }); + expect(r.ok(), await r.text()).toBeTruthy(); + }; + await putFeedChrome({ engine: "hyperframes", layout: "deck", deck: { posts: { layout: "feed" } } }); + + const start = await request.post("/api/report/build?replace=1", { data: { project: FEED_PROJECT, preset: "fast" } }); + expect(start.ok(), await start.text()).toBeTruthy(); + const built = await waitForJob(request, ((await start.json()) as { job: Job }).job.id); + expect(built.state, `${built.error ?? ""}\n${built.log?.slice(-20).join("\n")}`).toBe("done"); + // The feed is composed and rendered (by the stub) beside the deck. + expect(built.events.filter((e) => e.ev === "chrome" && e.region === "feed").map((e) => e.phase)).toEqual( + expect.arrayContaining(["compose", "render"]), + ); + + const render = feedManifest().render; + const g = feedGeometry(render); + const schedule = JSON.parse(readFileSync(path.join(FEED_OUT, "schedule.json"), "utf8")) as Preview["schedule"] & { fps: number; transition: number }; + expect(schedule.layout).toBe("feed"); + expect(schedule.segments.some((s) => (s.hold ?? 0) > 0)).toBe(false); + expect(schedule.moves).toBeUndefined(); + // Each post is in at its clip's start, after the incoming transition. + for (const p of schedule.posts) { + const seg = schedule.segments.find((s) => s.id === p.segment)!; + expect(p.in).toBeCloseTo(seg.start + schedule.transition, 3); + } + // One sequence for the whole cut, as long as the deck's. + const count = (dir: string) => readdirSync(dir).filter((f) => /^frame_\d{6}\.png$/.test(f)).length; + const frames = Math.round(schedule.total * schedule.fps); + expect(count(path.join(FEED_OUT, "chrome", "feed-frames"))).toBe(frames); + expect(count(path.join(FEED_OUT, "chrome", "deck-frames"))).toBe(frames); + expect(existsSync(path.join(FEED_OUT, "chrome", "posts-c01-frames"))).toBe(false); + // Every clip framed into the feed's box, and its record says so. + for (const id of ["c01", "c02"]) { + const rec = JSON.parse(readFileSync(path.join(FEED_OUT, "segments", `${id}.cut.json`), "utf8")); + expect(rec.framing).toEqual({ layout: "feed", box: g.footage }); + } + // The final is as long as the schedule: nothing held. + const final = path.join(FEED_DIR, "out", "onscreen-feed-fixture.mp4"); + const secs = Number(execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", final]).toString().trim()); + expect(Math.abs(secs - schedule.total)).toBeLessThan(2 / schedule.fps); + + // The layout switched back to popup: the segments are framed for the feed, + // so laying the deck over them alone is refused, by name. + await putFeedChrome({ engine: "hyperframes", layout: "deck", deck: {} }); + const again = await request.post("/api/report/build?replace=1", { + data: { project: FEED_PROJECT, preset: "fast", options: { chromeOnly: true } }, + }); + expect(again.ok(), await again.text()).toBeTruthy(); + const refused = await waitForJob(request, ((await again.json()) as { job: Job }).job.id); + expect(refused.state).toBe("failed"); + expect(`${refused.error ?? ""}\n${(refused.log ?? []).join("\n")}`).toMatch(/framed for another layout than this cut's deck/); + await putFeedChrome({ engine: "hyperframes", layout: "deck", deck: { posts: { layout: "feed" } } }); +}); diff --git a/umtool/lib/report/onscreen.mjs b/umtool/lib/report/onscreen.mjs @@ -22,6 +22,7 @@ import { selectVariant } from "umtool-report-to-video/build-video"; import { attachPosts, clipDay, + deckGeometry, deckText, estimateSchedule, footageMoves, @@ -30,6 +31,7 @@ import { postSchedule, postWindows, resolveDeck, + roundPosts, } from "umtool-report-to-video/deck"; import { channelsDirFor, @@ -39,7 +41,7 @@ import { readCues, } from "../projects/report.mjs"; import { normalizePostPatches } from "./manifest.mjs"; -import { deckPreviewDir, postsPreviewDir } from "./serve.mjs"; +import { deckPreviewDir, feedPreviewDir, postsPreviewDir } from "./serve.mjs"; /** The schedule document deck.mjs defines, built or estimated. */ /** @typedef {ReturnType<typeof estimateSchedule>} DeckSchedule */ @@ -159,7 +161,7 @@ export function previewSchedule({ variantManifest, built, draft, metas, postsDra const deck = resolveDeck(render); const provenance = variantManifest.provenance ?? {}; const patchedEntries = entries.map(patched); - const { posts: _builtPosts, moves: _builtMoves, ...rest } = built; + const { posts: _builtPosts, moves: _builtMoves, layout: _builtLayout, ...rest } = built; const round = (v) => Math.round(v * 1000) / 1000; const holds = deck.posts.show ? postHolds({ posts, entries: patchedEntries, metas, render }) : new Map(); let shift = 0; @@ -182,9 +184,12 @@ export function previewSchedule({ variantManifest, built, draft, metas, postsDra const placed = deck.posts.show ? postSchedule({ posts, entries: patchedEntries, metas, segments, D: built.transition, total, render }) : []; - const moves = placed.length ? footageMoves({ posts: placed, segments, render }) : []; + // The layout as it is NOW: a feed (no holds, no moves) only with posts to draw. + const feed = placed.length > 0 && deck.posts.layout === "feed"; + const moves = placed.length && !feed ? footageMoves({ posts: placed, segments, render }) : []; return { ...rest, + ...(feed ? { layout: "feed" } : {}), total, segments: segments.map((s, i) => { const e = patchedEntries[i]; @@ -193,7 +198,7 @@ export function previewSchedule({ variantManifest, built, draft, metas, postsDra const keepBuilt = e.type === "clip" && e.onscreen?.subtitle === undefined && !meta; return { ...s, title, subtitle: keepBuilt ? s.subtitle : subtitle }; }), - ...(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) })) } : {}), }; } @@ -288,7 +293,10 @@ export function postRows({ variantManifest, metas, schedule = null }) { hide: p.hide === true, auto: where(auto.get(p.id)), effective: p.hide ? null : where(effective.get(p.id)), - timing: t ? { segment: t.segment, slot: t.slot, of: t.of, appear: t.appear, out: t.out } : null, + // A popup post's appear and leave, or a feed post's tick-in (`in`). + timing: t + ? { segment: t.segment, slot: t.slot, of: t.of, ...("in" in t ? { in: t.in } : { appear: t.appear, out: t.out }) } + : null, }; }), clips: entries @@ -378,6 +386,61 @@ export async function composeDeckPreview(project, variant, schedule) { } /** + * Compose the PREVIEW of the posts FEED (a schedule with `layout: "feed"`): + * compose-chrome's `feed` region, one project for the whole cut under + * out/<variant>/chrome/feed-preview/. No render. `compose` is injectable for + * the unit test; the routes never pass it. + * + * @param {{ dir: string }} project + * @param {string} variant + * @param {Record<string, any>} schedule + * @param {{ compose?: (args: Record<string, unknown>) => Promise<any> }} [opts] + */ +export async function composeFeedPreview(project, variant, schedule, { compose = composeChrome } = {}) { + const outDir = path.join(project.dir, "out", variant); + const want = feedPreviewDir(project.dir, variant); + return serialised(want, async () => { + /** @type {Record<string, unknown>} */ + const args = { manifestPath: manifestPath(project.dir), outDir, variant, region: "feed", schedule, preview: true }; + const r = await compose(/** @type {any} */ (args)); + if (r?.projDir && path.resolve(r.projDir) !== path.resolve(want)) { + throw new Error(`compose-chrome wrote the feed preview to ${r.projDir}, not ${want}`); + } + return r; + }); +} + +/** + * The box each built segment's footage was framed into, by entry id: the + * `framing` its cut record names (build-video's segmentFraming), else -- a + * segment built before records named it, or none built yet -- the deck's own + * box. The preview carries a backdrop built for one layout into the other's + * box (the feed switched on since the build). + * + * @param {{ dir: string }} project + * @param {string} variant + * @param {Array<{ id: string }>} entries + * @param {Record<string, any>} render + * @returns {Promise<Record<string, { x: number, y: number, width: number, height: number }>>} + */ +export async function segmentBoxes(project, variant, entries, render) { + const segs = path.join(project.dir, "out", variant, "segments"); + const deckBox = deckGeometry(render).footage; + const out = /** @type {Record<string, any>} */ ({}); + await Promise.all(entries.map(async (e) => { + // An id names a file here: only a plain one is read. + if (!/^[A-Za-z0-9_-][A-Za-z0-9_.-]*$/.test(String(e.id)) || String(e.id).includes("..")) { + out[e.id] = deckBox; + return; + } + const rec = await readFile(path.join(segs, `${e.id}.cut.json`), "utf8").then(JSON.parse, () => null); + const box = rec?.framing?.box; + out[e.id] = box && [box.x, box.y, box.width, box.height].every(Number.isFinite) ? box : deckBox; + })); + return out; +} + +/** * Compose the PREVIEW of the posts region for one window. No render. * * The posts region is compose-chrome's (`region: "posts"`, one project per diff --git a/umtool/lib/report/onscreen.test.mjs b/umtool/lib/report/onscreen.test.mjs @@ -10,6 +10,7 @@ import { postWindows } from "umtool-report-to-video/deck"; import { applyPostsDraft, clipLabel, + composeFeedPreview, composePostsPreview, composePostsPreviews, normalizeDraft, @@ -17,8 +18,11 @@ import { postRows, previewSchedule, scheduleMatches, + segmentBoxes, stillTimeOf, } from "./onscreen.mjs"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; const cut = () => ({ slug: "t", @@ -293,3 +297,90 @@ test("composePostsPreview: ONE call per window, region posts, preview, into post const failed = await composePostsPreviews(project, "sourced", schedule, { compose: failing }); assert.deepEqual(failed.map((w) => [w.ok, w.error]), [[false, "unknown chrome region: posts"], [false, "unknown chrome region: posts"]]); }); + +// ---- the posts FEED (`posts.layout: "feed"`) ------------------------------------ + +/** `cut()` with posts in the feed layout. */ +const feedy = (posts = POSTS) => { + const m = withPosts(posts); + m.render.chrome.deck = { posts: { layout: "feed" } }; + return m; +}; + +test("the feed: a popup build's holds come off, the posts tick in at their clip's start, no moves, no windows", () => { + // A build made under the popup, with holds; the manifest says feed now. + const popupBuilt = previewSchedule({ variantManifest: roomy(), built: built(), draft: new Map(), metas }); + assert.ok(popupBuilt.segments.some((x) => x.hold > 0)); + const s = previewSchedule({ variantManifest: feedy(), built: popupBuilt, draft: new Map(), metas }); + assert.equal(s.layout, "feed"); + // The segments are their probed lengths again (built()'s): no hold anywhere. + assert.deepEqual(s.segments.map((x) => [x.id, x.start, x.duration, x.hold ?? 0]), [ + ["k1", 0, 5, 0], ["c01", 4.5, 9.9, 0], ["c02", 13.9, 12, 0], + ]); + assert.equal(s.total, 25.9); + assert.ok(!("moves" in s)); + // c01 (in 5.0 after its 0.5 dissolve) carries p3 and p1, 4 s apart; c02 p2 at 14.4. + assert.deepEqual(s.posts.map((p) => [p.id, p.segment, p.in]), [["p3", "c01", 5], ["p1", "c01", 9], ["p2", "c02", 14.4]]); + assert.ok(s.posts.every((p) => !("appear" in p))); + assert.deepEqual(postWindows(s), []); + // Back to the popup: no layout key, the holds again. + const back = previewSchedule({ variantManifest: roomy(), built: s, draft: new Map(), metas }); + assert.ok(!("layout" in back)); + assert.ok(back.segments.some((x) => x.hold > 0)); + // The estimate says the same about the layout. + assert.equal(previewSchedule({ variantManifest: feedy(), built: null, draft: new Map(), metas }).layout, "feed"); + // All posts hidden: a feed with nothing to draw is the deck alone. + const none = previewSchedule({ + variantManifest: feedy(), built: s, draft: new Map(), metas, + postsDraft: { p1: { hide: true }, p2: { hide: true }, p3: { hide: true } }, + }); + assert.ok(!("layout" in none) && !("posts" in none)); +}); + +test("the feed: the posts table's timing is the tick-in", () => { + const m = feedy(); + const schedule = previewSchedule({ variantManifest: m, built: built(), draft: new Map(), metas }); + const by = Object.fromEntries(postRows({ variantManifest: m, metas, schedule }).posts.map((p) => [p.id, p])); + assert.deepEqual(by.p2.timing, { segment: "c02", slot: 0, of: 1, in: 14.4 }); + assert.equal(by.p2.effective.entryId, "c02"); +}); + +test("composeFeedPreview: compose-chrome's feed region, into feed-preview, refused anywhere else", async () => { + const calls = []; + const project = { dir: "/proj" }; + const schedule = previewSchedule({ variantManifest: feedy(), built: built(), draft: new Map(), metas }); + const compose = async (args) => { + calls.push(args); + return { projDir: path.join(args.outDir, "chrome", "feed-preview") }; + }; + await composeFeedPreview(project, "sourced", schedule, { compose }); + assert.deepEqual(calls[0], { + manifestPath: path.join("/proj", "video.manifest.json"), + outDir: path.join("/proj", "out", "sourced"), + variant: "sourced", + region: "feed", + schedule, + preview: true, + }); + await assert.rejects( + composeFeedPreview(project, "sourced", schedule, { compose: async () => ({ projDir: "/elsewhere" }) }), + /not \/proj\/out\/sourced\/chrome\/feed-preview/, + ); +}); + +test("segmentBoxes: each segment's recorded framing box, else the deck's; an odd id is never read", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "boxes-")); + try { + const segs = path.join(dir, "out", "sourced", "segments"); + await mkdir(segs, { recursive: true }); + const feedBox = { x: 24, y: 87, width: 1272, height: 716 }; + await writeFile(path.join(segs, "c01.cut.json"), JSON.stringify({ version: 1, framing: { layout: "feed", box: feedBox } })); + await writeFile(path.join(segs, "c02.cut.json"), JSON.stringify({ version: 1, start: 1, end: 2 })); + const render = { width: 1920, height: 1080, chrome: { engine: "hyperframes", layout: "deck", deck: {} } }; + const deckBox = { x: 173, y: 2, width: 1574, height: 886 }; + const boxes = await segmentBoxes({ dir }, "sourced", [{ id: "c01" }, { id: "c02" }, { id: "k1" }, { id: "../x" }], render); + assert.deepEqual(boxes, { c01: feedBox, c02: deckBox, k1: deckBox, "../x": deckBox }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/umtool/lib/report/serve.mjs b/umtool/lib/report/serve.mjs @@ -323,6 +323,19 @@ export async function deckPreviewFile(dir, segments) { const POSTS_PREVIEW_PREFIX = "posts-preview-"; +// The posts FEED's preview composition (`posts.layout: "feed"`): one project +// for the whole cut, out/<variant>/chrome/feed-preview/, served one segment +// deeper under the same prefix, `feed-preview/…`. +const FEED_PREVIEW = "feed-preview"; + +/** The preview project of a cut's posts feed. */ +export const feedPreviewDir = (projectDir, variant) => + path.join(projectDir, "out", variant, "chrome", FEED_PREVIEW); + +/** The iframe src for a cut's posts-feed preview composition. */ +export const feedPreviewSrc = (projectId, variant) => + `/api/report/chrome/files/${encodeProjectSegment(projectId)}/${variant}/${FEED_PREVIEW}/index.html`; + /** The preview project of the posts window on one segment. */ export const postsPreviewDir = (projectDir, variant, segment) => path.join(projectDir, "out", variant, "chrome", `${POSTS_PREVIEW_PREFIX}${segment}`); @@ -335,6 +348,7 @@ export const postsPreviewSrc = (projectId, variant, segment) => * Which preview directory a files request is for, and the segments left to * resolve inside it. * + * `feed-preview/…` is the posts feed's project (one per cut). * `posts-preview-<segment>/…` is a posts window's project when `<segment>` is * one of `segmentIds` -- the cut's own entry ids, which the caller reads from * the manifest, so a name the client made up is not a directory this serves. @@ -352,6 +366,7 @@ export const postsPreviewSrc = (projectId, variant, segment) => export function previewDirFor(projectDir, variant, rest, segmentIds) { if (!Array.isArray(rest) || !rest.length) return null; const head = rest[0]; + if (head === FEED_PREVIEW) return { dir: feedPreviewDir(projectDir, variant), rest: rest.slice(1) }; if (typeof head === "string" && head.startsWith(POSTS_PREVIEW_PREFIX)) { const seg = head.slice(POSTS_PREVIEW_PREFIX.length); if (!seg || /[\/\\\0]/.test(seg) || seg === "." || seg === "..") return null; diff --git a/umtool/lib/report/serve.test.mjs b/umtool/lib/report/serve.test.mjs @@ -12,6 +12,8 @@ import test from "node:test"; import { decodeProjectSegment, deckPreviewDir, + feedPreviewDir, + feedPreviewSrc, deckPreviewFile, deckPreviewSrc, encodeProjectSegment, @@ -188,6 +190,15 @@ async function realDir(p) { } +test("previewDirFor: feed-preview/ is the posts feed's project", () => { + assert.deepEqual(previewDirFor("/p", "sourced", ["feed-preview", "assets", "qr00.png"], []), { + dir: feedPreviewDir("/p", "sourced"), + rest: ["assets", "qr00.png"], + }); + assert.equal(feedPreviewDir("/p", "full"), path.join("/p", "out", "full", "chrome", "feed-preview")); + assert.match(feedPreviewSrc("reports/x", "sourced"), /\/sourced\/feed-preview\/index\.html$/); +}); + test("previewDirFor: posts-preview-<segment> is a window's project only for an entry of the cut", () => { const ids = ["c01", "c02", "k1"]; assert.deepEqual(previewDirFor("/p", "sourced", ["posts-preview-c02", "index.html"], ids), { diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md @@ -239,7 +239,8 @@ 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": 4, "hold": 2.5, // the manifest's `posts` (below); seconds 0.5–10; hold 0–10 (0: none) + "posts": { "show": true, "layout": "popup", // | "feed": a column beside the footage for the whole cut (below) + "seconds": 4, "hold": 2.5, // the manifest's `posts` (below); seconds 0.5–10; hold 0–10 (0: none; the feed never holds) "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 @@ -1173,6 +1174,66 @@ node umtool/report-to-video/compose-chrome.mjs <manifest.json> --region posts -- under three frames of still picture there (0.5 s under a 0.5 s crossfade) is reported as not checked. +### The posts feed (`posts.layout: "feed"`) + +The other way to draw the posts: a column for the WHOLE cut beside the +footage, instead of cards over the end of each clip. The deck stays full +width at the bottom; the column stands on it from the frame's top, so the two +read as one L-shaped surface around the picture. Nothing pauses and nothing +moves: no hold, no footage move, and the cut is as long as its segments. + +- **Geometry** (`feedGeometry`): the column is `posts.width` (600) wide, flush + with the `position` side's edge and the frame's top, down to the deck's top + edge — 600×890 at (1320, 0) at 1920×1080. The footage keeps the frame's + aspect and is as large as fits beside it with `posts.inset` (24) clear on + every side, centred: 1272×716 at (24, 87), 66 % of the frame's width against + the deck alone's 82 %. A feed whose footage would be under half the frame's + width is refused (`postsFitErrors`). +- **Framing.** Every footage segment of a feed cut — clips, stills, cards with + `overCards: "show"` — is framed into that box when it is built + (`deckFraming(render, { feed: true })`), for the whole cut. A cut is a feed + (`feedOn`) when the deck is on, `posts.show`, `layout: "feed"`, and at least + one post is drawn on a clip of the cut's whole timeline (an `--only` rebuild + frames as the full build would). Each framed segment's `<id>.cut.json` + records `framing: { layout, box }`; a still's and a card's record holds only + that. +- **Switching the layout reframes every segment**, so it takes a normal build + (with `--skip-fetch` it re-cuts from the cached windows). `--chrome-only` and + `--chrome-preview` read the records first and refuse segments framed for the + other layout, naming each one (`framingProblems`); a segment with no + framing record was built for the deck's box. +- **When** (`postSchedule`): a post has one time, `in` — its clip's start + after the incoming dissolve (start + D); a clip's next posts follow + `seconds` apart, or closer on a clip too short for that, so the last is in at + least that long before the clip leaves. Once in, a post stays. The schedule + says `layout: "feed"` and carries each post's `in` — only when there are + posts to draw, so a popup schedule and one without posts are byte-identical + to what they were. `postWindows` is empty for a feed. +- **The column** (`chrome-feed.mjs`, one composition for the whole cut, + region-local at the column, transparent outside its panel): a header (the + platform and `@handle`, or "Posts" over several authors, a count, "posts as + the timeline reaches them"), then an empty state until the first post. Each + post TICKS IN at the top, newest first: the cards already in slide down by + its height over `push` while it enters from the column's outer edge, its + accent rim flares and settles to a lit rail, which goes out when the next + one arrives. A card pushed past the column's bottom fades out as it goes — + the oldest scroll out. Over a segment the deck hides for (a full-frame card, + the teaser) the column slides out of the frame's side with the deck and + back after (`deckChoreography`'s visibility). Cards are the popup's design + sized for the column: 24 px words on 33 px lines, `maxLines`, the post's + date (the handle and platform too when there is more than one author), a + `qrSize` QR in its own cell. The plan (`feedCues`) runs in the page on the + measured card heights, embedded by `embedFn`, like the popup's. +- **Files:** `compose-chrome.mjs <manifest> --region feed` — project + `chrome/feed/`, frames `chrome/feed-frames/` and their `.key`, a window + `chrome/feed-from<s>[-frames]`, the preview `chrome/feed-preview/` (never + rendered); cached exactly as the deck's. The build renders it after the + deck, checks it is as many frames as the deck's, and lays it the deck's way + (`-reinit_filter 0`, `format=rgba`, `shortest=1`) in the crossfade concat, + the hard-cut `applyChrome` pass and `--chrome-preview`. +- **`verify-build`** checks `chrome/feed-frames` holds the whole cut's frames + and that the schedule holds and moves nothing. + ### The hold and the move, where the cut is joined `segmentJoins(schedule)` turns the schedule's holds and `moves` into one join 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,62 @@ 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)); +} + +/** A framing layout as a refusal names it. */ +const layoutName = (layout) => (layout === "feed" ? "posts feed" : "deck"); + +/** + * 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; + const why = got ? `framed for the ${got.layout}, ${where(box)}` : `no framing record: built for the deck's ${where(box)}`; + wrong.push(`${e.id} (${why})`); + }); + if (!wrong.length) return []; + return [ + `${wrong.length} segment(s) are framed for another layout than this cut's ` + + `${layoutName(want.layout)} (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 +749,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 +837,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 +1029,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 +1037,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 +1060,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 +1238,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 +1275,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 +1448,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 +1974,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 +2036,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 +2896,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 +3309,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 +3405,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 +3470,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 +3480,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 +3501,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,501 @@ +// 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, "&amp;") + .replace(/</g, "&lt;") + .replace(/>/g, "&gt;") + .replace(/"/g, "&quot;") + .replace(/'/g, "&#39;"); + +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, front-loaded, so the room is open (≈ 90 %) before the card comes + * in over it -- the two never overlap on screen. 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.4, lag: 0.22, 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.4, lag = 0.22, 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] }, "power3.out", `push for ${id}`); + continue; + } + add(`c${j}`, t, Math.max(push, leave), { y: y[j], autoAlpha: 0 }, "power2.out", `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("&lt;/script&gt;&lt;script&gt;alert(1)&lt;/script&gt;\nsecond line &amp; &#39;quotes&#39;")); + assert.ok(html.includes("@&quot;&gt;&lt;img src=x onerror=alert(1)&gt;")); + 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`); }