@bendyline/squisq-editor-react 2.3.4 → 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.
- package/README.md +4 -2
- package/dist/{chunk-GNIVYDZH.js → chunk-KR4VLXUM.js} +10998 -8247
- package/dist/{chunk-6VDYKI3L.js → chunk-LJRHKXDV.js} +3 -1
- package/dist/chunk-LOTY7AOS.js +1925 -0
- package/dist/{chunk-5JMHFAVW.js → chunk-PDCKJCOS.js} +1200 -868
- package/dist/chunk-V4NBQF5C.js +62 -0
- package/dist/chunk-V6Z7GG55.js +16 -0
- package/dist/{chunk-54UGTQBO.js → chunk-WDX6UDL5.js} +1 -1
- package/dist/{chunk-NITZVAXL.js → chunk-WLJ623UZ.js} +24 -0
- package/dist/index.d.ts +289 -118
- package/dist/index.js +52 -20
- package/dist/json-editor/index.js +2 -2
- package/dist/monaco.d.ts +26 -0
- package/dist/monaco.js +144 -10
- package/dist/monacoLanguageDetection-DEyUW-BS.d.ts +18 -0
- package/dist/monacoSuggestions-MDBODZ7F.js +3 -0
- package/dist/recorder/index.d.ts +7 -407
- package/dist/recorder/index.js +3 -3
- package/dist/recorder-C5tAYUE3.d.ts +508 -0
- package/dist/shell/index.d.ts +1 -1
- package/dist/shell/index.js +6 -5
- package/dist/{shell-C-KkTBz7.d.ts → shell-CU4GpGuq.d.ts} +76 -6
- package/dist/styles/index.css +845 -26
- package/dist/teleprompter/index.d.ts +57 -246
- package/dist/teleprompter/index.js +12 -3
- package/dist/useNarrationStage-Bqo18PBw.d.ts +313 -0
- package/package.json +8 -6
- package/dist/chunk-5Q4JN4I5.js +0 -132
- package/dist/chunk-MJJK7YQB.js +0 -949
|
@@ -1,12 +1,133 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
(
|
|
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 (
|
|
457
|
-
void mic.start(
|
|
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,
|
|
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 (
|
|
478
|
-
|
|
680
|
+
if (event.repeat) break;
|
|
681
|
+
nudge(1);
|
|
479
682
|
break;
|
|
480
683
|
case "ArrowUp":
|
|
481
|
-
case "ArrowLeft":
|
|
482
684
|
event.preventDefault();
|
|
483
|
-
|
|
685
|
+
if (event.repeat) break;
|
|
686
|
+
play();
|
|
484
687
|
break;
|
|
485
688
|
case "ArrowDown":
|
|
486
|
-
case "ArrowRight":
|
|
487
689
|
event.preventDefault();
|
|
488
|
-
|
|
690
|
+
if (event.repeat) break;
|
|
691
|
+
pause();
|
|
489
692
|
break;
|
|
490
693
|
case "[":
|
|
491
694
|
event.preventDefault();
|
|
492
|
-
setPrefs({
|
|
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({
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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/
|
|
1160
|
-
import {
|
|
1161
|
-
import {
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
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
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
const
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
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
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
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
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
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
|
-
|
|
1816
|
-
unmountedRef.current = false;
|
|
1830
|
+
useEffect6(() => {
|
|
1817
1831
|
return () => {
|
|
1818
|
-
|
|
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
|
-
}, [
|
|
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
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
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/
|
|
1848
|
-
var
|
|
1849
|
-
var
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
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
|
|
1855
|
-
|
|
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
|
|
1858
|
-
|
|
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
|
-
|
|
1861
|
-
|
|
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
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
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
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
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/
|
|
1895
|
-
import {
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
)
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
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/
|
|
1979
|
-
import { useCallback as
|
|
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
|
|
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 =
|
|
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/
|
|
2483
|
+
// src/teleprompter/NarrationStage.tsx
|
|
2117
2484
|
import { Fragment as Fragment3, jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
2118
|
-
function
|
|
2119
|
-
const {
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
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
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2509
|
+
useEffect8(() => {
|
|
2172
2510
|
if (presentationTarget) ensureTeleprompterStyles(presentationTarget.ownerDocument);
|
|
2173
2511
|
}, [presentationTarget]);
|
|
2174
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
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
|
-
|
|
2732
|
+
useNarrationStage,
|
|
2733
|
+
EYE_LINE_FRACTION,
|
|
2734
|
+
measureTokenLines,
|
|
2735
|
+
targetOffsetFor,
|
|
2736
|
+
stepScroll,
|
|
2737
|
+
TeleprompterSurface,
|
|
2738
|
+
TeleprompterControls,
|
|
2739
|
+
NarrationStage
|
|
2408
2740
|
};
|