commit ee7b10ed12cb0ada9c3990770ad86072f1f9d89e
parent 8b61a65f1c2719efccf02401a003b01a07bde694
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 5 Oct 2026 10:17:02 -0400
umtool: posts can ride the whole clip (posts.at "start"), shotMaxHeight, a "web" platform and per-post variant; snapLead/snapTail keep a pause's breath at the cut
- deck posts.at: "end" (default, unchanged) | "start" -- a popup's posts come up with
their clip, in manifest order, sharing it evenly; with hold 0 the cut never freezes
- deck posts.shotMaxHeight: caps a screenshot in px (null = a full card of words, as before)
- posts[].platform "web" (a page's words); posts[].variant keeps a post to one cut
(selectVariant filters posts like entries)
- render.snapLead / render.snapTail: how much of the pause a snapped cut keeps, never more
than the pause holds (defaults 0.10 / 0.18 unchanged), so a crossfade fades over the
breath rather than the last word
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
8 files changed, 157 insertions(+), 20 deletions(-)
diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md
@@ -78,6 +78,13 @@ Three stages, because a cue span is the wrong answer twice over.
tight cut beats a cut in the wrong place. The build logs `start✓ end✓` per clip
so you can see which snapped.
+ How much of the pause is kept: 0.10 s before speech resumes and 0.18 s after
+ it stops by default. `render.snapLead` / `render.snapTail` (seconds) set them,
+ and then never more than the pause holds — so with a crossfade, a tail as long
+ as the `transition` fades out over the breath instead of the last word, and a
+ long lead lets the next clip fade in before its first word. Pair them with a
+ `silenceMinDur` that finds real pauses (~0.3 s), not the gaps between words.
+
**The silence threshold is relative, and it has to be.** These are game
streams: the gaps between words are full of game audio and music — quiet, but
nowhere near silent. A fixed absolute threshold sits below the noise floor and
@@ -423,7 +430,7 @@ footage, so it rides on a clip.
```jsonc
"posts": [
- { "id": "bs-3msydljwjis2a", "platform": "bluesky", // "bluesky" | "x"
+ { "id": "bs-3msydljwjis2a", "platform": "bluesky", // "bluesky" | "x" | "web" (a page's words)
"author": "Pirate Software", "handle": "piratesoftware.live",
"date": "2026-08-13T19:05:26.424Z", // ISO date or date-time
"text": "We just signed off on 51 page document …", // newlines kept; ≤ 3000 characters
@@ -433,15 +440,16 @@ footage, so it rides on a clip.
"siteChannel": "piratesoftware-bsky", // optional: the archive channel that keeps it
"siteUrl": null, // optional: an http(s) page the QR links instead
"postId": null, // optional: its id, when `url` does not carry one
- "shot": "shots/post-1.png" } ] // optional: a screenshot drawn instead of the text card
+ "shot": "shots/post-1.png", // optional: a screenshot drawn instead of the text card
+ "variant": "full" } ] // optional: in that cut only, as an entry's `variant`
```
- **Which clip.** The one whose recording most closely PRECEDES the post: the
latest day on or before the post's (a clip's day is its own `date`, else its
record's upload date — the build reads the real one), ties to the later clip in
the cut; a post older than every clip goes on the first. `attachTo` overrides;
- `hide: true` leaves a post out. A variant that drops the named clip falls back
- to the date rule.
+ `hide: true` leaves a post out; `variant` keeps it to one cut. A variant that
+ drops the named clip falls back to the date rule.
- **When.** A clip's posts, oldest first, stack: post j of k appears at
A − seconds·(k − j), where A is the start of the outgoing transition (the next
segment's start under a crossfade, 0.3 s before a hard cut, 0.3 s before the end
@@ -449,6 +457,11 @@ footage, so it rides on a clip.
and the last has the clip's final `seconds`; a clip too short for that shares
what it has after its incoming dissolve. They all leave together over the
transition.
+
+ `at: "start"` brings them up with the clip instead: in the manifest's order
+ (not by date), sharing the whole clip after its incoming dissolve — post j of
+ k at from + j·(A − from)/k. A card beside the words it goes with needs no
+ hold; set `hold: 0`.
- **The hold.** A clip that carries posts is held on its last frame, in silence,
for `hold` seconds (2.5) before its outgoing transition, so the last post can
be read. The hold is part of the clip's length in the CUT: every start after
@@ -464,7 +477,9 @@ footage, so it rides on a clip.
- **What.** The post's date (as the deck writes dates; a date-time is drawn as
its day), `@handle · Bluesky` (or X), the words — paragraphs kept, clamped to
`maxLines` with an ellipsis — and a QR of the post's page on the archive
- (below), or of its own `url`.
+ (below), or of its own `url`. A post with a `shot` draws the screenshot in
+ place of the words, as wide as the card's text and no taller than a full card
+ of words, or than `shotMaxHeight` px when that is set.
- **Where.** A column `inset` from the top of the footage box and from the
FRAME's edge on the `position` side (inside the footage box when `shift` is
`false`), `width` wide. Cards stack top-down; when the next would overflow the
diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs
@@ -199,11 +199,15 @@ export function selectVariant(manifest, variant = DEFAULT_VARIANT) {
});
const kept = new Set(timeline.map((e) => e.id));
const ledger = (manifest.ledger ?? []).filter((c) => c.entryId && kept.has(c.entryId));
+ // A post may be in one cut only, as an entry may.
+ const posts = Array.isArray(manifest.posts)
+ ? manifest.posts.filter((p) => !p?.variant || p.variant === variant)
+ : manifest.posts;
// A brand preset resolves here, at the one door every reader of a cut goes
// through, so the build, verify-build, compose-chrome and umtool's export
// all see the same render block and the same appended end card. No brand:
// the object as built above.
- return brandManifest({ ...manifest, variant, timeline, ledger });
+ return brandManifest({ ...manifest, variant, timeline, ledger, ...(posts !== undefined ? { posts } : {}) });
}
/**
@@ -964,7 +968,13 @@ async function detectSilence(file, render, scan = null) {
// Snap a desired cut to the nearest silence, so the clip begins and ends between
// words instead of through one. Returns the desired point unchanged when no
// silence is close enough — better a tight cut than a cut in the wrong place.
-function snap(desired, intervals, kind, window) {
+//
+// How much of the pause is kept: 0.10 s before speech resumes, 0.18 s after it
+// stops, unless the render says `snapLead` / `snapTail` -- and then never more
+// than the pause holds, so a long one keeps its breath (room for the
+// crossfade to fade over silence, not the last word) without reaching the
+// next word. Exported for the tests.
+export function snap(desired, intervals, kind, window, render = {}) {
let best = null;
for (const iv of intervals) {
// Starting: we want to resume just before speech does -> the silence's END.
@@ -972,10 +982,15 @@ function snap(desired, intervals, kind, window) {
const point = kind === "start" ? iv.e : iv.s;
const d = Math.abs(point - desired);
if (d > window) continue;
- if (!best || d < best.d) best = { d, point };
+ if (!best || d < best.d) best = { d, point, iv };
}
if (!best) return { at: desired, snapped: false };
- const lead = kind === "start" ? -0.10 : 0.18;
+ const set = kind === "start" ? render.snapLead : render.snapTail;
+ let lead = kind === "start" ? -0.10 : 0.18;
+ if (Number.isFinite(set) && set >= 0) {
+ const room = best.iv.e - best.iv.s;
+ lead = (kind === "start" ? -1 : 1) * Math.min(set, room);
+ }
return { at: Math.max(0, best.point + lead), snapped: true };
}
@@ -1054,8 +1069,8 @@ async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes,
const win = render.snapWindow ?? 1.6;
const sil = await detectSilence(raw, render, scan);
- const a = snap(wantA, sil, "start", win);
- const b = snap(wantB, sil, "end", win);
+ const a = snap(wantA, sil, "start", win, render);
+ const b = snap(wantB, sil, "end", win, render);
// Never let snapping invert or collapse the window.
const cutA = Math.min(a.at, wantB - 1);
const cutB = Math.max(b.at, cutA + 1);
diff --git a/umtool/report-to-video/chrome-posts.mjs b/umtool/report-to-video/chrome-posts.mjs
@@ -50,7 +50,7 @@ const esc = (s) =>
const r4 = (v) => Math.round(v * 10000) / 10000;
/** How a platform is named on a card. */
-export const PLATFORM_LABEL = Object.freeze({ bluesky: "Bluesky", x: "X" });
+export const PLATFORM_LABEL = Object.freeze({ bluesky: "Bluesky", x: "X", web: "Web" });
/**
* The motion. Seconds. A card slides in from the frame's edge over `enter`;
@@ -335,9 +335,9 @@ export function postsHtml(schedule, render, window, opts = {}) {
.para + .para { margin-top: ${Math.round(lineH * 0.36)}px; }
.para.gone { display: none; }
/* A post's screenshot in place of its words: as wide as the words would
- be, no taller than a full card of them. */
+ be, no taller than a full card of them -- or than \`shotMaxHeight\`. */
.shot-body { padding: ${pad - 6}px; }
- .shot { display: block; width: 100%; height: auto; max-height: ${Math.round(metaSize * 1.3) + 8 + set.maxLines * lineH + 2 * pad}px;
+ .shot { display: block; width: 100%; height: auto; max-height: ${set.shotMaxHeight ?? Math.round(metaSize * 1.3) + 8 + set.maxLines * lineH + 2 * pad}px;
object-fit: contain; object-position: left top; border-radius: 6px; }
/* The source cell: the QR in a cell a shade down, as on the deck. */
.plate { position: absolute; right: 0; top: 0; bottom: 0; width: ${plateW}px;
diff --git a/umtool/report-to-video/chrome-posts.test.mjs b/umtool/report-to-video/chrome-posts.test.mjs
@@ -478,3 +478,12 @@ test("a post with a screenshot draws it in place of its text card, its QR cell k
// Without shotSrcs the page is the text cards, as before.
assert.doesNotMatch(postsHtml(sched, RENDER, win, { fonts: FONTS }), /class="shot"/);
});
+
+test("shotMaxHeight caps a screenshot in px; unset, a full card of words does", () => {
+ const sched = schedule([POST("a", "2024-10-19T17:01:17.640Z", { shot: "shots/a.png" })]);
+ const win = snapWindow(postWindows(sched)[0], { fps: 30, total: sched.total });
+ const cap = (render) => /\.shot \{[^}]*max-height: (\d+)px/.exec(postsHtml(sched, render, win, { fonts: FONTS, shotSrcs: { a: "assets/shot00.png" } }))[1];
+ const tall = { ...RENDER, chrome: { ...RENDER.chrome, deck: { ...RENDER.chrome.deck, posts: { ...RENDER.chrome.deck?.posts, shotMaxHeight: 820 } } } };
+ assert.equal(cap(tall), "820");
+ assert.notEqual(cap(RENDER), "820");
+});
diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs
@@ -55,9 +55,14 @@ export const DECK_DEFAULTS = Object.freeze({
// archive the manifest names, `provenance.siteOrigin`, which survives the
// post or the platform going away, and links on to the original) or
// "original" (the bsky.app / x.com link itself).
+ // `at` is when a popup's posts come up: "end" (the last `seconds` of the
+ // clip, as above) or "start" (from the clip's start, sharing the whole clip
+ // in the order the manifest lists them -- the post beside the words it goes
+ // with, so no hold is needed). `shotMaxHeight` caps a post's screenshot in
+ // px; null keeps it to the height of a full card of words (`maxLines`).
posts: Object.freeze({
show: true, layout: "popup", 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 }), links: "archive",
+ inset: 24, shift: Object.freeze({ scale: 0.86, seconds: 0.6 }), links: "archive", at: "end", shotMaxHeight: null,
}),
});
@@ -208,7 +213,14 @@ 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", "layout", "seconds", "hold", "position", "width", "qrSize", "maxLines", "inset", "shift", "links"], (p) => {
+ sub("posts", ["show", "layout", "seconds", "hold", "position", "width", "qrSize", "maxLines", "inset", "shift", "links", "at", "shotMaxHeight"], (p) => {
+ if (p.at !== undefined && !POST_AT.includes(p.at)) {
+ errors.push(`${w}.posts.at must be ${POST_AT.map((a) => `"${a}"`).join(" or ")}`);
+ }
+ if (p.shotMaxHeight !== undefined && p.shotMaxHeight !== null &&
+ !(Number.isInteger(p.shotMaxHeight) && numIn(p.shotMaxHeight, 120, 1080))) {
+ errors.push(`${w}.posts.shotMaxHeight must be null or a whole number of pixels from 120 to 1080`);
+ }
if (p.layout !== undefined && !POST_LAYOUTS.includes(p.layout)) {
errors.push(`${w}.posts.layout must be ${POST_LAYOUTS.map((l) => `"${l}"`).join(" or ")}`);
}
@@ -737,12 +749,16 @@ export function pipSegments(schedule) {
// `seconds`; all of them leave in the transition to the next segment.
// ---------------------------------------------------------------------------
-export const POST_PLATFORMS = Object.freeze(["bluesky", "x"]);
+export const POST_PLATFORMS = Object.freeze(["bluesky", "x", "web"]);
+/** When a popup's posts come up (`posts.at`): the end of their clip, or its start. */
+export const POST_AT = Object.freeze(["end", "start"]);
+/** The cuts a post may be limited to (`posts[].variant`) -- build-video's VARIANTS. */
+const POST_VARIANTS = Object.freeze(["sourced", "full"]);
/** How posts are drawn (`posts.layout`): cards at the end of a clip, or a column for the whole cut. */
export const POST_LAYOUTS = Object.freeze(["popup", "feed"]);
/** Where a post's QR links (`posts.links`): its page on the archive, or the platform's own link. */
export const POST_LINKS = Object.freeze(["archive", "original"]);
-const POST_KEYS = ["id", "platform", "author", "handle", "date", "text", "url", "attachTo", "hide", "siteChannel", "siteUrl", "postId", "shot"];
+const POST_KEYS = ["id", "platform", "author", "handle", "date", "text", "url", "attachTo", "hide", "siteChannel", "siteUrl", "postId", "shot", "variant"];
/** The pictures a post's `shot` may be. */
export const POST_SHOT_EXTS = Object.freeze([".png", ".jpg", ".jpeg", ".webp"]);
@@ -798,6 +814,10 @@ export function validatePosts(posts, timeline = [], render = null) {
errors.push(`${w}.attachTo ${JSON.stringify(p.attachTo)} is not a clip in the timeline`);
}
if (p.hide !== undefined && typeof p.hide !== "boolean") errors.push(`${w}.hide must be true or false`);
+ // Like an entry's: a post in one cut only. Its clip should be in that cut too.
+ if (p.variant !== undefined && !POST_VARIANTS.includes(p.variant)) {
+ errors.push(`${w}.variant must be ${POST_VARIANTS.map((v) => `"${v}"`).join(" or ")}`);
+ }
// Where the archive keeps the post: its own channel's slug (a post channel
// is not the video channel -- `piratesoftware-bsky`, not `piratesoftware`),
// a page to link instead, or the post's id when its url does not carry one.
@@ -897,7 +917,12 @@ export function attachPosts({ posts = [], entries = [], metas = [] }) {
* A the start of the outgoing transition as below -- so the last one is in at
* least `step` before its clip leaves. Once in, a post stays to the end.
*
- * In the POPUP, a clip's posts (oldest first) share an anchor A: the start of the outgoing
+ * In the POPUP at the clip's start (`posts.at: "start"`), a clip's posts keep
+ * the manifest's order and share the whole clip: post j of k appears at
+ * from + share·j, from = the clip's start after its incoming dissolve, share =
+ * (A − from)/k with A as below; they leave together over `out`.
+ *
+ * In the POPUP (at the end, the default), a clip's posts (oldest first) share an anchor A: the start of the outgoing
* transition -- the next segment's start with a crossfade, 0.3 s before the cut
* on a hard cut, 0.3 s before the end on the last segment. Post j of k appears
* at A − step·(k − j), step = `posts.seconds`, so each has its seconds alone
@@ -925,7 +950,9 @@ export function postSchedule({ posts = [], entries, metas = [], segments, D, tot
segments.forEach((seg, i) => {
const group = groups.get(seg.id);
if (!group) return;
- group.sort((x, y) => Date.parse(x.date) - Date.parse(y.date));
+ // Oldest first -- except a popup at the clip's start, which keeps the
+ // manifest's order (a claim, then the post that bears on it).
+ if (feed || settings.at !== "start") group.sort((x, y) => Date.parse(x.date) - Date.parse(y.date));
const last = i === segments.length - 1;
const leave = last
? [total - 0.3, total]
@@ -941,6 +968,14 @@ export function postSchedule({ posts = [], entries, metas = [], segments, D, tot
return;
}
const from = seg.start + (i > 0 ? D : 0);
+ if (settings.at === "start") {
+ // The whole clip, shared evenly: post j comes up at from + step·j.
+ const share = Math.max(0, A - from) / k;
+ group.forEach((p, j) => {
+ out.push({ id: p.id, segment: seg.id, slot: j, of: k, appear: from + share * j, out: leave, ...postFields(p) });
+ });
+ return;
+ }
const step = Math.min(settings.seconds, Math.max(0, A - from) / k);
group.forEach((p, j) => {
out.push({
diff --git a/umtool/report-to-video/deck.test.mjs b/umtool/report-to-video/deck.test.mjs
@@ -320,6 +320,38 @@ test("postSchedule: stacked from the end, one every `seconds`, leaving in the tr
]);
});
+test("postSchedule at the clip's start: manifest order, the whole clip shared", () => {
+ const segments = [
+ { id: "c1", start: 0, duration: 10 },
+ { id: "c2", start: 9.5, duration: 12 },
+ { id: "k1", start: 21, duration: 4 },
+ { id: "c3", start: 24.5, duration: 4 },
+ ];
+ const AT_START = { ...RENDER, chrome: { ...CHROME, deck: { posts: { at: "start", hold: 0, shift: false } } } };
+ // Listed newest first: a popup at the start keeps that order.
+ const posts = [POST("w", "2026-01-01", { attachTo: "c2", platform: "web", url: "https://example.org/a" }),
+ POST("x", "2024-10-19", { attachTo: "c2" }), POST("y", "2024-01-01", { attachTo: "c1" })];
+ const s = postSchedule({ posts, entries: CLIPS, metas: METAS, segments, D: 0.5, total: 28.5, render: AT_START });
+ // c2 runs 10 (after its dissolve) to 21 (its leave): 11 s, two posts, 5.5 s each.
+ assert.deepEqual(s.filter((p) => p.segment === "c2").map((p) => [p.id, p.slot, p.appear, p.out]), [
+ ["w", 0, 10, [21, 21.5]],
+ ["x", 1, 15.5, [21, 21.5]],
+ ]);
+ // The first clip has no incoming dissolve: its post is up from 0.
+ assert.deepEqual(s.filter((p) => p.segment === "c1").map((p) => [p.id, p.appear]), [["y", 0]]);
+ assert.equal(s.find((p) => p.id === "w").platform, "web");
+});
+
+test("posts: at, shotMaxHeight, variant and the web platform are validated", () => {
+ const chrome = (posts) => validateChrome({ ...CHROME, deck: { posts } }, RENDER);
+ assert.deepEqual(chrome({ at: "start", shotMaxHeight: 800 }), []);
+ assert.deepEqual(chrome({ shotMaxHeight: null }), []);
+ assert.match(chrome({ at: "middle" }).join(), /posts\.at must be "end" or "start"/);
+ assert.match(chrome({ shotMaxHeight: 40 }).join(), /shotMaxHeight must be null or a whole number/);
+ assert.deepEqual(validatePosts([POST("a", "2026-01-01", { platform: "web", url: "https://example.org/a", variant: "full" })]), []);
+ assert.match(validatePosts([POST("a", "2026-01-01", { variant: "short" })]).join(), /variant must be "sourced" or "full"/);
+});
+
test("deckSchedule carries posts only when there are some; posts.show false drops them", () => {
const timeline = CLIPS;
const base = estimateSchedule({ render: RENDER, provenance: PROV, timeline });
diff --git a/umtool/report-to-video/ledger-totals.test.mjs b/umtool/report-to-video/ledger-totals.test.mjs
@@ -639,6 +639,13 @@ test("selectVariant merges a card's per-variant copy and drops the override key"
assert.equal(selectVariant(VARIANT_MANIFEST, "full").timeline[0].heading, "all 50");
});
+test("selectVariant keeps a post with no variant in every cut and a variant post in its own", () => {
+ const m = { ...VARIANT_MANIFEST, posts: [{ id: "p-all" }, { id: "p-full", variant: "full" }, { id: "p-src", variant: "sourced" }] };
+ assert.deepEqual(selectVariant(m, "sourced").posts.map((p) => p.id), ["p-all", "p-src"]);
+ assert.deepEqual(selectVariant(m, "full").posts.map((p) => p.id), ["p-all", "p-full"]);
+ assert.equal("posts" in selectVariant(VARIANT_MANIFEST, "full"), "posts" in VARIANT_MANIFEST);
+});
+
test("selectVariant refuses a variant nobody defined", () => {
assert.throws(() => selectVariant(VARIANT_MANIFEST, "director's cut"), /unknown variant/);
});
diff --git a/umtool/report-to-video/snap.test.mjs b/umtool/report-to-video/snap.test.mjs
@@ -0,0 +1,24 @@
+// Tests for build-video's snap: where a cut lands in a pause.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { snap } from "./build-video.mjs";
+
+const SIL = [{ s: 10, e: 10.8 }, { s: 20, e: 20.12 }];
+
+test("snap: the default keeps 0.10 s before speech and 0.18 s after it", () => {
+ assert.deepEqual(snap(10.5, SIL, "end", 1.6), { at: 10.18, snapped: true });
+ assert.equal(Number(snap(10.5, SIL, "start", 1.6).at.toFixed(3)), 10.7);
+ assert.deepEqual(snap(15, SIL, "end", 1.6), { at: 15, snapped: false });
+});
+
+test("snap: snapTail / snapLead keep more of the pause, never more than it holds", () => {
+ const render = { snapTail: 0.55, snapLead: 0.45 };
+ assert.equal(snap(10.5, SIL, "end", 1.6, render).at, 10.55);
+ assert.equal(Number(snap(10.5, SIL, "start", 1.6, render).at.toFixed(3)), 10.35);
+ // A 0.12 s gap: the whole gap and no more.
+ assert.equal(Number(snap(20, SIL, "end", 1, render).at.toFixed(3)), 20.12);
+ assert.equal(snap(20.1, SIL, "start", 1, render).at, 20);
+});