@vanillaskyai/video 0.7.0 → 0.8.0

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 (84) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/PUBLIC-API.md +83 -79
  3. package/README.md +16 -15
  4. package/dist/{builtin-server-KB7FKF6A.js → builtin-server-CUYPIDE7.js} +2 -2
  5. package/dist/check-runtime.js +1 -1
  6. package/dist/{chunk-2PYO6VAC.js → chunk-2DOLF4QN.js} +1 -43
  7. package/dist/{chunk-GAIOHGNR.js → chunk-2OKB27TG.js} +42 -45
  8. package/dist/{chunk-K746NUIP.js → chunk-2USVB3CY.js} +80 -29
  9. package/dist/{chunk-RE4IMWJR.js → chunk-3VRMKFM7.js} +87 -14
  10. package/dist/{chunk-JNN3EYVP.js → chunk-4UNKZEAT.js} +46 -46
  11. package/dist/{chunk-2XT4MZ76.js → chunk-APQKY6P3.js} +36 -3
  12. package/dist/{chunk-RXHW4EP4.js → chunk-L7QGGKJ2.js} +71 -40
  13. package/dist/{chunk-RGF452LL.js → chunk-OWBT45EN.js} +1 -1
  14. package/dist/{chunk-LKBZX7GV.js → chunk-PP3MCPN4.js} +4 -4
  15. package/dist/chunk-ZD2VTUYR.js +28 -0
  16. package/dist/cli.js +131 -24
  17. package/dist/{compose-video-PD6LKKRK.js → compose-video-2NFH4DZY.js} +3 -3
  18. package/dist/{events-B6qS1Lsb.d.ts → events-BQ6z264n.d.ts} +1 -1
  19. package/dist/index.d.ts +5 -67
  20. package/dist/index.js +2 -4
  21. package/dist/{kit-8g46H2RZ.d.ts → kit-tQnCPMOM.d.ts} +1 -1
  22. package/dist/react.d.ts +33 -53
  23. package/dist/react.js +642 -536
  24. package/dist/server.d.ts +11 -13
  25. package/dist/server.js +170 -81
  26. package/dist/template-catalog.js +1 -1
  27. package/dist/templates.d.ts +3 -3
  28. package/dist/test.d.ts +2 -2
  29. package/dist/test.js +3 -3
  30. package/dist/{text-stream-FW4BLBAL.js → text-stream-54LXABJW.js} +1 -1
  31. package/dist/{types-2wHBqtg8.d.ts → types-CEh9eUFu.d.ts} +1 -1
  32. package/dist/{types-DrABlRa7.d.ts → types-DWFO6qca.d.ts} +1 -1
  33. package/docs/agent-integration.md +9 -3
  34. package/docs/architecture.md +8 -10
  35. package/docs/concepts.md +16 -40
  36. package/docs/custom-templates.md +27 -23
  37. package/docs/customization.md +12 -49
  38. package/docs/errors.md +58 -68
  39. package/docs/getting-started.md +21 -14
  40. package/docs/media-and-audio.md +2 -2
  41. package/docs/performance.md +69 -0
  42. package/docs/persistence.md +12 -27
  43. package/docs/production.md +2 -2
  44. package/docs/provider-integration.md +11 -5
  45. package/docs/reference/provider-adapters.md +37 -89
  46. package/docs/security.md +1 -1
  47. package/docs/streaming-protocol.md +2 -2
  48. package/examples/custom-template/README.md +6 -6
  49. package/package.json +6 -7
  50. package/registry/items/barChart.json +2 -2
  51. package/registry/items/bigNumber.json +2 -2
  52. package/registry/items/brandMessage.json +2 -2
  53. package/registry/items/cardList.json +2 -2
  54. package/registry/items/codeEditor.json +2 -2
  55. package/registry/items/ctaLogo.json +2 -2
  56. package/registry/items/ctaMedia.json +2 -2
  57. package/registry/items/incomingCall.json +2 -2
  58. package/registry/items/media.json +2 -2
  59. package/registry/items/milestone.json +2 -2
  60. package/registry/items/notification.json +2 -2
  61. package/registry/items/phoneMockup.json +2 -2
  62. package/registry/items/problemSolution.json +2 -2
  63. package/registry/items/progressRing.json +2 -2
  64. package/registry/items/promptInput.json +2 -2
  65. package/registry/items/reaction.json +2 -2
  66. package/registry/items/reviewStack.json +2 -2
  67. package/registry/items/steps.json +2 -2
  68. package/registry/items/terminal.json +2 -2
  69. package/registry/items/testimonial.json +2 -2
  70. package/registry/items/tripleStats.json +2 -2
  71. package/registry/items/tweet.json +2 -2
  72. package/registry/items/webMockup.json +2 -2
  73. package/starters/video-chat/.env.example +4 -2
  74. package/starters/video-chat/README.md +17 -5
  75. package/starters/video-chat/package.json +2 -4
  76. package/starters/video-chat/providers/speech.ts +20 -0
  77. package/starters/video-chat/providers/video.ts +66 -0
  78. package/starters/video-chat/providers.ts +3 -0
  79. package/starters/video-chat/server.ts +4 -80
  80. package/starters/video-chat/stock.ts +114 -99
  81. package/dist/chunk-SKRGRKHY.js +0 -142
  82. package/dist/state-DZcKuS32.d.ts +0 -3
  83. package/docs/branding-and-personalization.md +0 -87
  84. 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 {
@@ -2311,28 +2281,35 @@ function conversationFor(turns) {
2311
2281
  response: [...transcriptFor(turn).join(" ")].slice(0, 8e3).join("")
2312
2282
  }));
2313
2283
  }
