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)