commit 64e311189fd9a34a91de6c31524b8ddc5c5e187e
parent a39ab7add1b689bd21661054dba653888314a142
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 12:59:30 -0400
report-to-video: room for posts — the contract and the core (4 s each, a hold on the carrying clip, the footage moves aside)
posts.seconds defaults to 4; posts.hold (2.5 s) extends a carrying clip in the
cut by a freeze on its last frame, measured into every start, the total and
the posts' timing (segments[i].hold, only when > 0); posts.shift ({scale 0.86,
seconds 0.6} or false) moves the footage to shiftedFootage while a clip's posts
are up (the schedule's moves), and the posts column goes to the frame's edge.
A cut without posts writes the schedule it always did.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
4 files changed, 170 insertions(+), 16 deletions(-)
diff --git a/plans/deck-posts.md b/plans/deck-posts.md
@@ -125,3 +125,27 @@ Branch `deck/posts` from `main` 2cf43a69; slices merged `--no-ff` after review.
Umtool-only, like the deck: a umtool rebuild and restart. Nothing under `export/`, `homepage/`,
`common/` or editor code.
+
+## Room for posts (second pass)
+
+Rulings, on top of the above:
+
+- **Longer.** `posts.seconds` defaults to 4 (was 2).
+- **Hold.** A clip that carries posts is held on its last frame, in silence, for `posts.hold` (default
+ 2.5 s) before its outgoing transition, so the last post can be read. The hold is part of the
+ segment's length in the CUT: `deckSchedule` adds it to the carrying segments (`segments[i].hold`,
+ present only when > 0) and every start, the total and the posts' timing are measured with it.
+ The segment FILES are unchanged; the hold is applied where the cut is joined (`tpad` clone +
+ `apad`), so `--chrome-only` changes it without rebuilding a clip.
+- **Make room.** With `posts.shift` (default `{ scale: 0.86, seconds: 0.6 }`; `false` turns it
+ off), the footage eases from its box to `shiftedFootage(render)` — scaled, its far edge `inset`
+ from the frame edge away from the column, centred above the deck — as a clip's first post
+ appears, and stays there to the end of the segment; the next segment comes in at the normal box
+ through the transition. The posts column then sits at the FRAME's edge (`postsGeometry`). At
+ 1920×1080 the footage goes 1574×886 at (173,2) → 1354×762 at (24,64); the column is 600 wide at
+ x 1296, overlapping the moved footage by 82 px instead of 600.
+- **More noticeable.** The cards themselves read as a highlighted interruption, not a caption.
+
+Core additions (`deck.mjs`): `shiftedFootage`, `postHolds`, `footageMoves` (the schedule's `moves`:
+`[{segment, at, segmentAt, seconds, from, to}]`, present only when there are moves), `posts.hold`
+and `posts.shift` settings and validation; `resolveDeck` fills `posts.shift` from its default.
diff --git a/umtool/lib/report/onscreen.test.mjs b/umtool/lib/report/onscreen.test.mjs
@@ -24,7 +24,7 @@ const cut = () => ({
slug: "t",
variant: "sourced",
provenance: { siteOrigin: "https://example.test", channelSlug: "chan" },
- render: { fps: 30, transition: 0.5, chrome: { engine: "hyperframes", layout: "deck" } },
+ render: { fps: 30, transition: 0.5, chrome: { engine: "hyperframes", layout: "deck", deck: { posts: { seconds: 2, hold: 0, shift: false } } } },
timeline: [
{ type: "card", id: "k1", heading: "Opening", sub: "a card", seconds: 5 },
{ type: "clip", id: "c01", video: "v1", start: 10, end: 20 },
diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs
@@ -38,7 +38,14 @@ export const DECK_DEFAULTS = Object.freeze({
motion: Object.freeze({ out: 0.3, in: 0.45, pip: 0.7 }),
// The manifest's `posts`, drawn as cards over the footage at the end of the
// clip each one is attached to. position | "top-left".
- posts: Object.freeze({ show: true, seconds: 2, position: "top-right", width: 600, qrSize: 120, maxLines: 7, inset: 24 }),
+ // `hold` freezes the last frame of a clip that carries posts for that long,
+ // so the last of them can be read before the next clip; `shift` moves the
+ // footage away from the posts column (scaled to `scale`, over `seconds`)
+ // while they are up, or is `false`.
+ posts: Object.freeze({
+ show: true, seconds: 4, hold: 2.5, position: "top-right", width: 600, qrSize: 120, maxLines: 7, inset: 24,
+ shift: Object.freeze({ scale: 0.86, seconds: 0.6 }),
+ }),
});
/** Subtitle tokens a `subtitle.parts` array may name. "auto" picks from these. */
@@ -63,6 +70,12 @@ export function resolveDeck(render) {
? { ...base, ...v }
: v;
}
+ // `posts.shift` is the one setting two levels down: `false` turns it off,
+ // an object fills from the default's.
+ const sh = d.posts?.shift;
+ if (sh !== undefined && sh !== null) {
+ merged.posts = { ...merged.posts, shift: sh === false ? false : { ...DECK_DEFAULTS.posts.shift, ...sh } };
+ }
return merged;
}
@@ -167,7 +180,16 @@ export function validateChrome(chrome, render = {}) {
errors.push(`${w}.qr.size ${size} does not fit a ${h}px deck (at most ${h - 20})`);
}
});
- sub("posts", ["show", "seconds", "position", "width", "qrSize", "maxLines", "inset"], (p) => {
+ sub("posts", ["show", "seconds", "hold", "position", "width", "qrSize", "maxLines", "inset", "shift"], (p) => {
+ if (p.hold !== undefined && !numIn(p.hold, 0, 10)) errors.push(`${w}.posts.hold must be from 0 to 10 seconds`);
+ if (p.shift !== undefined && p.shift !== false) {
+ if (!isObj(p.shift)) errors.push(`${w}.posts.shift must be false or { scale, seconds }`);
+ else {
+ unknownKeys(p.shift, ["scale", "seconds"], `${w}.posts.shift`, errors);
+ if (p.shift.scale !== undefined && !numIn(p.shift.scale, 0.5, 1)) errors.push(`${w}.posts.shift.scale must be from 0.5 to 1`);
+ if (p.shift.seconds !== undefined && !numIn(p.shift.seconds, 0, 3)) errors.push(`${w}.posts.shift.seconds must be from 0 to 3`);
+ }
+ }
if (p.show !== undefined && typeof p.show !== "boolean") errors.push(`${w}.posts.show must be true or false`);
if (p.seconds !== undefined && !numIn(p.seconds, 0.5, 10)) errors.push(`${w}.posts.seconds must be from 0.5 to 10`);
if (p.position !== undefined && !["top-right", "top-left"].includes(p.position)) {
@@ -457,11 +479,18 @@ export function deckSchedule({
entries, durs, D, render, provenance = {}, metas = [], estimated = false, posts = [],
}) {
const deck = resolveDeck(render);
- const { starts, total } = scheduleFrom(durs, D);
+ // A clip that carries posts is held on its last frame for `posts.hold`: the
+ // hold is part of the segment's length in the cut, so every start, the total
+ // and the posts' timing below are measured with it. `durs` are the segments'
+ // own (probed or estimated) lengths.
+ const holds = deck.posts.show ? postHolds({ posts, entries, metas, render }) : new Map();
+ const full = durs.map((d, i) => d + (holds.get(entries[i].id) ?? 0));
+ const { starts, total } = scheduleFrom(full, D);
const multiChannel = isMultiChannel(entries, provenance);
const round = (v) => Math.round(v * 1000) / 1000;
- const segs = entries.map((e, i) => ({ id: e.id, start: starts[i], duration: durs[i] }));
+ const segs = entries.map((e, i) => ({ id: e.id, start: starts[i], duration: full[i] }));
const placed = deck.posts.show ? postSchedule({ posts, entries, metas, segments: segs, D, total, render }) : [];
+ const moves = placed.length ? footageMoves({ posts: placed, segments: segs, render }) : [];
return {
version: 1,
kind: "deck",
@@ -476,8 +505,10 @@ export function deckSchedule({
id: e.id,
type: e.type,
start: round(starts[i]),
- duration: round(durs[i]),
- end: round(starts[i] + durs[i]),
+ duration: round(full[i]),
+ end: round(starts[i] + full[i]),
+ // Only on a held clip, so a cut without posts writes the schedule it always did.
+ ...(holds.get(e.id) ? { hold: round(holds.get(e.id)) } : {}),
title,
subtitle,
qrUrl: deck.qr.show ? deckQrUrl(e, provenance) : null,
@@ -487,6 +518,7 @@ export function deckSchedule({
// Present only when there are posts to draw, so a cut without them writes
// the schedule it always did.
...(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) })) } : {}),
};
}
@@ -773,14 +805,65 @@ export function snapWindow(window, { fps, total }) {
* height less the insets. Cards stack top-down inside it.
*/
export function postsGeometry(render) {
- const { footage: f } = deckGeometry(render);
+ const { W, footage: f } = deckGeometry(render);
const p = resolveDeck(render).posts;
const width = even(p.width);
const height = even(f.height - 2 * p.inset);
- const x = p.position === "top-left" ? f.x + p.inset : f.x + f.width - p.inset - width;
+ const left = p.position === "top-left";
+ // With `shift` the footage makes room, so the column sits at the FRAME's
+ // edge; without it, inside the footage box as before.
+ const x = p.shift
+ ? (left ? p.inset : W - p.inset - width)
+ : (left ? f.x + p.inset : f.x + f.width - p.inset - width);
return { x: Math.round(x), y: Math.round(f.y + p.inset), width, height };
}
+/**
+ * The footage box while a clip's posts are up (`posts.shift`): scaled by
+ * `shift.scale` about nothing in particular, its far edge `inset` from the
+ * frame edge AWAY from the posts column, centred in the height above the deck.
+ * null when shift is off.
+ */
+export function shiftedFootage(render) {
+ const { W, H, deck: d, footage: f } = deckGeometry(render);
+ const p = resolveDeck(render).posts;
+ if (!p.shift) return null;
+ const width = even(f.width * p.shift.scale);
+ const height = even(f.height * p.shift.scale);
+ const x = p.position === "top-left" ? W - p.inset - width : p.inset;
+ return { x: Math.round(x), y: Math.floor((H - d.height - height) / 2), width, height };
+}
+
+/** How long each clip that carries posts is held on its last frame (entry id → seconds). */
+export function postHolds({ posts = [], entries = [], metas = [], render }) {
+ const hold = resolveDeck(render).posts.hold;
+ const out = new Map();
+ if (!(hold > 0)) return out;
+ for (const a of attachPosts({ posts, entries, metas })) out.set(a.entryId, hold);
+ return out;
+}
+
+/**
+ * When the footage moves aside for a clip's posts: one move per carrying clip,
+ * starting as its first post appears (`at`, cut clock; `segmentAt`, the
+ * segment's own clock) and easing over `shift.seconds` from the footage box
+ * to `shiftedFootage`. It stays there to the end of the segment; the next
+ * segment comes in at the normal box through the transition.
+ *
+ * @returns {Array<{ segment, at, segmentAt, seconds, from, to }>}
+ */
+export function footageMoves({ posts, segments, render }) {
+ const to = shiftedFootage(render);
+ if (!to) return [];
+ const from = deckGeometry(render).footage;
+ const seconds = resolveDeck(render).posts.shift.seconds;
+ const first = new Map();
+ for (const p of posts) first.set(p.segment, Math.min(first.get(p.segment) ?? Infinity, p.appear));
+ return segments
+ .filter((s) => first.has(s.id))
+ .map((s) => ({ segment: s.id, at: first.get(s.id), segmentAt: first.get(s.id) - s.start, seconds, from, to }));
+}
+
// ---------------------------------------------------------------------------
// The render cache and the renderer command.
// ---------------------------------------------------------------------------
diff --git a/umtool/report-to-video/deck.test.mjs b/umtool/report-to-video/deck.test.mjs
@@ -241,8 +241,10 @@ test("hyperframesCommand: pinned by default, overridable", () => {
});
// ---- posts -----------------------------------------------------------------
-import { attachPosts, clipDay, postSchedule, postsGeometry, postWindows, validatePosts } from "./deck.mjs";
+import { attachPosts, clipDay, postSchedule, postsGeometry, postWindows, shiftedFootage, validatePosts } from "./deck.mjs";
+// The first posts release's settings, which the timing tests below were written for.
+const POSTS2 = { ...RENDER, chrome: { ...CHROME, deck: { posts: { seconds: 2, hold: 0, shift: false } } } };
const POST = (id, date, extra = {}) => ({
id, platform: "bluesky", date, text: `post ${id}`, url: `https://bsky.app/profile/a/post/${id}`, ...extra,
});
@@ -290,7 +292,7 @@ test("postSchedule: stacked from the end, one every `seconds`, leaving in the tr
{ id: "c3", start: 24.5, duration: 4 },
];
const posts = [POST("a", "2024-10-19"), POST("b", "2024-11-27"), POST("z", "2026-01-22")];
- const s = postSchedule({ posts, entries: CLIPS, metas: METAS, segments, D: 0.5, total: 28.5, render: RENDER });
+ const s = postSchedule({ posts, entries: CLIPS, metas: METAS, segments, D: 0.5, total: 28.5, render: POSTS2 });
// c2 carries a and b; its leave is the dissolve into k1 at 21.
assert.deepEqual(s.filter((p) => p.segment === "c2").map((p) => [p.id, p.slot, p.of, p.appear, p.out]), [
["a", 0, 2, 17, [21, 21.5]],
@@ -305,11 +307,11 @@ test("postSchedule: stacked from the end, one every `seconds`, leaving in the tr
const h = postSchedule({ posts, entries: CLIPS, metas: METAS, segments: [
{ id: "c1", start: 0, duration: 10 }, { id: "c2", start: 10, duration: 12 },
{ id: "k1", start: 22, duration: 4 }, { id: "c3", start: 26, duration: 4 },
- ], D: 0, total: 30, render: RENDER });
+ ], D: 0, total: 30, render: POSTS2 });
assert.deepEqual(h.find((p) => p.id === "b").out, [21.7, 22]);
// Too short for k × seconds: what is left after the incoming dissolve is shared.
const many = ["m1", "m2", "m3", "m4"].map((id) => POST(id, "2026-01-01"));
- const sq = postSchedule({ posts: many, entries: CLIPS, metas: METAS, segments, D: 0.5, total: 28.5, render: RENDER });
+ const sq = postSchedule({ posts: many, entries: CLIPS, metas: METAS, segments, D: 0.5, total: 28.5, render: POSTS2 });
assert.deepEqual(sq.map((p) => Number(p.appear.toFixed(3))), [25, 25.8, 26.6, 27.4]);
// Windows: one per carrying clip, first appearance to the end of the leave.
assert.deepEqual(postWindows({ posts: s }), [
@@ -329,10 +331,12 @@ test("deckSchedule carries posts only when there are some; posts.show false drop
assert.equal("posts" in estimateSchedule({ render: off, provenance: PROV, timeline, posts: [POST("p", "2026-01-01")] }), false);
});
-test("postsGeometry: a column inside the footage box", () => {
- assert.deepEqual(postsGeometry(RENDER), { x: 173 + 1574 - 24 - 600, y: 26, width: 600, height: 838 });
- const left = { ...RENDER, chrome: { ...CHROME, deck: { posts: { position: "top-left", width: 500, inset: 10 } } } };
+test("postsGeometry: a column inside the footage box (no shift), at the frame's edge (shift)", () => {
+ assert.deepEqual(postsGeometry(POSTS2), { x: 173 + 1574 - 24 - 600, y: 26, width: 600, height: 838 });
+ const left = { ...RENDER, chrome: { ...CHROME, deck: { posts: { position: "top-left", width: 500, inset: 10, shift: false } } } };
assert.deepEqual(postsGeometry(left), { x: 183, y: 12, width: 500, height: 866 });
+ // Shift on (the default): the column moves to the frame's right edge.
+ assert.deepEqual(postsGeometry(RENDER), { x: 1920 - 24 - 600, y: 26, width: 600, height: 838 });
});
test("validatePosts and the posts settings refuse in sentences", () => {
@@ -366,3 +370,46 @@ test("posts fit: a deck without posts is never refused for the column; drawn pos
// ...unless it switches them off.
assert.deepEqual(validateChrome({ ...CHROME, deck: { footageScale: 0.5, posts: { show: false } } }, narrow), []);
});
+
+test("posts hold: a carrying clip is held on its last frame, and every start after it moves", () => {
+ // CLIPS: c1 (2024-09-05), c2 (2024-09-29 by record), k1 card, c3 (2025-12-08).
+ const posts = [POST("a", "2024-10-19"), POST("z", "2026-01-22")];
+ const durs = [10, 12, 4, 4];
+ const base = deckSchedule({ entries: CLIPS, durs, D: 0.5, render: RENDER, provenance: PROV, metas: METAS });
+ const held = deckSchedule({ entries: CLIPS, durs, D: 0.5, render: RENDER, provenance: PROV, metas: METAS, posts });
+ assert.equal("hold" in base.segments[1], false);
+ assert.deepEqual(held.segments.map((x) => x.hold ?? 0), [0, 2.5, 0, 2.5]);
+ assert.deepEqual(held.segments.map((x) => x.duration), [10, 14.5, 4, 6.5]);
+ assert.deepEqual(held.segments.map((x) => x.start), [0, 9.5, 23.5, 27]);
+ assert.equal(held.total, base.total + 5);
+ // Four seconds each by default: c2's one post is up for its last 4 s, hold included.
+ const a = held.posts.find((p) => p.id === "a");
+ assert.deepEqual([a.segment, a.appear, a.out], ["c2", 19.5, [23.5, 24]]);
+ // hold 0: nothing held, the schedule's lengths are the segments'.
+ const none = { ...RENDER, chrome: { ...CHROME, deck: { posts: { hold: 0 } } } };
+ const h0 = deckSchedule({ entries: CLIPS, durs, D: 0.5, render: none, provenance: PROV, metas: METAS, posts });
+ assert.deepEqual(h0.segments.map((x) => x.duration), durs);
+});
+
+test("posts shift: the footage moves aside from the column while a clip's posts are up", () => {
+ const to = shiftedFootage(RENDER);
+ // 86 % of the 1574×886 box, 24 px from the left, centred above the deck.
+ assert.deepEqual(to, { x: 24, y: 64, width: 1354, height: 762 });
+ // Room: the column at the frame edge starts at 1296; the footage ends at 1378.
+ assert.equal(postsGeometry(RENDER).x - (to.x + to.width), -82);
+ const left = { ...RENDER, chrome: { ...CHROME, deck: { posts: { position: "top-left" } } } };
+ assert.equal(shiftedFootage(left).x, 1920 - 24 - 1354);
+ assert.equal(shiftedFootage(POSTS2), null);
+ const posts = [POST("a", "2024-10-19"), POST("b", "2024-11-27")];
+ const s = deckSchedule({ entries: CLIPS, durs: [10, 12, 4, 4], D: 0.5, render: RENDER, provenance: PROV, metas: METAS, posts });
+ assert.deepEqual(s.moves, [{
+ segment: "c2", at: 15.5, segmentAt: 6, seconds: 0.6,
+ from: { x: 173, y: 2, width: 1574, height: 886 }, to,
+ }]);
+ assert.equal("moves" in deckSchedule({ entries: CLIPS, durs: [10, 12, 4, 4], D: 0.5, render: POSTS2, provenance: PROV, metas: METAS, posts }), false);
+ // Validation of the new keys.
+ assert.deepEqual(validateChrome({ ...CHROME, deck: { posts: { shift: false, hold: 0 } } }, RENDER), []);
+ assert.match(validateChrome({ ...CHROME, deck: { posts: { shift: { scale: 0.2 } } } }, RENDER)[0], /shift.scale/);
+ assert.match(validateChrome({ ...CHROME, deck: { posts: { shift: { speed: 1 } } } }, RENDER)[0], /speed is not a deck setting/);
+ assert.match(validateChrome({ ...CHROME, deck: { posts: { hold: 20 } } }, RENDER)[0], /hold/);
+});