Archilyzer · Source

archilyzer

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

commit bb90538334cf3007912313fc490859bdfc45b6e2
parent 5b8b43cf0182427e9e42365742a8cac6109b38a5
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 16:59:42 -0400

umtool: the posts layout switch (popup | feed); the preview lays the feed over the whole cut and frames the footage in the feed's box

- The posts settings group gains `layout`.
- POST /api/report/chrome/preview composes the feed (compose-chrome's feed
  region, chrome/feed-preview) when the schedule is a feed's and returns
  `feed: { src, geometry, footage, boxes }`; the files route serves
  feed-preview/.
- The preview's schedule follows the layout as saved: a feed drops the
  popup's holds and moves, and the posts table's jump uses the tick-in.
- The live preview shows the feed's iframe at the column for every moment,
  carries a backdrop built for the deck's box into the feed's (each
  segment's box comes from its cut record), and draws the neutral frame in
  the feed's box.
- e2e: the switch, the feed's estimate and preview on the posts fixture, and
  a built feed fixture (stub renderer): every clip framed into the feed's
  box, one feed sequence for the whole cut, nothing held, and --chrome-only
  refused once the layout is popup.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mumtool/app/api/report/chrome/preview/route.ts | 31+++++++++++++++++++++++++++++--
Mumtool/components/projects/OnscreenSection.tsx | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
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+++++++++++
8 files changed, 550 insertions(+), 20 deletions(-)

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/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), {