commit 32f6707a84962ef3d7c583c974301c7237fae333
parent 64e311189fd9a34a91de6c31524b8ddc5c5e187e
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 13:10:29 -0400
umtool: room for posts in the On-screen section — hold and make-room settings, the held schedule, the footage moving aside in the preview
The posts settings group sets posts.hold and posts.shift: a "make room"
switch, with scale and move seconds while it is on. Off is written as
shift: false and kept by every later save; the fields under it are not
written while it is off.
The preview's schedule carries the holds and the moves whether it is the
build's or the estimate: over a build schedule each carrying clip gains the
difference between the hold it has now and the one it was built with, every
later start and the total move by the sum before it, and the posts and the
moves are placed again (footageMoves). A build whose holds are still the
manifest's is kept to the millisecond.
While the scrubber is inside a clip's move the backdrop (segment still or
neutral frame) is clipped to the footage box and carried to where the build
puts it, scale and translate on smoothstep (lib/report/footage-move.mjs);
the posts iframe sits at postsGeometry. The segment bar draws a held
segment's hold as a hatched tail. The clip bench's strip follows the source
only through the unheld part of a held clip.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
7 files changed, 407 insertions(+), 44 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/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;