@vanillaskyai/video 0.11.1 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,12 @@ VanillaSky follows semantic versioning. This changelog begins with the 0.1 beta.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.11.2
8
+
9
+ - Show the spoken introduction in the normal subtitle line when subtitles are enabled. Keep the opening once in the full transcript, including replay and restored sessions.
10
+ - Keep “Ask next” directly above its follow-up thumbnails on desktop and phone layouts.
11
+ - Allow one native-speed repeat for a small measured speech overrun on healthy footage, ending when speech finishes. Never repeat solely for the quiet tail; retain chapter recovery for larger or unmeasured overruns and failed media. Planning still targets a 0.8-second tail and never buys an extra clip to fit narration.
12
+
7
13
  ## 0.11.1
8
14
 
9
15
  - Plan single-idea narration with voice headroom against the configured video duration; keep the full repair budget for necessary meaning. Give the single rewrite explicit speech/word budgets and report content-free outcomes instead of silently collapsing timeout, empty, failed and oversized results.
package/PUBLIC-API.md CHANGED
@@ -35,7 +35,7 @@ Two visual modes remain: `cinematic` (AI video) and `pexels` (application-owned
35
35
 
36
36
  The video callback receives `requestedDurationSec`, `shotDirection` and an absolute `deadlineAt`, alongside its abort signal. Returned media may report `durationSec`. Configure the model, clip duration and timeout together in the application adapter; the server accepts timeouts up to ten minutes, while the client defaults to an eleven-minute response deadline. These are safety ceilings, not latency promises.
37
37
 
38
- Planning and playback share one conservative narration budget, reserving at least 0.8 seconds of quiet footage. Before buying a clip, the handler allows one bounded `generateText` call with task `narration-rewrite`. Oversized or failed rewrites retain the original narration on a chapter and do not buy a clip. Measured audio and decoded footage are checked again before playback; normal video runs at its native speed without repeats. Rewriting is model-assisted, not automatic fact verification.
38
+ Planning targets at least 0.8 seconds of quiet footage after narration. Before buying a clip, the handler allows one bounded `generateText` call with task `narration-rewrite`. Oversized or failed rewrites retain the original narration on a chapter and do not buy a clip. Measured audio and decoded footage are checked again before playback; normal video runs at its native speed without repeats. A measured speech overrun of at most the smaller of one second or 25% of the actual clip may repeat healthy footage once, ending at speech completion. Footage never repeats just to fill a quiet tail; larger/unmeasured overruns and failed media recover to a chapter. Rewriting is model-assisted, not automatic fact verification.
39
39
 
40
40
  `authorize` is required; use `authorize: "none"` only for intentionally local/test handlers. `invalidPartBehavior: "drop"` preserves valid scenes after malformed planner output; `"fail"` opts into strict failure. Interrupted plans preserve playable partial answers and emit a safe warning. The narration action is a fallback for missing narration, not a second model call in the normal path.
41
41
 
package/README.md CHANGED
@@ -67,8 +67,9 @@ substitutes stock; stock mode never spends on generated video. A failed or late
67
67
  clip becomes a narrated chapter. Narration targets a 0.8-second visual tail;
68
68
  one short rewrite may fit an oversized beat before generation. If it still
69
69
  does not fit, the complete original narration plays over a chapter. Footage
70
- plays once at native speed; unexpected overruns recover to a chapter without
71
- cutting off the sentence.
70
+ normally plays once at native speed. A small measured speech overrun can repeat
71
+ healthy footage once, only until speech finishes; larger or unmeasured overruns
72
+ recover to a chapter without cutting off the sentence.
72
73
 
73
74
  This is progressive **scene** delivery, not real-time frames from every vendor.
74
75
  Some generation APIs take minutes; preloading cannot remove that latency.
@@ -0,0 +1,39 @@
1
+ // src/protocol/clip-budget.ts
2
+ var CLIP_NARRATION_TAIL_SEC = 0.8;
3
+ function clipNarrationBudget(clipDurationSec) {
4
+ const maxSpeechSec = Math.max(0, clipDurationSec - CLIP_NARRATION_TAIL_SEC);
5
+ const maxWords = Math.floor(maxSpeechSec * 2);
6
+ const maxUnspacedCharacters = Math.floor(maxSpeechSec * 3);
7
+ return {
8
+ clipDurationSec,
9
+ maxSpeechSec,
10
+ targetWords: Math.floor(maxWords * 0.8),
11
+ targetUnspacedCharacters: Math.floor(maxUnspacedCharacters * 0.8),
12
+ maxWords,
13
+ maxUnspacedCharacters
14
+ };
15
+ }
16
+ var UNSPACED_SCRIPT = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/gu;
17
+ function estimateNarrationSeconds(text) {
18
+ const normalized = text.trim();
19
+ if (!normalized) return 0;
20
+ const characters = normalized.match(UNSPACED_SCRIPT)?.length ?? 0;
21
+ const words = normalized.replace(UNSPACED_SCRIPT, " ").match(/[\p{L}\p{N}]+(?:['’][\p{L}\p{N}]+)*/gu)?.length ?? 0;
22
+ const numericExpansion = (normalized.match(/\p{Nd}+/gu) ?? []).reduce((sum, digits) => sum + Math.max(0, digits.length - 1) + (digits.length >= 3 ? 1 : 0), 0);
23
+ const symbols = normalized.match(/[%°\p{Sc}]/gu)?.length ?? 0;
24
+ const pauses = normalized.match(/[.!?。!?;;::]/gu)?.length ?? 0;
25
+ return (words + numericExpansion + symbols) / 2.2 + characters / 3.5 + pauses * 0.15;
26
+ }
27
+ function speechFitsClip(seconds, durationSec) {
28
+ return Number.isFinite(seconds) && seconds >= 0 && Number.isFinite(durationSec) && durationSec > 0 && seconds + CLIP_NARRATION_TAIL_SEC <= durationSec + 1e-6;
29
+ }
30
+ function narrationFitsClip(text, durationSec) {
31
+ return speechFitsClip(estimateNarrationSeconds(text), durationSec);
32
+ }
33
+
34
+ export {
35
+ CLIP_NARRATION_TAIL_SEC,
36
+ clipNarrationBudget,
37
+ estimateNarrationSeconds,
38
+ narrationFitsClip
39
+ };
@@ -1,3 +1,7 @@
1
+ import {
2
+ CLIP_NARRATION_TAIL_SEC
3
+ } from "./chunk-NEKYSCT6.js";
4
+
1
5
  // src/visual-system/scene-templates/external-video-backdrop.tsx
2
6
  import React from "react";
3
7
  import { jsx } from "react/jsx-runtime";
@@ -32,10 +36,20 @@ function useMediaFailure() {
32
36
  return React.useContext(BackdropContext).onMediaError;
33
37
  }
34
38
 
39
+ // src/player/clip-repeat.ts
40
+ function measuredClipPlayback(spokenSeconds, clipDurationSec) {
41
+ if (typeof spokenSeconds !== "number" || !Number.isFinite(spokenSeconds) || spokenSeconds <= 0 || !Number.isFinite(clipDurationSec) || clipDurationSec <= 0) return void 0;
42
+ const overrun = spokenSeconds - clipDurationSec;
43
+ if (overrun <= 0) return { durationSec: Math.min(spokenSeconds + CLIP_NARRATION_TAIL_SEC, clipDurationSec), repeat: false };
44
+ if (overrun <= Math.min(1, clipDurationSec * 0.25) + 1e-6) return { durationSec: spokenSeconds, repeat: true };
45
+ return void 0;
46
+ }
47
+
35
48
  export {
36
49
  ExternalVideoBackdropProvider,
37
50
  useExternalVideoBackdrop,
38
51
  useMediaAudio,
39
52
  useNarrationPreroll,
40
- useMediaFailure
53
+ useMediaFailure,
54
+ measuredClipPlayback
41
55
  };
@@ -136,39 +136,6 @@ function videoSseHeaders(headers) {
136
136
  return result;
137
137
  }
138
138
 
139
- // src/protocol/clip-budget.ts
140
- var CLIP_NARRATION_TAIL_SEC = 0.8;
141
- function clipNarrationBudget(clipDurationSec) {
142
- const maxSpeechSec = Math.max(0, clipDurationSec - CLIP_NARRATION_TAIL_SEC);
143
- const maxWords = Math.floor(maxSpeechSec * 2);
144
- const maxUnspacedCharacters = Math.floor(maxSpeechSec * 3);
145
- return {
146
- clipDurationSec,
147
- maxSpeechSec,
148
- targetWords: Math.floor(maxWords * 0.8),
149
- targetUnspacedCharacters: Math.floor(maxUnspacedCharacters * 0.8),
150
- maxWords,
151
- maxUnspacedCharacters
152
- };
153
- }
154
- var UNSPACED_SCRIPT = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/gu;
155
- function estimateNarrationSeconds(text) {
156
- const normalized = text.trim();
157
- if (!normalized) return 0;
158
- const characters = normalized.match(UNSPACED_SCRIPT)?.length ?? 0;
159
- const words = normalized.replace(UNSPACED_SCRIPT, " ").match(/[\p{L}\p{N}]+(?:['’][\p{L}\p{N}]+)*/gu)?.length ?? 0;
160
- const numericExpansion = (normalized.match(/\p{Nd}+/gu) ?? []).reduce((sum, digits) => sum + Math.max(0, digits.length - 1) + (digits.length >= 3 ? 1 : 0), 0);
161
- const symbols = normalized.match(/[%°\p{Sc}]/gu)?.length ?? 0;
162
- const pauses = normalized.match(/[.!?。!?;;::]/gu)?.length ?? 0;
163
- return (words + numericExpansion + symbols) / 2.2 + characters / 3.5 + pauses * 0.15;
164
- }
165
- function speechFitsClip(seconds, durationSec) {
166
- return Number.isFinite(seconds) && seconds >= 0 && Number.isFinite(durationSec) && durationSec > 0 && seconds + CLIP_NARRATION_TAIL_SEC <= durationSec + 1e-6;
167
- }
168
- function narrationFitsClip(text, durationSec) {
169
- return speechFitsClip(estimateNarrationSeconds(text), durationSec);
170
- }
171
-
172
139
  export {
173
140
  WELCOME_CARDS,
174
141
  orderWelcomeCards,
@@ -176,10 +143,5 @@ export {
176
143
  MEDIA_RECOVERY_NOTICE,
177
144
  encodeVideoSseEvent,
178
145
  decodeVideoSse,
179
- videoSseHeaders,
180
- CLIP_NARRATION_TAIL_SEC,
181
- clipNarrationBudget,
182
- estimateNarrationSeconds,
183
- speechFitsClip,
184
- narrationFitsClip
146
+ videoSseHeaders
185
147
  };
@@ -1,12 +1,14 @@
1
1
  import {
2
+ measuredClipPlayback,
2
3
  useExternalVideoBackdrop,
3
4
  useMediaAudio,
4
5
  useMediaFailure,
5
6
  useNarrationPreroll
6
- } from "./chunk-666HGTVZ.js";
7
+ } from "./chunk-R2RJIB6B.js";
7
8
  import {
8
9
  resolveMediaType
9
10
  } from "./chunk-K5J7ESRO.js";
11
+ import "./chunk-NEKYSCT6.js";
10
12
 
11
13
  // src/visual-system/scene-templates/scene-background.tsx
12
14
  import { useEffect as useEffect2, useState as useState2 } from "react";
@@ -32,6 +34,7 @@ var SceneVideoBackdrop = ({
32
34
  mediaPosition = "center",
33
35
  progress,
34
36
  sceneDuration,
37
+ measuredSpeechDurationSec,
35
38
  preparingNarration = false,
36
39
  isPlaying,
37
40
  muted,
@@ -50,15 +53,18 @@ var SceneVideoBackdrop = ({
50
53
  const [decodedVideoUrl, setDecodedVideoUrl] = useState();
51
54
  const [waitingKey, setWaitingKey] = useState();
52
55
  const [exhaustedKey, setExhaustedKey] = useState();
56
+ const [repeatingKey, setRepeatingKey] = useState();
57
+ const [endedKey, setEndedKey] = useState();
53
58
  const videoRef = useRef(null);
54
59
  const playableVideoUrl = useRef(void 0);
55
60
  const startedVideoUrl = useRef(void 0);
56
61
  const startedPlaybackId = useRef(void 0);
57
62
  const videoPresentationKey = `${playbackId}\0${mediaUrl}`;
58
- const presentationRef = useRef({ key: videoPresentationKey, playing: isPlaying });
63
+ const presentationRef = useRef({ key: videoPresentationKey, playing: isPlaying, progress });
59
64
  const failedPresentationRef = useRef(void 0);
65
+ const repeatedPresentationRef = useRef(void 0);
60
66
  const previousProgressRef = useRef({ key: videoPresentationKey, progress });
61
- presentationRef.current = { key: videoPresentationKey, playing: isPlaying };
67
+ presentationRef.current = { key: videoPresentationKey, playing: isPlaying, progress };
62
68
  const unavailable = (reason = "playback-error") => {
63
69
  if (presentationRef.current.key === videoPresentationKey && failedPresentationRef.current !== videoPresentationKey && (presentationRef.current.playing || reason === "duration-mismatch")) {
64
70
  failedPresentationRef.current = videoPresentationKey;
@@ -152,20 +158,60 @@ var SceneVideoBackdrop = ({
152
158
  if (frame !== void 0) video.cancelVideoFrameCallback?.(frame);
153
159
  };
154
160
  }, [mediaUrl, videoPresentationKey]);
161
+ const allowsRepeat = (video) => {
162
+ const fit = measuredClipPlayback(measuredSpeechDurationSec, video.duration);
163
+ return fit?.repeat === true && sceneDuration !== void 0 && sceneDuration <= fit.durationSec + 1e-6;
164
+ };
155
165
  const fitDuration = useCallback((video) => {
156
166
  video.playbackRate = 1;
157
- if (sceneDuration && Number.isFinite(video.duration) && video.duration > 0 && sceneDuration > video.duration + 0.05) {
167
+ if (sceneDuration && Number.isFinite(video.duration) && video.duration > 0 && sceneDuration > video.duration + 0.05 && !allowsRepeat(video)) {
158
168
  video.pause();
159
169
  unavailable("duration-mismatch");
160
170
  return false;
161
171
  }
162
172
  return true;
163
- }, [sceneDuration, videoPresentationKey]);
173
+ }, [sceneDuration, measuredSpeechDurationSec, videoPresentationKey]);
164
174
  useEffect(() => {
165
175
  if (videoRef.current) fitDuration(videoRef.current);
166
176
  }, [fitDuration]);
177
+ useEffect(() => {
178
+ if (!isPlaying || endedKey !== videoPresentationKey) return;
179
+ const timer = setTimeout(() => {
180
+ if (videoRef.current?.ended) unavailable();
181
+ }, 50);
182
+ return () => clearTimeout(timer);
183
+ }, [endedKey, videoPresentationKey, isPlaying]);
184
+ useEffect(() => {
185
+ if (!isPlaying || repeatingKey !== videoPresentationKey) return;
186
+ let frame;
187
+ const observe = () => {
188
+ const video = videoRef.current;
189
+ const fit = video && measuredClipPlayback(measuredSpeechDurationSec, video.duration);
190
+ if (!video || presentationRef.current.progress >= 1) return;
191
+ if (!fit?.repeat || video.currentTime > fit.durationSec - video.duration + 0.05) {
192
+ unavailable("duration-mismatch");
193
+ return;
194
+ }
195
+ frame = requestAnimationFrame(observe);
196
+ };
197
+ frame = requestAnimationFrame(observe);
198
+ return () => cancelAnimationFrame(frame);
199
+ }, [repeatingKey, videoPresentationKey, isPlaying, measuredSpeechDurationSec]);
167
200
  const finishMotion = () => {
168
201
  if (!isPlaying) return;
202
+ const video = videoRef.current;
203
+ const fit = video && measuredClipPlayback(measuredSpeechDurationSec, video.duration);
204
+ if (video?.ended && fit && !fit.repeat && sceneDuration !== void 0 && sceneDuration <= video.duration + 0.05 && (1 - progress) * sceneDuration <= 0.05) {
205
+ setEndedKey(videoPresentationKey);
206
+ return;
207
+ }
208
+ if (video && video.ended && !video.error && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && video.currentSrc === video.src && playableVideoUrl.current === mediaUrl && failedPresentationRef.current !== videoPresentationKey && waitingKey !== videoPresentationKey && repeatedPresentationRef.current !== videoPresentationKey && progress < 1 && allowsRepeat(video)) {
209
+ repeatedPresentationRef.current = videoPresentationKey;
210
+ setRepeatingKey(videoPresentationKey);
211
+ video.currentTime = 0;
212
+ void video.play().catch(() => unavailable());
213
+ return;
214
+ }
169
215
  unavailable();
170
216
  };
171
217
  useEffect(() => {
@@ -173,11 +219,17 @@ var SceneVideoBackdrop = ({
173
219
  previousProgressRef.current = { key: videoPresentationKey, progress };
174
220
  const video = videoRef.current;
175
221
  if (!video || rewindPreroll || previous.key !== videoPresentationKey || progress >= previous.progress - 0.05 || !sceneDuration || !Number.isFinite(sceneDuration)) return;
176
- video.currentTime = Math.max(0, progress * sceneDuration);
222
+ const target = Math.max(0, progress * sceneDuration);
223
+ setEndedKey(void 0);
224
+ const seekingRepeated = allowsRepeat(video) && target >= video.duration;
225
+ setRepeatingKey(seekingRepeated ? videoPresentationKey : void 0);
226
+ if (seekingRepeated) repeatedPresentationRef.current = videoPresentationKey;
227
+ else if (progress <= 1e-3) repeatedPresentationRef.current = void 0;
228
+ video.currentTime = seekingRepeated ? target % video.duration : target;
177
229
  failedPresentationRef.current = void 0;
178
230
  setExhaustedKey(void 0);
179
231
  if (isPlaying && video.paused) void video.play().catch(() => unavailable());
180
- }, [progress, videoPresentationKey, sceneDuration, rewindPreroll, isPlaying]);
232
+ }, [progress, videoPresentationKey, sceneDuration, measuredSpeechDurationSec, rewindPreroll, isPlaying]);
181
233
  useEffect(() => {
182
234
  const video = videoRef.current;
183
235
  if (!video) return;
@@ -280,12 +332,14 @@ function getMediaBackgroundProps(variables) {
280
332
  mediaUrl: String(variables.mediaUrl || ""),
281
333
  mediaType: String(variables.mediaType || "auto"),
282
334
  mediaPoster: String(variables.mediaPoster || ""),
283
- mediaPosition: String(variables.mediaPosition || "center")
335
+ mediaPosition: String(variables.mediaPosition || "center"),
336
+ measuredSpeechDurationSec: typeof variables.measuredSpeechDurationSec === "number" && Number.isFinite(variables.measuredSpeechDurationSec) && variables.measuredSpeechDurationSec > 0 ? variables.measuredSpeechDurationSec : void 0
284
337
  };
285
338
  }
286
339
  function SceneBackground({
287
340
  progress,
288
341
  sceneDuration,
342
+ measuredSpeechDurationSec,
289
343
  mediaUrl = "",
290
344
  mediaType = "auto",
291
345
  mediaPoster,
@@ -328,6 +382,7 @@ function SceneBackground({
328
382
  mediaPosition,
329
383
  progress,
330
384
  sceneDuration,
385
+ measuredSpeechDurationSec,
331
386
  isPlaying,
332
387
  onError: () => setFailedUrl(mediaUrl)
333
388
  }
package/dist/react.js CHANGED
@@ -3,8 +3,9 @@ import {
3
3
  getBuiltinSceneDefinition
4
4
  } from "./chunk-NM4CXXZY.js";
5
5
  import {
6
- ExternalVideoBackdropProvider
7
- } from "./chunk-666HGTVZ.js";
6
+ ExternalVideoBackdropProvider,
7
+ measuredClipPlayback
8
+ } from "./chunk-R2RJIB6B.js";
8
9
  import {
9
10
  TitleSceneTemplate
10
11
  } from "./chunk-5DOQTIMD.js";
@@ -18,14 +19,15 @@ import {
18
19
  resolveVideoTimeline
19
20
  } from "./chunk-AUN4S3YW.js";
20
21
  import {
21
- CLIP_NARRATION_TAIL_SEC,
22
22
  MEDIA_RECOVERY_NOTICE,
23
23
  decodeVideoSse,
24
- estimateNarrationSeconds,
25
24
  orderWelcomeCards,
26
- speechFitsClip,
27
25
  welcomeVisitSeed
28
- } from "./chunk-RFXHLLYU.js";
26
+ } from "./chunk-XJ36A5JD.js";
27
+ import {
28
+ CLIP_NARRATION_TAIL_SEC,
29
+ estimateNarrationSeconds
30
+ } from "./chunk-NEKYSCT6.js";
29
31
  import {
30
32
  withDeadline
31
33
  } from "./chunk-4G4JBMCM.js";
@@ -101,7 +103,7 @@ function MountedSceneReadiness({
101
103
  consumed = true;
102
104
  return valid;
103
105
  } } : void 0);
104
- } else report?.(key, error, actualVideoFrame);
106
+ } else report?.(key, error, actualVideoFrame, actualVideoFrame ? presented ?? observed : void 0);
105
107
  };
106
108
  const check = () => {
107
109
  if (stopped) return;
@@ -122,6 +124,7 @@ function MountedSceneReadiness({
122
124
  const video = layer.querySelector("video");
123
125
  if (video && video.getAttribute("src") === mediaUrl && video.currentSrc === video.src && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
124
126
  if (preparedProof?.consume(video)) {
127
+ presented = video;
125
128
  finish(void 0, true);
126
129
  return;
127
130
  }
@@ -248,7 +251,7 @@ function createRenderer(defaults, loader) {
248
251
  return Object.freeze({ component, defaults, preload });
249
252
  }
250
253
  var loaders = {
251
- cinemaMedia: () => import("./cinema-media-YZ2UANJG.js").then((module) => ({ default: module.MediaSceneTemplate })),
254
+ cinemaMedia: () => import("./cinema-media-DLIYPESB.js").then((module) => ({ default: module.MediaSceneTemplate })),
252
255
  chapterTitle: () => import("./chapter-title-2RCXX7SN.js").then((module) => ({ default: module.TitleSceneTemplate }))
253
256
  };
254
257
  var renderers = new Map(SCENE_DEFINITIONS.map((scene) => [
@@ -1044,6 +1047,7 @@ function usePlaybackClock({
1044
1047
  loopRef,
1045
1048
  sceneIndexRef,
1046
1049
  visualReadyRef,
1050
+ activeMediaRef,
1047
1051
  callbacksRef,
1048
1052
  setCurrentTime,
1049
1053
  setIsPlaying
@@ -1055,6 +1059,7 @@ function usePlaybackClock({
1055
1059
  let onsetWaitSeconds = 0;
1056
1060
  let clockWaitSeconds = 0;
1057
1061
  let lastNarrationTime;
1062
+ let narratedKey;
1058
1063
  let completionHold;
1059
1064
  let committedTime = timeRef.current;
1060
1065
  let groupHandoff;
@@ -1094,6 +1099,7 @@ function usePlaybackClock({
1094
1099
  if (externallySeeked || replaced) {
1095
1100
  groupHandoff = void 0;
1096
1101
  completionHold = void 0;
1102
+ narratedKey = void 0;
1097
1103
  }
1098
1104
  let deferGroupStall = false;
1099
1105
  let completionBlocked = false;
@@ -1132,6 +1138,8 @@ function usePlaybackClock({
1132
1138
  }
1133
1139
  clockWaitSeconds = narrationTime !== void 0 && narrationTime === lastNarrationTime && narrationReady && !stalled ? clockWaitSeconds + elapsed : 0;
1134
1140
  const audioMovedBackwards = narrationTime !== void 0 && lastNarrationTime !== void 0 && narrationTime < lastNarrationTime;
1141
+ if (audioMovedBackwards) narratedKey = void 0;
1142
+ if (cued && narrationReady && narrationTime !== void 0 && narrationTime > 0) narratedKey = sceneReadinessKey(cued.scene);
1135
1143
  lastNarrationTime = narrationTime;
1136
1144
  if (clockWaitSeconds >= 8) {
1137
1145
  failNarration(new Error("Narration audio clock did not advance within eight seconds"), current);
@@ -1142,6 +1150,8 @@ function usePlaybackClock({
1142
1150
  raw = Math.max(timeRef.current, raw);
1143
1151
  }
1144
1152
  if (cued && !cued.scene.narrationGroup && narrationTime === void 0) {
1153
+ const measuredSpeech = cued.scene.variables.measuredSpeechDurationSec;
1154
+ const tailSeconds = cued.scene.templateId === "cinemaMedia" && typeof measuredSpeech === "number" && Number.isFinite(measuredSpeech) && measuredSpeech > 0 ? Math.min(CLIP_NARRATION_TAIL_SEC, Math.max(0, cued.end - cued.start - measuredSpeech)) : CLIP_NARRATION_TAIL_SEC;
1145
1155
  let speaking = false;
1146
1156
  try {
1147
1157
  speaking = callbacksRef.current.narrationActive?.(cued.scene) === true;
@@ -1149,8 +1159,15 @@ function usePlaybackClock({
1149
1159
  failNarration(cause instanceof Error ? cause : new Error("Narration completion failed"), current);
1150
1160
  return;
1151
1161
  }
1162
+ const key = sceneReadinessKey(cued.scene);
1163
+ if (speaking && narrationReady) narratedKey = key;
1164
+ const native = activeMediaRef?.current;
1165
+ const media = native?.video;
1166
+ if (!speaking && narrationReady && callbacksRef.current.narrationActive && narratedKey === key && typeof measuredSpeech === "number" && Number.isFinite(measuredSpeech) && measuredSpeech > 0 && native?.key === key && media?.isConnected && !media.error && !media.seeking && media.getAttribute("src") === String(cued.scene.variables.mediaUrl || "") && media.currentSrc === media.src && media.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && Number.isFinite(media.currentTime) && media.currentTime >= 0 && Number.isFinite(media.duration) && media.duration > 0 && measuredSpeech <= media.duration && cued.end - cued.start <= media.duration + 0.05) {
1167
+ raw = Math.max(raw, Math.min(cued.end, cued.start + media.currentTime));
1168
+ }
1152
1169
  if (completionHold?.sceneId !== cued.scene.id) completionHold = void 0;
1153
- if (raw >= cued.end - CLIP_NARRATION_TAIL_SEC && speaking) {
1170
+ if (raw >= cued.end - tailSeconds && speaking) {
1154
1171
  completionHold ??= { sceneId: cued.scene.id, wait: 0, tail: 0 };
1155
1172
  if (raw >= cued.end) completionHold.wait += elapsed;
1156
1173
  if (completionHold.wait >= 8) {
@@ -1163,10 +1180,10 @@ function usePlaybackClock({
1163
1180
  }
1164
1181
  } else if (completionHold && !speaking) {
1165
1182
  completionHold.tail += elapsed;
1166
- if (completionHold.tail < CLIP_NARRATION_TAIL_SEC && raw >= cued.end) {
1183
+ if (completionHold.tail < tailSeconds && raw >= cued.end) {
1167
1184
  raw = Math.max(cued.start, cued.end - 0.01);
1168
1185
  completionBlocked = true;
1169
- } else if (completionHold.tail >= CLIP_NARRATION_TAIL_SEC) completionHold = void 0;
1186
+ } else if (completionHold.tail >= tailSeconds) completionHold = void 0;
1170
1187
  }
1171
1188
  }
1172
1189
  let nextTime;
@@ -1322,7 +1339,9 @@ function VideoPlayerRuntime({
1322
1339
  const sceneIndexRef = useRef4(-1);
1323
1340
  const mediaFrameReportedRef = useRef4(false);
1324
1341
  const visualReadyRef = useRef4(void 0);
1325
- const reportVisualReady = useMemo(() => (key, error, actualVideoFrame = false) => {
1342
+ const activeMediaRef = useRef4(void 0);
1343
+ const reportVisualReady = useMemo(() => (key, error, actualVideoFrame = false, media) => {
1344
+ activeMediaRef.current = !error && actualVideoFrame && media ? { key, video: media } : void 0;
1326
1345
  if (error) {
1327
1346
  setIsPlaying(false);
1328
1347
  callbacksRef.current.onError?.(error, stateRef.current);
@@ -1382,6 +1401,7 @@ function VideoPlayerRuntime({
1382
1401
  setActiveStream(stream);
1383
1402
  setActiveSavedVideo(video);
1384
1403
  mediaFrameReportedRef.current = false;
1404
+ activeMediaRef.current = void 0;
1385
1405
  sceneIndexRef.current = -1;
1386
1406
  setReplacementPending(stream != null);
1387
1407
  setState(video ? savedVideoState(video) : createVideoState());
@@ -1494,6 +1514,7 @@ function VideoPlayerRuntime({
1494
1514
  loopRef,
1495
1515
  sceneIndexRef,
1496
1516
  visualReadyRef,
1517
+ activeMediaRef,
1497
1518
  callbacksRef,
1498
1519
  setCurrentTime,
1499
1520
  setIsPlaying
@@ -1638,6 +1659,7 @@ function VideoPlayerRuntime({
1638
1659
  return;
1639
1660
  }
1640
1661
  if (!isPlaying && ended) {
1662
+ activeMediaRef.current = void 0;
1641
1663
  timeRef.current = 0;
1642
1664
  setCurrentTime(0);
1643
1665
  if (audioRef.current) audioRef.current.currentTime = 0;
@@ -1942,13 +1964,21 @@ function preparedSceneDuration(scene, spokenSeconds, metadata, clipDurationSec)
1942
1964
  const speech = Number.isFinite(spokenSeconds) && spokenSeconds > 0 ? spokenSeconds + CLIP_NARRATION_TAIL_SEC : scene.narration ? getSpokenDuration(scene.narration) : 0;
1943
1965
  return Math.max(floor, speech);
1944
1966
  }
1945
- function prepareNarratedScene(scene, spokenSeconds) {
1967
+ function prepareNarratedScene(scene, spokenSeconds, measured = false) {
1946
1968
  const requested = scene.timing.fixedDuration;
1947
1969
  const actual = scene.variables.mediaDurationSec;
1948
1970
  const clipDurationSec = scene.templateId === "cinemaMedia" ? [actual, requested].find((value) => typeof value === "number" && Number.isFinite(value) && value > 0) : void 0;
1949
- const recovered = clipDurationSec !== void 0 && spokenSeconds !== void 0 && !speechFitsClip(spokenSeconds, clipDurationSec);
1950
- const visual = recovered ? recoverSceneMedia(scene) : scene;
1951
- const duration = preparedSceneDuration(visual, spokenSeconds, getBuiltinSceneDefinition(visual.templateId), clipDurationSec);
1971
+ const { measuredSpeechDurationSec: _incomingMeasurement, ...variables } = scene.variables;
1972
+ const measuredSeconds = measured && typeof spokenSeconds === "number" && Number.isFinite(spokenSeconds) && spokenSeconds > 0 ? spokenSeconds : void 0;
1973
+ const candidate = { ...scene, variables: {
1974
+ ...variables,
1975
+ ...scene.templateId === "cinemaMedia" && measuredSeconds !== void 0 ? { measuredSpeechDurationSec: measuredSeconds } : {}
1976
+ } };
1977
+ const fit = clipDurationSec === void 0 ? void 0 : measuredClipPlayback(measuredSeconds, clipDurationSec);
1978
+ const recovered = clipDurationSec !== void 0 && (measuredSeconds === void 0 ? preparedSceneDuration(candidate, spokenSeconds, getBuiltinSceneDefinition(scene.templateId), clipDurationSec) > clipDurationSec : fit === void 0);
1979
+ const visual = recovered ? recoverSceneMedia(candidate) : candidate;
1980
+ const prepared = preparedSceneDuration(visual, spokenSeconds, getBuiltinSceneDefinition(visual.templateId), clipDurationSec);
1981
+ const duration = fit && !recovered ? fit.repeat ? fit.durationSec : Math.min(prepared, clipDurationSec) : prepared;
1952
1982
  const { startTime: _start, endTime: _end, beatStart: _beatStart, beatEnd: _beatEnd, ...timing } = visual.timing;
1953
1983
  return { scene: { ...visual, timing: { ...timing, fixedDuration: duration } }, recovered, clipDurationSec };
1954
1984
  }
@@ -2011,8 +2041,8 @@ function createScenePreparation(options) {
2011
2041
  void prepared.catch(() => void 0);
2012
2042
  return prepared;
2013
2043
  };
2014
- const pace = (scene, seconds) => {
2015
- const prepared = prepareNarratedScene(scene, seconds);
2044
+ const pace = (scene, seconds, measured) => {
2045
+ const prepared = prepareNarratedScene(scene, seconds, measured);
2016
2046
  if (prepared.recovered) options.warn(MEDIA_RECOVERY_NOTICE);
2017
2047
  if (seconds !== void 0 && prepared.clipDurationSec !== void 0) {
2018
2048
  try {
@@ -3219,7 +3249,7 @@ function useVideoChatSession(options = {}) {
3219
3249
  const withNarration = line ? { ...visual, narration: line } : visual;
3220
3250
  const group = plannedScene.narrationGroup;
3221
3251
  if (group && (spoken?.supportsOffsets !== true || voiceRef.current.supportsOffsets !== true || Math.abs(spoken.seconds - group.totalSeconds) > 0.1)) throw new VideoError("Narration group requires matching measured audio with offset support", { code: "narration_group_invalid" });
3222
- ready[position] = group ? withNarration : preparation.pace(withNarration, spoken?.seconds);
3252
+ ready[position] = group ? withNarration : preparation.pace(withNarration, spoken?.seconds, spoken?.supportsOffsets === true);
3223
3253
  flush();
3224
3254
  }).catch((cause) => {
3225
3255
  if (!isCurrent() || currentAttempt !== attempt) return;
@@ -4612,7 +4642,6 @@ function VideoChat({ options = {}, className, welcomeTitle, branding, showRecove
4612
4642
  "div",
4613
4643
  {
4614
4644
  className: "line-row",
4615
- "data-opening-copy": openingChapter && line === shown?.opening && !captionsExpanded,
4616
4645
  "data-expanded": captionsExpanded,
4617
4646
  "data-actions-visible": captionControls.visible,
4618
4647
  onPointerMove: captionControls.onPointerEnter,
@@ -4631,7 +4660,7 @@ function VideoChat({ options = {}, className, welcomeTitle, branding, showRecove
4631
4660
  setCaptionsExpanded(false);
4632
4661
  }, children: /* @__PURE__ */ jsx11(Close, {}) })
4633
4662
  ] }),
4634
- captionsExpanded ? /* @__PURE__ */ jsx11("div", { className: "expanded-captions", role: "region", tabIndex: 0, "aria-label": "Expanded subtitles", children: fullTranscript.map((entry, index) => /* @__PURE__ */ jsx11("p", { children: entry }, index)) }) : /* @__PURE__ */ jsx11(CaptionPages, { text: openingChapter && line === shown?.opening ? "" : line, getProgress: getCaptionProgress }, `${shown?.id}:${chat.playerKey}`)
4663
+ captionsExpanded ? /* @__PURE__ */ jsx11("div", { className: "expanded-captions", role: "region", tabIndex: 0, "aria-label": "Expanded subtitles", children: fullTranscript.map((entry, index) => /* @__PURE__ */ jsx11("p", { children: entry }, index)) }) : /* @__PURE__ */ jsx11(CaptionPages, { text: line, getProgress: getCaptionProgress }, `${shown?.id}:${chat.playerKey}`)
4635
4664
  ]
4636
4665
  }
4637
4666
  ) }) }),
package/dist/server.js CHANGED
@@ -1,14 +1,16 @@
1
1
  import {
2
- CLIP_NARRATION_TAIL_SEC,
3
2
  MEDIA_RECOVERY_NOTICE,
4
3
  WELCOME_CARDS,
5
- clipNarrationBudget,
6
4
  decodeVideoSse,
7
5
  encodeVideoSseEvent,
8
- estimateNarrationSeconds,
9
- narrationFitsClip,
10
6
  videoSseHeaders
11
- } from "./chunk-RFXHLLYU.js";
7
+ } from "./chunk-XJ36A5JD.js";
8
+ import {
9
+ CLIP_NARRATION_TAIL_SEC,
10
+ clipNarrationBudget,
11
+ estimateNarrationSeconds,
12
+ narrationFitsClip
13
+ } from "./chunk-NEKYSCT6.js";
12
14
  import {
13
15
  withDeadline
14
16
  } from "./chunk-4G4JBMCM.js";
@@ -30,8 +30,11 @@ Narration is estimated against each clip with a 0.8-second tail. An oversized
30
30
  beat gets at most one short rewrite before footage is requested. A failed or
31
31
  still-oversized rewrite preserves the original speech in a chapter instead of
32
32
  spending on unusable footage. Measured speech remains authoritative during
33
- playback; video plays once and unexpected overruns recover to a chapter. Estimation is
34
- not a guarantee that every voice/language finishes inside its clip.
33
+ playback. Normal footage plays once; a small measured speech overrun can repeat
34
+ healthy footage once until speech completes. Larger or unmeasured overruns
35
+ recover to a chapter. See the [playback bounds](media-and-audio.md#timing-and-recovery)
36
+ for the exceptional-repeat policy. Estimation is not a guarantee that every
37
+ voice/language finishes inside its clip.
35
38
 
36
39
  ## Where to work
37
40
 
@@ -72,6 +72,10 @@ await chat.ask(card.prompt, { opening: card.opening, openingMedia: card.media })
72
72
 
73
73
  Typed prompts receive their opening from the same model stream as the answer.
74
74
  The opening holds until its narration completes and the first scene is ready.
75
+ When subtitles are enabled, the spoken opening also appears in the standard
76
+ subtitle line. The full transcript includes that opening once, followed by the
77
+ scene narration, including on replay and when restoring an in-memory session.
78
+ At the end, the "Ask next" label stays directly above its follow-up cards.
75
79
 
76
80
  `portrait` reserves a 9:16 response frame; `landscape` reserves 16:9.
77
81
  The saved orientation stays stable. For responsive display without changing the
@@ -137,8 +137,20 @@ settings and host spending limits. The planner fits natural spoken beats to that
137
137
  budget with a 0.8-second tail. One bounded rewrite may shorten an oversized
138
138
  beat before generation; otherwise its complete narration stays on a chapter.
139
139
  Measured audio and actual decoded footage are checked before playback. Normal
140
- footage plays once at native speed; overruns recover to a chapter without
141
- cutting off narration or buying another clip.
140
+ footage plays once at native speed. If measured speech itself exceeds a healthy
141
+ clip by at most the smaller of one second or 25% of its length, playback may
142
+ repeat that same clip once, stopping when speech finishes. It never repeats just
143
+ to fill the quiet tail: when speech already fits, a shorter available tail is
144
+ allowed. Larger or unmeasured speech overruns, missing media and failed/stalled
145
+ decoders still recover to a chapter without cutting off narration or buying
146
+ another clip. The mounted decoder rechecks the bound against actual footage;
147
+ pause/resume does not grant another repeat, and explicit replay starts a fresh
148
+ playback lifecycle.
149
+
150
+ Repeat eligibility uses the prepared audio's decoded duration, not a browser
151
+ speech estimate. A custom voice must return `supportsOffsets: true` from
152
+ `prepare` only when its audio is measured and seekable. In-memory replay keeps
153
+ that prepared timing; the mounted decoder still enforces the repeat bound.
142
154
 
143
155
  `generateVideoTimeoutMs` sets the first-shot preparation budget (default 15 seconds).
144
156
  Later deadlines account for their position in the answer rather than restarting
@@ -104,8 +104,10 @@ footage generation. If the rewrite fails or still cannot fit, no video job is
104
104
  submitted for that beat: its complete original narration plays over a chapter.
105
105
  Rewrites are instructed to preserve facts and qualifications; applications
106
106
  should still evaluate meaning and timing with their actual models and voices.
107
- Measured speech can overrun the estimate; playback recovers to a chapter
108
- rather than looping or cutting off the sentence. Requested duration constrains
107
+ Measured speech can overrun the estimate. A small measured overrun may repeat
108
+ healthy footage once until speech finishes; other overruns recover to a chapter
109
+ without cutting off the sentence. See the [playback bounds](media-and-audio.md#timing-and-recovery).
110
+ Requested duration constrains
109
111
  the paid submission; a valid returned duration describes the footage actually
110
112
  available for playback. The mounted decoder also checks its physical duration.
111
113
  Without reported duration, generated footage keeps its requested budget.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillaskyai/video",
3
- "version": "0.11.1",
3
+ "version": "0.11.2",
4
4
  "description": "Open-source voice-and-video chat SDK for AI applications.",
5
5
  "keywords": [
6
6
  "video-chat",
@@ -9,7 +9,7 @@
9
9
  "preview": "vite preview"
10
10
  },
11
11
  "dependencies": {
12
- "@vanillaskyai/video": "0.11.1",
12
+ "@vanillaskyai/video": "0.11.2",
13
13
  "react": "^19.2.8",
14
14
  "react-dom": "^19.2.8",
15
15
  "@ai-sdk/anthropic": "^3.0.0",
@@ -887,7 +887,7 @@
887
887
  }
888
888
 
889
889
  .vanillasky-video-chat .ending { pointer-events: none; }
890
- .vanillasky-video-chat .ending .cards { pointer-events: auto; }
890
+ .vanillasky-video-chat .ending .cards { pointer-events: auto; margin-block-start: 0; }
891
891
 
892
892
  /* Respect viewers who prefer solid controls over translucent media surfaces. */
893
893
  @media (prefers-reduced-transparency: reduce) {
@@ -961,12 +961,6 @@
961
961
  .vanillasky-video-chat .opening-chapter [data-title-composition="centered"] {
962
962
  animation: vanillasky-video-chat-fade-in 800ms ease both;
963
963
  }
964
- .vanillasky-video-chat .line-row[data-opening-copy="true"] {
965
- background: transparent;
966
- backdrop-filter: none;
967
- -webkit-backdrop-filter: none;
968
- }
969
-
970
964
  .vanillasky-video-chat .opening-chapter { animation: vanillasky-chapter-enter 450ms ease-out both; }
971
965
  @keyframes vanillasky-chapter-enter { from { opacity: 0; } to { opacity: 1; } }
972
966
  @media (prefers-reduced-motion: reduce) { .vanillasky-video-chat .opening-chapter { animation: none; } }