@vanillaskyai/video 0.3.4 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,30 @@ VanillaSky follows semantic versioning. This changelog begins with the 0.1 beta.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.4.1
8
+
9
+ - Keeps soundtrack audio continuous when a saved `VideoPlayer` uses `loop`.
10
+ A track shorter than the visual timeline now repeats as soon as it ends,
11
+ instead of leaving silence until the video itself wraps.
12
+
13
+ ## 0.4.0
14
+
15
+ - Adds `loop` to `VideoPlayer` for saved videos. A completed video previously
16
+ painted its replay affordance and stopped, so there was no way to run one
17
+ continuously; with `loop` it restarts from the beginning and keeps its
18
+ soundtrack in step. Streaming playback is unchanged.
19
+ - Adds `onSceneChange(scene, index)` to `VideoPlayer`, fired when the scene
20
+ under the playhead changes and again on index `0` each time a loop wraps.
21
+ Nothing previously reported playback position to the host: `onComplete`
22
+ reports the end of a stream, so it never fires for a saved video, and any UI
23
+ that had to stay in step ran a parallel timer that drifted silently.
24
+ - Exports `resolveVideoBrand` from the root entry. `parseVideo` requires a
25
+ fully resolved brand, and the resolver already existed internally, so
26
+ hand-authoring a `Video` meant copying a defaults blob into application code.
27
+ - Documents live channels — continuously playing, self-refreshing video built
28
+ from your own data rather than generated per viewer — in
29
+ `docs/live-channels.md`, with an example compiled against the packed package.
30
+
7
31
  ## 0.3.4
8
32
 
9
33
  - Mounts a scene that brings a new photo or video 1.2s before it appears,
package/PUBLIC-API.md CHANGED
@@ -63,6 +63,7 @@ a request, renders React, imports a provider, or accesses browser globals.
63
63
 
64
64
  - `getVideoDuration(video: Video): number`
65
65
  - `parseVideo(value: unknown): Video`
66
+ - `resolveVideoBrand(input?: VideoBrandInput): VideoBrand`
66
67
  - `VideoValidationError`
67
68
 
68
69
  ### Types
@@ -190,6 +191,17 @@ wants the completed video to begin with the first generated scene.
190
191
  Saved-video playback performs no generation request. `VideoPlayerBinding` and
191
192
  the internal reducer state are not public types.
192
193
 
194
+ `loop` and `onSceneChange` apply to saved-video playback. `loop` restarts a
195
+ completed video from the beginning instead of showing the replay affordance;
196
+ `onSceneChange(scene, index)` fires whenever the scene under the playhead
197
+ changes, including when a loop wraps back to the first scene. Streaming
198
+ playback is unaffected by either.
199
+
200
+ `resolveVideoBrand` fills a partial brand with the documented defaults and
201
+ preset backgrounds, producing the fully resolved `VideoBrand` that `parseVideo`
202
+ requires. Use it when authoring a `Video` by hand rather than copying default
203
+ values into application code.
204
+
193
205
  ## Template authoring
194
206
 
195
207
  The template entry point owns React render definitions only. React-free server
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Give your AI a video output
2
2
 
