commit 15f845db57fca26363e97b358ecbdc2a8e529d24
parent fcc479278961d7f1227a2b4c9e79b0958873321a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 9 Oct 2026 01:15:39 -0400
threads: a card shows its clips as dim dots ahead, and an outcome waits for the rail to come back
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 21 insertions(+), 7 deletions(-)
diff --git a/umtool/report-to-video/threads.mjs b/umtool/report-to-video/threads.mjs
@@ -144,7 +144,8 @@ export function validateThreadEntries(manifest) {
* - `threads`: each listed thread with its `clips` (`{ segment, at }`: when
* its dot fills -- halfway into the dissolve that brings the clip in) and,
* with an outcome, `closeAt`: when it is stamped -- `OUTCOME_LEAD` before
- * its last clip ends, but never in that clip's first half second;
+ * its last clip ends, but never in that clip's first half second, and never
+ * while the rail is aside (then as it is back);
* - `runs`: when each thread is the one on screen (`{ thread, from, to }`:
* consecutive clips of one thread make one run, ending as the next clip
* starts to come in);
@@ -168,11 +169,19 @@ export function threadSchedule({ segments, D, total, render, moves = [] }) {
t.clips.push({ segment: s.id, at: R(i === 0 ? s.start : s.start + D / 2) });
last.set(t.id, i);
});
+ const asides = moves.map((m) => {
+ const i = segments.findIndex((s) => s.id === m.segment);
+ return { from: R(m.at), to: R(i < 0 ? total : endOf(i)) };
+ });
for (const t of threads) {
if (!t.outcome || !last.has(t.id)) continue;
const i = last.get(t.id);
const s = segments[i];
- t.closeAt = R(Math.max(s.start + 0.5, endOf(i) - D - OUTCOME_LEAD));
+ let at = Math.max(s.start + 0.5, endOf(i) - D - OUTCOME_LEAD);
+ // Stamped where it can be seen: a rail stepped aside for a post gets it as it comes back.
+ const hidden = asides.find((a) => at >= a.from - 0.2 && at < a.to + THREAD_MOTION.aside);
+ if (hidden) at = hidden.to + THREAD_MOTION.aside + 0.1;
+ t.closeAt = R(at);
}
const runs = [];
segments.forEach((s, i) => {
@@ -183,10 +192,6 @@ export function threadSchedule({ segments, D, total, render, moves = [] }) {
prev.lastIdx = i;
} else runs.push({ thread: s.thread, from: R(s.start), to: R(endOf(i)), lastIdx: i });
});
- const asides = moves.map((m) => {
- const i = segments.findIndex((s) => s.id === m.segment);
- return { from: R(m.at), to: R(i < 0 ? total : endOf(i)) };
- });
return { threads, runs: runs.map(({ lastIdx, ...r }) => r), asides };
}
@@ -225,6 +230,9 @@ export function planCues(init, events, instant = 0.001) {
/** A card's resting opacity before its thread plays, and after. */
export const CARD_REST = Object.freeze({ ahead: 0.42, done: 0.8 });
+/** A dot before its clip plays: there, hollow-looking and small, so a card shows how many clips its thread has. */
+export const DOT_AHEAD = Object.freeze({ opacity: 0.22, scale: 0.7 });
+
/**
* Everything the rail's timeline does, as data. Card j is `c<j>` (opacity), its
* lit wash `w<j>` and string `s<j>` (scaleX from its left), its dots
@@ -244,7 +252,7 @@ export function threadCues(sched) {
init[`c${j}`] = { opacity: 0 };
init[`w${j}`] = { opacity: 0 };
init[`s${j}`] = { scaleX: 0 };
- t.clips.forEach((_, n) => { init[`d${j}-${n}`] = { opacity: 0, scale: 0.4 }; });
+ t.clips.forEach((_, n) => { init[`d${j}-${n}`] = { ...DOT_AHEAD }; });
// A card whose thread is already on screen by the end of its intro comes up lit.
const lit = (firstRun.get(t.id) ?? Infinity) <= m.stagger * j + m.intro;
add(`c${j}`, m.stagger * j, m.intro, { opacity: lit ? 1 : CARD_REST.ahead }, "power2.out", `intro ${t.id}`);
diff --git a/umtool/report-to-video/threads.test.mjs b/umtool/report-to-video/threads.test.mjs
@@ -124,6 +124,11 @@ test("the schedule: dots, runs, outcomes on the last clip, asides for posts", ()
{ thread: "aside", from: 47.5, to: 53.5 },
]);
assert.deepEqual(s.asides, [{ from: 30, to: 38 }]);
+ // An outcome that would land while the rail is aside lands as it comes back.
+ const hid = threadSchedule({
+ segments: SEGS, D: 0.5, total: 53.5, render: RENDER, moves: [{ segment: "s4", at: 40 }],
+ });
+ assert.equal(hid.threads[0].closeAt, 47.5 + 0.4 + 0.1);
// A clip too short for the lead is stamped half a second in, never before.
const short = threadSchedule({
segments: [{ id: "x", start: 0, duration: 1.2, thread: "proof" }], D: 0.5, total: 1.2, render: RENDER,
@@ -146,6 +151,7 @@ test("the cues state every from, light and dim each run, and stamp each outcome
assert.equal(rests.length, 2);
assert.equal(cues.filter((c) => c.k === "o0" && c.to.autoAlpha === 1).length, 1);
assert.equal(init.o2, undefined, "no outcome, no stamp");
+ assert.deepEqual(init["d0-0"], { opacity: 0.22, scale: 0.7 }, "a dot is there, small and dim, before its clip");
assert.deepEqual(cues.filter((c) => c.k === "rail").map((c) => c.to.autoAlpha), [0, 1]);
// One element's cues never overlap.
const byK = {};