@vanillaskyai/video 0.7.1 → 0.8.1

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 (89) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/PUBLIC-API.md +83 -79
  3. package/README.md +20 -15
  4. package/dist/assets/vanillasky-logo.svg +151 -0
  5. package/dist/{builtin-server-KB7FKF6A.js → builtin-server-CUYPIDE7.js} +2 -2
  6. package/dist/check-runtime.js +1 -1
  7. package/dist/{chunk-2PYO6VAC.js → chunk-2DOLF4QN.js} +1 -43
  8. package/dist/{chunk-GAIOHGNR.js → chunk-2OKB27TG.js} +42 -45
  9. package/dist/{chunk-K746NUIP.js → chunk-2USVB3CY.js} +80 -29
  10. package/dist/{chunk-RE4IMWJR.js → chunk-3VRMKFM7.js} +87 -14
  11. package/dist/{chunk-JNN3EYVP.js → chunk-4UNKZEAT.js} +46 -46
  12. package/dist/{chunk-2XT4MZ76.js → chunk-APQKY6P3.js} +36 -3
  13. package/dist/{chunk-LLPMVDJZ.js → chunk-L7QGGKJ2.js} +69 -38
  14. package/dist/{chunk-RGF452LL.js → chunk-OWBT45EN.js} +1 -1
  15. package/dist/{chunk-LKBZX7GV.js → chunk-PP3MCPN4.js} +4 -4
  16. package/dist/chunk-ZD2VTUYR.js +28 -0
  17. package/dist/cli.js +131 -24
  18. package/dist/{compose-video-PD6LKKRK.js → compose-video-2NFH4DZY.js} +3 -3
  19. package/dist/{events-B6qS1Lsb.d.ts → events-BQ6z264n.d.ts} +1 -1
  20. package/dist/index.d.ts +5 -67
  21. package/dist/index.js +2 -4
  22. package/dist/{kit-8g46H2RZ.d.ts → kit-tQnCPMOM.d.ts} +1 -1
  23. package/dist/react.d.ts +33 -53
  24. package/dist/react.js +982 -648
  25. package/dist/server.d.ts +11 -13
  26. package/dist/server.js +170 -81
  27. package/dist/template-catalog.js +1 -1
  28. package/dist/templates.d.ts +3 -3
  29. package/dist/test.d.ts +2 -2
  30. package/dist/test.js +3 -3
  31. package/dist/{text-stream-ROTNGSD4.js → text-stream-54LXABJW.js} +1 -1
  32. package/dist/{types-2wHBqtg8.d.ts → types-CEh9eUFu.d.ts} +1 -1
  33. package/dist/{types-DrABlRa7.d.ts → types-DWFO6qca.d.ts} +1 -1
  34. package/docs/agent-integration.md +9 -3
  35. package/docs/architecture.md +8 -10
  36. package/docs/concepts.md +16 -40
  37. package/docs/custom-templates.md +27 -23
  38. package/docs/customization.md +23 -55
  39. package/docs/errors.md +58 -68
  40. package/docs/getting-started.md +21 -14
  41. package/docs/immersive-interface.md +87 -0
  42. package/docs/media-and-audio.md +2 -2
  43. package/docs/performance.md +69 -0
  44. package/docs/persistence.md +12 -27
  45. package/docs/production.md +2 -2
  46. package/docs/provider-integration.md +11 -5
  47. package/docs/reference/design-system.html +115 -0
  48. package/docs/reference/provider-adapters.md +37 -89
  49. package/docs/security.md +1 -1
  50. package/docs/streaming-protocol.md +2 -2
  51. package/examples/custom-template/README.md +6 -6
  52. package/package.json +6 -7
  53. package/registry/items/barChart.json +2 -2
  54. package/registry/items/bigNumber.json +2 -2
  55. package/registry/items/brandMessage.json +2 -2
  56. package/registry/items/cardList.json +2 -2
  57. package/registry/items/codeEditor.json +2 -2
  58. package/registry/items/ctaLogo.json +2 -2
  59. package/registry/items/ctaMedia.json +2 -2
  60. package/registry/items/incomingCall.json +2 -2
  61. package/registry/items/media.json +2 -2
  62. package/registry/items/milestone.json +2 -2
  63. package/registry/items/notification.json +2 -2
  64. package/registry/items/phoneMockup.json +2 -2
  65. package/registry/items/problemSolution.json +2 -2
  66. package/registry/items/progressRing.json +2 -2
  67. package/registry/items/promptInput.json +2 -2
  68. package/registry/items/reaction.json +2 -2
  69. package/registry/items/reviewStack.json +2 -2
  70. package/registry/items/steps.json +2 -2
  71. package/registry/items/terminal.json +2 -2
  72. package/registry/items/testimonial.json +2 -2
  73. package/registry/items/tripleStats.json +2 -2
  74. package/registry/items/tweet.json +2 -2
  75. package/registry/items/webMockup.json +2 -2
  76. package/starters/video-chat/.env.example +4 -2
  77. package/starters/video-chat/README.md +17 -5
  78. package/starters/video-chat/index.html +2 -1
  79. package/starters/video-chat/package.json +2 -4
  80. package/starters/video-chat/providers/speech.ts +20 -0
  81. package/starters/video-chat/providers/video.ts +66 -0
  82. package/starters/video-chat/providers.ts +3 -0
  83. package/starters/video-chat/server.ts +4 -80
  84. package/starters/video-chat/stock.ts +114 -99
  85. package/styles/video-chat.css +650 -246
  86. package/dist/chunk-SKRGRKHY.js +0 -142
  87. package/dist/state-DZcKuS32.d.ts +0 -3
  88. package/docs/branding-and-personalization.md +0 -87
  89. package/examples/custom-template/supplied-media.tsx +0 -92
package/dist/react.js CHANGED
@@ -1,31 +1,29 @@
1
1
  import {
2
- createSceneTimeline,
3
2
  getSceneDuration
4
- } from "./chunk-SKRGRKHY.js";
3
+ } from "./chunk-ZD2VTUYR.js";
5
4
  import {
6
- VIDEO_STREAM_CONTENT_TYPE,
7
- VIDEO_STREAM_HEADER,
8
5
  decodeVideoSse,
9
- sanitizeVideoChatMedia
10
- } from "./chunk-2XT4MZ76.js";
6
+ sanitizeVideoChatMedia,
7
+ withDeadline
8
+ } from "./chunk-APQKY6P3.js";
11
9
  import {
12
10
  applyVideoEvent,
13
- createReplayableStream,
11
+ checksumVideo,
12
+ createVideoEventFactory,
14
13
  createVideoState
15
- } from "./chunk-GAIOHGNR.js";
14
+ } from "./chunk-2OKB27TG.js";
16
15
  import {
17
16
  safePublicDiagnostic
18
17
  } from "./chunk-MMUXVA47.js";
19
18
  import {
20
19
  parseVideo
21
- } from "./chunk-2PYO6VAC.js";
20
+ } from "./chunk-2DOLF4QN.js";
22
21
  import {
23
- VIDEO_PROTOCOL_VERSION,
24
- createVideoRequest
22
+ VIDEO_SCHEMA_VERSION
25
23
  } from "./chunk-AOWSXU2K.js";
26
24
  import {
27
25
  getBuiltinTemplateMetadata
28
- } from "./chunk-JNN3EYVP.js";
26
+ } from "./chunk-4UNKZEAT.js";
29
27
  import "./chunk-3O7OMMMF.js";
