import { test, expect, type APIRequestContext, type Locator, type Page } from "@playwright/test"; 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, 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 // titles, its override and hide, the writer behind it, and the posts region // in the live preview. // // One project, onscreen-posts-fixture (make-fixture.mjs): two dated clips and // a card, three posts whose dates put them on c01 ("first"), c01 ("date") and // c02 ("date"). Never built, so every timing is the estimate's. Each test puts // the posts back to automatic and shown, and the deck back to its defaults, // through the routes before it starts. // // With the defaults (posts.seconds 4, hold 2.5, shift on) and the fixture's // 0.2 s crossfade, the estimate is: // c01 0 → 5.533 3 s clip + a 2.5 s hold, which at the fixture's // 15 fps is 38 frames (2.533 s); two posts in // 5.333 s share it, p-early at 0, p-mid at 2.667 // c02 5.333 → 10.867 the same; p-late at 6.667, 4 s before its leave // at 10.667 // k01 10.667 → 13.667 the card // and the footage moves aside as each clip's first post appears (c01 at 0, // c02 at 6.667), with the posts column at the frame's right edge. // // The posts region's COMPOSITION is compose-chrome's (`region: "posts"`): the // preview test asserts every window composes and its page reports // `posts:ready` in the live preview. // --------------------------------------------------------------------------- const HERE = path.dirname(fileURLToPath(import.meta.url)); const FIXTURE = path.join(HERE, "..", ".e2e-song"); const PROJECT = "reports/onscreen-posts-fixture"; const DIR = path.join(FIXTURE, "reports", "onscreen-posts-fixture"); const IDS = ["p-early", "p-mid", "p-late"]; type Post = { id: string; attachTo?: string | null; hide?: boolean }; type Manifest = { render: Record; posts: Post[] }; const readManifest = (): Manifest => JSON.parse(readFileSync(path.join(DIR, "video.manifest.json"), "utf8")); const post = (id: string) => readManifest().posts.find((p) => p.id === id)!; const enc = encodeURIComponent; type PostRow = { id: string; attachTo: string | null; hide: boolean; auto: { entryId: string; rule: string; label: string } | null; effective: { entryId: string; rule: string } | null; timing: { segment: string; appear: number } | null; }; async function token(request: APIRequestContext): Promise { const j = (await (await request.get(`/api/report/chrome?project=${enc(PROJECT)}`)).json()) as { token: string }; return j.token; } async function putPosts(request: APIRequestContext, posts: Record) { const r = await request.put("/api/report/posts", { data: { project: PROJECT, posts, token: await token(request) } }); expect(r.ok(), await r.text()).toBeTruthy(); } async function rows(request: APIRequestContext): Promise> { const j = (await (await request.get(`/api/report/onscreen?project=${enc(PROJECT)}`)).json()) as { posts: PostRow[] }; return Object.fromEntries(j.posts.map((p) => [p.id, p])); } const DECK_ON = { engine: "hyperframes", layout: "deck", deck: {} }; async function putChrome(request: APIRequestContext, chrome: unknown) { const r = await request.put("/api/report/chrome", { data: { project: PROJECT, chrome, token: await token(request) } }); expect(r.ok(), await r.text()).toBeTruthy(); } const reset = async (request: APIRequestContext) => { await putChrome(request, DECK_ON); await putPosts(request, Object.fromEntries(IDS.map((id) => [id, { attachTo: null, hide: false }]))); }; 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]; 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; src?: string; error?: string }; }; const previewOf = async (request: APIRequestContext, extra: Record = {}) => (await (await request.post("/api/report/chrome/preview", { data: { project: PROJECT, ...extra } })).json()) as Preview; /** Put the scrubber at `t` (seconds) and wait for the clock to say so. */ async function seek(page: Page, t: number) { await page.getByTestId("onscreen-scrubber").fill(String(t)); await expect(page.getByTestId("onscreen-scrubber")).toHaveValue(String(t)); } /** Fill a field the page may still be re-rendering from a load: retry until the value holds. */ async function fillSure(input: Locator, value: string) { await expect(async () => { await input.fill(value); await expect(input).toHaveValue(value, { timeout: 1000 }); }).toPass({ timeout: 15_000 }); } /** The backdrop's computed transform as [scaleX, scaleY, translateX px, translateY px, frame width px], or null for none. */ const backdropMatrix = (page: Page) => page.getByTestId("onscreen-backdrop-frame").evaluate((el) => { const tr = getComputedStyle(el).transform; if (!tr || tr === "none") return null; const m = new DOMMatrixReadOnly(tr); // offsetWidth: the frame's laid-out width, before the transform scales it. return [m.a, m.d, m.e, m.f, (el as HTMLElement).offsetWidth]; }); async function openSection(page: Page) { await page.goto(`/browse/${PROJECT}`); await expect(page.getByTestId("onscreen-section")).toHaveAttribute("data-onscreen", "on"); await expect(page.getByTestId("onscreen-post-row")).toHaveCount(3); } const postRow = (page: Page, id: string) => page.locator(`[data-testid="onscreen-post-row"][data-post="${id}"]`); test.beforeEach(async ({ request }) => { test.setTimeout(120_000); test.skip( !readManifest().render.fontRegular, "the deck refuses to draw without render.fontRegular/fontBold, and make-fixture.mjs found no font on this machine", ); await reset(request); }); test("the posts table shows each post with the clip the date rule puts it on", async ({ page, request }) => { // The route first: the automatic clip and the effective one, and a slot in // the estimated schedule. const r = await rows(request); expect(r["p-early"].auto).toMatchObject({ entryId: "c01", rule: "first", label: "and because" }); expect(r["p-mid"].auto).toMatchObject({ entryId: "c01", rule: "date" }); expect(r["p-late"].auto).toMatchObject({ entryId: "c02", rule: "date", label: "another whole sentence" }); for (const id of IDS) expect(r[id].effective?.entryId).toBe(r[id].auto?.entryId); expect(r["p-late"].timing?.segment).toBe("c02"); await openSection(page); const late = postRow(page, "p-late"); await expect(late).toHaveAttribute("data-effective", "c02"); await expect(late.getByTestId("onscreen-post-platform")).toHaveText("x"); await expect(late.getByTestId("onscreen-post-handle")).toHaveText("@fixture"); await expect(late.getByTestId("onscreen-post-link")).toHaveAttribute("href", "https://x.com/fixture/status/1"); await expect(late.getByTestId("onscreen-post-text")).toHaveText("Two days after the second clip."); await expect(late.getByTestId("onscreen-post-auto")).toHaveText("auto: c02 — another whole sentence"); await expect(late.getByTestId("onscreen-post-attach")).toHaveValue(""); await expect(late.getByTestId("onscreen-post-effective")).toContainText("automatic: c02 (2024-09-10)"); await expect(postRow(page, "p-early")).toHaveAttribute("data-effective", "c01"); await expect(postRow(page, "p-early").getByTestId("onscreen-post-auto")).toHaveText("auto: c01 — and because"); await expect(page.getByTestId("onscreen-posts-save")).toBeDisabled(); }); test("an override saves attachTo and moves the effective clip; auto deletes the key", async ({ page, request }) => { await openSection(page); const mid = postRow(page, "p-mid"); await mid.getByTestId("onscreen-post-attach").selectOption("c02"); await expect(mid).toHaveAttribute("data-dirty", "1"); await expect(mid).toHaveAttribute("data-effective", "c02"); await expect(mid.getByTestId("onscreen-post-effective")).toContainText("chosen: c02"); // Unsaved: nothing written yet. expect("attachTo" in post("p-mid")).toBe(false); const put = page.waitForRequest((q) => q.url().includes("/api/report/posts") && q.method() === "PUT"); await page.getByTestId("onscreen-posts-save").click(); // Only the post that changed, only the key that changed. expect((await put).postDataJSON().posts).toEqual({ "p-mid": { attachTo: "c02" } }); await expect.poll(() => post("p-mid").attachTo).toBe("c02"); await expect(mid).toHaveAttribute("data-dirty", "0"); const r = await rows(request); expect(r["p-mid"].effective).toMatchObject({ entryId: "c02", rule: "attachTo" }); // The automatic answer is still the date rule's. expect(r["p-mid"].auto?.entryId).toBe("c01"); expect(r["p-mid"].timing?.segment).toBe("c02"); // Back to auto: the key goes, it is not left as null. await page.reload(); await expect(postRow(page, "p-mid").getByTestId("onscreen-post-attach")).toHaveValue("c02"); await postRow(page, "p-mid").getByTestId("onscreen-post-attach").selectOption(""); await page.getByTestId("onscreen-posts-save").click(); await expect.poll(() => "attachTo" in post("p-mid")).toBe(false); await expect(postRow(page, "p-mid")).toHaveAttribute("data-effective", "c01"); }); test("hide persists across a reload, and showing it again deletes the key", async ({ page, request }) => { await openSection(page); const early = postRow(page, "p-early"); await early.getByTestId("onscreen-post-hide").check(); await expect(early).toHaveAttribute("data-hidden", "1"); await expect(early.getByTestId("onscreen-post-attach")).toBeDisabled(); await page.getByTestId("onscreen-posts-save").click(); await expect.poll(() => post("p-early").hide).toBe(true); const r = await rows(request); expect(r["p-early"].effective).toBeNull(); // It still says where it WOULD ride. expect(r["p-early"].auto?.entryId).toBe("c01"); expect(r["p-early"].timing).toBeNull(); await page.reload(); await expect(postRow(page, "p-early").getByTestId("onscreen-post-hide")).toBeChecked(); await expect(postRow(page, "p-early")).toHaveAttribute("data-dirty", "0"); await expect(postRow(page, "p-early").getByTestId("onscreen-post-effective")).toContainText("hidden"); await postRow(page, "p-early").getByTestId("onscreen-post-hide").uncheck(); await page.getByTestId("onscreen-posts-save").click(); await expect.poll(() => "hide" in post("p-early")).toBe(false); }); test("a stale token is a 409; reloading keeps the edit and shows the other writer's", async ({ page, request }) => { await openSection(page); // Somebody else writes after this page read its token. await putPosts(request, { "p-late": { hide: true } }); await postRow(page, "p-mid").getByTestId("onscreen-post-attach").selectOption("c02"); const refused = page.waitForResponse((r) => r.url().includes("/api/report/posts") && r.request().method() === "PUT"); await page.getByTestId("onscreen-posts-save").click(); expect((await refused).status()).toBe(409); await expect(page.getByTestId("onscreen-note")).toContainText("the manifest changed since you opened this"); expect("attachTo" in post("p-mid")).toBe(false); expect(post("p-late").hide).toBe(true); await page.getByTestId("onscreen-reload").click(); await expect(page.getByTestId("onscreen-reload")).toHaveCount(0); // The unsaved override survives... await expect(postRow(page, "p-mid").getByTestId("onscreen-post-attach")).toHaveValue("c02"); await expect(postRow(page, "p-mid")).toHaveAttribute("data-dirty", "1"); // ...and a post nobody touched here shows what the other writer saved, // rather than reading as an edit the next save would quietly revert. await expect(postRow(page, "p-late").getByTestId("onscreen-post-hide")).toBeChecked(); await expect(postRow(page, "p-late")).toHaveAttribute("data-dirty", "0"); const saved = page.waitForResponse((r) => r.url().includes("/api/report/posts") && r.request().method() === "PUT"); await page.getByTestId("onscreen-posts-save").click(); expect((await saved).status()).toBe(200); await expect.poll(() => post("p-mid").attachTo).toBe("c02"); expect(post("p-late").hide).toBe(true); }); test("the writer refuses an unknown post and a clip that is not one, and writes nothing", async ({ request }) => { const before = readFileSync(path.join(DIR, "video.manifest.json"), "utf8"); const unknown = await request.put("/api/report/posts", { data: { project: PROJECT, posts: { "p-mid": { hide: true }, nope: { hide: true } }, token: await token(request) }, }); expect(unknown.status()).toBe(400); expect(((await unknown.json()) as { error: string }).error).toContain("no post with id nope"); const card = await request.put("/api/report/posts", { data: { project: PROJECT, posts: { "p-mid": { attachTo: "k01" } }, token: await token(request) }, }); expect(card.status()).toBe(400); expect(((await card.json()) as { errors: string[] }).errors).toEqual([ 'posts[1].attachTo "k01" is not a clip in the timeline', ]); expect(readFileSync(path.join(DIR, "video.manifest.json"), "utf8")).toBe(before); }); test("the preview composes the posts region per window and overlays it while the scrubber is inside", async ({ page, request, }) => { // The route: one window per clip that carries posts, the deck's own // postWindows over the schedule it returns, at postsGeometry. const pv = await previewOf(request); expect(pv.posts.windows.map(({ segment, from, to }) => ({ segment, from, to }))).toEqual(postWindows(pv.schedule as Parameters[0])); expect(pv.posts.windows.map((w) => w.segment)).toEqual(["c01", "c02"]); expect(pv.posts.geometry.width).toBe(600); // The defaults' timing: 4 s per post, each carrying clip held 2.5 s, every // start and the total measured with the holds. expect(pv.schedule.segments.map((s) => [s.id, s.start, s.duration, s.hold ?? 0])).toEqual([ // The hold is rounded to whole frames: 2.5 s at the fixture's 15 fps is // 37.5 frames, so 38 -- 2.533 s. ["c01", 0, 5.533, 2.533], ["c02", 5.333, 5.533, 2.533], ["k01", 10.667, 3, 0], ]); expect(pv.schedule.total).toBe(13.667); expect(pv.schedule.posts.map((p) => [p.id, p.segment, p.appear, p.out])).toEqual([ ["p-early", "c01", 0, [5.333, 5.533]], ["p-mid", "c01", 2.667, [5.333, 5.533]], ["p-late", "c02", 6.667, [10.667, 10.867]], ]); expect(pv.posts.windows.map(({ from, to }) => [from, to])).toEqual([[0, 5.533], [6.667, 10.867]]); // The footage makes room: one move per carrying clip, and the column at the frame's edge. const render = readManifest().render; expect(pv.schedule.moves?.map((m) => [m.segment, m.at, m.seconds])).toEqual([["c01", 0, 0.6], ["c02", 6.667, 0.6]]); expect(pv.schedule.moves?.[0].from).toEqual(deckGeometry(render).footage); expect(pv.schedule.moves?.[0].to).toEqual(shiftedFootage(render)); expect(pv.posts.geometry).toEqual(postsGeometry(render)); expect(pv.posts.geometry.x).toBe(1920 - 24 - 600); // Every window composes: compose-chrome draws the posts region. for (const w of pv.posts.windows) { expect(w.error).toBeUndefined(); expect(w.src).toMatch(new RegExp(`posts-preview-${w.segment}/index\\.html`)); } // The bench's strip asks for no posts windows at all. const bench = (await ( await request.post("/api/report/chrome/preview", { data: { project: PROJECT, posts: false } }) ).json()) as { posts: { windows: unknown[] } }; expect(bench.posts.windows).toEqual([]); await openSection(page); await expect(page.getByTestId("onscreen-preview")).toHaveAttribute("data-deck-ready", "1", { timeout: 30_000 }); await expect(page.locator("[data-posts-window]")).toHaveCount(2); // The scrubber runs the held length, and the window marks sit on its clock. await expect(page.getByTestId("onscreen-scrubber")).toHaveAttribute("max", "13.667"); await expect(page.getByTestId("onscreen-time")).toContainText("/ 0:13.7"); for (const [seg, from, to] of [["c01", 0, 5.533], ["c02", 6.667, 10.867]] as const) { const style = await page.locator(`[data-posts-window="${seg}"]`).evaluate((el) => [ parseFloat((el as HTMLElement).style.left), parseFloat((el as HTMLElement).style.width), ]); expect(style[0]).toBeCloseTo((from / 13.667) * 100, 2); expect(style[1]).toBeCloseTo(((to - from) / 13.667) * 100, 2); } // A held segment shows its hold: a hatched tail, hold/duration of its block. await expect(page.getByTestId("onscreen-segment-hold")).toHaveCount(2); await expect(page.locator('[data-seg-jump="c01"]')).toHaveAttribute("data-hold", "2.533"); await expect(page.locator('[data-seg-jump="k01"]')).toHaveAttribute("data-hold", "0"); await expect(page.locator('[data-seg-jump="c01"]')).toHaveAttribute("title", /held 2\.533 s for its posts/); const block = (await page.locator('[data-seg-jump="c02"]').boundingBox())!; const tail = (await page.locator('[data-seg-hold="c02"]').boundingBox())!; expect(tail.width / block.width).toBeCloseTo(2.533 / 5.533, 1); expect(Math.abs(tail.x + tail.width - (block.x + block.width))).toBeLessThan(2); // Outside every window: nothing over the footage. k01 starts after the last. await page.locator('[data-seg-jump="k01"]').click(); await expect(page.getByTestId("onscreen-current")).toHaveText("k01"); await expect(page.getByTestId("onscreen-posts-preview")).toHaveCount(0); await page.locator('[data-posts-window="c02"]').click(); const overlay = page.getByTestId("onscreen-posts-preview"); await expect(overlay).toHaveAttribute("data-segment", "c02"); await expect(postRow(page, "p-late")).toHaveAttribute("data-current", "1"); await expect(postRow(page, "p-mid")).toHaveAttribute("data-current", "0"); await expect(overlay).toHaveAttribute("data-posts-ready", "1", { timeout: 30_000 }); await expect(page.getByTestId("onscreen-posts-preview-iframe")).toHaveAttribute("src", /posts-preview-c02\/index\.html/); await expect(overlay.getByTestId("onscreen-posts-preview-error")).toHaveCount(0); // The overlay sits at the posts rect inside the 16:9 frame. const frame = (await page.getByTestId("onscreen-preview").boundingBox())!; const box = (await overlay.boundingBox())!; const W = 1920; expect(Math.abs((box.x - frame.x) / frame.width - pv.posts.geometry.x / W)).toBeLessThan(0.01); expect(Math.abs(box.width / frame.width - pv.posts.geometry.width / W)).toBeLessThan(0.01); // The backdrop moves inside the window: the window mark put the scrubber at // 8.7, past c02's move (6.667 → 7.267), so the footage is all the way aside... const from = pv.schedule.moves![1].from; const to = pv.schedule.moves![1].to; const backdrop = page.getByTestId("onscreen-backdrop-frame"); await expect(backdrop).toHaveAttribute("data-move", "c02"); await expect(backdrop).toHaveAttribute("data-move-progress", "1"); const aside = (await backdropMatrix(page))!; expect(aside[0]).toBeCloseTo(to.width / from.width, 3); expect(aside[1]).toBeCloseTo(to.height / from.height, 3); // ...its box's left edge where the build puts it (in frame pixels)... expect((aside[2] / aside[4]) * W + aside[0] * from.x).toBeCloseTo(to.x, 0); // ...half way through the move at its middle, on the smoothstep curve... // (the move's middle from the schedule itself; the scrubber steps in 0.01 s, // so the progress lands within a step of 0.5, not on it) const mid = pv.schedule.moves![1].at + pv.schedule.moves![1].seconds / 2; await seek(page, Math.round(mid * 100) / 100); await expect .poll(async () => Math.abs(Number(await backdrop.getAttribute("data-move-progress")) - 0.5)) .toBeLessThan(0.02); const half = (await backdropMatrix(page))!; expect(half[0]).toBeCloseTo((1 + to.width / from.width) / 2, 2); // ...in its box before the move... await seek(page, 6.5); await expect(page.getByTestId("onscreen-current")).toHaveText("c02"); await expect(backdrop).toHaveAttribute("data-move-progress", "0"); expect((await backdropMatrix(page))?.[0] ?? 1).toBeCloseTo(1, 3); // ...and not moved at all over a segment that carries no posts. await seek(page, 12); await expect(page.getByTestId("onscreen-current")).toHaveText("k01"); await expect(backdrop).toHaveAttribute("data-move", ""); expect(await backdropMatrix(page)).toBeNull(); }); test("make room off in the settings writes shift: false, the form keeps it, and the column goes back inside the footage", async ({ page, request, }) => { await openSection(page); await expect(page.getByTestId("onscreen-preview")).toHaveAttribute("data-deck-ready", "1", { timeout: 30_000 }); const shift = page.getByTestId("onscreen-setting-posts.shift"); await expect(shift).toBeChecked(); await expect(page.getByTestId("onscreen-setting-posts.hold")).toHaveValue("2.5"); await expect(page.getByTestId("onscreen-setting-posts.seconds")).toHaveValue("4"); await expect(page.getByTestId("onscreen-setting-posts.shift.scale")).toHaveValue("0.86"); await expect(page.getByTestId("onscreen-setting-posts.shift.seconds")).toHaveValue("0.6"); await shift.uncheck(); // Off has no scale or move to set. await expect(page.getByTestId("onscreen-setting-posts.shift.scale")).toHaveCount(0); await page.getByTestId("onscreen-settings-save").click(); await expect.poll(() => (readManifest().render.chrome as { deck: unknown }).deck).toEqual({ posts: { shift: false } }); // The schedule has no moves and the column is inside the footage box again. const render = readManifest().render; const pv = await previewOf(request); expect(pv.schedule.moves).toBeUndefined(); expect(pv.posts.geometry).toEqual(postsGeometry(render)); const f = deckGeometry(render).footage; expect(pv.posts.geometry.x).toBe(f.x + f.width - 24 - 600); // Saving another setting keeps it: the form never drops `shift: false`. await page.reload(); await expect(page.getByTestId("onscreen-setting-posts.shift")).not.toBeChecked(); await fillSure(page.getByTestId("onscreen-setting-posts.hold"), "1.5"); await page.getByTestId("onscreen-settings-save").click(); await expect.poll(() => (readManifest().render.chrome as { deck: unknown }).deck).toEqual({ posts: { hold: 1.5, shift: false }, }); // In the live preview: the overlay inside the footage box, and the backdrop where it always was. await expect(page.getByTestId("onscreen-preview")).toHaveAttribute("data-deck-ready", "1", { timeout: 30_000 }); await page.locator('[data-posts-window="c02"]').click(); const overlay = page.getByTestId("onscreen-posts-preview"); await expect(overlay).toHaveAttribute("data-segment", "c02"); await expect(overlay).toHaveAttribute("data-posts-ready", "1", { timeout: 30_000 }); const frame = (await page.getByTestId("onscreen-preview").boundingBox())!; const box = (await overlay.boundingBox())!; const W = 1920; expect((box.x - frame.x) / frame.width).toBeGreaterThanOrEqual(f.x / W - 0.005); expect((box.x + box.width - frame.x) / frame.width).toBeLessThanOrEqual((f.x + f.width) / W + 0.005); expect(Math.abs((box.x - frame.x) / frame.width - (f.x + f.width - 24 - 600) / W)).toBeLessThan(0.01); await expect(page.getByTestId("onscreen-backdrop-frame")).toHaveAttribute("data-move", ""); expect(await backdropMatrix(page)).toBeNull(); // On again: the default, so nothing under posts but the hold. await page.getByTestId("onscreen-setting-posts.shift").check(); await expect(page.getByTestId("onscreen-setting-posts.shift.scale")).toHaveValue("0.86"); 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(/
[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 { 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" } } }); });