2314
- function errorFrom2(cause) {
2284
+ async function untilAborted(work, signal) {
2285
+ let abort = () => void 0;
2286
+ const cancelled = new Promise((_resolve, reject) => {
2287
+ abort = () => reject(signal.reason ?? new DOMException("Cancelled", "AbortError"));
2288
+ if (signal.aborted) abort();
2289
+ else signal.addEventListener("abort", abort, { once: true });
2290
+ });
2291
+ try {
2292
+ return await Promise.race([work, cancelled]);
2293
+ } finally {
2294
+ signal.removeEventListener("abort", abort);
2295
+ }
2296
+ }
2297
+ function errorFrom(cause) {
2315
2298
  if (cause instanceof VideoError) return cause;
2316
2299
  if (cause instanceof DOMException && cause.name === "AbortError") {
2317
2300
  return new VideoError("Video chat was cancelled", { code: "aborted", recoverable: false });
2318
2301
  }
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 {
2302
+ if (cause instanceof DOMException && cause.name === "TimeoutError") {
2303
+ return new VideoError("Video chat timed out", { code: "timeout", recoverable: false });
2334
2304
  }
2335
- return new VideoError(message, { code, status: response.status, recoverable: false });
2305
+ return new VideoError("Video chat could not produce a playable response", { code: "video_chat_failed", recoverable: false });
2306
+ }
2307
+ async function responseError(response) {
2308
+ return new VideoError("Video chat could not produce a playable response", {
2309
+ code: "http_error",
2310
+ status: response.status,
2311
+ recoverable: false
2312
+ });
2336
2313
  }
2337
2314
  function pacedScene(scene, spokenSeconds, templates) {
2338
2315
  const held = spokenSeconds && spokenSeconds > 0 ? spokenSeconds + 0.8 : getSceneDuration(scene, templates?.getTemplateMetadata(scene.templateId) ?? getBuiltinTemplateMetadata(scene.templateId));
@@ -2361,35 +2338,76 @@ function prepareVisualScene(scene, mode) {
2361
2338
  return overMedia ? { ...shown, textArchetype: "subtle" } : shown;
2362
2339
  }
2363
2340
  function useVideoChat(options = {}) {
2364
- const optionsRef = useRef4(options);
2341
+ const optionsRef = useRef3(options);
2365
2342
  optionsRef.current = options;
2366
- const ownedVoiceRef = useRef4(void 0);
2343
+ const voiceWarningRef = useRef3(() => void 0);
2344
+ const ownedVoiceRef = useRef3(void 0);
2367
2345
  if (!options.voice && !ownedVoiceRef.current) {
2368
2346
  ownedVoiceRef.current = createVideoChatVoice({
2369
2347
  endpoint: options.endpoint,
2370
2348
  headers: options.headers,
2371
2349
  credentials: options.credentials,
2372
- fetcher: options.fetcher
2350
+ fetcher: options.fetcher,
2351
+ onFallback: () => voiceWarningRef.current()
2373
2352
  });
2374
2353
  }
2375
2354
  const voice = options.voice ?? ownedVoiceRef.current;
2376
- const voiceRef = useRef4(voice);
2355
+ const voiceRef = useRef3(voice);
2377
2356
  voiceRef.current = voice;
2378
- const narration = useNarration({ voice });
2379
- const narrationRef = useRef4(narration);
2357
+ const unavailableVoiceLines = useRef3(/* @__PURE__ */ new Set());
2358
+ const speechStartRef = useRef3(() => void 0);
2359
+ const narration = useNarration({ onSpeechStart: (source) => speechStartRef.current(source), voice: {
2360
+ speak: (text, options2) => unavailableVoiceLines.current.has(text) ? void 0 : voice.speak(text, options2)
2361
+ } });
2362
+ const narrationRef = useRef3(narration);
2380
2363
  narrationRef.current = narration;
2381
2364
  const [state, dispatch] = useReducer(reducer, options.initialMuted ?? false, initialState);
2382
- const stateRef = useRef4(state);
2365
+ const stateRef = useRef3(state);
2383
2366
  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) => {
2367
+ const runRef = useRef3(0);
2368
+ const inFlightRef = useRef3(void 0);
2369
+ const suggestionsRef = useRef3(void 0);
2370
+ const openingRef = useRef3(void 0);
2371
+ const timelineRef = useRef3(void 0);
2372
+ const heldRef = useRef3(false);
2373
+ const flushRef = useRef3(void 0);
2374
+ const mountedRef = useRef3(true);
2375
+ const firstFrameRef = useRef3(void 0);
2376
+ const reportMetric = useCallback2((metric) => {
2377
+ observe(() => optionsRef.current.onPlaybackMetric?.(metric));
2378
+ }, []);
2379
+ const finishStall = useCallback2(() => {
2380
+ const timing = firstFrameRef.current;
2381
+ if (!timing?.active || timing.stallStartedAt == null) return;
2382
+ const now = monotonicNow();
2383
+ const started = timing.stallStartedAt;
2384
+ timing.stallStartedAt = void 0;
2385
+ reportMetric({
2386
+ type: "stall",
2387
+ turnId: timing.turnId,
2388
+ mode: timing.mode,
2389
+ elapsedMs: Math.max(0, Math.round(now - timing.startedAt)),
2390
+ durationMs: Math.max(0, Math.round(now - started)),
2391
+ reason: "scene-generation"
2392
+ });
2393
+ }, [reportMetric]);
2394
+ const endTiming = useCallback2(() => {
2395
+ finishStall();
2396
+ if (firstFrameRef.current) firstFrameRef.current.active = false;
2397
+ }, [finishStall]);
2398
+ speechStartRef.current = (source) => {
2399
+ const timing = firstFrameRef.current;
2400
+ if (!mountedRef.current || !timing?.active || timing.speechReported || heldRef.current || stateRef.current.muted) return;
2401
+ timing.speechReported = true;
2402
+ reportMetric({
2403
+ type: "first-speech",
2404
+ turnId: timing.turnId,
2405
+ mode: timing.mode,
2406
+ elapsedMs: Math.max(0, Math.round(monotonicNow() - timing.startedAt)),
2407
+ source: optionsRef.current.voice ? "custom" : source === "generated" ? "generated" : "browser"
2408
+ });
2409
+ };
2410
+ const request = useCallback2(async (action, init = {}, signal) => {
2393
2411
  const current = optionsRef.current;
2394
2412
  const headers = new Headers(current.headers);
2395
2413
  new Headers(init.headers).forEach((header, name) => headers.set(name, header));
@@ -2403,27 +2421,29 @@ function useVideoChat(options = {}) {
2403
2421
  signal
2404
2422
  });
2405
2423
  }, []);
2406
- useEffect5(() => {
2424
+ useEffect4(() => {
2407
2425
  mountedRef.current = true;
2408
2426
  const controller = new AbortController();
2409
2427
  void request("capabilities", {}, controller.signal).then(async (response) => {
2410
- if (!response.ok) throw await responseError2(response);
2428
+ if (!response.ok) throw await responseError(response);
2411
2429
  return response.json();
2412
2430
  }).then((value) => {
2413
2431
  if (mountedRef.current) dispatch({ type: "capabilities", value });
2414
2432
  }).catch(() => void 0);
2415
2433
  void request("welcome", {}, controller.signal).then(async (response) => {
2416
- if (!response.ok) throw await responseError2(response);
2434
+ if (!response.ok) throw await responseError(response);
2417
2435
  return response.json();
2418
2436
  }).then((value) => {
2419
2437
  if (mountedRef.current) dispatch({ type: "welcome", value });
2420
2438
  }).catch(() => void 0);
2421
2439
  return () => {
2440
+ endTiming();
2422
2441
  mountedRef.current = false;
2423
2442
  controller.abort();
2424
2443
  runRef.current += 1;
2425
2444
  inFlightRef.current?.abort(new DOMException("Component unmounted", "AbortError"));
2426
2445
  openingRef.current?.abort(new DOMException("Component unmounted", "AbortError"));
2446
+ suggestionsRef.current?.abort();
2427
2447
  timelineRef.current?.complete();
2428
2448
  timelineRef.current = void 0;
2429
2449
  narrationRef.current.interrupt();
@@ -2431,12 +2451,14 @@ function useVideoChat(options = {}) {
2431
2451
  if (!mountedRef.current) ownedVoiceRef.current?.dispose?.();
2432
2452
  });
2433
2453
  };
2434
- }, [request]);
2435
- useEffect5(() => voice.setMuted(state.muted), [state.muted, voice]);
2436
- const cancel = useCallback3((reason = "Video chat was cancelled") => {
2454
+ }, [request, endTiming]);
2455
+ useEffect4(() => voice.setMuted(state.muted), [state.muted, voice]);
2456
+ const cancel = useCallback2((reason = "Video chat was cancelled") => {
2457
+ endTiming();
2437
2458
  runRef.current += 1;
2438
2459
  inFlightRef.current?.abort(new DOMException(reason, "AbortError"));
2439
2460
  openingRef.current?.abort(new DOMException(reason, "AbortError"));
2461
+ suggestionsRef.current?.abort();
2440
2462
  inFlightRef.current = void 0;
2441
2463
  openingRef.current = void 0;
2442
2464
  timelineRef.current?.complete();
@@ -2444,16 +2466,19 @@ function useVideoChat(options = {}) {
2444
2466
  flushRef.current = void 0;
2445
2467
  narrationRef.current.interrupt();
2446
2468
  dispatch({ type: "cancelled" });
2447
- }, []);
2448
- const ask = useCallback3(async (value, askOptions = {}) => {
2469
+ }, [endTiming]);
2470
+ const ask = useCallback2(async (value, askOptions = {}) => {
2449
2471
  const prompt = value.trim();
2450
2472
  if (!prompt) return void 0;
2473
+ endTiming();
2451
2474
  inFlightRef.current?.abort(new DOMException("Replaced by a new prompt", "AbortError"));
2452
2475
  openingRef.current?.abort(new DOMException("Replaced by a new prompt", "AbortError"));
2476
+ suggestionsRef.current?.abort();
2453
2477
  timelineRef.current?.complete();
2454
2478
  timelineRef.current = void 0;
2455
2479
  narrationRef.current.interrupt();
2456
2480
  voiceRef.current.resume();
2481
+ unavailableVoiceLines.current.clear();
2457
2482
  heldRef.current = false;
2458
2483
  const controller = new AbortController();
2459
2484
  const openingController = new AbortController();
@@ -2488,7 +2513,7 @@ function useVideoChat(options = {}) {
2488
2513
  suggestions: [],
2489
2514
  ...openingMedia ? { openingMedia } : {}
2490
2515
  };
2491
- firstFrameRef.current = { turnId: id, mode, startedAt: monotonicNow(), reported: false };
2516
+ firstFrameRef.current = { turnId: id, mode, startedAt: monotonicNow(), reported: false, speechReported: false, active: true };
2492
2517
  dispatch({ type: "start", turn });
2493
2518
  let timeline;
2494
2519
  let style;
@@ -2500,9 +2525,14 @@ function useVideoChat(options = {}) {
2500
2525
  let terminal = false;
2501
2526
  let attempt = 0;
2502
2527
  let ready = [];
2528
+ let received = [];
2503
2529
  let appended = 0;
2504
- const isCurrent = () => mountedRef.current && runRef.current === run && !controller.signal.aborted;
2530
+ const isCurrent = () => mountedRef.current && runRef.current === run && !controller.signal.aborted && !terminal;
2505
2531
  const isOpeningCurrent = () => isCurrent() && !terminal && openingRef.current === openingController && !openingController.signal.aborted;
2532
+ const warn = (message) => {
2533
+ if (isCurrent()) dispatch({ type: "warning", id, message });
2534
+ };
2535
+ voiceWarningRef.current = () => warn("Using browser voice for this response.");
2506
2536
  const flush = () => {
2507
2537
  if (!isCurrent()) return;
2508
2538
  let available = appended;
@@ -2536,11 +2566,11 @@ function useVideoChat(options = {}) {
2536
2566
  }
2537
2567
  };
2538
2568
  flushRef.current = flush;
2539
- const resolveOpeningMedia = async (keyword) => {
2569
+ const resolveOpeningMedia = async (keyword, fallbackKeyword) => {
2540
2570
  try {
2541
2571
  const response = await request("opening-media", {
2542
2572
  method: "POST",
2543
- body: JSON.stringify({ keyword, orientation })
2573
+ body: JSON.stringify({ keyword, ...fallbackKeyword ? { fallbackKeyword } : {}, orientation })
2544
2574
  }, openingController.signal);
2545
2575
  if (!response.ok || !isOpeningCurrent() || timeline) return;
2546
2576
  const payload = await response.json();
@@ -2549,13 +2579,25 @@ function useVideoChat(options = {}) {
2549
2579
  } catch {
2550
2580
  }
2551
2581
  };
2582
+ const prepareSpeech = async (text, signal) => {
2583
+ try {
2584
+ return currentOptions.voice ? await withDeadline((child) => voiceRef.current.prepare(text, { signal: child }), 3e3, signal) : await voiceRef.current.prepare(text, { signal });
2585
+ } catch (cause) {
2586
+ if (currentOptions.voice && !signal.aborted && isCurrent()) unavailableVoiceLines.current.add(text);
2587
+ throw cause;
2588
+ }
2589
+ };
2552
2590
  const speakOpening = async (text) => {
2553
2591
  try {
2554
- await voiceRef.current.prepare(text, { signal: openingController.signal });
2555
2592
  if (!isOpeningCurrent()) return;
2556
2593
  dispatch({ type: "opening-start", id, line: text });
2557
- await voiceRef.current.speak(text, { signal: openingController.signal });
2594
+ await prepareSpeech(text, openingController.signal);
2595
+ if (!isOpeningCurrent()) return;
2596
+ await voiceRef.current.speak(text, { signal: openingController.signal, onStart: (source) => {
2597
+ if (isOpeningCurrent()) speechStartRef.current(source);
2598
+ } });
2558
2599
  } catch {
2600
+ if (isOpeningCurrent()) warn("Some narration is unavailable; the response will continue.");
2559
2601
  } finally {
2560
2602
  openingActive = false;
2561
2603
  if (isOpeningCurrent()) dispatch({ type: "opening-end", id });
@@ -2581,7 +2623,7 @@ function useVideoChat(options = {}) {
2581
2623
  ...currentOptions.style ? { style: currentOptions.style } : {}
2582
2624
  })
2583
2625
  }, controller.signal);
2584
- if (!response.ok) throw await responseError2(response);
2626
+ if (!response.ok) throw await responseError(response);
2585
2627
  if (!response.body || !response.headers.get("content-type")?.includes("text/event-stream")) {
2586
2628
  throw new VideoError("Video chat endpoint did not return a video stream", { code: "invalid_response" });
2587
2629
  }
@@ -2590,83 +2632,100 @@ function useVideoChat(options = {}) {
2590
2632
  const pending = [];
2591
2633
  let narrating = Promise.resolve();
2592
2634
  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);
2635
+ try {
2636
+ for await (const event of decodeVideoSse(response.body)) {
2637
+ if (!isCurrent() || currentAttempt !== attempt) return { video: { schemaVersion: "0.1", orientation, scenes: [], style }, lines: [] };
2638
+ if (event.type === "response.start") style = event.data.style;
2639
+ if (event.type === "response.warning" || event.type === "response.error" && !event.data.terminal) {
2640
+ warn("Some parts were simplified so the response could continue.");
2607
2641
  }
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
2642
+ if (event.type === "data.video-chat-opening") {
2643
+ const payload = event.data && typeof event.data === "object" && !Array.isArray(event.data) ? event.data : {};
2644
+ const line = typeof payload.line === "string" ? payload.line.trim().slice(0, 300) : "";
2645
+ const keyword = typeof payload.keyword === "string" ? payload.keyword.trim().slice(0, 80) : "";
2646
+ const fallbackKeyword = typeof payload.fallbackKeyword === "string" ? payload.fallbackKeyword.trim().slice(0, 80) : void 0;
2647
+ if (!openingMedia && keyword) void resolveOpeningMedia(keyword, fallbackKeyword);
2648
+ if (!openingRequested && line) {
2649
+ spokenHook = line;
2650
+ lines.push(line);
2651
+ openingRequested = true;
2652
+ openingActive = true;
2653
+ void speakOpening(line);
2654
+ }
2655
+ continue;
2656
+ }
2657
+ if (event.type === "response.error" && event.data.terminal) {
2658
+ terminalError = new VideoError("Video chat could not finish this response", {
2659
+ code: event.data.error.code,
2660
+ requestId: event.data.snapshot ? void 0 : id,
2661
+ recoverable: event.data.error.recoverable
2662
+ });
2663
+ }
2664
+ if (event.type === "response.abort") {
2665
+ terminalError = new VideoError("Video chat was interrupted", { code: "aborted", recoverable: false });
2666
+ }
2667
+ if (event.type !== "scene.add") continue;
2668
+ const position = event.data.position;
2669
+ const plannedScene = event.data.scene;
2670
+ planned[position] = plannedScene;
2671
+ received[position] = prepareVisualScene(plannedScene, mode);
2672
+ if (!currentOptions.templates?.getTemplate(plannedScene.templateId)) {
2673
+ preloadBuiltinTemplate(plannedScene.templateId);
2674
+ }
2675
+ warmSceneMedia(plannedScene.variables);
2676
+ const narrated = narrating.then(async () => {
2677
+ const supplied = plannedScene.narration?.trim();
2678
+ if (supplied) return supplied;
2679
+ return withDeadline(async (signal) => {
2680
+ const narrationResponse = await request("narration", {
2681
+ method: "POST",
2682
+ body: JSON.stringify({ prompt, scene: plannedScene, earlier: [...lines] })
2683
+ }, signal);
2684
+ if (!narrationResponse.ok) throw new Error("Narration unavailable");
2685
+ const payload = await narrationResponse.json();
2686
+ const line = typeof payload.line === "string" ? payload.line.trim() : "";
2687
+ if (!line) throw new Error("Narration unavailable");
2688
+ return line;
2689
+ }, 3e3, controller.signal);
2690
+ }).catch((cause) => {
2691
+ if (controller.signal.aborted) throw cause;
2692
+ warn("Some narration was simplified so the response could continue.");
2693
+ return Object.values(plannedScene.variables).filter((value2) => typeof value2 === "string" && !/^https?:/i.test(value2)).join(" ").slice(0, 500);
2694
+ }).then((line) => {
2695
+ if (line) lines.push(line);
2696
+ return line;
2615
2697
  });
2698
+ narrating = narrated.catch(() => "");
2699
+ pending.push(narrated.then(async (line) => {
2700
+ if (!isCurrent() || currentAttempt !== attempt) return;
2701
+ const visual = prepareVisualScene(plannedScene, mode);
2702
+ const withNarration = line ? { ...visual, narration: line } : visual;
2703
+ const spoken = line ? await prepareSpeech(line, controller.signal).catch((cause) => {
2704
+ if (controller.signal.aborted) throw cause;
2705
+ warn("Some narration is unavailable; the response will continue.");
2706
+ return void 0;
2707
+ }) : void 0;
2708
+ if (!isCurrent() || currentAttempt !== attempt) return;
2709
+ ready[position] = pacedScene(withNarration, spoken?.seconds, currentOptions.templates);
2710
+ flush();
2711
+ }).catch(() => {
2712
+ if (!isCurrent() || currentAttempt !== attempt) return;
2713
+ ready[position] = { ...received[position], timing: { fixedDuration: 5 } };
2714
+ warn("Some parts were simplified so the response could continue.");
2715
+ flush();
2716
+ }));
2616
2717
  }
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
- }));
2718
+ } catch (cause) {
2719
+ if (controller.signal.aborted) throw cause;
2720
+ terminalError = errorFrom(cause);
2652
2721
  }
2653
2722
  await Promise.all(pending);
2654
- if (terminalError) throw terminalError;
2723
+ if (terminalError && ready.some(Boolean) && style) {
2724
+ warn("The response was interrupted; completed scenes are still available.");
2725
+ } else if (terminalError) throw terminalError;
2655
2726
  if (planned.length === 0 || ready.filter(Boolean).length === 0 || !style) {
2656
2727
  throw new VideoError("The video response contained no playable scenes", { code: "empty_response" });
2657
2728
  }
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
2729
  return {
2671
2730
  video: {
2672
2731
  schemaVersion: "0.1",
@@ -2674,25 +2733,43 @@ function useVideoChat(options = {}) {
2674
2733
  scenes: ready.filter((entry) => entry != null),
2675
2734
  style
2676
2735
  },
2677
- suggestions: suggestions2
2736
+ lines
2678
2737
  };
2679
2738
  };
2680
2739
  try {
2681
2740
  let response;
2682
2741
  try {
2683
- response = await runAttempt(attempt);
2742
+ response = await untilAborted(runAttempt(attempt), controller.signal);
2684
2743
  } catch (cause) {
2685
- if (controller.signal.aborted || timeline) throw cause;
2744
+ if (controller.signal.aborted || timeline || spokenHook) throw cause;
2686
2745
  attempt += 1;
2687
2746
  ready = [];
2747
+ received = [];
2688
2748
  appended = 0;
2689
2749
  style = void 0;
2690
- response = await runAttempt(attempt);
2750
+ response = await untilAborted(runAttempt(attempt), controller.signal);
2691
2751
  }
2692
2752
  if (!isCurrent()) return void 0;
2693
2753
  planDone = true;
2694
2754
  flush();
2695
- dispatch({ type: "complete", id, video: response.video, suggestions: response.suggestions });
2755
+ dispatch({ type: "complete", id, video: response.video, suggestions: [] });
2756
+ const suggestionsController = new AbortController();
2757
+ suggestionsRef.current = suggestionsController;
2758
+ void withDeadline(async (signal) => {
2759
+ const result = await request("suggestions", {
2760
+ method: "POST",
2761
+ body: JSON.stringify({ prompt, lines: response.lines })
2762
+ }, signal);
2763
+ if (!result.ok) return [];
2764
+ const payload = await result.json();
2765
+ return Array.isArray(payload.suggestions) ? payload.suggestions : [];
2766
+ }, 7e3, suggestionsController.signal).then((suggestions2) => {
2767
+ if (mountedRef.current && runRef.current === run && !suggestionsController.signal.aborted) {
2768
+ dispatch({ type: "suggestions", id, suggestions: suggestions2 });
2769
+ }
2770
+ }).catch(() => void 0).finally(() => {
2771
+ if (suggestionsRef.current === suggestionsController) suggestionsRef.current = void 0;
2772
+ });
2696
2773
  return response.video;
2697
2774
  } catch (cause) {
2698
2775
  if (runRef.current !== run) return void 0;
@@ -2700,31 +2777,52 @@ function useVideoChat(options = {}) {
2700
2777
  return void 0;
2701
2778
  }
2702
2779
  terminal = true;
2780
+ const recovered = received.flatMap((scene, index) => scene ? [ready[index] ?? { ...scene, timing: { fixedDuration: 5 } }] : []);
2781
+ if (recovered.length === 0 && spokenHook) {
2782
+ 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) } });
2783
+ }
2784
+ if (recovered.length > 0) {
2785
+ style ??= { brand: resolveVideoBrand(currentOptions.brand), density: "normal", motion: "normal", defaultBackgroundEffect: "static", defaultTextArchetype: "subtle", defaultTransition: "crossfade" };
2786
+ openingController.abort(new DOMException("Continuing completed response", "AbortError"));
2787
+ if (!timeline) {
2788
+ timeline = createSceneTimeline({ style, orientation });
2789
+ dispatch({ type: "player", id, stream: timeline.stream });
2790
+ }
2791
+ for (const scene of recovered.slice(appended)) timeline.add(scene);
2792
+ timeline.complete();
2793
+ if (timelineRef.current === timeline) timelineRef.current = void 0;
2794
+ const video = { schemaVersion: "0.1", orientation, style, scenes: recovered };
2795
+ dispatch({ type: "warning", id, message: "The response was interrupted; completed scenes are still available." });
2796
+ dispatch({ type: "complete", id, video, suggestions: [] });
2797
+ return video;
2798
+ }
2703
2799
  openingController.abort(new DOMException("Response failed", "AbortError"));
2704
2800
  timeline?.complete();
2705
2801
  if (timelineRef.current === timeline) timelineRef.current = void 0;
2706
- const error = errorFrom2(cause);
2802
+ const error = errorFrom(cause);
2707
2803
  dispatch({ type: "error", id, error });
2708
2804
  return void 0;
2709
2805
  } finally {
2710
2806
  clearTimeout(timeout);
2711
2807
  if (runRef.current === run) inFlightRef.current = void 0;
2712
2808
  }
2713
- }, [request]);
2714
- const pause = useCallback3(() => {
2809
+ }, [request, endTiming]);
2810
+ const pause = useCallback2(() => {
2811
+ finishStall();
2715
2812
  heldRef.current = true;
2716
2813
  voiceRef.current.pause();
2717
2814
  dispatch({ type: "pause" });
2718
- }, []);
2719
- const resume = useCallback3(() => {
2815
+ }, [finishStall]);
2816
+ const resume = useCallback2(() => {
2720
2817
  heldRef.current = false;
2721
2818
  voiceRef.current.resume();
2722
2819
  dispatch({ type: "resume" });
2723
2820
  flushRef.current?.();
2724
2821
  }, []);
