@vanillaskyai/video 0.10.3 → 0.10.4

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/PUBLIC-API.md +43 -31
  3. package/README.md +11 -7
  4. package/dist/check-runtime.js +2 -2
  5. package/dist/{chunk-UCXQORDZ.js → chunk-G5TYLTL5.js} +1 -1
  6. package/dist/{chunk-SI6CFFAA.js → chunk-O6FBIB4L.js} +6 -5
  7. package/dist/{chunk-YZZSWGC3.js → chunk-OOBT4X46.js} +8 -3
  8. package/dist/{chunk-LJR5VLEI.js → chunk-QZAZT44G.js} +2 -2
  9. package/dist/{chunk-3U2F7C7C.js → chunk-WZFLEPLM.js} +7 -3
  10. package/dist/{chunk-VVNZCY2U.js → chunk-Z2ZI5G7O.js} +1 -1
  11. package/dist/{cinema-media-4JUYKPQI.js → cinema-media-3SIGVVCY.js} +4 -4
  12. package/dist/{comparison-3QLMUC5V.js → comparison-VDGRWPB5.js} +4 -4
  13. package/dist/{editorial-timeline-IVE2P25N.js → editorial-timeline-QJU4JJWB.js} +4 -4
  14. package/dist/{key-figure-APD56LCO.js → key-figure-YUAQW6OE.js} +4 -4
  15. package/dist/{mobile-message-WLK3WY5B.js → mobile-message-ZMMTUDUQ.js} +4 -4
  16. package/dist/{quote-Z6YET5BO.js → quote-BB6UJT6L.js} +4 -4
  17. package/dist/react.d.ts +3 -1
  18. package/dist/react.js +79 -81
  19. package/dist/{scene-video-backdrop-OWUXPXHZ.js → scene-video-backdrop-4EZVVQY7.js} +2 -2
  20. package/dist/server.d.ts +15 -3
  21. package/dist/server.js +129 -33
  22. package/dist/{types-CdBmNTGD.d.ts → types-BqB8zC9u.d.ts} +1 -1
  23. package/docs/concepts.md +2 -2
  24. package/docs/development.md +23 -0
  25. package/docs/getting-started.md +2 -2
  26. package/docs/media-and-audio.md +26 -13
  27. package/docs/performance.md +21 -10
  28. package/docs/production.md +11 -13
  29. package/docs/prompt-and-input.md +3 -3
  30. package/docs/provider-integration.md +9 -11
  31. package/docs/reference/provider-adapters.md +3 -2
  32. package/package.json +5 -2
  33. package/registry/items/backgrounds.json +2 -2
  34. package/starters/video-chat/README.md +13 -15
  35. package/starters/video-chat/package.json +1 -1
  36. package/starters/video-chat/providers/video.ts +5 -2
  37. package/starters/video-chat/server.ts +1 -1
  38. package/starters/video-chat/stock.ts +59 -31
  39. package/styles/video-chat.css +6 -61
package/dist/server.js CHANGED
@@ -895,15 +895,16 @@ var object = (value) => value && typeof value === "object" && !Array.isArray(val
895
895
  function text(value, maximum) {
896
896
  return typeof value === "string" && value.trim().length <= maximum ? value.trim() : "";
897
897
  }
898
- function readShot(value) {
898
+ function readShot(value, clipDurationSec) {
899
899
  const item = object(value);
900
900
  const narration = text(item?.narration, 2e3);
901
901
  if (!narration) throw new Error("Chat shot requires bounded authored narration");
902
902
  return {
903
903
  narration,
904
+ title: text(item?.title, 65) || text(item?.subject, 65) || "The next step",
904
905
  subject: text(item?.subject, 80),
905
906
  action: text(item?.action, 600),
906
- durationSec: typeof item?.durationSec === "number" && Number.isFinite(item.durationSec) ? Math.min(5, Math.max(2, item.durationSec)) : 5,
907
+ durationSec: typeof item?.durationSec === "number" && Number.isFinite(item.durationSec) ? Math.min(clipDurationSec, Math.max(2, item.durationSec)) : clipDurationSec,
907
908
  continuity: item?.continuity === "continue" ? "continue" : "cut"
908
909
  };
909
910
  }
@@ -916,12 +917,13 @@ function replaceStream(source, textStream) {
916
917
  });
917
918
  }
