@vanillaskyai/video 0.10.18 → 0.10.20

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/dist/server.js CHANGED
@@ -1235,6 +1235,7 @@ function createVideoChatResponseInstructions(generatedVideoAvailable, openingAlr
1235
1235
  `The selected footage mode is ${mode === "pexels" ? "Pexels stock search: use literal filmable subjects; never imply stock proves a mechanism or depicts fictional events exactly" : `AI video, with at most ${generatedVideoAvailable ? maxGeneratedVideos : 0} generation attempts`}. Missing footage becomes the authored chapter title, with complete narration. Never truncate already-authored narration when footage fails. The host selects providers; do not make source choices.`,
1236
1236
  ...mode === "cinematic" ? [generatedVideoAvailable && maxGeneratedVideos > 0 ? `Plan at most ${maxGeneratedVideos} generated-video beats in total, including the saved ending. Use at most ${maxGeneratedVideos - 1} developing shot records; the ending uses the remaining beat. The opening chapter does not consume a generated clip. Before writing, choose a concise, complete treatment that fits this budget: combine related ideas, preserve essential facts and qualifiers, and finish the requested answer. Do not plan an extra chapter tail simply because generation attempts will run out.${maxGeneratedVideos === 1 ? " Put the complete answer in the saved ending, set development to an empty string, and emit no developing shot records." : ""}` : "No generated-video attempts are available; plan a complete chapter-led answer with a useful ending. Do not omit the answer to satisfy a zero clip budget."] : [],
1237
1237
  ...mode === "pexels" ? ["Stock queries must retain the essential subject, activity and distinguishing equipment in the shot's subject field, within its word limit. That field alone is the search query; action and visualDirection do not refine it. Prefer common observable actions with usable framing. Do not replace the required actor or activity with scenery, a different sport or a loosely related setting. Preserve fictional or comic narration, but do not depend on stock showing an exact invented expression or sequence; choose an illustrative action that supports the beat."] : [],
1238
+ ...mode === "pexels" ? ["Choose a separate stock subject for each beat, describing footage that can realistically exist in a stock library. For historical, abstract or unseen events, use relevant present-day evidence, objects, environments or analogous visible processes as clearly illustrative support. Do not require literal footage of events or subjects that cannot realistically be filmed. Keep the causal explanation in narration; do not claim illustrative footage records the historical event or proves the mechanism. Make stockSelection describe the chosen visible subject, not the overall topic. Do not use illustrative freedom to replace a required practical action, person, sport or distinguishing equipment with unrelated scenery."] : [],
1238
1239
  ...mode === "pexels" ? ['Include stockSelection on every shot and the saved ending when the essential subject is known: "stockSelection":{"subject":"essential actor or object category","activity":"optional literal activity","equipment":"optional distinguishing equipment","exclude":["optional contradictory subject or activity"]}. Each phrase must be 1\u20134 words and at most 48 characters; exclude has at most 3 phrases. The essential subject is separate from the setting: do not use scenery, mood, camera framing or incidental appearance as the actor. Keep the search query broad enough to find footage; the optional hint helps select results without substituting a different actor or task. Use exclusions only for actual contradictions, not every detail absent from the story. Omit unknown fields or the whole hint rather than inventing an anchor. This is selection guidance, not verification that footage depicts the exact narration.'] : [],
1239
1240
  "Keep development to one concise sentence and visualDirection to the few details needed for consistency. Emit the complete brief, then the first developing shot immediately when developing shots are needed and allowed by the budget; otherwise end after the brief. Continue the same stream without an outline, recap or second planning pass. The saved ending must still contain the complete payoff before the brief is emitted.",
1240
1241
  "For a very short answer whose ending alone fulfills the request, development may be empty and no developing shots are needed. Otherwise, develop the essential content before the ending.",
@@ -116,3 +116,13 @@ Grouped playback requires prepared speech with `supportsOffsets: true`. The gene
116
116
  For standalone playback, provide a synchronous `narrationReady` callback alongside your `onSceneChange` narration handler. Return false while a new grouped paragraph awaits actual audio onset, then true from the voice's `onStart` callback; also release readiness on completion, failure, or interruption. Abort pending narration from the player's `onError` handler. `VideoChat` wires this automatically through its internal narration hook. For grouped paragraphs, the voice must invoke `onStart` when audio actually begins, not when audio is prepared or `play()` is requested. The first visual cue starts narration, then the playhead waits for that onset without pausing the voice. A missing onset stops the player with an error after eight seconds of active waiting. For prepared audio, also provide `narrationTime(scene)` using the active voice's optional `getCurrentTime()`: return paragraph-relative seconds (including the seek offset) for a group, or scene-relative seconds otherwise. This makes the actual audio clock authoritative through cold-start delays and mid-speech stalls. Return `undefined` for silent scenes or unavailable clocks; after ordinary narration completes, release to wall time so the authored reading hold can finish. `VideoChat` coordinates these callbacks automatically. A clock that stops advancing for eight active seconds produces an error; visual-readiness holds and deliberate pauses do not consume that timeout. Voices without an observable playback clock retain wall-time playback.
117
117
 
118
118
  Readiness holds pause narration together with the picture. The same audio continues across adjacent group scenes; replay starts a new playback session. This preserves words through delayed media rather than promising uninterrupted playback on every network.
119
+
120
+ ### Host-resolved chat footage mode
121
+
122
+ A chat host that changes the requested footage mode before planning may return
123
+ `x-vanillasky-resolved-video-mode: pexels` or `cinematic` on a successful SSE
124
+ response. The default chat client records that mode for the active turn and its
125
+ playback metrics. Unknown values are ignored. Authorization, allowance checks
126
+ and provider selection remain host-owned; the header never grants access or
127
+ changes a spending limit. A mixed response that changes footage source partway
128
+ through retains its initially resolved mode.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillaskyai/video",
3
- "version": "0.10.18",
3
+ "version": "0.10.20",
4
4
  "description": "Open-source voice-and-video chat SDK for AI applications.",
5
5
  "keywords": [
6
6
  "video-chat",
@@ -9,7 +9,7 @@
9
9
  "preview": "vite preview"
10
10
  },
11
11
  "dependencies": {
12
- "@vanillaskyai/video": "0.10.18",
12
+ "@vanillaskyai/video": "0.10.20",
13
13
  "react": "^19.2.8",
14
14
  "react-dom": "^19.2.8",
15
15
  "@ai-sdk/anthropic": "^3.0.0",
@@ -53,48 +53,59 @@ export async function findStockFootage(query: string, orientation: VideoOrientat
53
53
  const normalized = query.trim().toLowerCase().replace(/\s+/g, " ");
54
54
  const tokens = words(normalized);
55
55
  const selection = selectionHint(rawSelection);
56
- const key = JSON.stringify({version: 3, orientation, query: normalized, selection});
56
+ const key = JSON.stringify({version: 5, orientation, query: normalized, selection});
57
57
  const apiKey = process.env.PEXELS_API_KEY;
58
58
  if (!apiKey || !tokens.length || normalized.length > 80 || tokens.length > 8) return null;
59
59
  const existing = cache.get(key);
60
60
  if (existing && existing.expires > Date.now()) return existing.media;
61
- const url = new URL("https://api.pexels.com/v1/videos/search");
62
- url.search = new URLSearchParams({ query: normalized, orientation, per_page: "12", size: "medium" }).toString();
63
- const response = await fetch(url, { headers: { Authorization: apiKey }, signal });
64
- signal.throwIfAborted();
65
- if (!response.ok) return null;
66
- const result = await response.json() as { videos?: PexelsVideo[] };
67
- signal.throwIfAborted();
68
- let selected: StockVideo | null = null, bestScore = -1;
69
- for (const video of (Array.isArray(result.videos) ? result.videos : []).slice(0, 12)) {
70
- if (!pexelsUrl(video.url)) continue;
71
- const slug = new URL(video.url).pathname.replace(/^\/video\//, "");
72
- const title = typeof video.title === "string" ? video.title : "";
73
- const tags = Array.isArray(video.tags) ? video.tags.filter((tag): tag is string => typeof tag === "string").join(" ") : "";
74
- const subject = words(`${slug} ${title} ${typeof video.description === "string" ? video.description : ""} ${tags}`).filter(token => !/^\d+$/.test(token)).map(wordForm);
75
- let matches = tokens.filter(token => subject.includes(wordForm(token))).length;
76
- if (selection && subject.length) {
77
- const covers = (phrase: string) => terms(phrase).every(word => subject.includes(wordForm(word)));
78
- if (!covers(selection.subject) || selection.exclude?.some(covers)) continue;
79
- // Query context breaks equal hint matches without outweighing a hint.
80
- const contextScore = matches / (tokens.length + 1);
81
- matches = 2 + contextScore + Number(Boolean(selection.activity && covers(selection.activity)))
82
- + Number(Boolean(selection.equipment && covers(selection.equipment)));
61
+ let selected: StockVideo | null = null;
62
+ // Reuse the caller's deadline signal; broadening never starts a new timeout.
63
+ const queries = [...new Set([normalized, ...(selection ? [selection.subject] : [])])];
64
+ for (const searchQuery of queries) {
65
+ signal.throwIfAborted();
66
+ const url = new URL("https://api.pexels.com/v1/videos/search");
67
+ url.search = new URLSearchParams({ query: searchQuery, per_page: "12", size: "medium" }).toString();
68
+ const response = await fetch(url, { headers: { Authorization: apiKey }, signal });
69
+ signal.throwIfAborted();
70
+ if (!response.ok) return null;
71
+ const result = await response.json() as { videos?: PexelsVideo[] };
72
+ signal.throwIfAborted();
73
+ let bestScore = -1, bestOrientation = -1;
74
+ const matchesOrientation = (file: {width?: number; height?: number}) => orientation === "portrait"
75
+ ? file.height! > file.width! : file.width! >= file.height!;
76
+ for (const video of (Array.isArray(result.videos) ? result.videos : []).slice(0, 12)) {
77
+ if (!pexelsUrl(video.url)) continue;
78
+ const slug = new URL(video.url).pathname.replace(/^\/video\//, "");
79
+ const title = typeof video.title === "string" ? video.title : "";
80
+ const tags = Array.isArray(video.tags) ? video.tags.filter((tag): tag is string => typeof tag === "string").join(" ") : "";
81
+ const subject = words(`${slug} ${title} ${typeof video.description === "string" ? video.description : ""} ${tags}`).filter(token => !/^\d+$/.test(token)).map(wordForm);
82
+ let matches = tokens.filter(token => subject.includes(wordForm(token))).length;
83
+ if (selection && subject.length) {
84
+ const covers = (phrase: string) => terms(phrase).every(word => subject.includes(wordForm(word)));
85
+ if (selection.exclude?.some(covers)) continue;
86
+ // Query context breaks equal hint matches without outweighing a hint.
87
+ const contextScore = matches / (tokens.length + 1);
88
+ matches = covers(selection.subject) ? 2 + contextScore + Number(Boolean(selection.activity && covers(selection.activity)))
89
+ + Number(Boolean(selection.equipment && covers(selection.equipment))) : 0;
90
+ }
91
+ // The documented Video resource can have only a numeric page URL and no
92
+ // editorial metadata. Preserve provider search order for unknown relevance;
93
+ // positive overlap ranks above provider-ranked illustrative alternatives.
94
+ const files = (Array.isArray(video.video_files) ? video.video_files : []).filter(file =>
95
+ file.file_type === "video/mp4" && pexelsUrl(file.link)
96
+ && Number.isFinite(file.width) && Number.isFinite(file.height)
97
+ && Math.min(file.width!, file.height!) >= 360,
98
+ ).sort((a, b) => Number(matchesOrientation(b)) - Number(matchesOrientation(a)) || Math.abs(Math.max(a.width!, a.height!) - 1280) - Math.abs(Math.max(b.width!, b.height!) - 1280));
99
+ const file = files[0];
100
+ if (!file) continue;
101
+ const orientationScore = Number(matchesOrientation(file));
102
+ // Prefer composition fit only when subject relevance is equal.
103
+ if (matches < bestScore || (matches === bestScore && orientationScore <= bestOrientation)) continue;
104
+ bestScore = matches;
105
+ bestOrientation = orientationScore;
106
+ selected = { url: file.link!, type: "video", ...(pexelsUrl(video.image) ? { posterUrl: video.image } : {}) };
83
107
  }
84
- // The documented Video resource can have only a numeric page URL and no
85
- // editorial metadata. Preserve provider search order for unknown relevance;
86
- // positive overlap ranks above it, while explicitly unrelated copy is skipped.
87
- if (subject.length > 0 && matches === 0) continue;
88
- const files = (Array.isArray(video.video_files) ? video.video_files : []).filter(file =>
89
- file.file_type === "video/mp4" && pexelsUrl(file.link)
90
- && Number.isFinite(file.width) && Number.isFinite(file.height)
91
- && Math.min(file.width!, file.height!) >= 360
92
- && (orientation === "portrait" ? file.height! > file.width! : file.width! >= file.height!),
93
- ).sort((a, b) => Math.abs(Math.max(a.width!, a.height!) - 1280) - Math.abs(Math.max(b.width!, b.height!) - 1280));
94
- const file = files[0];
95
- if (!file || matches <= bestScore) continue;
96
- bestScore = matches;
97
- selected = { url: file.link!, type: "video", ...(pexelsUrl(video.image) ? { posterUrl: video.image } : {}) };
108
+ if (selected) break;
98
109
  }
99
110
  // Bounded process-local cache; no request signal or credentials are retained.
100
111
  if (cache.size >= 128) cache.delete(cache.keys().next().value!);
@@ -370,16 +370,6 @@
370
370
  text-shadow: 0 1px 3px oklch(0.14 0.05 265 / 70%);
371
371
  }
372
372
 
373
- .vanillasky-video-chat .ending .cards {
374
- margin-top: 0;
375
- display: grid;
376
- grid-template-columns: repeat(2, minmax(0, 1fr));
377
- flex-shrink: 0;
378
- overflow: visible;
379
- }
380
-
381
- .vanillasky-video-chat .ending .card-dots { align-self: flex-start; margin-left: -0.35rem; }
382
-
383
373
  .vanillasky-video-chat .panel { display: grid; align-items: center; min-height: 0; background: var(--vs-bg); }
384
374
 
385
375
  .vanillasky-video-chat .panel-inner {
@@ -620,7 +610,7 @@
620
610
 
621
611
  .vanillasky-video-chat .cards { gap: 14px; margin: auto -12px 0; width: calc(100% + 24px); max-width: calc(100% + 24px); padding: 12px; }
622
612
 
623
- .vanillasky-video-chat .cards button { width: clamp(160px, 18vw, 260px); aspect-ratio: 1.65; border-radius: 14px; box-shadow: 0 8px 30px rgb(0 0 0 / 24%); background: #202438; }
613
+ .vanillasky-video-chat .cards button { --vs-card-width: clamp(160px, 18vw, 260px); --vs-card-ratio: 1.65; width: var(--vs-card-width); aspect-ratio: 1.65; border-radius: 14px; box-shadow: 0 8px 30px rgb(0 0 0 / 24%); background: #202438; }
624
614
 
625
615
  .vanillasky-video-chat .card-prompt { font-size: clamp(13px, 1.1vw, 16px); padding: 14px 16px; letter-spacing: -.015em; font-weight: 550; }
626
616
 
@@ -642,7 +632,7 @@
642
632
  .vanillasky-video-chat .welcome-body { padding: max(135px, 17dvh) 20px 118px; gap: 24px; }
643
633
  .vanillasky-video-chat .welcome-title { font-size: clamp(36px, 9.6vw, 58px); line-height: 1.12; letter-spacing: -.055em; }
644
634
  .vanillasky-video-chat .cards { gap: 12px; width: calc(100% + 20px); max-width: calc(100% + 20px); padding: 12px 20px 12px 12px; }
645
- .vanillasky-video-chat .cards button { width: 172px; aspect-ratio: .95; }
635
+ .vanillasky-video-chat .cards button { --vs-card-width: 172px; --vs-card-ratio: .95; width: var(--vs-card-width); aspect-ratio: .95; }
646
636
  .vanillasky-video-chat .card-prompt { font-size: 14px; padding: 14px; }
647
637
 
648
638
  }
@@ -650,13 +640,13 @@
650
640
  @media (max-height: 650px) and (min-width: 701px) {
651
641
  .vanillasky-video-chat .welcome-body { padding-top: 110px; padding-bottom: 118px; }
652
642
  .vanillasky-video-chat .welcome-title { font-size: clamp(32px, 4vw, 54px); }
653
- .vanillasky-video-chat .cards button { width: 160px; }
643
+ .vanillasky-video-chat .cards button { --vs-card-width: 160px; width: var(--vs-card-width); }
654
644
  }
655
645
 
656
646
  @media (max-height: 650px) and (max-width: 700px) {
657
647
  .vanillasky-video-chat .welcome-body { padding-top: 98px; }
658
648
  .vanillasky-video-chat .welcome-title { font-size: 34px; }
659
- .vanillasky-video-chat .cards button { width: 145px; aspect-ratio: 1.5; }
649
+ .vanillasky-video-chat .cards button { --vs-card-width: 145px; --vs-card-ratio: 1.5; width: var(--vs-card-width); aspect-ratio: 1.5; }
660
650
  }
661
651
 
662
652
  @media (prefers-reduced-motion: reduce) {
@@ -1075,35 +1065,65 @@
1075
1065
  }
1076
1066
 
1077
1067
  .vanillasky-video-chat .opening-chapter { animation: vanillasky-chapter-enter 450ms ease-out both; }
1078
- .vanillasky-video-chat .media-credit { color: var(--vs-fg); font-size: 11px; margin-left: 12px; text-underline-offset: 3px; }
1079
1068
  @keyframes vanillasky-chapter-enter { from { opacity: 0; } to { opacity: 1; } }
1080
1069
  @media (prefers-reduced-motion: reduce) { .vanillasky-video-chat .opening-chapter { animation: none; } }
1081
1070
 
1082
- .vanillasky-video-chat .ending .cards li { min-width: 0; }
1083
-
1084
- .vanillasky-video-chat .ending .cards button {
1085
- display: flex;
1086
- flex-direction: column;
1087
- justify-content: flex-end;
1088
- width: 100%;
1089
- height: 100%;
1090
- min-height: 180px;
1091
- aspect-ratio: auto;
1071
+ @media (max-height: 650px) {
1072
+ .vanillasky-video-chat .ending-body { inset-block: 80px 180px; }
1092
1073
  }
1093
1074
 
1094
- .vanillasky-video-chat .ending .card-prompt {
1095
- position: relative;
1096
- inset: auto;
1097
- overflow-wrap: anywhere;
1098
- font-size: clamp(14px, 1.2vw, 18px);
1075
+ .vanillasky-video-chat .video-preparation {
1076
+ position: absolute;
1077
+ inset-inline: 0;
1078
+ z-index: 3;
1079
+ display: flex;
1080
+ align-items: center;
1081
+ justify-content: center;
1082
+ gap: 10px;
1083
+ pointer-events: none;
1084
+ color: rgb(255 255 255 / 62%);
1085
+ font-size: 17px;
1099
1086
  line-height: 1.4;
1100
1087
  }
1101
-
1102
- @container (min-width: 1200px) {
1103
- .vanillasky-video-chat .ending .cards { grid-template-columns: repeat(4, minmax(0, 1fr)); }
1104
- .vanillasky-video-chat .ending .cards button { min-height: 210px; }
1088
+ .vanillasky-video-chat .video-preparation-spinner {
1089
+ width: 16px;
1090
+ height: 16px;
1091
+ border: 1.5px solid rgb(255 255 255 / 18%);
1092
+ border-top-color: rgb(255 255 255 / 75%);
1093
+ border-radius: 50%;
1094
+ animation: vanillasky-video-preparation 1s linear infinite;
1095
+ }
1096
+ @keyframes vanillasky-video-preparation { to { transform: rotate(360deg); } }
1097
+ @media (prefers-reduced-motion: reduce) {
1098
+ .vanillasky-video-chat .video-preparation-spinner { animation: none; }
1105
1099
  }
1106
1100
 
1107
- @media (max-height: 650px) {
1108
- .vanillasky-video-chat .ending-body { inset-block: 80px 180px; }
1101
+ .vanillasky-video-chat .video-preparation-text {
1102
+ color: transparent;
1103
+ background: linear-gradient(105deg, rgb(255 255 255 / 55%) 35%, #fff 50%, rgb(255 255 255 / 55%) 65%);
1104
+ background-size: 250% 100%;
1105
+ background-clip: text;
1106
+ -webkit-background-clip: text;
1107
+ animation: vanillasky-preparation-shimmer 2.4s ease-in-out infinite;
1109
1108
  }
1109
+ @keyframes vanillasky-preparation-shimmer {
1110
+ from { background-position: 100% 0; }
1111
+ to { background-position: 0% 0; }
1112
+ }
1113
+ @media (prefers-reduced-motion: reduce) {
1114
+ .vanillasky-video-chat .video-preparation-text { animation: none; background: none; color: inherit; }
1115
+ }
1116
+ @media (forced-colors: active) {
1117
+ .vanillasky-video-chat .video-preparation-text { animation: none; background: none; color: CanvasText; }
1118
+ }
1119
+
1120
+ .vanillasky-video-chat .home-link { display: inline-flex; border-radius: 4px; }
1121
+ .vanillasky-video-chat .home-link:focus-visible { outline: 2px solid currentColor; outline-offset: 6px; }
1122
+ .vanillasky-video-chat .cards button { display: flex; flex-direction: column; justify-content: flex-end; aspect-ratio: auto; min-height: calc(var(--vs-card-width) / var(--vs-card-ratio)); }
1123
+ .vanillasky-video-chat .card-prompt { position: relative; inset: auto; overflow-wrap: anywhere; flex-shrink: 0; }
1124
+
1125
+ .vanillasky-video-chat .cards { flex-shrink: 0; }
1126
+
1127
+ .vanillasky-video-chat .transcript-toggle { display: inline-flex; align-items: center; gap: 8px; min-height: 40px; padding: 8px 12px; border: 0; border-radius: 10px; background: var(--vs-media-glass); color: var(--vs-media-muted); font: inherit; font-size: 13px; cursor: pointer; pointer-events: auto; }
1128
+ .vanillasky-video-chat .transcript-toggle svg { width: 16px; height: 16px; }
1129
+ .vanillasky-video-chat .transcript-toggle:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }