@bendyline/squisq-editor-react 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE.md +30 -30
- package/dist/chunk-54UGTQBO.js +862 -0
- package/dist/chunk-5JMHFAVW.js +2408 -0
- package/dist/chunk-5Q4JN4I5.js +132 -0
- package/dist/chunk-6VDYKI3L.js +49 -0
- package/dist/chunk-GS7QWYFT.js +9 -0
- package/dist/chunk-MJJK7YQB.js +949 -0
- package/dist/chunk-NITZVAXL.js +986 -0
- package/dist/chunk-TRCKHRBS.js +35234 -0
- package/dist/chunk-V44VP242.js +3256 -0
- package/dist/image-editor/index.d.ts +223 -0
- package/dist/image-editor/index.js +15 -0
- package/dist/index.d.ts +2310 -4423
- package/dist/index.js +494 -42158
- package/dist/json-editor/index.d.ts +27 -0
- package/dist/json-editor/index.js +7 -0
- package/dist/monaco.d.ts +1 -1
- package/dist/monaco.js +1 -1
- package/dist/recorder/index.d.ts +407 -0
- package/dist/recorder/index.js +43 -0
- package/dist/shell/index.d.ts +9 -0
- package/dist/shell/index.js +22 -0
- package/dist/shell-BxSCBm4H.d.ts +1064 -0
- package/dist/styles/index.css +367 -19
- package/dist/teleprompter/index.d.ts +497 -0
- package/dist/teleprompter/index.js +57 -0
- package/package.json +29 -6
|
@@ -0,0 +1,2408 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildFilename,
|
|
3
|
+
encodeTimingJson,
|
|
4
|
+
requestCameraStream,
|
|
5
|
+
requestMicStream,
|
|
6
|
+
resolveFormat,
|
|
7
|
+
timingPathFor,
|
|
8
|
+
useStreamPreview
|
|
9
|
+
} from "./chunk-5Q4JN4I5.js";
|
|
10
|
+
|
|
11
|
+
// src/teleprompter/pcmWorklet.ts
|
|
12
|
+
var PCM_WORKLET_NAME = "squisq-pcm-tap";
|
|
13
|
+
var PCM_WORKLET_SOURCE = `
|
|
14
|
+
class SquisqPcmTap extends AudioWorkletProcessor {
|
|
15
|
+
constructor() {
|
|
16
|
+
super();
|
|
17
|
+
this._buf = new Float32Array(1024);
|
|
18
|
+
this._len = 0;
|
|
19
|
+
}
|
|
20
|
+
process(inputs) {
|
|
21
|
+
const channel = inputs[0] && inputs[0][0];
|
|
22
|
+
if (channel && channel.length > 0) {
|
|
23
|
+
let i = 0;
|
|
24
|
+
while (i < channel.length) {
|
|
25
|
+
const n = Math.min(channel.length - i, this._buf.length - this._len);
|
|
26
|
+
this._buf.set(channel.subarray(i, i + n), this._len);
|
|
27
|
+
this._len += n;
|
|
28
|
+
i += n;
|
|
29
|
+
if (this._len === this._buf.length) {
|
|
30
|
+
const out = this._buf;
|
|
31
|
+
this._buf = new Float32Array(1024);
|
|
32
|
+
this._len = 0;
|
|
33
|
+
// Clone, don't transfer: Chrome recycles buffers transferred out
|
|
34
|
+
// of the worklet scope once the receiving handler returns, so a
|
|
35
|
+
// transferred hop reads as zeros if anything retains it. A 4 KB
|
|
36
|
+
// structured clone at ~47 Hz is negligible.
|
|
37
|
+
this.port.postMessage({ pcm: out, audioTime: currentTime });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
registerProcessor('${PCM_WORKLET_NAME}', SquisqPcmTap);
|
|
45
|
+
`;
|
|
46
|
+
async function registerPcmWorklet(ctx) {
|
|
47
|
+
const url = URL.createObjectURL(new Blob([PCM_WORKLET_SOURCE], { type: "text/javascript" }));
|
|
48
|
+
try {
|
|
49
|
+
await ctx.audioWorklet.addModule(url);
|
|
50
|
+
} finally {
|
|
51
|
+
URL.revokeObjectURL(url);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// src/teleprompter/useMicAnalysis.ts
|
|
56
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
57
|
+
function useMicAnalysis() {
|
|
58
|
+
const [status, setStatus] = useState("idle");
|
|
59
|
+
const [error, setError] = useState(null);
|
|
60
|
+
const [devices, setDevices] = useState([]);
|
|
61
|
+
const [stream, setStream] = useState(null);
|
|
62
|
+
const [sampleRate, setSampleRate] = useState(null);
|
|
63
|
+
const graphRef = useRef(null);
|
|
64
|
+
const listenersRef = useRef(/* @__PURE__ */ new Set());
|
|
65
|
+
const generationRef = useRef(0);
|
|
66
|
+
const refreshDevices = useCallback(async () => {
|
|
67
|
+
try {
|
|
68
|
+
const all = await navigator.mediaDevices.enumerateDevices();
|
|
69
|
+
setDevices(all.filter((d) => d.kind === "audioinput"));
|
|
70
|
+
} catch {
|
|
71
|
+
}
|
|
72
|
+
}, []);
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (typeof navigator === "undefined" || !navigator.mediaDevices?.addEventListener) return;
|
|
75
|
+
const onChange = () => void refreshDevices();
|
|
76
|
+
navigator.mediaDevices.addEventListener("devicechange", onChange);
|
|
77
|
+
return () => navigator.mediaDevices.removeEventListener("devicechange", onChange);
|
|
78
|
+
}, [refreshDevices]);
|
|
79
|
+
const teardown = useCallback(() => {
|
|
80
|
+
const graph = graphRef.current;
|
|
81
|
+
graphRef.current = null;
|
|
82
|
+
if (!graph) return;
|
|
83
|
+
try {
|
|
84
|
+
if ("port" in graph.node) {
|
|
85
|
+
graph.node.port.onmessage = null;
|
|
86
|
+
} else {
|
|
87
|
+
graph.node.onaudioprocess = null;
|
|
88
|
+
}
|
|
89
|
+
graph.node.disconnect();
|
|
90
|
+
graph.source.disconnect();
|
|
91
|
+
graph.sink?.disconnect();
|
|
92
|
+
} catch {
|
|
93
|
+
}
|
|
94
|
+
for (const track of graph.stream.getTracks()) track.stop();
|
|
95
|
+
void graph.context.close().catch(() => void 0);
|
|
96
|
+
}, []);
|
|
97
|
+
const stop = useCallback(() => {
|
|
98
|
+
generationRef.current += 1;
|
|
99
|
+
teardown();
|
|
100
|
+
setStream(null);
|
|
101
|
+
setSampleRate(null);
|
|
102
|
+
setStatus("idle");
|
|
103
|
+
setError(null);
|
|
104
|
+
}, [teardown]);
|
|
105
|
+
const start = useCallback(
|
|
106
|
+
async (deviceId) => {
|
|
107
|
+
const generation = ++generationRef.current;
|
|
108
|
+
teardown();
|
|
109
|
+
setStatus("starting");
|
|
110
|
+
setError(null);
|
|
111
|
+
try {
|
|
112
|
+
const micStream = await requestMicStream(
|
|
113
|
+
deviceId ? { deviceId: { exact: deviceId } } : void 0
|
|
114
|
+
);
|
|
115
|
+
if (generation !== generationRef.current) {
|
|
116
|
+
for (const track of micStream.getTracks()) track.stop();
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
const context = new AudioContext();
|
|
120
|
+
if (context.state === "suspended") {
|
|
121
|
+
await context.resume().catch(() => void 0);
|
|
122
|
+
}
|
|
123
|
+
const source = context.createMediaStreamSource(micStream);
|
|
124
|
+
const emit = (pcm, audioTime) => {
|
|
125
|
+
for (const listener of listenersRef.current) listener(pcm, audioTime);
|
|
126
|
+
};
|
|
127
|
+
let node = null;
|
|
128
|
+
let sink = null;
|
|
129
|
+
if (context.audioWorklet && typeof AudioWorkletNode !== "undefined") {
|
|
130
|
+
try {
|
|
131
|
+
await registerPcmWorklet(context);
|
|
132
|
+
const workletNode = new AudioWorkletNode(context, PCM_WORKLET_NAME, {
|
|
133
|
+
numberOfInputs: 1,
|
|
134
|
+
numberOfOutputs: 1,
|
|
135
|
+
channelCount: 1,
|
|
136
|
+
channelCountMode: "explicit"
|
|
137
|
+
});
|
|
138
|
+
workletNode.port.onmessage = (event) => {
|
|
139
|
+
const data = event.data;
|
|
140
|
+
if (data && data.pcm instanceof Float32Array) {
|
|
141
|
+
emit(data.pcm, typeof data.audioTime === "number" ? data.audioTime : 0);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
source.connect(workletNode);
|
|
145
|
+
sink = context.createGain();
|
|
146
|
+
sink.gain.value = 0;
|
|
147
|
+
workletNode.connect(sink);
|
|
148
|
+
sink.connect(context.destination);
|
|
149
|
+
node = workletNode;
|
|
150
|
+
} catch {
|
|
151
|
+
node = null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (node === null) {
|
|
155
|
+
const processor = context.createScriptProcessor(1024, 1, 1);
|
|
156
|
+
processor.onaudioprocess = (event) => {
|
|
157
|
+
const input = event.inputBuffer.getChannelData(0);
|
|
158
|
+
emit(new Float32Array(input), event.playbackTime);
|
|
159
|
+
};
|
|
160
|
+
sink = context.createGain();
|
|
161
|
+
sink.gain.value = 0;
|
|
162
|
+
source.connect(processor);
|
|
163
|
+
processor.connect(sink);
|
|
164
|
+
sink.connect(context.destination);
|
|
165
|
+
node = processor;
|
|
166
|
+
}
|
|
167
|
+
if (generation !== generationRef.current) {
|
|
168
|
+
for (const track of micStream.getTracks()) track.stop();
|
|
169
|
+
void context.close().catch(() => void 0);
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
graphRef.current = { stream: micStream, context, source, node, sink };
|
|
173
|
+
setStream(micStream);
|
|
174
|
+
setSampleRate(context.sampleRate);
|
|
175
|
+
setStatus("live");
|
|
176
|
+
void refreshDevices();
|
|
177
|
+
return micStream;
|
|
178
|
+
} catch (err) {
|
|
179
|
+
if (generation !== generationRef.current) return null;
|
|
180
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
181
|
+
setStatus("error");
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
[refreshDevices, teardown]
|
|
186
|
+
);
|
|
187
|
+
const subscribeHop = useCallback((listener) => {
|
|
188
|
+
listenersRef.current.add(listener);
|
|
189
|
+
return () => {
|
|
190
|
+
listenersRef.current.delete(listener);
|
|
191
|
+
};
|
|
192
|
+
}, []);
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
return () => {
|
|
195
|
+
generationRef.current += 1;
|
|
196
|
+
teardown();
|
|
197
|
+
};
|
|
198
|
+
}, [teardown]);
|
|
199
|
+
return { status, error, stream, sampleRate, devices, start, stop, subscribeHop };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// src/teleprompter/types.ts
|
|
203
|
+
var DEFAULT_TELEPROMPTER_PREFS = Object.freeze({
|
|
204
|
+
fontSizePx: 48,
|
|
205
|
+
mirrored: false,
|
|
206
|
+
baseWpm: 150,
|
|
207
|
+
voiceTracking: true,
|
|
208
|
+
vadSensitivity: 0.5,
|
|
209
|
+
countdownSec: 3,
|
|
210
|
+
lineGuide: true,
|
|
211
|
+
micDeviceId: null
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// src/teleprompter/useTeleprompter.ts
|
|
215
|
+
import { useCallback as useCallback2, useEffect as useEffect2, useMemo, useRef as useRef2, useState as useState2 } from "react";
|
|
216
|
+
import {
|
|
217
|
+
buildNarrationScript,
|
|
218
|
+
createNarrationSession,
|
|
219
|
+
narrationSessionStep,
|
|
220
|
+
reanchorSession,
|
|
221
|
+
DEFAULT_VAD_CONFIG
|
|
222
|
+
} from "@bendyline/squisq/narration";
|
|
223
|
+
var PREFS_STORAGE_KEY = "squisq:teleprompter-prefs";
|
|
224
|
+
var PUBLISH_INTERVAL_MS = 66;
|
|
225
|
+
var NUDGE_WORDS = 6;
|
|
226
|
+
function loadPrefs() {
|
|
227
|
+
try {
|
|
228
|
+
const raw = globalThis.localStorage?.getItem(PREFS_STORAGE_KEY);
|
|
229
|
+
if (!raw) return { ...DEFAULT_TELEPROMPTER_PREFS };
|
|
230
|
+
const parsed = JSON.parse(raw);
|
|
231
|
+
return { ...DEFAULT_TELEPROMPTER_PREFS, ...parsed };
|
|
232
|
+
} catch {
|
|
233
|
+
return { ...DEFAULT_TELEPROMPTER_PREFS };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function savePrefs(prefs) {
|
|
237
|
+
try {
|
|
238
|
+
globalThis.localStorage?.setItem(PREFS_STORAGE_KEY, JSON.stringify(prefs));
|
|
239
|
+
} catch {
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function vadConfigForSensitivity(sensitivity) {
|
|
243
|
+
const s = Math.min(1, Math.max(0, sensitivity));
|
|
244
|
+
const factor = 1.5 - s;
|
|
245
|
+
const enterRatio = Math.min(5, Math.max(2.6, DEFAULT_VAD_CONFIG.enterRatio * factor));
|
|
246
|
+
return {
|
|
247
|
+
enterRatio,
|
|
248
|
+
exitRatio: Math.max(
|
|
249
|
+
2,
|
|
250
|
+
enterRatio * (DEFAULT_VAD_CONFIG.exitRatio / DEFAULT_VAD_CONFIG.enterRatio)
|
|
251
|
+
)
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
function useTeleprompter(opts) {
|
|
255
|
+
const { doc } = opts;
|
|
256
|
+
const script = useMemo(
|
|
257
|
+
() => doc && doc.blocks.length > 0 ? buildNarrationScript(doc) : null,
|
|
258
|
+
[doc]
|
|
259
|
+
);
|
|
260
|
+
const [prefs, setPrefsState] = useState2(loadPrefs);
|
|
261
|
+
const [transport, setTransport] = useState2("stopped");
|
|
262
|
+
const [countdownRemaining, setCountdownRemaining] = useState2(null);
|
|
263
|
+
const [view, setView] = useState2({ wordPos: 0, micLevel: 0, voiceActive: false });
|
|
264
|
+
const mic = useMicAnalysis();
|
|
265
|
+
const scriptRef = useRef2(script);
|
|
266
|
+
const prefsRef = useRef2(prefs);
|
|
267
|
+
const transportRef = useRef2(transport);
|
|
268
|
+
const wordPosRef = useRef2(0);
|
|
269
|
+
const sessionRef = useRef2(null);
|
|
270
|
+
const sessionKeyRef = useRef2("");
|
|
271
|
+
const lastPublishRef = useRef2(0);
|
|
272
|
+
const tickSubsRef = useRef2(/* @__PURE__ */ new Set());
|
|
273
|
+
const levelRef = useRef2(0);
|
|
274
|
+
const voiceRef = useRef2(false);
|
|
275
|
+
const countdownTimerRef = useRef2(null);
|
|
276
|
+
scriptRef.current = script;
|
|
277
|
+
prefsRef.current = prefs;
|
|
278
|
+
transportRef.current = transport;
|
|
279
|
+
useEffect2(() => {
|
|
280
|
+
wordPosRef.current = Math.min(wordPosRef.current, script?.tokens.length ?? 0);
|
|
281
|
+
sessionRef.current = null;
|
|
282
|
+
setView((v) => ({ ...v, wordPos: wordPosRef.current }));
|
|
283
|
+
}, [script]);
|
|
284
|
+
const publish = useCallback2(
|
|
285
|
+
(force = false) => {
|
|
286
|
+
const now = performance.now();
|
|
287
|
+
if (!force && now - lastPublishRef.current < PUBLISH_INTERVAL_MS) return;
|
|
288
|
+
lastPublishRef.current = now;
|
|
289
|
+
setView({
|
|
290
|
+
wordPos: wordPosRef.current,
|
|
291
|
+
micLevel: levelRef.current,
|
|
292
|
+
voiceActive: voiceRef.current
|
|
293
|
+
});
|
|
294
|
+
},
|
|
295
|
+
[setView]
|
|
296
|
+
);
|
|
297
|
+
const notifyTick = useCallback2(() => {
|
|
298
|
+
for (const cb of tickSubsRef.current) cb(wordPosRef.current);
|
|
299
|
+
}, []);
|
|
300
|
+
const clearCountdown = useCallback2(() => {
|
|
301
|
+
if (countdownTimerRef.current !== null) {
|
|
302
|
+
clearInterval(countdownTimerRef.current);
|
|
303
|
+
countdownTimerRef.current = null;
|
|
304
|
+
}
|
|
305
|
+
setCountdownRemaining(null);
|
|
306
|
+
}, []);
|
|
307
|
+
const finish = useCallback2(() => {
|
|
308
|
+
setTransport("finished");
|
|
309
|
+
transportRef.current = "finished";
|
|
310
|
+
}, []);
|
|
311
|
+
useEffect2(() => {
|
|
312
|
+
return mic.subscribeHop((pcm) => {
|
|
313
|
+
const currentScript = scriptRef.current;
|
|
314
|
+
const sampleRate = mic.sampleRate;
|
|
315
|
+
if (!currentScript || !sampleRate) return;
|
|
316
|
+
const currentPrefs = prefsRef.current;
|
|
317
|
+
const key = `${sampleRate}:${currentScript.tokens.length}:${currentScript.sourceText.length}`;
|
|
318
|
+
let session = sessionRef.current;
|
|
319
|
+
if (!session || sessionKeyRef.current !== key) {
|
|
320
|
+
session = reanchorSession(
|
|
321
|
+
createNarrationSession(sampleRate, currentScript, {
|
|
322
|
+
vad: vadConfigForSensitivity(currentPrefs.vadSensitivity),
|
|
323
|
+
pacing: { baseWpm: currentPrefs.baseWpm }
|
|
324
|
+
}),
|
|
325
|
+
wordPosRef.current
|
|
326
|
+
);
|
|
327
|
+
sessionKeyRef.current = key;
|
|
328
|
+
} else {
|
|
329
|
+
session = {
|
|
330
|
+
...session,
|
|
331
|
+
config: {
|
|
332
|
+
...session.config,
|
|
333
|
+
vad: vadConfigForSensitivity(currentPrefs.vadSensitivity),
|
|
334
|
+
pacing: { baseWpm: currentPrefs.baseWpm }
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
session = narrationSessionStep(session, pcm);
|
|
339
|
+
levelRef.current = session.lastFrame ? Math.min(1, session.lastFrame.rms * 6) : 0;
|
|
340
|
+
voiceRef.current = session.vad.speaking;
|
|
341
|
+
if (transportRef.current === "rolling" && prefsRef.current.voiceTracking) {
|
|
342
|
+
wordPosRef.current = session.pacing.wordPos;
|
|
343
|
+
if (wordPosRef.current >= currentScript.tokens.length) finish();
|
|
344
|
+
} else {
|
|
345
|
+
session = reanchorSession(session, wordPosRef.current);
|
|
346
|
+
}
|
|
347
|
+
sessionRef.current = session;
|
|
348
|
+
notifyTick();
|
|
349
|
+
publish();
|
|
350
|
+
});
|
|
351
|
+
}, [mic, finish, notifyTick, publish]);
|
|
352
|
+
useEffect2(() => {
|
|
353
|
+
if (transport !== "rolling") return;
|
|
354
|
+
let raf = 0;
|
|
355
|
+
let last = performance.now();
|
|
356
|
+
const loop = (now) => {
|
|
357
|
+
const dt = Math.min(0.25, (now - last) / 1e3);
|
|
358
|
+
last = now;
|
|
359
|
+
const currentScript = scriptRef.current;
|
|
360
|
+
const manual = !prefsRef.current.voiceTracking || mic.status !== "live";
|
|
361
|
+
if (currentScript && manual) {
|
|
362
|
+
wordPosRef.current = Math.min(
|
|
363
|
+
currentScript.tokens.length,
|
|
364
|
+
wordPosRef.current + prefsRef.current.baseWpm / 60 * dt
|
|
365
|
+
);
|
|
366
|
+
if (wordPosRef.current >= currentScript.tokens.length) {
|
|
367
|
+
finish();
|
|
368
|
+
publish(true);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
notifyTick();
|
|
372
|
+
}
|
|
373
|
+
publish();
|
|
374
|
+
raf = requestAnimationFrame(loop);
|
|
375
|
+
};
|
|
376
|
+
raf = requestAnimationFrame(loop);
|
|
377
|
+
return () => cancelAnimationFrame(raf);
|
|
378
|
+
}, [transport, mic.status, finish, notifyTick, publish]);
|
|
379
|
+
const beginRolling = useCallback2(() => {
|
|
380
|
+
clearCountdown();
|
|
381
|
+
if (sessionRef.current) {
|
|
382
|
+
sessionRef.current = reanchorSession(sessionRef.current, wordPosRef.current);
|
|
383
|
+
}
|
|
384
|
+
setTransport("rolling");
|
|
385
|
+
transportRef.current = "rolling";
|
|
386
|
+
}, [clearCountdown]);
|
|
387
|
+
const play = useCallback2(() => {
|
|
388
|
+
if (transportRef.current === "rolling" || transportRef.current === "countdown") return;
|
|
389
|
+
if (transportRef.current === "finished") {
|
|
390
|
+
wordPosRef.current = 0;
|
|
391
|
+
publish(true);
|
|
392
|
+
}
|
|
393
|
+
if (prefsRef.current.voiceTracking && mic.status === "idle") {
|
|
394
|
+
void mic.start(prefsRef.current.micDeviceId);
|
|
395
|
+
}
|
|
396
|
+
const countdown = prefsRef.current.countdownSec;
|
|
397
|
+
if (countdown > 0) {
|
|
398
|
+
setTransport("countdown");
|
|
399
|
+
transportRef.current = "countdown";
|
|
400
|
+
setCountdownRemaining(countdown);
|
|
401
|
+
let remaining = countdown;
|
|
402
|
+
countdownTimerRef.current = setInterval(() => {
|
|
403
|
+
remaining -= 1;
|
|
404
|
+
if (remaining <= 0) beginRolling();
|
|
405
|
+
else setCountdownRemaining(remaining);
|
|
406
|
+
}, 1e3);
|
|
407
|
+
} else {
|
|
408
|
+
beginRolling();
|
|
409
|
+
}
|
|
410
|
+
}, [beginRolling, mic, publish]);
|
|
411
|
+
const pause = useCallback2(() => {
|
|
412
|
+
if (transportRef.current === "countdown") {
|
|
413
|
+
clearCountdown();
|
|
414
|
+
setTransport("stopped");
|
|
415
|
+
transportRef.current = "stopped";
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (transportRef.current !== "rolling") return;
|
|
419
|
+
setTransport("paused");
|
|
420
|
+
transportRef.current = "paused";
|
|
421
|
+
}, [clearCountdown]);
|
|
422
|
+
const restart = useCallback2(() => {
|
|
423
|
+
clearCountdown();
|
|
424
|
+
wordPosRef.current = 0;
|
|
425
|
+
if (sessionRef.current) sessionRef.current = reanchorSession(sessionRef.current, 0);
|
|
426
|
+
setTransport("stopped");
|
|
427
|
+
transportRef.current = "stopped";
|
|
428
|
+
publish(true);
|
|
429
|
+
}, [clearCountdown, publish]);
|
|
430
|
+
const seekToToken = useCallback2(
|
|
431
|
+
(tokenIndex) => {
|
|
432
|
+
const currentScript = scriptRef.current;
|
|
433
|
+
if (!currentScript) return;
|
|
434
|
+
wordPosRef.current = Math.min(Math.max(0, tokenIndex), currentScript.tokens.length);
|
|
435
|
+
if (sessionRef.current) {
|
|
436
|
+
sessionRef.current = reanchorSession(sessionRef.current, wordPosRef.current);
|
|
437
|
+
}
|
|
438
|
+
if (transportRef.current === "finished") {
|
|
439
|
+
setTransport("paused");
|
|
440
|
+
transportRef.current = "paused";
|
|
441
|
+
}
|
|
442
|
+
notifyTick();
|
|
443
|
+
publish(true);
|
|
444
|
+
},
|
|
445
|
+
[notifyTick, publish]
|
|
446
|
+
);
|
|
447
|
+
const nudge = useCallback2(
|
|
448
|
+
(deltaTokens) => seekToToken(Math.round(wordPosRef.current + deltaTokens)),
|
|
449
|
+
[seekToToken]
|
|
450
|
+
);
|
|
451
|
+
const setPrefs = useCallback2(
|
|
452
|
+
(patch) => {
|
|
453
|
+
setPrefsState((prev) => {
|
|
454
|
+
const next = { ...prev, ...patch };
|
|
455
|
+
savePrefs(next);
|
|
456
|
+
if (patch.micDeviceId !== void 0 && patch.micDeviceId !== prev.micDeviceId && mic.status === "live") {
|
|
457
|
+
void mic.start(patch.micDeviceId);
|
|
458
|
+
}
|
|
459
|
+
return next;
|
|
460
|
+
});
|
|
461
|
+
},
|
|
462
|
+
[mic]
|
|
463
|
+
);
|
|
464
|
+
const subscribeTick = useCallback2((cb) => {
|
|
465
|
+
tickSubsRef.current.add(cb);
|
|
466
|
+
return () => {
|
|
467
|
+
tickSubsRef.current.delete(cb);
|
|
468
|
+
};
|
|
469
|
+
}, []);
|
|
470
|
+
const handleKeyDown = useCallback2(
|
|
471
|
+
(event) => {
|
|
472
|
+
const target = event.target;
|
|
473
|
+
if (target?.closest('input, textarea, select, button, a, [contenteditable="true"]')) return;
|
|
474
|
+
switch (event.key) {
|
|
475
|
+
case " ":
|
|
476
|
+
event.preventDefault();
|
|
477
|
+
if (transportRef.current === "rolling" || transportRef.current === "countdown") pause();
|
|
478
|
+
else play();
|
|
479
|
+
break;
|
|
480
|
+
case "ArrowUp":
|
|
481
|
+
case "ArrowLeft":
|
|
482
|
+
event.preventDefault();
|
|
483
|
+
nudge(-NUDGE_WORDS);
|
|
484
|
+
break;
|
|
485
|
+
case "ArrowDown":
|
|
486
|
+
case "ArrowRight":
|
|
487
|
+
event.preventDefault();
|
|
488
|
+
nudge(NUDGE_WORDS);
|
|
489
|
+
break;
|
|
490
|
+
case "[":
|
|
491
|
+
event.preventDefault();
|
|
492
|
+
setPrefs({ baseWpm: Math.max(80, prefsRef.current.baseWpm - 10) });
|
|
493
|
+
break;
|
|
494
|
+
case "]":
|
|
495
|
+
event.preventDefault();
|
|
496
|
+
setPrefs({ baseWpm: Math.min(260, prefsRef.current.baseWpm + 10) });
|
|
497
|
+
break;
|
|
498
|
+
case "m":
|
|
499
|
+
case "M":
|
|
500
|
+
event.preventDefault();
|
|
501
|
+
setPrefs({ mirrored: !prefsRef.current.mirrored });
|
|
502
|
+
break;
|
|
503
|
+
case "Escape":
|
|
504
|
+
if (transportRef.current === "countdown" || transportRef.current === "rolling") {
|
|
505
|
+
event.preventDefault();
|
|
506
|
+
pause();
|
|
507
|
+
}
|
|
508
|
+
break;
|
|
509
|
+
default:
|
|
510
|
+
break;
|
|
511
|
+
}
|
|
512
|
+
},
|
|
513
|
+
[nudge, pause, play, setPrefs]
|
|
514
|
+
);
|
|
515
|
+
useEffect2(() => clearCountdown, [clearCountdown]);
|
|
516
|
+
return {
|
|
517
|
+
script,
|
|
518
|
+
transport,
|
|
519
|
+
countdownRemaining,
|
|
520
|
+
wordPos: view.wordPos,
|
|
521
|
+
micLevel: view.micLevel,
|
|
522
|
+
voiceActive: view.voiceActive,
|
|
523
|
+
mic,
|
|
524
|
+
prefs,
|
|
525
|
+
setPrefs,
|
|
526
|
+
play,
|
|
527
|
+
pause,
|
|
528
|
+
restart,
|
|
529
|
+
nudge,
|
|
530
|
+
seekToToken,
|
|
531
|
+
subscribeTick,
|
|
532
|
+
handleKeyDown
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// src/teleprompter/floatingWindow.ts
|
|
537
|
+
function detectFloatTiers() {
|
|
538
|
+
const tiers = [];
|
|
539
|
+
if (typeof window !== "undefined" && typeof document !== "undefined") {
|
|
540
|
+
if (window.documentPictureInPicture) tiers.push("document-pip");
|
|
541
|
+
const video = document.createElement("video");
|
|
542
|
+
const stdPip = document.pictureInPictureEnabled === true && typeof video.requestPictureInPicture === "function";
|
|
543
|
+
const webkitPip = typeof video.webkitSetPresentationMode === "function" && (video.webkitSupportsPresentationMode?.("picture-in-picture") ?? true);
|
|
544
|
+
const canCapture = typeof HTMLCanvasElement !== "undefined" && typeof HTMLCanvasElement.prototype.captureStream === "function";
|
|
545
|
+
if ((stdPip || webkitPip) && canCapture) tiers.push("video-pip");
|
|
546
|
+
if (typeof window.open === "function") tiers.push("popup");
|
|
547
|
+
}
|
|
548
|
+
tiers.push("docked");
|
|
549
|
+
return tiers;
|
|
550
|
+
}
|
|
551
|
+
function createFloatingWindowManager(deps) {
|
|
552
|
+
let active = null;
|
|
553
|
+
let generation = 0;
|
|
554
|
+
let disposed = false;
|
|
555
|
+
const listeners = {
|
|
556
|
+
closed: /* @__PURE__ */ new Set(),
|
|
557
|
+
tierchange: /* @__PURE__ */ new Set()
|
|
558
|
+
};
|
|
559
|
+
const emit = (event, tier) => {
|
|
560
|
+
for (const cb of listeners[event]) cb(tier);
|
|
561
|
+
};
|
|
562
|
+
const handleExternalClose = () => {
|
|
563
|
+
if (!active) return;
|
|
564
|
+
const closing = active;
|
|
565
|
+
active = null;
|
|
566
|
+
closing.dispose();
|
|
567
|
+
emit("tierchange", "docked");
|
|
568
|
+
emit("closed", "docked");
|
|
569
|
+
};
|
|
570
|
+
const openerPageHide = () => manager.close();
|
|
571
|
+
if (typeof window !== "undefined") {
|
|
572
|
+
window.addEventListener("pagehide", openerPageHide);
|
|
573
|
+
}
|
|
574
|
+
const writeFloatDocument = (doc, title) => {
|
|
575
|
+
doc.title = title;
|
|
576
|
+
const style = doc.createElement("style");
|
|
577
|
+
style.textContent = `${deps.styleCss}
|
|
578
|
+
html,body{margin:0;height:100%;}#squisq-float-root{height:100%;display:flex;}`;
|
|
579
|
+
doc.head.appendChild(style);
|
|
580
|
+
const root = doc.createElement("div");
|
|
581
|
+
root.id = "squisq-float-root";
|
|
582
|
+
doc.body.appendChild(root);
|
|
583
|
+
return root;
|
|
584
|
+
};
|
|
585
|
+
const openDocumentPip = async (opts) => {
|
|
586
|
+
const api = window.documentPictureInPicture;
|
|
587
|
+
if (!api) throw new Error("Document PiP unavailable");
|
|
588
|
+
const pip = await api.requestWindow({ width: opts.width, height: opts.height });
|
|
589
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
590
|
+
try {
|
|
591
|
+
const rules = Array.from(sheet.cssRules).map((rule) => rule.cssText).join("\n");
|
|
592
|
+
const style = pip.document.createElement("style");
|
|
593
|
+
style.textContent = rules;
|
|
594
|
+
pip.document.head.appendChild(style);
|
|
595
|
+
} catch {
|
|
596
|
+
if (sheet.href) {
|
|
597
|
+
const link = pip.document.createElement("link");
|
|
598
|
+
link.rel = "stylesheet";
|
|
599
|
+
link.href = sheet.href;
|
|
600
|
+
pip.document.head.appendChild(link);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
const root = writeFloatDocument(pip.document, opts.title);
|
|
605
|
+
pip.addEventListener("pagehide", handleExternalClose);
|
|
606
|
+
return {
|
|
607
|
+
tier: "document-pip",
|
|
608
|
+
portalTarget: root,
|
|
609
|
+
canvasSink: null,
|
|
610
|
+
dispose: () => {
|
|
611
|
+
pip.removeEventListener("pagehide", handleExternalClose);
|
|
612
|
+
try {
|
|
613
|
+
pip.close();
|
|
614
|
+
} catch {
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
};
|
|
619
|
+
const openVideoPip = async (opts) => {
|
|
620
|
+
const canvas = document.createElement("canvas");
|
|
621
|
+
canvas.width = Math.round(opts.width * (window.devicePixelRatio || 1));
|
|
622
|
+
canvas.height = Math.round(opts.height * (window.devicePixelRatio || 1));
|
|
623
|
+
const ctx = canvas.getContext("2d");
|
|
624
|
+
if (!ctx || typeof canvas.captureStream !== "function") {
|
|
625
|
+
throw new Error("canvas.captureStream unavailable");
|
|
626
|
+
}
|
|
627
|
+
ctx.fillStyle = "#101014";
|
|
628
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
629
|
+
let stream;
|
|
630
|
+
let requestFrame;
|
|
631
|
+
try {
|
|
632
|
+
stream = canvas.captureStream(0);
|
|
633
|
+
const track = stream.getVideoTracks()[0];
|
|
634
|
+
if (typeof track.requestFrame === "function") {
|
|
635
|
+
const pushFrame = track.requestFrame.bind(track);
|
|
636
|
+
requestFrame = () => pushFrame();
|
|
637
|
+
} else {
|
|
638
|
+
stream = canvas.captureStream(30);
|
|
639
|
+
requestFrame = () => void 0;
|
|
640
|
+
}
|
|
641
|
+
} catch {
|
|
642
|
+
stream = canvas.captureStream(30);
|
|
643
|
+
requestFrame = () => void 0;
|
|
644
|
+
}
|
|
645
|
+
const video = document.createElement("video");
|
|
646
|
+
video.muted = true;
|
|
647
|
+
video.playsInline = true;
|
|
648
|
+
video.style.position = "fixed";
|
|
649
|
+
video.style.left = "-10000px";
|
|
650
|
+
video.style.width = `${opts.width}px`;
|
|
651
|
+
video.srcObject = stream;
|
|
652
|
+
document.body.appendChild(video);
|
|
653
|
+
const cleanupDom = () => {
|
|
654
|
+
video.pause();
|
|
655
|
+
video.srcObject = null;
|
|
656
|
+
video.remove();
|
|
657
|
+
for (const track of stream.getTracks()) track.stop();
|
|
658
|
+
};
|
|
659
|
+
try {
|
|
660
|
+
await video.play();
|
|
661
|
+
requestFrame();
|
|
662
|
+
if (typeof video.requestPictureInPicture === "function" && document.pictureInPictureEnabled) {
|
|
663
|
+
await video.requestPictureInPicture();
|
|
664
|
+
video.addEventListener("leavepictureinpicture", handleExternalClose);
|
|
665
|
+
} else if (typeof video.webkitSetPresentationMode === "function") {
|
|
666
|
+
video.webkitSetPresentationMode("picture-in-picture");
|
|
667
|
+
video.addEventListener("webkitpresentationmodechanged", () => {
|
|
668
|
+
if (video.webkitPresentationMode === "inline") handleExternalClose();
|
|
669
|
+
});
|
|
670
|
+
} else {
|
|
671
|
+
throw new Error("No video PiP API");
|
|
672
|
+
}
|
|
673
|
+
} catch (err) {
|
|
674
|
+
cleanupDom();
|
|
675
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
676
|
+
}
|
|
677
|
+
return {
|
|
678
|
+
tier: "video-pip",
|
|
679
|
+
portalTarget: null,
|
|
680
|
+
canvasSink: { canvas, width: canvas.width, height: canvas.height, requestFrame },
|
|
681
|
+
dispose: () => {
|
|
682
|
+
video.removeEventListener("leavepictureinpicture", handleExternalClose);
|
|
683
|
+
if (document.pictureInPictureElement === video) {
|
|
684
|
+
void document.exitPictureInPicture().catch(() => void 0);
|
|
685
|
+
} else if (typeof video.webkitSetPresentationMode === "function" && video.webkitPresentationMode === "picture-in-picture") {
|
|
686
|
+
video.webkitSetPresentationMode("inline");
|
|
687
|
+
}
|
|
688
|
+
cleanupDom();
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
};
|
|
692
|
+
const openPopup = (opts) => {
|
|
693
|
+
const popup = window.open(
|
|
694
|
+
"",
|
|
695
|
+
"squisq-teleprompter",
|
|
696
|
+
`popup=yes,width=${opts.width},height=${opts.height}`
|
|
697
|
+
);
|
|
698
|
+
if (!popup) throw new Error("Popup blocked");
|
|
699
|
+
const root = writeFloatDocument(popup.document, opts.title);
|
|
700
|
+
popup.addEventListener("pagehide", handleExternalClose);
|
|
701
|
+
popup.focus();
|
|
702
|
+
return {
|
|
703
|
+
tier: "popup",
|
|
704
|
+
portalTarget: root,
|
|
705
|
+
canvasSink: null,
|
|
706
|
+
dispose: () => {
|
|
707
|
+
popup.removeEventListener("pagehide", handleExternalClose);
|
|
708
|
+
try {
|
|
709
|
+
popup.close();
|
|
710
|
+
} catch {
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
};
|
|
714
|
+
};
|
|
715
|
+
const manager = {
|
|
716
|
+
get tier() {
|
|
717
|
+
return active?.tier ?? "docked";
|
|
718
|
+
},
|
|
719
|
+
get isOpen() {
|
|
720
|
+
return active !== null;
|
|
721
|
+
},
|
|
722
|
+
async open(opts) {
|
|
723
|
+
manager.close();
|
|
724
|
+
if (disposed) return "docked";
|
|
725
|
+
const attempt = ++generation;
|
|
726
|
+
const superseded = () => generation !== attempt || disposed;
|
|
727
|
+
const supported = detectFloatTiers();
|
|
728
|
+
const ladder = opts.preferredTier && supported.includes(opts.preferredTier) ? [opts.preferredTier, ...supported.filter((t) => t !== opts.preferredTier)] : supported;
|
|
729
|
+
for (const tier of ladder) {
|
|
730
|
+
if (tier === "docked") break;
|
|
731
|
+
if (superseded()) return "docked";
|
|
732
|
+
let opened = null;
|
|
733
|
+
try {
|
|
734
|
+
if (tier === "document-pip") opened = await openDocumentPip(opts);
|
|
735
|
+
else if (tier === "video-pip") opened = await openVideoPip(opts);
|
|
736
|
+
else if (tier === "popup") opened = openPopup(opts);
|
|
737
|
+
} catch {
|
|
738
|
+
opened = null;
|
|
739
|
+
}
|
|
740
|
+
if (!opened) continue;
|
|
741
|
+
if (superseded()) {
|
|
742
|
+
opened.dispose();
|
|
743
|
+
return "docked";
|
|
744
|
+
}
|
|
745
|
+
active = opened;
|
|
746
|
+
emit("tierchange", active.tier);
|
|
747
|
+
return active.tier;
|
|
748
|
+
}
|
|
749
|
+
if (superseded()) return "docked";
|
|
750
|
+
emit("tierchange", "docked");
|
|
751
|
+
return "docked";
|
|
752
|
+
},
|
|
753
|
+
close() {
|
|
754
|
+
generation++;
|
|
755
|
+
if (!active) return;
|
|
756
|
+
const closing = active;
|
|
757
|
+
active = null;
|
|
758
|
+
closing.dispose();
|
|
759
|
+
emit("tierchange", "docked");
|
|
760
|
+
emit("closed", "docked");
|
|
761
|
+
},
|
|
762
|
+
getPortalTarget() {
|
|
763
|
+
return active?.portalTarget ?? null;
|
|
764
|
+
},
|
|
765
|
+
getCanvasSink() {
|
|
766
|
+
return active?.canvasSink ?? null;
|
|
767
|
+
},
|
|
768
|
+
on(event, cb) {
|
|
769
|
+
listeners[event].add(cb);
|
|
770
|
+
return () => {
|
|
771
|
+
listeners[event].delete(cb);
|
|
772
|
+
};
|
|
773
|
+
},
|
|
774
|
+
dispose() {
|
|
775
|
+
manager.close();
|
|
776
|
+
disposed = true;
|
|
777
|
+
if (typeof window !== "undefined") {
|
|
778
|
+
window.removeEventListener("pagehide", openerPageHide);
|
|
779
|
+
}
|
|
780
|
+
listeners.closed.clear();
|
|
781
|
+
listeners.tierchange.clear();
|
|
782
|
+
}
|
|
783
|
+
};
|
|
784
|
+
return manager;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// src/teleprompter/useFloatingWindow.ts
|
|
788
|
+
import { useCallback as useCallback3, useEffect as useEffect3, useMemo as useMemo2, useRef as useRef3, useState as useState3 } from "react";
|
|
789
|
+
var FLOAT_WIDTH = 380;
|
|
790
|
+
var FLOAT_HEIGHT = 540;
|
|
791
|
+
function useFloatingWindow(styleCss) {
|
|
792
|
+
const managerRef = useRef3(null);
|
|
793
|
+
if (managerRef.current === null) {
|
|
794
|
+
managerRef.current = createFloatingWindowManager({ styleCss });
|
|
795
|
+
}
|
|
796
|
+
const manager = managerRef.current;
|
|
797
|
+
const [tier, setTier] = useState3("docked");
|
|
798
|
+
const supportedTiers = useMemo2(() => detectFloatTiers().filter((t) => t !== "docked"), []);
|
|
799
|
+
useEffect3(() => {
|
|
800
|
+
const offChange = manager.on("tierchange", setTier);
|
|
801
|
+
const offClosed = manager.on("closed", setTier);
|
|
802
|
+
return () => {
|
|
803
|
+
offChange();
|
|
804
|
+
offClosed();
|
|
805
|
+
manager.dispose();
|
|
806
|
+
};
|
|
807
|
+
}, [manager]);
|
|
808
|
+
const open = useCallback3(
|
|
809
|
+
async (preferredTier) => {
|
|
810
|
+
await manager.open({
|
|
811
|
+
width: FLOAT_WIDTH,
|
|
812
|
+
height: FLOAT_HEIGHT,
|
|
813
|
+
title: "Squisq Teleprompter",
|
|
814
|
+
...preferredTier !== void 0 ? { preferredTier } : {}
|
|
815
|
+
});
|
|
816
|
+
},
|
|
817
|
+
[manager]
|
|
818
|
+
);
|
|
819
|
+
const close = useCallback3(() => manager.close(), [manager]);
|
|
820
|
+
return {
|
|
821
|
+
tier,
|
|
822
|
+
isOpen: manager.isOpen,
|
|
823
|
+
supportedTiers,
|
|
824
|
+
portalTarget: manager.getPortalTarget(),
|
|
825
|
+
canvasSink: manager.getCanvasSink(),
|
|
826
|
+
open,
|
|
827
|
+
close
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
|
|
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
|
+
// src/teleprompter/teleprompterTheme.ts
|
|
872
|
+
import { resolveFontFamily } from "@bendyline/squisq/schemas";
|
|
873
|
+
function prompterVarsFromTheme(theme) {
|
|
874
|
+
const colors = theme.colors;
|
|
875
|
+
return {
|
|
876
|
+
"--squisq-prompter-bg": colors.background,
|
|
877
|
+
"--squisq-prompter-text": colors.text,
|
|
878
|
+
"--squisq-prompter-muted": colors.textMuted ?? colors.text,
|
|
879
|
+
"--squisq-prompter-accent": colors.primary,
|
|
880
|
+
"--squisq-prompter-font": resolveFontFamily(
|
|
881
|
+
theme.typography?.bodyFont,
|
|
882
|
+
"system-ui, sans-serif"
|
|
883
|
+
)
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
var TELEPROMPTER_STYLE_ATTR = "data-squisq-teleprompter";
|
|
887
|
+
function ensureTeleprompterStyles(doc) {
|
|
888
|
+
if (doc.querySelector(`style[${TELEPROMPTER_STYLE_ATTR}]`)) return;
|
|
889
|
+
const style = doc.createElement("style");
|
|
890
|
+
style.setAttribute(TELEPROMPTER_STYLE_ATTR, "");
|
|
891
|
+
style.textContent = TELEPROMPTER_CSS;
|
|
892
|
+
doc.head.appendChild(style);
|
|
893
|
+
}
|
|
894
|
+
var TELEPROMPTER_CSS = `
|
|
895
|
+
.squisq-teleprompter-root {
|
|
896
|
+
display: flex;
|
|
897
|
+
flex-direction: column;
|
|
898
|
+
width: 100%;
|
|
899
|
+
height: 100%;
|
|
900
|
+
min-height: 0;
|
|
901
|
+
background: var(--squisq-bg, #f5f5f5);
|
|
902
|
+
outline: none;
|
|
903
|
+
}
|
|
904
|
+
.squisq-teleprompter-stage {
|
|
905
|
+
position: relative;
|
|
906
|
+
flex: 1;
|
|
907
|
+
min-height: 0;
|
|
908
|
+
display: flex;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
.squisq-teleprompter-surface {
|
|
912
|
+
position: relative;
|
|
913
|
+
flex: 1;
|
|
914
|
+
min-height: 0;
|
|
915
|
+
overflow: hidden;
|
|
916
|
+
background: var(--squisq-prompter-bg, #101014);
|
|
917
|
+
color: var(--squisq-prompter-text, #f5f5f2);
|
|
918
|
+
font-family: var(--squisq-prompter-font, system-ui, sans-serif);
|
|
919
|
+
user-select: none;
|
|
920
|
+
}
|
|
921
|
+
.squisq-teleprompter-flip {
|
|
922
|
+
position: absolute;
|
|
923
|
+
inset: 0;
|
|
924
|
+
}
|
|
925
|
+
.squisq-teleprompter-surface--mirrored .squisq-teleprompter-flip {
|
|
926
|
+
transform: scaleX(-1);
|
|
927
|
+
}
|
|
928
|
+
.squisq-teleprompter-scroll {
|
|
929
|
+
position: absolute;
|
|
930
|
+
left: 0;
|
|
931
|
+
right: 0;
|
|
932
|
+
top: 0;
|
|
933
|
+
will-change: transform;
|
|
934
|
+
padding: 40vh 8% 60vh;
|
|
935
|
+
box-sizing: border-box;
|
|
936
|
+
}
|
|
937
|
+
.squisq-teleprompter-line-guide {
|
|
938
|
+
position: absolute;
|
|
939
|
+
left: 0;
|
|
940
|
+
right: 0;
|
|
941
|
+
pointer-events: none;
|
|
942
|
+
z-index: 2;
|
|
943
|
+
}
|
|
944
|
+
.squisq-teleprompter-line-guide::before,
|
|
945
|
+
.squisq-teleprompter-line-guide::after {
|
|
946
|
+
content: '';
|
|
947
|
+
position: absolute;
|
|
948
|
+
top: 50%;
|
|
949
|
+
border: 9px solid transparent;
|
|
950
|
+
transform: translateY(-50%);
|
|
951
|
+
}
|
|
952
|
+
.squisq-teleprompter-line-guide::before {
|
|
953
|
+
left: 6px;
|
|
954
|
+
border-left-color: var(--squisq-prompter-accent, #e8b64c);
|
|
955
|
+
}
|
|
956
|
+
.squisq-teleprompter-line-guide::after {
|
|
957
|
+
right: 6px;
|
|
958
|
+
border-right-color: var(--squisq-prompter-accent, #e8b64c);
|
|
959
|
+
}
|
|
960
|
+
.squisq-teleprompter-guide-band {
|
|
961
|
+
position: absolute;
|
|
962
|
+
left: 0;
|
|
963
|
+
right: 0;
|
|
964
|
+
background: color-mix(in srgb, var(--squisq-prompter-accent, #e8b64c) 9%, transparent);
|
|
965
|
+
pointer-events: none;
|
|
966
|
+
z-index: 1;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
.squisq-teleprompter-block-marker {
|
|
970
|
+
display: block;
|
|
971
|
+
margin: 1.1em 0 0.35em;
|
|
972
|
+
font-size: 0.38em;
|
|
973
|
+
font-weight: 600;
|
|
974
|
+
letter-spacing: 0.14em;
|
|
975
|
+
text-transform: uppercase;
|
|
976
|
+
color: var(--squisq-prompter-muted, #9a9aa0);
|
|
977
|
+
border-top: 1px solid color-mix(in srgb, var(--squisq-prompter-muted, #9a9aa0) 35%, transparent);
|
|
978
|
+
padding-top: 0.6em;
|
|
979
|
+
}
|
|
980
|
+
.squisq-teleprompter-para {
|
|
981
|
+
margin: 0 0 0.55em;
|
|
982
|
+
line-height: 1.4;
|
|
983
|
+
font-weight: 500;
|
|
984
|
+
}
|
|
985
|
+
.squisq-teleprompter-word {
|
|
986
|
+
opacity: 0.45;
|
|
987
|
+
transition: color 0.1s linear, opacity 0.1s linear;
|
|
988
|
+
}
|
|
989
|
+
.squisq-teleprompter-word--read {
|
|
990
|
+
opacity: 0.9;
|
|
991
|
+
}
|
|
992
|
+
.squisq-teleprompter-word--active {
|
|
993
|
+
opacity: 1;
|
|
994
|
+
font-weight: 800;
|
|
995
|
+
color: var(--squisq-prompter-accent, #e8b64c);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
.squisq-teleprompter-countdown {
|
|
999
|
+
position: absolute;
|
|
1000
|
+
inset: 0;
|
|
1001
|
+
display: flex;
|
|
1002
|
+
align-items: center;
|
|
1003
|
+
justify-content: center;
|
|
1004
|
+
z-index: 3;
|
|
1005
|
+
background: color-mix(in srgb, var(--squisq-prompter-bg, #101014) 72%, transparent);
|
|
1006
|
+
}
|
|
1007
|
+
.squisq-teleprompter-countdown-digit {
|
|
1008
|
+
font-size: 18vmin;
|
|
1009
|
+
font-weight: 800;
|
|
1010
|
+
color: var(--squisq-prompter-accent, #e8b64c);
|
|
1011
|
+
animation: squisq-prompter-pulse 1s ease-in-out infinite;
|
|
1012
|
+
}
|
|
1013
|
+
.squisq-teleprompter-recdot {
|
|
1014
|
+
position: absolute;
|
|
1015
|
+
top: 14px;
|
|
1016
|
+
right: 16px;
|
|
1017
|
+
z-index: 4;
|
|
1018
|
+
width: 14px;
|
|
1019
|
+
height: 14px;
|
|
1020
|
+
border-radius: 50%;
|
|
1021
|
+
background: #e5484d;
|
|
1022
|
+
box-shadow: 0 0 0 3px color-mix(in srgb, #e5484d 30%, transparent);
|
|
1023
|
+
animation: squisq-prompter-pulse 1.4s ease-in-out infinite;
|
|
1024
|
+
}
|
|
1025
|
+
/* Counter-flip overlays so they stay readable in mirror mode. */
|
|
1026
|
+
.squisq-teleprompter-surface--mirrored .squisq-teleprompter-countdown,
|
|
1027
|
+
.squisq-teleprompter-surface--mirrored .squisq-teleprompter-recdot {
|
|
1028
|
+
transform: scaleX(-1);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
.squisq-teleprompter-selfview {
|
|
1032
|
+
position: absolute;
|
|
1033
|
+
right: 14px;
|
|
1034
|
+
bottom: 14px;
|
|
1035
|
+
width: 160px;
|
|
1036
|
+
border-radius: 8px;
|
|
1037
|
+
border: 2px solid color-mix(in srgb, #e5484d 60%, transparent);
|
|
1038
|
+
z-index: 5;
|
|
1039
|
+
background: #000;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
.squisq-teleprompter-review {
|
|
1043
|
+
display: flex;
|
|
1044
|
+
align-items: center;
|
|
1045
|
+
flex-wrap: wrap;
|
|
1046
|
+
gap: 10px;
|
|
1047
|
+
padding: 8px 12px;
|
|
1048
|
+
border-top: 1px solid var(--squisq-border, #e5e7eb);
|
|
1049
|
+
background: var(--squisq-surface, var(--squisq-bg, #fff));
|
|
1050
|
+
color: var(--squisq-text, #111827);
|
|
1051
|
+
font-size: 12.5px;
|
|
1052
|
+
}
|
|
1053
|
+
.squisq-teleprompter-review audio {
|
|
1054
|
+
height: 28px;
|
|
1055
|
+
max-width: 260px;
|
|
1056
|
+
}
|
|
1057
|
+
.squisq-teleprompter-review button {
|
|
1058
|
+
font: inherit;
|
|
1059
|
+
color: inherit;
|
|
1060
|
+
background: var(--squisq-input-bg, #fff);
|
|
1061
|
+
border: 1px solid var(--squisq-border, #d1d5db);
|
|
1062
|
+
border-radius: 6px;
|
|
1063
|
+
padding: 4px 10px;
|
|
1064
|
+
cursor: pointer;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
.squisq-teleprompter-float-note {
|
|
1068
|
+
flex: 1;
|
|
1069
|
+
display: flex;
|
|
1070
|
+
flex-direction: column;
|
|
1071
|
+
align-items: center;
|
|
1072
|
+
justify-content: center;
|
|
1073
|
+
gap: 12px;
|
|
1074
|
+
color: var(--squisq-text-muted, #6b7280);
|
|
1075
|
+
font-size: 14px;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
.squisq-teleprompter-controls {
|
|
1079
|
+
display: flex;
|
|
1080
|
+
align-items: center;
|
|
1081
|
+
flex-wrap: wrap;
|
|
1082
|
+
gap: 10px;
|
|
1083
|
+
padding: 8px 12px;
|
|
1084
|
+
border-top: 1px solid var(--squisq-border, #e5e7eb);
|
|
1085
|
+
background: var(--squisq-surface, var(--squisq-bg, #fff));
|
|
1086
|
+
color: var(--squisq-text, #111827);
|
|
1087
|
+
font-size: 12.5px;
|
|
1088
|
+
}
|
|
1089
|
+
.squisq-teleprompter-controls .squisq-teleprompter-group {
|
|
1090
|
+
display: inline-flex;
|
|
1091
|
+
align-items: center;
|
|
1092
|
+
gap: 6px;
|
|
1093
|
+
white-space: nowrap;
|
|
1094
|
+
}
|
|
1095
|
+
.squisq-teleprompter-controls button {
|
|
1096
|
+
font: inherit;
|
|
1097
|
+
color: inherit;
|
|
1098
|
+
background: var(--squisq-input-bg, #fff);
|
|
1099
|
+
border: 1px solid var(--squisq-border, #d1d5db);
|
|
1100
|
+
border-radius: 6px;
|
|
1101
|
+
padding: 4px 10px;
|
|
1102
|
+
cursor: pointer;
|
|
1103
|
+
}
|
|
1104
|
+
.squisq-teleprompter-controls button:hover {
|
|
1105
|
+
background: var(--squisq-surface-hover, #f3f4f6);
|
|
1106
|
+
}
|
|
1107
|
+
.squisq-teleprompter-controls button[aria-pressed='true'] {
|
|
1108
|
+
background: var(--squisq-text, #111827);
|
|
1109
|
+
color: var(--squisq-bg, #fff);
|
|
1110
|
+
border-color: var(--squisq-text, #111827);
|
|
1111
|
+
}
|
|
1112
|
+
.squisq-teleprompter-controls select,
|
|
1113
|
+
.squisq-teleprompter-controls input[type='range'] {
|
|
1114
|
+
font: inherit;
|
|
1115
|
+
color: inherit;
|
|
1116
|
+
background: var(--squisq-input-bg, #fff);
|
|
1117
|
+
border: 1px solid var(--squisq-border, #d1d5db);
|
|
1118
|
+
border-radius: 6px;
|
|
1119
|
+
max-width: 170px;
|
|
1120
|
+
}
|
|
1121
|
+
.squisq-teleprompter-controls input[type='range'] {
|
|
1122
|
+
border: none;
|
|
1123
|
+
background: transparent;
|
|
1124
|
+
width: 110px;
|
|
1125
|
+
}
|
|
1126
|
+
.squisq-teleprompter-meter {
|
|
1127
|
+
position: relative;
|
|
1128
|
+
width: 64px;
|
|
1129
|
+
height: 8px;
|
|
1130
|
+
border-radius: 4px;
|
|
1131
|
+
overflow: hidden;
|
|
1132
|
+
background: var(--squisq-border, #e5e7eb);
|
|
1133
|
+
}
|
|
1134
|
+
.squisq-teleprompter-meter-fill {
|
|
1135
|
+
position: absolute;
|
|
1136
|
+
inset: 0 auto 0 0;
|
|
1137
|
+
background: #9ca3af;
|
|
1138
|
+
transition: width 0.08s linear;
|
|
1139
|
+
}
|
|
1140
|
+
.squisq-teleprompter-meter--voice .squisq-teleprompter-meter-fill {
|
|
1141
|
+
background: #30a46c;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
@keyframes squisq-prompter-pulse {
|
|
1145
|
+
0%, 100% { opacity: 1; }
|
|
1146
|
+
50% { opacity: 0.45; }
|
|
1147
|
+
}
|
|
1148
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1149
|
+
.squisq-teleprompter-countdown-digit,
|
|
1150
|
+
.squisq-teleprompter-recdot {
|
|
1151
|
+
animation: none;
|
|
1152
|
+
}
|
|
1153
|
+
.squisq-teleprompter-word {
|
|
1154
|
+
transition: none;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
`;
|
|
1158
|
+
|
|
1159
|
+
// src/teleprompter/TeleprompterSurface.tsx
|
|
1160
|
+
import { memo, useEffect as useEffect4, useMemo as useMemo3, useRef as useRef4 } from "react";
|
|
1161
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
1162
|
+
function groupScript(script) {
|
|
1163
|
+
return script.blocks.map((range) => {
|
|
1164
|
+
const paragraphs = [];
|
|
1165
|
+
let current = [];
|
|
1166
|
+
for (let i = range.tokenStart; i < range.tokenEnd; i++) {
|
|
1167
|
+
current.push(i);
|
|
1168
|
+
if (script.tokens[i].pauseAfter >= 2 && i < range.tokenEnd - 1) {
|
|
1169
|
+
paragraphs.push({ key: `${range.blockId}-${paragraphs.length}`, tokenIndexes: current });
|
|
1170
|
+
current = [];
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
if (current.length > 0) {
|
|
1174
|
+
paragraphs.push({ key: `${range.blockId}-${paragraphs.length}`, tokenIndexes: current });
|
|
1175
|
+
}
|
|
1176
|
+
const group = { blockId: range.blockId, paragraphs };
|
|
1177
|
+
if (range.heading !== void 0) group.heading = range.heading;
|
|
1178
|
+
return group;
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
var ScriptColumn = memo(function ScriptColumn2({
|
|
1182
|
+
script,
|
|
1183
|
+
compact
|
|
1184
|
+
}) {
|
|
1185
|
+
const groups = useMemo3(() => groupScript(script), [script]);
|
|
1186
|
+
return /* @__PURE__ */ jsx(Fragment, { children: groups.map((group) => /* @__PURE__ */ jsxs("section", { "data-block-id": group.blockId, children: [
|
|
1187
|
+
!compact && group.heading ? /* @__PURE__ */ jsx("span", { className: "squisq-teleprompter-block-marker", children: group.heading }) : null,
|
|
1188
|
+
group.paragraphs.map((paragraph) => /* @__PURE__ */ jsx("p", { className: "squisq-teleprompter-para", children: paragraph.tokenIndexes.map((idx) => /* @__PURE__ */ jsxs("span", { className: "squisq-teleprompter-word", "data-token-idx": idx, children: [
|
|
1189
|
+
script.tokens[idx].text,
|
|
1190
|
+
" "
|
|
1191
|
+
] }, idx)) }, paragraph.key))
|
|
1192
|
+
] }, group.blockId)) });
|
|
1193
|
+
});
|
|
1194
|
+
function TeleprompterSurface({
|
|
1195
|
+
script,
|
|
1196
|
+
wordPos,
|
|
1197
|
+
fontSizePx,
|
|
1198
|
+
mirrored,
|
|
1199
|
+
lineGuide,
|
|
1200
|
+
countdownRemaining,
|
|
1201
|
+
recordingIndicator,
|
|
1202
|
+
theme,
|
|
1203
|
+
compact = false,
|
|
1204
|
+
onSeekToken
|
|
1205
|
+
}) {
|
|
1206
|
+
const surfaceRef = useRef4(null);
|
|
1207
|
+
const columnRef = useRef4(null);
|
|
1208
|
+
const wordPosRef = useRef4(wordPos);
|
|
1209
|
+
wordPosRef.current = wordPos;
|
|
1210
|
+
const vars = useMemo3(() => prompterVarsFromTheme(theme), [theme]);
|
|
1211
|
+
useEffect4(() => {
|
|
1212
|
+
const doc = surfaceRef.current?.ownerDocument;
|
|
1213
|
+
if (doc) ensureTeleprompterStyles(doc);
|
|
1214
|
+
}, []);
|
|
1215
|
+
useEffect4(() => {
|
|
1216
|
+
const surface = surfaceRef.current;
|
|
1217
|
+
const column = columnRef.current;
|
|
1218
|
+
if (!surface || !column) return;
|
|
1219
|
+
const win = surface.ownerDocument.defaultView ?? window;
|
|
1220
|
+
let lines = null;
|
|
1221
|
+
let spans = [];
|
|
1222
|
+
let activeIdx = -1;
|
|
1223
|
+
let offset = 0;
|
|
1224
|
+
let lastTime = performance.now();
|
|
1225
|
+
let raf = 0;
|
|
1226
|
+
const tokens = script.tokens;
|
|
1227
|
+
const spokenAt = new Int32Array(tokens.length);
|
|
1228
|
+
let lastSpoken = -1;
|
|
1229
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
1230
|
+
if (tokens[i].spoken) lastSpoken = i;
|
|
1231
|
+
spokenAt[i] = lastSpoken;
|
|
1232
|
+
}
|
|
1233
|
+
let firstSpoken = -1;
|
|
1234
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
1235
|
+
if (tokens[i].spoken) {
|
|
1236
|
+
firstSpoken = i;
|
|
1237
|
+
break;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
for (let i = 0; i < tokens.length && spokenAt[i] === -1; i++) spokenAt[i] = firstSpoken;
|
|
1241
|
+
const remeasure = () => {
|
|
1242
|
+
lines = measureTokenLines(column);
|
|
1243
|
+
spans = Array.from(column.querySelectorAll("[data-token-idx]"));
|
|
1244
|
+
};
|
|
1245
|
+
remeasure();
|
|
1246
|
+
const applyHighlight = (nextIdx) => {
|
|
1247
|
+
if (nextIdx === activeIdx) return;
|
|
1248
|
+
const lo = Math.min(activeIdx, nextIdx);
|
|
1249
|
+
const hi = Math.max(activeIdx, nextIdx);
|
|
1250
|
+
for (let i = Math.max(0, lo); i <= hi && i < spans.length; i++) {
|
|
1251
|
+
const span = spans[i];
|
|
1252
|
+
if (!span) continue;
|
|
1253
|
+
span.classList.toggle("squisq-teleprompter-word--active", i === nextIdx);
|
|
1254
|
+
span.classList.toggle("squisq-teleprompter-word--read", i < nextIdx);
|
|
1255
|
+
}
|
|
1256
|
+
activeIdx = nextIdx;
|
|
1257
|
+
};
|
|
1258
|
+
const loop = (now) => {
|
|
1259
|
+
const dtMs = now - lastTime;
|
|
1260
|
+
lastTime = now;
|
|
1261
|
+
const pos = wordPosRef.current;
|
|
1262
|
+
const clampedIdx = Math.min(Math.max(Math.floor(pos), 0), spans.length - 1);
|
|
1263
|
+
const highlightIdx = clampedIdx < spokenAt.length && spokenAt[clampedIdx] >= 0 ? spokenAt[clampedIdx] : clampedIdx;
|
|
1264
|
+
if (spans.length > 0) applyHighlight(highlightIdx);
|
|
1265
|
+
if (lines) {
|
|
1266
|
+
const target = targetOffsetFor(pos, lines, surface.clientHeight);
|
|
1267
|
+
offset = stepScroll(offset, target, dtMs);
|
|
1268
|
+
column.style.transform = `translateY(${-offset}px)`;
|
|
1269
|
+
}
|
|
1270
|
+
raf = win.requestAnimationFrame(loop);
|
|
1271
|
+
};
|
|
1272
|
+
raf = win.requestAnimationFrame(loop);
|
|
1273
|
+
const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(remeasure) : null;
|
|
1274
|
+
resizeObserver?.observe(surface);
|
|
1275
|
+
return () => {
|
|
1276
|
+
win.cancelAnimationFrame(raf);
|
|
1277
|
+
resizeObserver?.disconnect();
|
|
1278
|
+
};
|
|
1279
|
+
}, [script, fontSizePx, compact]);
|
|
1280
|
+
const handleClick = onSeekToken ? (event) => {
|
|
1281
|
+
const target = event.target;
|
|
1282
|
+
const span = target?.closest("[data-token-idx]");
|
|
1283
|
+
if (!span) return;
|
|
1284
|
+
const idx = Number(span.dataset.tokenIdx);
|
|
1285
|
+
if (Number.isFinite(idx)) onSeekToken(idx);
|
|
1286
|
+
} : void 0;
|
|
1287
|
+
return /* @__PURE__ */ jsxs(
|
|
1288
|
+
"div",
|
|
1289
|
+
{
|
|
1290
|
+
ref: surfaceRef,
|
|
1291
|
+
className: `squisq-teleprompter-surface${mirrored ? " squisq-teleprompter-surface--mirrored" : ""}`,
|
|
1292
|
+
style: { ...vars, fontSize: `${fontSizePx}px` },
|
|
1293
|
+
"data-testid": "teleprompter-surface",
|
|
1294
|
+
children: [
|
|
1295
|
+
/* @__PURE__ */ jsx("div", { className: "squisq-teleprompter-flip", children: /* @__PURE__ */ jsx(
|
|
1296
|
+
"div",
|
|
1297
|
+
{
|
|
1298
|
+
ref: columnRef,
|
|
1299
|
+
className: "squisq-teleprompter-scroll",
|
|
1300
|
+
style: compact ? { padding: "30vh 5% 60vh" } : void 0,
|
|
1301
|
+
onClick: handleClick,
|
|
1302
|
+
children: /* @__PURE__ */ jsx(ScriptColumn, { script, compact })
|
|
1303
|
+
}
|
|
1304
|
+
) }),
|
|
1305
|
+
lineGuide ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1306
|
+
/* @__PURE__ */ jsx(
|
|
1307
|
+
"div",
|
|
1308
|
+
{
|
|
1309
|
+
className: "squisq-teleprompter-guide-band",
|
|
1310
|
+
style: { top: `calc(${EYE_LINE_FRACTION * 100}% - 0.75em)`, height: "1.5em" }
|
|
1311
|
+
}
|
|
1312
|
+
),
|
|
1313
|
+
/* @__PURE__ */ jsx(
|
|
1314
|
+
"div",
|
|
1315
|
+
{
|
|
1316
|
+
className: "squisq-teleprompter-line-guide",
|
|
1317
|
+
style: { top: `calc(${EYE_LINE_FRACTION * 100}% - 0.75em)`, height: "1.5em" }
|
|
1318
|
+
}
|
|
1319
|
+
)
|
|
1320
|
+
] }) : null,
|
|
1321
|
+
countdownRemaining !== null ? /* @__PURE__ */ jsx("div", { className: "squisq-teleprompter-countdown", children: /* @__PURE__ */ jsx("span", { className: "squisq-teleprompter-countdown-digit", children: countdownRemaining }) }) : null,
|
|
1322
|
+
recordingIndicator ? /* @__PURE__ */ jsx("div", { className: "squisq-teleprompter-recdot" }) : null
|
|
1323
|
+
]
|
|
1324
|
+
}
|
|
1325
|
+
);
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// src/teleprompter/TeleprompterControls.tsx
|
|
1329
|
+
import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
1330
|
+
var TIER_LABELS = {
|
|
1331
|
+
"document-pip": "Floating window (always on top)",
|
|
1332
|
+
"video-pip": "Picture-in-picture (read-only)",
|
|
1333
|
+
popup: "Popup window",
|
|
1334
|
+
docked: "Docked"
|
|
1335
|
+
};
|
|
1336
|
+
function TeleprompterControls({ controller, float, recordSlot }) {
|
|
1337
|
+
const { transport, prefs, setPrefs, mic } = controller;
|
|
1338
|
+
const rolling = transport === "rolling" || transport === "countdown";
|
|
1339
|
+
const voiceLive = prefs.voiceTracking && mic.status === "live";
|
|
1340
|
+
return /* @__PURE__ */ jsxs2(
|
|
1341
|
+
"div",
|
|
1342
|
+
{
|
|
1343
|
+
className: "squisq-teleprompter-controls",
|
|
1344
|
+
"data-testid": "teleprompter-controls",
|
|
1345
|
+
"data-mic-status": mic.status,
|
|
1346
|
+
"data-transport": transport,
|
|
1347
|
+
"data-voice-live": voiceLive || void 0,
|
|
1348
|
+
children: [
|
|
1349
|
+
/* @__PURE__ */ jsxs2("span", { className: "squisq-teleprompter-group", children: [
|
|
1350
|
+
/* @__PURE__ */ jsx2(
|
|
1351
|
+
"button",
|
|
1352
|
+
{
|
|
1353
|
+
type: "button",
|
|
1354
|
+
onClick: () => rolling ? controller.pause() : controller.play(),
|
|
1355
|
+
"aria-label": rolling ? "Pause prompter" : "Start prompter",
|
|
1356
|
+
children: rolling ? "\u23F8 Pause" : transport === "paused" ? "\u25B6 Resume" : "\u25B6 Start"
|
|
1357
|
+
}
|
|
1358
|
+
),
|
|
1359
|
+
/* @__PURE__ */ jsx2("button", { type: "button", onClick: controller.restart, "aria-label": "Restart prompter", children: "\u27F2 Restart" })
|
|
1360
|
+
] }),
|
|
1361
|
+
/* @__PURE__ */ jsxs2("span", { className: "squisq-teleprompter-group", children: [
|
|
1362
|
+
/* @__PURE__ */ jsx2("label", { htmlFor: "squisq-prompter-countdown", children: "Countdown" }),
|
|
1363
|
+
/* @__PURE__ */ jsxs2(
|
|
1364
|
+
"select",
|
|
1365
|
+
{
|
|
1366
|
+
id: "squisq-prompter-countdown",
|
|
1367
|
+
value: prefs.countdownSec,
|
|
1368
|
+
onChange: (e) => setPrefs({ countdownSec: Number(e.target.value) }),
|
|
1369
|
+
children: [
|
|
1370
|
+
/* @__PURE__ */ jsx2("option", { value: 0, children: "Off" }),
|
|
1371
|
+
/* @__PURE__ */ jsx2("option", { value: 3, children: "3s" }),
|
|
1372
|
+
/* @__PURE__ */ jsx2("option", { value: 5, children: "5s" }),
|
|
1373
|
+
/* @__PURE__ */ jsx2("option", { value: 10, children: "10s" })
|
|
1374
|
+
]
|
|
1375
|
+
}
|
|
1376
|
+
)
|
|
1377
|
+
] }),
|
|
1378
|
+
/* @__PURE__ */ jsxs2("span", { className: "squisq-teleprompter-group", children: [
|
|
1379
|
+
/* @__PURE__ */ jsx2("label", { htmlFor: "squisq-prompter-wpm", children: "Speed" }),
|
|
1380
|
+
/* @__PURE__ */ jsx2(
|
|
1381
|
+
"input",
|
|
1382
|
+
{
|
|
1383
|
+
id: "squisq-prompter-wpm",
|
|
1384
|
+
type: "range",
|
|
1385
|
+
min: 80,
|
|
1386
|
+
max: 260,
|
|
1387
|
+
step: 5,
|
|
1388
|
+
value: prefs.baseWpm,
|
|
1389
|
+
onChange: (e) => setPrefs({ baseWpm: Number(e.target.value) })
|
|
1390
|
+
}
|
|
1391
|
+
),
|
|
1392
|
+
/* @__PURE__ */ jsxs2("span", { "aria-live": "off", children: [
|
|
1393
|
+
prefs.baseWpm,
|
|
1394
|
+
" wpm"
|
|
1395
|
+
] })
|
|
1396
|
+
] }),
|
|
1397
|
+
/* @__PURE__ */ jsxs2("span", { className: "squisq-teleprompter-group", children: [
|
|
1398
|
+
/* @__PURE__ */ jsx2(
|
|
1399
|
+
"button",
|
|
1400
|
+
{
|
|
1401
|
+
type: "button",
|
|
1402
|
+
"aria-pressed": prefs.voiceTracking,
|
|
1403
|
+
onClick: () => {
|
|
1404
|
+
const next = !prefs.voiceTracking;
|
|
1405
|
+
setPrefs({ voiceTracking: next });
|
|
1406
|
+
if (next && mic.status === "idle") void mic.start(prefs.micDeviceId);
|
|
1407
|
+
if (!next && mic.status !== "idle") mic.stop();
|
|
1408
|
+
},
|
|
1409
|
+
title: "Match the prompter speed to your voice (halts when you stop speaking)",
|
|
1410
|
+
children: "\u{1F399} Voice pace"
|
|
1411
|
+
}
|
|
1412
|
+
),
|
|
1413
|
+
prefs.voiceTracking ? /* @__PURE__ */ jsxs2(Fragment2, { children: [
|
|
1414
|
+
/* @__PURE__ */ jsx2("label", { htmlFor: "squisq-prompter-sensitivity", title: "Voice detection sensitivity", children: "Sens." }),
|
|
1415
|
+
/* @__PURE__ */ jsx2(
|
|
1416
|
+
"input",
|
|
1417
|
+
{
|
|
1418
|
+
id: "squisq-prompter-sensitivity",
|
|
1419
|
+
type: "range",
|
|
1420
|
+
min: 0,
|
|
1421
|
+
max: 1,
|
|
1422
|
+
step: 0.05,
|
|
1423
|
+
value: prefs.vadSensitivity,
|
|
1424
|
+
onChange: (e) => setPrefs({ vadSensitivity: Number(e.target.value) }),
|
|
1425
|
+
style: { width: 70 }
|
|
1426
|
+
}
|
|
1427
|
+
),
|
|
1428
|
+
/* @__PURE__ */ jsxs2(
|
|
1429
|
+
"select",
|
|
1430
|
+
{
|
|
1431
|
+
"aria-label": "Microphone",
|
|
1432
|
+
value: prefs.micDeviceId ?? "",
|
|
1433
|
+
onChange: (e) => setPrefs({ micDeviceId: e.target.value || null }),
|
|
1434
|
+
children: [
|
|
1435
|
+
/* @__PURE__ */ jsx2("option", { value: "", children: "Default mic" }),
|
|
1436
|
+
mic.devices.map((device) => /* @__PURE__ */ jsx2("option", { value: device.deviceId, children: device.label || `Mic ${device.deviceId.slice(0, 6)}` }, device.deviceId))
|
|
1437
|
+
]
|
|
1438
|
+
}
|
|
1439
|
+
),
|
|
1440
|
+
/* @__PURE__ */ jsx2(
|
|
1441
|
+
"span",
|
|
1442
|
+
{
|
|
1443
|
+
className: `squisq-teleprompter-meter${controller.voiceActive ? " squisq-teleprompter-meter--voice" : ""}`,
|
|
1444
|
+
role: "meter",
|
|
1445
|
+
"aria-label": "Mic level",
|
|
1446
|
+
"aria-valuemin": 0,
|
|
1447
|
+
"aria-valuemax": 1,
|
|
1448
|
+
"aria-valuenow": Math.round(controller.micLevel * 100) / 100,
|
|
1449
|
+
children: /* @__PURE__ */ jsx2(
|
|
1450
|
+
"span",
|
|
1451
|
+
{
|
|
1452
|
+
className: "squisq-teleprompter-meter-fill",
|
|
1453
|
+
style: { width: `${Math.round(controller.micLevel * 100)}%` }
|
|
1454
|
+
}
|
|
1455
|
+
)
|
|
1456
|
+
}
|
|
1457
|
+
),
|
|
1458
|
+
mic.status === "error" ? /* @__PURE__ */ jsx2("span", { title: mic.error?.message ?? "Microphone unavailable", children: "\u26A0 mic unavailable \u2014 constant speed" }) : null
|
|
1459
|
+
] }) : null
|
|
1460
|
+
] }),
|
|
1461
|
+
/* @__PURE__ */ jsxs2("span", { className: "squisq-teleprompter-group", children: [
|
|
1462
|
+
/* @__PURE__ */ jsx2("label", { htmlFor: "squisq-prompter-fontsize", children: "Aa" }),
|
|
1463
|
+
/* @__PURE__ */ jsx2(
|
|
1464
|
+
"input",
|
|
1465
|
+
{
|
|
1466
|
+
id: "squisq-prompter-fontsize",
|
|
1467
|
+
type: "range",
|
|
1468
|
+
min: 28,
|
|
1469
|
+
max: 96,
|
|
1470
|
+
step: 2,
|
|
1471
|
+
value: prefs.fontSizePx,
|
|
1472
|
+
onChange: (e) => setPrefs({ fontSizePx: Number(e.target.value) }),
|
|
1473
|
+
style: { width: 80 },
|
|
1474
|
+
"aria-label": "Prompter font size"
|
|
1475
|
+
}
|
|
1476
|
+
),
|
|
1477
|
+
/* @__PURE__ */ jsx2(
|
|
1478
|
+
"button",
|
|
1479
|
+
{
|
|
1480
|
+
type: "button",
|
|
1481
|
+
"aria-pressed": prefs.mirrored,
|
|
1482
|
+
onClick: () => setPrefs({ mirrored: !prefs.mirrored }),
|
|
1483
|
+
title: "Mirror for beam-splitter teleprompter rigs (M)",
|
|
1484
|
+
children: "\u21CB Mirror"
|
|
1485
|
+
}
|
|
1486
|
+
),
|
|
1487
|
+
/* @__PURE__ */ jsx2(
|
|
1488
|
+
"button",
|
|
1489
|
+
{
|
|
1490
|
+
type: "button",
|
|
1491
|
+
"aria-pressed": prefs.lineGuide,
|
|
1492
|
+
onClick: () => setPrefs({ lineGuide: !prefs.lineGuide }),
|
|
1493
|
+
title: "Eye-line guide",
|
|
1494
|
+
children: "\u25B8 Guide"
|
|
1495
|
+
}
|
|
1496
|
+
)
|
|
1497
|
+
] }),
|
|
1498
|
+
recordSlot,
|
|
1499
|
+
float.supportedTiers.length > 0 ? /* @__PURE__ */ jsx2("span", { className: "squisq-teleprompter-group", style: { marginLeft: "auto" }, children: float.isOpen ? /* @__PURE__ */ jsx2("button", { type: "button", onClick: float.close, children: "\u21E4 Bring back" }) : /* @__PURE__ */ jsxs2(Fragment2, { children: [
|
|
1500
|
+
float.supportedTiers.length > 1 ? /* @__PURE__ */ jsx2(
|
|
1501
|
+
"select",
|
|
1502
|
+
{
|
|
1503
|
+
"aria-label": "Float mode",
|
|
1504
|
+
"data-testid": "teleprompter-float-tier",
|
|
1505
|
+
defaultValue: float.supportedTiers[0],
|
|
1506
|
+
id: "squisq-prompter-float-tier",
|
|
1507
|
+
children: float.supportedTiers.map((tier) => /* @__PURE__ */ jsx2("option", { value: tier, children: TIER_LABELS[tier] }, tier))
|
|
1508
|
+
}
|
|
1509
|
+
) : null,
|
|
1510
|
+
/* @__PURE__ */ jsx2(
|
|
1511
|
+
"button",
|
|
1512
|
+
{
|
|
1513
|
+
type: "button",
|
|
1514
|
+
onClick: () => {
|
|
1515
|
+
const select = document.getElementById(
|
|
1516
|
+
"squisq-prompter-float-tier"
|
|
1517
|
+
);
|
|
1518
|
+
const preferred = select?.value ?? void 0;
|
|
1519
|
+
void float.open(preferred);
|
|
1520
|
+
},
|
|
1521
|
+
title: "Pop the prompter out so it can sit next to your camera",
|
|
1522
|
+
children: "\u21F1 Pop out"
|
|
1523
|
+
}
|
|
1524
|
+
)
|
|
1525
|
+
] }) }) : null
|
|
1526
|
+
]
|
|
1527
|
+
}
|
|
1528
|
+
);
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
// src/teleprompter/recording/useNarrationRecorder.ts
|
|
1532
|
+
import { useCallback as useCallback4, useEffect as useEffect5, useRef as useRef5, useState as useState4 } from "react";
|
|
1533
|
+
import {
|
|
1534
|
+
alignNarration
|
|
1535
|
+
} from "@bendyline/squisq/narration";
|
|
1536
|
+
var TRACE_INTERVAL_MS = 250;
|
|
1537
|
+
function mixdownToMono(buffer) {
|
|
1538
|
+
const channels = buffer.numberOfChannels;
|
|
1539
|
+
if (channels === 1) return buffer.getChannelData(0).slice();
|
|
1540
|
+
const out = new Float32Array(buffer.length);
|
|
1541
|
+
for (let c = 0; c < channels; c++) {
|
|
1542
|
+
const data = buffer.getChannelData(c);
|
|
1543
|
+
for (let i = 0; i < out.length; i++) out[i] += data[i] / channels;
|
|
1544
|
+
}
|
|
1545
|
+
return out;
|
|
1546
|
+
}
|
|
1547
|
+
var StartAborted = class extends Error {
|
|
1548
|
+
constructor() {
|
|
1549
|
+
super("Narration start aborted");
|
|
1550
|
+
this.name = "StartAborted";
|
|
1551
|
+
}
|
|
1552
|
+
};
|
|
1553
|
+
function stopRecorder(recorder) {
|
|
1554
|
+
if (recorder.state === "inactive") return Promise.resolve();
|
|
1555
|
+
return new Promise((resolve) => {
|
|
1556
|
+
const done = () => resolve();
|
|
1557
|
+
recorder.addEventListener("stop", done, { once: true });
|
|
1558
|
+
try {
|
|
1559
|
+
recorder.stop();
|
|
1560
|
+
} catch {
|
|
1561
|
+
recorder.removeEventListener("stop", done);
|
|
1562
|
+
resolve();
|
|
1563
|
+
}
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
function useNarrationRecorder(options) {
|
|
1567
|
+
const [state, setState] = useState4("idle");
|
|
1568
|
+
const [error, setError] = useState4(null);
|
|
1569
|
+
const [withCamera, setWithCamera] = useState4(false);
|
|
1570
|
+
const [cameraStream, setCameraStream] = useState4(null);
|
|
1571
|
+
const [take, setTake] = useState4(null);
|
|
1572
|
+
const captureRef = useRef5(null);
|
|
1573
|
+
const processingRef = useRef5(false);
|
|
1574
|
+
const takeRef = useRef5(null);
|
|
1575
|
+
const unmountedRef = useRef5(false);
|
|
1576
|
+
const generationRef = useRef5(0);
|
|
1577
|
+
const startingRef = useRef5(false);
|
|
1578
|
+
const optionsRef = useRef5(options);
|
|
1579
|
+
optionsRef.current = options;
|
|
1580
|
+
const cancelPendingStart = useCallback4(() => {
|
|
1581
|
+
generationRef.current++;
|
|
1582
|
+
}, []);
|
|
1583
|
+
const applyTake = useCallback4((next) => {
|
|
1584
|
+
takeRef.current = next;
|
|
1585
|
+
setTake(next);
|
|
1586
|
+
}, []);
|
|
1587
|
+
const teardownCapture = useCallback4(() => {
|
|
1588
|
+
cancelPendingStart();
|
|
1589
|
+
const capture = captureRef.current;
|
|
1590
|
+
captureRef.current = null;
|
|
1591
|
+
if (!capture) return;
|
|
1592
|
+
if (capture.traceTimer !== null) clearInterval(capture.traceTimer);
|
|
1593
|
+
if (capture.audioRecorder.state !== "inactive") {
|
|
1594
|
+
try {
|
|
1595
|
+
capture.audioRecorder.stop();
|
|
1596
|
+
} catch {
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
if (capture.cameraRecorder && capture.cameraRecorder.state !== "inactive") {
|
|
1600
|
+
try {
|
|
1601
|
+
capture.cameraRecorder.stop();
|
|
1602
|
+
} catch {
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
for (const track of capture.cameraStream?.getTracks() ?? []) track.stop();
|
|
1606
|
+
setCameraStream(null);
|
|
1607
|
+
}, [cancelPendingStart]);
|
|
1608
|
+
const start = useCallback4(async () => {
|
|
1609
|
+
if (captureRef.current || startingRef.current) return;
|
|
1610
|
+
const opts = optionsRef.current;
|
|
1611
|
+
const generation = ++generationRef.current;
|
|
1612
|
+
const superseded = () => generationRef.current !== generation;
|
|
1613
|
+
startingRef.current = true;
|
|
1614
|
+
setError(null);
|
|
1615
|
+
applyTake(null);
|
|
1616
|
+
setState("starting");
|
|
1617
|
+
let camera = null;
|
|
1618
|
+
let audioRecorder = null;
|
|
1619
|
+
let cameraRecorder = null;
|
|
1620
|
+
let traceTimer = null;
|
|
1621
|
+
const releaseStartupMedia = () => {
|
|
1622
|
+
if (traceTimer !== null) clearInterval(traceTimer);
|
|
1623
|
+
for (const recorder of [audioRecorder, cameraRecorder]) {
|
|
1624
|
+
if (recorder && recorder.state !== "inactive") {
|
|
1625
|
+
try {
|
|
1626
|
+
recorder.stop();
|
|
1627
|
+
} catch {
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
for (const track of camera?.getTracks() ?? []) track.stop();
|
|
1632
|
+
};
|
|
1633
|
+
try {
|
|
1634
|
+
const micStream = opts.mic.status === "live" && opts.mic.stream ? opts.mic.stream : await opts.mic.start(opts.getMicDeviceId());
|
|
1635
|
+
if (superseded()) throw new StartAborted();
|
|
1636
|
+
if (!micStream) throw opts.mic.error ?? new Error("Microphone unavailable");
|
|
1637
|
+
const audioFormat = resolveFormat("audio");
|
|
1638
|
+
audioRecorder = new MediaRecorder(
|
|
1639
|
+
micStream,
|
|
1640
|
+
audioFormat.mimeType ? { mimeType: audioFormat.mimeType } : void 0
|
|
1641
|
+
);
|
|
1642
|
+
let cameraMime = null;
|
|
1643
|
+
let cameraExt = null;
|
|
1644
|
+
if (withCamera) {
|
|
1645
|
+
camera = await requestCameraStream({ video: true, audio: false });
|
|
1646
|
+
if (superseded()) throw new StartAborted();
|
|
1647
|
+
const videoFormat = resolveFormat("video");
|
|
1648
|
+
cameraRecorder = new MediaRecorder(
|
|
1649
|
+
camera,
|
|
1650
|
+
videoFormat.mimeType ? { mimeType: videoFormat.mimeType } : void 0
|
|
1651
|
+
);
|
|
1652
|
+
cameraMime = videoFormat.mimeType;
|
|
1653
|
+
cameraExt = videoFormat.extension;
|
|
1654
|
+
}
|
|
1655
|
+
const capture = {
|
|
1656
|
+
audioRecorder,
|
|
1657
|
+
audioChunks: [],
|
|
1658
|
+
audioMime: audioFormat.mimeType,
|
|
1659
|
+
audioExt: audioFormat.extension,
|
|
1660
|
+
cameraRecorder,
|
|
1661
|
+
cameraChunks: [],
|
|
1662
|
+
cameraMime,
|
|
1663
|
+
cameraExt,
|
|
1664
|
+
cameraStream: camera,
|
|
1665
|
+
traceSamples: [],
|
|
1666
|
+
traceTimer: null,
|
|
1667
|
+
startedAtMs: performance.now(),
|
|
1668
|
+
audioStartMs: null,
|
|
1669
|
+
cameraStartMs: null
|
|
1670
|
+
};
|
|
1671
|
+
audioRecorder.ondataavailable = (e) => {
|
|
1672
|
+
if (e.data.size > 0) capture.audioChunks.push(e.data);
|
|
1673
|
+
};
|
|
1674
|
+
audioRecorder.onstart = () => {
|
|
1675
|
+
capture.audioStartMs = performance.now();
|
|
1676
|
+
};
|
|
1677
|
+
if (cameraRecorder) {
|
|
1678
|
+
cameraRecorder.ondataavailable = (e) => {
|
|
1679
|
+
if (e.data.size > 0) capture.cameraChunks.push(e.data);
|
|
1680
|
+
};
|
|
1681
|
+
cameraRecorder.onstart = () => {
|
|
1682
|
+
capture.cameraStartMs = performance.now();
|
|
1683
|
+
};
|
|
1684
|
+
}
|
|
1685
|
+
if (superseded()) throw new StartAborted();
|
|
1686
|
+
audioRecorder.start(1e3);
|
|
1687
|
+
cameraRecorder?.start(1e3);
|
|
1688
|
+
traceTimer = setInterval(() => {
|
|
1689
|
+
const base = capture.audioStartMs ?? capture.startedAtMs;
|
|
1690
|
+
capture.traceSamples.push({
|
|
1691
|
+
tMs: performance.now() - base,
|
|
1692
|
+
wordPos: optionsRef.current.getWordPos()
|
|
1693
|
+
});
|
|
1694
|
+
}, TRACE_INTERVAL_MS);
|
|
1695
|
+
capture.traceTimer = traceTimer;
|
|
1696
|
+
captureRef.current = capture;
|
|
1697
|
+
setCameraStream(camera);
|
|
1698
|
+
setState("recording");
|
|
1699
|
+
opts.onRecordingStart?.();
|
|
1700
|
+
} catch (err) {
|
|
1701
|
+
releaseStartupMedia();
|
|
1702
|
+
teardownCapture();
|
|
1703
|
+
setCameraStream(null);
|
|
1704
|
+
if (err instanceof StartAborted) {
|
|
1705
|
+
setState("idle");
|
|
1706
|
+
} else {
|
|
1707
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
1708
|
+
setState("error");
|
|
1709
|
+
}
|
|
1710
|
+
} finally {
|
|
1711
|
+
startingRef.current = false;
|
|
1712
|
+
}
|
|
1713
|
+
}, [applyTake, teardownCapture, withCamera]);
|
|
1714
|
+
const stop = useCallback4(async () => {
|
|
1715
|
+
const capture = captureRef.current;
|
|
1716
|
+
if (!capture) {
|
|
1717
|
+
if (startingRef.current) {
|
|
1718
|
+
cancelPendingStart();
|
|
1719
|
+
setState("idle");
|
|
1720
|
+
}
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
captureRef.current = null;
|
|
1724
|
+
if (capture.traceTimer !== null) clearInterval(capture.traceTimer);
|
|
1725
|
+
optionsRef.current.onRecordingStop?.();
|
|
1726
|
+
processingRef.current = true;
|
|
1727
|
+
setState("processing");
|
|
1728
|
+
await stopRecorder(capture.audioRecorder);
|
|
1729
|
+
if (capture.cameraRecorder) await stopRecorder(capture.cameraRecorder);
|
|
1730
|
+
for (const track of capture.cameraStream?.getTracks() ?? []) track.stop();
|
|
1731
|
+
setCameraStream(null);
|
|
1732
|
+
const audioMime = capture.audioRecorder.mimeType || capture.audioMime;
|
|
1733
|
+
const audioBlob = new Blob(capture.audioChunks, { type: audioMime });
|
|
1734
|
+
const cameraBlob = capture.cameraRecorder && capture.cameraChunks.length > 0 ? new Blob(capture.cameraChunks, {
|
|
1735
|
+
type: capture.cameraRecorder.mimeType || capture.cameraMime || "video/webm"
|
|
1736
|
+
}) : null;
|
|
1737
|
+
const wallClockSec = (performance.now() - (capture.audioStartMs ?? capture.startedAtMs)) / 1e3;
|
|
1738
|
+
const script = optionsRef.current.getScript();
|
|
1739
|
+
const trace = { samples: capture.traceSamples };
|
|
1740
|
+
let alignment = null;
|
|
1741
|
+
let durationSec = wallClockSec;
|
|
1742
|
+
if (script) {
|
|
1743
|
+
try {
|
|
1744
|
+
const decodeCtx = new AudioContext();
|
|
1745
|
+
try {
|
|
1746
|
+
const decoded = await decodeCtx.decodeAudioData(await audioBlob.arrayBuffer());
|
|
1747
|
+
durationSec = decoded.duration;
|
|
1748
|
+
alignment = alignNarration({
|
|
1749
|
+
pcm: mixdownToMono(decoded),
|
|
1750
|
+
sampleRate: decoded.sampleRate,
|
|
1751
|
+
script,
|
|
1752
|
+
trace
|
|
1753
|
+
});
|
|
1754
|
+
} finally {
|
|
1755
|
+
void decodeCtx.close().catch(() => void 0);
|
|
1756
|
+
}
|
|
1757
|
+
} catch {
|
|
1758
|
+
alignment = null;
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
processingRef.current = false;
|
|
1762
|
+
if (unmountedRef.current) {
|
|
1763
|
+
console.warn(
|
|
1764
|
+
`[squisq-editor] Narration take discarded: the teleprompter was closed while the ${wallClockSec.toFixed(1)}s recording was still being aligned. The audio was not saved.`
|
|
1765
|
+
);
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
applyTake({
|
|
1769
|
+
audioBlob,
|
|
1770
|
+
audioMime,
|
|
1771
|
+
audioExt: capture.audioExt,
|
|
1772
|
+
cameraBlob,
|
|
1773
|
+
cameraMime: cameraBlob ? capture.cameraMime ?? "video/webm" : null,
|
|
1774
|
+
cameraExt: cameraBlob ? capture.cameraExt : null,
|
|
1775
|
+
durationSec,
|
|
1776
|
+
cameraOffsetSec: capture.cameraStartMs !== null && capture.audioStartMs !== null ? (capture.cameraStartMs - capture.audioStartMs) / 1e3 : void 0,
|
|
1777
|
+
trace,
|
|
1778
|
+
alignment,
|
|
1779
|
+
script: script ?? {
|
|
1780
|
+
sourceText: "",
|
|
1781
|
+
tokens: [],
|
|
1782
|
+
blocks: [],
|
|
1783
|
+
totalSyllables: 0,
|
|
1784
|
+
cumulativeSyllables: [0]
|
|
1785
|
+
}
|
|
1786
|
+
});
|
|
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]
|
|
1814
|
+
);
|
|
1815
|
+
useEffect5(() => {
|
|
1816
|
+
unmountedRef.current = false;
|
|
1817
|
+
return () => {
|
|
1818
|
+
unmountedRef.current = true;
|
|
1819
|
+
if (processingRef.current) {
|
|
1820
|
+
console.warn(
|
|
1821
|
+
"[squisq-editor] Narration take discarded: the teleprompter was closed while a recording was still being aligned. The audio was not saved."
|
|
1822
|
+
);
|
|
1823
|
+
} else if (takeRef.current) {
|
|
1824
|
+
console.warn(
|
|
1825
|
+
"[squisq-editor] Unsaved narration take discarded: the teleprompter was closed before the take was saved."
|
|
1826
|
+
);
|
|
1827
|
+
}
|
|
1828
|
+
teardownCapture();
|
|
1829
|
+
};
|
|
1830
|
+
}, [teardownCapture]);
|
|
1831
|
+
return {
|
|
1832
|
+
state,
|
|
1833
|
+
error,
|
|
1834
|
+
withCamera,
|
|
1835
|
+
setWithCamera,
|
|
1836
|
+
cameraStream,
|
|
1837
|
+
take,
|
|
1838
|
+
start,
|
|
1839
|
+
stop,
|
|
1840
|
+
retake,
|
|
1841
|
+
discard,
|
|
1842
|
+
beginSave,
|
|
1843
|
+
finishSave
|
|
1844
|
+
};
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
// src/teleprompter/recording/insertPreamble.ts
|
|
1848
|
+
var AUDIO_ANNOTATION_LINE = /^\{\[audio\s[^\]]*\]\}\s*$/;
|
|
1849
|
+
var DOCUMENT_ANCHOR = /\banchor=(?:"document"|'document'|document)(?:\s|\]|$)/;
|
|
1850
|
+
var CAMERA_LINE = /^<video\s[^>]*src="[^"]*"[^>]*><\/video>\s*$/;
|
|
1851
|
+
function isNarrationLine(line) {
|
|
1852
|
+
return AUDIO_ANNOTATION_LINE.test(line) && DOCUMENT_ANCHOR.test(line);
|
|
1853
|
+
}
|
|
1854
|
+
function quoteSrc(path) {
|
|
1855
|
+
return /[\s"']/.test(path) ? `"${path.replace(/"/g, '\\"')}"` : path;
|
|
1856
|
+
}
|
|
1857
|
+
function narrationAnnotationLine(audioPath) {
|
|
1858
|
+
return `{[audio src=${quoteSrc(audioPath)} anchor=document]}`;
|
|
1859
|
+
}
|
|
1860
|
+
function cameraVideoLine(cameraPath) {
|
|
1861
|
+
return `<video src="${cameraPath}" controls width="240"></video>`;
|
|
1862
|
+
}
|
|
1863
|
+
function insertNarrationPreamble(source, audioPath, cameraPath) {
|
|
1864
|
+
const lines = source.split("\n");
|
|
1865
|
+
let insertAt = 0;
|
|
1866
|
+
if (lines[0]?.trim() === "---") {
|
|
1867
|
+
for (let i = 1; i < lines.length; i++) {
|
|
1868
|
+
if (lines[i].trim() === "---") {
|
|
1869
|
+
insertAt = i + 1;
|
|
1870
|
+
break;
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
let scan = insertAt;
|
|
1875
|
+
while (scan < lines.length && lines[scan].trim() === "") scan++;
|
|
1876
|
+
if (scan < lines.length && isNarrationLine(lines[scan])) {
|
|
1877
|
+
let removeEnd = scan + 1;
|
|
1878
|
+
while (removeEnd < lines.length && lines[removeEnd].trim() === "") removeEnd++;
|
|
1879
|
+
if (removeEnd < lines.length && CAMERA_LINE.test(lines[removeEnd])) removeEnd++;
|
|
1880
|
+
if (removeEnd < lines.length && lines[removeEnd].trim() === "") removeEnd++;
|
|
1881
|
+
lines.splice(insertAt, removeEnd - insertAt);
|
|
1882
|
+
}
|
|
1883
|
+
const inserted = [narrationAnnotationLine(audioPath)];
|
|
1884
|
+
if (cameraPath) {
|
|
1885
|
+
inserted.push("", cameraVideoLine(cameraPath));
|
|
1886
|
+
}
|
|
1887
|
+
const before = lines.slice(0, insertAt);
|
|
1888
|
+
const after = lines.slice(insertAt);
|
|
1889
|
+
if (before.length > 0 && before[before.length - 1].trim() !== "") before.push("");
|
|
1890
|
+
if (after.length > 0 && after[0].trim() !== "") inserted.push("");
|
|
1891
|
+
return [...before, ...inserted, ...after].join("\n");
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
// src/teleprompter/recording/narrationSave.ts
|
|
1895
|
+
import {
|
|
1896
|
+
buildNarrationTimingJson
|
|
1897
|
+
} from "@bendyline/squisq/narration";
|
|
1898
|
+
function buildNarrationSavePlan(args) {
|
|
1899
|
+
const sidecarPayload = args.alignment ? buildNarrationTimingJson(args.script, args.alignment, args.durationSec, {
|
|
1900
|
+
baseWpm: args.baseWpm,
|
|
1901
|
+
...args.cameraOffsetSec !== void 0 ? { cameraOffsetSec: args.cameraOffsetSec } : {}
|
|
1902
|
+
}) : {
|
|
1903
|
+
version: 3,
|
|
1904
|
+
sourceText: args.script.sourceText,
|
|
1905
|
+
duration: args.durationSec,
|
|
1906
|
+
bookmarks: [],
|
|
1907
|
+
blocks: [],
|
|
1908
|
+
generator: { name: "squisq-teleprompter", method: "dsp-align", baseWpm: args.baseWpm }
|
|
1909
|
+
};
|
|
1910
|
+
return {
|
|
1911
|
+
audioRelativeName: `audio/${buildFilename("audio", args.audioExt)}`,
|
|
1912
|
+
cameraRelativeName: args.cameraExt ? `video/${buildFilename("video", args.cameraExt, "narration-cam")}` : null,
|
|
1913
|
+
sidecarPayload,
|
|
1914
|
+
sidecarPathFor: (savedAudioPath) => timingPathFor(savedAudioPath),
|
|
1915
|
+
nextMarkdown: (currentSource, savedAudioPath, savedCameraPath) => insertNarrationPreamble(currentSource, savedAudioPath, savedCameraPath)
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
async function executeNarrationSave(plan, take, deps, progress = {}) {
|
|
1919
|
+
const audioPath = progress.audioPath ?? await deps.mediaProvider.addMedia(plan.audioRelativeName, take.audioBlob, take.audioMime);
|
|
1920
|
+
progress.audioPath = audioPath;
|
|
1921
|
+
const sidecarPath = progress.sidecarPath ?? plan.sidecarPathFor(audioPath);
|
|
1922
|
+
if (progress.sidecarPath === void 0) {
|
|
1923
|
+
const encoded = encodeTimingJson(plan.sidecarPayload);
|
|
1924
|
+
if (deps.container) {
|
|
1925
|
+
await deps.container.writeFile(sidecarPath, encoded, "application/json");
|
|
1926
|
+
} else {
|
|
1927
|
+
const storedAt = await deps.mediaProvider.addMedia(sidecarPath, encoded, "application/json");
|
|
1928
|
+
if (storedAt !== sidecarPath) {
|
|
1929
|
+
console.warn(
|
|
1930
|
+
`Narration timing sidecar stored at "${storedAt}" instead of "${sidecarPath}"; narration timing will not be discovered until it sits next to the audio file.`
|
|
1931
|
+
);
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
progress.sidecarPath = sidecarPath;
|
|
1935
|
+
}
|
|
1936
|
+
let cameraPath = progress.cameraPath ?? null;
|
|
1937
|
+
if (progress.cameraPath === void 0) {
|
|
1938
|
+
if (plan.cameraRelativeName && take.cameraBlob && take.cameraMime) {
|
|
1939
|
+
cameraPath = await deps.mediaProvider.addMedia(
|
|
1940
|
+
plan.cameraRelativeName,
|
|
1941
|
+
take.cameraBlob,
|
|
1942
|
+
take.cameraMime
|
|
1943
|
+
);
|
|
1944
|
+
}
|
|
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
|
+
);
|
|
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
|
+
}
|
|
1977
|
+
|
|
1978
|
+
// src/teleprompter/TeleprompterView.tsx
|
|
1979
|
+
import { useCallback as useCallback5, useEffect as useEffect6, useMemo as useMemo4, useRef as useRef7, useState as useState5 } from "react";
|
|
1980
|
+
import { createPortal } from "react-dom";
|
|
1981
|
+
import { wordIndexAtTime } from "@bendyline/squisq/narration";
|
|
1982
|
+
|
|
1983
|
+
// src/teleprompter/TeleprompterSelfView.tsx
|
|
1984
|
+
import { useRef as useRef6 } from "react";
|
|
1985
|
+
import { jsx as jsx3 } from "react/jsx-runtime";
|
|
1986
|
+
function TeleprompterSelfView({ stream }) {
|
|
1987
|
+
const videoRef = useRef6(null);
|
|
1988
|
+
useStreamPreview(videoRef, stream);
|
|
1989
|
+
if (!stream) return null;
|
|
1990
|
+
return /* @__PURE__ */ jsx3(
|
|
1991
|
+
"video",
|
|
1992
|
+
{
|
|
1993
|
+
ref: videoRef,
|
|
1994
|
+
className: "squisq-teleprompter-selfview",
|
|
1995
|
+
muted: true,
|
|
1996
|
+
playsInline: true,
|
|
1997
|
+
autoPlay: true,
|
|
1998
|
+
"data-testid": "teleprompter-selfview"
|
|
1999
|
+
}
|
|
2000
|
+
);
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
// src/teleprompter/canvasRenderer.ts
|
|
2004
|
+
var layoutCache = null;
|
|
2005
|
+
function nearestSpoken(tokens, idx) {
|
|
2006
|
+
for (let i = idx; i >= 0; i--) {
|
|
2007
|
+
if (tokens[i].spoken) return i;
|
|
2008
|
+
}
|
|
2009
|
+
for (let i = idx + 1; i < tokens.length; i++) {
|
|
2010
|
+
if (tokens[i].spoken) return i;
|
|
2011
|
+
}
|
|
2012
|
+
return idx;
|
|
2013
|
+
}
|
|
2014
|
+
function layoutTokens(ctx, script, fontPx, maxWidth) {
|
|
2015
|
+
const key = `${script.tokens.length}:${script.sourceText.length}:${fontPx}:${maxWidth}`;
|
|
2016
|
+
if (layoutCache && layoutCache.key === key) return layoutCache;
|
|
2017
|
+
const spaceW = ctx.measureText(" ").width;
|
|
2018
|
+
const tokenLine = new Array(script.tokens.length);
|
|
2019
|
+
const lines = [];
|
|
2020
|
+
let current = [];
|
|
2021
|
+
let x = 0;
|
|
2022
|
+
for (let i = 0; i < script.tokens.length; i++) {
|
|
2023
|
+
const token = script.tokens[i];
|
|
2024
|
+
const w = ctx.measureText(token.text).width;
|
|
2025
|
+
const startsBlock = i === 0 || script.tokens[i - 1].blockIndex !== token.blockIndex;
|
|
2026
|
+
const startsPara = i > 0 && script.tokens[i - 1].pauseAfter >= 2;
|
|
2027
|
+
if (current.length > 0 && (x + w > maxWidth || startsBlock || startsPara)) {
|
|
2028
|
+
lines.push(current);
|
|
2029
|
+
current = [];
|
|
2030
|
+
x = 0;
|
|
2031
|
+
}
|
|
2032
|
+
tokenLine[i] = lines.length;
|
|
2033
|
+
current.push(i);
|
|
2034
|
+
x += w + spaceW;
|
|
2035
|
+
}
|
|
2036
|
+
if (current.length > 0) lines.push(current);
|
|
2037
|
+
layoutCache = { key, tokenLine, lines };
|
|
2038
|
+
return layoutCache;
|
|
2039
|
+
}
|
|
2040
|
+
function drawPrompterFrame(canvas, frame) {
|
|
2041
|
+
const ctx = canvas.getContext("2d");
|
|
2042
|
+
if (!ctx) return;
|
|
2043
|
+
const { width, height } = canvas;
|
|
2044
|
+
const fontPx = Math.round(frame.fontSizePx * 0.72);
|
|
2045
|
+
const lineH = Math.round(fontPx * 1.4);
|
|
2046
|
+
const marginX = Math.round(width * 0.06);
|
|
2047
|
+
ctx.save();
|
|
2048
|
+
ctx.fillStyle = frame.colors.bg;
|
|
2049
|
+
ctx.fillRect(0, 0, width, height);
|
|
2050
|
+
if (frame.mirrored) {
|
|
2051
|
+
ctx.translate(width, 0);
|
|
2052
|
+
ctx.scale(-1, 1);
|
|
2053
|
+
}
|
|
2054
|
+
ctx.font = `500 ${fontPx}px system-ui, sans-serif`;
|
|
2055
|
+
ctx.textBaseline = "top";
|
|
2056
|
+
const layout = layoutTokens(ctx, frame.script, fontPx, width - marginX * 2);
|
|
2057
|
+
const count = frame.script.tokens.length;
|
|
2058
|
+
if (count > 0) {
|
|
2059
|
+
const rawActive = Math.min(Math.max(Math.floor(frame.wordPos), 0), count - 1);
|
|
2060
|
+
const frac = Math.min(Math.max(frame.wordPos - rawActive, 0), 1);
|
|
2061
|
+
const active = nearestSpoken(frame.script.tokens, rawActive);
|
|
2062
|
+
const activeLine = layout.tokenLine[active] ?? 0;
|
|
2063
|
+
const eyeY = height * 0.38;
|
|
2064
|
+
const spaceW = ctx.measureText(" ").width;
|
|
2065
|
+
const scrollY = (activeLine + frac) * lineH - eyeY;
|
|
2066
|
+
const firstLine = Math.max(0, Math.floor(scrollY / lineH) - 1);
|
|
2067
|
+
const lastLine = Math.min(layout.lines.length - 1, Math.ceil((scrollY + height) / lineH) + 1);
|
|
2068
|
+
for (let line = firstLine; line <= lastLine; line++) {
|
|
2069
|
+
const y = line * lineH - scrollY;
|
|
2070
|
+
let x = marginX;
|
|
2071
|
+
for (const tokenIdx of layout.lines[line]) {
|
|
2072
|
+
const token = frame.script.tokens[tokenIdx];
|
|
2073
|
+
if (tokenIdx === active) {
|
|
2074
|
+
ctx.fillStyle = frame.colors.accent;
|
|
2075
|
+
ctx.font = `800 ${fontPx}px system-ui, sans-serif`;
|
|
2076
|
+
} else {
|
|
2077
|
+
ctx.fillStyle = frame.colors.text;
|
|
2078
|
+
ctx.font = `500 ${fontPx}px system-ui, sans-serif`;
|
|
2079
|
+
ctx.globalAlpha = tokenIdx < active ? 0.9 : 0.45;
|
|
2080
|
+
}
|
|
2081
|
+
ctx.fillText(token.text, x, y);
|
|
2082
|
+
ctx.globalAlpha = 1;
|
|
2083
|
+
x += ctx.measureText(token.text).width + spaceW;
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
ctx.fillStyle = frame.colors.accent;
|
|
2087
|
+
ctx.beginPath();
|
|
2088
|
+
ctx.moveTo(4, eyeY + lineH * 0.2);
|
|
2089
|
+
ctx.lineTo(14, eyeY + lineH * 0.5);
|
|
2090
|
+
ctx.lineTo(4, eyeY + lineH * 0.8);
|
|
2091
|
+
ctx.closePath();
|
|
2092
|
+
ctx.fill();
|
|
2093
|
+
}
|
|
2094
|
+
ctx.restore();
|
|
2095
|
+
if (frame.countdownRemaining !== null) {
|
|
2096
|
+
ctx.save();
|
|
2097
|
+
ctx.fillStyle = `${frame.colors.bg}cc`;
|
|
2098
|
+
ctx.fillRect(0, 0, width, height);
|
|
2099
|
+
ctx.fillStyle = frame.colors.accent;
|
|
2100
|
+
ctx.font = `800 ${Math.round(height * 0.4)}px system-ui, sans-serif`;
|
|
2101
|
+
ctx.textAlign = "center";
|
|
2102
|
+
ctx.textBaseline = "middle";
|
|
2103
|
+
ctx.fillText(String(frame.countdownRemaining), width / 2, height / 2);
|
|
2104
|
+
ctx.restore();
|
|
2105
|
+
}
|
|
2106
|
+
if (frame.recording) {
|
|
2107
|
+
ctx.save();
|
|
2108
|
+
ctx.fillStyle = "#e5484d";
|
|
2109
|
+
ctx.beginPath();
|
|
2110
|
+
ctx.arc(width - 20, 20, 8, 0, Math.PI * 2);
|
|
2111
|
+
ctx.fill();
|
|
2112
|
+
ctx.restore();
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
// src/teleprompter/TeleprompterView.tsx
|
|
2117
|
+
import { Fragment as Fragment3, jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
2118
|
+
function TeleprompterView(props) {
|
|
2119
|
+
const { doc, theme, presentationTarget = null, recording = null } = props;
|
|
2120
|
+
const controller = useTeleprompter({ doc });
|
|
2121
|
+
const float = useFloatingWindow(TELEPROMPTER_CSS);
|
|
2122
|
+
const rootRef = useRef7(null);
|
|
2123
|
+
const controllerRef = useRef7(controller);
|
|
2124
|
+
controllerRef.current = controller;
|
|
2125
|
+
const [saveNotice, setSaveNotice] = useState5(null);
|
|
2126
|
+
const recorder = useNarrationRecorder({
|
|
2127
|
+
mic: controller.mic,
|
|
2128
|
+
getScript: () => controllerRef.current.script,
|
|
2129
|
+
getWordPos: () => controllerRef.current.wordPos,
|
|
2130
|
+
getMicDeviceId: () => controllerRef.current.prefs.micDeviceId,
|
|
2131
|
+
onRecordingStart: () => controllerRef.current.play(),
|
|
2132
|
+
onRecordingStop: () => controllerRef.current.pause()
|
|
2133
|
+
});
|
|
2134
|
+
const recorderRef = useRef7(recorder);
|
|
2135
|
+
recorderRef.current = recorder;
|
|
2136
|
+
const recordingRef = useRef7(recording);
|
|
2137
|
+
recordingRef.current = recording;
|
|
2138
|
+
const saveProgressRef = useRef7(null);
|
|
2139
|
+
const progressForTake = useCallback5((take) => {
|
|
2140
|
+
const existing = saveProgressRef.current;
|
|
2141
|
+
if (existing && existing.take === take) return existing.progress;
|
|
2142
|
+
const fresh = { take, progress: {} };
|
|
2143
|
+
saveProgressRef.current = fresh;
|
|
2144
|
+
return fresh.progress;
|
|
2145
|
+
}, []);
|
|
2146
|
+
const cleanupAbandonedSave = useCallback5(() => {
|
|
2147
|
+
const pending = saveProgressRef.current;
|
|
2148
|
+
saveProgressRef.current = null;
|
|
2149
|
+
const deps = recordingRef.current;
|
|
2150
|
+
if (!pending || !deps) return;
|
|
2151
|
+
if (pending.progress.audioPath === void 0 && pending.progress.sidecarPath === void 0) {
|
|
2152
|
+
return;
|
|
2153
|
+
}
|
|
2154
|
+
void discardNarrationSaveProgress(pending.progress, {
|
|
2155
|
+
mediaProvider: deps.mediaProvider,
|
|
2156
|
+
container: deps.container
|
|
2157
|
+
});
|
|
2158
|
+
}, []);
|
|
2159
|
+
const handleRetake = useCallback5(() => {
|
|
2160
|
+
cleanupAbandonedSave();
|
|
2161
|
+
recorderRef.current.retake();
|
|
2162
|
+
}, [cleanupAbandonedSave]);
|
|
2163
|
+
const handleDiscard = useCallback5(() => {
|
|
2164
|
+
cleanupAbandonedSave();
|
|
2165
|
+
recorderRef.current.discard();
|
|
2166
|
+
}, [cleanupAbandonedSave]);
|
|
2167
|
+
useEffect6(() => {
|
|
2168
|
+
const ownerDoc = rootRef.current?.ownerDocument;
|
|
2169
|
+
if (ownerDoc) ensureTeleprompterStyles(ownerDoc);
|
|
2170
|
+
}, []);
|
|
2171
|
+
useEffect6(() => {
|
|
2172
|
+
if (presentationTarget) ensureTeleprompterStyles(presentationTarget.ownerDocument);
|
|
2173
|
+
}, [presentationTarget]);
|
|
2174
|
+
const canvasFrameRef = useRef7(null);
|
|
2175
|
+
canvasFrameRef.current = controller.script ? {
|
|
2176
|
+
script: controller.script,
|
|
2177
|
+
fontSizePx: controller.prefs.fontSizePx,
|
|
2178
|
+
mirrored: controller.prefs.mirrored,
|
|
2179
|
+
colors: {
|
|
2180
|
+
bg: theme.colors.background,
|
|
2181
|
+
text: theme.colors.text,
|
|
2182
|
+
accent: theme.colors.primary,
|
|
2183
|
+
muted: theme.colors.textMuted ?? theme.colors.text
|
|
2184
|
+
},
|
|
2185
|
+
countdownRemaining: controller.countdownRemaining,
|
|
2186
|
+
recording: recorder.state === "recording"
|
|
2187
|
+
} : null;
|
|
2188
|
+
useEffect6(() => {
|
|
2189
|
+
const sink = float.canvasSink;
|
|
2190
|
+
if (float.tier !== "video-pip" || !sink) return;
|
|
2191
|
+
const draw = (wordPos) => {
|
|
2192
|
+
const config = canvasFrameRef.current;
|
|
2193
|
+
if (!config) return;
|
|
2194
|
+
drawPrompterFrame(sink.canvas, { ...config, wordPos });
|
|
2195
|
+
sink.requestFrame();
|
|
2196
|
+
};
|
|
2197
|
+
draw(controllerRef.current.wordPos);
|
|
2198
|
+
const unsubscribe = controllerRef.current.subscribeTick(draw);
|
|
2199
|
+
const interval = setInterval(() => draw(controllerRef.current.wordPos), 200);
|
|
2200
|
+
return () => {
|
|
2201
|
+
unsubscribe();
|
|
2202
|
+
clearInterval(interval);
|
|
2203
|
+
};
|
|
2204
|
+
}, [float.tier, float.canvasSink]);
|
|
2205
|
+
const reviewAudioUrl = useMemo4(
|
|
2206
|
+
() => recorder.take ? URL.createObjectURL(recorder.take.audioBlob) : null,
|
|
2207
|
+
[recorder.take]
|
|
2208
|
+
);
|
|
2209
|
+
useEffect6(() => {
|
|
2210
|
+
return () => {
|
|
2211
|
+
if (reviewAudioUrl) URL.revokeObjectURL(reviewAudioUrl);
|
|
2212
|
+
};
|
|
2213
|
+
}, [reviewAudioUrl]);
|
|
2214
|
+
const handleReviewTimeUpdate = useCallback5((event) => {
|
|
2215
|
+
const alignment = recorderRef.current.take?.alignment;
|
|
2216
|
+
if (!alignment || alignment.words.length === 0) return;
|
|
2217
|
+
controllerRef.current.seekToToken(
|
|
2218
|
+
wordIndexAtTime(alignment.words, event.currentTarget.currentTime)
|
|
2219
|
+
);
|
|
2220
|
+
}, []);
|
|
2221
|
+
const handleSave = useCallback5(async () => {
|
|
2222
|
+
const take = recorderRef.current.take;
|
|
2223
|
+
if (!take || !recording) return;
|
|
2224
|
+
recorderRef.current.beginSave();
|
|
2225
|
+
try {
|
|
2226
|
+
const plan = buildNarrationSavePlan({
|
|
2227
|
+
script: take.script,
|
|
2228
|
+
alignment: take.alignment,
|
|
2229
|
+
durationSec: take.durationSec,
|
|
2230
|
+
audioExt: take.audioExt,
|
|
2231
|
+
cameraExt: take.cameraExt,
|
|
2232
|
+
baseWpm: controllerRef.current.prefs.baseWpm,
|
|
2233
|
+
...take.cameraOffsetSec !== void 0 ? { cameraOffsetSec: take.cameraOffsetSec } : {}
|
|
2234
|
+
});
|
|
2235
|
+
const result = await executeNarrationSave(
|
|
2236
|
+
plan,
|
|
2237
|
+
take,
|
|
2238
|
+
{
|
|
2239
|
+
mediaProvider: recording.mediaProvider,
|
|
2240
|
+
container: recording.container,
|
|
2241
|
+
getMarkdownSource: () => recordingRef.current?.markdownSource ?? "",
|
|
2242
|
+
setMarkdownSource: recording.setMarkdownSource,
|
|
2243
|
+
bumpMediaRevision: recording.bumpMediaRevision
|
|
2244
|
+
},
|
|
2245
|
+
progressForTake(take)
|
|
2246
|
+
);
|
|
2247
|
+
saveProgressRef.current = null;
|
|
2248
|
+
recorderRef.current.finishSave(true);
|
|
2249
|
+
setSaveNotice(
|
|
2250
|
+
`Saved ${result.audioPath}${take.alignment ? " \u2014 blocks re-timed to your voice" : ""}`
|
|
2251
|
+
);
|
|
2252
|
+
} catch (err) {
|
|
2253
|
+
recorderRef.current.finishSave(false, err instanceof Error ? err : new Error(String(err)));
|
|
2254
|
+
}
|
|
2255
|
+
}, [recording, progressForTake]);
|
|
2256
|
+
const surfaceProps = useMemo4(
|
|
2257
|
+
() => ({
|
|
2258
|
+
wordPos: controller.wordPos,
|
|
2259
|
+
fontSizePx: controller.prefs.fontSizePx,
|
|
2260
|
+
mirrored: controller.prefs.mirrored,
|
|
2261
|
+
lineGuide: controller.prefs.lineGuide,
|
|
2262
|
+
countdownRemaining: controller.countdownRemaining,
|
|
2263
|
+
recordingIndicator: recorder.state === "recording",
|
|
2264
|
+
theme,
|
|
2265
|
+
onSeekToken: controller.seekToToken
|
|
2266
|
+
}),
|
|
2267
|
+
[
|
|
2268
|
+
controller.wordPos,
|
|
2269
|
+
controller.prefs.fontSizePx,
|
|
2270
|
+
controller.prefs.mirrored,
|
|
2271
|
+
controller.prefs.lineGuide,
|
|
2272
|
+
controller.countdownRemaining,
|
|
2273
|
+
controller.seekToToken,
|
|
2274
|
+
recorder.state,
|
|
2275
|
+
theme
|
|
2276
|
+
]
|
|
2277
|
+
);
|
|
2278
|
+
if (!controller.script) {
|
|
2279
|
+
return /* @__PURE__ */ jsxs3(Fragment3, { children: [
|
|
2280
|
+
/* @__PURE__ */ jsx4("div", { ref: rootRef, className: "squisq-teleprompter-root", "data-testid": "teleprompter-view", children: /* @__PURE__ */ jsx4("div", { className: "squisq-teleprompter-float-note", children: /* @__PURE__ */ jsx4("p", { children: "Nothing to narrate yet \u2014 add some content to the document." }) }) }),
|
|
2281
|
+
presentationTarget ? createPortal(
|
|
2282
|
+
/* @__PURE__ */ jsx4("div", { className: "squisq-presentation-teleprompter", "aria-label": "Audience presentation", children: /* @__PURE__ */ jsx4("div", { className: "squisq-teleprompter-float-note", children: /* @__PURE__ */ jsx4("p", { children: "Nothing to narrate yet \u2014 add some content to the document." }) }) }),
|
|
2283
|
+
presentationTarget
|
|
2284
|
+
) : null
|
|
2285
|
+
] });
|
|
2286
|
+
}
|
|
2287
|
+
const script = controller.script;
|
|
2288
|
+
const portalOpen = float.portalTarget !== null;
|
|
2289
|
+
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: [
|
|
2291
|
+
/* @__PURE__ */ jsx4(
|
|
2292
|
+
"button",
|
|
2293
|
+
{
|
|
2294
|
+
type: "button",
|
|
2295
|
+
onClick: () => void recorder.start(),
|
|
2296
|
+
title: "Record narration while you read",
|
|
2297
|
+
children: "\u23FA Record"
|
|
2298
|
+
}
|
|
2299
|
+
),
|
|
2300
|
+
/* @__PURE__ */ jsxs3("label", { title: "Also capture your camera as a separate video file", children: [
|
|
2301
|
+
/* @__PURE__ */ jsx4(
|
|
2302
|
+
"input",
|
|
2303
|
+
{
|
|
2304
|
+
type: "checkbox",
|
|
2305
|
+
checked: recorder.withCamera,
|
|
2306
|
+
onChange: (e) => recorder.setWithCamera(e.target.checked)
|
|
2307
|
+
}
|
|
2308
|
+
),
|
|
2309
|
+
"camera"
|
|
2310
|
+
] }),
|
|
2311
|
+
recorder.state === "error" ? /* @__PURE__ */ jsxs3("span", { title: recorder.error?.message, children: [
|
|
2312
|
+
"\u26A0 ",
|
|
2313
|
+
recorder.error?.message
|
|
2314
|
+
] }) : null
|
|
2315
|
+
] }) : busyRecording ? /* @__PURE__ */ jsx4("button", { type: "button", onClick: () => void recorder.stop(), children: "\u23F9 Stop" }) : recorder.state === "processing" ? /* @__PURE__ */ jsx4("span", { children: "Aligning take\u2026" }) : recorder.state === "saving" ? /* @__PURE__ */ jsx4("span", { children: "Saving\u2026" }) : null }) : null;
|
|
2316
|
+
return /* @__PURE__ */ jsxs3(
|
|
2317
|
+
"div",
|
|
2318
|
+
{
|
|
2319
|
+
ref: rootRef,
|
|
2320
|
+
className: "squisq-teleprompter-root",
|
|
2321
|
+
"data-testid": "teleprompter-view",
|
|
2322
|
+
tabIndex: 0,
|
|
2323
|
+
onKeyDown: controller.handleKeyDown,
|
|
2324
|
+
children: [
|
|
2325
|
+
/* @__PURE__ */ jsxs3("div", { className: "squisq-teleprompter-stage", children: [
|
|
2326
|
+
portalOpen ? /* @__PURE__ */ jsxs3("div", { className: "squisq-teleprompter-float-note", children: [
|
|
2327
|
+
/* @__PURE__ */ jsx4("p", { children: "The prompter is floating in its own window." }),
|
|
2328
|
+
/* @__PURE__ */ jsx4("button", { type: "button", onClick: float.close, children: "\u21E4 Bring it back" })
|
|
2329
|
+
] }) : /* @__PURE__ */ jsx4(TeleprompterSurface, { script, ...surfaceProps }, "docked"),
|
|
2330
|
+
portalOpen && float.portalTarget ? createPortal(
|
|
2331
|
+
/* @__PURE__ */ jsx4(
|
|
2332
|
+
TeleprompterSurface,
|
|
2333
|
+
{
|
|
2334
|
+
script,
|
|
2335
|
+
...surfaceProps,
|
|
2336
|
+
compact: true
|
|
2337
|
+
},
|
|
2338
|
+
`float-${float.tier}`
|
|
2339
|
+
),
|
|
2340
|
+
float.portalTarget
|
|
2341
|
+
) : null,
|
|
2342
|
+
presentationTarget ? createPortal(
|
|
2343
|
+
/* @__PURE__ */ jsx4("div", { className: "squisq-presentation-teleprompter", "aria-label": "Audience presentation", children: /* @__PURE__ */ jsx4(
|
|
2344
|
+
TeleprompterSurface,
|
|
2345
|
+
{
|
|
2346
|
+
script,
|
|
2347
|
+
...surfaceProps
|
|
2348
|
+
},
|
|
2349
|
+
"presentation-audience"
|
|
2350
|
+
) }),
|
|
2351
|
+
presentationTarget
|
|
2352
|
+
) : null,
|
|
2353
|
+
/* @__PURE__ */ jsx4(TeleprompterSelfView, { stream: recorder.cameraStream })
|
|
2354
|
+
] }),
|
|
2355
|
+
recorder.state === "review" && recorder.take ? /* @__PURE__ */ jsxs3("div", { className: "squisq-teleprompter-review", "data-testid": "teleprompter-review", children: [
|
|
2356
|
+
/* @__PURE__ */ jsxs3("span", { children: [
|
|
2357
|
+
"Take: ",
|
|
2358
|
+
recorder.take.durationSec.toFixed(1),
|
|
2359
|
+
"s",
|
|
2360
|
+
recorder.take.alignment ? ` \xB7 ${recorder.take.alignment.detectedSyllables} syllables aligned` : " \xB7 timing unavailable (saved without re-timing)"
|
|
2361
|
+
] }),
|
|
2362
|
+
reviewAudioUrl ? /* @__PURE__ */ jsx4("audio", { controls: true, src: reviewAudioUrl, onTimeUpdate: handleReviewTimeUpdate }) : null,
|
|
2363
|
+
/* @__PURE__ */ jsx4("button", { type: "button", onClick: () => void handleSave(), children: "\u2713 Save narration" }),
|
|
2364
|
+
/* @__PURE__ */ jsx4("button", { type: "button", onClick: handleRetake, children: "\u21BA Retake" }),
|
|
2365
|
+
/* @__PURE__ */ jsx4("button", { type: "button", onClick: handleDiscard, children: "\u2715 Discard" }),
|
|
2366
|
+
recorder.error ? /* @__PURE__ */ jsxs3("span", { children: [
|
|
2367
|
+
"\u26A0 ",
|
|
2368
|
+
recorder.error.message
|
|
2369
|
+
] }) : null
|
|
2370
|
+
] }) : null,
|
|
2371
|
+
saveNotice ? /* @__PURE__ */ jsxs3("div", { className: "squisq-teleprompter-review", "data-testid": "teleprompter-save-notice", children: [
|
|
2372
|
+
/* @__PURE__ */ jsx4("span", { children: saveNotice }),
|
|
2373
|
+
/* @__PURE__ */ jsx4("button", { type: "button", onClick: () => setSaveNotice(null), children: "Dismiss" })
|
|
2374
|
+
] }) : null,
|
|
2375
|
+
/* @__PURE__ */ jsx4(TeleprompterControls, { controller, float, recordSlot })
|
|
2376
|
+
]
|
|
2377
|
+
}
|
|
2378
|
+
);
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
export {
|
|
2382
|
+
PCM_WORKLET_NAME,
|
|
2383
|
+
PCM_WORKLET_SOURCE,
|
|
2384
|
+
registerPcmWorklet,
|
|
2385
|
+
useMicAnalysis,
|
|
2386
|
+
DEFAULT_TELEPROMPTER_PREFS,
|
|
2387
|
+
vadConfigForSensitivity,
|
|
2388
|
+
useTeleprompter,
|
|
2389
|
+
detectFloatTiers,
|
|
2390
|
+
createFloatingWindowManager,
|
|
2391
|
+
useFloatingWindow,
|
|
2392
|
+
EYE_LINE_FRACTION,
|
|
2393
|
+
measureTokenLines,
|
|
2394
|
+
targetOffsetFor,
|
|
2395
|
+
stepScroll,
|
|
2396
|
+
prompterVarsFromTheme,
|
|
2397
|
+
ensureTeleprompterStyles,
|
|
2398
|
+
TELEPROMPTER_CSS,
|
|
2399
|
+
TeleprompterSurface,
|
|
2400
|
+
TeleprompterControls,
|
|
2401
|
+
useNarrationRecorder,
|
|
2402
|
+
narrationAnnotationLine,
|
|
2403
|
+
cameraVideoLine,
|
|
2404
|
+
insertNarrationPreamble,
|
|
2405
|
+
buildNarrationSavePlan,
|
|
2406
|
+
executeNarrationSave,
|
|
2407
|
+
TeleprompterView
|
|
2408
|
+
};
|