30
28
  import {
31
29
  assertTemplateTransitionMetadata
@@ -33,7 +31,7 @@ import {
33
31
  import {
34
32
  VideoFrame,
35
33
  getDimensions
36
- } from "./chunk-K746NUIP.js";
34
+ } from "./chunk-2USVB3CY.js";
37
35
  import "./chunk-224QNWRA.js";
38
36
  import "./chunk-5CDAM24P.js";
39
37
  import {
@@ -43,7 +41,9 @@ import {
43
41
  getVideoDuration,
44
42
  resolveVideoTimeline
45
43
  } from "./chunk-SPVTJH3F.js";
46
- import "./chunk-XGRA2MUV.js";
44
+ import {
45
+ resolveVideoBrand
46
+ } from "./chunk-XGRA2MUV.js";
47
47
  import "./chunk-WJ6FLKNC.js";
48
48
  import "./chunk-73NTSFFI.js";
49
49
 
@@ -1018,6 +1018,15 @@ function usePlaybackClock({
1018
1018
  }) {
1019
1019
  useEffect(() => {
1020
1020
  if (!isPlaying) return;
1021
+ let stalled = false;
1022
+ const reportStall = (next) => {
1023
+ if (stalled === next) return;
1024
+ stalled = next;
1025
+ try {
1026
+ void Promise.resolve(callbacksRef.current.onStallChange?.(next)).catch(() => void 0);
1027
+ } catch {
1028
+ }
1029
+ };
1021
1030
  let frame = 0;
1022
1031
  let previous = performance.now();
1023
1032
  const tick = (now) => {
@@ -1067,11 +1076,15 @@ function usePlaybackClock({
1067
1076
  }
1068
1077
  }
1069
1078
  const duration = current.config ? getVideoDuration(current.config) : 0;
1079
+ reportStall(!settled && Boolean(current.config?.scenes.length) && duration > 0 && timeRef.current >= duration);
1070
1080
  if (!settled || looping || timeRef.current < duration) frame = requestAnimationFrame(tick);
1071
1081
  else setIsPlaying(false);
1072
1082
  };
1073
1083
  frame = requestAnimationFrame(tick);
1074
- return () => cancelAnimationFrame(frame);
1084
+ return () => {
1085
+ cancelAnimationFrame(frame);
1086
+ reportStall(false);
1087
+ };
1075
1088
  }, [isPlaying]);
1076
1089
  }
1077
1090
 
@@ -1106,6 +1119,8 @@ function VideoPlayerRuntime({
1106
1119
  onComplete,
1107
1120
  onError,
1108
1121
  onSceneChange,
1122
+ onFramePresented,
1123
+ onStallChange,
1109
1124
  onStateChange
1110
1125
  }) {
1111
1126
  const nativeMediaVolume = Math.max(0, Math.min(
@@ -1140,14 +1155,25 @@ function VideoPlayerRuntime({
1140
1155
  const timeRef = useRef(currentTime);
1141
1156
  const audioRef = useRef(null);
1142
1157
  const introStartedAtRef = useRef(autoStartGeneration ? performance.now() : null);
1143
- const callbacksRef = useRef({ onComplete, onPlaybackEnd, onError, onSceneChange, onStateChange });
1158
+ const callbacksRef = useRef({ onComplete, onPlaybackEnd, onError, onSceneChange, onFramePresented, onStallChange, onStateChange });
1144
1159
  const loopRef = useRef(loop);
1145
1160
  const sceneIndexRef = useRef(-1);
1146
1161
  const playbackEndedRef = useRef(false);
1147
1162
  stateRef.current = state;
1148
1163
  timeRef.current = currentTime;
1149
- callbacksRef.current = { onComplete, onPlaybackEnd, onError, onSceneChange, onStateChange };
1164
+ callbacksRef.current = { onComplete, onPlaybackEnd, onError, onSceneChange, onFramePresented, onStallChange, onStateChange };
1150
1165
  loopRef.current = loop;
1166
+ const reportFramePresented = useMemo(() => {
1167
+ let reported = false;
1168
+ return () => {
1169
+ if (reported) return;
1170
+ reported = true;
1171
+ try {
1172
+ void Promise.resolve(callbacksRef.current.onFramePresented?.()).catch(() => void 0);
1173
+ } catch {
1174
+ }
1175
+ };
1176
+ }, [stream, video]);
1151
1177
  const primeSoundtrack = () => {
1152
1178
  const audio = audioRef.current;
1153
1179
  if (!audio || audio.dataset.audioOutput) return;
@@ -1499,6 +1525,7 @@ function VideoPlayerRuntime({
1499
1525
  VideoFrame,
1500
1526
  {
1501
1527
  kit,
1528
+ onFramePresented: !showStartPoster && onFramePresented ? reportFramePresented : void 0,
1502
1529
  config: displayConfig,
1503
1530
  time: showStartPoster ? posterTime : currentTime,
1504
1531
  width: dimensions.width,
@@ -1611,292 +1638,134 @@ var VideoError = class extends Error {
1611
1638
  }
1612
1639
  };
1613
1640
 
1614
- // src/player/use-video.ts
1615
- import { useCallback, useEffect as useEffect3, useMemo as useMemo2, useRef as useRef2, useState as useState2 } from "react";
1641
+ // src/video-chat/use-video-chat.ts
1642
+ import { useCallback as useCallback2, useEffect as useEffect4, useReducer, useRef as useRef3 } from "react";
1616
1643
 
1617
- // src/player/stream-video.ts
1618
- var HTTP_ERROR_CODES = /* @__PURE__ */ new Set([
1619
- "body_too_large",
1620
- "http_error",
1621
- "invalid_body",
1622
- "invalid_cursor",
1623
- "invalid_input",
1624
- "invalid_json",
1625
- "invalid_request",
1626
- "method_not_allowed",
1627
- "origin_forbidden",
1628
- "resume_unavailable",
1629
- "secret_field",
1630
- "unauthorized"
1631
- ]);
1632
- async function responseError(response, requestId) {
1633
- let code = "http_error";
1634
- let message = `Video response endpoint failed (${response.status})`;
1635
- try {
1636
- const body = await response.json();
1637
- if (body && typeof body === "object" && "error" in body) {
1638
- const error = body.error;
1639
- if (error && typeof error === "object") {
1640
- if ("code" in error && typeof error.code === "string" && HTTP_ERROR_CODES.has(error.code)) {
1641
- code = error.code;
1642
- }
1643
- if ("message" in error && typeof error.message === "string") {
1644
- message = safePublicDiagnostic(error.message, message);
1645
- }
1646
- }
1647
- }
1648
- } catch {
1649
- }
1650
- return new VideoError(message, { code, status: response.status, requestId });
1651
- }
1652
- function normalizeStreamError(cause, requestId, state, aborted) {
1653
- if (cause instanceof VideoError) return cause;
1654
- const causeError = cause instanceof Error ? cause : void 0;
1655
- const safeMessage = aborted ? causeError?.name === "TimeoutError" ? "Request timed out" : safePublicDiagnostic(causeError?.message, "Video generation was aborted") : causeError?.message.startsWith("Video response protocol") || causeError?.message.startsWith("Video response endpoint omitted protocol") ? safePublicDiagnostic(causeError.message, "Video response protocol failed") : "Video response stream failed";
1656
- return new VideoError(safeMessage, {
1657
- code: aborted ? "aborted" : "stream_failed",
1658
- requestId: state.requestId ?? requestId,
1659
- runId: state.runId,
1660
- recoverable: false
1661
- });
1662
- }
1663
- function streamVideo(options) {
1664
- const request = createVideoRequest(options.input, {
1665
- requestId: options.requestId,
1666
- capabilities: options.capabilities,
1667
- ...options.resume?.state.runId != null && options.resume.state.lastSequence >= 0 ? {
1668
- resume: {
1669
- runId: options.resume.state.runId,
1670
- afterSequence: options.resume.state.lastSequence
1671
- }
1672
- } : {}
1673
- });
1674
- const controller = new AbortController();
1675
- const forwardAbort = () => controller.abort(options.signal?.reason);
1676
- if (options.signal?.aborted) forwardAbort();
1677
- else options.signal?.addEventListener("abort", forwardAbort, { once: true });
1678
- let resolveResult;
1679
- let rejectResult;
1680
- let settled = false;
1681
- const result = new Promise((resolve, reject) => {
1682
- resolveResult = resolve;
1683
- rejectResult = reject;
1684
- });
1685
- const source = (async function* consume() {
1686
- let state = options.resume?.state ?? createVideoState();
1687
- try {
1688
- const fetcher = options.fetcher ?? fetch;
1689
- const headers = new Headers(options.headers);
1690
- headers.set("Accept", VIDEO_STREAM_CONTENT_TYPE);
1691
- headers.set("Content-Type", "application/json");
1692
- if (request.resume) headers.set("Last-Event-ID", `${request.resume.runId}:${request.resume.afterSequence}`);
1693
- const response = await fetcher(options.endpoint, {
1694
- method: "POST",
1695
- headers,
1696
- body: JSON.stringify(request),
1697
- signal: controller.signal,
1698
- credentials: options.credentials
1699
- });
1700
- if (!response.ok) throw await responseError(response, request.requestId);
1701
- if (!response.body || !response.headers.get("content-type")?.includes("text/event-stream")) {
1702
- throw new Error("Video response endpoint did not return an SSE stream");
1703
- }
1704
- const protocolVersion = response.headers.get(VIDEO_STREAM_HEADER);
1705
- if (protocolVersion == null) {
1706
- throw new Error(`Video response endpoint omitted protocol version header ${VIDEO_STREAM_HEADER}`);
1707
- }
1708
- if (protocolVersion !== VIDEO_PROTOCOL_VERSION) {
1709
- throw new Error(
1710
- `Video response protocol mismatch: expected ${VIDEO_PROTOCOL_VERSION}, received ${protocolVersion}`
1711
- );
1712
- }
1713
- for await (const event of decodeVideoSse(response.body)) {
1714
- state = applyVideoEvent(state, event);
1715
- options.onEvent?.(event, state);
1716
- if (state.status === "complete" && !settled) {
1717
- settled = true;
1718
- resolveResult(state);
1719
- } else if (state.status === "error" && !settled) {
1720
- settled = true;
1721
- const error = state.errors.at(-1);
1722
- rejectResult(new VideoError(error?.message ?? "Video response could not finish", {
1723
- code: error?.code ?? "generation_failed",
1724
- status: response.status,
1725
- requestId: state.requestId ?? request.requestId,
1726
- runId: state.runId,
1727
- recoverable: error?.recoverable
1728
- }));
1729
- } else if (state.status === "aborted" && !settled) {
1730
- settled = true;
1731
- rejectResult(new VideoError(state.abortReason ?? "Video generation was aborted", {
1732
- code: "aborted",
1733
- status: response.status,
1734
- requestId: state.requestId ?? request.requestId,
1735
- runId: state.runId,
1736
- recoverable: false
1737
- }));
1738
- }
1739
- yield event;
1740
- }
1741
- if (!["complete", "error", "aborted"].includes(state.status)) {
1742
- throw new Error("Video response stream ended without a terminal event");
1743
- }
1744
- if (!settled && state.status === "complete") {
1745
- settled = true;
1746
- resolveResult(state);
1644
+ // src/protocol/scene-timeline.ts
1645
+ function createQueue() {
1646
+ const buffered = [];
1647
+ const waiting = [];
1648
+ let closed = false;
1649
+ return {
1650
+ push(value) {
1651
+ const next = waiting.shift();
1652
+ if (next) next({ value, done: false });
1653
+ else buffered.push(value);
1654
+ },
1655
+ close() {
1656
+ closed = true;
1657
+ let next = waiting.shift();
1658
+ while (next) {
1659
+ next({ value: void 0, done: true });
1660
+ next = waiting.shift();
1747
1661
  }
1748
- } catch (cause) {
1749
- const error = normalizeStreamError(cause, request.requestId, state, controller.signal.aborted);
1750
- if (!settled) {
1751
- settled = true;
1752
- rejectResult(error);
1662
+ },
1663
+ iterable: {
1664
+ [Symbol.asyncIterator]() {
1665
+ return {
1666
+ next() {
1667
+ const buffer = buffered.shift();
1668
+ if (buffer !== void 0) return Promise.resolve({ value: buffer, done: false });
1669
+ if (closed) return Promise.resolve({ value: void 0, done: true });
1670
+ return new Promise((resolve) => waiting.push(resolve));
1671
+ }
1672
+ };
1753
1673
  }
1754
- throw error;
1755
- } finally {
1756
- options.signal?.removeEventListener("abort", forwardAbort);
1757
- }
1758
- })();
1759
- const stream = createReplayableStream(source, () => void 0);
1760
- return {
1761
- request,
1762
- stream,
1763
- result,
1764
- abort(reason = "user cancelled") {
1765
- controller.abort(new Error(reason));
1766
1674
  }
1767
1675
  };
1768
1676
  }
1769
-
1770
- // src/player/use-video.ts
1771
- function defaultRequestId() {
1772
- const id = globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
1773
- return `request-${id}`;
1774
- }
1775
- function errorFrom(cause) {
1776
- if (cause instanceof VideoError) return cause;
1777
- const error = cause instanceof Error ? cause : new Error(String(cause));
1778
- return new VideoError(error.message, { code: "video_failed" });
1779
- }
1780
- function useVideo(options = {}) {
1781
- const templates = useMemo2(
1782
- () => overlayPlayerTemplateRegistry(BUILTIN_PLAYER_KIT, options.templates),
1783
- [options.templates]
1784
- );
1785
- const [state, setState] = useState2(() => createVideoState());
1786
- const [error, setError] = useState2();
1787
- const [run, setRun] = useState2();
1788
- const activeRun = useRef2(void 0);
1789
- const generation = useRef2(0);
1790
- const mounted = useRef2(true);
1791
- const optionsRef = useRef2(options);
1792
- const templatesRef = useRef2(templates);
1793
- optionsRef.current = options;
1794
- templatesRef.current = templates;
1795
- const abort = useCallback((reason = "user cancelled") => {
1796
- generation.current += 1;
1797
- const current = activeRun.current;
1798
- activeRun.current = void 0;
1799
- current?.abort(reason);
1800
- if (current && mounted.current) {
1801
- setError(new VideoError(reason, {
1802
- code: "aborted",
1803
- requestId: current.request.requestId,
1804
- recoverable: false
1805
- }));
1806
- setState((previous) => ({ ...previous, status: "aborted", abortReason: reason }));
1677
+ var runCounter = 0;
1678
+ function createSceneTimeline(options) {
1679
+ const queue = createQueue();
1680
+ const runId = options.runId ?? `composed-${runCounter += 1}`;
1681
+ const events = createVideoEventFactory({ runId });
1682
+ const orientation = options.orientation ?? "landscape";
1683
+ const snapshot = {
1684
+ schemaVersion: VIDEO_SCHEMA_VERSION,
1685
+ orientation,
1686
+ scenes: [],
1687
+ style: options.style
1688
+ };
1689
+ let position = 0;
1690
+ let finished = false;
1691
+ const held = [];
1692
+ let openForScenes = !options.awaitAudio;
1693
+ const emit = (event) => {
1694
+ if (finished) return;
1695
+ queue.push(event);
1696
+ };
1697
+ emit(events.create("response.start", {
1698
+ requestId: options.requestId ?? runId,
1699
+ format: { orientation },
1700
+ style: options.style
1701
+ }));
1702
+ if (options.audio) {
1703
+ snapshot.audio = options.audio;
1704
+ emit(events.create("audio.set", { audio: options.audio }));
1705
+ }
1706
+ const release = () => {
1707
+ const pending = held.splice(0, held.length);
1708
+ for (const scene of pending) {
1709
+ snapshot.scenes.push(scene);
1710
+ emit(events.create("scene.add", { scene, position: position++ }));
1807
1711
  }
1808
- }, []);
1809
- const generate = useCallback((input) => {
1810
- const currentGeneration = generation.current + 1;
1811
- generation.current = currentGeneration;
1812
- activeRun.current?.abort("replaced by a new composition");
1813
- const currentOptions = optionsRef.current;
1814
- const initialState2 = createVideoState();
1815
- setState({ ...initialState2, status: "streaming" });
1816
- setError(void 0);
1817
- const nextRun = streamVideo({
1818
- endpoint: currentOptions.endpoint ?? "/api/video",
1819
- input,
1820
- requestId: (currentOptions.createRequestId ?? defaultRequestId)(),
1821
- capabilities: currentOptions.templateIds ? { ...templatesRef.current.capabilities, templates: [...new Set(currentOptions.templateIds)] } : templatesRef.current.capabilities,
1822
- headers: currentOptions.headers,
1823
- credentials: currentOptions.credentials,
1824
- fetcher: currentOptions.fetcher,
1825
- onEvent: (_event, nextState) => {
1826
- if (_event.type === "scene.add") {
1827
- if (!currentOptions.templates?.getTemplate(_event.data.scene.templateId)) {
1828
- preloadBuiltinTemplate(_event.data.scene.templateId);
1829
- }
1830
- warmSceneMedia(_event.data.scene.variables);
1831
- }
1832
- if (mounted.current && generation.current === currentGeneration) setState(nextState);
1712
+ };
1713
+ return {
1714
+ stream: queue.iterable,
1715
+ add(scene) {
1716
+ if (finished) return;
1717
+ if (!openForScenes) {
1718
+ held.push(scene);
1719
+ return;
1833
1720
  }
1834
- });
1835
- activeRun.current = nextRun;
1836
- setRun(nextRun);
1837
- void nextRun.result.then(() => {
1838
- if (generation.current === currentGeneration) activeRun.current = void 0;
1839
- }).catch((cause) => {
1840
- if (mounted.current && generation.current === currentGeneration) {
1841
- activeRun.current = void 0;
1842
- const error2 = errorFrom(cause);
1843
- setError(error2);
1844
- setState((previous) => ({
1845
- ...previous,
1846
- status: error2.code === "aborted" ? "aborted" : "error",
1847
- ...error2.code === "aborted" && previous.abortReason == null ? { abortReason: error2.message } : {}
1848
- }));
1721
+ snapshot.scenes.push(scene);
1722
+ emit(events.create("scene.add", { scene, position: position++ }));
1723
+ },
1724
+ setAudio(audio) {
1725
+ if (finished || openForScenes) return;
1726
+ if (audio) {
1727
+ snapshot.audio = audio;
1728
+ emit(events.create("audio.set", { audio }));
1849
1729
  }
1850
- });
1851
- const result = nextRun.result.then((nextState) => {
1852
- if (!nextState.config) throw new Error("Video generation ended without a video");
1853
- return nextState.config;
1854
- });
1855
- void result.catch(() => void 0);
1856
- return result;
1857
- }, []);
1858
- useEffect3(() => {
1859
- mounted.current = true;
1860
- return () => {
1861
- mounted.current = false;
1862
- generation.current += 1;
1863
- activeRun.current?.abort("component unmounted");
1864
- activeRun.current = void 0;
1865
- };
1866
- }, []);
1867
- return {
1868
- generate,
1869
- abort,
1870
- video: state.config,
1871
- status: state.status,
1872
- error,
1873
- warnings: state.warnings,
1874
- playerProps: {
1875
- ...options.templates ? { templates: options.templates } : {},
1876
- ...run ? { stream: run.stream } : {}
1730
+ openForScenes = true;
1731
+ release();
1732
+ },
1733
+ complete() {
1734
+ if (finished) return;
1735
+ if (!openForScenes) {
1736
+ openForScenes = true;
1737
+ release();
1738
+ }
1739
+ emit(events.create("response.complete", {
1740
+ finishReason: "stop",
1741
+ snapshot,
1742
+ checksum: checksumVideo(snapshot)
1743
+ }));
1744
+ finished = true;
1745
+ queue.close();
1877
1746
  }
1878
1747
  };
1879
1748
  }
1880
1749
 
1881
1750
  // src/player/use-narration.ts
1882
- import { useCallback as useCallback2, useEffect as useEffect4, useRef as useRef3, useState as useState3 } from "react";
1751
+ import { useCallback, useEffect as useEffect3, useRef as useRef2, useState as useState2 } from "react";
1883
1752
  function useNarration(options) {
1884
- const [speaking, setSpeaking] = useState3(false);
1885
- const optionsRef = useRef3(options);
1753
+ const [speaking, setSpeaking] = useState2(false);
1754
+ const optionsRef = useRef2(options);
1886
1755
  optionsRef.current = options;
1887
- const currentRef = useRef3(void 0);
1888
- const spokenIndexRef = useRef3(void 0);
1889
- const stop = useCallback2(() => {
1756
+ const currentRef = useRef2(void 0);
1757
+ const spokenIndexRef = useRef2(void 0);
1758
+ const stop = useCallback(() => {
1890
1759
  currentRef.current?.abort();
1891
1760
  currentRef.current = void 0;
1892
1761
  setSpeaking(false);
1893
1762
  }, []);
1894
- const interrupt = useCallback2(() => {
1763
+ const interrupt = useCallback(() => {
1895
1764
  spokenIndexRef.current = void 0;
1896
1765
  stop();
1897
1766
  }, [stop]);
1898
- useEffect4(() => () => currentRef.current?.abort(), []);
1899
- const onSceneChange = useCallback2((scene, index) => {
1767
+ useEffect3(() => () => currentRef.current?.abort(), []);
1768
+ const onSceneChange = useCallback((scene, index) => {
1900
1769
  const { voice, enabled = true } = optionsRef.current;
1901
1770
  if (!enabled) return;
1902
1771
  if (spokenIndexRef.current === index) return;
@@ -1909,7 +1778,18 @@ function useNarration(options) {
1909
1778
  setSpeaking(true);
1910
1779
  void (async () => {
1911
1780
  try {
1912
- await voice.speak(line, { signal: controller.signal });
1781
+ let started = false;
1782
+ await voice.speak(line, {
1783
+ signal: controller.signal,
1784
+ onStart: (source) => {
1785
+ if (started || controller.signal.aborted || currentRef.current !== controller || spokenIndexRef.current !== index || optionsRef.current.enabled === false) return;
1786
+ started = true;
1787
+ try {
1788
+ void Promise.resolve(optionsRef.current.onSpeechStart?.(source)).catch(() => void 0);
1789
+ } catch {
1790
+ }
1791
+ }
1792
+ });
1913
1793
  } catch {
1914
1794
  } finally {
1915
1795
  if (currentRef.current === controller) {
@@ -1922,11 +1802,9 @@ function useNarration(options) {
1922
1802
  return { onSceneChange, interrupt, speaking };
1923
1803
  }
1924
1804
 
1925
- // src/video-chat/use-video-chat.ts
1926
- import { useCallback as useCallback3, useEffect as useEffect5, useReducer, useRef as useRef4 } from "react";
1927
-
1928
1805
  // src/video-chat/voice.ts
1929
1806
  var DEFAULT_MAX_CACHED_LINES = 60;
1807
+ var SPEECH_PREPARATION_TIMEOUT_MS = 3e3;
1930
1808
  var FALLBACK_BITS_PER_SECOND = 128e3;
1931
1809
  var sharedContext;
1932
1810
  function actionEndpoint(endpoint, action) {
@@ -1959,6 +1837,13 @@ function createVideoChatVoice(options = {}) {
1959
1837
  let silent = false;
1960
1838
  let disposed = false;
1961
1839
  let generatedSpeechUnavailable = false;
1840
+ let playbackFailure;
1841
+ const notifyFallback = () => {
1842
+ try {
1843
+ void Promise.resolve(options.onFallback?.()).catch(() => void 0);
1844
+ } catch {
1845
+ }
1846
+ };
1962
1847
  const forgetOldest = () => {
1963
1848
  while (lines.size > maximum) {
1964
1849
  const oldest = lines.keys().next();
@@ -1991,38 +1876,34 @@ function createVideoChatVoice(options = {}) {
1991
1876
  if (generatedSpeechUnavailable) {
1992
1877
  prepared = { source: "browser", seconds: estimatedBrowserSeconds(normalized) };
1993
1878
  } else {
1994
- const response = await fetcher(actionEndpoint(endpoint, "speech"), {
1995
- method: "POST",
1996
- headers,
1997
- credentials: options.credentials,
1998
- signal: controller.signal,
1999
- body: JSON.stringify({ text: normalized })
2000
- });
2001
- if (controller.signal.aborted) {
2002
- throw controller.signal.reason ?? new DOMException("Speech preparation cancelled", "AbortError");
2003
- }
2004
- if (response.status === 204 || response.status === 404) {
2005
- generatedSpeechUnavailable = true;
2006
- prepared = { source: "browser", seconds: estimatedBrowserSeconds(normalized) };
2007
- } else if (!response.ok) {
2008
- prepared = { source: "browser", seconds: estimatedBrowserSeconds(normalized) };
2009
- } else {
2010
- const bytes = await response.arrayBuffer();
2011
- if (controller.signal.aborted) {
2012
- throw controller.signal.reason ?? new DOMException("Speech preparation cancelled", "AbortError");
1879
+ prepared = await withDeadline(async (preparationSignal) => {
1880
+ const response = await fetcher(actionEndpoint(endpoint, "speech"), {
1881
+ method: "POST",
1882
+ headers,
1883
+ credentials: options.credentials,
1884
+ signal: preparationSignal,
1885
+ body: JSON.stringify({ text: normalized })
1886
+ });
1887
+ preparationSignal.throwIfAborted();
1888
+ if (response.status === 204 || response.status === 404) {
1889
+ generatedSpeechUnavailable = true;
1890
+ return { source: "browser", seconds: estimatedBrowserSeconds(normalized) };
2013
1891
  }
1892
+ if (!response.ok) throw new Error("Speech is unavailable");
1893
+ const bytes = await response.arrayBuffer();
1894
+ preparationSignal.throwIfAborted();
1895
+ const seconds = await measureSeconds(bytes);
1896
+ preparationSignal.throwIfAborted();
2014
1897
  createdSrc = URL.createObjectURL(new Blob([bytes], {
2015
1898
  type: response.headers.get("content-type") || "audio/mpeg"
2016
1899
  }));
2017
- prepared = { source: "generated", src: createdSrc, seconds: await measureSeconds(bytes) };
2018
- if (controller.signal.aborted) {
2019
- throw controller.signal.reason ?? new DOMException("Speech preparation cancelled", "AbortError");
2020
- }
2021
- }
1900
+ return { source: "generated", src: createdSrc, seconds };
1901
+ }, SPEECH_PREPARATION_TIMEOUT_MS, controller.signal);
2022
1902
  }
2023
1903
  } catch (cause) {
2024
1904
  if (createdSrc) URL.revokeObjectURL(createdSrc);
2025
1905
  if (controller.signal.aborted) throw controller.signal.reason ?? cause;
1906
+ notifyFallback();
2026
1907
  prepared = { source: "browser", seconds: estimatedBrowserSeconds(normalized) };
2027
1908
  }
2028
1909
  if (disposed || controller.signal.aborted) {
@@ -2044,6 +1925,19 @@ function createVideoChatVoice(options = {}) {
2044
1925
  signal?.removeEventListener("abort", forwardAbort);
2045
1926
  }
2046
1927
  };
1928
+ const speechStops = /* @__PURE__ */ new Set();
1929
+ const watchSpeech = (seconds, expire) => {
1930
+ let remaining = Math.max(1, seconds) * 2e3 + 5e3;
1931
+ const timer = setInterval(() => {
1932
+ if (held) return;
1933
+ remaining -= 250;
1934
+ if (remaining <= 0) {
1935
+ clearInterval(timer);
1936
+ expire();
1937
+ }
1938
+ }, 250);
1939
+ return () => clearInterval(timer);
1940
+ };
2047
1941
  const stopBrowser = () => {
2048
1942
  globalThis.speechSynthesis?.cancel();
2049
1943
  browserFinish?.();
@@ -2060,7 +1954,7 @@ function createVideoChatVoice(options = {}) {
2060
1954
  },
2061
1955
  resume() {
2062
1956
  held = false;
2063
- if (sounding && !silent) void sounding.play().catch(() => void 0);
1957
+ if (sounding && !silent) void sounding.play().catch(() => playbackFailure?.());
2064
1958
  if (!silent) globalThis.speechSynthesis?.resume();
2065
1959
  },
2066
1960
  setMuted(muted) {
@@ -2068,7 +1962,16 @@ function createVideoChatVoice(options = {}) {
2068
1962
  if (sounding) sounding.muted = muted;
2069
1963
  if (muted) stopBrowser();
2070
1964
  },
2071
- async speak(text, { signal }) {
1965
+ async speak(text, { signal, onStart }) {
1966
+ let started = false;
1967
+ const notifyStart = (source) => {
1968
+ if (started || disposed || signal.aborted || silent || held) return;
1969
+ started = true;
1970
+ try {
1971
+ void Promise.resolve(onStart?.(source)).catch(() => void 0);
1972
+ } catch {
1973
+ }
1974
+ };
2072
1975
  const line = await load(text, signal);
2073
1976
  if (disposed || signal.aborted || silent) return;
2074
1977
  if (line.source === "browser") {
@@ -2081,40 +1984,91 @@ function createVideoChatVoice(options = {}) {
2081
1984
  const finish = () => {
2082
1985
  if (finished) return;
2083
1986
  finished = true;
1987
+ clearWatchdog();
1988
+ signal.removeEventListener("abort", stop);
1989
+ speechStops.delete(stop);
1990
+ utterance.onstart = null;
1991
+ utterance.onend = null;
1992
+ utterance.onerror = null;
2084
1993
  if (browserFinish === finish) browserFinish = void 0;
2085
1994
  resolve();
2086
1995
  };
1996
+ const stop = () => {
1997
+ synthesis.cancel();
1998
+ finish();
1999
+ };
2000
+ const clearWatchdog = watchSpeech(estimatedBrowserSeconds(text), stop);
2001
+ speechStops.add(stop);
2087
2002
  browserFinish = finish;
2003
+ utterance.onstart = () => {
2004
+ if (!finished) notifyStart("browser");
2005
+ };
2088
2006
  utterance.onend = finish;
2089
2007
  utterance.onerror = finish;
2090
- signal.addEventListener("abort", () => {
2091
- synthesis.cancel();
2008
+ signal.addEventListener("abort", stop, { once: true });
2009
+ try {
2010
+ synthesis.speak(utterance);
2011
+ if (held) synthesis.pause();
2012
+ } catch {
2092
2013
  finish();
2093
- }, { once: true });
2094
- synthesis.speak(utterance);
2095
- if (held) synthesis.pause();
2014
+ }
2096
2015
  });
2097
2016
  return;
2098
2017
  }
2099
- const element = new Audio(line.src);
2100
- element.muted = silent;
2101
- sounding = element;
2102
- await new Promise((resolve) => {
2103
- let finished = false;
2104
- const finish = () => {
2105
- if (finished) return;
2106
- finished = true;
2107
- if (sounding === element) sounding = void 0;
2108
- resolve();
2109
- };
2110
- element.onended = finish;
2111
- element.onerror = finish;
2112
- signal.addEventListener("abort", () => {
2113
- element.pause();
2114
- finish();
2115
- }, { once: true });
2116
- if (!held) void element.play().catch(finish);
2117
- });
2018
+ let playbackFailed = false;
2019
+ try {
2020
+ const element = new Audio(line.src);
2021
+ element.muted = silent;
2022
+ sounding = element;
2023
+ await new Promise((resolve) => {
2024
+ let finished = false;
2025
+ const finish = () => {
2026
+ if (finished) return;
2027
+ finished = true;
2028
+ clearWatchdog();
2029
+ signal.removeEventListener("abort", stop);
2030
+ speechStops.delete(stop);
2031
+ element.onplaying = null;
2032
+ element.onended = null;
2033
+ element.onerror = null;
2034
+ if (sounding === element) {
2035
+ sounding = void 0;
2036
+ playbackFailure = void 0;
2037
+ }
2038
+ resolve();
2039
+ };
2040
+ const stop = () => {
2041
+ element.pause();
2042
+ finish();
2043
+ };
2044
+ const fail = () => {
2045
+ playbackFailed = true;
2046
+ stop();
2047
+ };
2048
+ const clearWatchdog = watchSpeech(Math.max(line.seconds, estimatedBrowserSeconds(text)), fail);
2049
+ speechStops.add(stop);
2050
+ playbackFailure = fail;
2051
+ element.onplaying = () => {
2052
+ if (!finished) notifyStart("generated");
2053
+ };
2054
+ element.onended = finish;
2055
+ element.onerror = fail;
2056
+ signal.addEventListener("abort", stop, { once: true });
2057
+ try {
2058
+ if (!held) void element.play().catch(fail);
2059
+ } catch {
2060
+ fail();
2061
+ }
2062
+ });
2063
+ } catch {
2064
+ playbackFailed = true;
2065
+ }
2066
+ if (playbackFailed && !disposed && !signal.aborted && !silent) {
2067
+ notifyFallback();
2068
+ URL.revokeObjectURL(line.src);
2069
+ lines.set(text.trim(), { source: "browser", seconds: estimatedBrowserSeconds(text) });
2070
+ await this.speak(text, { signal, onStart: notifyStart });
2071
+ }
2118
2072
  },
2119
2073
  dispose() {
2120
2074
  disposed = true;
@@ -2122,6 +2076,8 @@ function createVideoChatVoice(options = {}) {
2122
2076
  controller.abort(new DOMException("Video chat voice was disposed", "AbortError"));
2123
2077
  }
2124
2078
  pendingLoads.clear();
2079
+ for (const stop of speechStops) stop();
2080
+ speechStops.clear();
2125
2081
  sounding?.pause();
2126
2082
  sounding = void 0;
2127
2083
  stopBrowser();
@@ -2135,6 +2091,12 @@ function createVideoChatVoice(options = {}) {
2135
2091
 
2136
2092
  // src/video-chat/use-video-chat.ts
2137
2093
  var DEFAULT_TIMEOUT_MS = 18e4;
2094
+ function observe(callback) {
2095
+ try {
2096
+ void Promise.resolve(callback?.()).catch(() => void 0);
2097
+ } catch {
2098
+ }
2099
+ }
2138
2100
  function initialState(muted) {
2139
2101
  return {
2140
2102
  turns: [],
@@ -2160,6 +2122,7 @@ function reducer(state, action) {
2160
2122
  return {
2161
2123
  ...state,
2162
2124
  turns: [...state.turns, action.turn],
2125
+ playerKey: state.playerKey + 1,
2163
2126
  shownTurnId: action.turn.id,
2164
2127
  status: "composing",
2165
2128
  resumeStatus: "composing",
@@ -2219,6 +2182,8 @@ function reducer(state, action) {
2219
2182
  suggestions: action.suggestions
2220
2183
  }))
2221
2184
  };
2185
+ case "suggestions":
2186
+ return { ...state, turns: replaceTurn(state.turns, action.id, (turn) => ({ ...turn, suggestions: action.suggestions })) };
2222
2187
  case "scene":
2223
2188
  if (state.playerKey !== action.key) return state;
2224
2189
  return {
@@ -2229,6 +2194,11 @@ function reducer(state, action) {
2229
2194
  case "playback-end":
2230
2195
  if (state.playerKey !== action.key) return state;
2231
2196
  return { ...state, status: "ended", resumeStatus: "ended", playbackEnded: true, openingSpeaking: false };
2197
+ case "warning":
2198
+ return { ...state, turns: replaceTurn(state.turns, action.id, (turn) => ({
2199
+ ...turn,
2200
+ warnings: [.../* @__PURE__ */ new Set([...turn.warnings ?? [], action.message])]
2201
+ })) };
2232
2202
  case "error":
2233
2203
  if (state.turns.at(-1)?.id !== action.id) return state;
2234
2204
  return {
@@ -2285,6 +2255,17 @@ function reducer(state, action) {
2285
2255
  error: void 0
2286
2256
  };
2287
2257
  }
2258
+ case "restore": {
2259
+ const restored = {
2260
+ ...initialState(state.muted),
2261
+ capabilities: state.capabilities,
2262
+ welcome: state.welcome,
2263
+ turns: action.turns,
2264
+ playerKey: state.playerKey
2265
+ };
2266
+ const latest = action.turns.at(-1);
2267
+ return latest ? reducer(restored, { type: "select", id: latest.id }) : restored;
2268
+ }
2288
2269
  case "reset":
2289
2270
  return { ...initialState(state.muted), capabilities: state.capabilities, welcome: state.welcome };
2290
2271
  }
@@ -2311,28 +2292,35 @@ function conversationFor(turns) {
2311
2292
  response: [...transcriptFor(turn).join(" ")].slice(0, 8e3).join("")
2312
2293
  }));
2313
2294
  }
2314
- function errorFrom2(cause) {
2295
+ async function untilAborted(work, signal) {
2296
+ let abort = () => void 0;
2297
+ const cancelled = new Promise((_resolve, reject) => {
2298
+ abort = () => reject(signal.reason ?? new DOMException("Cancelled", "AbortError"));
2299
+ if (signal.aborted) abort();
2300
+ else signal.addEventListener("abort", abort, { once: true });
2301
+ });
2302
+ try {
2303
+ return await Promise.race([work, cancelled]);
2304
+ } finally {
2305
+ signal.removeEventListener("abort", abort);
2306
+ }
2307
+ }
2308
+ function errorFrom(cause) {
2315
2309
  if (cause instanceof VideoError) return cause;
2316
2310
  if (cause instanceof DOMException && cause.name === "AbortError") {
2317
2311
  return new VideoError("Video chat was cancelled", { code: "aborted", recoverable: false });
2318
2312
  }
2319
- return new VideoError(safePublicDiagnostic(
2320
- cause instanceof Error ? cause.message : String(cause),
2321
- "Video chat failed"
2322
- ), { code: "video_chat_failed", recoverable: false });
2323
- }
2324
- async function responseError2(response) {
2325
- let code = "http_error";
2326
- let message = `Video chat endpoint failed (${response.status})`;
2327
- try {
2328
- const body = await response.json();
2329
- if (typeof body.error?.code === "string") code = body.error.code.slice(0, 80);
2330
- if (typeof body.error?.message === "string") {
2331
- message = safePublicDiagnostic(body.error.message, message);
2332
- }
2333
- } catch {
2313
+ if (cause instanceof DOMException && cause.name === "TimeoutError") {
2314
+ return new VideoError("Video chat timed out", { code: "timeout", recoverable: false });
2334
2315
  }
2335
- return new VideoError(message, { code, status: response.status, recoverable: false });
2316
+ return new VideoError("Video chat could not produce a playable response", { code: "video_chat_failed", recoverable: false });
2317
+ }
2318
+ async function responseError(response) {
2319
+ return new VideoError("Video chat could not produce a playable response", {
2320
+ code: "http_error",
2321
+ status: response.status,
2322
+ recoverable: false
2323
+ });
2336
2324
  }
2337
2325
  function pacedScene(scene, spokenSeconds, templates) {
2338
2326
  const held = spokenSeconds && spokenSeconds > 0 ? spokenSeconds + 0.8 : getSceneDuration(scene, templates?.getTemplateMetadata(scene.templateId) ?? getBuiltinTemplateMetadata(scene.templateId));
@@ -2361,35 +2349,79 @@ function prepareVisualScene(scene, mode) {
2361
2349
  return overMedia ? { ...shown, textArchetype: "subtle" } : shown;
2362
2350
  }
2363
2351
  function useVideoChat(options = {}) {
2364
- const optionsRef = useRef4(options);
2352
+ return useVideoChatSession(options).chat;
2353
+ }
2354
+ function useVideoChatSession(options = {}) {
2355
+ const optionsRef = useRef3(options);
2365
2356
  optionsRef.current = options;
2366
- const ownedVoiceRef = useRef4(void 0);
2357
+ const voiceWarningRef = useRef3(() => void 0);
2358
+ const ownedVoiceRef = useRef3(void 0);
2367
2359
  if (!options.voice && !ownedVoiceRef.current) {
2368
2360
  ownedVoiceRef.current = createVideoChatVoice({
2369
2361
  endpoint: options.endpoint,
2370
2362
  headers: options.headers,
2371
2363
  credentials: options.credentials,
2372
- fetcher: options.fetcher
2364
+ fetcher: options.fetcher,
2365
+ onFallback: () => voiceWarningRef.current()
2373
2366
  });
2374
2367
  }
2375
2368
  const voice = options.voice ?? ownedVoiceRef.current;
2376
- const voiceRef = useRef4(voice);
2369
+ const voiceRef = useRef3(voice);
2377
2370
  voiceRef.current = voice;
2378
- const narration = useNarration({ voice });
2379
- const narrationRef = useRef4(narration);
2371
+ const unavailableVoiceLines = useRef3(/* @__PURE__ */ new Set());
2372
+ const speechStartRef = useRef3(() => void 0);
2373
+ const narration = useNarration({ onSpeechStart: (source) => speechStartRef.current(source), voice: {
2374
+ speak: (text, options2) => unavailableVoiceLines.current.has(text) ? void 0 : voice.speak(text, options2)
2375
+ } });
2376
+ const narrationRef = useRef3(narration);
2380
2377
  narrationRef.current = narration;
2381
2378
  const [state, dispatch] = useReducer(reducer, options.initialMuted ?? false, initialState);
2382
- const stateRef = useRef4(state);
2379
+ const stateRef = useRef3(state);
2383
2380
  stateRef.current = state;
2384
- const runRef = useRef4(0);
2385
- const inFlightRef = useRef4(void 0);
2386
- const openingRef = useRef4(void 0);
2387
- const timelineRef = useRef4(void 0);
2388
- const heldRef = useRef4(false);
2389
- const flushRef = useRef4(void 0);
2390
- const mountedRef = useRef4(true);
2391
- const firstFrameRef = useRef4(void 0);
2392
- const request = useCallback3(async (action, init = {}, signal) => {
2381
+ const runRef = useRef3(0);
2382
+ const inFlightRef = useRef3(void 0);
2383
+ const suggestionsRef = useRef3(void 0);
2384
+ const openingRef = useRef3(void 0);
2385
+ const timelineRef = useRef3(void 0);
2386
+ const heldRef = useRef3(false);
2387
+ const flushRef = useRef3(void 0);
2388
+ const mountedRef = useRef3(true);
2389
+ const firstFrameRef = useRef3(void 0);
2390
+ const reportMetric = useCallback2((metric) => {
2391
+ observe(() => optionsRef.current.onPlaybackMetric?.(metric));
2392
+ }, []);
2393
+ const finishStall = useCallback2(() => {
2394
+ const timing = firstFrameRef.current;
2395
+ if (!timing?.active || timing.stallStartedAt == null) return;
2396
+ const now = monotonicNow();
2397
+ const started = timing.stallStartedAt;
2398
+ timing.stallStartedAt = void 0;
2399
+ reportMetric({
2400
+ type: "stall",
2401
+ turnId: timing.turnId,
2402
+ mode: timing.mode,
2403
+ elapsedMs: Math.max(0, Math.round(now - timing.startedAt)),
2404
+ durationMs: Math.max(0, Math.round(now - started)),
2405
+ reason: "scene-generation"
2406
+ });
2407
+ }, [reportMetric]);
2408
+ const endTiming = useCallback2(() => {
2409
+ finishStall();
2410
+ if (firstFrameRef.current) firstFrameRef.current.active = false;
2411
+ }, [finishStall]);
2412
+ speechStartRef.current = (source) => {
2413
+ const timing = firstFrameRef.current;
2414
+ if (!mountedRef.current || !timing?.active || timing.speechReported || heldRef.current || stateRef.current.muted) return;
2415
+ timing.speechReported = true;
2416
+ reportMetric({
2417
+ type: "first-speech",
2418
+ turnId: timing.turnId,
2419
+ mode: timing.mode,
2420
+ elapsedMs: Math.max(0, Math.round(monotonicNow() - timing.startedAt)),
2421
+ source: optionsRef.current.voice ? "custom" : source === "generated" ? "generated" : "browser"
2422
+ });
2423
+ };
2424
+ const request = useCallback2(async (action, init = {}, signal) => {
2393
2425
  const current = optionsRef.current;
2394
2426
  const headers = new Headers(current.headers);
2395
2427
  new Headers(init.headers).forEach((header, name) => headers.set(name, header));
@@ -2403,27 +2435,29 @@ function useVideoChat(options = {}) {
2403
2435
  signal
2404
2436
  });
2405
2437
  }, []);
2406
- useEffect5(() => {
2438
+ useEffect4(() => {
2407
2439
  mountedRef.current = true;
2408
2440
  const controller = new AbortController();
2409
2441
  void request("capabilities", {}, controller.signal).then(async (response) => {
2410
- if (!response.ok) throw await responseError2(response);
2442
+ if (!response.ok) throw await responseError(response);
2411
2443
  return response.json();
2412
2444
  }).then((value) => {
2413
2445
  if (mountedRef.current) dispatch({ type: "capabilities", value });
2414
2446
  }).catch(() => void 0);
2415
2447
  void request("welcome", {}, controller.signal).then(async (response) => {
2416
- if (!response.ok) throw await responseError2(response);
2448
+ if (!response.ok) throw await responseError(response);
2417
2449
  return response.json();
2418
2450
  }).then((value) => {
2419
2451
  if (mountedRef.current) dispatch({ type: "welcome", value });
2420
2452
  }).catch(() => void 0);
2421
2453
  return () => {
2454
+ endTiming();
2422
2455
  mountedRef.current = false;
2423
2456
  controller.abort();
2424
2457
  runRef.current += 1;
2425
2458
  inFlightRef.current?.abort(new DOMException("Component unmounted", "AbortError"));
2426
2459
  openingRef.current?.abort(new DOMException("Component unmounted", "AbortError"));
2460
+ suggestionsRef.current?.abort();
2427
2461
  timelineRef.current?.complete();
2428
2462
  timelineRef.current = void 0;
2429
2463
  narrationRef.current.interrupt();
@@ -2431,12 +2465,14 @@ function useVideoChat(options = {}) {
2431
2465
  if (!mountedRef.current) ownedVoiceRef.current?.dispose?.();
2432
2466
  });
2433
2467
  };
2434
- }, [request]);
2435
- useEffect5(() => voice.setMuted(state.muted), [state.muted, voice]);
2436
- const cancel = useCallback3((reason = "Video chat was cancelled") => {
2468
+ }, [request, endTiming]);
2469
+ useEffect4(() => voice.setMuted(state.muted), [state.muted, voice]);
2470
+ const cancel = useCallback2((reason = "Video chat was cancelled") => {
2471
+ endTiming();
2437
2472
  runRef.current += 1;
2438
2473
  inFlightRef.current?.abort(new DOMException(reason, "AbortError"));
2439
2474
  openingRef.current?.abort(new DOMException(reason, "AbortError"));
2475
+ suggestionsRef.current?.abort();
2440
2476
  inFlightRef.current = void 0;
2441
2477
  openingRef.current = void 0;
2442
2478
  timelineRef.current?.complete();
@@ -2444,16 +2480,19 @@ function useVideoChat(options = {}) {
2444
2480
  flushRef.current = void 0;
2445
2481
  narrationRef.current.interrupt();
2446
2482
  dispatch({ type: "cancelled" });
2447
- }, []);
2448
- const ask = useCallback3(async (value, askOptions = {}) => {
2483
+ }, [endTiming]);
2484
+ const ask = useCallback2(async (value, askOptions = {}) => {
2449
2485
  const prompt = value.trim();
2450
2486
  if (!prompt) return void 0;
2487
+ endTiming();
2451
2488
  inFlightRef.current?.abort(new DOMException("Replaced by a new prompt", "AbortError"));
2452
2489
  openingRef.current?.abort(new DOMException("Replaced by a new prompt", "AbortError"));
2490
+ suggestionsRef.current?.abort();
2453
2491
  timelineRef.current?.complete();
2454
2492
  timelineRef.current = void 0;
2455
2493
  narrationRef.current.interrupt();
2456
2494
  voiceRef.current.resume();
2495
+ unavailableVoiceLines.current.clear();
2457
2496
  heldRef.current = false;
2458
2497
  const controller = new AbortController();
2459
2498
  const openingController = new AbortController();
@@ -2488,7 +2527,7 @@ function useVideoChat(options = {}) {
2488
2527
  suggestions: [],
2489
2528
  ...openingMedia ? { openingMedia } : {}
2490
2529
  };
2491
- firstFrameRef.current = { turnId: id, mode, startedAt: monotonicNow(), reported: false };
2530
+ firstFrameRef.current = { turnId: id, mode, startedAt: monotonicNow(), reported: false, speechReported: false, active: true };
2492
2531
  dispatch({ type: "start", turn });
2493
2532
  let timeline;
2494
2533
  let style;
@@ -2500,9 +2539,14 @@ function useVideoChat(options = {}) {
2500
2539
  let terminal = false;
2501
2540
  let attempt = 0;
2502
2541
  let ready = [];
2542
+ let received = [];
2503
2543
  let appended = 0;
2504
- const isCurrent = () => mountedRef.current && runRef.current === run && !controller.signal.aborted;
2544
+ const isCurrent = () => mountedRef.current && runRef.current === run && !controller.signal.aborted && !terminal;
2505
2545
  const isOpeningCurrent = () => isCurrent() && !terminal && openingRef.current === openingController && !openingController.signal.aborted;
2546
+ const warn = (message) => {
2547
+ if (isCurrent()) dispatch({ type: "warning", id, message });
2548
+ };
2549
+ voiceWarningRef.current = () => warn("Using browser voice for this response.");
2506
2550
  const flush = () => {
2507
2551
  if (!isCurrent()) return;
2508
2552
  let available = appended;
@@ -2536,11 +2580,11 @@ function useVideoChat(options = {}) {
2536
2580
  }
2537
2581
  };
2538
2582
  flushRef.current = flush;
2539
- const resolveOpeningMedia = async (keyword) => {
2583
+ const resolveOpeningMedia = async (keyword, fallbackKeyword) => {
2540
2584
  try {
2541
2585
  const response = await request("opening-media", {
2542
2586
  method: "POST",
2543
- body: JSON.stringify({ keyword, orientation })
2587
+ body: JSON.stringify({ keyword, ...fallbackKeyword ? { fallbackKeyword } : {}, orientation })
2544
2588
  }, openingController.signal);
2545
2589
  if (!response.ok || !isOpeningCurrent() || timeline) return;
2546
2590
  const payload = await response.json();
@@ -2549,13 +2593,25 @@ function useVideoChat(options = {}) {
2549
2593
  } catch {
2550
2594
  }
2551
2595
  };
2596
+ const prepareSpeech = async (text, signal) => {
2597
+ try {
2598
+ return currentOptions.voice ? await withDeadline((child) => voiceRef.current.prepare(text, { signal: child }), 3e3, signal) : await voiceRef.current.prepare(text, { signal });
2599
+ } catch (cause) {
2600
+ if (currentOptions.voice && !signal.aborted && isCurrent()) unavailableVoiceLines.current.add(text);
2601
+ throw cause;
2602
+ }
2603
+ };
2552
2604
  const speakOpening = async (text) => {
2553
2605
  try {
2554
- await voiceRef.current.prepare(text, { signal: openingController.signal });
2555
2606
  if (!isOpeningCurrent()) return;
2556
2607
  dispatch({ type: "opening-start", id, line: text });
2557
- await voiceRef.current.speak(text, { signal: openingController.signal });
2608
+ await prepareSpeech(text, openingController.signal);
2609
+ if (!isOpeningCurrent()) return;
2610
+ await voiceRef.current.speak(text, { signal: openingController.signal, onStart: (source) => {
2611
+ if (isOpeningCurrent()) speechStartRef.current(source);
2612
+ } });
2558
2613
  } catch {
2614
+ if (isOpeningCurrent()) warn("Some narration is unavailable; the response will continue.");
2559
2615
  } finally {
2560
2616
  openingActive = false;
2561
2617
  if (isOpeningCurrent()) dispatch({ type: "opening-end", id });
@@ -2581,7 +2637,7 @@ function useVideoChat(options = {}) {
2581
2637
  ...currentOptions.style ? { style: currentOptions.style } : {}
2582
2638
  })
2583
2639
  }, controller.signal);
2584
- if (!response.ok) throw await responseError2(response);
2640
+ if (!response.ok) throw await responseError(response);
2585
2641
  if (!response.body || !response.headers.get("content-type")?.includes("text/event-stream")) {
2586
2642
  throw new VideoError("Video chat endpoint did not return a video stream", { code: "invalid_response" });
2587
2643
  }
@@ -2590,83 +2646,100 @@ function useVideoChat(options = {}) {
2590
2646
  const pending = [];
2591
2647
  let narrating = Promise.resolve();
2592
2648
  let terminalError;
2593
- for await (const event of decodeVideoSse(response.body)) {
2594
- if (!isCurrent() || currentAttempt !== attempt) return { video: { schemaVersion: "0.1", orientation, scenes: [], style }, suggestions: [] };
2595
- if (event.type === "response.start") style = event.data.style;
2596
- if (event.type === "data.video-chat-opening") {
2597
- const payload = event.data && typeof event.data === "object" && !Array.isArray(event.data) ? event.data : {};
2598
- const line = typeof payload.line === "string" ? payload.line.trim().slice(0, 300) : "";
2599
- const keyword = typeof payload.keyword === "string" ? payload.keyword.trim().slice(0, 80) : "";
2600
- if (!openingMedia && keyword) void resolveOpeningMedia(keyword);
2601
- if (!openingRequested && line) {
2602
- spokenHook = line;
2603
- lines.push(line);
2604
- openingRequested = true;
2605
- openingActive = true;
2606
- void speakOpening(line);
2649
+ try {
2650
+ for await (const event of decodeVideoSse(response.body)) {
2651
+ if (!isCurrent() || currentAttempt !== attempt) return { video: { schemaVersion: "0.1", orientation, scenes: [], style }, lines: [] };
2652
+ if (event.type === "response.start") style = event.data.style;
2653
+ if (event.type === "response.warning" || event.type === "response.error" && !event.data.terminal) {
2654
+ warn("Some parts were simplified so the response could continue.");
2607
2655
  }
2608
- continue;
2609
- }
2610
- if (event.type === "response.error" && event.data.terminal) {
2611
- terminalError = new VideoError(event.data.error.message, {
2612
- code: event.data.error.code,
2613
- requestId: event.data.snapshot ? void 0 : id,
2614
- recoverable: event.data.error.recoverable
2656
+ if (event.type === "data.video-chat-opening") {
2657
+ const payload = event.data && typeof event.data === "object" && !Array.isArray(event.data) ? event.data : {};
2658
+ const line = typeof payload.line === "string" ? payload.line.trim().slice(0, 300) : "";
2659
+ const keyword = typeof payload.keyword === "string" ? payload.keyword.trim().slice(0, 80) : "";
2660
+ const fallbackKeyword = typeof payload.fallbackKeyword === "string" ? payload.fallbackKeyword.trim().slice(0, 80) : void 0;
2661
+ if (!openingMedia && keyword) void resolveOpeningMedia(keyword, fallbackKeyword);
2662
+ if (!openingRequested && line) {
2663
+ spokenHook = line;
2664
+ lines.push(line);
2665
+ openingRequested = true;
2666
+ openingActive = true;
2667
+ void speakOpening(line);
2668
+ }
2669
+ continue;
2670
+ }
2671
+ if (event.type === "response.error" && event.data.terminal) {
2672
+ terminalError = new VideoError("Video chat could not finish this response", {
2673
+ code: event.data.error.code,
2674
+ requestId: event.data.snapshot ? void 0 : id,
2675
+ recoverable: event.data.error.recoverable
2676
+ });
2677
+ }
2678
+ if (event.type === "response.abort") {
2679
+ terminalError = new VideoError("Video chat was interrupted", { code: "aborted", recoverable: false });
2680
+ }
2681
+ if (event.type !== "scene.add") continue;
2682
+ const position = event.data.position;
2683
+ const plannedScene = event.data.scene;
2684
+ planned[position] = plannedScene;
2685
+ received[position] = prepareVisualScene(plannedScene, mode);
2686
+ if (!currentOptions.templates?.getTemplate(plannedScene.templateId)) {
2687
+ preloadBuiltinTemplate(plannedScene.templateId);
2688
+ }
2689
+ warmSceneMedia(plannedScene.variables);
2690
+ const narrated = narrating.then(async () => {
2691
+ const supplied = plannedScene.narration?.trim();
2692
+ if (supplied) return supplied;
2693
+ return withDeadline(async (signal) => {
2694
+ const narrationResponse = await request("narration", {
2695
+ method: "POST",
2696
+ body: JSON.stringify({ prompt, scene: plannedScene, earlier: [...lines] })
2697
+ }, signal);
2698
+ if (!narrationResponse.ok) throw new Error("Narration unavailable");
2699
+ const payload = await narrationResponse.json();
2700
+ const line = typeof payload.line === "string" ? payload.line.trim() : "";
2701
+ if (!line) throw new Error("Narration unavailable");
2702
+ return line;
2703
+ }, 3e3, controller.signal);
2704
+ }).catch((cause) => {
2705
+ if (controller.signal.aborted) throw cause;
2706
+ warn("Some narration was simplified so the response could continue.");
2707
+ return Object.values(plannedScene.variables).filter((value2) => typeof value2 === "string" && !/^https?:/i.test(value2)).join(" ").slice(0, 500);
2708
+ }).then((line) => {
2709
+ if (line) lines.push(line);
2710
+ return line;
2615
2711
  });
2712
+ narrating = narrated.catch(() => "");
2713
+ pending.push(narrated.then(async (line) => {
2714
+ if (!isCurrent() || currentAttempt !== attempt) return;
2715
+ const visual = prepareVisualScene(plannedScene, mode);
2716
+ const withNarration = line ? { ...visual, narration: line } : visual;
2717
+ const spoken = line ? await prepareSpeech(line, controller.signal).catch((cause) => {
2718
+ if (controller.signal.aborted) throw cause;
2719
+ warn("Some narration is unavailable; the response will continue.");
2720
+ return void 0;
2721
+ }) : void 0;
2722
+ if (!isCurrent() || currentAttempt !== attempt) return;
2723
+ ready[position] = pacedScene(withNarration, spoken?.seconds, currentOptions.templates);
2724
+ flush();
2725
+ }).catch(() => {
2726
+ if (!isCurrent() || currentAttempt !== attempt) return;
2727
+ ready[position] = { ...received[position], timing: { fixedDuration: 5 } };
2728
+ warn("Some parts were simplified so the response could continue.");
2729
+ flush();
2730
+ }));
2616
2731
  }
2617
- if (event.type === "response.abort") {
2618
- terminalError = new VideoError(event.data.reason, { code: "aborted", recoverable: false });
2619
- }
2620
- if (event.type !== "scene.add") continue;
2621
- const position = event.data.position;
2622
- const plannedScene = event.data.scene;
2623
- planned[position] = plannedScene;
2624
- if (!currentOptions.templates?.getTemplate(plannedScene.templateId)) {
2625
- preloadBuiltinTemplate(plannedScene.templateId);
2626
- }
2627
- warmSceneMedia(plannedScene.variables);
2628
- const narrated = narrating.then(async () => {
2629
- const supplied = plannedScene.narration?.trim();
2630
- if (supplied) return supplied;
2631
- const narrationResponse = await request("narration", {
2632
- method: "POST",
2633
- body: JSON.stringify({ prompt, scene: plannedScene, earlier: [...lines] })
2634
- }, controller.signal);
2635
- if (!narrationResponse.ok) return "";
2636
- const payload = await narrationResponse.json();
2637
- return typeof payload.line === "string" ? payload.line.trim() : "";
2638
- }).then((line) => {
2639
- if (line) lines.push(line);
2640
- return line;
2641
- });
2642
- narrating = narrated.catch(() => "");
2643
- pending.push(narrated.then(async (line) => {
2644
- if (!isCurrent() || currentAttempt !== attempt) return;
2645
- const visual = prepareVisualScene(plannedScene, mode);
2646
- const withNarration = line ? { ...visual, narration: line } : visual;
2647
- const spoken = line ? await voiceRef.current.prepare(line, { signal: controller.signal }) : void 0;
2648
- if (!isCurrent() || currentAttempt !== attempt) return;
2649
- ready[position] = pacedScene(withNarration, spoken?.seconds, currentOptions.templates);
2650
- flush();
2651
- }));
2732
+ } catch (cause) {
2733
+ if (controller.signal.aborted) throw cause;
2734
+ terminalError = errorFrom(cause);
2652
2735
  }
2653
2736
  await Promise.all(pending);
2654
- if (terminalError) throw terminalError;
2737
+ if (terminalError && ready.some(Boolean) && style) {
2738
+ warn("The response was interrupted; completed scenes are still available.");
2739
+ } else if (terminalError) throw terminalError;
2655
2740
  if (planned.length === 0 || ready.filter(Boolean).length === 0 || !style) {
2656
2741
  throw new VideoError("The video response contained no playable scenes", { code: "empty_response" });
2657
2742
  }
2658
- let suggestions2 = [];
2659
- try {
2660
- const suggestionsResponse = await request("suggestions", {
2661
- method: "POST",
2662
- body: JSON.stringify({ prompt, lines })
2663
- }, controller.signal);
2664
- if (suggestionsResponse.ok) {
2665
- suggestions2 = (await suggestionsResponse.json()).suggestions ?? [];
2666
- }
2667
- } catch (cause) {
2668
- if (controller.signal.aborted) throw cause;
2669
- }
2670
2743
  return {
2671
2744
  video: {
2672
2745
  schemaVersion: "0.1",
@@ -2674,25 +2747,43 @@ function useVideoChat(options = {}) {
2674
2747
  scenes: ready.filter((entry) => entry != null),
2675
2748
  style
2676
2749
  },
2677
- suggestions: suggestions2
2750
+ lines
2678
2751
  };
2679
2752
  };
2680
2753
  try {
2681
2754
  let response;
2682
2755
  try {
2683
- response = await runAttempt(attempt);
2756
+ response = await untilAborted(runAttempt(attempt), controller.signal);
2684
2757
  } catch (cause) {
2685
- if (controller.signal.aborted || timeline) throw cause;
2758
+ if (controller.signal.aborted || timeline || spokenHook) throw cause;
2686
2759
  attempt += 1;
2687
2760
  ready = [];
2761
+ received = [];
2688
2762
  appended = 0;
2689
2763
  style = void 0;
2690
- response = await runAttempt(attempt);
2764
+ response = await untilAborted(runAttempt(attempt), controller.signal);
2691
2765
  }
2692
2766
  if (!isCurrent()) return void 0;
2693
2767
  planDone = true;
2694
2768
  flush();
2695
- dispatch({ type: "complete", id, video: response.video, suggestions: response.suggestions });
2769
+ dispatch({ type: "complete", id, video: response.video, suggestions: [] });
2770
+ const suggestionsController = new AbortController();
2771
+ suggestionsRef.current = suggestionsController;
2772
+ void withDeadline(async (signal) => {
2773
+ const result = await request("suggestions", {
2774
+ method: "POST",
2775
+ body: JSON.stringify({ prompt, lines: response.lines })
2776
+ }, signal);
2777
+ if (!result.ok) return [];
2778
+ const payload = await result.json();
2779
+ return Array.isArray(payload.suggestions) ? payload.suggestions : [];
2780
+ }, 7e3, suggestionsController.signal).then((suggestions2) => {
2781
+ if (mountedRef.current && runRef.current === run && !suggestionsController.signal.aborted) {
2782
+ dispatch({ type: "suggestions", id, suggestions: suggestions2 });
2783
+ }
2784
+ }).catch(() => void 0).finally(() => {
2785
+ if (suggestionsRef.current === suggestionsController) suggestionsRef.current = void 0;
2786
+ });
2696
2787
  return response.video;
2697
2788
  } catch (cause) {
2698
2789
  if (runRef.current !== run) return void 0;
@@ -2700,31 +2791,52 @@ function useVideoChat(options = {}) {
2700
2791
  return void 0;
2701
2792
  }
2702
2793
  terminal = true;
2794
+ const recovered = received.flatMap((scene, index) => scene ? [ready[index] ?? { ...scene, timing: { fixedDuration: 5 } }] : []);
2795
+ if (recovered.length === 0 && spokenHook) {
2796
+ recovered.push({ id: `${id}-opening`, templateId: "media", variables: { texts: spokenHook, mediaType: "gradient" }, narration: spokenHook, timing: { fixedDuration: Math.max(4, spokenHook.split(/\s+/u).length / 2.5) } });
2797
+ }
2798
+ if (recovered.length > 0) {
2799
+ style ??= { brand: resolveVideoBrand(currentOptions.brand), density: "normal", motion: "normal", defaultBackgroundEffect: "static", defaultTextArchetype: "subtle", defaultTransition: "crossfade" };
2800
+ openingController.abort(new DOMException("Continuing completed response", "AbortError"));
2801
+ if (!timeline) {
2802
+ timeline = createSceneTimeline({ style, orientation });
2803
+ dispatch({ type: "player", id, stream: timeline.stream });
2804
+ }
2805
+ for (const scene of recovered.slice(appended)) timeline.add(scene);
2806
+ timeline.complete();
2807
+ if (timelineRef.current === timeline) timelineRef.current = void 0;
2808
+ const video = { schemaVersion: "0.1", orientation, style, scenes: recovered };
2809
+ dispatch({ type: "warning", id, message: "The response was interrupted; completed scenes are still available." });
2810
+ dispatch({ type: "complete", id, video, suggestions: [] });
2811
+ return video;
2812
+ }
2703
2813
  openingController.abort(new DOMException("Response failed", "AbortError"));
2704
2814
  timeline?.complete();
2705
2815
  if (timelineRef.current === timeline) timelineRef.current = void 0;
2706
- const error = errorFrom2(cause);
2816
+ const error = errorFrom(cause);
2707
2817
  dispatch({ type: "error", id, error });
2708
2818
  return void 0;
2709
2819
  } finally {
2710
2820
  clearTimeout(timeout);
2711
2821
  if (runRef.current === run) inFlightRef.current = void 0;
2712
2822
  }
2713
- }, [request]);
2714
- const pause = useCallback3(() => {
2823
+ }, [request, endTiming]);
2824
+ const pause = useCallback2(() => {
2825
+ finishStall();
2715
2826
  heldRef.current = true;
2716
2827
  voiceRef.current.pause();
2717
2828
  dispatch({ type: "pause" });
2718
- }, []);
2719
- const resume = useCallback3(() => {
2829
+ }, [finishStall]);
2830
+ const resume = useCallback2(() => {
2720
2831
  heldRef.current = false;
2721
2832
  voiceRef.current.resume();
2722
2833
  dispatch({ type: "resume" });
2723
2834
  flushRef.current?.();
2724
2835
  }, []);
2725
- const replay = useCallback3(() => {
2836
+ const replay = useCallback2(() => {
2726
2837
  const turn = stateRef.current.turns.find((entry) => entry.id === stateRef.current.shownTurnId);
2727
2838
  if (!turn?.completed || !turn.video) return;
2839
+ endTiming();
2728
2840
  if (inFlightRef.current) {
2729
2841
  runRef.current += 1;
2730
2842
  inFlightRef.current.abort(new DOMException("Replaying a saved response", "AbortError"));
@@ -2738,10 +2850,11 @@ function useVideoChat(options = {}) {
2738
2850
  heldRef.current = false;
2739
2851
  voiceRef.current.resume();
2740
2852
  dispatch({ type: "replay" });
2741
- }, []);
2742
- const selectTurn = useCallback3((id) => {
2853
+ }, [endTiming]);
2854
+ const selectTurn = useCallback2((id) => {
2743
2855
  const turn = stateRef.current.turns.find((entry) => entry.id === id);
2744
2856
  if (!turn?.completed || !turn.video) return;
2857
+ endTiming();
2745
2858
  if (inFlightRef.current) {
2746
2859
  runRef.current += 1;
2747
2860
  inFlightRef.current.abort(new DOMException("Viewing a saved response", "AbortError"));
@@ -2755,14 +2868,20 @@ function useVideoChat(options = {}) {
2755
2868
  heldRef.current = false;
2756
2869
  voiceRef.current.resume();
2757
2870
  dispatch({ type: "select", id });
2758
- }, []);
2759
- const reset = useCallback3(() => {
2871
+ }, [endTiming]);
2872
+ const reset = useCallback2(() => {
2760
2873
  cancel("Session reset");
2761
2874
  heldRef.current = false;
2762
2875
  voiceRef.current.resume();
2763
2876
  dispatch({ type: "reset" });
2764
2877
  }, [cancel]);
2765
- const setMuted = useCallback3((muted) => dispatch({ type: "mute", value: muted }), []);
2878
+ const restoreSession = useCallback2((turns) => {
2879
+ cancel("Restoring session");
2880
+ heldRef.current = false;
2881
+ voiceRef.current.resume();
2882
+ dispatch({ type: "restore", turns: turns.filter((turn) => turn.completed && turn.video).slice(-100) });
2883
+ }, [cancel]);
2884
+ const setMuted = useCallback2((muted) => dispatch({ type: "mute", value: muted }), []);
2766
2885
  const currentTurn = state.turns.at(-1);
2767
2886
  const shownTurn = state.turns.find((turn) => turn.id === state.shownTurnId) ?? currentTurn;
2768
2887
  const availableModes = state.capabilities?.modes ?? ["templates"];
@@ -2777,30 +2896,38 @@ function useVideoChat(options = {}) {
2777
2896
  paused: state.status === "paused",
2778
2897
  controls: false,
2779
2898
  orientation: shownTurn?.fixedOrientation ? shownTurn.orientation : "auto",
2899
+ onFramePresented: () => {
2900
+ const timing = firstFrameRef.current;
2901
+ if (stateRef.current.playerKey !== playbackKey || state.playback?.kind !== "stream" || !timing?.active || timing.reported) return;
2902
+ timing.reported = true;
2903
+ const elapsedMs = Math.max(0, Math.round(monotonicNow() - timing.startedAt));
2904
+ observe(() => optionsRef.current.onFirstFrame?.({ turnId: timing.turnId, mode: timing.mode, timeToFirstFrameMs: elapsedMs }));
2905
+ reportMetric({ type: "first-frame", turnId: timing.turnId, mode: timing.mode, elapsedMs });
2906
+ },
2907
+ onStallChange: (stalled) => {
2908
+ if (stateRef.current.playerKey !== playbackKey || state.playback?.kind !== "stream") return;
2909
+ const timing = firstFrameRef.current;
2910
+ if (!timing?.active || !timing.reported) return;
2911
+ if (!stalled) finishStall();
2912
+ else if (!heldRef.current && timing.stallStartedAt == null) timing.stallStartedAt = monotonicNow();
2913
+ },
2780
2914
  onSceneChange: (scene, index) => {
2781
2915
  if (stateRef.current.playerKey !== playbackKey) return;
2782
- const firstFrame = firstFrameRef.current;
2783
- if (index === 0 && firstFrame && !firstFrame.reported && state.playback?.kind === "stream") {
2784
- firstFrame.reported = true;
2785
- try {
2786
- void optionsRef.current.onFirstFrame?.({
2787
- turnId: firstFrame.turnId,
2788
- mode: firstFrame.mode,
2789
- timeToFirstFrameMs: Math.max(0, Math.round(monotonicNow() - firstFrame.startedAt))
2790
- });
2791
- } catch {
2792
- }
2793
- }
2794
2916
  dispatch({ type: "scene", key: playbackKey, scene, index });
2795
2917
  narrationRef.current.onSceneChange(scene, index);
2796
2918
  },
2797
- onPlaybackEnd: () => dispatch({ type: "playback-end", key: playbackKey }),
2919
+ onPlaybackEnd: () => {
2920
+ if (stateRef.current.playerKey !== playbackKey) return;
2921
+ endTiming();
2922
+ dispatch({ type: "playback-end", key: playbackKey });
2923
+ },
2798
2924
  onError: (cause) => {
2925
+ if (stateRef.current.playerKey !== playbackKey) return;
2799
2926
  const id = stateRef.current.turns.at(-1)?.id;
2800
- if (id) dispatch({ type: "error", id, error: errorFrom2(cause) });
2927
+ if (id) dispatch({ type: "error", id, error: errorFrom(cause) });
2801
2928
  }
2802
2929
  } : void 0;
2803
- return {
2930
+ const chat = {
2804
2931
  ask,
2805
2932
  cancel,
2806
2933
  pause,
@@ -2815,6 +2942,7 @@ function useVideoChat(options = {}) {
2815
2942
  welcome: state.welcome,
2816
2943
  availableModes,
2817
2944
  status: state.status,
2945
+ warnings: shownTurn?.warnings ?? [],
2818
2946
  error: state.error,
2819
2947
  suggestions,
2820
2948
  caption: state.caption,
@@ -2826,15 +2954,16 @@ function useVideoChat(options = {}) {
2826
2954
  playerKey: state.playerKey,
2827
2955
  playerProps
2828
2956
  };
2957
+ return { chat, restoreSession };
2829
2958
  }
2830
2959
 
2831
2960
  // src/video-chat/video-chat.tsx
2832
- import { useCallback as useCallback6, useEffect as useEffect9, useId, useMemo as useMemo3, useRef as useRef7, useState as useState6 } from "react";
2961
+ import { useCallback as useCallback6, useEffect as useEffect9, useId, useLayoutEffect, useMemo as useMemo2, useRef as useRef7, useState as useState6 } from "react";
2833
2962
 
2834
2963
  // src/video-chat/modes.ts
2835
2964
  var visualModes = [
2836
- { id: "templates", label: "Templates only", note: "Rendered scenes \xB7 free and instant" },
2837
- { id: "full", label: "Full AI video", note: "Every beat filmed \xB7 billed per clip" }
2965
+ { id: "templates", label: "Templates only", note: "Animated text, diagrams and charts" },
2966
+ { id: "full", label: "Full AI video", note: "AI-generated footage \xB7 billed per clip" }
2838
2967
  ];
2839
2968
  var defaultMode = visualModes[0];
2840
2969
  var modeById = (id) => visualModes.find((mode) => mode.id === id) ?? defaultMode;
@@ -2939,9 +3068,9 @@ var Warning = () => /* @__PURE__ */ jsxs3(Glyph, { children: [
2939
3068
  ] });
2940
3069
 
2941
3070
  // src/video-chat/use-dismiss.ts
2942
- import { useEffect as useEffect6 } from "react";
3071
+ import { useEffect as useEffect5 } from "react";
2943
3072
  function useDismiss(open, close, surfaces) {
2944
- useEffect6(() => {
3073
+ useEffect5(() => {
2945
3074
  if (!open) return;
2946
3075
  const onKeyDown = (event) => {
2947
3076
  if (event.key !== "Escape") return;
@@ -2963,7 +3092,7 @@ function useDismiss(open, close, surfaces) {
2963
3092
  }, [open, close, surfaces]);
2964
3093
  }
2965
3094
  function useFocusTrap(active, surface) {
2966
- useEffect6(() => {
3095
+ useEffect5(() => {
2967
3096
  if (!active) return;
2968
3097
  const returnTo = document.activeElement;
2969
3098
  const focusable = () => Array.from(
@@ -2996,11 +3125,11 @@ function useFocusTrap(active, surface) {
2996
3125
  }
2997
3126
 
2998
3127
  // src/video-chat/suggestion-cards.tsx
2999
- import { useCallback as useCallback4, useEffect as useEffect7, useRef as useRef5, useState as useState4 } from "react";
3128
+ import { useCallback as useCallback3, useEffect as useEffect6, useRef as useRef4, useState as useState3 } from "react";
3000
3129
  import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
3001
3130
  function Frame({ media, poster, playing }) {
3002
- const video = useRef5(null);
3003
- useEffect7(() => {
3131
+ const video = useRef4(null);
3132
+ useEffect6(() => {
3004
3133
  const element = video.current;
3005
3134
  if (!element || playing === void 0) return;
3006
3135
  if (playing) void element.play().catch(() => void 0);
@@ -3025,20 +3154,20 @@ function Frame({ media, poster, playing }) {
3025
3154
  );
3026
3155
  }
3027
3156
  function SuggestionCards({ suggestions, label, onAsk }) {
3028
- const railRef = useRef5(null);
3029
- const [at, setAt] = useState4(0);
3030
- const [taken, setTaken] = useState4(false);
3031
- const take = useCallback4((index) => {
3157
+ const railRef = useRef4(null);
3158
+ const [at, setAt] = useState3(0);
3159
+ const [taken, setTaken] = useState3(false);
3160
+ const take = useCallback3((index) => {
3032
3161
  setTaken(true);
3033
3162
  setAt(index);
3034
3163
  }, []);
3035
- useEffect7(() => {
3164
+ useEffect6(() => {
3036
3165
  if (taken || suggestions.length < 2) return;
3037
3166
  if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
3038
3167
  const tour = window.setInterval(() => setAt((index) => (index + 1) % suggestions.length), 5e3);
3039
3168
  return () => window.clearInterval(tour);
3040
3169
  }, [taken, suggestions.length]);
3041
- useEffect7(() => {
3170
+ useEffect6(() => {
3042
3171
  const rail = railRef.current;
3043
3172
  const card = rail?.children[at];
3044
3173
  if (!rail || !card) return;
@@ -3095,7 +3224,7 @@ function Welcome({ data, onAsk, title }) {
3095
3224
  }
3096
3225
 
3097
3226
  // src/video-chat/use-voice-input.ts
3098
- import { useCallback as useCallback5, useEffect as useEffect8, useRef as useRef6, useState as useState5 } from "react";
3227
+ import { useCallback as useCallback4, useEffect as useEffect7, useRef as useRef5, useState as useState4 } from "react";
3099
3228
  function recognitionConstructor() {
3100
3229
  if (typeof window === "undefined") return void 0;
3101
3230
  const holder = window;
@@ -3165,19 +3294,19 @@ async function recordAndTranscribe(signal, ready, captured, options) {
3165
3294
  return clip ? transcribeRecording(clip, signal, options) : "";
3166
3295
  }
3167
3296
  function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {}) {
3168
- const [supported, setSupported] = useState5(false);
3169
- const [listening, setListening] = useState5(false);
3170
- const [error, setError] = useState5();
3171
- const [thinking, setThinking] = useState5(false);
3172
- const recognitionRef = useRef6(void 0);
3173
- const useRecorderRef = useRef6(false);
3174
- const operationRef = useRef6(void 0);
3175
- const handlerRef = useRef6(onTranscript);
3297
+ const [supported, setSupported] = useState4(false);
3298
+ const [listening, setListening] = useState4(false);
3299
+ const [error, setError] = useState4();
3300
+ const [thinking, setThinking] = useState4(false);
3301
+ const recognitionRef = useRef5(void 0);
3302
+ const useRecorderRef = useRef5(false);
3303
+ const operationRef = useRef5(void 0);
3304
+ const handlerRef = useRef5(onTranscript);
3176
3305
  handlerRef.current = onTranscript;
3177
- useEffect8(() => {
3306
+ useEffect7(() => {
3178
3307
  setSupported(supportsVoiceInput(transcriptionAvailable));
3179
3308
  }, [transcriptionAvailable]);
3180
- const stop = useCallback5(() => {
3309
+ const stop = useCallback4(() => {
3181
3310
  recognitionRef.current?.abort();
3182
3311
  recognitionRef.current = void 0;
3183
3312
  const operation = operationRef.current;
@@ -3187,7 +3316,7 @@ function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {
3187
3316
  setListening(false);
3188
3317
  setThinking(false);
3189
3318
  }, []);
3190
- useEffect8(() => () => {
3319
+ useEffect7(() => () => {
3191
3320
  recognitionRef.current?.abort();
3192
3321
  recognitionRef.current = void 0;
3193
3322
  const operation = operationRef.current;
@@ -3195,7 +3324,7 @@ function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {
3195
3324
  operation?.controller.abort();
3196
3325
  operation?.finish?.();
3197
3326
  }, []);
3198
- const finish = useCallback5(() => {
3327
+ const finish = useCallback4(() => {
3199
3328
  const recognition = recognitionRef.current;
3200
3329
  if (recognition) {
3201
3330
  recognition.stop();
@@ -3209,7 +3338,7 @@ function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {
3209
3338
  }
3210
3339
  stop();
3211
3340
  }, [stop]);
3212
- const record = useCallback5(async () => {
3341
+ const record = useCallback4(async () => {
3213
3342
  if (!transcriptionAvailable || !recorderAvailable()) {
3214
3343
  setError("Server transcription is not configured.");
3215
3344
  return;
@@ -3247,7 +3376,7 @@ function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {
3247
3376
  }
3248
3377
  }
3249
3378
  }, [transcriptionAvailable, options.endpoint, options.headers, options.credentials, options.fetcher]);
3250
- const toggle = useCallback5(() => {
3379
+ const toggle = useCallback4(() => {
3251
3380
  if (listening) {
3252
3381
  finish();
3253
3382
  return;
@@ -3298,14 +3427,77 @@ function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {
3298
3427
  return { supported, listening, thinking, error, toggle, stop };
3299
3428
  }
3300
3429
 
3430
+ // src/video-chat/use-immersive-controls.ts
3431
+ import { useCallback as useCallback5, useEffect as useEffect8, useRef as useRef6, useState as useState5 } from "react";
3432
+ function useImmersiveControls(playing, pinned, hasCaptions = false) {
3433
+ const [visible, setVisible] = useState5(true);
3434
+ const hovered = useRef6(false);
3435
+ const focused = useRef6(false);
3436
+ const previousCaptions = useRef6(false);
3437
+ const timer = useRef6(null);
3438
+ const clearTimer = useCallback5(() => {
3439
+ if (timer.current !== null) clearTimeout(timer.current);
3440
+ timer.current = null;
3441
+ }, []);
3442
+ const reveal = useCallback5(() => {
3443
+ clearTimer();
3444
+ setVisible(true);
3445
+ if (playing && !pinned && !hovered.current && !focused.current) {
3446
+ timer.current = setTimeout(() => {
3447
+ timer.current = null;
3448
+ setVisible(false);
3449
+ }, 2e3);
3450
+ }
3451
+ }, [clearTimer, playing, pinned]);
3452
+ useEffect8(() => {
3453
+ const firstCaption = hasCaptions && !previousCaptions.current;
3454
+ previousCaptions.current = hasCaptions;
3455
+ if (firstCaption && playing && !pinned && !focused.current) {
3456
+ hovered.current = false;
3457
+ clearTimer();
3458
+ setVisible(false);
3459
+ } else {
3460
+ reveal();
3461
+ }
3462
+ return clearTimer;
3463
+ }, [hasCaptions, playing, pinned, reveal, clearTimer]);
3464
+ const onPointerEnter = useCallback5((event) => {
3465
+ if (event?.pointerType === "touch") return;
3466
+ hovered.current = true;
3467
+ reveal();
3468
+ }, [reveal]);
3469
+ const onPointerLeave = useCallback5(() => {
3470
+ if (!hovered.current) return;
3471
+ hovered.current = false;
3472
+ reveal();
3473
+ }, [reveal]);
3474
+ const onFocusCapture = useCallback5((event) => {
3475
+ focused.current = event.target.matches(":focus-visible");
3476
+ reveal();
3477
+ }, [reveal]);
3478
+ const onBlurCapture = useCallback5((event) => {
3479
+ if (focused.current && !event.currentTarget.contains(event.relatedTarget)) {
3480
+ focused.current = false;
3481
+ reveal();
3482
+ }
3483
+ }, [reveal]);
3484
+ return { visible, reveal, onPointerEnter, onPointerLeave, onFocusCapture, onBlurCapture };
3485
+ }
3486
+
3487
+ // src/video-chat/logo.tsx
3488
+ import { jsx as jsx6 } from "react/jsx-runtime";
3489
+ function Logo() {
3490
+ return /* @__PURE__ */ jsx6("img", { className: "brand-logo", src: new URL("./assets/vanillasky-logo.svg", import.meta.url).href, alt: "VanillaSky", width: 144, height: 36 });
3491
+ }
3492
+
3301
3493
  // src/video-chat/video-chat.tsx
3302
- import { Fragment as Fragment4, jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
3494
+ import { Fragment as Fragment4, jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
3303
3495
  var DESKTOP_WIDTH = 900;
3304
3496
  function useViewportOrientation() {
3305
- const [portrait, setPortrait] = useState6(() => typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia(`(max-width: ${DESKTOP_WIDTH - 1}px)`).matches : false);
3497
+ const [portrait, setPortrait] = useState6(() => typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia(`(max-width: ${DESKTOP_WIDTH - 1}px) and (orientation: portrait)`).matches : false);
3306
3498
  useEffect9(() => {
3307
3499
  if (typeof window.matchMedia !== "function") return;
3308
- const query = window.matchMedia(`(max-width: ${DESKTOP_WIDTH - 1}px)`);
3500
+ const query = window.matchMedia(`(max-width: ${DESKTOP_WIDTH - 1}px) and (orientation: portrait)`);
3309
3501
  const update = () => setPortrait(query.matches);
3310
3502
  update();
3311
3503
  query.addEventListener("change", update);
@@ -3330,22 +3522,28 @@ function useFilmingStep(active, key) {
3330
3522
  return STEPS[Math.min(step, STEPS.length - 1)];
3331
3523
  }
3332
3524
  function Waveform({ active, listening }) {
3333
- return /* @__PURE__ */ jsx6("span", { className: `waveform${active ? " on" : ""}${listening ? " hearing" : ""}`, "aria-hidden": "true", children: [0, 1, 2, 3, 4].map((bar) => /* @__PURE__ */ jsx6("span", { style: { animationDelay: `${bar * 140}ms`, animationDuration: `${900 + bar * 130}ms` } }, bar)) });
3525
+ return /* @__PURE__ */ jsx7("span", { className: `waveform${active ? " on" : ""}${listening ? " hearing" : ""}`, "aria-hidden": "true", children: [0, 1, 2, 3, 4].map((bar) => /* @__PURE__ */ jsx7("span", { style: { animationDelay: `${bar * 140}ms`, animationDuration: `${900 + bar * 130}ms` } }, bar)) });
3334
3526
  }
3335
3527
  function VideoChat({ options = {}, className, welcomeTitle }) {
3336
3528
  const [draft, setDraft] = useState6("");
3529
+ const [savedSessions, setSavedSessions] = useState6([]);
3337
3530
  const [themeId, setThemeId] = useState6(defaultTheme.id);
3338
3531
  const [modeId, setModeId] = useState6(options.mode ?? defaultMode.id);
3339
3532
  const [historyOpen, setHistoryOpen] = useState6(false);
3340
3533
  const [settingsOpen, setSettingsOpen] = useState6(false);
3341
- const [sheetOpen, setSheetOpen] = useState6(false);
3534
+ const [captionsOn, setCaptionsOn] = useState6(true);
3535
+ const [captionsExpanded, setCaptionsExpanded] = useState6(false);
3536
+ const [alwaysShowControls, setAlwaysShowControls] = useState6(false);
3537
+ const [editing, setEditing] = useState6(false);
3538
+ const resumeAfterInput = useRef7(false);
3539
+ const panelRef = useRef7(null);
3540
+ const composerRef = useRef7(null);
3342
3541
  const viewportOrientation = useViewportOrientation();
3343
- const [themeChoice, setThemeChoice] = useState6("system");
3344
3542
  const theme = themeById(themeId);
3345
3543
  const sessionOrientation = options.orientation ?? viewportOrientation;
3346
3544
  const { brand: configuredBrand, style: configuredStyle, ...sessionOptions } = options;
3347
3545
  const presentationBrand = configuredBrand ?? theme.brand;
3348
- const chat = useVideoChat({
3546
+ const { chat, restoreSession } = useVideoChatSession({
3349
3547
  ...sessionOptions,
3350
3548
  mode: modeId,
3351
3549
  orientation: sessionOrientation,
@@ -3356,8 +3554,6 @@ function VideoChat({ options = {}, className, welcomeTitle }) {
3356
3554
  const historyId = `${instanceId}-history`;
3357
3555
  const settingsId = `${instanceId}-settings`;
3358
3556
  const promptId = `${instanceId}-prompt`;
3359
- const responseTitleId = `${instanceId}-response-title`;
3360
- const appearanceName = `${instanceId}-appearance`;
3361
3557
  const visualsName = `${instanceId}-visuals`;
3362
3558
  const styleName = `${instanceId}-style`;
3363
3559
  const inputRef = useRef7(null);
@@ -3365,23 +3561,20 @@ function VideoChat({ options = {}, className, welcomeTitle }) {
3365
3561
  const historyButtonRef = useRef7(null);
3366
3562
  const settingsRef = useRef7(null);
3367
3563
  const settingsButtonRef = useRef7(null);
3368
- const sheetRef = useRef7(null);
3369
3564
  const listen = useVoiceInput(setDraft, chat.capabilities?.transcription ?? false, {
3370
3565
  endpoint: options.endpoint,
3371
3566
  headers: options.headers,
3372
3567
  credentials: options.credentials,
3373
3568
  fetcher: options.fetcher
3374
3569
  });
3375
- const historySurfaces = useMemo3(() => [historyRef, historyButtonRef], []);
3376
- const settingsSurfaces = useMemo3(() => [settingsRef, settingsButtonRef], []);
3377
- const noSurfaces = useMemo3(() => [], []);
3570
+ const historySurfaces = useMemo2(() => [historyRef, historyButtonRef], []);
3571
+ const settingsSurfaces = useMemo2(() => [settingsRef, settingsButtonRef], []);
3378
3572
  const closeHistory = useCallback6(() => setHistoryOpen(false), []);
3379
3573
  const closeSettings = useCallback6(() => setSettingsOpen(false), []);
3380
- const closeSheet = useCallback6(() => setSheetOpen(false), []);
3381
3574
  useDismiss(historyOpen, closeHistory, historySurfaces);
3382
3575
  useDismiss(settingsOpen, closeSettings, settingsSurfaces);
3383
- useDismiss(sheetOpen, closeSheet, noSurfaces);
3384
- useFocusTrap(sheetOpen, sheetRef);
3576
+ useFocusTrap(settingsOpen, settingsRef);
3577
+ useFocusTrap(historyOpen, historyRef);
3385
3578
  const ask = useCallback6((value) => {
3386
3579
  const prompt = (typeof value === "string" ? value : value.prompt).trim();
3387
3580
  if (!prompt) return;
@@ -3389,7 +3582,10 @@ function VideoChat({ options = {}, className, welcomeTitle }) {
3389
3582
  listen.stop();
3390
3583
  setHistoryOpen(false);
3391
3584
  setSettingsOpen(false);
3392
- setSheetOpen(false);
3585
+ setCaptionsExpanded(false);
3586
+ setEditing(false);
3587
+ resumeAfterInput.current = false;
3588
+ inputRef.current?.blur();
3393
3589
  void chat.ask(prompt, typeof value === "string" ? void 0 : {
3394
3590
  openingMedia: value.media,
3395
3591
  opening: value.opening
@@ -3400,14 +3596,22 @@ function VideoChat({ options = {}, className, welcomeTitle }) {
3400
3596
  inputRef.current?.focus();
3401
3597
  return;
3402
3598
  }
3599
+ listen.stop();
3600
+ setDraft("");
3601
+ const completed = chat.turns.filter((turn) => turn.completed && turn.video);
3602
+ if (completed.length) setSavedSessions((sessions) => [{ id: completed[0].id, turns: completed }, ...sessions].slice(0, 10));
3403
3603
  chat.reset();
3404
3604
  setHistoryOpen(false);
3405
3605
  setSettingsOpen(false);
3406
- setSheetOpen(false);
3407
- }, [chat]);
3606
+ setCaptionsExpanded(false);
3607
+ setEditing(false);
3608
+ resumeAfterInput.current = false;
3609
+ inputRef.current?.blur();
3610
+ }, [chat, listen]);
3408
3611
  const current = chat.currentTurn;
3409
3612
  const shown = chat.shownTurn;
3410
3613
  const showing = chat.playerProps != null;
3614
+ const ambientPoster = shown?.openingMedia?.type === "image" ? shown.openingMedia.url : shown?.openingMedia?.posterUrl;
3411
3615
  const waitingForPicture = chat.turns.length > 0 && !showing && (chat.status === "composing" || chat.status === "playing" || chat.status === "paused");
3412
3616
  const filmingStep = useFilmingStep(waitingForPicture, current?.id);
3413
3617
  const status = chat.turns.length === 0 ? "idle" : chat.status === "composing" ? "drawing" : chat.status === "playing" ? "narrating" : chat.status === "error" || chat.status === "cancelled" ? "ended" : chat.status;
@@ -3416,47 +3620,99 @@ function VideoChat({ options = {}, className, welcomeTitle }) {
3416
3620
  const availableModes = visualModes.filter((option) => chat.availableModes.includes(option.id));
3417
3621
  const selectedMode = modeById(modeId);
3418
3622
  const line = chat.caption ?? "";
3419
- const transport = status === "narrating" ? { label: "Pause", action: chat.pause, icon: /* @__PURE__ */ jsx6(Stop, {}) } : status === "paused" ? { label: "Continue", action: chat.resume, icon: /* @__PURE__ */ jsx6(Play, {}) } : status === "ended" && shown?.completed && shown.video ? { label: "Play again", action: chat.replay, icon: /* @__PURE__ */ jsx6(Replay, {}) } : void 0;
3623
+ const fullTranscript = shown?.video ? [shown.opening, ...shown.video.scenes.map((scene) => scene.narration)].filter((entry) => Boolean(entry)) : chat.transcript;
3624
+ const transport = status === "narrating" ? { label: "Pause", action: chat.pause, icon: /* @__PURE__ */ jsx7(Stop, {}) } : status === "paused" ? { label: "Continue", action: chat.resume, icon: /* @__PURE__ */ jsx7(Play, {}) } : status === "ended" && shown?.completed && shown.video ? { label: "Play again", action: chat.replay, icon: /* @__PURE__ */ jsx7(Replay, {}) } : void 0;
3625
+ const cancelInput = useCallback6(() => {
3626
+ listen.stop();
3627
+ setDraft("");
3628
+ setEditing(false);
3629
+ inputRef.current?.blur();
3630
+ if (resumeAfterInput.current) chat.resume();
3631
+ resumeAfterInput.current = false;
3632
+ }, [listen, chat]);
3633
+ const beginInput = (speak = false) => {
3634
+ if (!editing) {
3635
+ resumeAfterInput.current = chat.status === "playing" || chat.status === "composing";
3636
+ if (resumeAfterInput.current) chat.pause();
3637
+ }
3638
+ setEditing(true);
3639
+ setSettingsOpen(false);
3640
+ setHistoryOpen(false);
3641
+ if (speak) listen.toggle();
3642
+ };
3643
+ const controls = useImmersiveControls(
3644
+ status === "narrating" || Boolean(line) && status !== "paused",
3645
+ alwaysShowControls || captionsExpanded || editing || historyOpen || settingsOpen || listen.listening || listen.thinking || Boolean(chat.error || listen.error),
3646
+ captionsOn && Boolean(line)
3647
+ );
3648
+ const captionControls = useImmersiveControls(Boolean(line), captionsExpanded, false);
3649
+ const controlEvents = {
3650
+ onPointerEnter: controls.onPointerEnter,
3651
+ onPointerLeave: controls.onPointerLeave,
3652
+ onFocusCapture: controls.onFocusCapture,
3653
+ onBlurCapture: controls.onBlurCapture
3654
+ };
3655
+ useLayoutEffect(() => {
3656
+ const composer = composerRef.current;
3657
+ if (!composer) return;
3658
+ const measure = () => panelRef.current?.style.setProperty("--composer-height", `${composer.getBoundingClientRect().height}px`);
3659
+ measure();
3660
+ if (typeof ResizeObserver === "undefined") return;
3661
+ const observer = new ResizeObserver(measure);
3662
+ observer.observe(composer);
3663
+ return () => observer.disconnect();
3664
+ }, []);
3420
3665
  return /* @__PURE__ */ jsxs6(
3421
3666
  "div",
3422
3667
  {
3423
3668
  className: `vanillasky-video-chat${className ? ` ${className}` : ""}`,
3424
3669
  "data-orientation": stageOrientation,
3425
- "data-theme": themeChoice,
3670
+ "data-controls-visible": controls.visible,
3671
+ tabIndex: 0,
3672
+ "aria-label": "Video conversation",
3673
+ onPointerMove: controls.reveal,
3674
+ onPointerDown: controls.reveal,
3675
+ onKeyDownCapture: controls.reveal,
3426
3676
  children: [
3427
- /* @__PURE__ */ jsxs6("header", { className: "chrome", children: [
3677
+ /* @__PURE__ */ jsxs6("header", { className: "chrome", ...controlEvents, children: [
3678
+ /* @__PURE__ */ jsx7("div", { className: "session-brand", children: /* @__PURE__ */ jsx7(Logo, {}) }),
3428
3679
  /* @__PURE__ */ jsxs6("div", { className: "group", children: [
3429
- /* @__PURE__ */ jsx6(
3680
+ /* @__PURE__ */ jsxs6(
3430
3681
  "button",
3431
3682
  {
3432
3683
  type: "button",
3433
3684
  className: "round",
3434
- "aria-label": chat.turns.length === 0 ? "Ask a prompt" : "New session",
3685
+ "aria-label": "New session",
3435
3686
  onClick: newSession,
3436
- children: /* @__PURE__ */ jsx6(Plus, {})
3687
+ children: [
3688
+ /* @__PURE__ */ jsx7(Plus, {}),
3689
+ /* @__PURE__ */ jsx7("span", { className: "nav-label", children: "New session" })
3690
+ ]
3437
3691
  }
3438
3692
  ),
3439
- chat.turns.length > 0 && /* @__PURE__ */ jsxs6(
3693
+ /* @__PURE__ */ jsxs6(
3440
3694
  "button",
3441
3695
  {
3442
3696
  ref: historyButtonRef,
3443
3697
  type: "button",
3444
3698
  className: "pill",
3445
3699
  "aria-expanded": historyOpen,
3700
+ "aria-label": "History",
3701
+ "aria-haspopup": "dialog",
3446
3702
  "aria-controls": historyId,
3447
3703
  onClick: () => {
3448
3704
  setHistoryOpen((open) => !open);
3449
3705
  setSettingsOpen(false);
3450
3706
  },
3451
3707
  children: [
3452
- chat.turns.length,
3453
- " asked"
3708
+ /* @__PURE__ */ jsx7(Replay, {}),
3709
+ /* @__PURE__ */ jsx7("span", { className: "nav-label", children: "History" })
3454
3710
  ]
3455
3711
  }
3456
3712
  )
3457
3713
  ] }),
3458
3714
  /* @__PURE__ */ jsxs6("div", { className: "group", children: [
3459
- /* @__PURE__ */ jsx6(
3715
+ /* @__PURE__ */ jsx7(
3460
3716
  "button",
3461
3717
  {
3462
3718
  ref: settingsButtonRef,
@@ -3470,10 +3726,10 @@ function VideoChat({ options = {}, className, welcomeTitle }) {
3470
3726
  setSettingsOpen((open) => !open);
3471
3727
  setHistoryOpen(false);
3472
3728
  },
3473
- children: /* @__PURE__ */ jsx6(Gear, {})
3729
+ children: /* @__PURE__ */ jsx7(Gear, {})
3474
3730
  }
3475
3731
  ),
3476
- /* @__PURE__ */ jsx6(
3732
+ /* @__PURE__ */ jsx7(
3477
3733
  "button",
3478
3734
  {
3479
3735
  type: "button",
@@ -3481,60 +3737,102 @@ function VideoChat({ options = {}, className, welcomeTitle }) {
3481
3737
  "aria-label": chat.muted ? "Turn the voice on" : "Turn the voice off",
3482
3738
  "aria-pressed": chat.muted,
3483
3739
  onClick: () => chat.setMuted(!chat.muted),
3484
- children: chat.muted ? /* @__PURE__ */ jsx6(Muted, {}) : /* @__PURE__ */ jsx6(Sound, {})
3740
+ children: chat.muted ? /* @__PURE__ */ jsx7(Muted, {}) : /* @__PURE__ */ jsx7(Sound, {})
3485
3741
  }
3486
3742
  )
3487
3743
  ] })
3488
3744
  ] }),
3489
3745
  /* @__PURE__ */ jsxs6("div", { className: "stage-area", children: [
3490
3746
  /* @__PURE__ */ jsxs6("div", { className: "stage", style: { background: themeBackground(presentationBrand) }, children: [
3747
+ showing && /* @__PURE__ */ jsx7("div", { className: "ambient-media", "aria-hidden": "true", children: ambientPoster && /* @__PURE__ */ jsx7("img", { className: "frame-media", src: ambientPoster, alt: "" }) }),
3491
3748
  !showing && /* @__PURE__ */ jsxs6(Fragment4, { children: [
3492
- /* @__PURE__ */ jsx6("div", { className: "ground", "aria-hidden": "true" }),
3749
+ /* @__PURE__ */ jsx7("div", { className: "ground", "aria-hidden": "true" }),
3493
3750
  shown?.openingMedia && /* @__PURE__ */ jsxs6(Fragment4, { children: [
3494
- /* @__PURE__ */ jsx6(Frame, { media: shown.openingMedia, poster: true }),
3495
- /* @__PURE__ */ jsx6("div", { className: "opening-wash", "aria-hidden": "true" })
3751
+ /* @__PURE__ */ jsx7(Frame, { media: shown.openingMedia, poster: true }),
3752
+ /* @__PURE__ */ jsx7("div", { className: "opening-wash", "aria-hidden": "true" })
3496
3753
  ] }),
3497
- chat.turns.length === 0 && /* @__PURE__ */ jsx6(Welcome, { data: chat.welcome, onAsk: ask, title: welcomeTitle }),
3754
+ chat.turns.length === 0 && /* @__PURE__ */ jsx7(Welcome, { data: chat.welcome, onAsk: ask, title: welcomeTitle }),
3498
3755
  shown?.prompt && /* @__PURE__ */ jsxs6("div", { className: "asked", children: [
3499
- /* @__PURE__ */ jsx6("p", { className: "asked-prompt", children: shown.prompt }),
3500
- waitingForPicture && /* @__PURE__ */ jsx6("p", { className: "asked-step", "aria-live": "polite", children: filmingStep })
3756
+ /* @__PURE__ */ jsx7("p", { className: "asked-prompt", children: shown.prompt }),
3757
+ waitingForPicture && /* @__PURE__ */ jsx7("p", { className: "asked-step", "aria-live": "polite", children: filmingStep })
3501
3758
  ] })
3502
3759
  ] }),
3503
- chat.playerProps && /* @__PURE__ */ jsx6(
3760
+ chat.playerProps && /* @__PURE__ */ jsx7("div", { className: "player-fit", style: { width: stageOrientation === "portrait" ? "min(100cqw, 56.25cqh)" : "min(100cqw, 177.7778cqh)" }, children: /* @__PURE__ */ jsx7(
3504
3761
  VideoPlayer,
3505
3762
  {
3506
3763
  ...chat.playerProps,
3507
3764
  templates: options.templates,
3765
+ orientation: stageOrientation,
3508
3766
  responsiveBreakpoint: DESKTOP_WIDTH,
3509
3767
  ariaLabel: chat.playerProps.video ? "Replay" : "The response"
3510
3768
  },
3511
3769
  chat.playerKey
3512
- ),
3770
+ ) }),
3513
3771
  showing && chat.playbackEnded && chat.suggestions.length > 0 && /* @__PURE__ */ jsxs6("div", { className: "ending", children: [
3514
- /* @__PURE__ */ jsx6("div", { className: "ending-wash", "aria-hidden": "true" }),
3772
+ /* @__PURE__ */ jsx7("div", { className: "ending-wash", "aria-hidden": "true" }),
3515
3773
  /* @__PURE__ */ jsxs6("div", { className: "ending-body", children: [
3516
- /* @__PURE__ */ jsx6("p", { className: "ending-label", children: "Ask next" }),
3517
- /* @__PURE__ */ jsx6(SuggestionCards, { suggestions: [...chat.suggestions], label: "Follow-up prompts", onAsk: ask })
3774
+ /* @__PURE__ */ jsx7("p", { className: "ending-label", children: "Ask next" }),
3775
+ /* @__PURE__ */ jsx7(SuggestionCards, { suggestions: [...chat.suggestions], label: "Follow-up prompts", onAsk: ask })
3518
3776
  ] })
3519
3777
  ] })
3520
3778
  ] }),
3521
- historyOpen && /* @__PURE__ */ jsx6("nav", { ref: historyRef, id: historyId, className: "sheet-popover history", "aria-label": "Prompts this session", children: chat.turns.map((turn, index) => /* @__PURE__ */ jsxs6(
3522
- "button",
3523
- {
3524
- type: "button",
3525
- className: turn.id === shown?.id ? "current" : void 0,
3526
- disabled: !turn.completed || !turn.video,
3527
- onClick: () => {
3528
- chat.selectTurn(turn.id);
3529
- setHistoryOpen(false);
3779
+ historyOpen && /* @__PURE__ */ jsxs6("nav", { ref: historyRef, id: historyId, className: "sheet-popover history", role: "dialog", "aria-modal": "true", "aria-label": "History", children: [
3780
+ /* @__PURE__ */ jsxs6("div", { className: "popover-heading", children: [
3781
+ /* @__PURE__ */ jsx7("h2", { children: "History" }),
3782
+ /* @__PURE__ */ jsx7("button", { type: "button", className: "round", "aria-label": "Close history", onClick: closeHistory, children: /* @__PURE__ */ jsx7(Close, {}) })
3783
+ ] }),
3784
+ /* @__PURE__ */ jsx7("h3", { className: "section-label", children: "Current session" }),
3785
+ chat.turns.length === 0 && /* @__PURE__ */ jsx7("p", { className: "history-empty", children: "Your questions will appear here." }),
3786
+ chat.turns.map((turn, index) => /* @__PURE__ */ jsxs6(
3787
+ "button",
3788
+ {
3789
+ type: "button",
3790
+ className: `history-row${turn.id === shown?.id ? " current" : ""}`,
3791
+ "aria-current": turn.id === shown?.id ? true : void 0,
3792
+ disabled: !turn.completed || !turn.video,
3793
+ onClick: () => {
3794
+ listen.stop();
3795
+ setDraft("");
3796
+ setEditing(false);
3797
+ resumeAfterInput.current = false;
3798
+ chat.selectTurn(turn.id);
3799
+ setHistoryOpen(false);
3800
+ },
3801
+ children: [
3802
+ /* @__PURE__ */ jsx7("span", { className: "index", children: String(index + 1).padStart(2, "0") }),
3803
+ /* @__PURE__ */ jsxs6("span", { className: "prompt", children: [
3804
+ turn.prompt,
3805
+ /* @__PURE__ */ jsx7("small", { children: turn.id === shown?.id ? "Now showing" : turn.completed ? "Play answer" : "Unfinished answer" })
3806
+ ] })
3807
+ ]
3530
3808
  },
3531
- children: [
3532
- /* @__PURE__ */ jsx6("span", { className: "index", children: String(index + 1).padStart(2, "0") }),
3533
- /* @__PURE__ */ jsx6("span", { className: "prompt", children: turn.prompt })
3534
- ]
3535
- },
3536
- turn.id
3537
- )) }),
3809
+ turn.id
3810
+ )),
3811
+ savedSessions.length > 0 && /* @__PURE__ */ jsxs6(Fragment4, { children: [
3812
+ /* @__PURE__ */ jsx7("h3", { className: "section-label", children: "Earlier sessions" }),
3813
+ savedSessions.map((session) => /* @__PURE__ */ jsxs6("button", { type: "button", className: "history-row", onClick: () => {
3814
+ listen.stop();
3815
+ setDraft("");
3816
+ setEditing(false);
3817
+ resumeAfterInput.current = false;
3818
+ const completed = chat.turns.filter((turn) => turn.completed && turn.video);
3819
+ setSavedSessions((sessions) => [...completed.length ? [{ id: completed[0].id, turns: completed }] : [], ...sessions.filter((entry) => entry.id !== session.id)].slice(0, 10));
3820
+ restoreSession(session.turns);
3821
+ setHistoryOpen(false);
3822
+ setCaptionsExpanded(false);
3823
+ }, children: [
3824
+ /* @__PURE__ */ jsx7(Replay, {}),
3825
+ /* @__PURE__ */ jsxs6("span", { className: "prompt", children: [
3826
+ session.turns[0]?.prompt,
3827
+ /* @__PURE__ */ jsxs6("small", { children: [
3828
+ session.turns.length,
3829
+ " ",
3830
+ session.turns.length === 1 ? "answer" : "answers"
3831
+ ] })
3832
+ ] })
3833
+ ] }, session.id))
3834
+ ] })
3835
+ ] }),
3538
3836
  settingsOpen && /* @__PURE__ */ jsxs6(
3539
3837
  "div",
3540
3838
  {
@@ -3543,97 +3841,135 @@ function VideoChat({ options = {}, className, welcomeTitle }) {
3543
3841
  className: "sheet-popover settings",
3544
3842
  role: "dialog",
3545
3843
  "aria-label": "Settings",
3844
+ "aria-modal": "true",
3546
3845
  children: [
3547
- /* @__PURE__ */ jsxs6("fieldset", { children: [
3548
- /* @__PURE__ */ jsx6("legend", { children: "Appearance" }),
3549
- [["system", "Match system"], ["light", "Light"], ["dark", "Dark"]].map(([value, label]) => /* @__PURE__ */ jsxs6("label", { children: [
3550
- /* @__PURE__ */ jsx6("input", { type: "radio", name: appearanceName, checked: value === themeChoice, onChange: () => setThemeChoice(value) }),
3551
- /* @__PURE__ */ jsx6("span", { children: /* @__PURE__ */ jsx6("strong", { children: label }) })
3552
- ] }, value))
3846
+ /* @__PURE__ */ jsxs6("div", { className: "popover-heading", children: [
3847
+ /* @__PURE__ */ jsx7("h2", { children: "Settings" }),
3848
+ /* @__PURE__ */ jsx7("button", { type: "button", className: "round", "aria-label": "Close settings", onClick: closeSettings, children: /* @__PURE__ */ jsx7(Close, {}) })
3553
3849
  ] }),
3554
- /* @__PURE__ */ jsx6("p", { className: "settings-note", children: "Visuals and style apply to your next prompt." }),
3555
- /* @__PURE__ */ jsxs6("fieldset", { children: [
3556
- /* @__PURE__ */ jsx6("legend", { children: "Visuals" }),
3557
- availableModes.map((option) => /* @__PURE__ */ jsxs6("label", { children: [
3558
- /* @__PURE__ */ jsx6("input", { type: "radio", name: visualsName, checked: option.id === selectedMode.id, onChange: () => setModeId(option.id) }),
3850
+ /* @__PURE__ */ jsxs6("fieldset", { className: "playback-options", children: [
3851
+ /* @__PURE__ */ jsx7("legend", { children: "Watching" }),
3852
+ /* @__PURE__ */ jsxs6("label", { className: "switch-row", children: [
3559
3853
  /* @__PURE__ */ jsxs6("span", { children: [
3560
- /* @__PURE__ */ jsx6("strong", { children: option.label }),
3561
- option.note
3854
+ /* @__PURE__ */ jsx7("strong", { children: "Subtitles" }),
3855
+ /* @__PURE__ */ jsx7("small", { children: "Read along with the answer" })
3856
+ ] }),
3857
+ /* @__PURE__ */ jsx7("input", { type: "checkbox", role: "switch", checked: captionsOn, onChange: (event) => {
3858
+ setCaptionsOn(event.target.checked);
3859
+ setCaptionsExpanded(false);
3860
+ } })
3861
+ ] }),
3862
+ /* @__PURE__ */ jsxs6("label", { className: "switch-row", children: [
3863
+ /* @__PURE__ */ jsxs6("span", { children: [
3864
+ /* @__PURE__ */ jsx7("strong", { children: "Keep controls visible" }),
3865
+ /* @__PURE__ */ jsx7("small", { children: "Keep the input bar on screen" })
3866
+ ] }),
3867
+ /* @__PURE__ */ jsx7("input", { type: "checkbox", role: "switch", checked: alwaysShowControls, onChange: (event) => setAlwaysShowControls(event.target.checked) })
3868
+ ] })
3869
+ ] }),
3870
+ /* @__PURE__ */ jsxs6("fieldset", { className: "visual-options", children: [
3871
+ /* @__PURE__ */ jsx7("legend", { children: "Video creation" }),
3872
+ availableModes.map((option) => /* @__PURE__ */ jsxs6("label", { className: "choice-row", children: [
3873
+ /* @__PURE__ */ jsx7("input", { type: "radio", name: visualsName, checked: option.id === selectedMode.id, onChange: () => setModeId(option.id) }),
3874
+ /* @__PURE__ */ jsxs6("span", { children: [
3875
+ /* @__PURE__ */ jsx7("strong", { children: option.label }),
3876
+ /* @__PURE__ */ jsx7("small", { children: option.note })
3562
3877
  ] })
3563
3878
  ] }, option.id))
3564
3879
  ] }),
3565
- /* @__PURE__ */ jsxs6("fieldset", { children: [
3566
- /* @__PURE__ */ jsx6("legend", { children: "Style" }),
3880
+ /* @__PURE__ */ jsxs6("fieldset", { className: "style-options", children: [
3881
+ /* @__PURE__ */ jsx7("legend", { children: "Video style" }),
3567
3882
  themes.map((option) => /* @__PURE__ */ jsxs6("label", { children: [
3568
- /* @__PURE__ */ jsx6("input", { type: "radio", name: styleName, checked: option.id === themeId, onChange: () => setThemeId(option.id) }),
3569
- /* @__PURE__ */ jsx6("span", { children: /* @__PURE__ */ jsx6("strong", { children: option.label }) })
3883
+ /* @__PURE__ */ jsx7("input", { type: "radio", name: styleName, checked: option.id === themeId, onChange: () => setThemeId(option.id) }),
3884
+ /* @__PURE__ */ jsx7("span", { children: /* @__PURE__ */ jsx7("strong", { children: option.label }) })
3570
3885
  ] }, option.id))
3571
- ] })
3886
+ ] }),
3887
+ /* @__PURE__ */ jsx7("p", { className: "settings-note", children: "Creation and style changes apply to your next question." })
3572
3888
  ]
3573
3889
  }
3574
3890
  )
3575
3891
  ] }),
3576
- /* @__PURE__ */ jsx6("div", { className: "panel", children: /* @__PURE__ */ jsxs6("div", { className: "panel-inner", children: [
3577
- /* @__PURE__ */ jsxs6("div", { className: "line-row", children: [
3578
- /* @__PURE__ */ jsx6("p", { className: "line", "aria-live": "polite", children: line }),
3579
- shown?.video && /* @__PURE__ */ jsxs6("button", { type: "button", className: "full-response", onClick: () => setSheetOpen(true), children: [
3580
- "Full response ",
3581
- /* @__PURE__ */ jsx6(ChevronUp, {})
3582
- ] })
3583
- ] }),
3892
+ /* @__PURE__ */ jsx7("div", { ref: panelRef, className: "panel", "data-input-visible": controls.visible || !captionsOn || !line, children: /* @__PURE__ */ jsxs6("div", { className: "panel-inner", children: [
3893
+ /* @__PURE__ */ jsx7("div", { className: "caption-slot", "data-captions": captionsOn && Boolean(line), "aria-hidden": !captionsOn || !line, children: /* @__PURE__ */ jsx7("div", { className: "caption-clip", children: /* @__PURE__ */ jsxs6(
3894
+ "div",
3895
+ {
3896
+ className: "line-row",
3897
+ "data-expanded": captionsExpanded,
3898
+ "data-actions-visible": captionControls.visible,
3899
+ onPointerMove: captionControls.onPointerEnter,
3900
+ onPointerLeave: captionControls.onPointerLeave,
3901
+ onPointerDown: captionControls.reveal,
3902
+ onFocusCapture: captionControls.onFocusCapture,
3903
+ onBlurCapture: captionControls.onBlurCapture,
3904
+ children: [
3905
+ captionsOn && line && /* @__PURE__ */ jsxs6("div", { className: "caption-actions", children: [
3906
+ /* @__PURE__ */ jsxs6("button", { type: "button", className: "caption-action", "aria-label": captionsExpanded ? "Collapse subtitles" : "Expand subtitles", "aria-expanded": captionsExpanded, onClick: () => setCaptionsExpanded((open) => !open), children: [
3907
+ captionsExpanded ? "Collapse" : "Expand",
3908
+ /* @__PURE__ */ jsx7(ChevronUp, {})
3909
+ ] }),
3910
+ /* @__PURE__ */ jsx7("button", { type: "button", className: "caption-action", "aria-label": "Hide subtitles", onClick: () => {
3911
+ setCaptionsOn(false);
3912
+ setCaptionsExpanded(false);
3913
+ }, children: /* @__PURE__ */ jsx7(Close, {}) })
3914
+ ] }),
3915
+ captionsExpanded ? /* @__PURE__ */ jsx7("div", { className: "expanded-captions", role: "region", tabIndex: 0, "aria-label": "Expanded subtitles", children: fullTranscript.map((entry, index) => /* @__PURE__ */ jsx7("p", { children: entry }, index)) }) : /* @__PURE__ */ jsx7("p", { className: "line", "aria-live": "polite", children: line })
3916
+ ]
3917
+ }
3918
+ ) }) }),
3584
3919
  (chat.error || listen.error) && /* @__PURE__ */ jsxs6("p", { className: "error", role: "status", children: [
3585
- /* @__PURE__ */ jsx6(Warning, {}),
3586
- /* @__PURE__ */ jsx6("span", { children: chat.error?.message ?? listen.error })
3920
+ /* @__PURE__ */ jsx7(Warning, {}),
3921
+ /* @__PURE__ */ jsx7("span", { children: chat.error?.message ?? listen.error })
3587
3922
  ] }),
3588
- /* @__PURE__ */ jsxs6("form", { className: "composer", onSubmit: (event) => {
3589
- event.preventDefault();
3590
- ask(draft);
3591
- }, children: [
3592
- /* @__PURE__ */ jsx6(Waveform, { active: listen.listening || chat.speaking && !chat.muted && status !== "paused", listening: listen.listening }),
3593
- /* @__PURE__ */ jsx6("label", { className: "sr-only", htmlFor: promptId, children: "Prompt" }),
3594
- /* @__PURE__ */ jsx6(
3595
- "textarea",
3596
- {
3597
- id: promptId,
3598
- ref: inputRef,
3599
- rows: 1,
3600
- value: draft,
3601
- placeholder: listen.listening ? "Listening\u2026" : listen.thinking ? "Working out what you said\u2026" : status === "narrating" ? "Talking \u2014 type to jump in\u2026" : chat.turns.length > 0 ? "Ask a follow-up\u2026" : "Ask anything\u2026",
3602
- onChange: (event) => setDraft(event.target.value),
3603
- onKeyDown: (event) => {
3604
- if (event.key === "Enter" && !event.shiftKey) {
3605
- event.preventDefault();
3606
- ask(draft);
3923
+ /* @__PURE__ */ jsxs6("div", { ref: composerRef, className: "conversation-composer", "data-editing": editing, ...controlEvents, children: [
3924
+ (listen.listening || listen.thinking) && /* @__PURE__ */ jsx7("div", { className: "composer-meta", children: /* @__PURE__ */ jsx7("span", { role: "status", children: listen.listening ? "Listening\u2026 Tap the mic to finish, then review and send." : "Turning your words into a draft\u2026" }) }),
3925
+ /* @__PURE__ */ jsxs6("form", { className: "composer", "aria-label": "Ask a question", onSubmit: (event) => {
3926
+ event.preventDefault();
3927
+ ask(draft);
3928
+ }, children: [
3929
+ transport && /* @__PURE__ */ jsx7("button", { type: "button", className: "ghost transport", "aria-label": transport.label, onClick: () => {
3930
+ listen.stop();
3931
+ setEditing(false);
3932
+ resumeAfterInput.current = false;
3933
+ inputRef.current?.blur();
3934
+ transport.action();
3935
+ }, children: transport.icon }),
3936
+ /* @__PURE__ */ jsx7(Waveform, { active: listen.listening || chat.speaking && !chat.muted && status !== "paused", listening: listen.listening }),
3937
+ /* @__PURE__ */ jsx7("label", { className: "sr-only", htmlFor: promptId, children: "Prompt" }),
3938
+ /* @__PURE__ */ jsx7(
3939
+ "textarea",
3940
+ {
3941
+ id: promptId,
3942
+ ref: inputRef,
3943
+ rows: 1,
3944
+ value: draft,
3945
+ placeholder: listen.listening ? "Listening\u2026" : chat.turns.length ? "Ask a follow-up\u2026" : "Ask anything\u2026",
3946
+ onFocus: () => {
3947
+ if (!editing) beginInput();
3948
+ },
3949
+ onChange: (event) => {
3950
+ setDraft(event.target.value);
3951
+ event.target.style.height = "auto";
3952
+ event.target.style.height = `${Math.min(event.target.scrollHeight, 120)}px`;
3953
+ },
3954
+ onKeyDown: (event) => {
3955
+ if (event.key === "Escape") {
3956
+ event.stopPropagation();
3957
+ cancelInput();
3958
+ }
3959
+ if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
3960
+ event.preventDefault();
3961
+ ask(draft);
3962
+ }
3607
3963
  }
3608
3964
  }
3609
- }
3610
- ),
3611
- transport && /* @__PURE__ */ jsx6("button", { type: "button", className: "ghost transport", "aria-label": transport.label, onClick: transport.action, children: transport.icon }),
3612
- listen.supported && /* @__PURE__ */ jsx6(
3613
- "button",
3614
- {
3615
- type: "button",
3616
- className: `ghost${listen.listening ? " listening" : ""}`,
3617
- "aria-label": listen.listening ? "Stop listening" : "Ask by voice",
3618
- "aria-pressed": listen.listening,
3619
- disabled: listen.thinking,
3620
- onClick: listen.toggle,
3621
- children: /* @__PURE__ */ jsx6(Mic, {})
3622
- }
3623
- ),
3624
- /* @__PURE__ */ jsx6("button", { type: "submit", className: "send", "aria-label": "Ask", disabled: !draft.trim() || status === "drawing", children: /* @__PURE__ */ jsx6(Send, {}) })
3625
- ] })
3626
- ] }) }),
3627
- sheetOpen && shown && /* @__PURE__ */ jsxs6("div", { className: "sheet-layer", children: [
3628
- /* @__PURE__ */ jsx6("button", { type: "button", className: "scrim", tabIndex: -1, "aria-hidden": "true", onClick: () => setSheetOpen(false) }),
3629
- /* @__PURE__ */ jsxs6("article", { ref: sheetRef, className: "sheet", role: "dialog", "aria-modal": "true", "aria-labelledby": responseTitleId, children: [
3630
- /* @__PURE__ */ jsxs6("header", { children: [
3631
- /* @__PURE__ */ jsx6("h1", { id: responseTitleId, children: shown.prompt }),
3632
- /* @__PURE__ */ jsx6("button", { type: "button", className: "round", "aria-label": "Close the full response", onClick: () => setSheetOpen(false), children: /* @__PURE__ */ jsx6(Close, {}) })
3633
- ] }),
3634
- /* @__PURE__ */ jsx6("div", { className: "sheet-body", children: chat.transcript.map((entry, index) => /* @__PURE__ */ jsx6("p", { children: entry }, `${index}-${entry}`)) })
3635
- ] })
3636
- ] })
3965
+ ),
3966
+ listen.supported && /* @__PURE__ */ jsx7("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__ */ jsx7(Mic, {}) }),
3967
+ /* @__PURE__ */ jsx7("button", { type: "submit", className: "send", "aria-label": "Ask", disabled: !draft.trim() || listen.thinking, children: /* @__PURE__ */ jsx7(Send, {}) }),
3968
+ editing && /* @__PURE__ */ jsx7("button", { type: "button", className: "ghost", "aria-label": "Cancel question", onClick: cancelInput, children: /* @__PURE__ */ jsx7(Close, {}) })
3969
+ ] })
3970
+ ] }),
3971
+ !showing && chat.turns.length === 0 && /* @__PURE__ */ jsx7("p", { className: "dock-hint", children: "Speak or type. See where it takes you." })
3972
+ ] }) })
3637
3973
  ]
3638
3974
  }
3639
3975
  );
@@ -3643,7 +3979,5 @@ export {
3643
3979
  VideoError,
3644
3980
  VideoPlayer,
3645
3981
  createVideoChatVoice,
3646
- useNarration,
3647
- useVideo,
3648
3982
  useVideoChat
3649
3983
  };