From 8f2ea0a43fb6b4b1f6d3a351b19de964c40e8621 Mon Sep 17 00:00:00 2001 From: Karn Date: Sat, 10 Oct 2026 16:14:03 +0530 Subject: [PATCH] fix: keep looping scene animations moving in the gap between scenes The scene clock stopped at the end of the narration. In the silence gap after it, every scene froze, so data moving between two boxes stopped until the next scene began. The clock now runs up to the scene's end. Co-Authored-By: Claude Opus 5.5 --- .changeset/scene-clock-through-gap.md | 5 +++++ README.md | 2 +- src/scenes/protocol.ts | 4 ++-- src/scenes/sequence-timeline.test.ts | 10 ++++++---- src/scenes/sequence-timeline.ts | 8 +++++--- 5 files changed, 19 insertions(+), 10 deletions(-) create mode 100644 .changeset/scene-clock-through-gap.md 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)) }