@vanillaskyai/video 0.10.19 → 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/CHANGELOG.md CHANGED
@@ -4,6 +4,13 @@ VanillaSky follows semantic versioning. This changelog begins with the 0.1 beta.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.10.20
8
+
9
+ - Collapse completed-response subtitles to an expandable transcript and remove the Pexels header link.
10
+ - Present narration in compact, progressive caption pages while preserving the complete spoken text.
11
+ - Reflect host-resolved footage mode when a response falls back to stock.
12
+ - Prefer close stock matches while allowing provider-ranked illustrative results and one bounded broader search when no usable footage is found.
13
+
7
14
  ## 0.10.19
8
15
 
9
16
  - Show a subtle preparation status while the first response scene is loading.
package/dist/react.js CHANGED
@@ -1262,6 +1262,77 @@ var VideoError = class extends Error {
1262
1262
  }
1263
1263
  };
1264
1264
 
1265
+ // src/video-chat/caption-progress.ts
1266
+ function createCaptionVoice(source, now = () => performance.now()) {
1267
+ const durations = /* @__PURE__ */ new Map();
1268
+ let held = false;
1269
+ let current;
1270
+ const read = () => {
1271
+ const state = current;
1272
+ if (!state || !state.started || state.signal.aborted) return;
1273
+ const audioTime = !state.browser ? source.getCurrentTime?.() : void 0;
1274
+ const realClock = typeof audioTime === "number" && Number.isFinite(audioTime);
1275
+ if (!held) {
1276
+ state.elapsed = realClock ? Math.max(state.offset, audioTime) : state.offset + (state.since === void 0 ? 0 : (now() - state.since) / 1e3);
1277
+ }
1278
+ return { text: state.text, elapsedSeconds: Math.min(state.duration, Math.max(0, state.elapsed)), durationSeconds: state.duration, timing: realClock ? "audio" : "estimated" };
1279
+ };
1280
+ const reset = () => {
1281
+ current = void 0;
1282
+ };
1283
+ const voice = {
1284
+ get supportsOffsets() {
1285
+ return source.supportsOffsets;
1286
+ },
1287
+ ...source.getCurrentTime ? { getCurrentTime: () => source.getCurrentTime() } : {},
1288
+ async prepare(text, options) {
1289
+ const prepared = await source.prepare(text, options);
1290
+ if (!options?.signal?.aborted && Number.isFinite(prepared.seconds) && prepared.seconds > 0) {
1291
+ if (durations.size >= 60) durations.delete(durations.keys().next().value);
1292
+ durations.set(text.trim(), prepared.seconds);
1293
+ }
1294
+ return prepared;
1295
+ },
1296
+ async speak(text, options) {
1297
+ const state = { text, duration: durations.get(text.trim()) ?? Math.max(1, text.trim().split(/\s+/u).length / 2.5), offset: options.offsetSeconds ?? 0, elapsed: options.offsetSeconds ?? 0, started: false, since: void 0, browser: false, signal: options.signal };
1298
+ current = state;
1299
+ const aborted = () => {
1300
+ if (current === state) reset();
1301
+ };
1302
+ options.signal.addEventListener("abort", aborted, { once: true });
1303
+ try {
1304
+ await source.speak(text, { ...options, onStart: (kind) => {
1305
+ if (current !== state || state.signal.aborted) return;
1306
+ if (!state.started) {
1307
+ state.started = true;
1308
+ state.browser = kind === "browser";
1309
+ state.since = now();
1310
+ }
1311
+ options.onStart?.(kind);
1312
+ } });
1313
+ } finally {
1314
+ options.signal.removeEventListener("abort", aborted);
1315
+ if (current === state) reset();
1316
+ }
1317
+ },
1318
+ pause() {
1319
+ read();
1320
+ held = true;
1321
+ source.pause();
1322
+ },
1323
+ resume() {
1324
+ if (held && current?.started) {
1325
+ current.offset = current.elapsed;
1326
+ current.since = now();
1327
+ }
1328
+ held = false;
1329
+ source.resume();
1330
+ },
1331
+ setMuted: (muted) => source.setMuted(muted)
1332
+ };
1333
+ return { voice, getCaptionProgress: read, reset };
1334
+ }
1335
+
1265
1336
  // src/player/recover-scene-media.ts
