@bendyline/squisq-editor-react 2.4.0 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,133 @@
1
- import {
2
- buildFilename,
3
- encodeTimingJson,
4
- requestCameraStream,
5
- requestMicStream,
6
- resolveFormat,
7
- timingPathFor,
8
- useStreamPreview
9
- } from "./chunk-5Q4JN4I5.js";
1
+ // src/recorder/formats.ts
2
+ var AUDIO_CANDIDATES = [
3
+ "audio/webm;codecs=opus",
4
+ "audio/webm",
5
+ "audio/mp4;codecs=mp4a.40.2",
6
+ "audio/mp4",
7
+ "audio/ogg;codecs=opus"
8
+ ];
9
+ var VIDEO_CANDIDATES = [
10
+ "video/webm;codecs=vp9,opus",
11
+ "video/webm;codecs=vp8,opus",
12
+ "video/webm",
13
+ "video/mp4;codecs=avc1.42E01E,mp4a.40.2",
14
+ "video/mp4"
15
+ ];
16
+ function extensionForMime(mimeType) {
17
+ const m = mimeType.toLowerCase();
18
+ if (m.startsWith("audio/webm")) return ".webm";
19
+ if (m.startsWith("audio/ogg")) return ".ogg";
20
+ if (m.startsWith("audio/mp4")) return ".m4a";
21
+ if (m.startsWith("audio/mpeg")) return ".mp3";
22
+ if (m.startsWith("audio/wav")) return ".wav";
23
+ if (m.startsWith("video/webm")) return ".webm";
24
+ if (m.startsWith("video/mp4")) return ".mp4";
25
+ return ".bin";
26
+ }
27
+ function probeMimeType(candidates) {
28
+ if (typeof MediaRecorder === "undefined") return null;
29
+ for (const candidate of candidates) {
30
+ try {
31
+ if (MediaRecorder.isTypeSupported(candidate)) return candidate;
32
+ } catch {
33
+ }
34
+ }
35
+ return null;
36
+ }
37
+ function resolveFormat(kind, preferred) {
38
+ const candidates = kind === "audio" ? AUDIO_CANDIDATES : VIDEO_CANDIDATES;
39
+ const probed = (preferred && probeMimeType([preferred])) ?? probeMimeType(candidates) ?? "";
40
+ const directory = kind === "audio" ? "audio" : "video";
41
+ const extension = probed ? extensionForMime(probed) : ".webm";
42
+ return { mimeType: probed, extension, directory };
43
+ }
44
+ function supportsMediaRecorder() {
45
+ return typeof MediaRecorder !== "undefined";
46
+ }
47
+ function supportsUserMedia() {
48
+ return typeof navigator !== "undefined" && typeof navigator.mediaDevices !== "undefined" && typeof navigator.mediaDevices.getUserMedia === "function";
49
+ }
50
+ function supportsDisplayMedia() {
51
+ return typeof navigator !== "undefined" && typeof navigator.mediaDevices !== "undefined" && typeof navigator.mediaDevices.getDisplayMedia === "function";
52
+ }
53
+ function supportsSystemAudioCapture() {
54
+ if (!supportsDisplayMedia()) return false;
55
+ const nav = navigator;
56
+ const uaData = nav.userAgentData;
57
+ if (uaData?.mobile === true) return false;
58
+ if (uaData?.brands?.length) {
59
+ return uaData.brands.some(({ brand }) => /chromium/i.test(brand));
60
+ }
61
+ const userAgent = nav.userAgent ?? "";
62
+ if (/Android|Mobile|iPhone|iPad|iPod/i.test(userAgent)) return false;
63
+ return /(?:Chrome|Chromium|Edg|OPR)\//.test(userAgent);
64
+ }
65
+ function buildFilename(kind, extension, basename, seed) {
66
+ const safe = basename ? basename.trim().replace(/[\\/:*?"<>|]+/g, "-").replace(/\s+/g, "-") : "";
67
+ if (safe) return `${safe}${extension}`;
68
+ const now = /* @__PURE__ */ new Date();
69
+ const stamp = now.getFullYear().toString().padStart(4, "0") + (now.getMonth() + 1).toString().padStart(2, "0") + now.getDate().toString().padStart(2, "0") + "-" + now.getHours().toString().padStart(2, "0") + now.getMinutes().toString().padStart(2, "0") + now.getSeconds().toString().padStart(2, "0");
70
+ const prefix = seed ?? (kind === "audio" ? "narration" : "recording");
71
+ return `${prefix}-${stamp}${extension}`;
72
+ }
73
+
74
+ // src/recorder/sources/micStream.ts
75
+ async function requestMicStream(constraints) {
76
+ if (!supportsUserMedia()) {
77
+ throw new Error("navigator.mediaDevices.getUserMedia is not available in this environment.");
78
+ }
79
+ return navigator.mediaDevices.getUserMedia({
80
+ audio: constraints ?? true,
81
+ video: false
82
+ });
83
+ }
84
+
85
+ // src/recorder/sources/cameraStream.ts
86
+ async function requestCameraStream(options) {
87
+ if (!supportsUserMedia()) {
88
+ throw new Error("navigator.mediaDevices.getUserMedia is not available in this environment.");
89
+ }
90
+ const video = options?.video ?? true;
91
+ const audio = options?.audio ?? true;
92
+ return navigator.mediaDevices.getUserMedia({ video, audio });
93
+ }
94
+
95
+ // src/recorder/hooks/useStreamPreview.ts
96
+ import { useEffect } from "react";
97
+ function useStreamPreview(ref, stream) {
98
+ useEffect(() => {
99
+ const el = ref.current;
100
+ if (!el) return;
101
+ el.muted = true;
102
+ el.playsInline = true;
103
+ el.srcObject = stream;
104
+ if (stream) {
105
+ void el.play().catch(() => {
106
+ });
107
+ }
108
+ return () => {
109
+ if (el.srcObject === stream) {
110
+ el.srcObject = null;
111
+ }
112
+ };
113
+ }, [ref, stream]);
114
+ }
115
+
116
+ // src/recorder/timingJson.ts
117
+ function buildTimingJson(sourceText, durationSec) {
118
+ return {
119
+ sourceText: sourceText ?? "",
120
+ duration: Number.isFinite(durationSec) && durationSec >= 0 ? durationSec : 0,
121
+ bookmarks: []
122
+ };
123
+ }
124
+ function encodeTimingJson(timing) {
125
+ const text = JSON.stringify(timing, null, 2);
126
+ return new TextEncoder().encode(text);
127
+ }
128
+ function timingPathFor(audioRelativePath) {
129
+ return `${audioRelativePath}.timing.json`;
130
+ }
10
131
 
11
132
  // src/teleprompter/pcmWorklet.ts
12
133
  var PCM_WORKLET_NAME = "squisq-pcm-tap";
@@ -53,7 +174,7 @@ async function registerPcmWorklet(ctx) {
53
174
  }
54
175
 
55
176
  // src/teleprompter/useMicAnalysis.ts
56
- import { useCallback, useEffect, useRef, useState } from "react";
177
+ import { useCallback, useEffect as useEffect2, useRef, useState } from "react";
57
178
  function useMicAnalysis() {
58
179
  const [status, setStatus] = useState("idle");
59
180
  const [error, setError] = useState(null);
@@ -70,7 +191,7 @@ function useMicAnalysis() {
70
191
  } catch {
71
192
  }
72
193
  }, []);
73
- useEffect(() => {
194
+ useEffect2(() => {
74
195
  if (typeof navigator === "undefined" || !navigator.mediaDevices?.addEventListener) return;
75
196
  const onChange = () => void refreshDevices();
76
197
  navigator.mediaDevices.addEventListener("devicechange", onChange);
@@ -190,7 +311,7 @@ function useMicAnalysis() {
190
311
  listenersRef.current.delete(listener);
191
312
  };
192
313
  }, []);
193
- useEffect(() => {
314
+ useEffect2(() => {
194
315
  return () => {
195
316
  generationRef.current += 1;
196
317
  teardown();
@@ -210,9 +331,55 @@ var DEFAULT_TELEPROMPTER_PREFS = Object.freeze({
210
331
  lineGuide: true,
211
332
  micDeviceId: null
212
333
  });
334
+ var TELEPROMPTER_PREF_LIMITS = Object.freeze({
335
+ fontSizePx: { min: 28, max: 96 },
336
+ baseWpm: { min: 80, max: 260 },
337
+ vadSensitivity: { min: 0, max: 1 },
338
+ micDeviceIdMaxLength: 1024
339
+ });
340
+ var COUNTDOWN_OPTIONS = /* @__PURE__ */ new Set([0, 3, 5, 10]);
341
+ function isCountdownOption(value) {
342
+ return typeof value === "number" && COUNTDOWN_OPTIONS.has(value);
343
+ }
344
+ function clampNumber(value, min, max, fallback) {
345
+ return typeof value === "number" && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
346
+ }
347
+ function booleanOr(value, fallback) {
348
+ return typeof value === "boolean" ? value : fallback;
349
+ }
350
+ function normalizeTeleprompterPrefs(value, fallback = DEFAULT_TELEPROMPTER_PREFS) {
351
+ const input = value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
352
+ const countdown = input.countdownSec;
353
+ const micDeviceId = input.micDeviceId;
354
+ return {
355
+ fontSizePx: clampNumber(
356
+ input.fontSizePx,
357
+ TELEPROMPTER_PREF_LIMITS.fontSizePx.min,
358
+ TELEPROMPTER_PREF_LIMITS.fontSizePx.max,
359
+ fallback.fontSizePx
360
+ ),
361
+ mirrored: booleanOr(input.mirrored, fallback.mirrored),
362
+ baseWpm: clampNumber(
363
+ input.baseWpm,
364
+ TELEPROMPTER_PREF_LIMITS.baseWpm.min,
365
+ TELEPROMPTER_PREF_LIMITS.baseWpm.max,
366
+ fallback.baseWpm
367
+ ),
368
+ voiceTracking: booleanOr(input.voiceTracking, fallback.voiceTracking),
369
+ vadSensitivity: clampNumber(
370
+ input.vadSensitivity,
371
+ TELEPROMPTER_PREF_LIMITS.vadSensitivity.min,
372
+ TELEPROMPTER_PREF_LIMITS.vadSensitivity.max,
373
+ fallback.vadSensitivity
374
+ ),
375
+ countdownSec: isCountdownOption(countdown) ? countdown : fallback.countdownSec,
376
+ lineGuide: booleanOr(input.lineGuide, fallback.lineGuide),
377
+ micDeviceId: micDeviceId === null || typeof micDeviceId === "string" && micDeviceId.length > 0 && micDeviceId.length <= TELEPROMPTER_PREF_LIMITS.micDeviceIdMaxLength ? micDeviceId : micDeviceId === "" ? null : fallback.micDeviceId
378
+ };
379
+ }
213
380
 
214
381
  // src/teleprompter/useTeleprompter.ts
215
- import { useCallback as useCallback2, useEffect as useEffect2, useMemo, useRef as useRef2, useState as useState2 } from "react";
382
+ import { useCallback as useCallback2, useEffect as useEffect3, useMemo, useRef as useRef2, useState as useState2 } from "react";
216
383
  import {
217
384
  buildNarrationScript,
218
385
  createNarrationSession,
@@ -222,13 +389,11 @@ import {
222
389
  } from "@bendyline/squisq/narration";
223
390
  var PREFS_STORAGE_KEY = "squisq:teleprompter-prefs";
224
391
  var PUBLISH_INTERVAL_MS = 66;
225
- var NUDGE_WORDS = 6;
226
392
  function loadPrefs() {
227
393
  try {
228
394
  const raw = globalThis.localStorage?.getItem(PREFS_STORAGE_KEY);
229
395
  if (!raw) return { ...DEFAULT_TELEPROMPTER_PREFS };
230
- const parsed = JSON.parse(raw);
231
- return { ...DEFAULT_TELEPROMPTER_PREFS, ...parsed };
396
+ return normalizeTeleprompterPrefs(JSON.parse(raw));
232
397
  } catch {
233
398
  return { ...DEFAULT_TELEPROMPTER_PREFS };
234
399
  }
@@ -251,6 +416,29 @@ function vadConfigForSensitivity(sensitivity) {
251
416
  )
252
417
  };
253
418
  }
419
+ function spokenWordTarget(script, currentPosition, deltaWords) {
420
+ const tokens = script.tokens;
421
+ const direction = Math.sign(deltaWords);
422
+ if (tokens.length === 0 || direction === 0) return currentPosition;
423
+ let index = Math.min(Math.max(Math.floor(currentPosition), 0), tokens.length);
424
+ if (index < tokens.length) {
425
+ while (index >= 0 && tokens[index]?.spoken === false) index -= 1;
426
+ if (index < 0) index = tokens.findIndex((token) => token.spoken !== false);
427
+ }
428
+ if (index < 0) return currentPosition;
429
+ let remaining = Math.abs(Math.trunc(deltaWords));
430
+ while (remaining > 0) {
431
+ let candidate = index + direction;
432
+ while (candidate >= 0 && candidate < tokens.length && tokens[candidate]?.spoken === false) {
433
+ candidate += direction;
434
+ }
435
+ if (candidate < 0) return 0;
436
+ if (candidate >= tokens.length) return tokens.length;
437
+ index = candidate;
438
+ remaining -= 1;
439
+ }
440
+ return index;
441
+ }
254
442
  function useTeleprompter(opts) {
255
443
  const { doc } = opts;
256
444
  const script = useMemo(
@@ -276,7 +464,7 @@ function useTeleprompter(opts) {
276
464
  scriptRef.current = script;
277
465
  prefsRef.current = prefs;
278
466
  transportRef.current = transport;
279
- useEffect2(() => {
467
+ useEffect3(() => {
280
468
  wordPosRef.current = Math.min(wordPosRef.current, script?.tokens.length ?? 0);
281
469
  sessionRef.current = null;
282
470
  setView((v) => ({ ...v, wordPos: wordPosRef.current }));
@@ -308,7 +496,7 @@ function useTeleprompter(opts) {
308
496
  setTransport("finished");
309
497
  transportRef.current = "finished";
310
498
  }, []);
311
- useEffect2(() => {
499
+ useEffect3(() => {
312
500
  return mic.subscribeHop((pcm) => {
313
501
  const currentScript = scriptRef.current;
314
502
  const sampleRate = mic.sampleRate;
@@ -349,7 +537,7 @@ function useTeleprompter(opts) {
349
537
  publish();
350
538
  });
351
539
  }, [mic, finish, notifyTick, publish]);
352
- useEffect2(() => {
540
+ useEffect3(() => {
353
541
  if (transport !== "rolling") return;
354
542
  let raf = 0;
355
543
  let last = performance.now();
@@ -445,16 +633,20 @@ function useTeleprompter(opts) {
445
633
  [notifyTick, publish]
446
634
  );
447
635
  const nudge = useCallback2(
448
- (deltaTokens) => seekToToken(Math.round(wordPosRef.current + deltaTokens)),
636
+ (deltaWords) => {
637
+ const currentScript = scriptRef.current;
638
+ if (!currentScript) return;
639
+ seekToToken(spokenWordTarget(currentScript, wordPosRef.current, deltaWords));
640
+ },
449
641
  [seekToToken]
450
642
  );
451
643
  const setPrefs = useCallback2(
452
644
  (patch) => {
453
645
  setPrefsState((prev) => {
454
- const next = { ...prev, ...patch };
646
+ const next = normalizeTeleprompterPrefs({ ...prev, ...patch }, prev);
455
647
  savePrefs(next);
456
- if (patch.micDeviceId !== void 0 && patch.micDeviceId !== prev.micDeviceId && mic.status === "live") {
457
- void mic.start(patch.micDeviceId);
648
+ if (next.micDeviceId !== prev.micDeviceId && mic.status === "live") {
649
+ void mic.start(next.micDeviceId);
458
650
  }
459
651
  return next;
460
652
  });
@@ -469,31 +661,46 @@ function useTeleprompter(opts) {
469
661
  }, []);
470
662
  const handleKeyDown = useCallback2(
471
663
  (event) => {
664
+ if (event.defaultPrevented) return;
472
665
  const target = event.target;
473
- if (target?.closest('input, textarea, select, button, a, [contenteditable="true"]')) return;
666
+ if (target?.closest('input, textarea, select, [contenteditable="true"]')) return;
667
+ if (event.key.startsWith("Arrow") && target?.closest(
668
+ '[role="tab"], [role="menuitem"], [role="option"], [role="slider"], [role="spinbutton"], [role="treeitem"]'
669
+ )) {
670
+ return;
671
+ }
474
672
  switch (event.key) {
475
- case " ":
673
+ case "ArrowLeft":
674
+ event.preventDefault();
675
+ if (event.repeat) break;
676
+ nudge(-1);
677
+ break;
678
+ case "ArrowRight":
476
679
  event.preventDefault();
477
- if (transportRef.current === "rolling" || transportRef.current === "countdown") pause();
478
- else play();
680
+ if (event.repeat) break;
681
+ nudge(1);
479
682
  break;
480
683
  case "ArrowUp":
481
- case "ArrowLeft":
482
684
  event.preventDefault();
483
- nudge(-NUDGE_WORDS);
685
+ if (event.repeat) break;
686
+ play();
484
687
  break;
485
688
  case "ArrowDown":
486
- case "ArrowRight":
487
689
  event.preventDefault();
488
- nudge(NUDGE_WORDS);
690
+ if (event.repeat) break;
691
+ pause();
489
692
  break;
490
693
  case "[":
491
694
  event.preventDefault();
492
- setPrefs({ baseWpm: Math.max(80, prefsRef.current.baseWpm - 10) });
695
+ setPrefs({
696
+ baseWpm: Math.max(TELEPROMPTER_PREF_LIMITS.baseWpm.min, prefsRef.current.baseWpm - 10)
697
+ });
493
698
  break;
494
699
  case "]":
495
700
  event.preventDefault();
496
- setPrefs({ baseWpm: Math.min(260, prefsRef.current.baseWpm + 10) });
701
+ setPrefs({
702
+ baseWpm: Math.min(TELEPROMPTER_PREF_LIMITS.baseWpm.max, prefsRef.current.baseWpm + 10)
703
+ });
497
704
  break;
498
705
  case "m":
499
706
  case "M":
@@ -512,7 +719,7 @@ function useTeleprompter(opts) {
512
719
  },
513
720
  [nudge, pause, play, setPrefs]
514
721
  );
515
- useEffect2(() => clearCountdown, [clearCountdown]);
722
+ useEffect3(() => clearCountdown, [clearCountdown]);
516
723
  return {
517
724
  script,
518
725
  transport,
@@ -785,7 +992,7 @@ html,body{margin:0;height:100%;}#squisq-float-root{height:100%;display:flex;}`;
785
992
  }
786
993
 
787
994
  // src/teleprompter/useFloatingWindow.ts
788
- import { useCallback as useCallback3, useEffect as useEffect3, useMemo as useMemo2, useRef as useRef3, useState as useState3 } from "react";
995
+ import { useCallback as useCallback3, useEffect as useEffect4, useMemo as useMemo2, useRef as useRef3, useState as useState3 } from "react";
789
996
  var FLOAT_WIDTH = 380;
790
997
  var FLOAT_HEIGHT = 540;
791
998
  function useFloatingWindow(styleCss) {
@@ -796,7 +1003,7 @@ function useFloatingWindow(styleCss) {
796
1003
  const manager = managerRef.current;
797
1004
  const [tier, setTier] = useState3("docked");
798
1005
  const supportedTiers = useMemo2(() => detectFloatTiers().filter((t) => t !== "docked"), []);
799
- useEffect3(() => {
1006
+ useEffect4(() => {
800
1007
  const offChange = manager.on("tierchange", setTier);
801
1008
  const offClosed = manager.on("closed", setTier);
802
1009
  return () => {
@@ -828,46 +1035,6 @@ function useFloatingWindow(styleCss) {
828
1035
  };
829
1036
  }
830
1037
 
831
- // src/teleprompter/scrollModel.ts
832
- var EYE_LINE_FRACTION = 0.35;
833
- var MAX_SCROLL_PX_PER_SEC = 2600;
834
- function measureTokenLines(scrollColumn) {
835
- const spans = scrollColumn.querySelectorAll("[data-token-idx]");
836
- const tokenTops = new Array(spans.length);
837
- const tokenHeights = new Array(spans.length);
838
- const columnRect = scrollColumn.getBoundingClientRect();
839
- spans.forEach((span) => {
840
- const idx = Number(span.dataset.tokenIdx);
841
- if (!Number.isFinite(idx)) return;
842
- const rect = span.getBoundingClientRect();
843
- tokenTops[idx] = rect.top - columnRect.top;
844
- tokenHeights[idx] = rect.height;
845
- });
846
- return { tokenTops, tokenHeights };
847
- }
848
- function targetOffsetFor(wordPos, lines, viewportHeightPx, eyeLine = EYE_LINE_FRACTION) {
849
- const count = lines.tokenTops.length;
850
- if (count === 0) return 0;
851
- const clamped = Math.min(Math.max(wordPos, 0), count - 1);
852
- const idx = Math.floor(clamped);
853
- const frac = clamped - idx;
854
- const top = lines.tokenTops[idx] ?? 0;
855
- const nextTop = lines.tokenTops[Math.min(idx + 1, count - 1)] ?? top;
856
- const y = top + (nextTop - top) * frac;
857
- const lineHeight = lines.tokenHeights[idx] ?? 0;
858
- return Math.max(0, y + lineHeight / 2 - viewportHeightPx * eyeLine);
859
- }
860
- function stepScroll(currentPx, targetPx, dtMs, maxPxPerSec = MAX_SCROLL_PX_PER_SEC) {
861
- const dt = Math.min(Math.max(dtMs, 0), 250) / 1e3;
862
- if (dt === 0) return currentPx;
863
- const blend = 1 - Math.exp(-dt / 0.18);
864
- let next = currentPx + (targetPx - currentPx) * blend;
865
- const maxStep = maxPxPerSec * dt;
866
- if (next - currentPx > maxStep) next = currentPx + maxStep;
867
- else if (currentPx - next > maxStep) next = currentPx - maxStep;
868
- return Math.abs(next - targetPx) < 0.25 ? targetPx : next;
869
- }
870
-
871
1038
  // src/teleprompter/teleprompterTheme.ts
872
1039
  import { resolveFontFamily } from "@bendyline/squisq/schemas";
873
1040
  function prompterVarsFromTheme(theme) {
@@ -1156,450 +1323,78 @@ var TELEPROMPTER_CSS = `
1156
1323
  }
1157
1324
  `;
1158
1325
 
1159
- // src/teleprompter/TeleprompterSurface.tsx
1160
- import { memo, useEffect as useEffect4, useMemo as useMemo3, useRef as useRef4 } from "react";
1161
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
1162
- function groupScript(script) {
1163
- return script.blocks.map((range) => {
1164
- const paragraphs = [];
1165
- let current = [];
1166
- for (let i = range.tokenStart; i < range.tokenEnd; i++) {
1167
- current.push(i);
1168
- if (script.tokens[i].pauseAfter >= 2 && i < range.tokenEnd - 1) {
1169
- paragraphs.push({ key: `${range.blockId}-${paragraphs.length}`, tokenIndexes: current });
1170
- current = [];
1171
- }
1172
- }
1173
- if (current.length > 0) {
1174
- paragraphs.push({ key: `${range.blockId}-${paragraphs.length}`, tokenIndexes: current });
1326
+ // src/teleprompter/recording/useNarrationRecorder.ts
1327
+ import { useCallback as useCallback4, useEffect as useEffect5, useRef as useRef4, useState as useState4 } from "react";
1328
+ import {
1329
+ alignNarration
1330
+ } from "@bendyline/squisq/narration";
1331
+ var TRACE_INTERVAL_MS = 250;
1332
+ function mixdownToMono(buffer) {
1333
+ const channels = buffer.numberOfChannels;
1334
+ if (channels === 1) return buffer.getChannelData(0).slice();
1335
+ const out = new Float32Array(buffer.length);
1336
+ for (let c = 0; c < channels; c++) {
1337
+ const data = buffer.getChannelData(c);
1338
+ for (let i = 0; i < out.length; i++) out[i] += data[i] / channels;
1339
+ }
1340
+ return out;
1341
+ }
1342
+ var StartAborted = class extends Error {
1343
+ constructor() {
1344
+ super("Narration start aborted");
1345
+ this.name = "StartAborted";
1346
+ }
1347
+ };
1348
+ function stopRecorder(recorder) {
1349
+ if (recorder.state === "inactive") return Promise.resolve();
1350
+ return new Promise((resolve) => {
1351
+ const done = () => resolve();
1352
+ recorder.addEventListener("stop", done, { once: true });
1353
+ try {
1354
+ recorder.stop();
1355
+ } catch {
1356
+ recorder.removeEventListener("stop", done);
1357
+ resolve();
1175
1358
  }
1176
- const group = { blockId: range.blockId, paragraphs };
1177
- if (range.heading !== void 0) group.heading = range.heading;
1178
- return group;
1179
1359
  });
1180
1360
  }
1181
- var ScriptColumn = memo(function ScriptColumn2({
1182
- script,
1183
- compact
1184
- }) {
1185
- const groups = useMemo3(() => groupScript(script), [script]);
1186
- return /* @__PURE__ */ jsx(Fragment, { children: groups.map((group) => /* @__PURE__ */ jsxs("section", { "data-block-id": group.blockId, children: [
1187
- !compact && group.heading ? /* @__PURE__ */ jsx("span", { className: "squisq-teleprompter-block-marker", children: group.heading }) : null,
1188
- group.paragraphs.map((paragraph) => /* @__PURE__ */ jsx("p", { className: "squisq-teleprompter-para", children: paragraph.tokenIndexes.map((idx) => /* @__PURE__ */ jsxs("span", { className: "squisq-teleprompter-word", "data-token-idx": idx, children: [
1189
- script.tokens[idx].text,
1190
- " "
1191
- ] }, idx)) }, paragraph.key))
1192
- ] }, group.blockId)) });
1193
- });
1194
- function TeleprompterSurface({
1195
- script,
1196
- wordPos,
1197
- fontSizePx,
1198
- mirrored,
1199
- lineGuide,
1200
- countdownRemaining,
1201
- recordingIndicator,
1202
- theme,
1203
- compact = false,
1204
- onSeekToken
1205
- }) {
1206
- const surfaceRef = useRef4(null);
1207
- const columnRef = useRef4(null);
1208
- const wordPosRef = useRef4(wordPos);
1209
- wordPosRef.current = wordPos;
1210
- const vars = useMemo3(() => prompterVarsFromTheme(theme), [theme]);
1211
- useEffect4(() => {
1212
- const doc = surfaceRef.current?.ownerDocument;
1213
- if (doc) ensureTeleprompterStyles(doc);
1361
+ function useNarrationRecorder(options) {
1362
+ const [state, setState] = useState4("idle");
1363
+ const [error, setError] = useState4(null);
1364
+ const [withCamera, setWithCamera] = useState4(false);
1365
+ const [cameraStream, setCameraStream] = useState4(null);
1366
+ const [take, setTake] = useState4(null);
1367
+ const captureRef = useRef4(null);
1368
+ const processingRef = useRef4(false);
1369
+ const takeRef = useRef4(null);
1370
+ const unmountedRef = useRef4(false);
1371
+ const generationRef = useRef4(0);
1372
+ const startingRef = useRef4(false);
1373
+ const optionsRef = useRef4(options);
1374
+ optionsRef.current = options;
1375
+ const cancelPendingStart = useCallback4(() => {
1376
+ generationRef.current++;
1214
1377
  }, []);
1215
- useEffect4(() => {
1216
- const surface = surfaceRef.current;
1217
- const column = columnRef.current;
1218
- if (!surface || !column) return;
1219
- const win = surface.ownerDocument.defaultView ?? window;
1220
- let lines = null;
1221
- let spans = [];
1222
- let activeIdx = -1;
1223
- let offset = 0;
1224
- let lastTime = performance.now();
1225
- let raf = 0;
1226
- const tokens = script.tokens;
1227
- const spokenAt = new Int32Array(tokens.length);
1228
- let lastSpoken = -1;
1229
- for (let i = 0; i < tokens.length; i++) {
1230
- if (tokens[i].spoken) lastSpoken = i;
1231
- spokenAt[i] = lastSpoken;
1232
- }
1233
- let firstSpoken = -1;
1234
- for (let i = 0; i < tokens.length; i++) {
1235
- if (tokens[i].spoken) {
1236
- firstSpoken = i;
1237
- break;
1378
+ const applyTake = useCallback4((next) => {
1379
+ takeRef.current = next;
1380
+ setTake(next);
1381
+ }, []);
1382
+ const teardownCapture = useCallback4(() => {
1383
+ cancelPendingStart();
1384
+ const capture = captureRef.current;
1385
+ captureRef.current = null;
1386
+ if (!capture) return;
1387
+ if (capture.traceTimer !== null) clearInterval(capture.traceTimer);
1388
+ if (capture.audioRecorder.state !== "inactive") {
1389
+ try {
1390
+ capture.audioRecorder.stop();
1391
+ } catch {
1238
1392
  }
1239
1393
  }
1240
- for (let i = 0; i < tokens.length && spokenAt[i] === -1; i++) spokenAt[i] = firstSpoken;
1241
- const remeasure = () => {
1242
- lines = measureTokenLines(column);
1243
- spans = Array.from(column.querySelectorAll("[data-token-idx]"));
1244
- };
1245
- remeasure();
1246
- const applyHighlight = (nextIdx) => {
1247
- if (nextIdx === activeIdx) return;
1248
- const lo = Math.min(activeIdx, nextIdx);
1249
- const hi = Math.max(activeIdx, nextIdx);
1250
- for (let i = Math.max(0, lo); i <= hi && i < spans.length; i++) {
1251
- const span = spans[i];
1252
- if (!span) continue;
1253
- span.classList.toggle("squisq-teleprompter-word--active", i === nextIdx);
1254
- span.classList.toggle("squisq-teleprompter-word--read", i < nextIdx);
1255
- }
1256
- activeIdx = nextIdx;
1257
- };
1258
- const loop = (now) => {
1259
- const dtMs = now - lastTime;
1260
- lastTime = now;
1261
- const pos = wordPosRef.current;
1262
- const clampedIdx = Math.min(Math.max(Math.floor(pos), 0), spans.length - 1);
1263
- const highlightIdx = clampedIdx < spokenAt.length && spokenAt[clampedIdx] >= 0 ? spokenAt[clampedIdx] : clampedIdx;
1264
- if (spans.length > 0) applyHighlight(highlightIdx);
1265
- if (lines) {
1266
- const target = targetOffsetFor(pos, lines, surface.clientHeight);
1267
- offset = stepScroll(offset, target, dtMs);
1268
- column.style.transform = `translateY(${-offset}px)`;
1269
- }
1270
- raf = win.requestAnimationFrame(loop);
1271
- };
1272
- raf = win.requestAnimationFrame(loop);
1273
- const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(remeasure) : null;
1274
- resizeObserver?.observe(surface);
1275
- return () => {
1276
- win.cancelAnimationFrame(raf);
1277
- resizeObserver?.disconnect();
1278
- };
1279
- }, [script, fontSizePx, compact]);
1280
- const handleClick = onSeekToken ? (event) => {
1281
- const target = event.target;
1282
- const span = target?.closest("[data-token-idx]");
1283
- if (!span) return;
1284
- const idx = Number(span.dataset.tokenIdx);
1285
- if (Number.isFinite(idx)) onSeekToken(idx);
1286
- } : void 0;
1287
- return /* @__PURE__ */ jsxs(
1288
- "div",
1289
- {
1290
- ref: surfaceRef,
1291
- className: `squisq-teleprompter-surface${mirrored ? " squisq-teleprompter-surface--mirrored" : ""}`,
1292
- style: { ...vars, fontSize: `${fontSizePx}px` },
1293
- "data-testid": "teleprompter-surface",
1294
- children: [
1295
- /* @__PURE__ */ jsx("div", { className: "squisq-teleprompter-flip", children: /* @__PURE__ */ jsx(
1296
- "div",
1297
- {
1298
- ref: columnRef,
1299
- className: "squisq-teleprompter-scroll",
1300
- style: compact ? { padding: "30vh 5% 60vh" } : void 0,
1301
- onClick: handleClick,
1302
- children: /* @__PURE__ */ jsx(ScriptColumn, { script, compact })
1303
- }
1304
- ) }),
1305
- lineGuide ? /* @__PURE__ */ jsxs(Fragment, { children: [
1306
- /* @__PURE__ */ jsx(
1307
- "div",
1308
- {
1309
- className: "squisq-teleprompter-guide-band",
1310
- style: { top: `calc(${EYE_LINE_FRACTION * 100}% - 0.75em)`, height: "1.5em" }
1311
- }
1312
- ),
1313
- /* @__PURE__ */ jsx(
1314
- "div",
1315
- {
1316
- className: "squisq-teleprompter-line-guide",
1317
- style: { top: `calc(${EYE_LINE_FRACTION * 100}% - 0.75em)`, height: "1.5em" }
1318
- }
1319
- )
1320
- ] }) : null,
1321
- countdownRemaining !== null ? /* @__PURE__ */ jsx("div", { className: "squisq-teleprompter-countdown", children: /* @__PURE__ */ jsx("span", { className: "squisq-teleprompter-countdown-digit", children: countdownRemaining }) }) : null,
1322
- recordingIndicator ? /* @__PURE__ */ jsx("div", { className: "squisq-teleprompter-recdot" }) : null
1323
- ]
1324
- }
1325
- );
1326
- }
1327
-
1328
- // src/teleprompter/TeleprompterControls.tsx
1329
- import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
1330
- var TIER_LABELS = {
1331
- "document-pip": "Floating window (always on top)",
1332
- "video-pip": "Picture-in-picture (read-only)",
1333
- popup: "Popup window",
1334
- docked: "Docked"
1335
- };
1336
- function TeleprompterControls({ controller, float, recordSlot }) {
1337
- const { transport, prefs, setPrefs, mic } = controller;
1338
- const rolling = transport === "rolling" || transport === "countdown";
1339
- const voiceLive = prefs.voiceTracking && mic.status === "live";
1340
- return /* @__PURE__ */ jsxs2(
1341
- "div",
1342
- {
1343
- className: "squisq-teleprompter-controls",
1344
- "data-testid": "teleprompter-controls",
1345
- "data-mic-status": mic.status,
1346
- "data-transport": transport,
1347
- "data-voice-live": voiceLive || void 0,
1348
- children: [
1349
- /* @__PURE__ */ jsxs2("span", { className: "squisq-teleprompter-group", children: [
1350
- /* @__PURE__ */ jsx2(
1351
- "button",
1352
- {
1353
- type: "button",
1354
- onClick: () => rolling ? controller.pause() : controller.play(),
1355
- "aria-label": rolling ? "Pause prompter" : "Start prompter",
1356
- children: rolling ? "\u23F8 Pause" : transport === "paused" ? "\u25B6 Resume" : "\u25B6 Start"
1357
- }
1358
- ),
1359
- /* @__PURE__ */ jsx2("button", { type: "button", onClick: controller.restart, "aria-label": "Restart prompter", children: "\u27F2 Restart" })
1360
- ] }),
1361
- /* @__PURE__ */ jsxs2("span", { className: "squisq-teleprompter-group", children: [
1362
- /* @__PURE__ */ jsx2("label", { htmlFor: "squisq-prompter-countdown", children: "Countdown" }),
1363
- /* @__PURE__ */ jsxs2(
1364
- "select",
1365
- {
1366
- id: "squisq-prompter-countdown",
1367
- value: prefs.countdownSec,
1368
- onChange: (e) => setPrefs({ countdownSec: Number(e.target.value) }),
1369
- children: [
1370
- /* @__PURE__ */ jsx2("option", { value: 0, children: "Off" }),
1371
- /* @__PURE__ */ jsx2("option", { value: 3, children: "3s" }),
1372
- /* @__PURE__ */ jsx2("option", { value: 5, children: "5s" }),
1373
- /* @__PURE__ */ jsx2("option", { value: 10, children: "10s" })
1374
- ]
1375
- }
1376
- )
1377
- ] }),
1378
- /* @__PURE__ */ jsxs2("span", { className: "squisq-teleprompter-group", children: [
1379
- /* @__PURE__ */ jsx2("label", { htmlFor: "squisq-prompter-wpm", children: "Speed" }),
1380
- /* @__PURE__ */ jsx2(
1381
- "input",
1382
- {
1383
- id: "squisq-prompter-wpm",
1384
- type: "range",
1385
- min: 80,
1386
- max: 260,
1387
- step: 5,
1388
- value: prefs.baseWpm,
1389
- onChange: (e) => setPrefs({ baseWpm: Number(e.target.value) })
1390
- }
1391
- ),
1392
- /* @__PURE__ */ jsxs2("span", { "aria-live": "off", children: [
1393
- prefs.baseWpm,
1394
- " wpm"
1395
- ] })
1396
- ] }),
1397
- /* @__PURE__ */ jsxs2("span", { className: "squisq-teleprompter-group", children: [
1398
- /* @__PURE__ */ jsx2(
1399
- "button",
1400
- {
1401
- type: "button",
1402
- "aria-pressed": prefs.voiceTracking,
1403
- onClick: () => {
1404
- const next = !prefs.voiceTracking;
1405
- setPrefs({ voiceTracking: next });
1406
- if (next && mic.status === "idle") void mic.start(prefs.micDeviceId);
1407
- if (!next && mic.status !== "idle") mic.stop();
1408
- },
1409
- title: "Match the prompter speed to your voice (halts when you stop speaking)",
1410
- children: "\u{1F399} Voice pace"
1411
- }
1412
- ),
1413
- prefs.voiceTracking ? /* @__PURE__ */ jsxs2(Fragment2, { children: [
1414
- /* @__PURE__ */ jsx2("label", { htmlFor: "squisq-prompter-sensitivity", title: "Voice detection sensitivity", children: "Sens." }),
1415
- /* @__PURE__ */ jsx2(
1416
- "input",
1417
- {
1418
- id: "squisq-prompter-sensitivity",
1419
- type: "range",
1420
- min: 0,
1421
- max: 1,
1422
- step: 0.05,
1423
- value: prefs.vadSensitivity,
1424
- onChange: (e) => setPrefs({ vadSensitivity: Number(e.target.value) }),
1425
- style: { width: 70 }
1426
- }
1427
- ),
1428
- /* @__PURE__ */ jsxs2(
1429
- "select",
1430
- {
1431
- "aria-label": "Microphone",
1432
- value: prefs.micDeviceId ?? "",
1433
- onChange: (e) => setPrefs({ micDeviceId: e.target.value || null }),
1434
- children: [
1435
- /* @__PURE__ */ jsx2("option", { value: "", children: "Default mic" }),
1436
- mic.devices.map((device) => /* @__PURE__ */ jsx2("option", { value: device.deviceId, children: device.label || `Mic ${device.deviceId.slice(0, 6)}` }, device.deviceId))
1437
- ]
1438
- }
1439
- ),
1440
- /* @__PURE__ */ jsx2(
1441
- "span",
1442
- {
1443
- className: `squisq-teleprompter-meter${controller.voiceActive ? " squisq-teleprompter-meter--voice" : ""}`,
1444
- role: "meter",
1445
- "aria-label": "Mic level",
1446
- "aria-valuemin": 0,
1447
- "aria-valuemax": 1,
1448
- "aria-valuenow": Math.round(controller.micLevel * 100) / 100,
1449
- children: /* @__PURE__ */ jsx2(
1450
- "span",
1451
- {
1452
- className: "squisq-teleprompter-meter-fill",
1453
- style: { width: `${Math.round(controller.micLevel * 100)}%` }
1454
- }
1455
- )
1456
- }
1457
- ),
1458
- mic.status === "error" ? /* @__PURE__ */ jsx2("span", { title: mic.error?.message ?? "Microphone unavailable", children: "\u26A0 mic unavailable \u2014 constant speed" }) : null
1459
- ] }) : null
1460
- ] }),
1461
- /* @__PURE__ */ jsxs2("span", { className: "squisq-teleprompter-group", children: [
1462
- /* @__PURE__ */ jsx2("label", { htmlFor: "squisq-prompter-fontsize", children: "Aa" }),
1463
- /* @__PURE__ */ jsx2(
1464
- "input",
1465
- {
1466
- id: "squisq-prompter-fontsize",
1467
- type: "range",
1468
- min: 28,
1469
- max: 96,
1470
- step: 2,
1471
- value: prefs.fontSizePx,
1472
- onChange: (e) => setPrefs({ fontSizePx: Number(e.target.value) }),
1473
- style: { width: 80 },
1474
- "aria-label": "Prompter font size"
1475
- }
1476
- ),
1477
- /* @__PURE__ */ jsx2(
1478
- "button",
1479
- {
1480
- type: "button",
1481
- "aria-pressed": prefs.mirrored,
1482
- onClick: () => setPrefs({ mirrored: !prefs.mirrored }),
1483
- title: "Mirror for beam-splitter teleprompter rigs (M)",
1484
- children: "\u21CB Mirror"
1485
- }
1486
- ),
1487
- /* @__PURE__ */ jsx2(
1488
- "button",
1489
- {
1490
- type: "button",
1491
- "aria-pressed": prefs.lineGuide,
1492
- onClick: () => setPrefs({ lineGuide: !prefs.lineGuide }),
1493
- title: "Eye-line guide",
1494
- children: "\u25B8 Guide"
1495
- }
1496
- )
1497
- ] }),
1498
- recordSlot,
1499
- float.supportedTiers.length > 0 ? /* @__PURE__ */ jsx2("span", { className: "squisq-teleprompter-group", style: { marginLeft: "auto" }, children: float.isOpen ? /* @__PURE__ */ jsx2("button", { type: "button", onClick: float.close, children: "\u21E4 Bring back" }) : /* @__PURE__ */ jsxs2(Fragment2, { children: [
1500
- float.supportedTiers.length > 1 ? /* @__PURE__ */ jsx2(
1501
- "select",
1502
- {
1503
- "aria-label": "Float mode",
1504
- "data-testid": "teleprompter-float-tier",
1505
- defaultValue: float.supportedTiers[0],
1506
- id: "squisq-prompter-float-tier",
1507
- children: float.supportedTiers.map((tier) => /* @__PURE__ */ jsx2("option", { value: tier, children: TIER_LABELS[tier] }, tier))
1508
- }
1509
- ) : null,
1510
- /* @__PURE__ */ jsx2(
1511
- "button",
1512
- {
1513
- type: "button",
1514
- onClick: () => {
1515
- const select = document.getElementById(
1516
- "squisq-prompter-float-tier"
1517
- );
1518
- const preferred = select?.value ?? void 0;
1519
- void float.open(preferred);
1520
- },
1521
- title: "Pop the prompter out so it can sit next to your camera",
1522
- children: "\u21F1 Pop out"
1523
- }
1524
- )
1525
- ] }) }) : null
1526
- ]
1527
- }
1528
- );
1529
- }
1530
-
1531
- // src/teleprompter/recording/useNarrationRecorder.ts
1532
- import { useCallback as useCallback4, useEffect as useEffect5, useRef as useRef5, useState as useState4 } from "react";
1533
- import {
1534
- alignNarration
1535
- } from "@bendyline/squisq/narration";
1536
- var TRACE_INTERVAL_MS = 250;
1537
- function mixdownToMono(buffer) {
1538
- const channels = buffer.numberOfChannels;
1539
- if (channels === 1) return buffer.getChannelData(0).slice();
1540
- const out = new Float32Array(buffer.length);
1541
- for (let c = 0; c < channels; c++) {
1542
- const data = buffer.getChannelData(c);
1543
- for (let i = 0; i < out.length; i++) out[i] += data[i] / channels;
1544
- }
1545
- return out;
1546
- }
1547
- var StartAborted = class extends Error {
1548
- constructor() {
1549
- super("Narration start aborted");
1550
- this.name = "StartAborted";
1551
- }
1552
- };
1553
- function stopRecorder(recorder) {
1554
- if (recorder.state === "inactive") return Promise.resolve();
1555
- return new Promise((resolve) => {
1556
- const done = () => resolve();
1557
- recorder.addEventListener("stop", done, { once: true });
1558
- try {
1559
- recorder.stop();
1560
- } catch {
1561
- recorder.removeEventListener("stop", done);
1562
- resolve();
1563
- }
1564
- });
1565
- }
1566
- function useNarrationRecorder(options) {
1567
- const [state, setState] = useState4("idle");
1568
- const [error, setError] = useState4(null);
1569
- const [withCamera, setWithCamera] = useState4(false);
1570
- const [cameraStream, setCameraStream] = useState4(null);
1571
- const [take, setTake] = useState4(null);
1572
- const captureRef = useRef5(null);
1573
- const processingRef = useRef5(false);
1574
- const takeRef = useRef5(null);
1575
- const unmountedRef = useRef5(false);
1576
- const generationRef = useRef5(0);
1577
- const startingRef = useRef5(false);
1578
- const optionsRef = useRef5(options);
1579
- optionsRef.current = options;
1580
- const cancelPendingStart = useCallback4(() => {
1581
- generationRef.current++;
1582
- }, []);
1583
- const applyTake = useCallback4((next) => {
1584
- takeRef.current = next;
1585
- setTake(next);
1586
- }, []);
1587
- const teardownCapture = useCallback4(() => {
1588
- cancelPendingStart();
1589
- const capture = captureRef.current;
1590
- captureRef.current = null;
1591
- if (!capture) return;
1592
- if (capture.traceTimer !== null) clearInterval(capture.traceTimer);
1593
- if (capture.audioRecorder.state !== "inactive") {
1594
- try {
1595
- capture.audioRecorder.stop();
1596
- } catch {
1597
- }
1598
- }
1599
- if (capture.cameraRecorder && capture.cameraRecorder.state !== "inactive") {
1600
- try {
1601
- capture.cameraRecorder.stop();
1602
- } catch {
1394
+ if (capture.cameraRecorder && capture.cameraRecorder.state !== "inactive") {
1395
+ try {
1396
+ capture.cameraRecorder.stop();
1397
+ } catch {
1603
1398
  }
1604
1399
  }
1605
1400
  for (const track of capture.cameraStream?.getTracks() ?? []) track.stop();
@@ -1784,207 +1579,779 @@ function useNarrationRecorder(options) {
1784
1579
  cumulativeSyllables: [0]
1785
1580
  }
1786
1581
  });
1787
- setState("review");
1788
- }, [applyTake, cancelPendingStart]);
1789
- const retake = useCallback4(() => {
1790
- teardownCapture();
1791
- applyTake(null);
1792
- setError(null);
1793
- setState("idle");
1794
- }, [applyTake, teardownCapture]);
1795
- const discard = useCallback4(() => {
1796
- teardownCapture();
1797
- applyTake(null);
1798
- setError(null);
1799
- setState("idle");
1800
- }, [applyTake, teardownCapture]);
1801
- const beginSave = useCallback4(() => setState("saving"), []);
1802
- const finishSave = useCallback4(
1803
- (ok, saveError) => {
1804
- if (ok) {
1805
- applyTake(null);
1806
- setError(null);
1807
- setState("idle");
1808
- } else {
1809
- setError(saveError ?? new Error("Save failed"));
1810
- setState("review");
1811
- }
1812
- },
1813
- [applyTake]
1582
+ setState("review");
1583
+ }, [applyTake, cancelPendingStart]);
1584
+ const retake = useCallback4(() => {
1585
+ teardownCapture();
1586
+ applyTake(null);
1587
+ setError(null);
1588
+ setState("idle");
1589
+ }, [applyTake, teardownCapture]);
1590
+ const discard = useCallback4(() => {
1591
+ teardownCapture();
1592
+ applyTake(null);
1593
+ setError(null);
1594
+ setState("idle");
1595
+ }, [applyTake, teardownCapture]);
1596
+ const beginSave = useCallback4(() => setState("saving"), []);
1597
+ const finishSave = useCallback4(
1598
+ (ok, saveError) => {
1599
+ if (ok) {
1600
+ applyTake(null);
1601
+ setError(null);
1602
+ setState("idle");
1603
+ } else {
1604
+ setError(saveError ?? new Error("Save failed"));
1605
+ setState("review");
1606
+ }
1607
+ },
1608
+ [applyTake]
1609
+ );
1610
+ useEffect5(() => {
1611
+ unmountedRef.current = false;
1612
+ return () => {
1613
+ unmountedRef.current = true;
1614
+ if (processingRef.current) {
1615
+ console.warn(
1616
+ "[squisq-editor] Narration take discarded: the teleprompter was closed while a recording was still being aligned. The audio was not saved."
1617
+ );
1618
+ } else if (takeRef.current) {
1619
+ console.warn(
1620
+ "[squisq-editor] Unsaved narration take discarded: the teleprompter was closed before the take was saved."
1621
+ );
1622
+ }
1623
+ teardownCapture();
1624
+ };
1625
+ }, [teardownCapture]);
1626
+ return {
1627
+ state,
1628
+ error,
1629
+ withCamera,
1630
+ setWithCamera,
1631
+ cameraStream,
1632
+ take,
1633
+ start,
1634
+ stop,
1635
+ retake,
1636
+ discard,
1637
+ beginSave,
1638
+ finishSave
1639
+ };
1640
+ }
1641
+
1642
+ // src/teleprompter/recording/insertPreamble.ts
1643
+ var AUDIO_ANNOTATION_LINE = /^\{\[audio\s[^\]]*\]\}\s*$/;
1644
+ var DOCUMENT_ANCHOR = /\banchor=(?:"document"|'document'|document)(?:\s|\]|$)/;
1645
+ var CAMERA_LINE = /^<video\s[^>]*src="[^"]*"[^>]*><\/video>\s*$/;
1646
+ function isNarrationLine(line) {
1647
+ return AUDIO_ANNOTATION_LINE.test(line) && DOCUMENT_ANCHOR.test(line);
1648
+ }
1649
+ function quoteSrc(path) {
1650
+ return /[\s"']/.test(path) ? `"${path.replace(/"/g, '\\"')}"` : path;
1651
+ }
1652
+ function narrationAnnotationLine(audioPath) {
1653
+ return `{[audio src=${quoteSrc(audioPath)} anchor=document]}`;
1654
+ }
1655
+ function cameraVideoLine(cameraPath) {
1656
+ return `<video src="${cameraPath}" controls width="240"></video>`;
1657
+ }
1658
+ function insertNarrationPreamble(source, audioPath, cameraPath) {
1659
+ const lines = source.split("\n");
1660
+ let insertAt = 0;
1661
+ if (lines[0]?.trim() === "---") {
1662
+ for (let i = 1; i < lines.length; i++) {
1663
+ if (lines[i].trim() === "---") {
1664
+ insertAt = i + 1;
1665
+ break;
1666
+ }
1667
+ }
1668
+ }
1669
+ let scan = insertAt;
1670
+ while (scan < lines.length && lines[scan].trim() === "") scan++;
1671
+ if (scan < lines.length && isNarrationLine(lines[scan])) {
1672
+ let removeEnd = scan + 1;
1673
+ while (removeEnd < lines.length && lines[removeEnd].trim() === "") removeEnd++;
1674
+ if (removeEnd < lines.length && CAMERA_LINE.test(lines[removeEnd])) removeEnd++;
1675
+ if (removeEnd < lines.length && lines[removeEnd].trim() === "") removeEnd++;
1676
+ lines.splice(insertAt, removeEnd - insertAt);
1677
+ }
1678
+ const inserted = [narrationAnnotationLine(audioPath)];
1679
+ if (cameraPath) {
1680
+ inserted.push("", cameraVideoLine(cameraPath));
1681
+ }
1682
+ const before = lines.slice(0, insertAt);
1683
+ const after = lines.slice(insertAt);
1684
+ if (before.length > 0 && before[before.length - 1].trim() !== "") before.push("");
1685
+ if (after.length > 0 && after[0].trim() !== "") inserted.push("");
1686
+ return [...before, ...inserted, ...after].join("\n");
1687
+ }
1688
+
1689
+ // src/teleprompter/recording/narrationSave.ts
1690
+ import {
1691
+ buildNarrationTimingJson
1692
+ } from "@bendyline/squisq/narration";
1693
+ function buildNarrationSavePlan(args) {
1694
+ const sidecarPayload = args.alignment ? buildNarrationTimingJson(args.script, args.alignment, args.durationSec, {
1695
+ baseWpm: args.baseWpm,
1696
+ ...args.cameraOffsetSec !== void 0 ? { cameraOffsetSec: args.cameraOffsetSec } : {}
1697
+ }) : {
1698
+ version: 3,
1699
+ sourceText: args.script.sourceText,
1700
+ duration: args.durationSec,
1701
+ bookmarks: [],
1702
+ blocks: [],
1703
+ generator: { name: "squisq-teleprompter", method: "dsp-align", baseWpm: args.baseWpm }
1704
+ };
1705
+ return {
1706
+ audioRelativeName: `audio/${buildFilename("audio", args.audioExt, args.audioBasename)}`,
1707
+ cameraRelativeName: args.cameraExt ? `video/${buildFilename("video", args.cameraExt, "narration-cam")}` : null,
1708
+ sidecarPayload,
1709
+ sidecarPathFor: (savedAudioPath) => timingPathFor(savedAudioPath),
1710
+ nextMarkdown: (currentSource, savedAudioPath, savedCameraPath) => insertNarrationPreamble(currentSource, savedAudioPath, savedCameraPath)
1711
+ };
1712
+ }
1713
+ async function executeNarrationSave(plan, take, deps, progress = {}) {
1714
+ const audioPath = progress.audioPath ?? await deps.mediaProvider.addMedia(plan.audioRelativeName, take.audioBlob, take.audioMime);
1715
+ progress.audioPath = audioPath;
1716
+ const sidecarPath = progress.sidecarPath ?? plan.sidecarPathFor(audioPath);
1717
+ if (progress.sidecarPath === void 0) {
1718
+ const encoded = encodeTimingJson(plan.sidecarPayload);
1719
+ if (deps.container) {
1720
+ await deps.container.writeFile(sidecarPath, encoded, "application/json");
1721
+ } else {
1722
+ const storedAt = await deps.mediaProvider.addMedia(sidecarPath, encoded, "application/json");
1723
+ if (storedAt !== sidecarPath) {
1724
+ console.warn(
1725
+ `Narration timing sidecar stored at "${storedAt}" instead of "${sidecarPath}"; narration timing will not be discovered until it sits next to the audio file.`
1726
+ );
1727
+ }
1728
+ }
1729
+ progress.sidecarPath = sidecarPath;
1730
+ }
1731
+ let cameraPath = progress.cameraPath ?? null;
1732
+ if (progress.cameraPath === void 0) {
1733
+ if (plan.cameraRelativeName && take.cameraBlob && take.cameraMime) {
1734
+ cameraPath = await deps.mediaProvider.addMedia(
1735
+ plan.cameraRelativeName,
1736
+ take.cameraBlob,
1737
+ take.cameraMime
1738
+ );
1739
+ }
1740
+ progress.cameraPath = cameraPath;
1741
+ }
1742
+ deps.setMarkdownSource(plan.nextMarkdown(deps.getMarkdownSource(), audioPath, cameraPath));
1743
+ deps.bumpMediaRevision();
1744
+ return { audioPath, cameraPath, sidecarPath };
1745
+ }
1746
+ async function discardNarrationSaveProgress(progress, deps) {
1747
+ const paths = [progress.audioPath, progress.cameraPath ?? void 0].filter(
1748
+ (p) => typeof p === "string"
1749
+ );
1750
+ for (const path of paths) {
1751
+ try {
1752
+ await deps.mediaProvider.removeMedia(path);
1753
+ } catch (err) {
1754
+ console.warn(
1755
+ `Could not remove orphaned narration media "${path}": ` + (err instanceof Error ? err.message : String(err))
1756
+ );
1757
+ }
1758
+ }
1759
+ if (progress.sidecarPath !== void 0 && deps.container) {
1760
+ try {
1761
+ await deps.container.removeFile(progress.sidecarPath);
1762
+ } catch (err) {
1763
+ console.warn(
1764
+ `Could not remove orphaned narration sidecar "${progress.sidecarPath}": ` + (err instanceof Error ? err.message : String(err))
1765
+ );
1766
+ }
1767
+ }
1768
+ delete progress.audioPath;
1769
+ delete progress.cameraPath;
1770
+ delete progress.sidecarPath;
1771
+ }
1772
+
1773
+ // src/teleprompter/useNarrationStage.ts
1774
+ import { useCallback as useCallback5, useEffect as useEffect6, useMemo as useMemo3, useRef as useRef5, useState as useState5 } from "react";
1775
+ import { wordIndexAtTime } from "@bendyline/squisq/narration";
1776
+ function useNarrationStage(opts) {
1777
+ const { doc, recording = null, getAudioBasename } = opts;
1778
+ const controller = useTeleprompter({ doc });
1779
+ const float = useFloatingWindow(TELEPROMPTER_CSS);
1780
+ const controllerRef = useRef5(controller);
1781
+ controllerRef.current = controller;
1782
+ const [saveNotice, setSaveNotice] = useState5(null);
1783
+ const recorder = useNarrationRecorder({
1784
+ mic: controller.mic,
1785
+ getScript: () => controllerRef.current.script,
1786
+ getWordPos: () => controllerRef.current.wordPos,
1787
+ getMicDeviceId: () => controllerRef.current.prefs.micDeviceId,
1788
+ onRecordingStart: () => controllerRef.current.play(),
1789
+ onRecordingStop: () => controllerRef.current.pause()
1790
+ });
1791
+ const recorderRef = useRef5(recorder);
1792
+ recorderRef.current = recorder;
1793
+ const recordingRef = useRef5(recording);
1794
+ recordingRef.current = recording;
1795
+ const getAudioBasenameRef = useRef5(getAudioBasename);
1796
+ getAudioBasenameRef.current = getAudioBasename;
1797
+ const saveProgressRef = useRef5(null);
1798
+ const progressForTake = useCallback5((take) => {
1799
+ const existing = saveProgressRef.current;
1800
+ if (existing && existing.take === take) return existing.progress;
1801
+ const fresh = { take, progress: {} };
1802
+ saveProgressRef.current = fresh;
1803
+ return fresh.progress;
1804
+ }, []);
1805
+ const cleanupAbandonedSave = useCallback5(() => {
1806
+ const pending = saveProgressRef.current;
1807
+ saveProgressRef.current = null;
1808
+ const deps = recordingRef.current;
1809
+ if (!pending || !deps) return;
1810
+ if (pending.progress.audioPath === void 0 && pending.progress.sidecarPath === void 0) {
1811
+ return;
1812
+ }
1813
+ void discardNarrationSaveProgress(pending.progress, {
1814
+ mediaProvider: deps.mediaProvider,
1815
+ container: deps.container
1816
+ });
1817
+ }, []);
1818
+ const handleRetake = useCallback5(() => {
1819
+ cleanupAbandonedSave();
1820
+ recorderRef.current.retake();
1821
+ }, [cleanupAbandonedSave]);
1822
+ const handleDiscard = useCallback5(() => {
1823
+ cleanupAbandonedSave();
1824
+ recorderRef.current.discard();
1825
+ }, [cleanupAbandonedSave]);
1826
+ const reviewAudioUrl = useMemo3(
1827
+ () => recorder.take ? URL.createObjectURL(recorder.take.audioBlob) : null,
1828
+ [recorder.take]
1814
1829
  );
1815
- useEffect5(() => {
1816
- unmountedRef.current = false;
1830
+ useEffect6(() => {
1817
1831
  return () => {
1818
- unmountedRef.current = true;
1819
- if (processingRef.current) {
1820
- console.warn(
1821
- "[squisq-editor] Narration take discarded: the teleprompter was closed while a recording was still being aligned. The audio was not saved."
1822
- );
1823
- } else if (takeRef.current) {
1824
- console.warn(
1825
- "[squisq-editor] Unsaved narration take discarded: the teleprompter was closed before the take was saved."
1826
- );
1827
- }
1828
- teardownCapture();
1832
+ if (reviewAudioUrl) URL.revokeObjectURL(reviewAudioUrl);
1829
1833
  };
1830
- }, [teardownCapture]);
1834
+ }, [reviewAudioUrl]);
1835
+ const handleReviewTimeUpdate = useCallback5((event) => {
1836
+ const alignment = recorderRef.current.take?.alignment;
1837
+ if (!alignment || alignment.words.length === 0) return;
1838
+ controllerRef.current.seekToToken(
1839
+ wordIndexAtTime(alignment.words, event.currentTarget.currentTime)
1840
+ );
1841
+ }, []);
1842
+ const handleSave = useCallback5(async () => {
1843
+ const take = recorderRef.current.take;
1844
+ if (!take || !recording) return;
1845
+ recorderRef.current.beginSave();
1846
+ try {
1847
+ const basename = getAudioBasenameRef.current?.();
1848
+ const plan = buildNarrationSavePlan({
1849
+ script: take.script,
1850
+ alignment: take.alignment,
1851
+ durationSec: take.durationSec,
1852
+ audioExt: take.audioExt,
1853
+ cameraExt: take.cameraExt,
1854
+ baseWpm: controllerRef.current.prefs.baseWpm,
1855
+ ...take.cameraOffsetSec !== void 0 ? { cameraOffsetSec: take.cameraOffsetSec } : {},
1856
+ ...basename ? { audioBasename: basename } : {}
1857
+ });
1858
+ const result = await executeNarrationSave(
1859
+ plan,
1860
+ take,
1861
+ {
1862
+ mediaProvider: recording.mediaProvider,
1863
+ container: recording.container,
1864
+ getMarkdownSource: () => recordingRef.current?.markdownSource ?? "",
1865
+ setMarkdownSource: recording.setMarkdownSource,
1866
+ bumpMediaRevision: recording.bumpMediaRevision
1867
+ },
1868
+ progressForTake(take)
1869
+ );
1870
+ saveProgressRef.current = null;
1871
+ recorderRef.current.finishSave(true);
1872
+ setSaveNotice(
1873
+ `Saved ${result.audioPath}${take.alignment ? " \u2014 blocks re-timed to your voice" : ""}`
1874
+ );
1875
+ } catch (err) {
1876
+ recorderRef.current.finishSave(false, err instanceof Error ? err : new Error(String(err)));
1877
+ }
1878
+ }, [recording, progressForTake]);
1879
+ const dismissSaveNotice = useCallback5(() => setSaveNotice(null), []);
1831
1880
  return {
1832
- state,
1833
- error,
1834
- withCamera,
1835
- setWithCamera,
1836
- cameraStream,
1837
- take,
1838
- start,
1839
- stop,
1840
- retake,
1841
- discard,
1842
- beginSave,
1843
- finishSave
1881
+ controller,
1882
+ float,
1883
+ recorder,
1884
+ recording,
1885
+ saveNotice,
1886
+ dismissSaveNotice,
1887
+ handleSave,
1888
+ handleRetake,
1889
+ handleDiscard,
1890
+ reviewAudioUrl,
1891
+ handleReviewTimeUpdate
1844
1892
  };
1845
1893
  }
1846
1894
 
1847
- // src/teleprompter/recording/insertPreamble.ts
1848
- var AUDIO_ANNOTATION_LINE = /^\{\[audio\s[^\]]*\]\}\s*$/;
1849
- var DOCUMENT_ANCHOR = /\banchor=(?:"document"|'document'|document)(?:\s|\]|$)/;
1850
- var CAMERA_LINE = /^<video\s[^>]*src="[^"]*"[^>]*><\/video>\s*$/;
1851
- function isNarrationLine(line) {
1852
- return AUDIO_ANNOTATION_LINE.test(line) && DOCUMENT_ANCHOR.test(line);
1895
+ // src/teleprompter/scrollModel.ts
1896
+ var EYE_LINE_FRACTION = 0.35;
1897
+ var MAX_SCROLL_PX_PER_SEC = 2600;
1898
+ function measureTokenLines(scrollColumn) {
1899
+ const spans = scrollColumn.querySelectorAll("[data-token-idx]");
1900
+ const tokenTops = new Array(spans.length);
1901
+ const tokenHeights = new Array(spans.length);
1902
+ const columnRect = scrollColumn.getBoundingClientRect();
1903
+ spans.forEach((span) => {
1904
+ const idx = Number(span.dataset.tokenIdx);
1905
+ if (!Number.isFinite(idx)) return;
1906
+ const rect = span.getBoundingClientRect();
1907
+ tokenTops[idx] = rect.top - columnRect.top;
1908
+ tokenHeights[idx] = rect.height;
1909
+ });
1910
+ return { tokenTops, tokenHeights };
1853
1911
  }
1854
- function quoteSrc(path) {
1855
- return /[\s"']/.test(path) ? `"${path.replace(/"/g, '\\"')}"` : path;
1912
+ function targetOffsetFor(wordPos, lines, viewportHeightPx, eyeLine = EYE_LINE_FRACTION) {
1913
+ const count = lines.tokenTops.length;
1914
+ if (count === 0) return 0;
1915
+ const clamped = Math.min(Math.max(wordPos, 0), count - 1);
1916
+ const idx = Math.floor(clamped);
1917
+ const frac = clamped - idx;
1918
+ const top = lines.tokenTops[idx] ?? 0;
1919
+ const nextTop = lines.tokenTops[Math.min(idx + 1, count - 1)] ?? top;
1920
+ const y = top + (nextTop - top) * frac;
1921
+ const lineHeight = lines.tokenHeights[idx] ?? 0;
1922
+ return Math.max(0, y + lineHeight / 2 - viewportHeightPx * eyeLine);
1856
1923
  }
1857
- function narrationAnnotationLine(audioPath) {
1858
- return `{[audio src=${quoteSrc(audioPath)} anchor=document]}`;
1924
+ function stepScroll(currentPx, targetPx, dtMs, maxPxPerSec = MAX_SCROLL_PX_PER_SEC) {
1925
+ const dt = Math.min(Math.max(dtMs, 0), 250) / 1e3;
1926
+ if (dt === 0) return currentPx;
1927
+ const blend = 1 - Math.exp(-dt / 0.18);
1928
+ let next = currentPx + (targetPx - currentPx) * blend;
1929
+ const maxStep = maxPxPerSec * dt;
1930
+ if (next - currentPx > maxStep) next = currentPx + maxStep;
1931
+ else if (currentPx - next > maxStep) next = currentPx - maxStep;
1932
+ return Math.abs(next - targetPx) < 0.25 ? targetPx : next;
1859
1933
  }
1860
- function cameraVideoLine(cameraPath) {
1861
- return `<video src="${cameraPath}" controls width="240"></video>`;
1934
+
1935
+ // src/teleprompter/TeleprompterSurface.tsx
1936
+ import { memo, useEffect as useEffect7, useMemo as useMemo4, useRef as useRef6 } from "react";
1937
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
1938
+ var WHEEL_NUDGE_INTERVAL_MS = 60;
1939
+ function groupScript(script) {
1940
+ return script.blocks.map((range) => {
1941
+ const paragraphs = [];
1942
+ let current = [];
1943
+ for (let i = range.tokenStart; i < range.tokenEnd; i++) {
1944
+ current.push(i);
1945
+ if (script.tokens[i].pauseAfter >= 2 && i < range.tokenEnd - 1) {
1946
+ paragraphs.push({ key: `${range.blockId}-${paragraphs.length}`, tokenIndexes: current });
1947
+ current = [];
1948
+ }
1949
+ }
1950
+ if (current.length > 0) {
1951
+ paragraphs.push({ key: `${range.blockId}-${paragraphs.length}`, tokenIndexes: current });
1952
+ }
1953
+ const group = { blockId: range.blockId, paragraphs };
1954
+ if (range.heading !== void 0) group.heading = range.heading;
1955
+ return group;
1956
+ });
1862
1957
  }
1863
- function insertNarrationPreamble(source, audioPath, cameraPath) {
1864
- const lines = source.split("\n");
1865
- let insertAt = 0;
1866
- if (lines[0]?.trim() === "---") {
1867
- for (let i = 1; i < lines.length; i++) {
1868
- if (lines[i].trim() === "---") {
1869
- insertAt = i + 1;
1958
+ var ScriptColumn = memo(function ScriptColumn2({
1959
+ script,
1960
+ compact
1961
+ }) {
1962
+ const groups = useMemo4(() => groupScript(script), [script]);
1963
+ return /* @__PURE__ */ jsx(Fragment, { children: groups.map((group) => /* @__PURE__ */ jsxs("section", { "data-block-id": group.blockId, children: [
1964
+ !compact && group.heading ? /* @__PURE__ */ jsx("span", { className: "squisq-teleprompter-block-marker", children: group.heading }) : null,
1965
+ group.paragraphs.map((paragraph) => /* @__PURE__ */ jsx("p", { className: "squisq-teleprompter-para", children: paragraph.tokenIndexes.map((idx) => /* @__PURE__ */ jsxs("span", { className: "squisq-teleprompter-word", "data-token-idx": idx, children: [
1966
+ script.tokens[idx].text,
1967
+ " "
1968
+ ] }, idx)) }, paragraph.key))
1969
+ ] }, group.blockId)) });
1970
+ });
1971
+ function TeleprompterSurface({
1972
+ script,
1973
+ wordPos,
1974
+ fontSizePx,
1975
+ mirrored,
1976
+ lineGuide,
1977
+ countdownRemaining,
1978
+ recordingIndicator,
1979
+ theme,
1980
+ compact = false,
1981
+ onSeekToken,
1982
+ onNudge,
1983
+ onToggleAutoAdvance
1984
+ }) {
1985
+ const surfaceRef = useRef6(null);
1986
+ const columnRef = useRef6(null);
1987
+ const wordPosRef = useRef6(wordPos);
1988
+ wordPosRef.current = wordPos;
1989
+ const vars = useMemo4(() => prompterVarsFromTheme(theme), [theme]);
1990
+ useEffect7(() => {
1991
+ const doc = surfaceRef.current?.ownerDocument;
1992
+ if (doc) ensureTeleprompterStyles(doc);
1993
+ }, []);
1994
+ useEffect7(() => {
1995
+ const surface = surfaceRef.current;
1996
+ if (!surface || !onNudge) return;
1997
+ let lastNudgeAt = Number.NEGATIVE_INFINITY;
1998
+ let lastDirection = 0;
1999
+ const handleWheel = (event) => {
2000
+ if (event.ctrlKey || event.metaKey || event.altKey || event.deltaY === 0) return;
2001
+ const direction = Math.sign(event.deltaY);
2002
+ event.preventDefault();
2003
+ if (direction === lastDirection && event.timeStamp - lastNudgeAt < WHEEL_NUDGE_INTERVAL_MS) {
2004
+ return;
2005
+ }
2006
+ onNudge(direction);
2007
+ lastNudgeAt = event.timeStamp;
2008
+ lastDirection = direction;
2009
+ };
2010
+ surface.addEventListener("wheel", handleWheel, { passive: false });
2011
+ return () => surface.removeEventListener("wheel", handleWheel);
2012
+ }, [onNudge]);
2013
+ useEffect7(() => {
2014
+ const surface = surfaceRef.current;
2015
+ const column = columnRef.current;
2016
+ if (!surface || !column) return;
2017
+ const win = surface.ownerDocument.defaultView ?? window;
2018
+ let lines = null;
2019
+ let spans = [];
2020
+ let activeIdx = -1;
2021
+ let offset = 0;
2022
+ let lastTime = performance.now();
2023
+ let raf = 0;
2024
+ const tokens = script.tokens;
2025
+ const spokenAt = new Int32Array(tokens.length);
2026
+ let lastSpoken = -1;
2027
+ for (let i = 0; i < tokens.length; i++) {
2028
+ if (tokens[i].spoken) lastSpoken = i;
2029
+ spokenAt[i] = lastSpoken;
2030
+ }
2031
+ let firstSpoken = -1;
2032
+ for (let i = 0; i < tokens.length; i++) {
2033
+ if (tokens[i].spoken) {
2034
+ firstSpoken = i;
1870
2035
  break;
1871
2036
  }
1872
2037
  }
1873
- }
1874
- let scan = insertAt;
1875
- while (scan < lines.length && lines[scan].trim() === "") scan++;
1876
- if (scan < lines.length && isNarrationLine(lines[scan])) {
1877
- let removeEnd = scan + 1;
1878
- while (removeEnd < lines.length && lines[removeEnd].trim() === "") removeEnd++;
1879
- if (removeEnd < lines.length && CAMERA_LINE.test(lines[removeEnd])) removeEnd++;
1880
- if (removeEnd < lines.length && lines[removeEnd].trim() === "") removeEnd++;
1881
- lines.splice(insertAt, removeEnd - insertAt);
1882
- }
1883
- const inserted = [narrationAnnotationLine(audioPath)];
1884
- if (cameraPath) {
1885
- inserted.push("", cameraVideoLine(cameraPath));
1886
- }
1887
- const before = lines.slice(0, insertAt);
1888
- const after = lines.slice(insertAt);
1889
- if (before.length > 0 && before[before.length - 1].trim() !== "") before.push("");
1890
- if (after.length > 0 && after[0].trim() !== "") inserted.push("");
1891
- return [...before, ...inserted, ...after].join("\n");
2038
+ for (let i = 0; i < tokens.length && spokenAt[i] === -1; i++) spokenAt[i] = firstSpoken;
2039
+ const remeasure = () => {
2040
+ lines = measureTokenLines(column);
2041
+ spans = Array.from(column.querySelectorAll("[data-token-idx]"));
2042
+ };
2043
+ remeasure();
2044
+ const applyHighlight = (nextIdx) => {
2045
+ if (nextIdx === activeIdx) return;
2046
+ const lo = Math.min(activeIdx, nextIdx);
2047
+ const hi = Math.max(activeIdx, nextIdx);
2048
+ for (let i = Math.max(0, lo); i <= hi && i < spans.length; i++) {
2049
+ const span = spans[i];
2050
+ if (!span) continue;
2051
+ span.classList.toggle("squisq-teleprompter-word--active", i === nextIdx);
2052
+ span.classList.toggle("squisq-teleprompter-word--read", i < nextIdx);
2053
+ }
2054
+ activeIdx = nextIdx;
2055
+ };
2056
+ const loop = (now) => {
2057
+ const dtMs = now - lastTime;
2058
+ lastTime = now;
2059
+ const pos = wordPosRef.current;
2060
+ const clampedIdx = Math.min(Math.max(Math.floor(pos), 0), spans.length - 1);
2061
+ const highlightIdx = clampedIdx < spokenAt.length && spokenAt[clampedIdx] >= 0 ? spokenAt[clampedIdx] : clampedIdx;
2062
+ if (spans.length > 0) applyHighlight(highlightIdx);
2063
+ if (lines) {
2064
+ const target = targetOffsetFor(pos, lines, surface.clientHeight);
2065
+ offset = stepScroll(offset, target, dtMs);
2066
+ column.style.transform = `translateY(${-offset}px)`;
2067
+ }
2068
+ raf = win.requestAnimationFrame(loop);
2069
+ };
2070
+ raf = win.requestAnimationFrame(loop);
2071
+ const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(remeasure) : null;
2072
+ resizeObserver?.observe(surface);
2073
+ return () => {
2074
+ win.cancelAnimationFrame(raf);
2075
+ resizeObserver?.disconnect();
2076
+ };
2077
+ }, [script, fontSizePx, compact]);
2078
+ const handleDoubleClick = onSeekToken ? (event) => {
2079
+ const target = event.target;
2080
+ const span = target?.closest("[data-token-idx]");
2081
+ if (!span) return;
2082
+ const idx = Number(span.dataset.tokenIdx);
2083
+ if (Number.isFinite(idx)) onSeekToken(idx);
2084
+ } : void 0;
2085
+ const handleMouseDown = onToggleAutoAdvance ? (event) => {
2086
+ if (event.button !== 1) return;
2087
+ event.preventDefault();
2088
+ onToggleAutoAdvance();
2089
+ } : void 0;
2090
+ const handleAuxClick = onToggleAutoAdvance ? (event) => {
2091
+ if (event.button === 1) event.preventDefault();
2092
+ } : void 0;
2093
+ return /* @__PURE__ */ jsxs(
2094
+ "div",
2095
+ {
2096
+ ref: surfaceRef,
2097
+ className: `squisq-teleprompter-surface${mirrored ? " squisq-teleprompter-surface--mirrored" : ""}`,
2098
+ style: { ...vars, fontSize: `${fontSizePx}px` },
2099
+ "data-testid": "teleprompter-surface",
2100
+ tabIndex: 0,
2101
+ "aria-label": "Teleprompter script; left and right arrows or the mouse wheel adjust the word position; up starts automatic advancement; down stops it; press the mouse wheel to toggle it; double-click a word to jump",
2102
+ onMouseDown: handleMouseDown,
2103
+ onAuxClick: handleAuxClick,
2104
+ children: [
2105
+ /* @__PURE__ */ jsx("div", { className: "squisq-teleprompter-flip", children: /* @__PURE__ */ jsx(
2106
+ "div",
2107
+ {
2108
+ ref: columnRef,
2109
+ className: "squisq-teleprompter-scroll",
2110
+ style: compact ? { padding: "30vh 5% 60vh" } : void 0,
2111
+ onDoubleClick: handleDoubleClick,
2112
+ children: /* @__PURE__ */ jsx(ScriptColumn, { script, compact })
2113
+ }
2114
+ ) }),
2115
+ lineGuide ? /* @__PURE__ */ jsxs(Fragment, { children: [
2116
+ /* @__PURE__ */ jsx(
2117
+ "div",
2118
+ {
2119
+ className: "squisq-teleprompter-guide-band",
2120
+ style: { top: `calc(${EYE_LINE_FRACTION * 100}% - 0.75em)`, height: "1.5em" }
2121
+ }
2122
+ ),
2123
+ /* @__PURE__ */ jsx(
2124
+ "div",
2125
+ {
2126
+ className: "squisq-teleprompter-line-guide",
2127
+ style: { top: `calc(${EYE_LINE_FRACTION * 100}% - 0.75em)`, height: "1.5em" }
2128
+ }
2129
+ )
2130
+ ] }) : null,
2131
+ countdownRemaining !== null ? /* @__PURE__ */ jsx("div", { className: "squisq-teleprompter-countdown", children: /* @__PURE__ */ jsx("span", { className: "squisq-teleprompter-countdown-digit", children: countdownRemaining }) }) : null,
2132
+ recordingIndicator ? /* @__PURE__ */ jsx("div", { className: "squisq-teleprompter-recdot" }) : null
2133
+ ]
2134
+ }
2135
+ );
1892
2136
  }
1893
2137
 
1894
- // src/teleprompter/recording/narrationSave.ts
1895
- import {
1896
- buildNarrationTimingJson
1897
- } from "@bendyline/squisq/narration";
1898
- function buildNarrationSavePlan(args) {
1899
- const sidecarPayload = args.alignment ? buildNarrationTimingJson(args.script, args.alignment, args.durationSec, {
1900
- baseWpm: args.baseWpm,
1901
- ...args.cameraOffsetSec !== void 0 ? { cameraOffsetSec: args.cameraOffsetSec } : {}
1902
- }) : {
1903
- version: 3,
1904
- sourceText: args.script.sourceText,
1905
- duration: args.durationSec,
1906
- bookmarks: [],
1907
- blocks: [],
1908
- generator: { name: "squisq-teleprompter", method: "dsp-align", baseWpm: args.baseWpm }
1909
- };
1910
- return {
1911
- audioRelativeName: `audio/${buildFilename("audio", args.audioExt)}`,
1912
- cameraRelativeName: args.cameraExt ? `video/${buildFilename("video", args.cameraExt, "narration-cam")}` : null,
1913
- sidecarPayload,
1914
- sidecarPathFor: (savedAudioPath) => timingPathFor(savedAudioPath),
1915
- nextMarkdown: (currentSource, savedAudioPath, savedCameraPath) => insertNarrationPreamble(currentSource, savedAudioPath, savedCameraPath)
1916
- };
1917
- }
1918
- async function executeNarrationSave(plan, take, deps, progress = {}) {
1919
- const audioPath = progress.audioPath ?? await deps.mediaProvider.addMedia(plan.audioRelativeName, take.audioBlob, take.audioMime);
1920
- progress.audioPath = audioPath;
1921
- const sidecarPath = progress.sidecarPath ?? plan.sidecarPathFor(audioPath);
1922
- if (progress.sidecarPath === void 0) {
1923
- const encoded = encodeTimingJson(plan.sidecarPayload);
1924
- if (deps.container) {
1925
- await deps.container.writeFile(sidecarPath, encoded, "application/json");
1926
- } else {
1927
- const storedAt = await deps.mediaProvider.addMedia(sidecarPath, encoded, "application/json");
1928
- if (storedAt !== sidecarPath) {
1929
- console.warn(
1930
- `Narration timing sidecar stored at "${storedAt}" instead of "${sidecarPath}"; narration timing will not be discovered until it sits next to the audio file.`
1931
- );
1932
- }
1933
- }
1934
- progress.sidecarPath = sidecarPath;
1935
- }
1936
- let cameraPath = progress.cameraPath ?? null;
1937
- if (progress.cameraPath === void 0) {
1938
- if (plan.cameraRelativeName && take.cameraBlob && take.cameraMime) {
1939
- cameraPath = await deps.mediaProvider.addMedia(
1940
- plan.cameraRelativeName,
1941
- take.cameraBlob,
1942
- take.cameraMime
1943
- );
2138
+ // src/teleprompter/TeleprompterControls.tsx
2139
+ import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
2140
+ var TIER_LABELS = {
2141
+ "document-pip": "Floating window (always on top)",
2142
+ "video-pip": "Picture-in-picture (read-only)",
2143
+ popup: "Popup window",
2144
+ docked: "Docked"
2145
+ };
2146
+ function TeleprompterControls({
2147
+ controller,
2148
+ float,
2149
+ recordSlot,
2150
+ showPlayPause = true
2151
+ }) {
2152
+ const { transport, prefs, setPrefs, mic } = controller;
2153
+ const rolling = transport === "rolling" || transport === "countdown";
2154
+ const voiceLive = prefs.voiceTracking && mic.status === "live";
2155
+ return /* @__PURE__ */ jsxs2(
2156
+ "div",
2157
+ {
2158
+ className: "squisq-teleprompter-controls",
2159
+ "data-testid": "teleprompter-controls",
2160
+ "data-mic-status": mic.status,
2161
+ "data-transport": transport,
2162
+ "data-voice-live": voiceLive || void 0,
2163
+ children: [
2164
+ /* @__PURE__ */ jsxs2("span", { className: "squisq-teleprompter-group", children: [
2165
+ showPlayPause ? /* @__PURE__ */ jsx2(
2166
+ "button",
2167
+ {
2168
+ type: "button",
2169
+ onClick: () => rolling ? controller.pause() : controller.play(),
2170
+ "aria-label": rolling ? "Pause prompter" : "Start prompter",
2171
+ children: rolling ? "\u23F8 Pause" : transport === "paused" ? "\u25B6 Resume" : "\u25B6 Start"
2172
+ }
2173
+ ) : null,
2174
+ /* @__PURE__ */ jsx2("button", { type: "button", onClick: controller.restart, "aria-label": "Restart prompter", children: "\u27F2 Restart" })
2175
+ ] }),
2176
+ /* @__PURE__ */ jsxs2("span", { className: "squisq-teleprompter-group", children: [
2177
+ /* @__PURE__ */ jsx2("label", { htmlFor: "squisq-prompter-countdown", children: "Countdown" }),
2178
+ /* @__PURE__ */ jsxs2(
2179
+ "select",
2180
+ {
2181
+ id: "squisq-prompter-countdown",
2182
+ value: prefs.countdownSec,
2183
+ onChange: (e) => setPrefs({ countdownSec: Number(e.target.value) }),
2184
+ children: [
2185
+ /* @__PURE__ */ jsx2("option", { value: 0, children: "Off" }),
2186
+ /* @__PURE__ */ jsx2("option", { value: 3, children: "3s" }),
2187
+ /* @__PURE__ */ jsx2("option", { value: 5, children: "5s" }),
2188
+ /* @__PURE__ */ jsx2("option", { value: 10, children: "10s" })
2189
+ ]
2190
+ }
2191
+ )
2192
+ ] }),
2193
+ /* @__PURE__ */ jsxs2("span", { className: "squisq-teleprompter-group", children: [
2194
+ /* @__PURE__ */ jsx2("label", { htmlFor: "squisq-prompter-wpm", children: "Speed" }),
2195
+ /* @__PURE__ */ jsx2(
2196
+ "input",
2197
+ {
2198
+ id: "squisq-prompter-wpm",
2199
+ type: "range",
2200
+ min: TELEPROMPTER_PREF_LIMITS.baseWpm.min,
2201
+ max: TELEPROMPTER_PREF_LIMITS.baseWpm.max,
2202
+ step: 5,
2203
+ value: prefs.baseWpm,
2204
+ onChange: (e) => setPrefs({ baseWpm: Number(e.target.value) })
2205
+ }
2206
+ ),
2207
+ /* @__PURE__ */ jsxs2("span", { "aria-live": "off", children: [
2208
+ prefs.baseWpm,
2209
+ " wpm"
2210
+ ] })
2211
+ ] }),
2212
+ /* @__PURE__ */ jsxs2("span", { className: "squisq-teleprompter-group", children: [
2213
+ /* @__PURE__ */ jsx2(
2214
+ "button",
2215
+ {
2216
+ type: "button",
2217
+ "aria-pressed": prefs.voiceTracking,
2218
+ onClick: () => {
2219
+ const next = !prefs.voiceTracking;
2220
+ setPrefs({ voiceTracking: next });
2221
+ if (next && mic.status === "idle") void mic.start(prefs.micDeviceId);
2222
+ if (!next && mic.status !== "idle") mic.stop();
2223
+ },
2224
+ title: "Match the prompter speed to your voice (halts when you stop speaking)",
2225
+ children: "\u{1F399} Voice pace"
2226
+ }
2227
+ ),
2228
+ prefs.voiceTracking ? /* @__PURE__ */ jsxs2(Fragment2, { children: [
2229
+ /* @__PURE__ */ jsx2("label", { htmlFor: "squisq-prompter-sensitivity", title: "Voice detection sensitivity", children: "Sens." }),
2230
+ /* @__PURE__ */ jsx2(
2231
+ "input",
2232
+ {
2233
+ id: "squisq-prompter-sensitivity",
2234
+ type: "range",
2235
+ min: TELEPROMPTER_PREF_LIMITS.vadSensitivity.min,
2236
+ max: TELEPROMPTER_PREF_LIMITS.vadSensitivity.max,
2237
+ step: 0.05,
2238
+ value: prefs.vadSensitivity,
2239
+ onChange: (e) => setPrefs({ vadSensitivity: Number(e.target.value) }),
2240
+ style: { width: 70 }
2241
+ }
2242
+ ),
2243
+ /* @__PURE__ */ jsxs2(
2244
+ "select",
2245
+ {
2246
+ "aria-label": "Microphone",
2247
+ value: prefs.micDeviceId ?? "",
2248
+ onChange: (e) => setPrefs({ micDeviceId: e.target.value || null }),
2249
+ children: [
2250
+ /* @__PURE__ */ jsx2("option", { value: "", children: "Default mic" }),
2251
+ mic.devices.map((device) => /* @__PURE__ */ jsx2("option", { value: device.deviceId, children: device.label || `Mic ${device.deviceId.slice(0, 6)}` }, device.deviceId))
2252
+ ]
2253
+ }
2254
+ ),
2255
+ /* @__PURE__ */ jsx2(
2256
+ "span",
2257
+ {
2258
+ className: `squisq-teleprompter-meter${controller.voiceActive ? " squisq-teleprompter-meter--voice" : ""}`,
2259
+ role: "meter",
2260
+ "aria-label": "Mic level",
2261
+ "aria-valuemin": 0,
2262
+ "aria-valuemax": 1,
2263
+ "aria-valuenow": Math.round(controller.micLevel * 100) / 100,
2264
+ children: /* @__PURE__ */ jsx2(
2265
+ "span",
2266
+ {
2267
+ className: "squisq-teleprompter-meter-fill",
2268
+ style: { width: `${Math.round(controller.micLevel * 100)}%` }
2269
+ }
2270
+ )
2271
+ }
2272
+ ),
2273
+ mic.status === "error" ? /* @__PURE__ */ jsx2("span", { title: mic.error?.message ?? "Microphone unavailable", children: "\u26A0 mic unavailable \u2014 constant speed" }) : null
2274
+ ] }) : null
2275
+ ] }),
2276
+ /* @__PURE__ */ jsxs2("span", { className: "squisq-teleprompter-group", children: [
2277
+ /* @__PURE__ */ jsx2("label", { htmlFor: "squisq-prompter-fontsize", children: "Aa" }),
2278
+ /* @__PURE__ */ jsx2(
2279
+ "input",
2280
+ {
2281
+ id: "squisq-prompter-fontsize",
2282
+ type: "range",
2283
+ min: TELEPROMPTER_PREF_LIMITS.fontSizePx.min,
2284
+ max: TELEPROMPTER_PREF_LIMITS.fontSizePx.max,
2285
+ step: 2,
2286
+ value: prefs.fontSizePx,
2287
+ onChange: (e) => setPrefs({ fontSizePx: Number(e.target.value) }),
2288
+ style: { width: 80 },
2289
+ "aria-label": "Prompter font size"
2290
+ }
2291
+ ),
2292
+ /* @__PURE__ */ jsx2(
2293
+ "button",
2294
+ {
2295
+ type: "button",
2296
+ "aria-pressed": prefs.mirrored,
2297
+ onClick: () => setPrefs({ mirrored: !prefs.mirrored }),
2298
+ title: "Mirror for beam-splitter teleprompter rigs (M)",
2299
+ children: "\u21CB Mirror"
2300
+ }
2301
+ ),
2302
+ /* @__PURE__ */ jsx2(
2303
+ "button",
2304
+ {
2305
+ type: "button",
2306
+ "aria-pressed": prefs.lineGuide,
2307
+ onClick: () => setPrefs({ lineGuide: !prefs.lineGuide }),
2308
+ title: "Eye-line guide",
2309
+ children: "\u25B8 Guide"
2310
+ }
2311
+ )
2312
+ ] }),
2313
+ recordSlot,
2314
+ float.supportedTiers.length > 0 ? /* @__PURE__ */ jsx2("span", { className: "squisq-teleprompter-group", style: { marginLeft: "auto" }, children: float.isOpen ? /* @__PURE__ */ jsx2("button", { type: "button", onClick: float.close, children: "\u21E4 Bring back" }) : /* @__PURE__ */ jsxs2(Fragment2, { children: [
2315
+ float.supportedTiers.length > 1 ? /* @__PURE__ */ jsx2(
2316
+ "select",
2317
+ {
2318
+ "aria-label": "Float mode",
2319
+ "data-testid": "teleprompter-float-tier",
2320
+ defaultValue: float.supportedTiers[0],
2321
+ id: "squisq-prompter-float-tier",
2322
+ children: float.supportedTiers.map((tier) => /* @__PURE__ */ jsx2("option", { value: tier, children: TIER_LABELS[tier] }, tier))
2323
+ }
2324
+ ) : null,
2325
+ /* @__PURE__ */ jsx2(
2326
+ "button",
2327
+ {
2328
+ type: "button",
2329
+ onClick: () => {
2330
+ const select = document.getElementById(
2331
+ "squisq-prompter-float-tier"
2332
+ );
2333
+ const preferred = select?.value ?? void 0;
2334
+ void float.open(preferred);
2335
+ },
2336
+ title: "Pop the prompter out so it can sit next to your camera",
2337
+ children: "\u21F1 Pop out"
2338
+ }
2339
+ )
2340
+ ] }) }) : null
2341
+ ]
1944
2342
  }
1945
- progress.cameraPath = cameraPath;
1946
- }
1947
- deps.setMarkdownSource(plan.nextMarkdown(deps.getMarkdownSource(), audioPath, cameraPath));
1948
- deps.bumpMediaRevision();
1949
- return { audioPath, cameraPath, sidecarPath };
1950
- }
1951
- async function discardNarrationSaveProgress(progress, deps) {
1952
- const paths = [progress.audioPath, progress.cameraPath ?? void 0].filter(
1953
- (p) => typeof p === "string"
1954
2343
  );
1955
- for (const path of paths) {
1956
- try {
1957
- await deps.mediaProvider.removeMedia(path);
1958
- } catch (err) {
1959
- console.warn(
1960
- `Could not remove orphaned narration media "${path}": ` + (err instanceof Error ? err.message : String(err))
1961
- );
1962
- }
1963
- }
1964
- if (progress.sidecarPath !== void 0 && deps.container) {
1965
- try {
1966
- await deps.container.removeFile(progress.sidecarPath);
1967
- } catch (err) {
1968
- console.warn(
1969
- `Could not remove orphaned narration sidecar "${progress.sidecarPath}": ` + (err instanceof Error ? err.message : String(err))
1970
- );
1971
- }
1972
- }
1973
- delete progress.audioPath;
1974
- delete progress.cameraPath;
1975
- delete progress.sidecarPath;
1976
2344
  }
1977
2345
 
1978
- // src/teleprompter/TeleprompterView.tsx
1979
- import { useCallback as useCallback5, useEffect as useEffect6, useMemo as useMemo4, useRef as useRef7, useState as useState5 } from "react";
2346
+ // src/teleprompter/NarrationStage.tsx
2347
+ import { useCallback as useCallback6, useEffect as useEffect8, useMemo as useMemo5, useRef as useRef8 } from "react";
1980
2348
  import { createPortal } from "react-dom";
1981
- import { wordIndexAtTime } from "@bendyline/squisq/narration";
1982
2349
 
1983
2350
  // src/teleprompter/TeleprompterSelfView.tsx
1984
- import { useRef as useRef6 } from "react";
2351
+ import { useRef as useRef7 } from "react";
1985
2352
  import { jsx as jsx3 } from "react/jsx-runtime";
1986
2353
  function TeleprompterSelfView({ stream }) {
1987
- const videoRef = useRef6(null);
2354
+ const videoRef = useRef7(null);
1988
2355
  useStreamPreview(videoRef, stream);
1989
2356
  if (!stream) return null;
1990
2357
  return /* @__PURE__ */ jsx3(
@@ -2113,65 +2480,55 @@ function drawPrompterFrame(canvas, frame) {
2113
2480
  }
2114
2481
  }
2115
2482
 
2116
- // src/teleprompter/TeleprompterView.tsx
2483
+ // src/teleprompter/NarrationStage.tsx
2117
2484
  import { Fragment as Fragment3, jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
2118
- function TeleprompterView(props) {
2119
- const { doc, theme, presentationTarget = null, recording = null } = props;
2120
- const controller = useTeleprompter({ doc });
2121
- const float = useFloatingWindow(TELEPROMPTER_CSS);
2122
- const rootRef = useRef7(null);
2123
- const controllerRef = useRef7(controller);
2485
+ function NarrationStage(props) {
2486
+ const {
2487
+ stage,
2488
+ theme,
2489
+ presentationTarget = null,
2490
+ showSelfView = true,
2491
+ showCameraToggleInRecordSlot = true,
2492
+ showRecordSlot = true,
2493
+ showTransportPlay = true,
2494
+ showReviewActions = true
2495
+ } = props;
2496
+ const { controller, float, recorder, recording } = stage;
2497
+ const rootRef = useRef8(null);
2498
+ const controllerRef = useRef8(controller);
2124
2499
  controllerRef.current = controller;
2125
- const [saveNotice, setSaveNotice] = useState5(null);
2126
- const recorder = useNarrationRecorder({
2127
- mic: controller.mic,
2128
- getScript: () => controllerRef.current.script,
2129
- getWordPos: () => controllerRef.current.wordPos,
2130
- getMicDeviceId: () => controllerRef.current.prefs.micDeviceId,
2131
- onRecordingStart: () => controllerRef.current.play(),
2132
- onRecordingStop: () => controllerRef.current.pause()
2133
- });
2134
- const recorderRef = useRef7(recorder);
2135
- recorderRef.current = recorder;
2136
- const recordingRef = useRef7(recording);
2137
- recordingRef.current = recording;
2138
- const saveProgressRef = useRef7(null);
2139
- const progressForTake = useCallback5((take) => {
2140
- const existing = saveProgressRef.current;
2141
- if (existing && existing.take === take) return existing.progress;
2142
- const fresh = { take, progress: {} };
2143
- saveProgressRef.current = fresh;
2144
- return fresh.progress;
2145
- }, []);
2146
- const cleanupAbandonedSave = useCallback5(() => {
2147
- const pending = saveProgressRef.current;
2148
- saveProgressRef.current = null;
2149
- const deps = recordingRef.current;
2150
- if (!pending || !deps) return;
2151
- if (pending.progress.audioPath === void 0 && pending.progress.sidecarPath === void 0) {
2152
- return;
2153
- }
2154
- void discardNarrationSaveProgress(pending.progress, {
2155
- mediaProvider: deps.mediaProvider,
2156
- container: deps.container
2157
- });
2500
+ const toggleAutoAdvance = useCallback6(() => {
2501
+ const current = controllerRef.current;
2502
+ if (current.transport === "rolling" || current.transport === "countdown") current.pause();
2503
+ else current.play();
2158
2504
  }, []);
2159
- const handleRetake = useCallback5(() => {
2160
- cleanupAbandonedSave();
2161
- recorderRef.current.retake();
2162
- }, [cleanupAbandonedSave]);
2163
- const handleDiscard = useCallback5(() => {
2164
- cleanupAbandonedSave();
2165
- recorderRef.current.discard();
2166
- }, [cleanupAbandonedSave]);
2167
- useEffect6(() => {
2505
+ useEffect8(() => {
2168
2506
  const ownerDoc = rootRef.current?.ownerDocument;
2169
2507
  if (ownerDoc) ensureTeleprompterStyles(ownerDoc);
2170
2508
  }, []);
2171
- useEffect6(() => {
2509
+ useEffect8(() => {
2172
2510
  if (presentationTarget) ensureTeleprompterStyles(presentationTarget.ownerDocument);
2173
2511
  }, [presentationTarget]);
2174
- const canvasFrameRef = useRef7(null);
2512
+ useEffect8(() => {
2513
+ if (!controller.script) return;
2514
+ const documents = /* @__PURE__ */ new Set();
2515
+ const ownerDocument = rootRef.current?.ownerDocument;
2516
+ if (ownerDocument) documents.add(ownerDocument);
2517
+ if (float.portalTarget) documents.add(float.portalTarget.ownerDocument);
2518
+ if (presentationTarget) documents.add(presentationTarget.ownerDocument);
2519
+ const handleKeyDown = (event) => {
2520
+ controllerRef.current.handleKeyDown(event);
2521
+ };
2522
+ for (const targetDocument of documents) {
2523
+ targetDocument.addEventListener("keydown", handleKeyDown);
2524
+ }
2525
+ return () => {
2526
+ for (const targetDocument of documents) {
2527
+ targetDocument.removeEventListener("keydown", handleKeyDown);
2528
+ }
2529
+ };
2530
+ }, [controller.script, float.portalTarget, presentationTarget]);
2531
+ const canvasFrameRef = useRef8(null);
2175
2532
  canvasFrameRef.current = controller.script ? {
2176
2533
  script: controller.script,
2177
2534
  fontSizePx: controller.prefs.fontSizePx,
@@ -2185,7 +2542,7 @@ function TeleprompterView(props) {
2185
2542
  countdownRemaining: controller.countdownRemaining,
2186
2543
  recording: recorder.state === "recording"
2187
2544
  } : null;
2188
- useEffect6(() => {
2545
+ useEffect8(() => {
2189
2546
  const sink = float.canvasSink;
2190
2547
  if (float.tier !== "video-pip" || !sink) return;
2191
2548
  const draw = (wordPos) => {
@@ -2202,58 +2559,7 @@ function TeleprompterView(props) {
2202
2559
  clearInterval(interval);
2203
2560
  };
2204
2561
  }, [float.tier, float.canvasSink]);
2205
- const reviewAudioUrl = useMemo4(
2206
- () => recorder.take ? URL.createObjectURL(recorder.take.audioBlob) : null,
2207
- [recorder.take]
2208
- );
2209
- useEffect6(() => {
2210
- return () => {
2211
- if (reviewAudioUrl) URL.revokeObjectURL(reviewAudioUrl);
2212
- };
2213
- }, [reviewAudioUrl]);
2214
- const handleReviewTimeUpdate = useCallback5((event) => {
2215
- const alignment = recorderRef.current.take?.alignment;
2216
- if (!alignment || alignment.words.length === 0) return;
2217
- controllerRef.current.seekToToken(
2218
- wordIndexAtTime(alignment.words, event.currentTarget.currentTime)
2219
- );
2220
- }, []);
2221
- const handleSave = useCallback5(async () => {
2222
- const take = recorderRef.current.take;
2223
- if (!take || !recording) return;
2224
- recorderRef.current.beginSave();
2225
- try {
2226
- const plan = buildNarrationSavePlan({
2227
- script: take.script,
2228
- alignment: take.alignment,
2229
- durationSec: take.durationSec,
2230
- audioExt: take.audioExt,
2231
- cameraExt: take.cameraExt,
2232
- baseWpm: controllerRef.current.prefs.baseWpm,
2233
- ...take.cameraOffsetSec !== void 0 ? { cameraOffsetSec: take.cameraOffsetSec } : {}
2234
- });
2235
- const result = await executeNarrationSave(
2236
- plan,
2237
- take,
2238
- {
2239
- mediaProvider: recording.mediaProvider,
2240
- container: recording.container,
2241
- getMarkdownSource: () => recordingRef.current?.markdownSource ?? "",
2242
- setMarkdownSource: recording.setMarkdownSource,
2243
- bumpMediaRevision: recording.bumpMediaRevision
2244
- },
2245
- progressForTake(take)
2246
- );
2247
- saveProgressRef.current = null;
2248
- recorderRef.current.finishSave(true);
2249
- setSaveNotice(
2250
- `Saved ${result.audioPath}${take.alignment ? " \u2014 blocks re-timed to your voice" : ""}`
2251
- );
2252
- } catch (err) {
2253
- recorderRef.current.finishSave(false, err instanceof Error ? err : new Error(String(err)));
2254
- }
2255
- }, [recording, progressForTake]);
2256
- const surfaceProps = useMemo4(
2562
+ const surfaceProps = useMemo5(
2257
2563
  () => ({
2258
2564
  wordPos: controller.wordPos,
2259
2565
  fontSizePx: controller.prefs.fontSizePx,
@@ -2262,7 +2568,9 @@ function TeleprompterView(props) {
2262
2568
  countdownRemaining: controller.countdownRemaining,
2263
2569
  recordingIndicator: recorder.state === "recording",
2264
2570
  theme,
2265
- onSeekToken: controller.seekToToken
2571
+ onSeekToken: controller.seekToToken,
2572
+ onNudge: controller.nudge,
2573
+ onToggleAutoAdvance: toggleAutoAdvance
2266
2574
  }),
2267
2575
  [
2268
2576
  controller.wordPos,
@@ -2271,6 +2579,8 @@ function TeleprompterView(props) {
2271
2579
  controller.prefs.lineGuide,
2272
2580
  controller.countdownRemaining,
2273
2581
  controller.seekToToken,
2582
+ controller.nudge,
2583
+ toggleAutoAdvance,
2274
2584
  recorder.state,
2275
2585
  theme
2276
2586
  ]
@@ -2287,7 +2597,7 @@ function TeleprompterView(props) {
2287
2597
  const script = controller.script;
2288
2598
  const portalOpen = float.portalTarget !== null;
2289
2599
  const busyRecording = recorder.state === "recording" || recorder.state === "starting";
2290
- const recordSlot = recording ? /* @__PURE__ */ jsx4("span", { className: "squisq-teleprompter-group", "data-testid": "teleprompter-record", children: recorder.state === "idle" || recorder.state === "error" ? /* @__PURE__ */ jsxs3(Fragment3, { children: [
2600
+ const recordSlot = recording && showRecordSlot ? /* @__PURE__ */ jsx4("span", { className: "squisq-teleprompter-group", "data-testid": "teleprompter-record", children: recorder.state === "idle" || recorder.state === "error" ? /* @__PURE__ */ jsxs3(Fragment3, { children: [
2291
2601
  /* @__PURE__ */ jsx4(
2292
2602
  "button",
2293
2603
  {
@@ -2297,7 +2607,7 @@ function TeleprompterView(props) {
2297
2607
  children: "\u23FA Record"
2298
2608
  }
2299
2609
  ),
2300
- /* @__PURE__ */ jsxs3("label", { title: "Also capture your camera as a separate video file", children: [
2610
+ showCameraToggleInRecordSlot ? /* @__PURE__ */ jsxs3("label", { title: "Also capture your camera as a separate video file", children: [
2301
2611
  /* @__PURE__ */ jsx4(
2302
2612
  "input",
2303
2613
  {
@@ -2307,102 +2617,124 @@ function TeleprompterView(props) {
2307
2617
  }
2308
2618
  ),
2309
2619
  "camera"
2310
- ] }),
2620
+ ] }) : null,
2311
2621
  recorder.state === "error" ? /* @__PURE__ */ jsxs3("span", { title: recorder.error?.message, children: [
2312
2622
  "\u26A0 ",
2313
2623
  recorder.error?.message
2314
2624
  ] }) : null
2315
2625
  ] }) : busyRecording ? /* @__PURE__ */ jsx4("button", { type: "button", onClick: () => void recorder.stop(), children: "\u23F9 Stop" }) : recorder.state === "processing" ? /* @__PURE__ */ jsx4("span", { children: "Aligning take\u2026" }) : recorder.state === "saving" ? /* @__PURE__ */ jsx4("span", { children: "Saving\u2026" }) : null }) : null;
2316
- return /* @__PURE__ */ jsxs3(
2317
- "div",
2318
- {
2319
- ref: rootRef,
2320
- className: "squisq-teleprompter-root",
2321
- "data-testid": "teleprompter-view",
2322
- tabIndex: 0,
2323
- onKeyDown: controller.handleKeyDown,
2324
- children: [
2325
- /* @__PURE__ */ jsxs3("div", { className: "squisq-teleprompter-stage", children: [
2326
- portalOpen ? /* @__PURE__ */ jsxs3("div", { className: "squisq-teleprompter-float-note", children: [
2327
- /* @__PURE__ */ jsx4("p", { children: "The prompter is floating in its own window." }),
2328
- /* @__PURE__ */ jsx4("button", { type: "button", onClick: float.close, children: "\u21E4 Bring it back" })
2329
- ] }) : /* @__PURE__ */ jsx4(TeleprompterSurface, { script, ...surfaceProps }, "docked"),
2330
- portalOpen && float.portalTarget ? createPortal(
2331
- /* @__PURE__ */ jsx4(
2332
- TeleprompterSurface,
2333
- {
2334
- script,
2335
- ...surfaceProps,
2336
- compact: true
2337
- },
2338
- `float-${float.tier}`
2339
- ),
2340
- float.portalTarget
2341
- ) : null,
2342
- presentationTarget ? createPortal(
2343
- /* @__PURE__ */ jsx4("div", { className: "squisq-presentation-teleprompter", "aria-label": "Audience presentation", children: /* @__PURE__ */ jsx4(
2344
- TeleprompterSurface,
2345
- {
2346
- script,
2347
- ...surfaceProps
2348
- },
2349
- "presentation-audience"
2350
- ) }),
2351
- presentationTarget
2352
- ) : null,
2353
- /* @__PURE__ */ jsx4(TeleprompterSelfView, { stream: recorder.cameraStream })
2354
- ] }),
2355
- recorder.state === "review" && recorder.take ? /* @__PURE__ */ jsxs3("div", { className: "squisq-teleprompter-review", "data-testid": "teleprompter-review", children: [
2356
- /* @__PURE__ */ jsxs3("span", { children: [
2357
- "Take: ",
2358
- recorder.take.durationSec.toFixed(1),
2359
- "s",
2360
- recorder.take.alignment ? ` \xB7 ${recorder.take.alignment.detectedSyllables} syllables aligned` : " \xB7 timing unavailable (saved without re-timing)"
2361
- ] }),
2362
- reviewAudioUrl ? /* @__PURE__ */ jsx4("audio", { controls: true, src: reviewAudioUrl, onTimeUpdate: handleReviewTimeUpdate }) : null,
2363
- /* @__PURE__ */ jsx4("button", { type: "button", onClick: () => void handleSave(), children: "\u2713 Save narration" }),
2364
- /* @__PURE__ */ jsx4("button", { type: "button", onClick: handleRetake, children: "\u21BA Retake" }),
2365
- /* @__PURE__ */ jsx4("button", { type: "button", onClick: handleDiscard, children: "\u2715 Discard" }),
2366
- recorder.error ? /* @__PURE__ */ jsxs3("span", { children: [
2367
- "\u26A0 ",
2368
- recorder.error.message
2369
- ] }) : null
2370
- ] }) : null,
2371
- saveNotice ? /* @__PURE__ */ jsxs3("div", { className: "squisq-teleprompter-review", "data-testid": "teleprompter-save-notice", children: [
2372
- /* @__PURE__ */ jsx4("span", { children: saveNotice }),
2373
- /* @__PURE__ */ jsx4("button", { type: "button", onClick: () => setSaveNotice(null), children: "Dismiss" })
2374
- ] }) : null,
2375
- /* @__PURE__ */ jsx4(TeleprompterControls, { controller, float, recordSlot })
2376
- ]
2377
- }
2378
- );
2626
+ return /* @__PURE__ */ jsxs3("div", { ref: rootRef, className: "squisq-teleprompter-root", "data-testid": "teleprompter-view", children: [
2627
+ /* @__PURE__ */ jsxs3("div", { className: "squisq-teleprompter-stage", children: [
2628
+ portalOpen ? /* @__PURE__ */ jsxs3("div", { className: "squisq-teleprompter-float-note", children: [
2629
+ /* @__PURE__ */ jsx4("p", { children: "The prompter is floating in its own window." }),
2630
+ /* @__PURE__ */ jsx4("button", { type: "button", onClick: float.close, children: "\u21E4 Bring it back" })
2631
+ ] }) : /* @__PURE__ */ jsx4(TeleprompterSurface, { script, ...surfaceProps }, "docked"),
2632
+ portalOpen && float.portalTarget ? createPortal(
2633
+ /* @__PURE__ */ jsx4(
2634
+ TeleprompterSurface,
2635
+ {
2636
+ script,
2637
+ ...surfaceProps,
2638
+ compact: true
2639
+ },
2640
+ `float-${float.tier}`
2641
+ ),
2642
+ float.portalTarget
2643
+ ) : null,
2644
+ presentationTarget ? createPortal(
2645
+ /* @__PURE__ */ jsx4("div", { className: "squisq-presentation-teleprompter", "aria-label": "Audience presentation", children: /* @__PURE__ */ jsx4(
2646
+ TeleprompterSurface,
2647
+ {
2648
+ script,
2649
+ ...surfaceProps
2650
+ },
2651
+ "presentation-audience"
2652
+ ) }),
2653
+ presentationTarget
2654
+ ) : null,
2655
+ showSelfView ? /* @__PURE__ */ jsx4(TeleprompterSelfView, { stream: recorder.cameraStream }) : null
2656
+ ] }),
2657
+ recorder.state === "review" && recorder.take ? /* @__PURE__ */ jsxs3("div", { className: "squisq-teleprompter-review", "data-testid": "teleprompter-review", children: [
2658
+ /* @__PURE__ */ jsxs3("span", { children: [
2659
+ "Take: ",
2660
+ recorder.take.durationSec.toFixed(1),
2661
+ "s",
2662
+ recorder.take.alignment ? ` \xB7 ${recorder.take.alignment.detectedSyllables} syllables aligned` : " \xB7 timing unavailable (saved without re-timing)"
2663
+ ] }),
2664
+ stage.reviewAudioUrl ? /* @__PURE__ */ jsx4(
2665
+ "audio",
2666
+ {
2667
+ controls: true,
2668
+ src: stage.reviewAudioUrl,
2669
+ onTimeUpdate: stage.handleReviewTimeUpdate
2670
+ }
2671
+ ) : null,
2672
+ showReviewActions ? /* @__PURE__ */ jsxs3(Fragment3, { children: [
2673
+ /* @__PURE__ */ jsx4("button", { type: "button", onClick: () => void stage.handleSave(), children: "\u2713 Save narration" }),
2674
+ /* @__PURE__ */ jsx4("button", { type: "button", onClick: stage.handleRetake, children: "\u21BA Retake" }),
2675
+ /* @__PURE__ */ jsx4("button", { type: "button", onClick: stage.handleDiscard, children: "\u2715 Discard" })
2676
+ ] }) : null,
2677
+ recorder.error ? /* @__PURE__ */ jsxs3("span", { children: [
2678
+ "\u26A0 ",
2679
+ recorder.error.message
2680
+ ] }) : null
2681
+ ] }) : null,
2682
+ stage.saveNotice ? /* @__PURE__ */ jsxs3("div", { className: "squisq-teleprompter-review", "data-testid": "teleprompter-save-notice", children: [
2683
+ /* @__PURE__ */ jsx4("span", { children: stage.saveNotice }),
2684
+ /* @__PURE__ */ jsx4("button", { type: "button", onClick: stage.dismissSaveNotice, children: "Dismiss" })
2685
+ ] }) : null,
2686
+ /* @__PURE__ */ jsx4(
2687
+ TeleprompterControls,
2688
+ {
2689
+ controller,
2690
+ float,
2691
+ recordSlot,
2692
+ showPlayPause: showTransportPlay
2693
+ }
2694
+ )
2695
+ ] });
2379
2696
  }
2380
2697
 
2381
2698
  export {
2699
+ resolveFormat,
2700
+ supportsMediaRecorder,
2701
+ supportsUserMedia,
2702
+ supportsDisplayMedia,
2703
+ supportsSystemAudioCapture,
2704
+ buildFilename,
2705
+ requestMicStream,
2706
+ requestCameraStream,
2707
+ useStreamPreview,
2708
+ buildTimingJson,
2709
+ encodeTimingJson,
2710
+ timingPathFor,
2382
2711
  PCM_WORKLET_NAME,
2383
2712
  PCM_WORKLET_SOURCE,
2384
2713
  registerPcmWorklet,
2385
2714
  useMicAnalysis,
2386
2715
  DEFAULT_TELEPROMPTER_PREFS,
2716
+ TELEPROMPTER_PREF_LIMITS,
2717
+ normalizeTeleprompterPrefs,
2387
2718
  vadConfigForSensitivity,
2388
2719
  useTeleprompter,
2389
2720
  detectFloatTiers,
2390
2721
  createFloatingWindowManager,
2391
2722
  useFloatingWindow,
2392
- EYE_LINE_FRACTION,
2393
- measureTokenLines,
2394
- targetOffsetFor,
2395
- stepScroll,
2396
2723
  prompterVarsFromTheme,
2397
2724
  ensureTeleprompterStyles,
2398
2725
  TELEPROMPTER_CSS,
2399
- TeleprompterSurface,
2400
- TeleprompterControls,
2401
2726
  useNarrationRecorder,
2402
2727
  narrationAnnotationLine,
2403
2728
  cameraVideoLine,
2404
2729
  insertNarrationPreamble,
2405
2730
  buildNarrationSavePlan,
2406
2731
  executeNarrationSave,
2407
- TeleprompterView
2732
+ useNarrationStage,
2733
+ EYE_LINE_FRACTION,
2734
+ measureTokenLines,
2735
+ targetOffsetFor,
2736
+ stepScroll,
2737
+ TeleprompterSurface,
2738
+ TeleprompterControls,
2739
+ NarrationStage
2408
2740
  };