@vanillaskyai/video 0.3.3 → 0.4.0

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,34 @@ VanillaSky follows semantic versioning. This changelog begins with the 0.1 beta.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.4.0
8
+
9
+ - Adds `loop` to `VideoPlayer` for saved videos. A completed video previously
10
+ painted its replay affordance and stopped, so there was no way to run one
11
+ continuously; with `loop` it restarts from the beginning and keeps its
12
+ soundtrack in step. Streaming playback is unchanged.
13
+ - Adds `onSceneChange(scene, index)` to `VideoPlayer`, fired when the scene
14
+ under the playhead changes and again on index `0` each time a loop wraps.
15
+ Nothing previously reported playback position to the host: `onComplete`
16
+ reports the end of a stream, so it never fires for a saved video, and any UI
17
+ that had to stay in step ran a parallel timer that drifted silently.
18
+ - Exports `resolveVideoBrand` from the root entry. `parseVideo` requires a
19
+ fully resolved brand, and the resolver already existed internally, so
20
+ hand-authoring a `Video` meant copying a defaults blob into application code.
21
+ - Documents live channels — continuously playing, self-refreshing video built
22
+ from your own data rather than generated per viewer — in
23
+ `docs/live-channels.md`, with an example compiled against the packed package.
24
+
25
+ ## 0.3.4
26
+
27
+ - Mounts a scene that brings a new photo or video 1.2s before it appears,
28
+ instead of 0.3s, so the element has time to decode and the scene arrives
29
+ showing its picture rather than a gradient that pops a beat later. The extra
30
+ time is invisible: the layer stays fully transparent until the existing
31
+ 0.3s cross-fade begins, so every rendered frame is unchanged, and the mounted
32
+ element is handed to the incoming scene rather than rebuilt. Scenes that
33
+ reuse the backdrop already on screen are unaffected.
34
+
7
35
  ## 0.3.3
8
36
 