1266
1337
  function recoverSceneMedia(scene) {
1267
1338
  if (scene.templateId === "cinemaMedia") {
@@ -1276,7 +1347,7 @@ function recoverSceneMedia(scene) {
1276
1347
  }
1277
1348
 
1278
1349
  // src/video-chat/use-video-chat.ts
1279
- import { useCallback as useCallback2, useEffect as useEffect4, useReducer, useRef as useRef3 } from "react";
1350
+ import { useCallback as useCallback2, useEffect as useEffect4, useMemo as useMemo2, useReducer, useRef as useRef3 } from "react";
1280
1351
 
1281
1352
  // src/protocol/scene-timeline.ts
1282
1353
  function createQueue() {
@@ -1905,6 +1976,9 @@ function reducer(state, action) {
1905
1976
  return { ...state, capabilities: action.value };
1906
1977
  case "welcome":
1907
1978
  return { ...state, welcome: action.value };
1979
+ case "resolved-mode":
1980
+ if (state.turns.at(-1)?.id !== action.id) return state;
1981
+ return { ...state, turns: replaceTurn(state.turns, action.id, (turn) => ({ ...turn, mode: action.mode })) };
1908
1982
  case "start":
1909
1983
  return {
1910
1984
  ...state,
@@ -2141,7 +2215,10 @@ function useVideoChatSession(options = {}) {
2141
2215
  onFallback: () => voiceWarningRef.current()
2142
2216
  });
2143
2217
  }
2144
- const voice = options.voice ?? ownedVoiceRef.current;
2218
+ const rawVoice = options.voice ?? ownedVoiceRef.current;
2219
+ const captionVoice = useMemo2(() => createCaptionVoice(rawVoice), [rawVoice]);
2220
+ useEffect4(() => () => captionVoice.reset(), [captionVoice]);
2221
+ const voice = captionVoice.voice;
2145
2222
  const voiceRef = useRef3(voice);
2146
2223
  voiceRef.current = voice;
2147
2224
  const unavailableVoiceLines = useRef3(/* @__PURE__ */ new Set());
@@ -2296,7 +2373,7 @@ function useVideoChatSession(options = {}) {
2296
2373
  throw new VideoError("timeoutMs must be positive", { code: "invalid_option" });
2297
2374
  }
2298
2375
  const timeout = setTimeout(() => controller.abort(new DOMException("Video chat timed out", "TimeoutError")), timeoutMs);
2299
- const mode = currentOptions.mode ?? "cinematic";
2376
+ let mode = currentOptions.mode ?? "cinematic";
2300
2377
  const orientation = currentOptions.orientation ?? "landscape";
2301
2378
  const id = (currentOptions.createTurnId ?? defaultTurnId)();
2302
2379
  const conversation = conversationFor(stateRef.current.turns);
@@ -2441,6 +2518,12 @@ function useVideoChatSession(options = {}) {
2441
2518
  if (!response.body || !response.headers.get("content-type")?.includes("text/event-stream")) {
2442
2519
  throw new VideoError("Video chat endpoint did not return a video stream", { code: "invalid_response" });
2443
2520
  }
2521
+ const resolvedMode = response.headers.get("x-vanillasky-resolved-video-mode");
2522
+ if (isCurrent() && (resolvedMode === "pexels" || resolvedMode === "cinematic")) {
2523
+ mode = resolvedMode;
2524
+ dispatch({ type: "resolved-mode", id, mode });
2525
+ if (firstFrameRef.current?.turnId === id) firstFrameRef.current.mode = mode;
2526
+ }
2444
2527
  const planned = [];
2445
2528
  const lines = spokenHook ? [spokenHook] : [];
2446
2529
  const pending = [];
@@ -2818,14 +2901,117 @@ function useVideoChatSession(options = {}) {
2818
2901
  playerKey: state.playerKey,
2819
2902
  playerProps
2820
2903
  };
2821
- return { chat, restoreSession };
2904
+ return { chat, restoreSession, getCaptionProgress: captionVoice.getCaptionProgress };
2905
+ }
2906
+
2907
+ // src/video-chat/caption-pages.tsx
2908
+ import { useEffect as useEffect5, useLayoutEffect, useRef as useRef4, useState as useState3 } from "react";
2909
+ import { jsx as jsx3 } from "react/jsx-runtime";
2910
+ function splitCaptionPages(text, fits) {
2911
+ const pages = [];
2912
+ let rest = Array.from(text);
2913
+ while (rest.length) {
2914
+ let low = 1;
2915
+ let high = rest.length;
2916
+ while (low < high) {
2917
+ const middle = Math.ceil((low + high) / 2);
2918
+ if (fits(rest.slice(0, middle).join(""))) low = middle;
2919
+ else high = middle - 1;
2920
+ }
2921
+ let end = low;
2922
+ if (end < rest.length) {
2923
+ const space = rest.slice(0, end + 1).lastIndexOf(" ");
2924
+ if (space > 0) end = space + 1;
2925
+ }
2926
+ pages.push(rest.slice(0, end).join(""));
2927
+ rest = rest.slice(end);
2928
+ }
2929
+ return pages;
2930
+ }
2931
+ function captionPageAt(pages, elapsed, duration) {
2932
+ const total = pages.reduce((sum, page) => sum + page.length, 0);
2933
+ const position = duration > 0 ? Math.max(0, elapsed / duration) * total : 0;
2934
+ let end = 0;
2935
+ for (let index = 0; index < pages.length - 1; index++) {
2936
+ end += pages[index].length;
2937
+ if (position < end) return index;
2938
+ }
2939
+ return Math.max(0, pages.length - 1);
2940
+ }
2941
+ function CaptionPages({ text, getProgress }) {
2942
+ const line = useRef4(null);
2943
+ const lastProgress = useRef4(void 0);
2944
+ const getter = useRef4(getProgress);
2945
+ getter.current = getProgress;
2946
+ const [source, setSource] = useState3(text);
2947
+ const [pages, setPages] = useState3([]);
2948
+ const [index, setIndex] = useState3(0);
2949
+ const [timing, setTiming] = useState3();
2950
+ useLayoutEffect(() => {
2951
+ const element = line.current;
2952
+ if (!element) return;
2953
+ const measure = () => {
2954
+ const width = element.getBoundingClientRect().width;
2955
+ if (!width) {
2956
+ setPages([source]);
2957
+ return;
2958
+ }
2959
+ const style = getComputedStyle(element);
2960
+ const probe = element.cloneNode(false);
2961
+ probe.removeAttribute("aria-live");
2962
+ probe.setAttribute("aria-hidden", "true");
2963
+ Object.assign(probe.style, { position: "absolute", visibility: "hidden", pointerEvents: "none", width: `${width}px`, height: "auto", maxHeight: "none", inset: "0 auto auto 0", animation: "none" });
2964
+ element.parentElement.append(probe);
2965
+ const height = parseFloat(style.lineHeight) * 2;
2966
+ const next = splitCaptionPages(source, (candidate) => {
2967
+ probe.textContent = candidate;
2968
+ return probe.getBoundingClientRect().height <= height + 0.5;
2969
+ });
2970
+ probe.remove();
2971
+ setPages(next);
2972
+ };
2973
+ measure();
2974
+ let previousWidth = element.getBoundingClientRect().width;
2975
+ let previousFont = getComputedStyle(element).font;
2976
+ const observer = typeof ResizeObserver === "undefined" ? void 0 : new ResizeObserver(() => {
2977
+ const width = element.getBoundingClientRect().width;
2978
+ const font = getComputedStyle(element).font;
2979
+ if (width !== previousWidth || font !== previousFont) {
2980
+ previousWidth = width;
2981
+ previousFont = font;
2982
+ measure();
2983
+ }
2984
+ });
2985
+ observer?.observe(element);
2986
+ document.fonts?.addEventListener("loadingdone", measure);
2987
+ return () => {
2988
+ observer?.disconnect();
2989
+ document.fonts?.removeEventListener("loadingdone", measure);
2990
+ };
2991
+ }, [source]);
2992
+ useEffect5(() => {
2993
+ let frame = 0;
2994
+ const update = () => {
2995
+ const observed = text ? getter.current() : void 0;
2996
+ if (observed) lastProgress.current = { caption: text, progress: observed };
2997
+ const progress = observed ?? (lastProgress.current?.caption === text ? lastProgress.current.progress : void 0);
2998
+ const nextSource = progress?.text ?? text;
2999
+ setSource((current) => current === nextSource ? current : nextSource);
3000
+ setTiming(progress?.timing);
3001
+ setIndex(nextSource !== source ? 0 : captionPageAt(pages, progress?.elapsedSeconds ?? 0, progress?.durationSeconds ?? 0));
3002
+ frame = requestAnimationFrame(update);
3003
+ };
3004
+ update();
3005
+ return () => cancelAnimationFrame(frame);
3006
+ }, [text, source, pages]);
3007
+ return /* @__PURE__ */ jsx3("p", { ref: line, className: "line", "aria-live": "polite", "data-caption-timing": timing, "data-caption-page": index, children: pages[index] ?? "" });
2822
3008
  }
2823
3009
 
2824
3010
  // src/video-chat/video-chat.tsx
2825
- import { useCallback as useCallback6, useEffect as useEffect9, useId, useLayoutEffect as useLayoutEffect2, useMemo as useMemo2, useRef as useRef8, useState as useState7 } from "react";
3011
+ import { useCallback as useCallback6, useEffect as useEffect10, useId, useLayoutEffect as useLayoutEffect3, useMemo as useMemo3, useRef as useRef9, useState as useState8 } from "react";
2826
3012
 
2827
3013
  // src/video-chat/icons.tsx
2828
- import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
3014
+ import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
2829
3015
  var stroke = {
2830
3016
  fill: "none",
2831
3017
  stroke: "currentColor",
@@ -2834,50 +3020,50 @@ var stroke = {
2834
3020
  strokeLinejoin: "round"
2835
3021
  };
2836
3022
  function Glyph({ children }) {
2837
- return /* @__PURE__ */ jsx3("svg", { viewBox: "0 0 24 24", width: "18", height: "18", "aria-hidden": "true", ...stroke, children });
3023
+ return /* @__PURE__ */ jsx4("svg", { viewBox: "0 0 24 24", width: "18", height: "18", "aria-hidden": "true", ...stroke, children });
2838
3024
  }
2839
3025
  var Sessions = () => /* @__PURE__ */ jsxs3(Glyph, { children: [
2840
- /* @__PURE__ */ jsx3("path", { d: "M5 3h10a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H8l-5 3V5a2 2 0 0 1 2-2Z" }),
2841
- /* @__PURE__ */ jsx3("path", { d: "M17 8h2a2 2 0 0 1 2 2v11l-4-3h-6a2 2 0 0 1-2-2v-2" })
3026
+ /* @__PURE__ */ jsx4("path", { d: "M5 3h10a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H8l-5 3V5a2 2 0 0 1 2-2Z" }),
3027
+ /* @__PURE__ */ jsx4("path", { d: "M17 8h2a2 2 0 0 1 2 2v11l-4-3h-6a2 2 0 0 1-2-2v-2" })
2842
3028
  ] });
2843
- var Plus = () => /* @__PURE__ */ jsx3(Glyph, { children: /* @__PURE__ */ jsx3("path", { d: "M12 5v14M5 12h14" }) });
3029
+ var Plus = () => /* @__PURE__ */ jsx4(Glyph, { children: /* @__PURE__ */ jsx4("path", { d: "M12 5v14M5 12h14" }) });
2844
3030
  var Gear = () => /* @__PURE__ */ jsxs3(Glyph, { children: [
2845
- /* @__PURE__ */ jsx3("circle", { cx: "12", cy: "12", r: "3" }),
2846
- /* @__PURE__ */ jsx3("path", { d: "M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" })
3031
+ /* @__PURE__ */ jsx4("circle", { cx: "12", cy: "12", r: "3" }),
3032
+ /* @__PURE__ */ jsx4("path", { d: "M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" })
2847
3033
  ] });
2848
3034
  var Sound = () => /* @__PURE__ */ jsxs3(Glyph, { children: [
2849
- /* @__PURE__ */ jsx3("path", { d: "M11 5 6 9H2v6h4l5 4z" }),
2850
- /* @__PURE__ */ jsx3("path", { d: "M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13" })
3035
+ /* @__PURE__ */ jsx4("path", { d: "M11 5 6 9H2v6h4l5 4z" }),
3036
+ /* @__PURE__ */ jsx4("path", { d: "M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13" })
2851
3037
  ] });
2852
3038
  var Muted = () => /* @__PURE__ */ jsxs3(Glyph, { children: [
2853
- /* @__PURE__ */ jsx3("path", { d: "M11 5 6 9H2v6h4l5 4z" }),
2854
- /* @__PURE__ */ jsx3("path", { d: "m22 9-6 6M16 9l6 6" })
3039
+ /* @__PURE__ */ jsx4("path", { d: "M11 5 6 9H2v6h4l5 4z" }),
3040
+ /* @__PURE__ */ jsx4("path", { d: "m22 9-6 6M16 9l6 6" })
2855
3041
  ] });
2856
3042
  var Mic = () => /* @__PURE__ */ jsxs3(Glyph, { children: [
2857
- /* @__PURE__ */ jsx3("rect", { x: "9", y: "2", width: "6", height: "12", rx: "3" }),
2858
- /* @__PURE__ */ jsx3("path", { d: "M5 11a7 7 0 0 0 14 0M12 18v4" })
3043
+ /* @__PURE__ */ jsx4("rect", { x: "9", y: "2", width: "6", height: "12", rx: "3" }),
3044
+ /* @__PURE__ */ jsx4("path", { d: "M5 11a7 7 0 0 0 14 0M12 18v4" })
2859
3045
  ] });
2860
- var Send = () => /* @__PURE__ */ jsx3(Glyph, { children: /* @__PURE__ */ jsx3("path", { d: "M12 19V5M5 12l7-7 7 7" }) });
3046
+ var Send = () => /* @__PURE__ */ jsx4(Glyph, { children: /* @__PURE__ */ jsx4("path", { d: "M12 19V5M5 12l7-7 7 7" }) });
2861
3047
  var Stop = () => /* @__PURE__ */ jsxs3("svg", { viewBox: "0 0 24 24", width: "18", height: "18", "aria-hidden": "true", fill: "currentColor", children: [
2862
- /* @__PURE__ */ jsx3("rect", { x: "7", y: "5", width: "3.4", height: "14", rx: "1.4" }),
2863
- /* @__PURE__ */ jsx3("rect", { x: "13.6", y: "5", width: "3.4", height: "14", rx: "1.4" })
3048
+ /* @__PURE__ */ jsx4("rect", { x: "7", y: "5", width: "3.4", height: "14", rx: "1.4" }),
3049
+ /* @__PURE__ */ jsx4("rect", { x: "13.6", y: "5", width: "3.4", height: "14", rx: "1.4" })
2864
3050
  ] });
2865
- var Play = () => /* @__PURE__ */ jsx3("svg", { viewBox: "0 0 24 24", width: "18", height: "18", "aria-hidden": "true", fill: "currentColor", children: /* @__PURE__ */ jsx3("path", { d: "M8 5.5v13l11-6.5z" }) });
3051
+ var Play = () => /* @__PURE__ */ jsx4("svg", { viewBox: "0 0 24 24", width: "18", height: "18", "aria-hidden": "true", fill: "currentColor", children: /* @__PURE__ */ jsx4("path", { d: "M8 5.5v13l11-6.5z" }) });
2866
3052
  var Replay = () => /* @__PURE__ */ jsxs3(Glyph, { children: [
2867
- /* @__PURE__ */ jsx3("path", { d: "M3 12a9 9 0 1 0 3-6.7L3 8" }),
2868
- /* @__PURE__ */ jsx3("path", { d: "M3 3v5h5" })
3053
+ /* @__PURE__ */ jsx4("path", { d: "M3 12a9 9 0 1 0 3-6.7L3 8" }),
3054
+ /* @__PURE__ */ jsx4("path", { d: "M3 3v5h5" })
2869
3055
  ] });
2870
- var ChevronUp = () => /* @__PURE__ */ jsx3("svg", { viewBox: "0 0 24 24", width: "14", height: "14", "aria-hidden": "true", ...stroke, children: /* @__PURE__ */ jsx3("path", { d: "m6 15 6-6 6 6" }) });
2871
- var Close = () => /* @__PURE__ */ jsx3(Glyph, { children: /* @__PURE__ */ jsx3("path", { d: "M6 6l12 12M18 6 6 18" }) });
3056
+ var ChevronUp = () => /* @__PURE__ */ jsx4("svg", { viewBox: "0 0 24 24", width: "14", height: "14", "aria-hidden": "true", ...stroke, children: /* @__PURE__ */ jsx4("path", { d: "m6 15 6-6 6 6" }) });
3057
+ var Close = () => /* @__PURE__ */ jsx4(Glyph, { children: /* @__PURE__ */ jsx4("path", { d: "M6 6l12 12M18 6 6 18" }) });
2872
3058
  var Warning = () => /* @__PURE__ */ jsxs3(Glyph, { children: [
2873
- /* @__PURE__ */ jsx3("circle", { cx: "12", cy: "12", r: "9" }),
2874
- /* @__PURE__ */ jsx3("path", { d: "M12 8v5M12 16.2v.1" })
3059
+ /* @__PURE__ */ jsx4("circle", { cx: "12", cy: "12", r: "9" }),
3060
+ /* @__PURE__ */ jsx4("path", { d: "M12 8v5M12 16.2v.1" })
2875
3061
  ] });
2876
3062
 
2877
3063
  // src/video-chat/use-dismiss.ts
2878
- import { useEffect as useEffect5 } from "react";
3064
+ import { useEffect as useEffect6 } from "react";
2879
3065
  function useDismiss(open, close, surfaces) {
2880
- useEffect5(() => {
3066
+ useEffect6(() => {
2881
3067
  if (!open) return;
2882
3068
  const onKeyDown = (event) => {
2883
3069
  if (event.key !== "Escape") return;
@@ -2899,7 +3085,7 @@ function useDismiss(open, close, surfaces) {
2899
3085
  }, [open, close, surfaces]);
2900
3086
  }
2901
3087
  function useFocusTrap(active, surface) {
2902
- useEffect5(() => {
3088
+ useEffect6(() => {
2903
3089
  if (!active) return;
2904
3090
  const returnTo = document.activeElement;
2905
3091
  const focusable = () => Array.from(
@@ -2932,12 +3118,12 @@ function useFocusTrap(active, surface) {
2932
3118
  }
2933
3119
 
2934
3120
  // src/video-chat/suggestion-cards.tsx
2935
- import { useCallback as useCallback3, useEffect as useEffect6, useRef as useRef4, useState as useState3 } from "react";
2936
- import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
3121
+ import { useCallback as useCallback3, useEffect as useEffect7, useRef as useRef5, useState as useState4 } from "react";
3122
+ import { Fragment as Fragment2, jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
2937
3123
  function Frame({ media, poster, playing, onReady, onError, revealWhenReady = false }) {
2938
- const video = useRef4(null);
2939
- const [playingUrl, setPlayingUrl] = useState3(null);
2940
- useEffect6(() => {
3124
+ const video = useRef5(null);
3125
+ const [playingUrl, setPlayingUrl] = useState4(null);
3126
+ useEffect7(() => {
2941
3127
  const element = video.current;
2942
3128
  if (!element || playing === void 0) return;
2943
3129
  if (playing) void element.play().catch(() => void 0);
@@ -2949,9 +3135,9 @@ function Frame({ media, poster, playing, onReady, onError, revealWhenReady = fal
2949
3135
  onReady?.();
2950
3136
  };
2951
3137
  const appearance = revealWhenReady ? { opacity: playingUrl === media.url ? 1 : 0, transition: "opacity 200ms ease" } : void 0;
2952
- if (media.type === "image") return /* @__PURE__ */ jsx4("img", { className: "frame-media", src: media.url, alt: "", style: appearance, onLoad: ready, onError });
3138
+ if (media.type === "image") return /* @__PURE__ */ jsx5("img", { className: "frame-media", src: media.url, alt: "", style: appearance, onLoad: ready, onError });
2953
3139
  return /* @__PURE__ */ jsxs4(Fragment2, { children: [
2954
- /* @__PURE__ */ jsx4(
3140
+ /* @__PURE__ */ jsx5(
2955
3141
  "video",
2956
3142
  {
2957
3143
  ref: video,
@@ -2972,24 +3158,24 @@ function Frame({ media, poster, playing, onReady, onError, revealWhenReady = fal
2972
3158
  "aria-hidden": "true"
2973
3159
  }
2974
3160
  ),
2975
- poster && media.posterUrl && playingUrl !== media.url && /* @__PURE__ */ jsx4("img", { className: "frame-media frame-poster", src: media.posterUrl, alt: "", onLoad: onReady })
3161
+ poster && media.posterUrl && playingUrl !== media.url && /* @__PURE__ */ jsx5("img", { className: "frame-media frame-poster", src: media.posterUrl, alt: "", onLoad: onReady })
2976
3162
  ] });
2977
3163
  }
2978
3164
  function SuggestionCards({ suggestions, label, onAsk }) {
2979
- const railRef = useRef4(null);
2980
- const [at, setAt] = useState3(0);
2981
- const [taken, setTaken] = useState3(false);
3165
+ const railRef = useRef5(null);
3166
+ const [at, setAt] = useState4(0);
3167
+ const [taken, setTaken] = useState4(false);
2982
3168
  const take = useCallback3((index) => {
2983
3169
  setTaken(true);
2984
3170
  setAt(index);
2985
3171
  }, []);
2986
- useEffect6(() => {
3172
+ useEffect7(() => {
2987
3173
  if (taken || suggestions.length < 2) return;
2988
3174
  if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
2989
3175
  const tour = window.setInterval(() => setAt((index) => (index + 1) % suggestions.length), 5e3);
2990
3176
  return () => window.clearInterval(tour);
2991
3177
  }, [taken, suggestions.length]);
2992
- useEffect6(() => {
3178
+ useEffect7(() => {
2993
3179
  const rail = railRef.current;
2994
3180
  const card = rail?.children[at];
2995
3181
  if (!rail || !card) return;
@@ -2999,7 +3185,7 @@ function SuggestionCards({ suggestions, label, onAsk }) {
2999
3185
  }, [at]);
3000
3186
  if (suggestions.length === 0) return null;
3001
3187
  return /* @__PURE__ */ jsxs4(Fragment2, { children: [
3002
- /* @__PURE__ */ jsx4("ul", { className: "cards", ref: railRef, "aria-label": label, children: suggestions.map((card, index) => /* @__PURE__ */ jsx4("li", { children: /* @__PURE__ */ jsxs4(
3188
+ /* @__PURE__ */ jsx5("ul", { className: "cards", ref: railRef, "aria-label": label, children: suggestions.map((card, index) => /* @__PURE__ */ jsx5("li", { children: /* @__PURE__ */ jsxs4(
3003
3189
  "button",
3004
3190
  {
3005
3191
  type: "button",
@@ -3008,13 +3194,13 @@ function SuggestionCards({ suggestions, label, onAsk }) {
3008
3194
  onPointerEnter: () => take(index),
3009
3195
  onClick: () => onAsk(card),
3010
3196
  children: [
3011
- /* @__PURE__ */ jsx4(Frame, { media: card.media, poster: true, playing: index === at }),
3012
- /* @__PURE__ */ jsx4("span", { className: "card-wash", "aria-hidden": "true" }),
3013
- /* @__PURE__ */ jsx4("span", { className: "card-prompt", children: card.prompt })
3197
+ /* @__PURE__ */ jsx5(Frame, { media: card.media, poster: true, playing: index === at }),
3198
+ /* @__PURE__ */ jsx5("span", { className: "card-wash", "aria-hidden": "true" }),
3199
+ /* @__PURE__ */ jsx5("span", { className: "card-prompt", children: card.prompt })
3014
3200
  ]
3015
3201
  }
3016
3202
  ) }, card.prompt)) }),
3017
- suggestions.length > 1 && /* @__PURE__ */ jsx4("div", { className: "card-dots", role: "group", "aria-label": `${label} navigation`, children: suggestions.map((card, index) => /* @__PURE__ */ jsx4(
3203
+ suggestions.length > 1 && /* @__PURE__ */ jsx5("div", { className: "card-dots", role: "group", "aria-label": `${label} navigation`, children: suggestions.map((card, index) => /* @__PURE__ */ jsx5(
3018
3204
  "button",
3019
3205
  {
3020
3206
  type: "button",
@@ -3029,30 +3215,30 @@ function SuggestionCards({ suggestions, label, onAsk }) {
3029
3215
  }
3030
3216
 
3031
3217
  // src/video-chat/welcome.tsx
3032
- import { Fragment as Fragment3, jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
3218
+ import { Fragment as Fragment3, jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
3033
3219
  function Welcome({ data, onAsk, title }) {
3034
3220
  return /* @__PURE__ */ jsxs5("div", { className: "welcome", children: [
3035
- /* @__PURE__ */ jsx5(Frame, { media: data?.hero ?? null, poster: true }),
3036
- /* @__PURE__ */ jsx5("div", { className: "welcome-wash", "aria-hidden": "true" }),
3221
+ /* @__PURE__ */ jsx6(Frame, { media: data?.hero ?? null, poster: true }),
3222
+ /* @__PURE__ */ jsx6("div", { className: "welcome-wash", "aria-hidden": "true" }),
3037
3223
  /* @__PURE__ */ jsxs5("div", { className: "welcome-body", children: [
3038
- /* @__PURE__ */ jsx5("h1", { className: "welcome-title", children: title ?? /* @__PURE__ */ jsxs5(Fragment3, { children: [
3224
+ /* @__PURE__ */ jsx6("h1", { className: "welcome-title", children: title ?? /* @__PURE__ */ jsxs5(Fragment3, { children: [
3039
3225
  "An AI chat that responds",
3040
- /* @__PURE__ */ jsx5("br", {}),
3041
- /* @__PURE__ */ jsx5("em", { children: "in video, not text." })
3226
+ /* @__PURE__ */ jsx6("br", {}),
3227
+ /* @__PURE__ */ jsx6("em", { children: "in video, not text." })
3042
3228
  ] }) }),
3043
- /* @__PURE__ */ jsx5(SuggestionCards, { suggestions: data?.cards ?? [], label: "Suggested prompts", onAsk })
3229
+ /* @__PURE__ */ jsx6(SuggestionCards, { suggestions: data?.cards ?? [], label: "Suggested prompts", onAsk })
3044
3230
  ] })
3045
3231
  ] });
3046
3232
  }
3047
3233
 
3048
3234
  // src/video-chat/opening-chapter.tsx
3049
- import { useLayoutEffect, useRef as useRef5, useState as useState4 } from "react";
3050
- import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
3235
+ import { useLayoutEffect as useLayoutEffect2, useRef as useRef6, useState as useState5 } from "react";
3236
+ import { jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
3051
3237
  function OpeningChapter({ title, preparing = false }) {
3052
- const root = useRef5(null);
3053
- const [size, setSize] = useState4({ width: 1080, height: 1080 });
3054
- const [titleBottom, setTitleBottom] = useState4(0);
3055
- useLayoutEffect(() => {
3238
+ const root = useRef6(null);
3239
+ const [size, setSize] = useState5({ width: 1080, height: 1080 });
3240
+ const [titleBottom, setTitleBottom] = useState5(0);
3241
+ useLayoutEffect2(() => {
3056
3242
  const element = root.current;
3057
3243
  if (!element) return;
3058
3244
  const measure = () => {
@@ -3070,7 +3256,7 @@ function OpeningChapter({ title, preparing = false }) {
3070
3256
  return () => observer.disconnect();
3071
3257
  }, [title]);
3072
3258
  return /* @__PURE__ */ jsxs6("div", { ref: root, "data-opening-chapter": true, className: "opening-chapter", children: [
3073
- /* @__PURE__ */ jsx6(
3259
+ /* @__PURE__ */ jsx7(
3074
3260
  TitleSceneTemplate,
3075
3261
  {
3076
3262
  variables: { title },
@@ -3083,14 +3269,14 @@ function OpeningChapter({ title, preparing = false }) {
3083
3269
  }
3084
3270
  ),
3085
3271
  preparing && /* @__PURE__ */ jsxs6("div", { className: "video-preparation", style: { top: titleBottom + 24 }, role: "status", "aria-label": "Video preparation", children: [
3086
- /* @__PURE__ */ jsx6("span", { className: "video-preparation-spinner", "aria-hidden": "true" }),
3087
- /* @__PURE__ */ jsx6("span", { className: "video-preparation-text", children: "Preparing your video\u2026" })
3272
+ /* @__PURE__ */ jsx7("span", { className: "video-preparation-spinner", "aria-hidden": "true" }),
3273
+ /* @__PURE__ */ jsx7("span", { className: "video-preparation-text", children: "Preparing your video\u2026" })
3088
3274
  ] })
3089
3275
  ] });
3090
3276
  }
3091
3277
 
3092
3278
  // src/video-chat/use-voice-input.ts
3093
- import { useCallback as useCallback4, useEffect as useEffect7, useRef as useRef6, useState as useState5 } from "react";
3279
+ import { useCallback as useCallback4, useEffect as useEffect8, useRef as useRef7, useState as useState6 } from "react";
3094
3280
  function recognitionConstructor() {
3095
3281
  if (typeof window === "undefined") return void 0;
3096
3282
  const holder = window;
@@ -3160,16 +3346,16 @@ async function recordAndTranscribe(signal, ready, captured, options) {
3160
3346
  return clip ? transcribeRecording(clip, signal, options) : "";
3161
3347
  }
3162
3348
  function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {}) {
3163
- const [supported, setSupported] = useState5(false);
3164
- const [listening, setListening] = useState5(false);
3165
- const [error, setError] = useState5();
3166
- const [thinking, setThinking] = useState5(false);
3167
- const recognitionRef = useRef6(void 0);
3168
- const useRecorderRef = useRef6(false);
3169
- const operationRef = useRef6(void 0);
3170
- const handlerRef = useRef6(onTranscript);
3349
+ const [supported, setSupported] = useState6(false);
3350
+ const [listening, setListening] = useState6(false);
3351
+ const [error, setError] = useState6();
3352
+ const [thinking, setThinking] = useState6(false);
3353
+ const recognitionRef = useRef7(void 0);
3354
+ const useRecorderRef = useRef7(false);
3355
+ const operationRef = useRef7(void 0);
3356
+ const handlerRef = useRef7(onTranscript);
3171
3357
  handlerRef.current = onTranscript;
3172
- useEffect7(() => {
3358
+ useEffect8(() => {
3173
3359
  setSupported(supportsVoiceInput(transcriptionAvailable));
3174
3360
  }, [transcriptionAvailable]);
3175
3361
  const stop = useCallback4(() => {
@@ -3182,7 +3368,7 @@ function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {
3182
3368
  setListening(false);
3183
3369
  setThinking(false);
3184
3370
  }, []);
3185
- useEffect7(() => () => {
3371
+ useEffect8(() => () => {
3186
3372
  recognitionRef.current?.abort();
3187
3373
  recognitionRef.current = void 0;
3188
3374
  const operation = operationRef.current;
@@ -3294,13 +3480,13 @@ function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {
3294
3480
  }
3295
3481
 
3296
3482
  // src/video-chat/use-immersive-controls.ts
3297
- import { useCallback as useCallback5, useEffect as useEffect8, useRef as useRef7, useState as useState6 } from "react";
3483
+ import { useCallback as useCallback5, useEffect as useEffect9, useRef as useRef8, useState as useState7 } from "react";
3298
3484
  function useImmersiveControls(playing, pinned, hasCaptions = false) {
3299
- const [visible, setVisible] = useState6(true);
3300
- const hovered = useRef7(false);
3301
- const focused = useRef7(false);
3302
- const previousCaptions = useRef7(false);
3303
- const timer = useRef7(null);
3485
+ const [visible, setVisible] = useState7(true);
3486
+ const hovered = useRef8(false);
3487
+ const focused = useRef8(false);
3488
+ const previousCaptions = useRef8(false);
3489
+ const timer = useRef8(null);
3304
3490
  const clearTimer = useCallback5(() => {
3305
3491
  if (timer.current !== null) clearTimeout(timer.current);
3306
3492
  timer.current = null;
@@ -3315,7 +3501,7 @@ function useImmersiveControls(playing, pinned, hasCaptions = false) {
3315
3501
  }, 2e3);
3316
3502
  }
3317
3503
  }, [clearTimer, playing, pinned]);
3318
- useEffect8(() => {
3504
+ useEffect9(() => {
3319
3505
  const firstCaption = hasCaptions && !previousCaptions.current;
3320
3506
  previousCaptions.current = hasCaptions;
3321
3507
  if (firstCaption && playing && !pinned && !focused.current) {
@@ -3351,9 +3537,9 @@ function useImmersiveControls(playing, pinned, hasCaptions = false) {
3351
3537
  }
3352
3538
 
3353
3539
  // src/video-chat/logo.tsx
3354
- import { jsx as jsx7 } from "react/jsx-runtime";
3540
+ import { jsx as jsx8 } from "react/jsx-runtime";
3355
3541
  function Logo() {
3356
- return /* @__PURE__ */ jsx7("img", { className: "brand-logo", src: new URL("./assets/vanillasky-logo.svg", import.meta.url).href, alt: "VanillaSky", width: 144, height: 36 });
3542
+ return /* @__PURE__ */ jsx8("img", { className: "brand-logo", src: new URL("./assets/vanillasky-logo.svg", import.meta.url).href, alt: "VanillaSky", width: 144, height: 36 });
3357
3543
  }
3358
3544
 
3359
3545
  // src/video-chat/modes.ts
@@ -3364,11 +3550,11 @@ var visualModes = [
3364
3550
  var defaultMode = visualModes[0];
3365
3551
 
3366
3552
  // src/video-chat/video-chat.tsx
3367
- import { Fragment as Fragment4, jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
3553
+ import { Fragment as Fragment4, jsx as jsx9, jsxs as jsxs7 } from "react/jsx-runtime";
3368
3554
  var DESKTOP_WIDTH = 900;
3369
3555
  function useViewportOrientation() {
3370
- const [portrait, setPortrait] = useState7(() => typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia(`(max-width: ${DESKTOP_WIDTH - 1}px) and (orientation: portrait)`).matches : false);
3371
- useEffect9(() => {
3556
+ const [portrait, setPortrait] = useState8(() => typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia(`(max-width: ${DESKTOP_WIDTH - 1}px) and (orientation: portrait)`).matches : false);
3557
+ useEffect10(() => {
3372
3558
  if (typeof window.matchMedia !== "function") return;
3373
3559
  const query = window.matchMedia(`(max-width: ${DESKTOP_WIDTH - 1}px) and (orientation: portrait)`);
3374
3560
  const update = () => setPortrait(query.matches);
@@ -3379,47 +3565,59 @@ function useViewportOrientation() {
3379
3565
  return portrait ? "portrait" : "landscape";
3380
3566
  }
3381
3567
  function Waveform({ active, listening }) {
3382
- return /* @__PURE__ */ jsx8("span", { className: `waveform${active ? " on" : ""}${listening ? " hearing" : ""}`, "aria-hidden": "true", children: [0, 1, 2, 3, 4].map((bar) => /* @__PURE__ */ jsx8("span", { style: { animationDelay: `${bar * 140}ms`, animationDuration: `${900 + bar * 130}ms` } }, bar)) });
3568
+ return /* @__PURE__ */ jsx9("span", { className: `waveform${active ? " on" : ""}${listening ? " hearing" : ""}`, "aria-hidden": "true", children: [0, 1, 2, 3, 4].map((bar) => /* @__PURE__ */ jsx9("span", { style: { animationDelay: `${bar * 140}ms`, animationDuration: `${900 + bar * 130}ms` } }, bar)) });
3383
3569
  }
3384
3570
  function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice = false }) {
3385
- const [dismissedNoticeTurn, setDismissedNoticeTurn] = useState7();
3386
- const [draft, setDraft] = useState7("");
3387
- const [selectedMode, setSelectedMode] = useState7();
3388
- const [savedSessions, setSavedSessions] = useState7([]);
3389
- const [historyOpen, setHistoryOpen] = useState7(false);
3390
- const [settingsOpen, setSettingsOpen] = useState7(false);
3391
- const [aboutOpen, setAboutOpen] = useState7(false);
3392
- const [captionsOn, setCaptionsOn] = useState7(true);
3393
- const [captionsExpanded, setCaptionsExpanded] = useState7(false);
3394
- const [alwaysShowControls, setAlwaysShowControls] = useState7(false);
3395
- const [editing, setEditing] = useState7(false);
3396
- const resumeAfterInput = useRef8(false);
3397
- const panelRef = useRef8(null);
3398
- const composerRef = useRef8(null);
3571
+ const [dismissedNoticeTurn, setDismissedNoticeTurn] = useState8();
3572
+ const [draft, setDraft] = useState8("");
3573
+ const [selectedMode, setSelectedMode] = useState8();
3574
+ const [savedSessions, setSavedSessions] = useState8([]);
3575
+ const [historyOpen, setHistoryOpen] = useState8(false);
3576
+ const [settingsOpen, setSettingsOpen] = useState8(false);
3577
+ const [aboutOpen, setAboutOpen] = useState8(false);
3578
+ const [captionsOn, setCaptionsOn] = useState8(true);
3579
+ const [captionsExpanded, setCaptionsExpanded] = useState8(false);
3580
+ const [alwaysShowControls, setAlwaysShowControls] = useState8(false);
3581
+ const [editing, setEditing] = useState8(false);
3582
+ const resumeAfterInput = useRef9(false);
3583
+ const panelRef = useRef9(null);
3584
+ const composerRef = useRef9(null);
3399
3585
  const viewportOrientation = useViewportOrientation();
3400
3586
  const sessionOrientation = options.orientation ?? viewportOrientation;
3401
- const { chat, restoreSession } = useVideoChatSession({
3587
+ const { chat, restoreSession, getCaptionProgress } = useVideoChatSession({
3402
3588
  ...options,
3403
3589
  orientation: sessionOrientation,
3404
3590
  mode: selectedMode ?? options.mode
3405
3591
  });
3592
+ const observedMode = useRef9(void 0);
3593
+ useEffect10(() => {
3594
+ const turn = chat.currentTurn;
3595
+ if (!turn?.mode) {
3596
+ observedMode.current = void 0;
3597
+ return;
3598
+ }
3599
+ const previous = observedMode.current;
3600
+ observedMode.current = { id: turn.id, mode: turn.mode };
3601
+ if (turn.id !== chat.shownTurn?.id) return;
3602
+ if (previous?.id !== turn.id || previous.mode !== turn.mode) setSelectedMode(turn.mode);
3603
+ }, [chat.currentTurn, chat.shownTurn?.id]);
3406
3604
  const instanceId = useId();
3407
3605
  const historyId = `${instanceId}-history`;
3408
3606
  const settingsId = `${instanceId}-settings`;
3409
3607
  const promptId = `${instanceId}-prompt`;
3410
- const inputRef = useRef8(null);
3411
- const historyRef = useRef8(null);
3412
- const historyButtonRef = useRef8(null);
3413
- const settingsRef = useRef8(null);
3414
- const settingsButtonRef = useRef8(null);
3608
+ const inputRef = useRef9(null);
3609
+ const historyRef = useRef9(null);
3610
+ const historyButtonRef = useRef9(null);
3611
+ const settingsRef = useRef9(null);
3612
+ const settingsButtonRef = useRef9(null);
3415
3613
  const listen = useVoiceInput(setDraft, chat.capabilities?.transcription ?? false, {
3416
3614
  endpoint: options.endpoint,
3417
3615
  headers: options.headers,
3418
3616
  credentials: options.credentials,
3419
3617
  fetcher: options.fetcher
3420
3618
  });
3421
- const historySurfaces = useMemo2(() => [historyRef, historyButtonRef], []);
3422
- const settingsSurfaces = useMemo2(() => [settingsRef, settingsButtonRef], []);
3619
+ const historySurfaces = useMemo3(() => [historyRef, historyButtonRef], []);
3620
+ const settingsSurfaces = useMemo3(() => [settingsRef, settingsButtonRef], []);
3423
3621
  const closeHistory = useCallback6(() => setHistoryOpen(false), []);
3424
3622
  const closeSettings = useCallback6(() => setSettingsOpen(false), []);
3425
3623
  useDismiss(historyOpen, closeHistory, historySurfaces);
@@ -3463,19 +3661,19 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3463
3661
  const shown = chat.shownTurn;
3464
3662
  const showing = chat.playerProps != null;
3465
3663
  const handoffKey = `${shown?.id ?? ""}:${chat.playerKey}`;
3466
- const [presentedBody, setPresentedBody] = useState7();
3467
- const handoff = useRef8({ key: handoffKey, live: showing, active: false, frame: 0 });
3664
+ const [presentedBody, setPresentedBody] = useState8();
3665
+ const handoff = useRef9({ key: handoffKey, live: showing, active: false, frame: 0 });
3468
3666
  const handoffStopped = chat.status === "error" || chat.status === "cancelled";
3469
3667
  const waitingForBody = showing && presentedBody !== handoffKey;
3470
3668
  const openingChapter = Boolean(shown?.prompt) && (!showing || waitingForBody) && chat.status !== "error" && chat.status !== "cancelled" && chat.status !== "ended";
3471
- const [preparingKey, setPreparingKey] = useState7();
3472
- useEffect9(() => {
3669
+ const [preparingKey, setPreparingKey] = useState8();
3670
+ useEffect10(() => {
3473
3671
  setPreparingKey(void 0);
3474
3672
  if (!openingChapter || !shown?.opening || chat.speaking) return;
3475
3673
  const timer = setTimeout(() => setPreparingKey(shown.id), 1e3);
3476
3674
  return () => clearTimeout(timer);
3477
3675
  }, [openingChapter, shown?.opening, shown?.id, chat.speaking]);
3478
- useLayoutEffect2(() => {
3676
+ useLayoutEffect3(() => {
3479
3677
  const current = { key: handoffKey, live: showing && !handoffStopped, active: openingChapter && showing, frame: 0 };
3480
3678
  handoff.current = current;
3481
3679
  return () => {
@@ -3508,9 +3706,12 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3508
3706
  const status = chat.turns.length === 0 ? "idle" : chat.status === "composing" ? "drawing" : chat.status === "playing" ? "narrating" : chat.status === "error" || chat.status === "cancelled" ? "ended" : chat.status;
3509
3707
  const shownOrientation = shown?.orientation ?? sessionOrientation;
3510
3708
  const stageOrientation = shown?.fixedOrientation ? shownOrientation : sessionOrientation;
3709
+ useEffect10(() => {
3710
+ setCaptionsExpanded(false);
3711
+ }, [chat.playbackEnded, shown?.id, chat.playerKey]);
3511
3712
  const line = chat.caption ?? "";
3512
3713
  const fullTranscript = shown?.video ? [shown.opening, ...shown.video.scenes.map((scene) => scene.narration)].filter((entry) => Boolean(entry)) : chat.transcript;
3513
- const transport = status === "narrating" ? { label: "Pause", action: chat.pause, icon: /* @__PURE__ */ jsx8(Stop, {}) } : status === "paused" ? { label: "Continue", action: chat.resume, icon: /* @__PURE__ */ jsx8(Play, {}) } : status === "ended" && shown?.completed && shown.video ? { label: "Play again", action: chat.replay, icon: /* @__PURE__ */ jsx8(Replay, {}) } : void 0;
3714
+ const transport = status === "narrating" ? { label: "Pause", action: chat.pause, icon: /* @__PURE__ */ jsx9(Stop, {}) } : status === "paused" ? { label: "Continue", action: chat.resume, icon: /* @__PURE__ */ jsx9(Play, {}) } : status === "ended" && shown?.completed && shown.video ? { label: "Play again", action: chat.replay, icon: /* @__PURE__ */ jsx9(Replay, {}) } : void 0;
3514
3715
  const cancelInput = useCallback6(() => {
3515
3716
  listen.stop();
3516
3717
  setDraft("");
@@ -3541,7 +3742,7 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3541
3742
  onFocusCapture: controls.onFocusCapture,
3542
3743
  onBlurCapture: controls.onBlurCapture
3543
3744
  };
3544
- useLayoutEffect2(() => {
3745
+ useLayoutEffect3(() => {
3545
3746
  const composer = composerRef.current;
3546
3747
  if (!composer) return;
3547
3748
  const measure = () => panelRef.current?.style.setProperty("--composer-height", `${composer.getBoundingClientRect().height}px`);
@@ -3564,10 +3765,7 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3564
3765
  onKeyDownCapture: controls.reveal,
3565
3766
  children: [
3566
3767
  /* @__PURE__ */ jsxs7("header", { className: "chrome", ...controlEvents, children: [
3567
- /* @__PURE__ */ jsxs7("div", { className: "session-brand", children: [
3568
- /* @__PURE__ */ jsx8("a", { className: "home-link", href: "/", "aria-label": "Home", children: /* @__PURE__ */ jsx8(Logo, {}) }),
3569
- (shown?.mode ?? selectedMode ?? options.mode) === "pexels" && /* @__PURE__ */ jsx8("a", { className: "media-credit", href: "https://www.pexels.com", target: "_blank", rel: "noopener noreferrer", children: "Pexels" })
3570
- ] }),
3768
+ /* @__PURE__ */ jsx9("div", { className: "session-brand", children: /* @__PURE__ */ jsx9("a", { className: "home-link", href: "/", "aria-label": "Home", children: /* @__PURE__ */ jsx9(Logo, {}) }) }),
3571
3769
  /* @__PURE__ */ jsxs7("div", { className: "group", children: [
3572
3770
  /* @__PURE__ */ jsxs7(
3573
3771
  "button",
@@ -3584,12 +3782,12 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3584
3782
  setSettingsOpen(false);
3585
3783
  },
3586
3784
  children: [
3587
- /* @__PURE__ */ jsx8(Sessions, {}),
3588
- /* @__PURE__ */ jsx8("span", { className: "nav-label", children: "Sessions" })
3785
+ /* @__PURE__ */ jsx9(Sessions, {}),
3786
+ /* @__PURE__ */ jsx9("span", { className: "nav-label", children: "Sessions" })
3589
3787
  ]
3590
3788
  }
3591
3789
  ),
3592
- /* @__PURE__ */ jsx8(
3790
+ /* @__PURE__ */ jsx9(
3593
3791
  "button",
3594
3792
  {
3595
3793
  ref: settingsButtonRef,
@@ -3603,10 +3801,10 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3603
3801
  setSettingsOpen((open) => !open);
3604
3802
  setHistoryOpen(false);
3605
3803
  },
3606
- children: /* @__PURE__ */ jsx8(Gear, {})
3804
+ children: /* @__PURE__ */ jsx9(Gear, {})
3607
3805
  }
3608
3806
  ),
3609
- /* @__PURE__ */ jsx8(
3807
+ /* @__PURE__ */ jsx9(
3610
3808
  "button",
3611
3809
  {
3612
3810
  type: "button",
@@ -3614,16 +3812,16 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3614
3812
  "aria-label": chat.muted ? "Turn the voice on" : "Turn the voice off",
3615
3813
  "aria-pressed": chat.muted,
3616
3814
  onClick: () => chat.setMuted(!chat.muted),
3617
- children: chat.muted ? /* @__PURE__ */ jsx8(Muted, {}) : /* @__PURE__ */ jsx8(Sound, {})
3815
+ children: chat.muted ? /* @__PURE__ */ jsx9(Muted, {}) : /* @__PURE__ */ jsx9(Sound, {})
3618
3816
  }
3619
3817
  )
3620
3818
  ] })
3621
3819
  ] }),
3622
3820
  /* @__PURE__ */ jsxs7("div", { className: "stage-area", children: [
3623
3821
  /* @__PURE__ */ jsxs7("div", { className: "stage", style: { background: "#000" }, children: [
3624
- openingChapter && /* @__PURE__ */ jsx8(OpeningChapter, { preparing: preparingKey === shown.id && !chat.speaking, title: openingTitle.length > 120 ? `${openingTitle.slice(0, 117).trimEnd()}\u2026` : openingTitle }, shown.id),
3625
- !showing && chat.turns.length === 0 && /* @__PURE__ */ jsx8(Welcome, { data: chat.welcome, onAsk: ask, title: welcomeTitle }),
3626
- chat.playerProps && /* @__PURE__ */ jsx8("div", { className: "player-fit", style: { width: stageOrientation === "portrait" ? "min(100cqw, 56.25cqh)" : "min(100cqw, 177.7778cqh)" }, children: /* @__PURE__ */ jsx8(
3822
+ openingChapter && /* @__PURE__ */ jsx9(OpeningChapter, { preparing: preparingKey === shown.id && !chat.speaking, title: openingTitle.length > 120 ? `${openingTitle.slice(0, 117).trimEnd()}\u2026` : openingTitle }, shown.id),
3823
+ !showing && chat.turns.length === 0 && /* @__PURE__ */ jsx9(Welcome, { data: chat.welcome, onAsk: ask, title: welcomeTitle }),
3824
+ chat.playerProps && /* @__PURE__ */ jsx9("div", { className: "player-fit", style: { width: stageOrientation === "portrait" ? "min(100cqw, 56.25cqh)" : "min(100cqw, 177.7778cqh)" }, children: /* @__PURE__ */ jsx9(
3627
3825
  VideoPlayer,
3628
3826
  {
3629
3827
  ...chat.playerProps,
@@ -3636,27 +3834,27 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3636
3834
  chat.playerKey
3637
3835
  ) }),
3638
3836
  showing && chat.playbackEnded && chat.suggestions.length > 0 && /* @__PURE__ */ jsxs7("div", { className: "ending", children: [
3639
- /* @__PURE__ */ jsx8("div", { className: "ending-wash", "aria-hidden": "true" }),
3837
+ /* @__PURE__ */ jsx9("div", { className: "ending-wash", "aria-hidden": "true" }),
3640
3838
  /* @__PURE__ */ jsxs7("div", { className: "ending-body", children: [
3641
- /* @__PURE__ */ jsx8("p", { className: "ending-label", children: "Ask next" }),
3642
- /* @__PURE__ */ jsx8(SuggestionCards, { suggestions: [...chat.suggestions], label: "Follow-up prompts", onAsk: ask })
3839
+ /* @__PURE__ */ jsx9("p", { className: "ending-label", children: "Ask next" }),
3840
+ /* @__PURE__ */ jsx9(SuggestionCards, { suggestions: [...chat.suggestions], label: "Follow-up prompts", onAsk: ask })
3643
3841
  ] })
3644
3842
  ] })
3645
3843
  ] }),
3646
3844
  historyOpen && /* @__PURE__ */ jsxs7("nav", { ref: historyRef, id: historyId, className: "sheet-popover history", role: "dialog", "aria-modal": "true", "aria-label": "Sessions", children: [
3647
3845
  /* @__PURE__ */ jsxs7("div", { className: "popover-heading", children: [
3648
- /* @__PURE__ */ jsx8("h2", { children: "Sessions" }),
3649
- /* @__PURE__ */ jsx8("button", { type: "button", className: "round", "aria-label": "Close sessions", onClick: closeHistory, children: /* @__PURE__ */ jsx8(Close, {}) })
3846
+ /* @__PURE__ */ jsx9("h2", { children: "Sessions" }),
3847
+ /* @__PURE__ */ jsx9("button", { type: "button", className: "round", "aria-label": "Close sessions", onClick: closeHistory, children: /* @__PURE__ */ jsx9(Close, {}) })
3650
3848
  ] }),
3651
3849
  /* @__PURE__ */ jsxs7("button", { type: "button", className: "history-row session-new", "aria-label": "New session", onClick: newSession, children: [
3652
- /* @__PURE__ */ jsx8(Plus, {}),
3850
+ /* @__PURE__ */ jsx9(Plus, {}),
3653
3851
  /* @__PURE__ */ jsxs7("span", { className: "prompt", children: [
3654
3852
  "New session",
3655
- /* @__PURE__ */ jsx8("small", { children: "Start a fresh conversation" })
3853
+ /* @__PURE__ */ jsx9("small", { children: "Start a fresh conversation" })
3656
3854
  ] })
3657
3855
  ] }),
3658
- /* @__PURE__ */ jsx8("h3", { className: "section-label", children: "Current session" }),
3659
- chat.turns.length === 0 && /* @__PURE__ */ jsx8("p", { className: "history-empty", children: "Your questions will appear here." }),
3856
+ /* @__PURE__ */ jsx9("h3", { className: "section-label", children: "Current session" }),
3857
+ chat.turns.length === 0 && /* @__PURE__ */ jsx9("p", { className: "history-empty", children: "Your questions will appear here." }),
3660
3858
  chat.turns.map((turn, index) => /* @__PURE__ */ jsxs7(
3661
3859
  "button",
3662
3860
  {
@@ -3673,17 +3871,17 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3673
3871
  setHistoryOpen(false);
3674
3872
  },
3675
3873
  children: [
3676
- /* @__PURE__ */ jsx8("span", { className: "index", children: String(index + 1).padStart(2, "0") }),
3874
+ /* @__PURE__ */ jsx9("span", { className: "index", children: String(index + 1).padStart(2, "0") }),
3677
3875
  /* @__PURE__ */ jsxs7("span", { className: "prompt", children: [
3678
3876
  turn.prompt,
3679
- /* @__PURE__ */ jsx8("small", { children: turn.id === shown?.id ? "Now showing" : turn.completed ? "Play answer" : "Unfinished answer" })
3877
+ /* @__PURE__ */ jsx9("small", { children: turn.id === shown?.id ? "Now showing" : turn.completed ? "Play answer" : "Unfinished answer" })
3680
3878
  ] })
3681
3879
  ]
3682
3880
  },
3683
3881
  turn.id
3684
3882
  )),
3685
3883
  savedSessions.length > 0 && /* @__PURE__ */ jsxs7(Fragment4, { children: [
3686
- /* @__PURE__ */ jsx8("h3", { className: "section-label", children: "Earlier sessions" }),
3884
+ /* @__PURE__ */ jsx9("h3", { className: "section-label", children: "Earlier sessions" }),
3687
3885
  savedSessions.map((session) => /* @__PURE__ */ jsxs7("button", { type: "button", className: "history-row", onClick: () => {
3688
3886
  listen.stop();
3689
3887
  setDraft("");
@@ -3695,7 +3893,7 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3695
3893
  setHistoryOpen(false);
3696
3894
  setCaptionsExpanded(false);
3697
3895
  }, children: [
3698
- /* @__PURE__ */ jsx8(Replay, {}),
3896
+ /* @__PURE__ */ jsx9(Replay, {}),
3699
3897
  /* @__PURE__ */ jsxs7("span", { className: "prompt", children: [
3700
3898
  session.turns[0]?.prompt,
3701
3899
  /* @__PURE__ */ jsxs7("small", { children: [
@@ -3718,17 +3916,17 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3718
3916
  "aria-modal": "true",
3719
3917
  children: [
3720
3918
  /* @__PURE__ */ jsxs7("div", { className: "popover-heading", children: [
3721
- /* @__PURE__ */ jsx8("h2", { children: "Settings" }),
3722
- /* @__PURE__ */ jsx8("button", { type: "button", className: "round", "aria-label": "Close settings", onClick: closeSettings, children: /* @__PURE__ */ jsx8(Close, {}) })
3919
+ /* @__PURE__ */ jsx9("h2", { children: "Settings" }),
3920
+ /* @__PURE__ */ jsx9("button", { type: "button", className: "round", "aria-label": "Close settings", onClick: closeSettings, children: /* @__PURE__ */ jsx9(Close, {}) })
3723
3921
  ] }),
3724
3922
  /* @__PURE__ */ jsxs7("fieldset", { className: "playback-options", children: [
3725
- /* @__PURE__ */ jsx8("legend", { children: "Video source" }),
3923
+ /* @__PURE__ */ jsx9("legend", { children: "Video source" }),
3726
3924
  visualModes.filter((mode) => chat.availableModes.includes(mode.id)).map((mode) => /* @__PURE__ */ jsxs7("label", { className: "switch-row", children: [
3727
3925
  /* @__PURE__ */ jsxs7("span", { children: [
3728
- /* @__PURE__ */ jsx8("strong", { children: mode.label }),
3729
- /* @__PURE__ */ jsx8("small", { children: mode.note })
3926
+ /* @__PURE__ */ jsx9("strong", { children: mode.label }),
3927
+ /* @__PURE__ */ jsx9("small", { children: mode.note })
3730
3928
  ] }),
3731
- /* @__PURE__ */ jsx8(
3929
+ /* @__PURE__ */ jsx9(
3732
3930
  "input",
3733
3931
  {
3734
3932
  type: "radio",
@@ -3741,47 +3939,50 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3741
3939
  ] }, mode.id))
3742
3940
  ] }),
3743
3941
  /* @__PURE__ */ jsxs7("fieldset", { className: "playback-options", children: [
3744
- /* @__PURE__ */ jsx8("legend", { children: "Watching" }),
3942
+ /* @__PURE__ */ jsx9("legend", { children: "Watching" }),
3745
3943
  /* @__PURE__ */ jsxs7("label", { className: "switch-row", children: [
3746
3944
  /* @__PURE__ */ jsxs7("span", { children: [
3747
- /* @__PURE__ */ jsx8("strong", { children: "Subtitles" }),
3748
- /* @__PURE__ */ jsx8("small", { children: "Read along with the answer" })
3945
+ /* @__PURE__ */ jsx9("strong", { children: "Subtitles" }),
3946
+ /* @__PURE__ */ jsx9("small", { children: "Read along with the answer" })
3749
3947
  ] }),
3750
- /* @__PURE__ */ jsx8("input", { type: "checkbox", role: "switch", checked: captionsOn, onChange: (event) => {
3948
+ /* @__PURE__ */ jsx9("input", { type: "checkbox", role: "switch", checked: captionsOn, onChange: (event) => {
3751
3949
  setCaptionsOn(event.target.checked);
3752
3950
  setCaptionsExpanded(false);
3753
3951
  } })
3754
3952
  ] }),
3755
3953
  /* @__PURE__ */ jsxs7("label", { className: "switch-row", children: [
3756
3954
  /* @__PURE__ */ jsxs7("span", { children: [
3757
- /* @__PURE__ */ jsx8("strong", { children: "Keep controls visible" }),
3758
- /* @__PURE__ */ jsx8("small", { children: "Keep the input bar on screen" })
3955
+ /* @__PURE__ */ jsx9("strong", { children: "Keep controls visible" }),
3956
+ /* @__PURE__ */ jsx9("small", { children: "Keep the input bar on screen" })
3759
3957
  ] }),
3760
- /* @__PURE__ */ jsx8("input", { type: "checkbox", role: "switch", checked: alwaysShowControls, onChange: (event) => setAlwaysShowControls(event.target.checked) })
3958
+ /* @__PURE__ */ jsx9("input", { type: "checkbox", role: "switch", checked: alwaysShowControls, onChange: (event) => setAlwaysShowControls(event.target.checked) })
3761
3959
  ] })
3762
3960
  ] }),
3763
3961
  /* @__PURE__ */ jsxs7("nav", { className: "developer-links", "aria-label": "Build with VanillaSky", children: [
3764
- /* @__PURE__ */ jsx8("p", { className: "section-label", children: "Build with VanillaSky" }),
3962
+ /* @__PURE__ */ jsx9("p", { className: "section-label", children: "Build with VanillaSky" }),
3765
3963
  /* @__PURE__ */ jsxs7("a", { href: "https://github.com/VanillaSkyAi/video/blob/main/docs/getting-started.md", target: "_blank", rel: "noopener noreferrer", children: [
3766
3964
  "Docs",
3767
- /* @__PURE__ */ jsx8("span", { "aria-hidden": "true", children: "\u2197" })
3965
+ /* @__PURE__ */ jsx9("span", { "aria-hidden": "true", children: "\u2197" })
3768
3966
  ] }),
3769
3967
  /* @__PURE__ */ jsxs7("button", { type: "button", "aria-expanded": aboutOpen, "aria-controls": `${instanceId}-about`, onClick: () => setAboutOpen((open) => !open), children: [
3770
3968
  "About",
3771
- /* @__PURE__ */ jsx8("span", { "aria-hidden": "true", children: aboutOpen ? "\u2212" : "+" })
3969
+ /* @__PURE__ */ jsx9("span", { "aria-hidden": "true", children: aboutOpen ? "\u2212" : "+" })
3772
3970
  ] }),
3773
- /* @__PURE__ */ jsx8("div", { id: `${instanceId}-about`, className: "developer-about", role: "region", "aria-label": "About VanillaSky", hidden: !aboutOpen, children: /* @__PURE__ */ jsx8("p", { children: "VanillaSky is an open-source SDK for conversations that answer in video. Developers connect their own AI providers through their application server." }) }),
3971
+ /* @__PURE__ */ jsx9("div", { id: `${instanceId}-about`, className: "developer-about", role: "region", "aria-label": "About VanillaSky", hidden: !aboutOpen, children: /* @__PURE__ */ jsx9("p", { children: "VanillaSky is an open-source SDK for conversations that answer in video. Developers connect their own AI providers through their application server." }) }),
3774
3972
  /* @__PURE__ */ jsxs7("a", { href: "https://github.com/VanillaSkyAi/video", target: "_blank", rel: "noopener noreferrer", children: [
3775
3973
  "GitHub",
3776
- /* @__PURE__ */ jsx8("span", { "aria-hidden": "true", children: "\u2197" })
3974
+ /* @__PURE__ */ jsx9("span", { "aria-hidden": "true", children: "\u2197" })
3777
3975
  ] })
3778
3976
  ] })
3779
3977
  ]
3780
3978
  }
3781
3979
  )
3782
3980
  ] }),
3783
- /* @__PURE__ */ jsx8("div", { ref: panelRef, className: "panel", "data-input-visible": controls.visible || !captionsOn || !line, children: /* @__PURE__ */ jsxs7("div", { className: "panel-inner", children: [
3784
- /* @__PURE__ */ jsx8("div", { className: "caption-slot", "data-captions": captionsOn && Boolean(line), "aria-hidden": !captionsOn || !line, children: /* @__PURE__ */ jsx8("div", { className: "caption-clip", children: /* @__PURE__ */ jsxs7(
3981
+ /* @__PURE__ */ jsx9("div", { ref: panelRef, className: "panel", "data-input-visible": controls.visible || !captionsOn || !line, children: /* @__PURE__ */ jsxs7("div", { className: "panel-inner", children: [
3982
+ /* @__PURE__ */ jsx9("div", { className: "caption-slot", "data-captions": captionsOn && Boolean(line), "aria-hidden": !captionsOn || !line, children: /* @__PURE__ */ jsx9("div", { className: "caption-clip", children: chat.playbackEnded && !captionsExpanded ? /* @__PURE__ */ jsxs7("button", { type: "button", className: "transcript-toggle", "aria-expanded": false, onClick: () => setCaptionsExpanded(true), children: [
3983
+ "Show transcript",
3984
+ /* @__PURE__ */ jsx9(ChevronUp, {})
3985
+ ] }) : /* @__PURE__ */ jsxs7(
3785
3986
  "div",
3786
3987
  {
3787
3988
  className: "line-row",
@@ -3797,41 +3998,41 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3797
3998
  captionsOn && line && /* @__PURE__ */ jsxs7("div", { className: "caption-actions", children: [
3798
3999
  /* @__PURE__ */ jsxs7("button", { type: "button", className: "caption-action", "aria-label": captionsExpanded ? "Collapse subtitles" : "Expand subtitles", "aria-expanded": captionsExpanded, onClick: () => setCaptionsExpanded((open) => !open), children: [
3799
4000
  captionsExpanded ? "Collapse" : "Expand",
3800
- /* @__PURE__ */ jsx8(ChevronUp, {})
4001
+ /* @__PURE__ */ jsx9(ChevronUp, {})
3801
4002
  ] }),
3802
- /* @__PURE__ */ jsx8("button", { type: "button", className: "caption-action", "aria-label": "Hide subtitles", onClick: () => {
4003
+ /* @__PURE__ */ jsx9("button", { type: "button", className: "caption-action", "aria-label": "Hide subtitles", onClick: () => {
3803
4004
  setCaptionsOn(false);
3804
4005
  setCaptionsExpanded(false);
3805
- }, children: /* @__PURE__ */ jsx8(Close, {}) })
4006
+ }, children: /* @__PURE__ */ jsx9(Close, {}) })
3806
4007
  ] }),
3807
- captionsExpanded ? /* @__PURE__ */ jsx8("div", { className: "expanded-captions", role: "region", tabIndex: 0, "aria-label": "Expanded subtitles", children: fullTranscript.map((entry, index) => /* @__PURE__ */ jsx8("p", { children: entry }, index)) }) : /* @__PURE__ */ jsx8("p", { className: "line", "aria-live": "polite", children: openingChapter && line === shown?.opening ? "" : line })
4008
+ captionsExpanded ? /* @__PURE__ */ jsx9("div", { className: "expanded-captions", role: "region", tabIndex: 0, "aria-label": "Expanded subtitles", children: fullTranscript.map((entry, index) => /* @__PURE__ */ jsx9("p", { children: entry }, index)) }) : /* @__PURE__ */ jsx9(CaptionPages, { text: openingChapter && line === shown?.opening ? "" : line, getProgress: getCaptionProgress }, `${shown?.id}:${chat.playerKey}`)
3808
4009
  ]
3809
4010
  }
3810
4011
  ) }) }),
3811
4012
  showRecoveryNotice && chat.shownTurn && dismissedNoticeTurn !== chat.shownTurn.id && chat.warnings.includes(MEDIA_RECOVERY_NOTICE) && !chat.error && /* @__PURE__ */ jsxs7("div", { className: "recovery-notice", children: [
3812
- /* @__PURE__ */ jsx8("p", { role: "status", children: MEDIA_RECOVERY_NOTICE }),
3813
- /* @__PURE__ */ jsx8("button", { type: "button", className: "round", "aria-label": "Dismiss notice", onClick: () => setDismissedNoticeTurn(chat.shownTurn?.id), children: /* @__PURE__ */ jsx8(Close, {}) })
4013
+ /* @__PURE__ */ jsx9("p", { role: "status", children: MEDIA_RECOVERY_NOTICE }),
4014
+ /* @__PURE__ */ jsx9("button", { type: "button", className: "round", "aria-label": "Dismiss notice", onClick: () => setDismissedNoticeTurn(chat.shownTurn?.id), children: /* @__PURE__ */ jsx9(Close, {}) })
3814
4015
  ] }),
3815
4016
  (chat.error || listen.error) && /* @__PURE__ */ jsxs7("p", { className: "error", role: "status", children: [
3816
- /* @__PURE__ */ jsx8(Warning, {}),
3817
- /* @__PURE__ */ jsx8("span", { children: chat.error?.message ?? listen.error })
4017
+ /* @__PURE__ */ jsx9(Warning, {}),
4018
+ /* @__PURE__ */ jsx9("span", { children: chat.error?.message ?? listen.error })
3818
4019
  ] }),
3819
4020
  /* @__PURE__ */ jsxs7("div", { ref: composerRef, className: "conversation-composer", "data-editing": editing, ...controlEvents, children: [
3820
- (listen.listening || listen.thinking) && /* @__PURE__ */ jsx8("div", { className: "composer-meta", children: /* @__PURE__ */ jsx8("span", { role: "status", children: listen.listening ? "Listening\u2026 Tap the mic to finish, then review and send." : "Turning your words into a draft\u2026" }) }),
4021
+ (listen.listening || listen.thinking) && /* @__PURE__ */ jsx9("div", { className: "composer-meta", children: /* @__PURE__ */ jsx9("span", { role: "status", children: listen.listening ? "Listening\u2026 Tap the mic to finish, then review and send." : "Turning your words into a draft\u2026" }) }),
3821
4022
  /* @__PURE__ */ jsxs7("form", { className: "composer", "aria-label": "Ask a question", onSubmit: (event) => {
3822
4023
  event.preventDefault();
3823
4024
  ask(draft);
3824
4025
  }, children: [
3825
- transport && /* @__PURE__ */ jsx8("button", { type: "button", className: "ghost transport", "aria-label": transport.label, onClick: () => {
4026
+ transport && /* @__PURE__ */ jsx9("button", { type: "button", className: "ghost transport", "aria-label": transport.label, onClick: () => {
3826
4027
  listen.stop();
3827
4028
  setEditing(false);
3828
4029
  resumeAfterInput.current = false;
3829
4030
  inputRef.current?.blur();
3830
4031
  transport.action();
3831
4032
  }, children: transport.icon }),
3832
- /* @__PURE__ */ jsx8(Waveform, { active: listen.listening || chat.speaking && !chat.muted && status !== "paused", listening: listen.listening }),
3833
- /* @__PURE__ */ jsx8("label", { className: "sr-only", htmlFor: promptId, children: "Prompt" }),
3834
- /* @__PURE__ */ jsx8(
4033
+ /* @__PURE__ */ jsx9(Waveform, { active: listen.listening || chat.speaking && !chat.muted && status !== "paused", listening: listen.listening }),
4034
+ /* @__PURE__ */ jsx9("label", { className: "sr-only", htmlFor: promptId, children: "Prompt" }),
4035
+ /* @__PURE__ */ jsx9(
3835
4036
  "textarea",
3836
4037
  {
3837
4038
  id: promptId,
@@ -3859,12 +4060,12 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3859
4060
  }
3860
4061
  }
3861
4062
  ),
3862
- listen.supported && /* @__PURE__ */ jsx8("button", { type: "button", className: `ghost${listen.listening ? " listening" : ""}`, "aria-label": listen.listening ? "Stop listening" : "Ask by voice", "aria-pressed": listen.listening, disabled: listen.thinking, onClick: () => beginInput(true), children: /* @__PURE__ */ jsx8(Mic, {}) }),
3863
- /* @__PURE__ */ jsx8("button", { type: "submit", className: "send", "aria-label": "Ask", disabled: !draft.trim() || listen.thinking, children: /* @__PURE__ */ jsx8(Send, {}) }),
3864
- editing && /* @__PURE__ */ jsx8("button", { type: "button", className: "ghost", "aria-label": "Cancel question", onClick: cancelInput, children: /* @__PURE__ */ jsx8(Close, {}) })
4063
+ listen.supported && /* @__PURE__ */ jsx9("button", { type: "button", className: `ghost${listen.listening ? " listening" : ""}`, "aria-label": listen.listening ? "Stop listening" : "Ask by voice", "aria-pressed": listen.listening, disabled: listen.thinking, onClick: () => beginInput(true), children: /* @__PURE__ */ jsx9(Mic, {}) }),
4064
+ /* @__PURE__ */ jsx9("button", { type: "submit", className: "send", "aria-label": "Ask", disabled: !draft.trim() || listen.thinking, children: /* @__PURE__ */ jsx9(Send, {}) }),
4065
+ editing && /* @__PURE__ */ jsx9("button", { type: "button", className: "ghost", "aria-label": "Cancel question", onClick: cancelInput, children: /* @__PURE__ */ jsx9(Close, {}) })
3865
4066
  ] })
3866
4067
  ] }),
3867
- !showing && chat.turns.length === 0 && /* @__PURE__ */ jsx8("p", { className: "dock-hint", children: "Speak or type. See where it takes you." })
4068
+ !showing && chat.turns.length === 0 && /* @__PURE__ */ jsx9("p", { className: "dock-hint", children: "Speak or type. See where it takes you." })
3868
4069
  ] }) })
3869
4070
  ]
3870
4071
  }
@@ -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.19",
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.19",
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,53 +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: 4, 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, 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, bestOrientation = -1;
69
- const matchesOrientation = (file: {width?: number; height?: number}) => orientation === "portrait"
70
- ? file.height! > file.width! : file.width! >= file.height!;
71
- for (const video of (Array.isArray(result.videos) ? result.videos : []).slice(0, 12)) {
72
- if (!pexelsUrl(video.url)) continue;
73
- const slug = new URL(video.url).pathname.replace(/^\/video\//, "");
74
- const title = typeof video.title === "string" ? video.title : "";
75
- const tags = Array.isArray(video.tags) ? video.tags.filter((tag): tag is string => typeof tag === "string").join(" ") : "";
76
- const subject = words(`${slug} ${title} ${typeof video.description === "string" ? video.description : ""} ${tags}`).filter(token => !/^\d+$/.test(token)).map(wordForm);
77
- let matches = tokens.filter(token => subject.includes(wordForm(token))).length;
78
- if (selection && subject.length) {
79
- const covers = (phrase: string) => terms(phrase).every(word => subject.includes(wordForm(word)));
80
- if (!covers(selection.subject) || selection.exclude?.some(covers)) continue;
81
- // Query context breaks equal hint matches without outweighing a hint.
82
- const contextScore = matches / (tokens.length + 1);
83
- matches = 2 + contextScore + Number(Boolean(selection.activity && covers(selection.activity)))
84
- + 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 } : {}) };
85
107
  }
86
- // The documented Video resource can have only a numeric page URL and no
87
- // editorial metadata. Preserve provider search order for unknown relevance;
88
- // positive overlap ranks above it, while explicitly unrelated copy is skipped.
89
- if (subject.length > 0 && matches === 0) continue;
90
- const files = (Array.isArray(video.video_files) ? video.video_files : []).filter(file =>
91
- file.file_type === "video/mp4" && pexelsUrl(file.link)
92
- && Number.isFinite(file.width) && Number.isFinite(file.height)
93
- && Math.min(file.width!, file.height!) >= 360,
94
- ).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));
95
- const file = files[0];
96
- if (!file) continue;
97
- const orientationScore = Number(matchesOrientation(file));
98
- // Prefer composition fit only when subject relevance is equal.
99
- if (matches < bestScore || (matches === bestScore && orientationScore <= bestOrientation)) continue;
100
- bestScore = matches;
101
- bestOrientation = orientationScore;
102
- selected = { url: file.link!, type: "video", ...(pexelsUrl(video.image) ? { posterUrl: video.image } : {}) };
108
+ if (selected) break;
103
109
  }
104
110
  // Bounded process-local cache; no request signal or credentials are retained.
105
111
  if (cache.size >= 128) cache.delete(cache.keys().next().value!);
@@ -1065,7 +1065,6 @@
1065
1065
  }
1066
1066
 
1067
1067
  .vanillasky-video-chat .opening-chapter { animation: vanillasky-chapter-enter 450ms ease-out both; }
1068
- .vanillasky-video-chat .media-credit { color: var(--vs-fg); font-size: 11px; margin-left: 12px; text-underline-offset: 3px; }
1069
1068
  @keyframes vanillasky-chapter-enter { from { opacity: 0; } to { opacity: 1; } }
1070
1069
  @media (prefers-reduced-motion: reduce) { .vanillasky-video-chat .opening-chapter { animation: none; } }
1071
1070
 
@@ -1124,3 +1123,7 @@
1124
1123
  .vanillasky-video-chat .card-prompt { position: relative; inset: auto; overflow-wrap: anywhere; flex-shrink: 0; }
1125
1124
 
1126
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; }