@vanillaskyai/video 0.3.4 → 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 +18 -0
- package/PUBLIC-API.md +12 -0
- package/README.md +4 -3
- package/dist/{events-tQ0x-VaL.d.ts → events-D_1F6gw5.d.ts} +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.js +5 -2
- package/dist/{kit-DlUSg8lA.d.ts → kit-DLeCr3d0.d.ts} +1 -1
- package/dist/react.d.ts +7 -3
- package/dist/react.js +37 -7
- package/dist/server.d.ts +3 -3
- package/dist/templates.d.ts +3 -3
- package/dist/test.d.ts +2 -2
- package/dist/{types-CVMb6QEq.d.ts → types-Cg0Onj7l.d.ts} +1 -1
- package/docs/getting-started.md +2 -2
- package/docs/integrate-nextjs.md +2 -2
- package/docs/live-channels.md +146 -0
- package/docs/persistence.md +1 -1
- package/docs/streaming-protocol.md +1 -1
- package/examples/nextjs-quickstart/package.json +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,24 @@ 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
|
+
|
|
7
25
|
## 0.3.4
|
|
8
26
|
|
|
9
27
|
- 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
|
-

|
|
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.
|
|
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.
|
|
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,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
2
|
-
export {
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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-
|
|
3
|
-
import {
|
|
4
|
-
import { T as TemplateRegistry } from './kit-
|
|
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
|
@@ -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
|
|
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 =
|
|
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 {
|
|
2
|
-
import { b as VideoFinishReason, a as VideoWarning, V as VideoEvent } from './events-
|
|
3
|
-
export { c as VideoWarningCategory } from './events-
|
|
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 {
|
package/dist/templates.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { I as InferTemplateJsonSchema, S as SceneTemplateProps, a as SceneTemplate } from './kit-
|
|
2
|
-
export { T as TemplateRegistry, c as createTemplateRegistry } from './kit-
|
|
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-
|
|
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 {
|
|
2
|
-
import { a as VideoWarning, b as VideoFinishReason } from './events-
|
|
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
|
|
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 };
|
package/docs/getting-started.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
Install VanillaSky:
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npm install @vanillaskyai/video@0.
|
|
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.
|
|
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
|
package/docs/integrate-nextjs.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
Install VanillaSky and one AI SDK provider:
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npm install @vanillaskyai/video@0.
|
|
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.
|
|
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
|
package/docs/persistence.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
[← Documentation home](../README.md) · [Previous: Core concepts](concepts.md) · [Next:
|
|
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
|
|