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:
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;