@vanillaskyai/video 0.10.14 → 0.10.16

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,17 @@ VanillaSky follows semantic versioning. This changelog begins with the 0.1 beta.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.10.16
8
+
9
+ - Start the first fully prepared scene without waiting for an eight-second startup buffer, while preserving narration and visual readiness.
10
+ - Keep generated narration alive when a superseded playback attempt rejects after pause and resume.
11
+ - Give ending suggestions larger responsive cards with complete labels, including narrow embedded players.
12
+ - Match simple singular and plural stock subjects in the starter without discarding required subjects or exclusions.
13
+
14
+ ## 0.10.15
15
+
16
+ - Recover a mislabeled first chat brief only when its complete authored content validates, preserving its shots and ending. Keep malformed JSON and incomplete or later records rejected.
17
+
7
18
  ## 0.10.14
8
19
 
9
20
  - Attach content-free record-shape diagnostics to rejected chat plans without accepting alternate formats or exposing model output.
package/dist/react.js CHANGED
@@ -1385,18 +1385,6 @@ function createSceneTimeline(options) {
1385
1385
  }
1386
1386
 
1387
1387
  // src/player/scene-readiness.ts
1388
- var READY_AHEAD_SECONDS = 8;
1389
- function canStartPreparedSequence(scenes, complete) {
1390
- let seconds = 0;
1391
- let count = 0;
1392
- for (const scene of scenes) {
1393
- if (!scene) break;
1394
- count += 1;
1395
- seconds += scene.timing.fixedDuration ?? 0;
1396
- if (seconds >= READY_AHEAD_SECONDS) return true;
1397
- }
1398
- return complete && count > 0 && count === scenes.length;
1399
- }
1400
1388
  function preparedSceneDuration(scene, spokenSeconds, metadata) {
1401
1389
  const timing = metadata?.timing;
1402
1390
  const authored = (timing?.revealSeconds ?? 0) + (timing?.holdSeconds ?? 0) + (timing?.exitSeconds ?? 0);
@@ -1586,6 +1574,18 @@ function createVideoChatVoice(options = {}) {
1586
1574
  let disposed = false;
1587
1575
  let generatedSpeechUnavailable = false;
1588
1576
  let playbackFailure;
1577
+ let playbackAttempt = 0;
1578
+ const playGenerated = (element, fail) => {
1579
+ const attempt = ++playbackAttempt;
1580
+ const reject = () => {
1581
+ if (attempt === playbackAttempt && !held && sounding === element) fail?.();
1582
+ };
1583
+ try {
1584
+ void element.play().catch(reject);
1585
+ } catch {
1586
+ reject();
1587
+ }
1588
+ };
1589
1589
  const notifyFallback = () => {
1590
1590
  try {
1591
1591
  void Promise.resolve(options.onFallback?.()).catch(() => void 0);
@@ -1698,6 +1698,7 @@ function createVideoChatVoice(options = {}) {
1698
1698
  return { seconds: line.seconds, ...line.source === "generated" && line.measured === true ? { supportsOffsets: true } : {} };
1699
1699
  },
1700
1700
  pause() {
1701
+ playbackAttempt++;
1701
1702
  held = true;
1702
1703
  sounding?.pause();
1703
1704
  globalThis.speechSynthesis?.pause();
@@ -1712,12 +1713,7 @@ function createVideoChatVoice(options = {}) {
1712
1713
  } catch {
1713
1714
  }
1714
1715
  }
1715
- if (sounding) {
1716
- const fail = playbackFailure;
1717
- void sounding.play().catch(() => {
1718
- if (!held) fail?.();
1719
- });
1720
- }
1716
+ if (sounding) playGenerated(sounding, playbackFailure);
1721
1717
  if (!silent) globalThis.speechSynthesis?.resume();
1722
1718
  },
1723
1719
  setMuted(muted) {
@@ -1848,13 +1844,7 @@ function createVideoChatVoice(options = {}) {
1848
1844
  element.onended = finish;
1849
1845
  element.onerror = fail;
1850
1846
  signal.addEventListener("abort", stop, { once: true });
1851
- try {
1852
- if (!held) void element.play().catch(() => {
1853
- if (!held) fail();
1854
- });
1855
- } catch {
1856
- fail();
1857
- }
1847
+ if (!held) playGenerated(element, fail);
1858
1848
  });
1859
1849
  } catch {
1860
1850
  playbackFailed = true;
@@ -2372,7 +2362,7 @@ function useVideoChatSession(options = {}) {
2372
2362
  return;
2373
2363
  }
2374
2364
  if (!timeline) {
2375
- if (!style || openingActive || heldRef.current || available === appended || !canStartPreparedSequence(ready.slice(0, available), planDone)) return;
2365
+ if (!style || openingActive || heldRef.current || available === appended) return;
2376
2366
  timeline = createSceneTimeline({ style, orientation });
2377
2367
  timelineRef.current = timeline;
2378
2368
  openingController.abort(new DOMException("Opening replaced by response", "AbortError"));
package/dist/server.js CHANGED
@@ -952,6 +952,21 @@ function readShot(value, clipDurationSec, answerSubject = "") {
952
952
  continuity: item?.continuity === "continue" ? "continue" : "cut"
953
953
  };
954
954
  }
955
+ function recoverFirstBrief(part, clipDurationSec) {
956
+ if (!part || typeof part.type !== "string" || !part.type.trim() || part.type === "answer" || part.type === "shot") return;
957
+ const bounded = (value, maximum, allowEmpty = false) => typeof value === "string" && value.trim().length <= maximum && (allowEmpty || Boolean(value.trim()));
958
+ if (!["opening", "subject", "development", "visualDirection", "ending"].every((key) => Object.hasOwn(part, key)) || !bounded(part.opening, 300) || !bounded(part.subject, 80) || !bounded(part.development, 2e3, true) || !bounded(part.visualDirection, 600)) return;
959
+ const ending = object(part.ending);
960
+ if (!ending || !bounded(ending.narration, 2e3) || !bounded(ending.subject, 80, true) || !bounded(ending.action, 600, true) || Object.hasOwn(ending, "title") && !bounded(ending.title, 65) || typeof ending.durationSec !== "number" || !Number.isFinite(ending.durationSec) || ending.continuity !== "cut" && ending.continuity !== "continue") return;
961
+ const subject = text(part.subject, 80);
962
+ return {
963
+ opening: text(part.opening, 300),
964
+ subject,
965
+ development: text(part.development, 2e3),
966
+ visualDirection: text(part.visualDirection, 600),
967
+ ending: readShot(ending, clipDurationSec, subject)
968
+ };
969
+ }
955
970
  function replaceStream(source, textStream) {
956
971
  if (!(typeof source === "object" && source != null && "textStream" in source)) return textStream;
957
972
  return new Proxy({ textStream }, {
@@ -984,7 +999,7 @@ function createChatShotPlanner(options) {
984
999
  const upstream = typeof source === "object" && source != null && "textStream" in source ? source.textStream : source;
985
1000
  const translated = (async function* () {
986
1001
  let brief, buffer = "", index = 0, bodyDuration = 0, lastNarration = "";
987
- let firstBody = true;
1002
+ let firstBody = true, recordsSeen = 0;
988
1003
  const reject = (cause) => {
989
1004
  incomplete.add(context);
990
1005
  const error = cause instanceof Error ? cause : new Error(String(cause));
@@ -1011,8 +1026,15 @@ function createChatShotPlanner(options) {
1011
1026
  const line = (raw) => {
1012
1027
  const trimmed = raw.trim();
1013
1028
  if (!trimmed || /^```(?:json|ndjson)?$/i.test(trimmed)) return;
1029
+ const firstRecord = recordsSeen++ === 0;
1014
1030
  const value = JSON.parse(trimmed);
1015
1031
  const part = object(value);
1032
+ const recovered = firstRecord && !brief && index === 0 ? recoverFirstBrief(part, clipDurationSec) : void 0;
1033
+ if (recovered) {
1034
+ brief = recovered;
1035
+ options.publishOpening({ line: brief.opening, keyword: brief.subject });
1036
+ return;
1037
+ }
1016
1038
  if (part?.type === "answer") {
1017
1039
  if (brief) throw new Error("Chat answer brief was emitted more than once");
1018
1040
  brief = { opening: text(part.opening, 300), subject: text(part.subject, 80), visualDirection: text(part.visualDirection, 600), development: text(part.development, 2e3) };
@@ -1193,6 +1215,7 @@ async function* resolveShots(parts, context, options) {
1193
1215
  // src/server/video-chat-prompts.ts
1194
1216
  function createVideoChatResponseInstructions(generatedVideoAvailable, openingAlreadyProvided = false, maxGeneratedVideos = 5, clipDurationSec = 5, mode = "cinematic") {
1195
1217
  return [
1218
+ 'Use the exact record type "answer" for the first brief and "shot" for developing beats. Output JSON records only, with no prose outside them, including when explaining a limitation.',
1196
1219
  "Write a complete, intentful video answer as newline-delimited JSON. Match the user's form and tone; mixed intents can combine directions.",
1197
1220
  `First write one brief: {"type":"answer","intent":"explanation|story|comedy|imagination|practical","opening":"a short inviting spoken introduction of 6\u20139 words","subject":"literal visual subject","development":"the essential development of this answer","visualDirection":"consistent subjects, appearance and visual approach","ending":{"title":"short meaningful chapter title, at most 65 characters","narration":"the authored payoff","subject":"literal subject","action":"visible action or change","durationSec":${clipDurationSec},"continuity":"cut|continue"}}.`,
1198
1221
  `Then stream each developing shot on its own line: {"type":"shot","title":"short meaningful chapter title, at most 65 characters","narration":"the exact spoken beat","subject":"2\u20138 literal filmable words, at most 80 characters","action":"concrete subject, action or visible change and useful framing","durationSec":${clipDurationSec},"continuity":"cut|continue"}.`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillaskyai/video",
3
- "version": "0.10.14",
3
+ "version": "0.10.16",
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.14",
12
+ "@vanillaskyai/video": "0.10.16",
13
13
  "react": "^19.2.8",
14
14
  "react-dom": "^19.2.8",
15
15
  "@ai-sdk/anthropic": "^3.0.0",
@@ -17,6 +17,11 @@ const cache = new Map<string, { expires: number; media: StockVideo | null }>();
17
17
  const words = (value: string): string[] => value.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
18
18
  const ignored = new Set(['a','an','the','in','on','at','of','with','and','to']);
19
19
  const terms = (value: string) => words(value).filter(word => !ignored.has(word));
20
+ // Only simple English -s forms; avoid aggressive stemming and ambiguous endings.
21
+ function wordForm(word: string) {
22
+ return /^[a-z]{3,}s$/.test(word) && !/(?:ss|us|is|ies)$/.test(word) && word !== 'news'
23
+ ? word.slice(0, -1) : word;
24
+ }
20
25
  function selectionHint(value: unknown) {
21
26
  const phrase = (input: unknown) => {
22
27
  if (typeof input !== 'string') return undefined;
@@ -48,7 +53,7 @@ export async function findStockFootage(query: string, orientation: VideoOrientat
48
53
  const normalized = query.trim().toLowerCase().replace(/\s+/g, " ");
49
54
  const tokens = words(normalized);
50
55
  const selection = selectionHint(rawSelection);
51
- const key = JSON.stringify({version: 2, orientation, query: normalized, selection});
56
+ const key = JSON.stringify({version: 3, orientation, query: normalized, selection});
52
57
  const apiKey = process.env.PEXELS_API_KEY;
53
58
  if (!apiKey || !tokens.length || normalized.length > 80 || tokens.length > 8) return null;
54
59
  const existing = cache.get(key);
@@ -66,10 +71,10 @@ export async function findStockFootage(query: string, orientation: VideoOrientat
66
71
  const slug = new URL(video.url).pathname.replace(/^\/video\//, "");
67
72
  const title = typeof video.title === "string" ? video.title : "";
68
73
  const tags = Array.isArray(video.tags) ? video.tags.filter((tag): tag is string => typeof tag === "string").join(" ") : "";
69
- const subject = words(`${slug} ${title} ${typeof video.description === "string" ? video.description : ""} ${tags}`).filter(token => !/^\d+$/.test(token));
70
- let matches = tokens.filter(token => subject.includes(token)).length;
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;
71
76
  if (selection && subject.length) {
72
- const covers = (phrase: string) => terms(phrase).every(word => subject.includes(word));
77
+ const covers = (phrase: string) => terms(phrase).every(word => subject.includes(wordForm(word)));
73
78
  if (!covers(selection.subject) || selection.exclude?.some(covers)) continue;
74
79
  // Query context breaks equal hint matches without outweighing a hint.
75
80
  const contextScore = matches / (tokens.length + 1);
@@ -348,12 +348,15 @@
348
348
 
349
349
  .vanillasky-video-chat .ending-body {
350
350
  position: absolute;
351
- inset: auto 0 0;
351
+ inset: 96px 0 180px;
352
352
  display: flex;
353
353
  flex-direction: column;
354
354
  align-items: flex-start;
355
355
  gap: clamp(0.5rem, 1.4cqh, 0.9rem);
356
- padding: 0 clamp(1.25rem, 4cqw, 3.5rem) clamp(1.25rem, 4cqh, 2.5rem);
356
+ padding: 12px clamp(20px, 3.5cqw, 56px);
357
+ overflow-y: auto;
358
+ overscroll-behavior: contain;
359
+ justify-content: safe flex-end;
357
360
  color: oklch(1 0 0);
358
361
  }
359
362
 
@@ -367,9 +370,13 @@
367
370
  text-shadow: 0 1px 3px oklch(0.14 0.05 265 / 70%);
368
371
  }
369
372
 
370
- .vanillasky-video-chat .ending .cards { margin-top: 0; }
371
-
372
- .vanillasky-video-chat .ending .cards button { width: clamp(8rem, 15.5cqw, 12rem); }
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
+ }
373
380
 
374
381
  .vanillasky-video-chat .ending .card-dots { align-self: flex-start; margin-left: -0.35rem; }
375
382
 
@@ -410,7 +417,6 @@
410
417
  animation: vanillasky-video-chat-fade-in 320ms ease both;
411
418
  }
412
419
 
413
-
414
420
  @keyframes vanillasky-video-chat-fade-in { from { opacity: 0; } to { opacity: 1; } }
415
421
 
416
422
  @keyframes vanillasky-video-chat-rise { from { opacity: 0; transform: translate(-50%, 6px); } to { opacity: 1; transform: translate(-50%, 0); } }
@@ -600,7 +606,6 @@
600
606
 
601
607
  .vanillasky-video-chat .panel .error { pointer-events: auto; width: min(560px, 100%); background: var(--vs-bg); }
602
608
 
603
-
604
609
  .vanillasky-video-chat .welcome { background: var(--vs-media-ground); }
605
610
 
606
611
  .vanillasky-video-chat .welcome .frame-media { max-width: none; max-height: none; object-fit: cover; }
@@ -625,10 +630,6 @@
625
630
 
626
631
  .vanillasky-video-chat .card-dots { display: none; }
627
632
 
628
- .vanillasky-video-chat .ending-body { padding: 0 var(--vs-media-gutter) 190px; }
629
-
630
- .vanillasky-video-chat .ending .cards { margin-top: 0; }
631
-
632
633
  .vanillasky-video-chat .ending-label { font-size: 15px; margin-bottom: 6px; }
633
634
 
634
635
  @media (max-width: 700px) {
@@ -643,15 +644,13 @@
643
644
  .vanillasky-video-chat .cards { gap: 12px; width: calc(100% + 20px); max-width: calc(100% + 20px); padding: 12px 20px 12px 12px; }
644
645
  .vanillasky-video-chat .cards button { width: 172px; aspect-ratio: .95; }
645
646
  .vanillasky-video-chat .card-prompt { font-size: 14px; padding: 14px; }
646
- .vanillasky-video-chat .ending-body { padding: 0 20px 190px; }
647
- .vanillasky-video-chat .ending .cards button { width: 165px; aspect-ratio: 1.15; }
647
+
648
648
  }
649
649
 
650
650
  @media (max-height: 650px) and (min-width: 701px) {
651
651
  .vanillasky-video-chat .welcome-body { padding-top: 110px; padding-bottom: 118px; }
652
652
  .vanillasky-video-chat .welcome-title { font-size: clamp(32px, 4vw, 54px); }
653
653
  .vanillasky-video-chat .cards button { width: 160px; }
654
- .vanillasky-video-chat .ending-body { padding-bottom: 160px; }
655
654
  }
656
655
 
657
656
  @media (max-height: 650px) and (max-width: 700px) {
@@ -709,8 +708,6 @@
709
708
 
710
709
  .vanillasky-video-chat .expanded-captions .eyebrow { color: var(--vs-media-muted); }
711
710
 
712
-
713
-
714
711
  .vanillasky-video-chat .dock-hint { position: absolute; inset: auto 0 7px; text-align: center; margin: 0; color: var(--vs-media-muted); font-size: 11px; line-height: 16px; }
715
712
 
716
713
  .vanillasky-video-chat .panel-inner:has(.dock-hint) { padding-bottom: max(38px, calc(env(safe-area-inset-bottom) + 20px)); }
@@ -784,7 +781,6 @@
784
781
 
785
782
  .vanillasky-video-chat .composer-meta:empty { display: none; }
786
783
 
787
-
788
784
  .vanillasky-video-chat .composer-meta .error { width: auto; }
789
785
 
790
786
  @media (max-width: 700px) {
@@ -922,7 +918,6 @@
922
918
  .vanillasky-video-chat .developer-links button { width: 100%; border: 0; background: transparent; text-align: left; cursor: pointer; font-family: inherit; }
923
919
  .vanillasky-video-chat .developer-about p { margin: 0; padding: 2px 12px 12px; color: var(--vs-fg-muted); font-size: 12px; line-height: 1.6; }
924
920
 
925
-
926
921
  .vanillasky-video-chat .history { display: grid; gap: 4px; }
927
922
 
928
923
  .vanillasky-video-chat .history .section-label { padding-top: 8px; padding-bottom: 5px; }
@@ -943,7 +938,6 @@
943
938
 
944
939
  .vanillasky-video-chat .history-row small { display: block; margin-top: 4px; font-size: 11px; font-weight: 400; color: #aeb2c7; }
945
940
 
946
-
947
941
  @media (max-width: 700px) {
948
942
  .vanillasky-video-chat .sheet-popover { right: 12px; width: min(360px, calc(100% - 24px)); max-height: calc(100dvh - 102px); padding: 16px; }
949
943
  .vanillasky-video-chat .popover-heading .round { width: 44px; height: 44px; }
@@ -1028,7 +1022,6 @@
1028
1022
  }
1029
1023
  }
1030
1024
 
1031
-
1032
1025
  /* Caption controls float outside the reading measure, so the complete cue
1033
1026
  keeps the same width when controls appear or disappear. */
1034
1027
  .vanillasky-video-chat .caption-actions {
@@ -1085,3 +1078,32 @@
1085
1078
  .vanillasky-video-chat .media-credit { color: var(--vs-fg); font-size: 11px; margin-left: 12px; text-underline-offset: 3px; }
1086
1079
  @keyframes vanillasky-chapter-enter { from { opacity: 0; } to { opacity: 1; } }
1087
1080
  @media (prefers-reduced-motion: reduce) { .vanillasky-video-chat .opening-chapter { animation: none; } }
1081
+
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;
1092
+ }
1093
+
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);
1099
+ line-height: 1.4;
1100
+ }
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; }
1105
+ }
1106
+
1107
+ @media (max-height: 650px) {
1108
+ .vanillasky-video-chat .ending-body { inset-block: 80px 180px; }
1109
+ }