2725
- const replay = useCallback3(() => {
2822
+ const replay = useCallback2(() => {
2726
2823
  const turn = stateRef.current.turns.find((entry) => entry.id === stateRef.current.shownTurnId);
2727
2824
  if (!turn?.completed || !turn.video) return;
2825
+ endTiming();
2728
2826
  if (inFlightRef.current) {
2729
2827
  runRef.current += 1;
2730
2828
  inFlightRef.current.abort(new DOMException("Replaying a saved response", "AbortError"));
@@ -2738,10 +2836,11 @@ function useVideoChat(options = {}) {
2738
2836
  heldRef.current = false;
2739
2837
  voiceRef.current.resume();
2740
2838
  dispatch({ type: "replay" });
2741
- }, []);
2742
- const selectTurn = useCallback3((id) => {
2839
+ }, [endTiming]);
2840
+ const selectTurn = useCallback2((id) => {
2743
2841
  const turn = stateRef.current.turns.find((entry) => entry.id === id);
2744
2842
  if (!turn?.completed || !turn.video) return;
2843
+ endTiming();
2745
2844
  if (inFlightRef.current) {
2746
2845
  runRef.current += 1;
2747
2846
  inFlightRef.current.abort(new DOMException("Viewing a saved response", "AbortError"));
@@ -2755,14 +2854,14 @@ function useVideoChat(options = {}) {
2755
2854
  heldRef.current = false;
2756
2855
  voiceRef.current.resume();
2757
2856
  dispatch({ type: "select", id });
2758
- }, []);
2759
- const reset = useCallback3(() => {
2857
+ }, [endTiming]);
2858
+ const reset = useCallback2(() => {
2760
2859
  cancel("Session reset");
2761
2860
  heldRef.current = false;
2762
2861
  voiceRef.current.resume();
2763
2862
  dispatch({ type: "reset" });
2764
2863
  }, [cancel]);
2765
- const setMuted = useCallback3((muted) => dispatch({ type: "mute", value: muted }), []);
2864
+ const setMuted = useCallback2((muted) => dispatch({ type: "mute", value: muted }), []);
2766
2865
  const currentTurn = state.turns.at(-1);
2767
2866
  const shownTurn = state.turns.find((turn) => turn.id === state.shownTurnId) ?? currentTurn;
2768
2867
  const availableModes = state.capabilities?.modes ?? ["templates"];
@@ -2777,27 +2876,35 @@ function useVideoChat(options = {}) {
2777
2876
  paused: state.status === "paused",
2778
2877
  controls: false,
2779
2878
  orientation: shownTurn?.fixedOrientation ? shownTurn.orientation : "auto",
2879
+ onFramePresented: () => {
2880
+ const timing = firstFrameRef.current;
2881
+ if (stateRef.current.playerKey !== playbackKey || state.playback?.kind !== "stream" || !timing?.active || timing.reported) return;
2882
+ timing.reported = true;
2883
+ const elapsedMs = Math.max(0, Math.round(monotonicNow() - timing.startedAt));
2884
+ observe(() => optionsRef.current.onFirstFrame?.({ turnId: timing.turnId, mode: timing.mode, timeToFirstFrameMs: elapsedMs }));
2885
+ reportMetric({ type: "first-frame", turnId: timing.turnId, mode: timing.mode, elapsedMs });
2886
+ },
2887
+ onStallChange: (stalled) => {
2888
+ if (stateRef.current.playerKey !== playbackKey || state.playback?.kind !== "stream") return;
2889
+ const timing = firstFrameRef.current;
2890
+ if (!timing?.active || !timing.reported) return;
2891
+ if (!stalled) finishStall();
2892
+ else if (!heldRef.current && timing.stallStartedAt == null) timing.stallStartedAt = monotonicNow();
2893
+ },
2780
2894
  onSceneChange: (scene, index) => {
2781
2895
  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
2896
  dispatch({ type: "scene", key: playbackKey, scene, index });
2795
2897
  narrationRef.current.onSceneChange(scene, index);
2796
2898
  },
2797
- onPlaybackEnd: () => dispatch({ type: "playback-end", key: playbackKey }),
2899
+ onPlaybackEnd: () => {
2900
+ if (stateRef.current.playerKey !== playbackKey) return;
2901
+ endTiming();
2902
+ dispatch({ type: "playback-end", key: playbackKey });
2903
+ },
2798
2904
  onError: (cause) => {
2905
+ if (stateRef.current.playerKey !== playbackKey) return;
2799
2906
  const id = stateRef.current.turns.at(-1)?.id;
2800
- if (id) dispatch({ type: "error", id, error: errorFrom2(cause) });
2907
+ if (id) dispatch({ type: "error", id, error: errorFrom(cause) });
2801
2908
  }
2802
2909
  } : void 0;
2803
2910
  return {
@@ -2815,6 +2922,7 @@ function useVideoChat(options = {}) {
2815
2922
  welcome: state.welcome,
2816
2923
  availableModes,
2817
2924
  status: state.status,
2925
+ warnings: shownTurn?.warnings ?? [],
2818
2926
  error: state.error,
2819
2927
  suggestions,
2820
2928
  caption: state.caption,
@@ -2829,7 +2937,7 @@ function useVideoChat(options = {}) {
2829
2937
  }
2830
2938
 
2831
2939
  // 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";
2940
+ import { useCallback as useCallback5, useEffect as useEffect8, useId, useMemo as useMemo2, useRef as useRef6, useState as useState5 } from "react";
2833
2941
 
2834
2942
  // src/video-chat/modes.ts
2835
2943
  var visualModes = [
@@ -2939,9 +3047,9 @@ var Warning = () => /* @__PURE__ */ jsxs3(Glyph, { children: [
2939
3047
  ] });
2940
3048
 
2941
3049
  // src/video-chat/use-dismiss.ts
2942
- import { useEffect as useEffect6 } from "react";
3050
+ import { useEffect as useEffect5 } from "react";
2943
3051
  function useDismiss(open, close, surfaces) {
2944
- useEffect6(() => {
3052
+ useEffect5(() => {
2945
3053
  if (!open) return;
2946
3054
  const onKeyDown = (event) => {
2947
3055
  if (event.key !== "Escape") return;
@@ -2963,7 +3071,7 @@ function useDismiss(open, close, surfaces) {
2963
3071
  }, [open, close, surfaces]);
2964
3072
  }
2965
3073
  function useFocusTrap(active, surface) {
2966
- useEffect6(() => {
3074
+ useEffect5(() => {
2967
3075
  if (!active) return;
2968
3076
  const returnTo = document.activeElement;
2969
3077
  const focusable = () => Array.from(
@@ -2996,11 +3104,11 @@ function useFocusTrap(active, surface) {
2996
3104
  }
2997
3105
 
2998
3106
  // src/video-chat/suggestion-cards.tsx
2999
- import { useCallback as useCallback4, useEffect as useEffect7, useRef as useRef5, useState as useState4 } from "react";
3107
+ import { useCallback as useCallback3, useEffect as useEffect6, useRef as useRef4, useState as useState3 } from "react";
3000
3108
  import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
3001
3109
  function Frame({ media, poster, playing }) {
3002
- const video = useRef5(null);
3003
- useEffect7(() => {
3110
+ const video = useRef4(null);
3111
+ useEffect6(() => {
3004
3112
  const element = video.current;
3005
3113
  if (!element || playing === void 0) return;
3006
3114
  if (playing) void element.play().catch(() => void 0);
@@ -3025,20 +3133,20 @@ function Frame({ media, poster, playing }) {
3025
3133
  );
3026
3134
  }
3027
3135
  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) => {
3136
+ const railRef = useRef4(null);
3137
+ const [at, setAt] = useState3(0);
3138
+ const [taken, setTaken] = useState3(false);
3139
+ const take = useCallback3((index) => {
3032
3140
  setTaken(true);
3033
3141
  setAt(index);
3034
3142
  }, []);
3035
- useEffect7(() => {
3143
+ useEffect6(() => {
3036
3144
  if (taken || suggestions.length < 2) return;
3037
3145
  if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
3038
3146
  const tour = window.setInterval(() => setAt((index) => (index + 1) % suggestions.length), 5e3);
3039
3147
  return () => window.clearInterval(tour);
3040
3148
  }, [taken, suggestions.length]);
3041
- useEffect7(() => {
3149
+ useEffect6(() => {
3042
3150
  const rail = railRef.current;
3043
3151
  const card = rail?.children[at];
3044
3152
  if (!rail || !card) return;
@@ -3095,7 +3203,7 @@ function Welcome({ data, onAsk, title }) {
3095
3203
  }
3096
3204
 
3097
3205
  // src/video-chat/use-voice-input.ts
3098
- import { useCallback as useCallback5, useEffect as useEffect8, useRef as useRef6, useState as useState5 } from "react";
3206
+ import { useCallback as useCallback4, useEffect as useEffect7, useRef as useRef5, useState as useState4 } from "react";
3099
3207
  function recognitionConstructor() {
3100
3208
  if (typeof window === "undefined") return void 0;
3101
3209
  const holder = window;
@@ -3165,19 +3273,19 @@ async function recordAndTranscribe(signal, ready, captured, options) {
3165
3273
  return clip ? transcribeRecording(clip, signal, options) : "";
3166
3274
  }
3167
3275
  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);
3276
+ const [supported, setSupported] = useState4(false);
3277
+ const [listening, setListening] = useState4(false);
3278
+ const [error, setError] = useState4();
3279
+ const [thinking, setThinking] = useState4(false);
3280
+ const recognitionRef = useRef5(void 0);
3281
+ const useRecorderRef = useRef5(false);
3282
+ const operationRef = useRef5(void 0);
3283
+ const handlerRef = useRef5(onTranscript);
3176
3284
  handlerRef.current = onTranscript;
3177
- useEffect8(() => {
3285
+ useEffect7(() => {
3178
3286
  setSupported(supportsVoiceInput(transcriptionAvailable));
3179
3287
  }, [transcriptionAvailable]);
3180
- const stop = useCallback5(() => {
3288
+ const stop = useCallback4(() => {
3181
3289
  recognitionRef.current?.abort();
3182
3290
  recognitionRef.current = void 0;
3183
3291
  const operation = operationRef.current;
@@ -3187,7 +3295,7 @@ function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {
3187
3295
  setListening(false);
3188
3296
  setThinking(false);
3189
3297
  }, []);
3190
- useEffect8(() => () => {
3298
+ useEffect7(() => () => {
3191
3299
  recognitionRef.current?.abort();
3192
3300
  recognitionRef.current = void 0;
3193
3301
  const operation = operationRef.current;
@@ -3195,7 +3303,7 @@ function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {
3195
3303
  operation?.controller.abort();
3196
3304
  operation?.finish?.();
3197
3305
  }, []);
3198
- const finish = useCallback5(() => {
3306
+ const finish = useCallback4(() => {
3199
3307
  const recognition = recognitionRef.current;
3200
3308
  if (recognition) {
3201
3309
  recognition.stop();
@@ -3209,7 +3317,7 @@ function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {
3209
3317
  }
3210
3318
  stop();
3211
3319
  }, [stop]);
3212
- const record = useCallback5(async () => {
3320
+ const record = useCallback4(async () => {
3213
3321
  if (!transcriptionAvailable || !recorderAvailable()) {
3214
3322
  setError("Server transcription is not configured.");
3215
3323
  return;
@@ -3247,7 +3355,7 @@ function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {
3247
3355
  }
3248
3356
  }
3249
3357
  }, [transcriptionAvailable, options.endpoint, options.headers, options.credentials, options.fetcher]);
3250
- const toggle = useCallback5(() => {
3358
+ const toggle = useCallback4(() => {
3251
3359
  if (listening) {
3252
3360
  finish();
3253
3361
  return;
@@ -3302,8 +3410,8 @@ function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {
3302
3410
  import { Fragment as Fragment4, jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
3303
3411
  var DESKTOP_WIDTH = 900;
3304
3412
  function useViewportOrientation() {
3305
- const [portrait, setPortrait] = useState6(() => typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia(`(max-width: ${DESKTOP_WIDTH - 1}px)`).matches : false);
3306
- useEffect9(() => {
3413
+ const [portrait, setPortrait] = useState5(() => typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia(`(max-width: ${DESKTOP_WIDTH - 1}px)`).matches : false);
3414
+ useEffect8(() => {
3307
3415
  if (typeof window.matchMedia !== "function") return;
3308
3416
  const query = window.matchMedia(`(max-width: ${DESKTOP_WIDTH - 1}px)`);
3309
3417
  const update = () => setPortrait(query.matches);
@@ -3320,8 +3428,8 @@ var STEPS = [
3320
3428
  "Still filming\u2026"
3321
3429
  ];
3322
3430
  function useFilmingStep(active, key) {
3323
- const [step, setStep] = useState6(0);
3324
- useEffect9(() => {
3431
+ const [step, setStep] = useState5(0);
3432
+ useEffect8(() => {
3325
3433
  if (!active) return;
3326
3434
  setStep(0);
3327
3435
  const stepper = window.setInterval(() => setStep((current) => current + 1), 3e3);
@@ -3333,14 +3441,14 @@ function Waveform({ active, listening }) {
3333
3441
  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)) });
3334
3442
  }
3335
3443
  function VideoChat({ options = {}, className, welcomeTitle }) {
3336
- const [draft, setDraft] = useState6("");
3337
- const [themeId, setThemeId] = useState6(defaultTheme.id);
3338
- const [modeId, setModeId] = useState6(options.mode ?? defaultMode.id);
3339
- const [historyOpen, setHistoryOpen] = useState6(false);
3340
- const [settingsOpen, setSettingsOpen] = useState6(false);
3341
- const [sheetOpen, setSheetOpen] = useState6(false);
3444
+ const [draft, setDraft] = useState5("");
3445
+ const [themeId, setThemeId] = useState5(defaultTheme.id);
3446
+ const [modeId, setModeId] = useState5(options.mode ?? defaultMode.id);
3447
+ const [historyOpen, setHistoryOpen] = useState5(false);
3448
+ const [settingsOpen, setSettingsOpen] = useState5(false);
3449
+ const [sheetOpen, setSheetOpen] = useState5(false);
3342
3450
  const viewportOrientation = useViewportOrientation();
3343
- const [themeChoice, setThemeChoice] = useState6("system");
3451
+ const [themeChoice, setThemeChoice] = useState5("system");
3344
3452
  const theme = themeById(themeId);
3345
3453
  const sessionOrientation = options.orientation ?? viewportOrientation;
3346
3454
  const { brand: configuredBrand, style: configuredStyle, ...sessionOptions } = options;
@@ -3360,29 +3468,29 @@ function VideoChat({ options = {}, className, welcomeTitle }) {
3360
3468
  const appearanceName = `${instanceId}-appearance`;
3361
3469
  const visualsName = `${instanceId}-visuals`;
3362
3470
  const styleName = `${instanceId}-style`;
3363
- const inputRef = useRef7(null);
3364
- const historyRef = useRef7(null);
3365
- const historyButtonRef = useRef7(null);
3366
- const settingsRef = useRef7(null);
3367
- const settingsButtonRef = useRef7(null);
3368
- const sheetRef = useRef7(null);
3471
+ const inputRef = useRef6(null);
3472
+ const historyRef = useRef6(null);
3473
+ const historyButtonRef = useRef6(null);
3474
+ const settingsRef = useRef6(null);
3475
+ const settingsButtonRef = useRef6(null);
3476
+ const sheetRef = useRef6(null);
3369
3477
  const listen = useVoiceInput(setDraft, chat.capabilities?.transcription ?? false, {
3370
3478
  endpoint: options.endpoint,
3371
3479
  headers: options.headers,
3372
3480
  credentials: options.credentials,
3373
3481
  fetcher: options.fetcher
3374
3482
  });
3375
- const historySurfaces = useMemo3(() => [historyRef, historyButtonRef], []);
3376
- const settingsSurfaces = useMemo3(() => [settingsRef, settingsButtonRef], []);
3377
- const noSurfaces = useMemo3(() => [], []);
3378
- const closeHistory = useCallback6(() => setHistoryOpen(false), []);
3379
- const closeSettings = useCallback6(() => setSettingsOpen(false), []);
3380
- const closeSheet = useCallback6(() => setSheetOpen(false), []);
3483
+ const historySurfaces = useMemo2(() => [historyRef, historyButtonRef], []);
3484
+ const settingsSurfaces = useMemo2(() => [settingsRef, settingsButtonRef], []);
3485
+ const noSurfaces = useMemo2(() => [], []);
3486
+ const closeHistory = useCallback5(() => setHistoryOpen(false), []);
3487
+ const closeSettings = useCallback5(() => setSettingsOpen(false), []);
3488
+ const closeSheet = useCallback5(() => setSheetOpen(false), []);
3381
3489
  useDismiss(historyOpen, closeHistory, historySurfaces);
3382
3490
  useDismiss(settingsOpen, closeSettings, settingsSurfaces);
3383
3491
  useDismiss(sheetOpen, closeSheet, noSurfaces);
3384
3492
  useFocusTrap(sheetOpen, sheetRef);
3385
- const ask = useCallback6((value) => {
3493
+ const ask = useCallback5((value) => {
3386
3494
  const prompt = (typeof value === "string" ? value : value.prompt).trim();
3387
3495
  if (!prompt) return;
3388
3496
  setDraft("");
@@ -3395,7 +3503,7 @@ function VideoChat({ options = {}, className, welcomeTitle }) {
3395
3503
  opening: value.opening
3396
3504
  });
3397
3505
  }, [chat, listen]);
3398
- const newSession = useCallback6(() => {
3506
+ const newSession = useCallback5(() => {
3399
3507
  if (chat.turns.length === 0) {
3400
3508
  inputRef.current?.focus();
3401
3509
  return;
@@ -3643,7 +3751,5 @@ export {
3643
3751
  VideoError,
3644
3752
  VideoPlayer,
3645
3753
  createVideoChatVoice,
3646
- useNarration,
3647
- useVideo,
3648
3754
  useVideoChat
3649
3755
  };