Archilyzer · Source

archilyzer

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

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:
Mplans/deck-posts.md | 24++++++++++++++++++++++++
Mumtool/lib/report/onscreen.test.mjs | 2+-
Mumtool/report-to-video/deck.mjs | 99++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mumtool/report-to-video/deck.test.mjs | 61++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
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/); +});