Archilyzer · Source

archilyzer

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

commit 1553a3fc30aae8c9b20cb59efe88e2eedbbd088c
parent 64e311189fd9a34a91de6c31524b8ddc5c5e187e
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 13:32:30 -0400

Merge deck/room-r2 (room slice R2) — umtool: posts.hold and the make-room switch in the settings form (shift:false round-trips), the preview schedule re-holding a built schedule from the manifest now, the backdrop moving with the footage inside a window, held tails on the segment bar; e2e 94/94; reviewed by screenshots

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

Diffstat:
Mumtool/app/api/report/chrome/preview/route.ts | 7+++++++
Mumtool/components/projects/ClipBench.tsx | 4+++-
Mumtool/components/projects/OnscreenSection.tsx | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mumtool/e2e/onscreen-posts.spec.ts | 207++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Aumtool/lib/report/footage-move.mjs | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/footage-move.test.mjs | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/lib/report/onscreen.mjs | 48++++++++++++++++++++++++++++++++++++------------
Mumtool/lib/report/onscreen.test.mjs | 49+++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 601 insertions(+), 57 deletions(-)

diff --git a/umtool/app/api/report/chrome/preview/route.ts b/umtool/app/api/report/chrome/preview/route.ts @@ -23,6 +23,11 @@ export const dynamic = "force-dynamic"; // `draft` -- unsaved rows, id → { title?, subtitle? } | null, the shape PUT // /api/report/onscreen takes -- is applied on top. // +// The schedule carries the posts' holds (a carrying clip's `hold`, part of its +// length) and the footage's `moves` when `posts.shift` is on; the page eases +// its backdrop along them, and the posts region sits at `postsGeometry`, at +// the frame's edge when the footage makes room. +// // The posts region is composed beside it, one project per window // (postWindows: a clip that carries posts, from its first post's appearance to // the end of their leave), each loaded in its own iframe at @@ -87,6 +92,8 @@ export async function POST(request: Request) { src: `${deckPreviewSrc(r.project.id, r.variant)}?v=${stamp}`, variant: r.variant, geometry: deckGeometry(render), + // The ground a footage moved aside for the posts (schedule.moves) leaves showing. + background: typeof (render.palette as { bg?: unknown } | undefined)?.bg === "string" ? (render.palette as { bg: string }).bg : null, layout: deckLayout(render), schedule, posts: { diff --git a/umtool/components/projects/ClipBench.tsx b/umtool/components/projects/ClipBench.tsx @@ -1255,10 +1255,12 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { const osOver = osShown.title.length > osMax; // The strip follows the player when the player is inside what the cut // plays, mapped onto the cut's clock; anywhere else it holds mid-segment. + // A clip that carries posts is held on its last frame in the cut (`hold`, + // part of its duration): the source plays only the rest. const playFrom = clip.cutStart ?? clip.start; const stripT = !deckSeg ? 0 - : playhead != null && playhead >= playFrom - 0.05 && playhead <= playFrom + deckSeg.duration + : playhead != null && playhead >= playFrom - 0.05 && playhead <= playFrom + deckSeg.duration - (deckSeg.hold ?? 0) ? deckSeg.start + Math.max(0, playhead - playFrom) : midOf(deckSeg); const overlayT = deckSeg ? deckSeg.start + Math.min(segT ?? deckSeg.duration / 4, deckSeg.duration) : 0; diff --git a/umtool/components/projects/OnscreenSection.tsx b/umtool/components/projects/OnscreenSection.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { badgeVariants } from "@/components/ui/badge"; import { buttonVariants } from "@/components/ui/button"; +import { backdropTransform, footageAt } from "@/lib/report/footage-move.mjs"; // --------------------------------------------------------------------------- // ON-SCREEN: the persistent panel under the footage of a report cut. @@ -51,7 +52,11 @@ export type DeckSegment = { subtitle: string; qrUrl: string | null; hideDeck: boolean; + /** Seconds this segment is held on its last frame for its posts (part of `duration`); absent when none. */ + hold?: number; }; +/** The footage moving aside for a clip's posts (`posts.shift`), as the schedule says. */ +export type FootageMove = { segment: string; at: number; segmentAt: number; seconds: number; from: Rect; to: Rect }; export type DeckSchedule = { estimated?: boolean; fps: number; @@ -59,6 +64,7 @@ export type DeckSchedule = { total: number; multiChannel: boolean; segments: DeckSegment[]; + moves?: FootageMove[]; }; export type PostSlot = { id: string; segment: string; slot: number; of: number; appear: number; out: [number, number] }; /** One posts window's preview composition: `src` when it composed, `error` when it did not. */ @@ -67,6 +73,8 @@ export type DeckPreviewDoc = { src: string; variant: string; geometry: DeckGeometry; + /** The palette's background: the ground a moved footage leaves showing. */ + background?: string | null; schedule: DeckSchedule & { posts?: PostSlot[] }; /** The posts region: where it sits in the frame, and one composition per window. */ posts?: { geometry: Rect; windows: PostsWindow[] }; @@ -383,14 +391,26 @@ type DeckSettings = { qr: { show: boolean; size: number }; overCards: string; motion: { out: number; in: number; pip: number }; - posts: { show: boolean; seconds: number; position: string; width: number; qrSize: number; maxLines: number; inset: number }; + posts: { + show: boolean; + seconds: number; + hold: number; + position: string; + width: number; + qrSize: number; + maxLines: number; + inset: number; + shift: false | { scale: number; seconds: number }; + }; }; -type Field = +/** `when`: the bool field that must be on for this one to apply (and be shown). */ +type Field = { when?: string } & ( | { key: string; label: string; kind: "int" | "num"; step?: number; hint: string } | { key: string; label: string; kind: "select"; options: string[]; hint: string } | { key: string; label: string; kind: "bool"; hint: string } - | { key: string; label: string; kind: "parts"; hint: string }; + | { key: string; label: string; kind: "parts"; hint: string } +); /** Grouped as the form shows them. The hints are deck.mjs's ranges. */ const GROUPS: { name: string; fields: Field[] }[] = [ @@ -437,11 +457,15 @@ const GROUPS: { name: string; fields: Field[] }[] = [ fields: [ { key: "posts.show", label: "show", kind: "bool", hint: "off leaves every post out of the cut" }, { 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.position", label: "side", kind: "select", options: ["top-right", "top-left"], hint: "the corner of the footage the column hangs from" }, - { key: "posts.width", label: "width", kind: "int", hint: "px, 320–900, inside the footage" }, + { 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" }, + { key: "posts.width", label: "width", kind: "int", hint: "px, 320–900" }, { key: "posts.qrSize", label: "qr", kind: "num", hint: "px, 80–200, at most half the card" }, { key: "posts.maxLines", label: "lines", kind: "int", hint: "2–14: longer posts end in an ellipsis" }, - { key: "posts.inset", label: "inset", kind: "num", hint: "px, 0–80 from the footage's edges" }, + { key: "posts.inset", label: "inset", kind: "num", hint: "px, 0–80 from the edges" }, + { key: "posts.shift", label: "make room", kind: "bool", hint: "the footage moves aside while a clip's posts are up; off leaves it in its box under the column" }, + { key: "posts.shift.scale", label: "scale", kind: "num", step: 0.01, when: "posts.shift", hint: "0.5–1: the footage's size while it is aside" }, + { key: "posts.shift.seconds", label: "move", kind: "num", step: 0.1, when: "posts.shift", hint: "s, 0–3: the move aside" }, ], }, { @@ -460,26 +484,57 @@ type Form = Record<string, string | boolean>; const getPath = (o: unknown, key: string): unknown => key.split(".").reduce<unknown>((v, k) => (v && typeof v === "object" ? (v as Record<string, unknown>)[k] : undefined), o); -const formOf = (deck: DeckSettings): Form => +/** + * The form for a deck. A field under a switch that is off (`posts.shift: + * false` has no scale) shows the default, so turning the switch on starts + * from it. + */ +const formOf = (deck: DeckSettings, defaults: DeckSettings): Form => Object.fromEntries( FIELDS.map((f) => { - const v = getPath(deck, f.key); + const own = getPath(deck, f.key); + const v = f.kind === "bool" ? own : own ?? getPath(defaults, f.key); if (f.kind === "bool") return [f.key, !!v]; if (f.kind === "parts") return [f.key, Array.isArray(v) ? v.join(", ") : String(v ?? "auto")]; return [f.key, v == null ? "" : String(v)]; }), ); +/** Set `o.a.b.c` from "a.b.c", making the objects on the way. */ +const setPath = (o: Record<string, unknown>, key: string, v: unknown) => { + const ks = key.split("."); + let at = o; + for (const k of ks.slice(0, -1)) { + const next = at[k]; + if (!next || typeof next !== "object") at[k] = {}; + at = at[k] as Record<string, unknown>; + } + at[ks[ks.length - 1]] = v; +}; + +/** Fields that are a switch over an object setting: on is the object (its fields), off is `false`. */ +const SWITCHES = new Set(FIELDS.filter((f) => FIELDS.some((g) => g.when === f.key)).map((f) => f.key)); + /** * The form, back into a `render.chrome.deck` block: ONLY what differs from * the defaults, so a manifest says what somebody chose and a default that * moves later still reaches it. Anything that does not parse is sent as typed * -- the validator's sentence is a better answer than a silent clamp here. + * + * A switch over an object setting (`posts.shift`) is written as `false` when + * off -- never dropped, since absent means the default, which is on -- and as + * the fields under it that differ when on; the fields under a switch that is + * off are not written at all. */ const deckOf = (form: Form, defaults: DeckSettings): Record<string, unknown> => { const out: Record<string, unknown> = {}; for (const f of FIELDS) { const raw = form[f.key]; + if (SWITCHES.has(f.key)) { + if (!raw) setPath(out, f.key, false); + continue; + } + if (f.when && !form[f.when]) continue; let v: unknown; if (f.kind === "bool") v = !!raw; else if (f.kind === "parts") { @@ -492,9 +547,7 @@ const deckOf = (form: Form, defaults: DeckSettings): Record<string, unknown> => v = Number.isFinite(Number(s)) ? Number(s) : s; } if (JSON.stringify(v) === JSON.stringify(getPath(defaults, f.key))) continue; - const [a, b] = f.key.split("."); - if (b) out[a] = { ...((out[a] as Record<string, unknown>) ?? {}), [b]: v }; - else out[a] = v; + setPath(out, f.key, v); } return out; }; @@ -643,7 +696,7 @@ export default function OnscreenSection({ } setDoc(j); token.current = j.token; - setForm(formOf(j.deck ?? j.defaults)); + setForm(formOf(j.deck ?? j.defaults, j.defaults)); setFormDirty(false); setErrors(j.errors ?? []); return j; @@ -998,6 +1051,24 @@ export default function OnscreenSection({ const current = schedule ? segmentAt(schedule, t) : null; const postsWin = preview?.posts ? postsWindowAt(preview.posts.windows, t) : null; const backdropId = current && segmentOf.has(current.id) ? current.id : null; + // The footage moving aside for the posts (`posts.shift`): the backdrop -- + // the built segment or the neutral frame, both a whole frame with the + // 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; + const backdropStyle: React.CSSProperties | undefined = + move && preview + ? (() => { + const { W, H } = preview.geometry; + const f = move.from; + const pc = (v: number, of: number) => `${(v / of) * 100}%`; + return { + transform: backdropTransform(move.from, move.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)})`, + }; + })() + : undefined; // The backdrop follows the scrubber: the built segment of whichever clip is // on screen, at the same offset into it. @@ -1404,20 +1475,29 @@ export default function OnscreenSection({ <div className="min-w-0 space-y-2"> {preview ? ( <DeckFrame preview={preview} t={t} texts={texts} testid="onscreen-preview"> - {backdropId ? ( - <video - ref={backdrop} - key={backdropId} - data-testid="onscreen-backdrop" - src={`/api/report/segment?project=${encodeURIComponent(project)}&clip=${encodeURIComponent(backdropId)}`} - muted - playsInline - preload="auto" - className="absolute inset-0 h-full w-full object-contain" - /> - ) : ( - <NeutralFrame geometry={preview.geometry} label={current ? `${current.id} · no segment built` : "footage"} /> - )} + {move && <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) : ""} + style={backdropStyle} + > + {backdropId ? ( + <video + ref={backdrop} + key={backdropId} + data-testid="onscreen-backdrop" + src={`/api/report/segment?project=${encodeURIComponent(project)}&clip=${encodeURIComponent(backdropId)}`} + muted + playsInline + preload="auto" + className="absolute inset-0 h-full w-full object-contain" + /> + ) : ( + <NeutralFrame geometry={preview.geometry} label={current ? `${current.id} · no segment built` : "footage"} /> + )} + </div> {postsWin && preview.posts && ( <PostsOverlay key={`${postsWin.segment}:${postsWin.src ?? "none"}`} @@ -1448,10 +1528,11 @@ export default function OnscreenSection({ <button key={s.id} type="button" - title={`${s.id}${s.hideDeck ? " · panel hidden" : ""}`} + title={`${s.id}${s.hideDeck ? " · panel hidden" : ""}${s.hold ? ` · held ${s.hold} s for its posts` : ""}`} data-seg-jump={s.id} + data-hold={s.hold ?? 0} onClick={() => setT(midOf(s))} - className={`h-full border-r border-[var(--color-ink)] ${ + className={`relative h-full border-r border-[var(--color-ink)] ${ current?.id === s.id ? "bg-[var(--color-sel)]" : s.hideDeck @@ -1461,7 +1542,22 @@ export default function OnscreenSection({ : "bg-[var(--color-line)]" }`} style={{ width: `${(s.duration / schedule.total) * 100}%` }} - /> + > + {s.hold ? ( + // The held tail: the clip's last frame, frozen for its posts. + <span + aria-hidden + data-testid="onscreen-segment-hold" + data-seg-hold={s.id} + className="pointer-events-none absolute inset-y-0 right-0" + style={{ + width: `${(s.hold / s.duration) * 100}%`, + backgroundImage: + "repeating-linear-gradient(135deg, rgba(0,0,0,0.55) 0 2px, rgba(255,255,255,0.18) 2px 4px)", + }} + /> + ) : null} + </button> ))} </div> {(preview?.posts?.windows.length ?? 0) > 0 && ( @@ -1649,7 +1745,7 @@ export default function OnscreenSection({ className={buttonVariants({ size: "sm" })} disabled={!!busy} onClick={() => { - setForm(formOf(doc.defaults)); + setForm(formOf(doc.defaults, doc.defaults)); setFormDirty(true); }} title="Fill the form with the defaults; nothing is written until you save" @@ -1662,8 +1758,10 @@ export default function OnscreenSection({ {GROUPS.map((group) => ( <div key={group.name} className="contents"> <span className="micro pt-1">{group.name}</span> - <div className="flex flex-wrap items-center gap-x-2 gap-y-1 pt-1"> + <div className="flex flex-wrap items-center gap-x-2 gap-y-1 pt-1" data-setting-group={group.name}> {group.fields.map((f) => { + // Under a switch that is off: nothing to set, so nothing shown. + if (f.when && !form[f.when]) return null; const v = form[f.key]; const set = (nv: string | boolean) => { setForm((prev) => ({ ...prev, [f.key]: nv })); diff --git a/umtool/e2e/onscreen-posts.spec.ts b/umtool/e2e/onscreen-posts.spec.ts @@ -1,8 +1,8 @@ -import { test, expect, type APIRequestContext, type Page } from "@playwright/test"; +import { test, expect, type APIRequestContext, type Locator, type Page } from "@playwright/test"; import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { postWindows } from "umtool-report-to-video/deck"; +import { deckGeometry, postWindows, postsGeometry, shiftedFootage } from "umtool-report-to-video/deck"; // --------------------------------------------------------------------------- // POSTS on the on-screen deck, as umtool edits them: the Posts table under the @@ -12,7 +12,18 @@ import { postWindows } from "umtool-report-to-video/deck"; // 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 through the route before it starts. +// 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.5 3 s clip + a 2.5 s hold; two posts in 5.3 s share it, +// p-early at 0, p-mid at 2.65 +// c02 5.3 → 10.8 3 s clip + a 2.5 s hold; p-late at 6.6, 4 s before +// its leave at 10.6 +// k01 10.6 → 13.6 the card +// and the footage moves aside as each clip's first post appears (c01 at 0, +// c02 at 6.6), 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 @@ -55,8 +66,54 @@ async function rows(request: APIRequestContext): Promise<Record<string, PostRow> return Object.fromEntries(j.posts.map((p) => [p.id, p])); } -const reset = (request: APIRequestContext) => - putPosts(request, Object.fromEntries(IDS.map((id) => [id, { attachTo: null, hide: false }]))); +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: { + total: number; + segments: { id: string; start: number; duration: number; end: number; hold?: number }[]; + posts: { id: string; segment: string; appear: number; out: [number, 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 }[] }; +}; +const previewOf = async (request: APIRequestContext, extra: Record<string, unknown> = {}) => + (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}`); @@ -210,16 +267,32 @@ test("the preview composes the posts region per window and overlays it while the }) => { // The route: one window per clip that carries posts, the deck's own // postWindows over the schedule it returns, at postsGeometry. - const pv = (await (await request.post("/api/report/chrome/preview", { data: { project: PROJECT } })).json()) as { - schedule: Parameters<typeof postWindows>[0] & { total: number }; - posts: { - geometry: { x: number; y: number; width: number; height: number }; - windows: { segment: string; from: number; to: number; src?: string; error?: string }[]; - }; - }; - expect(pv.posts.windows.map(({ segment, from, to }) => ({ segment, from, to }))).toEqual(postWindows(pv.schedule)); + const pv = await previewOf(request); + expect(pv.posts.windows.map(({ segment, from, to }) => ({ segment, from, to }))).toEqual(postWindows(pv.schedule as Parameters<typeof postWindows>[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([ + ["c01", 0, 5.5, 2.5], + ["c02", 5.3, 5.5, 2.5], + ["k01", 10.6, 3, 0], + ]); + expect(pv.schedule.total).toBe(13.6); + expect(pv.schedule.posts.map((p) => [p.id, p.segment, p.appear, p.out])).toEqual([ + ["p-early", "c01", 0, [5.3, 5.5]], + ["p-mid", "c01", 2.65, [5.3, 5.5]], + ["p-late", "c02", 6.6, [10.6, 10.8]], + ]); + expect(pv.posts.windows.map(({ from, to }) => [from, to])).toEqual([[0, 5.5], [6.6, 10.8]]); + // 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.6, 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(); @@ -235,6 +308,28 @@ test("the preview composes the posts region per window and overlays it while the 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.6"); + await expect(page.getByTestId("onscreen-time")).toContainText("/ 0:13.6"); + for (const [seg, from, to] of [["c01", 0, 5.5], ["c02", 6.6, 10.8]] 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.6) * 100, 2); + expect(style[1]).toBeCloseTo(((to - from) / 13.6) * 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.5"); + await expect(page.locator('[data-seg-jump="k01"]')).toHaveAttribute("data-hold", "0"); + await expect(page.locator('[data-seg-jump="c01"]')).toHaveAttribute("title", /held 2\.5 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.5 / 5.5, 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"); @@ -255,4 +350,90 @@ test("the preview composes the posts region per window and overlays it while the 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.6 → 7.2), 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... + await seek(page, 6.9); + await expect(backdrop).toHaveAttribute("data-move-progress", "0.5"); + const half = (await backdropMatrix(page))!; + expect(half[0]).toBeCloseTo((1 + to.width / from.width) / 2, 3); + // ...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 } }); }); diff --git a/umtool/lib/report/footage-move.mjs b/umtool/lib/report/footage-move.mjs @@ -0,0 +1,90 @@ +// Where the footage is at a moment of the cut, for umtool's live preview. +// +// `posts.shift` moves the footage aside while a clip's posts are up: the +// schedule's `moves` (deck.mjs `footageMoves`) say when and from which box to +// which. The BUILD draws the move; the preview only has to put its backdrop -- +// a still of the built segment, or the neutral frame -- where the build will +// have put the footage, so a scrubbed moment reads as the render. Pure: no +// DOM, no React, so the arithmetic is unit-tested and the page only applies it. +// +// The rule the build follows, read here the same way: +// - a move belongs to ONE segment, and only that segment's footage moves: +// the next one comes in at the normal box through the transition, so once +// the scrubber is in the next segment there is no move; +// - before `at` the footage is in its box; over `seconds` it eases to `to`; +// after that it stays at `to` to the end of the segment (the hold included). +// +// The easing is smoothstep, p²(3 − 2p): the curve the build's move uses. + +/** @typedef {{ x: number, y: number, width: number, height: number }} Rect */ +/** @typedef {{ segment: string, at: number, segmentAt: number, seconds: number, from: Rect, to: Rect }} Move */ + +/** smoothstep on [0, 1], clamped: 0 and 1 outside it. */ +export function ease(p) { + if (!(p > 0)) return 0; + if (p >= 1) return 1; + return p * p * (3 - 2 * p); +} + +/** + * The segment on screen at `t`: the last one that has started. Past the end, + * the last; before the first, the first. The preview's own rule (its + * `segmentAt`), so the backdrop and the move agree on whose footage it is. + * + * @param {{ segments: Array<{ id: string, start: number }> }} schedule + * @param {number} t + */ +export function segmentIdAt(schedule, t) { + const segs = schedule?.segments ?? []; + for (let i = segs.length - 1; i >= 0; i -= 1) if (t >= segs[i].start) return segs[i].id; + return segs[0]?.id ?? null; +} + +const lerp = (a, b, p) => a + (b - a) * p; + +/** + * The footage's box at `t`, when the segment on screen has a move: its + * eased progress (0 before the move, 1 after it) and the rect between `from` + * and `to`. null when the segment on screen has no move -- the footage is in + * its box and nothing needs drawing differently. + * + * @param {{ segments: Array<{ id: string, start: number }>, moves?: Move[] }} schedule + * @param {number} t seconds in the cut's clock + * @returns {{ segment: string, progress: number, rect: Rect, from: Rect, to: Rect } | null} + */ +export function footageAt(schedule, t) { + const moves = schedule?.moves ?? []; + if (!moves.length) return null; + const id = segmentIdAt(schedule, t); + const m = moves.find((x) => x.segment === id); + if (!m) return null; + const progress = m.seconds > 0 ? ease((t - m.at) / m.seconds) : t >= m.at ? 1 : 0; + const rect = { + x: lerp(m.from.x, m.to.x, progress), + y: lerp(m.from.y, m.to.y, progress), + width: lerp(m.from.width, m.to.width, progress), + height: lerp(m.from.height, m.to.height, progress), + }; + return { segment: m.segment, progress, rect, from: m.from, to: m.to }; +} + +/** + * The CSS transform that takes a whole-frame backdrop (W×H, `transform-origin: + * 0 0`) with its footage at `from` and puts that footage at `rect`. Translate + * is in percent of the element -- the frame -- so it holds at any displayed + * size. "none" when nothing moves. + * + * @param {Rect} from + * @param {Rect} rect + * @param {{ W: number, H: number }} frame + */ +export function backdropTransform(from, rect, { W, H }) { + const sx = rect.width / from.width; + const sy = rect.height / from.height; + const tx = rect.x - sx * from.x; + const ty = rect.y - sy * from.y; + if (Math.abs(sx - 1) < 1e-6 && Math.abs(sy - 1) < 1e-6 && Math.abs(tx) < 1e-6 && Math.abs(ty) < 1e-6) return "none"; + const pct = (v, of) => `${Math.round((v / of) * 100 * 10000) / 10000}%`; + const n = (v) => Math.round(v * 1e6) / 1e6; + return `translate(${pct(tx, W)}, ${pct(ty, H)}) scale(${n(sx)}, ${n(sy)})`; +} diff --git a/umtool/lib/report/footage-move.test.mjs b/umtool/lib/report/footage-move.test.mjs @@ -0,0 +1,93 @@ +// The footage's box at a moment of the cut, as umtool's preview draws it. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import test from "node:test"; + +import { estimateSchedule } from "umtool-report-to-video/deck"; +import { backdropTransform, ease, footageAt, segmentIdAt } from "./footage-move.mjs"; + +const FROM = { x: 173, y: 2, width: 1574, height: 886 }; +const TO = { x: 24, y: 64, width: 1354, height: 762 }; +const schedule = { + segments: [ + { id: "a", start: 0 }, + { id: "b", start: 9.5 }, + { id: "c", start: 19 }, + ], + moves: [{ segment: "a", at: 4, segmentAt: 4, seconds: 0.5, from: FROM, to: TO }], +}; + +test("ease: smoothstep, clamped", () => { + assert.equal(ease(-1), 0); + assert.equal(ease(0), 0); + assert.equal(ease(0.5), 0.5); + assert.equal(ease(0.25), 0.15625); + assert.equal(ease(1), 1); + assert.equal(ease(2), 1); + assert.equal(ease(Number.NaN), 0); +}); + +test("segmentIdAt: the last segment that has started", () => { + assert.equal(segmentIdAt(schedule, 0), "a"); + assert.equal(segmentIdAt(schedule, 9.49), "a"); + assert.equal(segmentIdAt(schedule, 9.5), "b"); + assert.equal(segmentIdAt(schedule, 100), "c"); + assert.equal(segmentIdAt(schedule, -1), "a"); + assert.equal(segmentIdAt({ segments: [] }, 1), null); +}); + +test("footageAt: the box before the move, eased through it, held at `to` to the end of the segment", () => { + assert.deepEqual(footageAt(schedule, 3.9).rect, FROM); + assert.equal(footageAt(schedule, 3.9).progress, 0); + const mid = footageAt(schedule, 4.25); + assert.equal(mid.progress, 0.5); + assert.deepEqual(mid.rect, { x: 98.5, y: 33, width: 1464, height: 824 }); + assert.equal(footageAt(schedule, 4.125).progress, 0.15625); + assert.deepEqual(footageAt(schedule, 4.5).rect, TO); + assert.deepEqual(footageAt(schedule, 9.4).rect, TO); + // The next segment comes in at the normal box: no move once it is on screen. + assert.equal(footageAt(schedule, 9.5), null); + assert.equal(footageAt(schedule, 20), null); + assert.equal(footageAt({ segments: schedule.segments }, 5), null); + // A zero-second move is a cut. + const snap = { ...schedule, moves: [{ ...schedule.moves[0], seconds: 0 }] }; + assert.equal(footageAt(snap, 3.99).progress, 0); + assert.equal(footageAt(snap, 4).progress, 1); +}); + +test("footageAt over deck.mjs's own schedule: the moves estimateSchedule emits", () => { + const m = { + render: { width: 1920, height: 1080, fps: 30, transition: 0.5, chrome: { engine: "hyperframes", layout: "deck", deck: {} } }, + timeline: [ + { type: "clip", id: "c01", video: "v", start: 0, end: 12, date: "2024-09-03" }, + { type: "clip", id: "c02", video: "v", start: 20, end: 30, date: "2024-09-10" }, + ], + posts: [{ id: "p", platform: "x", date: "2024-09-05", text: "t", url: "https://x.com/a/status/1" }], + }; + const s = estimateSchedule(m); + const [move] = s.moves; + assert.equal(move.segment, "c01"); + assert.deepEqual(footageAt(s, move.at - 0.01).rect, move.from); + assert.deepEqual(footageAt(s, move.at + move.seconds).rect, move.to); + // Through c01's hold, still aside; c02 is at its box. + const c02 = s.segments.find((x) => x.id === "c02"); + assert.deepEqual(footageAt(s, c02.start - 0.01).rect, move.to); + assert.equal(footageAt(s, c02.start), null); +}); + +test("backdropTransform: the frame scaled and moved so `from` lands on the rect", () => { + const frame = { W: 1920, H: 1080 }; + assert.equal(backdropTransform(FROM, FROM, frame), "none"); + const tr = backdropTransform(FROM, TO, frame); + // sx 1354/1574, tx 24 − sx·173 = −124.82 px = −6.501 % of 1920; ty 64 − sy·2 = 62.28 px. + assert.equal(tr, "translate(-6.501%, 5.7667%) scale(0.860229, 0.860045)"); + // Check the mapping itself: the box's corners land on TO's. + const sx = TO.width / FROM.width; + const sy = TO.height / FROM.height; + const tx = (-6.501 / 100) * 1920; + const ty = (5.7667 / 100) * 1080; + assert.ok(Math.abs(tx + sx * FROM.x - TO.x) < 0.01); + assert.ok(Math.abs(ty + sy * FROM.y - TO.y) < 0.01); + assert.ok(Math.abs(tx + sx * (FROM.x + FROM.width) - (TO.x + TO.width)) < 0.01); +}); diff --git a/umtool/lib/report/onscreen.mjs b/umtool/lib/report/onscreen.mjs @@ -24,7 +24,9 @@ import { clipDay, deckText, estimateSchedule, + footageMoves, normalizeOnscreen, + postHolds, postSchedule, postWindows, resolveDeck, @@ -121,6 +123,15 @@ export function scheduleMatches(schedule, entries) { * build's segments: an override or a hide saved since the build moves them, * and the build's `posts` would show where they were. * + * So are the HOLDS (`posts.hold` on a clip that carries posts) and the + * footage's moves: a hold is part of its segment's length in the cut, so a + * post moved to another clip, a hide, a changed `posts.hold`, or a build that + * predates holds all move every later start. The build's probed length of a + * segment is its `duration` less the hold it was built with; each segment + * gains the difference between the hold it has now and that one, and every + * later start (and the total) moves by the sum before it. A build whose holds + * are still the manifest's is kept to the millisecond. + * * Without a build schedule the draft is applied to the entries and the whole * cut is estimated. * @@ -148,22 +159,34 @@ export function previewSchedule({ variantManifest, built, draft, metas, postsDra const deck = resolveDeck(render); const provenance = variantManifest.provenance ?? {}; const patchedEntries = entries.map(patched); - const { posts: _builtPosts, ...rest } = built; + const { posts: _builtPosts, moves: _builtMoves, ...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; + const segments = built.segments.map((s) => { + const hold = holds.get(s.id) ?? 0; + const delta = hold - (s.hold ?? 0); + const start = s.start + shift; + const duration = s.duration + delta; + shift += delta; + const { hold: _h, ...bare } = s; + return { + ...bare, + start: round(start), + duration: round(duration), + end: round(start + duration), + ...(hold > 0 ? { hold: round(hold) } : {}), + }; + }); + const total = round(built.total + shift); const placed = deck.posts.show - ? postSchedule({ - posts, - entries: patchedEntries, - metas, - segments: built.segments, - D: built.transition, - total: built.total, - render, - }) + ? postSchedule({ posts, entries: patchedEntries, metas, segments, D: built.transition, total, render }) : []; - const round = (v) => Math.round(v * 1000) / 1000; + const moves = placed.length ? footageMoves({ posts: placed, segments, render }) : []; return { ...rest, - segments: built.segments.map((s, i) => { + total, + segments: segments.map((s, i) => { const e = patchedEntries[i]; const meta = metas[i] ?? null; const { title, subtitle } = deckText(e, meta, provenance, deck, built.multiChannel); @@ -171,6 +194,7 @@ export function previewSchedule({ variantManifest, built, draft, metas, postsDra return { ...s, title, subtitle: keepBuilt ? s.subtitle : subtitle }; }), ...(placed.length ? { posts: placed.map((p) => ({ ...p, appear: round(p.appear), out: p.out.map(round) })) } : {}), + ...(moves.length ? { moves: moves.map((m) => ({ ...m, at: round(m.at), segmentAt: round(m.segmentAt) })) } : {}), }; } return estimateSchedule({ ...variantManifest, posts, timeline: entries.map(patched) }, { metas }); diff --git a/umtool/lib/report/onscreen.test.mjs b/umtool/lib/report/onscreen.test.mjs @@ -162,6 +162,55 @@ test("no build schedule: the estimate places the posts with the draft applied", assert.deepEqual(s.posts.map((p) => [p.id, p.segment]), [["p1", "c01"], ["p2", "c02"]]); }); +/** `cut()` with posts and the room-for-posts defaults (4 s, hold 2.5, shift on) instead of the old ones. */ +const roomy = (posts = POSTS) => { + const m = withPosts(posts); + m.render.chrome.deck = {}; + return m; +}; + +test("a build that predates holds: each carrying clip gains the hold, every later start moves, and the moves follow", () => { + // built(): k1 0+5, c01 4.5+9.9, c02 13.9+12 = 25.9. p3 and p1 ride on c01, p2 on c02. + const s = previewSchedule({ variantManifest: roomy(), built: built(), draft: new Map(), metas }); + const by = Object.fromEntries(s.segments.map((x) => [x.id, x])); + assert.ok(!("hold" in by.k1)); + assert.deepEqual([by.k1.start, by.k1.duration, by.k1.end], [0, 5, 5]); + assert.deepEqual([by.c01.start, by.c01.duration, by.c01.end, by.c01.hold], [4.5, 12.4, 16.9, 2.5]); + assert.deepEqual([by.c02.start, by.c02.duration, by.c02.end, by.c02.hold], [16.4, 14.5, 30.9, 2.5]); + assert.equal(s.total, 30.9); + // The posts are measured with the holds: c01's leave is c02's (held) start. + const p = Object.fromEntries(s.posts.map((x) => [x.id, x])); + assert.deepEqual(p.p1.out, [16.4, 16.9]); + assert.equal(p.p1.appear, 12.4); + assert.equal(p.p3.appear, 8.4); + assert.deepEqual(p.p2.out, [30.6, 30.9]); + // One move per carrying clip, at its first post, from the box to the shifted box. + assert.deepEqual(s.moves.map((m) => [m.segment, m.at, m.segmentAt, m.seconds]), [["c01", 8.4, 3.9, 0.6], ["c02", 26.6, 10.2, 0.6]]); + assert.deepEqual(s.moves[0].to, { x: 24, y: 64, width: 1354, height: 762 }); + + // A build WITH those holds is kept to the millisecond: the same schedule back. + const again = previewSchedule({ variantManifest: roomy(), built: s, draft: new Map(), metas }); + assert.deepEqual(again.segments.map((x) => [x.id, x.start, x.duration, x.hold]), s.segments.map((x) => [x.id, x.start, x.duration, x.hold])); + assert.equal(again.total, s.total); + + // Hiding c01's posts takes its hold away again, and the later starts come back. + const hidden = previewSchedule({ + variantManifest: roomy(), built: s, draft: new Map(), metas, + postsDraft: { p1: { hide: true }, p3: { hide: true } }, + }); + const h = Object.fromEntries(hidden.segments.map((x) => [x.id, x])); + assert.ok(!("hold" in h.c01)); + assert.deepEqual([h.c01.duration, h.c02.start, hidden.total], [9.9, 13.9, 28.4]); + assert.deepEqual(hidden.moves.map((m) => m.segment), ["c02"]); + + // shift off: the holds, and no moves. + const fixed = roomy(); + fixed.render.chrome.deck = { posts: { shift: false } }; + const off = previewSchedule({ variantManifest: fixed, built: built(), draft: new Map(), metas }); + assert.equal(off.total, 30.9); + assert.ok(!("moves" in off)); +}); + test("applyPostsDraft: the writer's rule, on a copy", () => { const posts = withPosts().posts; posts[0].hide = true;