9
37
  - Keeps a soundtrack audible on iPhone Safari when its audio context cannot be
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.3 beta](https://img.shields.io/badge/version-0.3.3_beta-7c3aed)
3
+ ![Version 0.4.0 beta](https://img.shields.io/badge/version-0.4.0_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.3 ai @ai-sdk/openai
20
+ npm install @vanillaskyai/video@0.4.0 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.3/examples/nextjs-quickstart).
109
+ [`examples/nextjs-quickstart`](https://github.com/VanillaSkyAi/video/tree/v0.4.0/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,6 +1,6 @@
1
1
  import {
2
2
  VideoFrame
3
- } from "./chunk-HXSV6RHF.js";
3
+ } from "./chunk-N3KUA7VP.js";
4
4
  import "./chunk-XWWLKRNU.js";
5
5
  import "./chunk-SPVTJH3F.js";
6
6
  import {
@@ -31,6 +31,7 @@ function scaleSafeZone(zone, factor) {
31
31
  // src/player/video-frame.tsx
32
32
  import { jsx, jsxs } from "react/jsx-runtime";
33
33
  var SCENE_TRANSITION_SECONDS = 0.3;
34
+ var MEDIA_PREROLL_SECONDS = 1.2;
34
35
  var CONTIGUITY_ULP_FACTOR = 4;
35
36
  function clamp01(value) {
36
37
  return Math.max(0, Math.min(1, value));
@@ -51,6 +52,9 @@ function brandBackgroundFallback(config) {
51
52
  const background = config.style.brand.background;
52
53
  return background.type === "solid" ? background.color : background.colors[0];
53
54
  }
55
+ function sceneHasBackdrop(range) {
56
+ return String(range.scene.variables.mediaType || "auto") !== "gradient" && String(range.scene.variables.mediaUrl || "").trim() !== "";
57
+ }
54
58
  function sceneBackgroundChanges(left, right) {
55
59
  const mediaUrl = (range) => String(range.scene.variables.mediaType || "auto") === "gradient" ? "" : String(range.scene.variables.mediaUrl || "");
56
60
  return mediaUrl(left) !== mediaUrl(right);
@@ -178,6 +182,14 @@ function VideoFrame({
178
182
  );
179
183
  const blendStart = active.end - blendDuration;
180
184
  const blendEnd = blendStart + blendDuration;
185
+ const prerollsNext = Boolean(
186
+ contiguousNext && sceneHasBackdrop(contiguousNext) && sceneBackgroundChanges(active, contiguousNext)
187
+ );
188
+ const prerollDuration = prerollsNext ? Math.min(Math.max(MEDIA_PREROLL_SECONDS, blendDuration), Math.max(0, duration)) : blendDuration;
189
+ const prerollStart = active.end - prerollDuration;
190
+ const mountingNext = Boolean(
191
+ contiguousNext && time >= prerollStart && time < blendEnd && (eligibleNextTransition || prerollsNext)
192
+ );
181
193
  const previewingNext = Boolean(
182
194
  eligibleNextTransition && time >= blendStart && time < blendEnd
183
195
  );
@@ -232,7 +244,7 @@ function VideoFrame({
232
244
  }
233
245
  }
234
246
  ),
235
- previewingNext && contiguousNext && nextTiming ? [
247
+ mountingNext && contiguousNext ? [
236
248
  /* @__PURE__ */ jsx(
237
249
  SceneLayer,
238
250
  {
@@ -244,7 +256,7 @@ function VideoFrame({
244
256
  width: canvas.width,
245
257
  height: canvas.height,
246
258
  playing,
247
- layer: "outgoing",
259
+ layer: previewingNext ? "outgoing" : "active",
248
260
  opacity: 1 - blendProgress,
249
261
  interactive: true,
250
262
  zIndex: 1
@@ -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
+ /** Restart from the beginning instead of showing the replay affordance. Saved videos only. */
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
@@ -29,7 +29,7 @@ import "./chunk-2E6T633S.js";
29
29
  import {
30
30
  VideoFrame,
31
31
  getDimensions
32
- } from "./chunk-HXSV6RHF.js";
32
+ } from "./chunk-N3KUA7VP.js";
33
33
  import {
34
34
  fontStack
35
35
  } from "./chunk-XWWLKRNU.js";
@@ -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";
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.3 ai @ai-sdk/openai
8
+ npm install @vanillaskyai/video@0.4.0 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.3/examples/nextjs-quickstart).
53
+ [`examples/nextjs-quickstart` directory](https://github.com/VanillaSkyAi/video/tree/v0.4.0/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.3 ai @ai-sdk/openai
8
+ npm install @vanillaskyai/video@0.4.0 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.3/examples/nextjs-quickstart).
77
+ [`examples/nextjs-quickstart` directory](https://github.com/VanillaSkyAi/video/tree/v0.4.0/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,146 @@
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 keeps its soundtrack in step.
37
+
38
+ <!-- verify:live-channel-example:start -->
39
+ ```tsx
40
+ import { useEffect, useState } from "react";
41
+ import { parseVideo, type Video, type VideoScene } from "@vanillaskyai/video";
42
+ import { VideoPlayer } from "@vanillaskyai/video/react";
43
+
44
+ export function Channel({ endpoint }: { endpoint: string }) {
45
+ const [video, setVideo] = useState<Video | undefined>(undefined);
46
+ const [onScreen, setOnScreen] = useState<VideoScene | undefined>(undefined);
47
+
48
+ const load = async () => {
49
+ const stored: unknown = await fetch(endpoint).then((response) => response.json());
50
+ setVideo(parseVideo(stored));
51
+ };
52
+
53
+ useEffect(() => { void load(); }, []);
54
+ if (!video) return null;
55
+
56
+ return <>
57
+ <VideoPlayer
58
+ video={video}
59
+ loop
60
+ autoPlay
61
+ playbackMode="muted-autoplay"
62
+ onSceneChange={(scene, index) => {
63
+ setOnScreen(scene);
64
+ // Wrapping back to the start is a natural moment to pick up a rebuild,
65
+ // so a screen left running all day never serves a stale channel.
66
+ if (index === 0) void load();
67
+ }}
68
+ />
69
+ <p>{onScreen ? onScreen.templateId : "starting"}</p>
70
+ </>;
71
+ }
72
+ ```
73
+ <!-- verify:live-channel-example:end -->
74
+
75
+ `loop` applies to saved videos. A streaming response still ends when the stream
76
+ ends, because there is nothing to loop back to until it completes.
77
+
78
+ ## Follow what is on screen
79
+
80
+ `onSceneChange` fires whenever the scene under the playhead changes, and again
81
+ on index `0` each time a loop wraps. Use it to drive anything that has to stay
82
+ in step with the video: a caption rail, a source panel, chapter markers, an
83
+ analytics event per scene.
84
+
85
+ Do not run your own timer for this. A parallel clock drifts from the player's
86
+ as soon as playback is paused, throttled in a background tab, or delayed by a
87
+ slow asset, and the drift is silent.
88
+
89
+ `onComplete` is not an alternative here: it reports the end of a *stream*, so it
90
+ never fires for a saved video.
91
+
92
+ ## Build the configuration yourself
93
+
94
+ A channel's scenes usually come from your own data rather than from a model.
95
+ `resolveVideoBrand` fills a partial brand with the documented defaults, so a
96
+ hand-authored `Video` satisfies `parseVideo` without copying a defaults blob
97
+ into your application:
98
+
99
+ ```ts
100
+ import { parseVideo, resolveVideoBrand } from "@vanillaskyai/video";
101
+
102
+ const channel = parseVideo({
103
+ schemaVersion: "0.1",
104
+ orientation: "landscape",
105
+ scenes: rows.map((row, index) => ({
106
+ id: `row-${index}`,
107
+ templateId: "bigNumber",
108
+ variables: { texts: row.label, value: row.value, unit: row.unit },
109
+ timing: { fixedDuration: 5 },
110
+ })),
111
+ style: { brand: resolveVideoBrand({ name: "Acme", background: "midnight" }) },
112
+ });
113
+ ```
114
+
115
+ Validate with `parseVideo` inside the job, before storing. A channel that fails
116
+ validation in the browser is a channel nobody can watch; one that fails in the
117
+ job can simply leave the previous version in place.
118
+
119
+ ## Keep motion independent of scene length
120
+
121
+ Templates receive `sceneDuration`. Express motion in seconds against it rather
122
+ than as a fraction of `progress`, or lengthening a scene to give viewers more
123
+ reading time will stretch its animation instead and give them none.
124
+
125
+ ## Practical notes
126
+
127
+ - **Rebuild on a schedule, not per request.** Viewers should read a stored
128
+ configuration. If every viewer triggers generation, a channel becomes the most
129
+ expensive surface you own rather than the cheapest.
130
+ - **Never replace a good channel with a broken one.** Validate first, and on
131
+ failure leave the previous configuration in place.
132
+ - **Autoplay needs `playbackMode="muted-autoplay"`.** Browsers refuse unmuted
133
+ autoplay, and a channel has no one present to click.
134
+ - **Host your media.** Loops re-request assets over long sessions; serving them
135
+ from your own origin keeps a content policy simple and avoids depending on
136
+ someone else's CDN.
137
+ - **Long channels are ordinary videos.** Ten minutes of scenes is one `Video`
138
+ with more entries in `scenes`; there is no separate playlist concept.
139
+
140
+ ## Related
141
+
142
+ - [Persistence and replay](persistence.md) — the storage boundary these
143
+ configurations pass through
144
+ - [Custom templates](custom-templates.md) — templates for your own data shapes
145
+ - [Responsive orientation](responsive-orientation.md) — the same channel on a
146
+ portrait screen
@@ -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.3",
12
+ "@vanillaskyai/video": "0.4.0",
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.3",
3
+ "version": "0.4.0",
4
4
  "description": "Open-source video response SDK for personalized AI applications.",
5
5
  "keywords": [
6
6
  "generative-video",