From 0ac9d57adfd048912215cffe7af10a4fa3f06606 Mon Sep 17 00:00:00 2001 From: Karn Date: Sun, 26 Jul 2026 03:40:41 +0530 Subject: [PATCH] feat(scenes): loop the ambient preview at 2x by default The preview shipped locked to 1x on the theory that a viewer's saved rate is how fast they want to learn, not how fast a teaser should move, and that a faster pass would desync the stage: clock-driven phase logic racing ahead while wall-time transitions stayed at their authored speed. That second half is wrong. Scenes compute everything from the sequence clock, deliberately, because the render path drives them under a fake clock and wall-time CSS transitions either fail to progress or snap to the end under it. So the whole stage scales cleanly with the rate and a faster loop is not visually broken, just faster. What remained was taste, and the livelier read wins: double speed also covers the window in half the time, so the player settles in half as long. `preview` grows an optional `rate` for callers who want a different pace. It stays the loop's own rate rather than the viewer's either way, and theirs is still restored on the hand-back. A zero, negative or non-finite rate falls back to the default instead of freezing the clock at the first frame, which the grace timer would otherwise read as refused autoplay and settle. --- .changeset/loud-eels-argue.md | 5 +++++ src/scenes/provider.test.ts | 35 ++++++++++++++++++++++++++-------- src/scenes/provider.ts | 36 +++++++++++++++++++++++++++++------ 3 files changed, 62 insertions(+), 14 deletions(-) create mode 100644 .changeset/loud-eels-argue.md diff --git a/.changeset/loud-eels-argue.md b/.changeset/loud-eels-argue.md new file mode 100644 index 0000000..e2eedc0 --- /dev/null +++ b/.changeset/loud-eels-argue.md @@ -0,0 +1,5 @@ +--- +"@karnstack/kino": minor +--- + +Scenes: the ambient preview now loops at 2x by default, and `preview` takes an optional `rate` to override it. Scenes compute everything from the sequence clock rather than wall time (the render path drives them under a fake clock, so wall-time CSS transitions would not survive it), which means the whole stage scales cleanly with the rate instead of desyncing. The faster pass reads livelier and covers the window in half the time, so the player settles sooner. The loop's rate is its own either way: the viewer's rate is restored when they take the clock back, and a rate that would stall the clock falls back to the default rather than letting the grace timer mistake a frozen loop for refused autoplay. diff --git a/src/scenes/provider.test.ts b/src/scenes/provider.test.ts index 4ce630c..db05745 100644 --- a/src/scenes/provider.test.ts +++ b/src/scenes/provider.test.ts @@ -1177,16 +1177,35 @@ test("destroy tears down a preview that never started", () => { vi.unstubAllGlobals() }) -test("the loop always runs at 1x, whatever speed the viewer watches at", () => { - const { p, iframe, posted } = mountPreviewing({ defaultRate: 2.5 }) - // The viewer's speed is how fast they want to learn, not how fast a teaser - // should move. - expect(posted).toContainEqual({ type: "kino:setRate", rate: 1 }) +test("the loop runs at its own rate, not the viewer's, and hands theirs back", () => { + const { p, iframe, posted } = mountPreviewing({ defaultRate: 1.2 }) + // 2x by default, whatever speed the viewer watches at. + expect(posted).toContainEqual({ type: "kino:setRate", rate: 2 }) fromHost(iframe, playingAt(5)) posted.length = 0 - // It comes back on the hand-back, not before. + // Theirs comes back on the hand-back, not before. p.actions.play() - expect(posted).toContainEqual({ type: "kino:setRate", rate: 2.5 }) - expect(p.getState().rate).toBe(2.5) + expect(posted).toContainEqual({ type: "kino:setRate", rate: 1.2 }) + expect(p.getState().rate).toBe(1.2) + p.destroy() +}) + +test("an explicit preview rate overrides the default", () => { + const { p, posted } = mountPreviewing({ + preview: { endSeconds: 20, rate: 1 }, + }) + expect(posted).toContainEqual({ type: "kino:setRate", rate: 1 }) p.destroy() }) + +test("a rate that would stall the loop falls back to the default", () => { + // Zero or negative would freeze the clock at the first frame, which the + // grace timer would then read as refused autoplay and settle. + for (const rate of [0, -1, Number.NaN]) { + const { p, posted } = mountPreviewing({ + preview: { endSeconds: 20, rate }, + }) + expect(posted).toContainEqual({ type: "kino:setRate", rate: 2 }) + p.destroy() + } +}) diff --git a/src/scenes/provider.ts b/src/scenes/provider.ts index 884789f..e322e3e 100644 --- a/src/scenes/provider.ts +++ b/src/scenes/provider.ts @@ -42,6 +42,14 @@ export type ScenesPreviewOptions = { * on arrival and calm afterwards rather than animating forever. */ cycles?: number + /** + * Speed of the loop, defaulting to 2x. Scenes are driven off the sequence + * clock rather than wall time, so the whole stage scales cleanly with the + * rate; the faster pass reads livelier and covers the window in half the + * time, so the player settles sooner. Independent of the viewer's own rate, + * which is restored when they take the clock back. + */ + rate?: number } export type ScenesProviderOptions = { @@ -78,6 +86,12 @@ export type ScenesProvider = Provider & { const TRACK_ID = "captions" const PREVIEW_DEFAULT_CYCLES = 2 +// Default loop speed. Scenes compute everything from the sequence clock (the +// render path drives them under a fake clock, so wall-time CSS transitions +// would not survive it), which means the stage scales cleanly with the rate +// instead of desyncing. Double speed reads livelier and halves how long the +// player animates before it settles. +const PREVIEW_DEFAULT_RATE = 2 // How far back from the window's end the settled frame sits. Far enough inside // the opening scene that the clock cannot tip into the next one, close enough // that the scene is holding its final state rather than still animating. @@ -90,7 +104,12 @@ const PREVIEW_START_GRACE_MS = 800 // Anything inside this window of the seek target counts as the host agreeing. const PREVIEW_RESUME_EPSILON_S = 0.5 -type NormalizedPreview = { end: number; cycles: number; settleAt: number } +type NormalizedPreview = { + end: number + cycles: number + rate: number + settleAt: number +} function normalizePreview( opts: ScenesProviderOptions, @@ -105,7 +124,13 @@ function normalizePreview( 1, Math.floor(preview.cycles ?? PREVIEW_DEFAULT_CYCLES), ) - return { end, cycles, settleAt: end - PREVIEW_SETTLE_BACKOFF_S } + // A zero or negative rate would stall the loop at the first frame, and the + // grace timer would read that as refused autoplay and settle it. + const rate = + Number.isFinite(preview.rate) && (preview.rate ?? 0) > 0 + ? (preview.rate as number) + : PREVIEW_DEFAULT_RATE + return { end, cycles, rate, settleAt: end - PREVIEW_SETTLE_BACKOFF_S } } type NavigatorWithConnection = Navigator & { @@ -300,10 +325,9 @@ export function createScenesProvider( // Muted before playing: the muted-autoplay exemption is what lets this // start without any user activation. send({ type: "kino:setMuted", muted: true }) - // Always 1x, whatever speed the viewer watches at. Their rate is how fast - // they want to learn, not how fast a teaser should move; a saved 2x or - // 2.5x turns the loop frantic. The real rate rides the hand-back. - send({ type: "kino:setRate", rate: 1 }) + // The loop's own rate, not the viewer's: the tease runs at one deliberate + // speed for everyone. Theirs rides the hand-back. + send({ type: "kino:setRate", rate: preview.rate }) send({ type: "kino:seek", time: 0 }) send({ type: "kino:play" }) previewGrace = setTimeout(settlePreview, PREVIEW_START_GRACE_MS)