918
919
  function createChatShotPlanner(options) {
920
+ const clipDurationSec = options.generatedClipDurationSec ?? 5;
919
921
  const incomplete = /* @__PURE__ */ new WeakSet();
920
922
  const planner = createTextDeltaVideoPlanner({
921
923
  includeRawProviderData: options.includeRawProviderData,
922
924
  streamText(context) {
923
925
  const providerContext = { ...context, userPrompt: [
924
- `Create a complete answer within ${context.request.input.maxDurationSec ?? 40} seconds. Each generated clip has at most five seconds; give each spoken beat room to finish.`,
926
+ `Create a complete answer within ${context.request.input.maxDurationSec ?? 40} seconds. Each generated clip has at most ${clipDurationSec} seconds; give each spoken beat room to finish.`,
925
927
  `Orientation: ${context.request.input.orientation ?? "landscape"}.`,
926
928
  "USER REQUEST AND CONVERSATION",
927
929
  context.request.input.input
@@ -952,7 +954,7 @@ function createChatShotPlanner(options) {
952
954
  return { type: "scene.add", ...closer ? { placement: "closer" } : {}, scene: {
953
955
  id: `${context.request.requestId}-shot-${++index}`,
954
956
  templateId: "cinemaMedia",
955
- variables: { mediaType: "video", mediaKeyword: shot.subject, shotDirection: [
957
+ variables: { fallbackText: shot.title, mediaType: "video", mediaKeyword: shot.subject, shotDirection: [
956
958
  brief?.visualDirection,
957
959
  shot.action,
958
960
  shot.continuity === "continue" ? "Continue the established subject, setting and action consistently." : "A deliberate new shot; choose framing that reveals this beat.",
@@ -971,7 +973,7 @@ function createChatShotPlanner(options) {
971
973
  brief = { opening: text(part.opening, 300), subject: text(part.subject, 80), visualDirection: text(part.visualDirection, 600), development: text(part.development, 2e3) };
972
974
  if (part.ending) {
973
975
  try {
974
- brief.ending = readShot(part.ending);
976
+ brief.ending = readShot(part.ending, clipDurationSec);
975
977
  } catch (cause) {
976
978
  reject(cause);
977
979
  }
@@ -981,10 +983,10 @@ function createChatShotPlanner(options) {
981
983
  }
982
984
  if (part?.type !== "shot") throw new Error("Chat plan requires an answer brief followed by shots");
983
985
  if (!brief) throw new Error("Chat shot arrived before its answer brief");
984
- const shot = readShot(part);
986
+ const shot = readShot(part, clipDurationSec);
985
987
  if (shot.narration === brief.ending?.narration) return;
986
988
  if (firstBody && !continueAfterOpening(shot.narration, [options.openingLine ?? brief.opening])) return;
987
- const budget = (context.request.input.maxDurationSec ?? 40) - (brief.ending?.durationSec ?? 5);
989
+ const budget = (context.request.input.maxDurationSec ?? 40) - (brief.ending?.durationSec ?? clipDurationSec);
988
990
  if (bodyDuration + shot.durationSec > budget) throw new Error("Chat shot exceeds the answer duration budget");
989
991
  bodyDuration += shot.durationSec;
990
992
  return scenePart(shot);
@@ -1051,6 +1053,7 @@ async function* resolveShots(parts, context, options) {
1051
1053
  let notify, space;
1052
1054
  const resolve = async (part) => {
1053
1055
  if (part.type !== "scene.add") return part;
1056
+ options.prepareScene?.({ sceneId: part.scene.id, narration: part.scene.narration ?? "" });
1054
1057
  const { mediaKeyword } = part.scene.variables;
1055
1058
  let media;
1056
1059
  if (typeof mediaKeyword === "string" && mediaKeyword && options.resolveMedia) media = await options.resolveMedia(mediaKeyword, {
@@ -1063,8 +1066,9 @@ async function* resolveShots(parts, context, options) {
1063
1066
  signal: context.signal
1064
1067
  });
1065
1068
  context.signal.throwIfAborted();
1066
- if (!media) getGenerationLifecycleSink(context)?.reportWarning?.({ code: "provider_warning", category: "provider", message: "Some visuals are unavailable; narration continues.", recoverable: true });
1067
- const scene = { ...part.scene, variables: { mediaType: media?.type === "image" ? "photo" : "video", mediaUrl: media?.url ?? "", ...media?.posterUrl ? { mediaPoster: media.posterUrl } : {} } };
1069
+ if (!media) getGenerationLifecycleSink(context)?.reportWarning?.({ code: "provider_warning", category: "provider", message: MEDIA_RECOVERY_NOTICE, recoverable: true });
1070
+ const title = part.scene.variables.fallbackText;
1071
+ const scene = media ? { ...part.scene, variables: { fallbackText: title, mediaType: media.type === "image" ? "photo" : "video", mediaUrl: media.url, ...media.posterUrl ? { mediaPoster: media.posterUrl } : {} } } : { ...part.scene, templateId: "chapterTitle", variables: { title } };
1068
1072
  return { ...part, scene };
1069
1073
  };
1070
1074
  const producer = (async () => {
@@ -1112,16 +1116,16 @@ async function* resolveShots(parts, context, options) {
1112
1116
  }
1113
1117
 
1114
1118
  // src/server/video-chat-prompts.ts
1115
- function createVideoChatResponseInstructions(generatedVideoAvailable, openingAlreadyProvided = false, maxGeneratedVideos = 5) {
1119
+ function createVideoChatResponseInstructions(generatedVideoAvailable, openingAlreadyProvided = false, maxGeneratedVideos = 5, clipDurationSec = 5, mode = "cinematic") {
1116
1120
  return [
1117
1121
  "Write a complete, intentful video answer as newline-delimited JSON. Match the user's form and tone; mixed intents can combine directions.",
1118
- '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":{"narration":"the authored payoff","subject":"literal subject","action":"visible action or change","durationSec":5,"continuity":"cut|continue"}}.',
1119
- 'Then stream each developing shot on its own line: {"type":"shot","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":5,"continuity":"cut|continue"}.',
1120
- `The host permits at most ${generatedVideoAvailable ? maxGeneratedVideos : 0} generated-video attempts for this response. ${generatedVideoAvailable ? "Generated footage is preferred." : "Generated footage is unavailable."} Relevant stock is the fallback and may only depict a literal filmable subject; some requested visuals may be unavailable. Preserve the complete answer rather than shortening it to fit credits. The host selects providers; do not make source choices.`,
1122
+ `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"}}.`,
1123
+ `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"}.`,
1124
+ `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. Preserve the full answer rather than shortening it to fit credits. The host selects providers; do not make source choices.`,
1121
1125
  "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.",
1122
1126
  "The brief's ending is saved and played after your developing shots. Do not repeat it as a shot. Stop writing after the last developing shot. No technical events, identifiers, template choices, media providers, URLs or extra fields.",
1123
1127
  "Every shot uses moving footage with separate narration and subtitles. Generated footage is silent: do not ask its subjects to speak or render words. No headline cards or on-screen explanatory text.",
1124
- "Each clip has at most five seconds. Write spoken beats that fit naturally, usually 8\u201310 words per five-second shot. Split longer ideas across purposeful shots, preserving facts and qualifiers. Never truncate a claim to meet a word target. Use only the shots needed within the total duration, including the ending; do not pad to a fixed count.",
1128
+ `Each clip has at most ${clipDurationSec} seconds. Write spoken beats that fit naturally, usually ${Math.floor(clipDurationSec * 1.6)}\u2013${Math.floor(clipDurationSec * 2)} words per shot. Split longer ideas across purposeful shots, preserving facts and qualifiers. Never truncate a claim to meet a word target. Use only the shots needed within the total duration, including the ending; do not pad to a fixed count.`,
1125
1129
  "Identify the full answer and its ending before developing shots. Each action must support what is said: camera movement alone is not progression. Vary scale, viewpoint and meaningful details while keeping subjects consistent.",
1126
1130
  "Explanations: clarify the actual causal mechanism, separating physical cause from a metaphor. Generated cutaways and animation illustrate ideas; they are not factual evidence. Preserve uncertainty, quantities and conditions; never invent evidence or quotations.",
1127
1131
  "Stories: portray characters making choices and experiencing consequences; use consistent character descriptions and an earned resolution, not a promised next scene.",
@@ -1221,8 +1225,8 @@ function parseResponseRequest(value) {
1221
1225
  const body = record2(value, "request");
1222
1226
  allowedKeys2(body, ["prompt", "opening", "mode", "orientation", "conversation", "style"], "request");
1223
1227
  const mode = body.mode ?? "cinematic";
1224
- if (mode !== "cinematic") {
1225
- throw new Error("request.mode must be cinematic");
1228
+ if (mode !== "cinematic" && mode !== "pexels") {
1229
+ throw new Error("request.mode must be cinematic or pexels");
1226
1230
  }
1227
1231
  const orientation = body.orientation ?? "landscape";
1228
1232
  if (orientation !== "portrait" && orientation !== "landscape") {
@@ -1308,7 +1312,25 @@ function resequenceEvent(event, sequence) {
1308
1312
  eventId: `${event.runId}:${sequence}`
1309
1313
  };
1310
1314
  }
1311
- function streamVideoChatOpening(response, openingReady) {
1315
+ var VIDEO_CHAT_PREPARATION_EVENT_TYPE = "data.video-chat-preparation";
1316
+ function createPreparationChannel() {
1317
+ let wake;
1318
+ const channel = {
1319
+ queue: [],
1320
+ changed: new Promise((resolve) => {
1321
+ wake = resolve;
1322
+ }),
1323
+ publish(value) {
1324
+ channel.queue.push(value);
1325
+ wake();
1326
+ channel.changed = new Promise((resolve) => {
1327
+ wake = resolve;
1328
+ });
1329
+ }
1330
+ };
1331
+ return channel;
1332
+ }
1333
+ function streamVideoChatOpening(response, openingReady, preparations, cancel) {
1312
1334
  if (!response.body || !response.headers.get("content-type")?.includes("text/event-stream")) return response;
1313
1335
  const events = decodeVideoSse(response.body)[Symbol.asyncIterator]();
1314
1336
  const encoded = (async function* () {
@@ -1324,7 +1346,8 @@ function streamVideoChatOpening(response, openingReady) {
1324
1346
  ...first.value.data.capabilities,
1325
1347
  extensions: Array.from(/* @__PURE__ */ new Set([
1326
1348
  ...first.value.data.capabilities?.extensions ?? [],
1327
- VIDEO_CHAT_OPENING_EVENT_TYPE
1349
+ VIDEO_CHAT_OPENING_EVENT_TYPE,
1350
+ VIDEO_CHAT_PREPARATION_EVENT_TYPE
1328
1351
  ]))
1329
1352
  }
1330
1353
  }
@@ -1347,10 +1370,28 @@ function streamVideoChatOpening(response, openingReady) {
1347
1370
  });
1348
1371
  sequence += 1;
1349
1372
  }
1350
- let next = await nextEvent;
1351
- while (!next.done) {
1352
- yield encodeVideoSseEvent(resequenceEvent(next.value, sequence++));
1353
- next = await events.next();
1373
+ let pending = nextEvent;
1374
+ while (true) {
1375
+ while (preparations.queue.length) {
1376
+ const data = preparations.queue.shift();
1377
+ yield encodeVideoSseEvent({
1378
+ protocolVersion: first.value.protocolVersion,
1379
+ runId: first.value.runId,
1380
+ sequence,
1381
+ eventId: `${first.value.runId}:${sequence}`,
1382
+ type: VIDEO_CHAT_PREPARATION_EVENT_TYPE,
1383
+ data
1384
+ });
1385
+ sequence += 1;
1386
+ }
1387
+ const next = await Promise.race([
1388
+ pending.then((value) => ({ value })),
1389
+ preparations.changed.then(() => void 0)
1390
+ ]);
1391
+ if (!next || preparations.queue.length) continue;
1392
+ if (next.value.done) break;
1393
+ yield encodeVideoSseEvent(resequenceEvent(next.value.value, sequence++));
1394
+ pending = events.next();
1354
1395
  }
1355
1396
  } finally {
1356
1397
  await events.return?.(void 0);
@@ -1376,6 +1417,7 @@ function streamVideoChatOpening(response, openingReady) {
1376
1417
  }
1377
1418
  },
1378
1419
  async cancel() {
1420
+ cancel();
1379
1421
  completed = true;
1380
1422
  await iterator.return?.();
1381
1423
  }
@@ -1440,7 +1482,8 @@ function createVideoChatHandler(options) {
1440
1482
  allowCredentials,
1441
1483
  mediaConcurrency = 5,
1442
1484
  maxGeneratedVideos = 5,
1443
- generateVideoTimeoutMs = 15e3
1485
+ generateVideoTimeoutMs = 15e3,
1486
+ generatedClipDurationSec = 5
1444
1487
  } = options;
1445
1488
  const videoOptions = {
1446
1489
  templates: options.templates,
@@ -1456,6 +1499,7 @@ function createVideoChatHandler(options) {
1456
1499
  if (!Number.isFinite(maxAudioBytes) || maxAudioBytes <= 0) throw new Error("maxAudioBytes must be positive");
1457
1500
  if (!Number.isFinite(maxBodyBytes) || maxBodyBytes <= 0) throw new Error("maxBodyBytes must be positive");
1458
1501
  if (!Number.isSafeInteger(generateVideoTimeoutMs) || generateVideoTimeoutMs < 1 || generateVideoTimeoutMs > 12e4) throw new Error("generateVideoTimeoutMs must be an integer from 1 to 120000");
1502
+ if (!Number.isFinite(generatedClipDurationSec) || generatedClipDurationSec < 2 || generatedClipDurationSec > 20) throw new Error("generatedClipDurationSec must be from 2 to 20");
1459
1503
  if (!Number.isSafeInteger(maxGeneratedVideos) || maxGeneratedVideos < 0) throw new Error("maxGeneratedVideos must be a nonnegative safe integer");
1460
1504
  const capabilities2 = {
1461
1505
  templates: true,
@@ -1463,17 +1507,41 @@ function createVideoChatHandler(options) {
1463
1507
  generatedVideo: generateVideo != null,
1464
1508
  stockMedia: searchMedia != null,
1465
1509
  transcription: transcribe != null,
1466
- modes: ["cinematic"]
1510
+ modes: searchMedia ? ["cinematic", "pexels"] : ["cinematic"]
1467
1511
  };
1468
1512
  const welcomePrompts = (welcomeOptions?.prompts ?? DEFAULT_WELCOME_PROMPTS).slice(0, 4);
1469
1513
  const heroQuery = welcomeOptions?.heroQuery;
1470
1514
  let welcomeResponse;
1471
1515
  let requestSequence = 0;
1472
- const responseHandler = (requestId, openingProvided, openingChannel, openingLine) => {
1473
- const generatedVideoAvailable = generateVideo != null && maxGeneratedVideos > 0;
1516
+ const responseHandler = (requestId, openingProvided, openingChannel, openingLine, mode, preparations) => {
1517
+ const startedAt = Date.now();
1518
+ const diagnose = (event) => {
1519
+ try {
1520
+ void Promise.resolve(options.onDiagnostic?.({ requestId, mode, elapsedMs: Math.max(0, Date.now() - startedAt), ...event })).catch(() => void 0);
1521
+ } catch {
1522
+ }
1523
+ };
1524
+ diagnose({ phase: "request-accepted" });
1525
+ const generatedVideoAvailable = mode === "cinematic" && generateVideo != null && maxGeneratedVideos > 0;
1474
1526
  let lifecycle;
1475
1527
  let generatedAttempts = 0;
1528
+ let mediaStartedAt;
1529
+ let mediaIndex = 0;
1476
1530
  const resolveSelected = generateVideo || searchMedia ? async (query, context) => {
1531
+ mediaStartedAt ??= Date.now();
1532
+ const remainingMs = mediaStartedAt + generateVideoTimeoutMs + mediaIndex++ * generatedClipDurationSec * 1e3 - Date.now();
1533
+ if (remainingMs <= 0) {
1534
+ diagnose({ phase: "media-skipped", sceneId: context.scene.id, reason: "deadline" });
1535
+ return null;
1536
+ }
1537
+ if (mode === "cinematic" && (!generateVideo || generatedAttempts >= maxGeneratedVideos)) {
1538
+ diagnose({ phase: "media-skipped", sceneId: context.scene.id, reason: !generateVideo ? "not-configured" : "allowance" });
1539
+ return null;
1540
+ }
1541
+ if (mode === "pexels" && !searchMedia) {
1542
+ diagnose({ phase: "media-skipped", sceneId: context.scene.id, reason: "not-configured" });
1543
+ return null;
1544
+ }
1477
1545
  const mediaContext = {
1478
1546
  purpose: "response",
1479
1547
  orientation: context.input.orientation ?? "landscape",
@@ -1487,6 +1555,8 @@ function createVideoChatHandler(options) {
1487
1555
  const attempt = async (resolver, timeoutMs) => {
1488
1556
  context.signal.throwIfAborted();
1489
1557
  if (!resolver) return null;
1558
+ const mediaStart = Date.now();
1559
+ diagnose({ phase: "media-start", sceneId: context.scene.id });
1490
1560
  try {
1491
1561
  const result = sanitizeVideoChatMedia(await withDeadline(
1492
1562
  (signal) => resolver(query, { ...mediaContext, signal }),
@@ -1494,15 +1564,27 @@ function createVideoChatHandler(options) {
1494
1564
  context.signal
1495
1565
  ));
1496
1566
  context.signal.throwIfAborted();
1567
+ diagnose({
1568
+ phase: "media-end",
1569
+ sceneId: context.scene.id,
1570
+ durationMs: Math.max(0, Date.now() - mediaStart),
1571
+ reason: result && (result.type === "video" || options.templates && resolver === searchMedia) ? "ready" : "empty"
1572
+ });
1497
1573
  return result;
1498
1574
  } catch (cause) {
1575
+ diagnose({
1576
+ phase: "media-end",
1577
+ sceneId: context.scene.id,
1578
+ durationMs: Math.max(0, Date.now() - mediaStart),
1579
+ reason: context.signal.aborted ? "cancelled" : cause instanceof DOMException && cause.name === "TimeoutError" ? "timeout" : "provider-error"
1580
+ });
1499
1581
  context.signal.throwIfAborted();
1500
1582
  reportError(cause);
1501
1583
  return null;
1502
1584
  }
1503
1585
  };
1504
1586
  if (generatedVideoAvailable && (!options.templates || context.scene.variables.mediaSource === "generate")) {
1505
- const generated = generatedAttempts < maxGeneratedVideos ? (generatedAttempts++, await attempt(generateVideo, generateVideoTimeoutMs)) : null;
1587
+ const generated = generatedAttempts < maxGeneratedVideos ? (generatedAttempts++, await attempt(generateVideo, remainingMs)) : null;
1506
1588
  if (generated?.type === "video") return generated;
1507
1589
  lifecycle?.reportWarning?.({
1508
1590
  code: "provider_warning",
@@ -1511,7 +1593,8 @@ function createVideoChatHandler(options) {
1511
1593
  recoverable: true
1512
1594
  });
1513
1595
  }
1514
- const stock = await attempt(searchMedia, 3e3);
1596
+ if (mode !== "pexels") return null;
1597
+ const stock = await attempt(searchMedia, Math.min(3e3, remainingMs));
1515
1598
  if (stock && (options.templates || stock.type === "video")) return stock;
1516
1599
  return null;
1517
1600
  } : void 0;
@@ -1543,7 +1626,16 @@ function createVideoChatHandler(options) {
1543
1626
  },
1544
1627
  includeRawProviderData: videoOptions.includeRawProviderData,
1545
1628
  openingLine,
1546
- publishOpening: openingChannel.publish,
1629
+ publishOpening: (opening) => {
1630
+ if (opening) diagnose({ phase: "opening-authored" });
1631
+ openingChannel.publish(opening);
1632
+ },
1633
+ prepareScene: (scene) => {
1634
+ diagnose({ phase: "shot-authored", sceneId: scene.sceneId });
1635
+ if (!resolveSelected) diagnose({ phase: "media-skipped", sceneId: scene.sceneId, reason: "not-configured" });
1636
+ preparations.publish(scene);
1637
+ },
1638
+ generatedClipDurationSec,
1547
1639
  resolveMedia: resolveSelected,
1548
1640
  mediaConcurrency
1549
1641
  }),
@@ -1551,7 +1643,7 @@ function createVideoChatHandler(options) {
1551
1643
  allowedOrigins,
1552
1644
  allowCredentials,
1553
1645
  maxBodyBytes,
1554
- systemPrompt: [createVideoChatResponseInstructions(generatedVideoAvailable, openingProvided, maxGeneratedVideos), instructions?.trim()].filter(Boolean).join("\n\nAPPLICATION GUIDANCE\n")
1646
+ systemPrompt: [createVideoChatResponseInstructions(generatedVideoAvailable, openingProvided, maxGeneratedVideos, generatedClipDurationSec, mode), instructions?.trim()].filter(Boolean).join("\n\nAPPLICATION GUIDANCE\n")
1555
1647
  });
1556
1648
  return handler;
1557
1649
  };
@@ -1677,12 +1769,14 @@ function createVideoChatHandler(options) {
1677
1769
  }
1678
1770
  const requestId = `video-chat-${Date.now()}-${requestSequence += 1}`;
1679
1771
  const openingChannel = createOpeningChannel(input.opening ? { line: input.opening, keyword: "" } : void 0);
1772
+ const preparations = createPreparationChannel();
1773
+ const cancellation = new AbortController();
1680
1774
  const forwardedHeaders = new Headers(request.headers);
1681
1775
  forwardedHeaders.delete("content-length");
1682
1776
  const videoRequest = new Request(request.url, {
1683
1777
  method: "POST",
1684
1778
  headers: forwardedHeaders,
1685
- signal: request.signal,
1779
+ signal: AbortSignal.any([request.signal, cancellation.signal]),
1686
1780
  body: JSON.stringify({
1687
1781
  protocolVersion: VIDEO_PROTOCOL_VERSION,
1688
1782
  requestId,
@@ -1705,9 +1799,11 @@ function createVideoChatHandler(options) {
1705
1799
  requestId,
1706
1800
  input.opening != null,
1707
1801
  openingChannel,
1708
- input.opening
1802
+ input.opening,
1803
+ input.mode,
1804
+ preparations
1709
1805
  )(videoRequest);
1710
- return streamVideoChatOpening(response, openingChannel.ready);
1806
+ return streamVideoChatOpening(response, openingChannel.ready, preparations, () => cancellation.abort());
1711
1807
  }
1712
1808
  try {
1713
1809
  if (action === "opening-media") {
@@ -1,4 +1,4 @@
1
- type VideoChatMode = "cinematic";
1
+ type VideoChatMode = "cinematic" | "pexels";
2
2
  interface VideoChatConversationTurn {
3
3
  prompt: string;
4
4
  response?: string;
package/docs/concepts.md CHANGED
@@ -23,7 +23,7 @@ trusted templates. It begins before the full plan is available and ends as an
23
23
  editable deterministic configuration.
24
24
 
25
25
  It is not an encoded video stream. The browser renders normal React components
26
- from validated scene instructions. The 0.1 SDK does not include MP4 or WebM
26
+ from validated scene instructions. The SDK does not include MP4 or WebM
27
27
  encoding; pass the completed deterministic JSON to an application-owned render
28
28
  or export pipeline when an encoded file is required.
29
29
 
@@ -31,7 +31,7 @@ or export pipeline when an encoded file is required.
31
31
 
32
32
  The viewer sends a prompt with bounded completed conversation turns. The server
33
33
  adds trusted application `instructions`, template capabilities, and the selected
34
- cinematic mode. `VideoChat` options control style, orientation, and custom
34
+ AI video or Pexels mode. `VideoChat` options control style, orientation, and custom
35
35
  templates. Exact facts belong in the authorized prompt or conversation; secrets
36
36
  and provider configuration stay on the server.
37
37
 
@@ -0,0 +1,23 @@
1
+ # Develop the chat experience
2
+
3
+ Run `npm ci --no-audit`, then `npm run dev:chat`. The localhost HMR surface renders the actual SDK `VideoChat`, templates and server handler from source. It never loads provider credentials. Select an intent and fault condition in the development toolbar; use the chat's normal media-mode controls. The prompt box remains the real product UI.
4
+
5
+ Offline answers are deterministic for explanation, story, comedy, imagination, practical steps and golf. They use local waterfall footage and a recorded timing cue, **not narration that matches the displayed script**. This harness is for loading, playback, controls and recovery. It cannot prove generated answer quality. Conditions cover ready, delayed footage, missing media, decode failure, speech failure, exhausted video allowance and request throttling. Browser speech may be used in the speech-failure condition.
6
+
7
+ The toolbar labels source and fixture identity. It separates the first body surface from the first decoded moving-footage frame; the former is a renderer paint opportunity and can precede decode. Its bounded safe phase log records browser request/stream arrival phases, speech response completion, first speech, footage and buffer pauses. No prompts, narration, scene IDs or provider bodies are retained. Media start/end/skip reasons come from the handler’s separate host-only `onDiagnostic` callback, shown in the local terminal for offline fixtures; live hosts own that callback themselves. Stream arrival is not the server’s exact authorship timestamp. Local fixtures never fetch external footage or invoke a paid model. Mode boundaries, provider deadlines and host admission remain covered by their dedicated server and host suites.
8
+
9
+ ## Optional live host
10
+
11
+ Set `VANILLASKY_CHAT_LIVE_ENDPOINT` to an application-owned video-chat endpoint before starting the harness. The toolbar then offers **Connect live endpoint (uses allowance)**. It stays offline until that explicit click. Live fetches include host cookies, subject to browser cookie policy. The host must allow credentialed requests from the localhost origin and provide its normal authorization; credentials and allowances stay with that application. This tool does not proxy secrets, reset limits, or retry generated answers. Capabilities and welcome requests may run as soon as you connect. Request only the bounded live examples needed to evaluate actual quality.
12
+
13
+ ## Fast checks
14
+
15
+ `npm run check:chat` runs the harness/unit recovery cases, checks harness TypeScript and runs one Chromium recording with short footage looping under actual audio. Install Chromium once with `npx playwright install chromium`. The harness smoke additionally records submit-to-visible-chapter paint opportunity with a 200 ms warm-UI target. This is a browser animation-frame opportunity, not a physical display measurement. The target for the complete command is under a minute on a warm machine; the command reports its measured duration. It does not replace release checks or claim browser-wide/live-provider coverage.
16
+
17
+ ## One candidate for release verification
18
+
19
+ `npm run verify:release` runs registry, lint, type, unit, acceptance, size, production dependency audit, API, packed-consumer, onboarding, provider and all existing browser checks. It builds and packs once into ignored `artifacts/chat-candidate/`, then sends the exact tarball and integrity to every consumer verifier. Each verifier still uses its independent clean install. Browser and provider compatibility remain independent gates; no paid API calls are enabled by this command.
20
+
21
+ `candidate.json` records version, source commit, dirty-tree status, SHA-256 and npm integrity. A local candidate may include uncommitted work; it is never called a published artifact. `VANILLASKY_CANDIDATE_DIR` selects a different output directory. The command verifies but never publishes, merges or deploys. CI additionally retains the existing Node and React version checks.
22
+
23
+ For one targeted consumer check, run `node scripts/chat-candidate.mjs`, then provide `VANILLASKY_PACKED_TARBALL`, `VANILLASKY_EXPECTED_INTEGRITY` and `VANILLASKY_EXPECTED_SHA256` from that manifest to an existing verifier. Do not repack between checks of the same candidate.
@@ -4,9 +4,9 @@
4
4
 
5
5
  The fastest VanillaSky integration is the complete, general-purpose video chat.
6
6
  It starts with a template introduction and browser voice. Add the video adapter
7
- and its server key for generated footage, plus reviewed stock for fallback.
7
+ and its server key for generated footage, or choose Pexels for stock footage.
8
8
  Without a media provider, the answer retains narration and subtitles with an
9
- unavailable-visual state. Generated speech is optional.
9
+ chapter template. Generated speech is optional.
10
10
 
11
11
  ## Create the app
12
12
 
@@ -8,21 +8,21 @@ all credentials out of React and the browser bundle.
8
8
 
9
9
  ## AI-first video answers
10
10
 
11
- The default chat shows an immediate template introduction while footage prepares,
12
- then moving video with narration and subtitles. The planner describes visible
13
- subjects and actions, adapting its direction to the request's intent. The runtime
14
- attempts generated footage first and uses relevant stock when generation is
15
- unavailable, denied by the host budget, or fails. It does not select body cards.
11
+ The default chat displays the real chapter template immediately, then prepares
12
+ speech and footage concurrently. Choose `mode: "cinematic"` for AI video or
13
+ `mode: "pexels"` for stock. The UI labels these choices **AI video** and **Pexels**.
14
+ Each mode uses only its selected media provider. A failure, exhausted video
15
+ allowance, or missed deadline becomes an authored chapter with complete narration.
16
+ The chapter retains narration and subtitles for the whole beat.
16
17
 
17
- The seven packaged templates remain available to custom compositions. Default
18
- chat body shots use `cinemaMedia` as the footage renderer, without extra headlines.
19
- If both media providers miss, the answer retains its narration and subtitles
20
- with an explicit unavailable-visual state rather than a text-slide replacement.
18
+ ## Pexels search
21
19
 
22
- ## Reviewed stock
20
+ The starter performs bounded full-catalog Pexels video search with subject
21
+ matching, orientation-aware renditions and a bounded cache. Add `PEXELS_API_KEY`
22
+ on the server and choose Pexels in Settings. The SDK UI links to Pexels; custom
23
+ interfaces must also display the attribution required by their media provider.
23
24
 
24
- Add `searchMedia` for relevant fallback footage, welcome covers, and follow-up
25
- cards:
25
+ Applications can replace `searchMedia` with their own licensed catalog:
26
26
 
27
27
  ```ts
28
28
  createVideoChatHandler({
@@ -46,7 +46,7 @@ createVideoChatHandler({
46
46
  The planner emits a short semantic keyword, not a URL. The callback returns an
47
47
  application-approved image or video URL, and the SDK validates it before it
48
48
  reaches a scene. Return `null` when no licensed, safe, relevant asset exists;
49
- default chat retains the spoken answer when footage is unavailable. An explicit custom `templates` registry retains its structured planner,
49
+ default chat displays an authored chapter while retaining the spoken answer. An explicit custom `templates` registry retains its structured planner,
50
50
  validation, and fallback contracts.
51
51
 
52
52
  For Pexels, keep `PEXELS_API_KEY` on the server, enforce a deadline, filter for
@@ -96,6 +96,19 @@ decision with a known budget.
96
96
  VanillaSky does not depend on a video model or storage service. The application
97
97
  owns the provider key, model, spend, generated bytes, retention, and delivery.
98
98
 
99
+ ### Timing and recovery
100
+
101
+ Set `generatedClipDurationSec` to the duration your video adapter actually
102
+ requests (default 5, supported range 2–20 seconds). Keep it aligned with provider
103
+ settings and host spending limits. The planner fits natural spoken beats to that
104
+ budget; measured audio determines the final scene timing. Silent footage may
105
+ loop until the finite scene ends. Audible footage is not looped.
106
+
107
+ `generateVideoTimeoutMs` sets the first-shot preparation budget (default 15 seconds).
108
+ Later deadlines account for their position in the answer rather than restarting
109
+ an unlimited wait. Hosts must honor cancellation. A missed deadline selects the
110
+ authored chapter instead of a second paid generation or cross-mode stock search.
111
+
99
112
  ## Voice and transcription
100
113
 
101
114
  Without `generateSpeech`, `VideoChat` uses the browser voice. Add a speech
@@ -20,6 +20,13 @@ The SDK sends nothing to a telemetry service. Events contain only an opaque
20
20
  turn ID, mode, relative timing, and fixed event categories. Keep custom turn IDs
21
21
  opaque; do not put prompts or customer information into them.
22
22
 
23
+ The handler's optional `onDiagnostic(event)` observes accepted requests,
24
+ authored openings and shots, and media start/end/skip timings on the host.
25
+ Fixed reasons distinguish allowance, deadline, timeout, provider error, empty
26
+ results, cancellation and absent configuration. These records contain no
27
+ prompt, narration, query, media URL or provider response. They are never sent
28
+ to the browser automatically, and callback failures cannot stop an answer.
29
+
23
30
  ## What the measurements mean
24
31
 
25
32
  `first-frame` is the first committed active scene reaching an animation-frame
@@ -55,15 +62,19 @@ these against an explicitly authorized, bounded live run before treating them
55
62
  as tuned provider budgets. Compare first-frame/speech times, stalled duration,
56
63
  and visual/voice quality together; faster fallback alone does not prove quality.
57
64
 
58
- ## Opening stock footage
65
+ ## Opening and media preparation
66
+
67
+ The submitted prompt immediately appears in the chapter template; the streamed
68
+ opening replaces that topic with an authored spoken beat. The default UI makes
69
+ no opening stock request. Each body beat starts speech preparation while its
70
+ selected footage source prepares. At most two speech preparations run together.
59
71
 
60
- The opening's stock lookup runs alongside narration and body generation. Ready
61
- footage stays behind the hook until the body starts; late results are ignored.
62
- Playback never waits solely for intro footage.
72
+ `first-media-frame` reports the first decoded footage frame presented by the
73
+ mounted media surface. It is separate from `first-frame`, which also includes
74
+ chapter scenes. Neither callback measures the immediate opening template;
75
+ measure that surface separately when checking submit-to-template latency.
63
76
 
64
- The packaged stock adapter tries the specific and optional broader video query
65
- before photo recovery, within one three-second budget. The planner supplies the
66
- broader query only for opening atmosphere; body demonstrations retain their
67
- specific subject. Pexels resource-page slugs and photo descriptions can screen
68
- obvious mismatches, but missing metadata remains unknown and is accepted. This
69
- is not visual relevance verification. See the [Pexels API contract](https://www.pexels.com/api/documentation/).
77
+ AI and Pexels modes remain separate. Missing, late, or unplayable footage uses
78
+ the authored chapter and complete narration. Silent clips loop for the finite
79
+ narrated scene. See [the local chat harness](development.md) for fixture timing
80
+ and explicit live-provider checks.
@@ -22,13 +22,11 @@ player. Read the [security guide](security.md) for the complete controls.
22
22
 
23
23
  ## Cinematic direction and providers
24
24
 
25
- Default chat uses a template introduction during preparation, then generated
26
- footage with narration and subtitles. Add `generateVideo` for these shots and
27
- `searchMedia` for relevant stock fallback. The runtime selects media within the
28
- host generation budget; the model supplies narration and visible actions.
29
- If both sources miss, valid narration remains available with an explicit visual
30
- unavailability state. Explicit custom template registries keep their existing
31
- composition and fallback contracts.
25
+ Default chat shows an immediate chapter while speech and selected footage prepare.
26
+ Configure `generateVideo` for AI mode and `searchMedia` for Pexels mode. Neither
27
+ mode calls the other footage source. Missing or late footage uses the authored
28
+ chapter with complete narration. Explicit custom template registries keep their
29
+ existing composition and fallback contracts.
32
30
  A stock candidate must match the subject, action and permitted crop. Return
33
31
  `null` for uncertainty rather than broadening an essential detail.
34
32
 
@@ -39,11 +37,11 @@ licensing before use.
39
37
 
40
38
  ## Fast first response
41
39
 
42
- The planner's first streamed object supplies the spoken hook and media keyword.
43
- Start speech immediately, resolve stock in parallel, and keep the opening
44
- playing until the contiguous preparation cushion and first frame are ready. Welcome cards
45
- should carry a prepared hook and preloaded footage so they can start without a
46
- model round trip.
40
+ The prompt appears immediately in the chapter template. The planner's first
41
+ streamed object supplies an authored spoken opening and reserves the ending.
42
+ Prepare each shot's speech alongside its footage, and keep the opening readable
43
+ until its narration and the contiguous preparation cushion are ready. Welcome
44
+ cards may carry a prepared opening so its speech starts without a model round trip.
47
45
 
48
46
  Do not wait for the complete plan before showing the first validated scene.
49
47
  Preload upcoming assets and keep the current visual if the next one is late.
@@ -106,7 +104,7 @@ npm test
106
104
  - [ ] Keys exist only in the server secret store.
107
105
  - [ ] Authentication, tenant policy, rate limits, and origin allowlist are live.
108
106
  - [ ] Cancellation, timeouts, fallbacks, and safe errors are tested.
109
- - [ ] Grounded graphic recovery works when every optional provider is unavailable.
107
+ - [ ] Authored chapter recovery works when every optional provider is unavailable.
110
108
  - [ ] Per-scene media choices obey provider availability and spending limits.
111
109
  - [ ] Both orientations render and narration stays synchronized.
112
110
  - [ ] A packed-artifact consumer and deterministic browser chat pass.
@@ -96,9 +96,9 @@ Every `scene.add` is validated before the browser receives it. The model never
96
96
  returns React, HTML, CSS, or executable JavaScript. Invalid planning content
97
97
  produces safe diagnostics. A media failure does not delete valid narration.
98
98
 
99
- Generated footage is preferred within the host's allowance. Relevant stock is
100
- fallback. If neither is available, retain narration and subtitles and report
101
- unavailable visuals. Handlers configured with an explicit custom `templates` registry continue to
99
+ AI mode generates footage within the host allowance. Pexels mode searches
100
+ stock without calling the video generator. Each authored beat includes a short
101
+ chapter title; missing footage becomes that chapter with its complete narration. Handlers configured with an explicit custom `templates` registry continue to
102
102
  support the trusted catalog and its existing structured planner contract.
103
103
 
104
104
  ## Grounding