Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/scene-clock-through-gap.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions src/scenes/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions src/scenes/sequence-timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
8 changes: 5 additions & 3 deletions src/scenes/sequence-timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Loading