diff --git a/.changeset/scenes-storyboard.md b/.changeset/scenes-storyboard.md new file mode 100644 index 0000000..739be64 --- /dev/null +++ b/.changeset/scenes-storyboard.md @@ -0,0 +1,11 @@ +--- +"@karnstack/kino": minor +--- + +Scenes storyboard passthrough: `ScenesProviderOptions` (and therefore +`ScenesPlayer`) accept `storyboard: { vttUrl }`, surfacing +`capabilities.hasStoryboard` and `state.storyboard` so the scrubber shows +thumbnail previews on hover, exactly as it does for Mux sources. The VTT's +cues point into a sprite image via `#xywh` fragments (the same format the Mux +storyboard track uses). `SceneManifest` gains an optional `storyboard` field +recording where that VTT lives. diff --git a/README.md b/README.md index 4d3bce7..815c4ce 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,8 @@ export function Watch() { Captions are a sidecar VTT fetched and rendered by the parent in kino's own caption overlay, so the host page never deals with text tracks. +Storyboards work the same way: pass `storyboard={{ vttUrl }}` and the scrubber shows thumbnail previews on hover, exactly as it does for Mux sources. The VTT's cues point into a sprite image via `#xywh` fragments (the format Mux storyboard tracks use); whatever produces the sequence generates both files, kino only fetches the VTT and paints the tiles while you scrub. As with `src`, the URL may carry an auth token, already encoded by the caller. + kino sets `allow="autoplay; fullscreen"` on the iframe it creates. That delegates the parent page's user activation (the click on kino's play button) into the frame; without it, `audio.play()` inside the host is blocked by autoplay policy. If your app itself runs inside an iframe, the outer frame needs the same `allow` list. ### The host page @@ -240,6 +242,7 @@ type SceneManifest = { audio: Array<{ bitrate: number; src: string }> captions?: string poster?: string + storyboard?: string // thumbnail VTT with #xywh fragments; read by embedders, not the host chapters?: Array<{ id: string; title: string; start: number }> } ``` diff --git a/src/scenes/protocol.ts b/src/scenes/protocol.ts index a32c6d3..05c63e8 100644 --- a/src/scenes/protocol.ts +++ b/src/scenes/protocol.ts @@ -23,6 +23,10 @@ export type SceneManifest = { audio: Array<{ bitrate: number; src: string }> captions?: string poster?: string + // Relative URL of a thumbnail VTT whose cues point into a sprite image via + // #xywh fragments, the same format the Mux storyboard track uses. Consumed + // by embedding players (scrubber hover previews), not by the host. + storyboard?: string chapters?: Array<{ id: string; title: string; start: number }> } diff --git a/src/scenes/provider.test.ts b/src/scenes/provider.test.ts index 33beb99..5660a27 100644 --- a/src/scenes/provider.test.ts +++ b/src/scenes/provider.test.ts @@ -73,6 +73,22 @@ test("capabilities: rate yes, quality/storyboard/pip no, captions when provided" p2.destroy() }) +test("storyboard option reports the capability and exposes the vtt url", () => { + const vttUrl = "https://scenes.example.com/l/demo/storyboard.vtt?token=abc" + const p = createScenesProvider({ src: SRC, storyboard: { vttUrl } }) + const s = p.getState() + expect(s.capabilities.hasStoryboard).toBe(true) + expect(s.storyboard).toEqual({ vttUrl }) + p.destroy() +}) + +test("without a storyboard option the capability is off and state is null", () => { + const p = createScenesProvider({ src: SRC }) + expect(p.getState().capabilities.hasStoryboard).toBe(false) + expect(p.getState().storyboard).toBe(null) + p.destroy() +}) + test("ready handshake replies with init carrying rate and autoplay", () => { const p = createScenesProvider({ src: SRC, defaultRate: 1.5, autoPlay: true }) const { iframe } = mount(p) diff --git a/src/scenes/provider.ts b/src/scenes/provider.ts index 235c36c..7144187 100644 --- a/src/scenes/provider.ts +++ b/src/scenes/provider.ts @@ -7,6 +7,10 @@ export type ScenesProviderOptions = { // Full URL of the host page, token and sequence already encoded by the caller. src: string captions?: { src: string; label: string; srclang: string } + // Thumbnail VTT for scrubber hover previews: cues whose payload points into + // a sprite image via #xywh fragments, same format as the Mux storyboard + // track. Full URL, any auth token already encoded by the caller. + storyboard?: { vttUrl: string } metadata?: { videoId?: string; videoTitle?: string; viewerUserId?: string } defaultRate?: number autoPlay?: boolean @@ -46,10 +50,11 @@ export function createScenesProvider(opts: ScenesProviderOptions): Provider { ...defaultState(), rate: desiredRate, muted: opts.muted ?? false, + storyboard: opts.storyboard ? { vttUrl: opts.storyboard.vttUrl } : null, capabilities: { // The stage is resolution independent DOM; there is no rendition ladder. canSetQuality: false, - hasStoryboard: false, + hasStoryboard: opts.storyboard != null, // No parent-side media element to promote into PiP. canPiP: false, canFullscreen: true,