diff --git a/.changeset/scene-clock-through-gap.md b/.changeset/scene-clock-through-gap.md new file mode 100644 index 0000000..fb27a24 --- /dev/null +++ b/.changeset/scene-clock-through-gap.md @@ -0,0 +1,5 @@ +--- +"@karnstack/kino": minor +--- + +The scene clock now keeps running through the silence gap after a scene's narration, up to the scene's `end`. Before, it stopped at the end of the narration, so a looping animation (data moving between two boxes) froze for the length of the gap. Cue progress already stops at its end value, so cue-driven state looks the same as before. A scene that reads the raw time and does not stop it at `duration` now sees values past `duration` during the gap. diff --git a/README.md b/README.md index 92e82b5..f4c72be 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,7 @@ type SceneManifest = { } ``` -A scene owns `[start, end)` on the sequence clock. `end` includes the trailing silence after the scene's narration; the scene-local clock clamps to the narration length, so the scene holds its final settled state through the gap. Scenes should tile the clock with no gaps; the host warns once at startup if they do not. `src` records where a scene module lives for tooling, but the host actually loads modules through the `loadScene` callback. +A scene owns `[start, end)` on the sequence clock. `end` includes the trailing silence after the scene's narration. The scene-local clock keeps running through that gap, up to `end`, so looping animations keep moving until the next scene starts; cue progress already stops at its end value, so cue-driven state stays settled. Scenes should tile the clock with no gaps; the host warns once at startup if they do not. `src` records where a scene module lives for tooling, but the host actually loads modules through the `loadScene` callback. ### The wire protocol diff --git a/src/scenes/protocol.ts b/src/scenes/protocol.ts index fa6b28d..8995239 100644 --- a/src/scenes/protocol.ts +++ b/src/scenes/protocol.ts @@ -2,8 +2,8 @@ import type { Cues } from "./cues" // A scene sequence is one audio file plus React scene modules mapped onto its // timeline. start/end are global seconds; end includes the trailing silence -// gap after the scene's narration, during which the scene holds its final -// settled state. +// gap after the scene's narration. The scene clock keeps running through the +// gap, so looping animations keep moving; cue-driven state stays settled. export type SceneManifestScene = { id: string // Module URL resolving to { default: React.ComponentType }. Absolute, or diff --git a/src/scenes/sequence-timeline.test.ts b/src/scenes/sequence-timeline.test.ts index d5f4017..fd41b00 100644 --- a/src/scenes/sequence-timeline.test.ts +++ b/src/scenes/sequence-timeline.test.ts @@ -36,11 +36,13 @@ test("sceneAt clamps outside the sequence to first/last scene", () => { expect(sceneAt([], 5)).toBe(null) }) -test("localTime maps global t into the scene and clamps to the narration", () => { +test("localTime maps global t into the scene and runs through the trailing gap", () => { expect(localTime(scenes[1], 24.66)).toBe(0) expect(localTime(scenes[1], 30)).toBeCloseTo(5.34, 5) - // Inside the trailing gap the clock pins to audioDuration: the scene holds - // its final settled state instead of running past its cues. - expect(localTime(scenes[0], 24.5)).toBe(24.16) + // Inside the trailing gap the clock keeps running past audioDuration, so + // looping animations do not freeze before the next scene starts. + expect(localTime(scenes[0], 24.5)).toBeCloseTo(24.5, 5) expect(localTime(scenes[1], 20)).toBe(0) + // Past the scene's end (the last scene, after the sequence) it stops at the end. + expect(localTime(scenes[2], 999)).toBeCloseTo(5.34, 5) }) diff --git a/src/scenes/sequence-timeline.ts b/src/scenes/sequence-timeline.ts index 225a02c..84c06d0 100644 --- a/src/scenes/sequence-timeline.ts +++ b/src/scenes/sequence-timeline.ts @@ -16,8 +16,10 @@ export function sceneAt( return scenes[scenes.length - 1] ?? first } -// Scene-local clock value for global time t. Clamped to the narration length -// so the trailing silence gap holds the final settled state. +// Scene-local clock value for global time t. The clock keeps running through +// the trailing silence gap, up to the scene's end, so a looping animation (data +// moving along a path) keeps moving until the next scene starts. Cue-driven +// state is already settled there: cue progress stops at its end value. export function localTime(scene: SceneManifestScene, t: number): number { - return Math.max(0, Math.min(scene.cues.audioDuration, t - scene.start)) + return Math.max(0, Math.min(scene.end - scene.start, t - scene.start)) }