3
- ![Version 0.3.4 beta](https://img.shields.io/badge/version-0.3.4_beta-7c3aed)
3
+ ![Version 0.4.1 beta](https://img.shields.io/badge/version-0.4.1_beta-7c3aed)
4
4
 
5
5
  **VanillaSky is the open-source video response layer.** Turn text, structured
6
6
  data, and live application context into personalized video responses that start
@@ -17,7 +17,7 @@ the planning prompt, trusted templates, validation, streaming, and player.
17
17
  For humans:
18
18
 
19
19
  ```bash
20
- npm install @vanillaskyai/video@0.3.4 ai @ai-sdk/openai
20
+ npm install @vanillaskyai/video@0.4.1 ai @ai-sdk/openai
21
21
  ```
22
22
 
23
23
  For coding agents:
@@ -106,7 +106,7 @@ shows each complete, validated scene as soon as it is ready and returns a
106
106
  deterministic `Video` object when generation finishes.
107
107
 
108
108
  A copy-and-run app is in
109
- [`examples/nextjs-quickstart`](https://github.com/VanillaSkyAi/video/tree/v0.3.4/examples/nextjs-quickstart).
109
+ [`examples/nextjs-quickstart`](https://github.com/VanillaSkyAi/video/tree/v0.4.1/examples/nextjs-quickstart).
110
110
 
111
111
  ## Shape the response
112
112
 
@@ -152,6 +152,7 @@ compiler: `npm install --save-dev tsx`.
152
152
  | Add brand or viewer context | [Branding and personalization](docs/branding-and-personalization.md) |
153
153
  | Add media or soundtrack audio | [Media and soundtrack audio](docs/media-and-audio.md) |
154
154
  | Persist and replay results | [Persistence and replay](docs/persistence.md) |
155
+ | Run a looping, self-refreshing channel | [Live channels](docs/live-channels.md) |
155
156
  | Create source-owned templates | [Custom templates](docs/custom-templates.md) |
156
157
  | Test routes and streams | [Test integrations](docs/testing.md) |
157
158
  | Deploy securely | [Production](docs/production.md) · [Security](docs/security.md) |
@@ -1,4 +1,4 @@
1
- import { f as VideoOrientation, h as VideoStyle, V as Video, k as VideoCapabilities, a as VideoAudio, g as VideoScene, l as VIDEO_PROTOCOL_VERSION } from './types-CVMb6QEq.js';
1
+ import { b as VideoOrientation, V as VideoStyle, c as Video, a as VideoCapabilities, d as VideoAudio, e as VideoScene, f as VIDEO_PROTOCOL_VERSION } from './types-Cg0Onj7l.js';
2
2
 
3
3
  type VideoWarningCategory = "validation" | "readability" | "grounding" | "provider" | "media" | "protocol";
4
4
  type VideoWarningCode = "scene_duration_adjusted" | "scene_omitted_unreadable" | "scene_omitted_for_closer" | "scene_patch_rejected_readability" | "chart_scale_imbalance" | "plan_incomplete" | "plan_missing_closer" | "provider_warning" | "provider_diagnostics_unavailable";
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { V as Video } from './types-CVMb6QEq.js';
2
- export { a as VideoAudio, b as VideoBackground, c as VideoBrand, d as VideoInput, e as VideoKnowledgeMode, f as VideoOrientation, g as VideoScene, h as VideoStyle, i as VideoStyleOptions, j as VideoSuppliedMedia } from './types-CVMb6QEq.js';
1
+ import { c as Video, g as VideoBrandInput, h as VideoBrand } from './types-Cg0Onj7l.js';
2
+ export { d as VideoAudio, i as VideoBackground, j as VideoInput, k as VideoKnowledgeMode, b as VideoOrientation, e as VideoScene, V as VideoStyle, l as VideoStyleOptions, m as VideoSuppliedMedia } from './types-Cg0Onj7l.js';
3
3
  export { V as VideoStatus } from './state-DZcKuS32.js';
4
4
 
5
5
  type VideoValidationErrorCode = "invalid_video" | "unsupported_video_version";
@@ -12,4 +12,6 @@ declare function parseVideo(value: unknown): Video;
12
12
 
13
13
  declare function getVideoDuration(config: Video): number;
14
14
 
15
- export { Video, VideoValidationError, type VideoValidationErrorCode, getVideoDuration, parseVideo };
15
+ declare function resolveVideoBrand(input?: VideoBrandInput): VideoBrand;
16
+
17
+ export { Video, VideoBrand, VideoBrandInput, VideoValidationError, type VideoValidationErrorCode, getVideoDuration, parseVideo, resolveVideoBrand };
package/dist/index.js CHANGED
@@ -6,9 +6,12 @@ import "./chunk-EE6PK6XC.js";
6
6
  import {
7
7
  getVideoDuration
8
8
  } from "./chunk-SPVTJH3F.js";
9
- import "./chunk-XGRA2MUV.js";
9
+ import {
10
+ resolveVideoBrand
11
+ } from "./chunk-XGRA2MUV.js";
10
12
  export {
11
13
  VideoValidationError,
12
14
  getVideoDuration,
13
- parseVideo
15
+ parseVideo,
16
+ resolveVideoBrand
14
17
  };
@@ -1,4 +1,4 @@
1
- import { h as VideoStyle, k as VideoCapabilities } from './types-CVMb6QEq.js';
1
+ import { V as VideoStyle, a as VideoCapabilities } from './types-Cg0Onj7l.js';
2
2
  import { ComponentType } from 'react';
3
3
  import { S as SceneTemplateMetadata, d as TemplateJsonSchema, f as TemplateJsonSchemaProperty } from './catalog-types-BIhSpOWK.js';
4
4
 
package/dist/react.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { CSSProperties, ReactElement } from 'react';
2
- import { V as VideoEvent, a as VideoWarning } from './events-tQ0x-VaL.js';
3
- import { f as VideoOrientation, V as Video, d as VideoInput } from './types-CVMb6QEq.js';
4
- import { T as TemplateRegistry } from './kit-DlUSg8lA.js';
2
+ import { V as VideoEvent, a as VideoWarning } from './events-D_1F6gw5.js';
3
+ import { b as VideoOrientation, c as Video, e as VideoScene, j as VideoInput } from './types-Cg0Onj7l.js';
4
+ import { T as TemplateRegistry } from './kit-DLeCr3d0.js';
5
5
  import { V as VideoStatus } from './state-DZcKuS32.js';
6
6
  import './catalog-types-BIhSpOWK.js';
7
7
 
@@ -22,8 +22,12 @@ interface VideoPlayerSharedProps {
22
22
  style?: CSSProperties;
23
23
  /** Accessible name for the player region. */
24
24
  ariaLabel?: string;
25
+ /** Repeat the saved video and its soundtrack instead of showing the replay affordance. */
26
+ loop?: boolean;
25
27
  onComplete?: (video: Video) => void;
26
28
  onError?: (error: Error) => void;
29
+ /** Fires when the scene under the playhead changes, including on a loop wrap. */
30
+ onSceneChange?: (scene: VideoScene, index: number) => void;
27
31
  }
28
32
  type VideoPlayerProps = VideoPlayerSharedProps & ({
29
33
  video: Video;
package/dist/react.js CHANGED
@@ -211,8 +211,10 @@ function VideoPlayerRuntime({
211
211
  className,
212
212
  style,
213
213
  ariaLabel = "Video response",
214
+ loop = false,
214
215
  onComplete,
215
216
  onError,
217
+ onSceneChange,
216
218
  onStateChange
217
219
  }) {
218
220
  const [reducedMotion, setReducedMotion] = useState(
@@ -238,10 +240,13 @@ function VideoPlayerRuntime({
238
240
  const timeRef = useRef(currentTime);
239
241
  const audioRef = useRef(null);
240
242
  const introStartedAtRef = useRef(autoStartGeneration ? performance.now() : null);
241
- const callbacksRef = useRef({ onComplete, onError, onStateChange });
243
+ const callbacksRef = useRef({ onComplete, onError, onSceneChange, onStateChange });
244
+ const loopRef = useRef(loop);
245
+ const sceneIndexRef = useRef(-1);
242
246
  stateRef.current = state;
243
247
  timeRef.current = currentTime;
244
- callbacksRef.current = { onComplete, onError, onStateChange };
248
+ callbacksRef.current = { onComplete, onError, onSceneChange, onStateChange };
249
+ loopRef.current = loop;
245
250
  const primeSoundtrack = () => {
246
251
  const audio = audioRef.current;
247
252
  if (!audio || audio.dataset.audioOutput) return;
@@ -367,25 +372,50 @@ function VideoPlayerRuntime({
367
372
  const config2 = current.config;
368
373
  const delta = Math.max(0, (now - previous) / 1e3);
369
374
  previous = now;
375
+ const settled = current.status === "complete" || current.status === "error" || current.status === "aborted";
376
+ const looping = loopRef.current && settled;
377
+ let wrapped = false;
370
378
  if (config2?.scenes.length) {
371
379
  const duration3 = getVideoDuration(config2);
372
- const nextTime = Math.min(timeRef.current + delta, duration3);
380
+ const raw = timeRef.current + delta;
381
+ let nextTime;
382
+ if (looping && duration3 > 0 && raw >= duration3) {
383
+ nextTime = raw % duration3;
384
+ wrapped = true;
385
+ } else {
386
+ nextTime = Math.min(raw, duration3);
387
+ }
373
388
  if (nextTime !== timeRef.current) {
374
389
  timeRef.current = nextTime;
375
390
  setCurrentTime(nextTime);
376
391
  }
377
392
  const audio = audioRef.current;
378
393
  if (audio && (current.status === "complete" || current.status === "error")) {
394
+ if (wrapped) {
395
+ audio.currentTime = 0;
396
+ if (audio.paused) void audio.play().catch(Boolean);
397
+ }
379
398
  const fadeSeconds = Math.max(0, (config2.audio?.fadeOutMs ?? 3e3) / 1e3);
380
399
  const remaining = Math.max(0, duration3 - nextTime);
381
400
  const baseVolume = config2.audio?.volume ?? 1;
382
401
  audio.volume = fadeSeconds > 0 ? baseVolume * Math.min(1, remaining / fadeSeconds) : baseVolume;
383
- if (remaining <= 0) audio.pause();
402
+ if (remaining <= 0 && !looping) audio.pause();
403
+ }
404
+ const notify = callbacksRef.current.onSceneChange;
405
+ if (notify) {
406
+ if (wrapped) sceneIndexRef.current = -1;
407
+ const ranges = resolveVideoTimeline(config2);
408
+ const index = ranges.findIndex((range) => nextTime >= range.start && nextTime < range.end);
409
+ const resolved = index === -1 && nextTime >= duration3 ? ranges.length - 1 : index;
410
+ if (resolved !== -1 && resolved !== sceneIndexRef.current) {
411
+ sceneIndexRef.current = resolved;
412
+ notify(ranges[resolved].scene, resolved);
413
+ }
384
414
  }
385
415
  }
386
- const terminal2 = current.status === "complete" || current.status === "error" || current.status === "aborted";
416
+ const terminal2 = settled;
387
417
  const duration2 = current.config ? getVideoDuration(current.config) : 0;
388
- if (!terminal2 || timeRef.current < duration2) frame = requestAnimationFrame(tick);
418
+ if (!terminal2 || looping || timeRef.current < duration2) frame = requestAnimationFrame(tick);
389
419
  else setIsPlaying(false);
390
420
  };
391
421
  frame = requestAnimationFrame(tick);
@@ -470,7 +500,7 @@ function VideoPlayerRuntime({
470
500
  const displayConfig = config && config.orientation !== orientation ? { ...config, orientation } : config;
471
501
  const duration = config ? getVideoDuration(config) : 0;
472
502
  const terminal = state.status === "complete" || state.status === "error" || state.status === "aborted";
473
- const ended = terminal && duration > 0 && currentTime >= duration - 1e-3;
503
+ const ended = !loop && terminal && duration > 0 && currentTime >= duration - 1e-3;
474
504
  const generationIntroWaiting = Boolean(playbackMode && stream && !generationIntroComplete);
475
505
  const firstSceneRange = config ? resolveVideoTimeline(config)[0] : void 0;
476
506
  const hasSuppliedOpening = firstSceneRange?.scene.id === "supplied-opening";
@@ -703,7 +733,7 @@ function VideoPlayerRuntime({
703
733
  autoPlay: isPlaying,
704
734
  muted: isMuted,
705
735
  preload: "auto",
706
- loop: state.status === "streaming" || introPlaying
736
+ loop: loop || state.status === "streaming" || introPlaying
707
737
  },
708
738
  config.audio.audioUrl
709
739
  ) : null,
package/dist/server.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { m as VideoGenerationContext, n as VideoPlanner, o as VideoRequest, k as VideoCapabilities, d as VideoInput, a as VideoAudio, p as VideoSceneValidator, q as VideoTemplatePacing, r as VideoSnapshotRetention, s as VideoResumeCursor, t as VideoSceneValidationContext, g as VideoScene } from './types-CVMb6QEq.js';
2
- import { b as VideoFinishReason, a as VideoWarning, V as VideoEvent } from './events-tQ0x-VaL.js';
3
- export { c as VideoWarningCategory } from './events-tQ0x-VaL.js';
1
+ import { n as VideoGenerationContext, o as VideoPlanner, p as VideoRequest, a as VideoCapabilities, j as VideoInput, d as VideoAudio, q as VideoSceneValidator, r as VideoTemplatePacing, s as VideoSnapshotRetention, t as VideoResumeCursor, u as VideoSceneValidationContext, e as VideoScene } from './types-Cg0Onj7l.js';
2
+ import { b as VideoFinishReason, a as VideoWarning, V as VideoEvent } from './events-D_1F6gw5.js';
3
+ export { c as VideoWarningCategory } from './events-D_1F6gw5.js';
4
4
  import { S as SceneTemplateMetadata } from './catalog-types-BIhSpOWK.js';
5
5
 
6
6
  interface VideoProviderUsage {
@@ -1,9 +1,9 @@
1
- import { I as InferTemplateJsonSchema, S as SceneTemplateProps, a as SceneTemplate } from './kit-DlUSg8lA.js';
2
- export { T as TemplateRegistry, c as createTemplateRegistry } from './kit-DlUSg8lA.js';
1
+ import { I as InferTemplateJsonSchema, S as SceneTemplateProps, a as SceneTemplate } from './kit-DLeCr3d0.js';
2
+ export { T as TemplateRegistry, c as createTemplateRegistry } from './kit-DLeCr3d0.js';
3
3
  import { ComponentType } from 'react';
4
4
  import { d as TemplateJsonSchema, S as SceneTemplateMetadata } from './catalog-types-BIhSpOWK.js';
5
5
  export { T as TemplateFamily, c as TemplateTimingMetadata, e as TemplateTransitionTiming } from './catalog-types-BIhSpOWK.js';
6
- import './types-CVMb6QEq.js';
6
+ import './types-Cg0Onj7l.js';
7
7
 
8
8
  interface TemplateExample<Variables extends Record<string, unknown> = Record<string, unknown>> {
9
9
  name: string;
package/dist/test.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { g as VideoScene, f as VideoOrientation, h as VideoStyle, V as Video, a as VideoAudio, d as VideoInput } from './types-CVMb6QEq.js';
2
- import { a as VideoWarning, b as VideoFinishReason } from './events-tQ0x-VaL.js';
1
+ import { e as VideoScene, b as VideoOrientation, V as VideoStyle, c as Video, d as VideoAudio, j as VideoInput } from './types-Cg0Onj7l.js';
2
+ import { a as VideoWarning, b as VideoFinishReason } from './events-D_1F6gw5.js';
3
3
 
4
4
  type MockVideoStreamPart = {
5
5
  type: "scene.add";
@@ -209,4 +209,4 @@ interface VideoSnapshotRetention {
209
209
  suppliedMediaUrls?: boolean;
210
210
  }
211
211
 
212
- export { type Video as V, type VideoAudio as a, type VideoBackground as b, type VideoBrand as c, type VideoInput as d, type VideoKnowledgeMode as e, type VideoOrientation as f, type VideoScene as g, type VideoStyle as h, type VideoStyleOptions as i, type VideoSuppliedMedia as j, type VideoCapabilities as k, VIDEO_PROTOCOL_VERSION as l, type VideoGenerationContext as m, type VideoPlanner as n, type VideoRequest as o, type VideoSceneValidator as p, type VideoTemplatePacing as q, type VideoSnapshotRetention as r, type VideoResumeCursor as s, type VideoSceneValidationContext as t };
212
+ export { type VideoStyle as V, type VideoCapabilities as a, type VideoOrientation as b, type Video as c, type VideoAudio as d, type VideoScene as e, VIDEO_PROTOCOL_VERSION as f, type VideoBrandInput as g, type VideoBrand as h, type VideoBackground as i, type VideoInput as j, type VideoKnowledgeMode as k, type VideoStyleOptions as l, type VideoSuppliedMedia as m, type VideoGenerationContext as n, type VideoPlanner as o, type VideoRequest as p, type VideoSceneValidator as q, type VideoTemplatePacing as r, type VideoSnapshotRetention as s, type VideoResumeCursor as t, type VideoSceneValidationContext as u };
@@ -5,7 +5,7 @@
5
5
  Install VanillaSky:
6
6
 
7
7
  ```bash
8
- npm install @vanillaskyai/video@0.3.4 ai @ai-sdk/openai
8
+ npm install @vanillaskyai/video@0.4.1 ai @ai-sdk/openai
9
9
  ```
10
10
 
11
11
  Set your provider key in `.env.local` (never commit it):
@@ -50,7 +50,7 @@ sets its marker only for `next dev`, and it accepts only localhost. Every
50
50
  production request is denied. Replace it with your real session validation
51
51
  before deploying. For literal files and commands,
52
52
  use the tested
53
- [`examples/nextjs-quickstart` directory](https://github.com/VanillaSkyAi/video/tree/v0.3.4/examples/nextjs-quickstart).
53
+ [`examples/nextjs-quickstart` directory](https://github.com/VanillaSkyAi/video/tree/v0.4.1/examples/nextjs-quickstart).
54
54
 
55
55
  `model` can come from any AI SDK provider, registry, gateway, compatible API,
56
56
  or custom implementation. The application can choose a cheaper or faster model
@@ -5,7 +5,7 @@
5
5
  Install VanillaSky and one AI SDK provider:
6
6
 
7
7
  ```bash
8
- npm install @vanillaskyai/video@0.3.4 ai @ai-sdk/openai
8
+ npm install @vanillaskyai/video@0.4.1 ai @ai-sdk/openai
9
9
  ```
10
10
 
11
11
  Create an ignored `.env.local`:
@@ -74,7 +74,7 @@ requests; replace it with your application's session validation before
74
74
  deploying.
75
75
 
76
76
  The copy-and-run app is in the
77
- [`examples/nextjs-quickstart` directory](https://github.com/VanillaSkyAi/video/tree/v0.3.4/examples/nextjs-quickstart).
77
+ [`examples/nextjs-quickstart` directory](https://github.com/VanillaSkyAi/video/tree/v0.4.1/examples/nextjs-quickstart).
78
78
 
79
79
  For another LLM, replace `openai(...)` with the matching AI SDK model. The route
80
80
  shape and React code stay the same. See [Provider integration](provider-integration.md)
@@ -0,0 +1,149 @@
1
+ [← Documentation home](../README.md) · [Previous: Persistence and replay](persistence.md) · [Next: Streaming protocol →](streaming-protocol.md)
2
+
3
+ # Live channels
4
+
5
+ A live channel is a video that plays continuously and rebuilds itself on a
6
+ schedule: a market ticker, a news wall, a status board in an office, a loop
7
+ behind a conference stand. Nobody presses play, nobody watches from the start,
8
+ and the content changes on its own.
9
+
10
+ This is a different shape from a generated response. A response is produced for
11
+ one person, once, in reply to something they asked. A channel is produced ahead
12
+ of time for nobody in particular, and then played to whoever is looking.
13
+
14
+ That difference is what makes it cheap. Playback of a saved `Video` performs no
15
+ generation request and no model call — the player is DOM and CSS advanced by a
16
+ `requestAnimationFrame` clock — so a channel that loops all day costs exactly as
17
+ much as a channel that loops once. All of the cost lives in building the
18
+ configuration, which happens on your schedule rather than per viewer.
19
+
20
+ ## The shape
21
+
22
+ ```
23
+ scheduled job browser
24
+ ───────────── ───────
25
+ fetch your data ┌────────► fetch the stored Video
26
+ map it to scenes │ parseVideo(...)
27
+ parseVideo(...) ────────┘ <VideoPlayer video={...} loop />
28
+ store the JSON plays forever
29
+ ```
30
+
31
+ The job owns the data and the mapping. The player owns nothing but playback.
32
+
33
+ ## Play without stopping
34
+
35
+ Pass `loop` alongside a saved video. The player restarts from the beginning
36
+ instead of showing its replay affordance and loops its soundtrack continuously.
37
+ A track shorter than the channel repeats as soon as it ends; when the visual
38
+ timeline wraps, the soundtrack restarts from the beginning with it.
39
+
40
+ <!-- verify:live-channel-example:start -->
41
+ ```tsx
42
+ import { useEffect, useState } from "react";
43
+ import { parseVideo, type Video, type VideoScene } from "@vanillaskyai/video";
44
+ import { VideoPlayer } from "@vanillaskyai/video/react";
45
+
46
+ export function Channel({ endpoint }: { endpoint: string }) {
47
+ const [video, setVideo] = useState<Video | undefined>(undefined);
48
+ const [onScreen, setOnScreen] = useState<VideoScene | undefined>(undefined);
49
+
50
+ const load = async () => {
51
+ const stored: unknown = await fetch(endpoint).then((response) => response.json());
52
+ setVideo(parseVideo(stored));
53
+ };
54
+
55
+ useEffect(() => { void load(); }, []);
56
+ if (!video) return null;
57
+
58
+ return <>
59
+ <VideoPlayer
60
+ video={video}
61
+ loop
62
+ autoPlay
63
+ playbackMode="muted-autoplay"
64
+ onSceneChange={(scene, index) => {
65
+ setOnScreen(scene);
66
+ // Wrapping back to the start is a natural moment to pick up a rebuild,
67
+ // so a screen left running all day never serves a stale channel.
68
+ if (index === 0) void load();
69
+ }}
70
+ />
71
+ <p>{onScreen ? onScreen.templateId : "starting"}</p>
72
+ </>;
73
+ }
74
+ ```
75
+ <!-- verify:live-channel-example:end -->
76
+
77
+ `loop` applies to saved videos. A streaming response still ends when the stream
78
+ ends, because there is nothing to loop back to until it completes.
79
+
80
+ ## Follow what is on screen
81
+
82
+ `onSceneChange` fires whenever the scene under the playhead changes, and again
83
+ on index `0` each time a loop wraps. Use it to drive anything that has to stay
84
+ in step with the video: a caption rail, a source panel, chapter markers, an
85
+ analytics event per scene.
86
+
87
+ Do not run your own timer for this. A parallel clock drifts from the player's
88
+ as soon as playback is paused, throttled in a background tab, or delayed by a
89
+ slow asset, and the drift is silent.
90
+
91
+ `onComplete` is not an alternative here: it reports the end of a *stream*, so it
92
+ never fires for a saved video.
93
+
94
+ ## Build the configuration yourself
95
+
96
+ A channel's scenes usually come from your own data rather than from a model.
97
+ `resolveVideoBrand` fills a partial brand with the documented defaults, so a
98
+ hand-authored `Video` satisfies `parseVideo` without copying a defaults blob
99
+ into your application:
100
+
101
+ ```ts
102
+ import { parseVideo, resolveVideoBrand } from "@vanillaskyai/video";
103
+
104
+ const channel = parseVideo({
105
+ schemaVersion: "0.1",
106
+ orientation: "landscape",
107
+ scenes: rows.map((row, index) => ({
108
+ id: `row-${index}`,
109
+ templateId: "bigNumber",
110
+ variables: { texts: row.label, value: row.value, unit: row.unit },
111
+ timing: { fixedDuration: 5 },
112
+ })),
113
+ style: { brand: resolveVideoBrand({ name: "Acme", background: "midnight" }) },
114
+ });
115
+ ```
116
+
117
+ Validate with `parseVideo` inside the job, before storing. A channel that fails
118
+ validation in the browser is a channel nobody can watch; one that fails in the
119
+ job can simply leave the previous version in place.
120
+
121
+ ## Keep motion independent of scene length
122
+
123
+ Templates receive `sceneDuration`. Express motion in seconds against it rather
124
+ than as a fraction of `progress`, or lengthening a scene to give viewers more
125
+ reading time will stretch its animation instead and give them none.
126
+
127
+ ## Practical notes
128
+
129
+ - **Rebuild on a schedule, not per request.** Viewers should read a stored
130
+ configuration. If every viewer triggers generation, a channel becomes the most
131
+ expensive surface you own rather than the cheapest.
132
+ - **Never replace a good channel with a broken one.** Validate first, and on
133
+ failure leave the previous configuration in place.
134
+ - **Autoplay needs `playbackMode="muted-autoplay"`.** Browsers refuse unmuted
135
+ autoplay. A channel can still carry a looping soundtrack: it starts muted and
136
+ becomes audible when a viewer uses the player's sound control.
137
+ - **Host your media.** Loops re-request assets over long sessions; serving them
138
+ from your own origin keeps a content policy simple and avoids depending on
139
+ someone else's CDN.
140
+ - **Long channels are ordinary videos.** Ten minutes of scenes is one `Video`
141
+ with more entries in `scenes`; there is no separate playlist concept.
142
+
143
+ ## Related
144
+
145
+ - [Persistence and replay](persistence.md) — the storage boundary these
146
+ configurations pass through
147
+ - [Custom templates](custom-templates.md) — templates for your own data shapes
148
+ - [Responsive orientation](responsive-orientation.md) — the same channel on a
149
+ portrait screen
@@ -87,6 +87,13 @@ generated scene exists, and render it with `autoPlay` and `startMuted={false}`.
87
87
  If the browser blocks the audible start, the player returns to the first frame
88
88
  and exposes its sound-start control.
89
89
 
90
+ For a saved video rendered with `<VideoPlayer video={video} loop />`, the
91
+ soundtrack loops too. A track shorter than the visual timeline repeats without
92
+ a silent gap, and the player restarts it from the beginning when the visual
93
+ timeline wraps. This does not bypass browser autoplay policy: use muted autoplay
94
+ for unattended playback and let a viewer unmute, or start audible playback from
95
+ a user interaction.
96
+
90
97
  ## Media providers
91
98
 
92
99
  VanillaSky is provider-independent. When `resolveMedia` is configured, the
@@ -1,4 +1,4 @@
1
- [← Documentation home](../README.md) · [Previous: Core concepts](concepts.md) · [Next: Streaming protocol →](streaming-protocol.md)
1
+ [← Documentation home](../README.md) · [Previous: Core concepts](concepts.md) · [Next: Live channels →](live-channels.md)
2
2
 
3
3
  # Persistence and replay
4
4
 
@@ -1,4 +1,4 @@
1
- [← Documentation home](../README.md) · [Previous: Core concepts](concepts.md) · [Next: Security →](security.md)
1
+ [← Documentation home](../README.md) · [Previous: Live channels](live-channels.md) · [Next: Security →](security.md)
2
2
 
3
3
  # Streaming protocol
4
4
 
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "dependencies": {
11
11
  "@ai-sdk/openai": "^4.0.42",
12
- "@vanillaskyai/video": "0.3.4",
12
+ "@vanillaskyai/video": "0.4.1",
13
13
  "ai": "^7.0.66",
14
14
  "next": "16.3.1",
15
15
  "react": "19.2.8",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillaskyai/video",
3
- "version": "0.3.4",
3
+ "version": "0.4.1",
4
4
  "description": "Open-source video response SDK for personalized AI applications.",
5
5
  "keywords": [
6
6
  "generative-video",