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