@bendyline/squisq-editor-react 2.11.2 → 2.11.4

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.
@@ -1344,6 +1344,17 @@ import { useCallback as useCallback4, useEffect as useEffect5, useRef as useRef4
1344
1344
  import {
1345
1345
  alignNarration
1346
1346
  } from "@bendyline/squisq/narration";
1347
+
1348
+ // src/recorder/recordingLimits.ts
1349
+ var DEFAULT_MAX_RECORDING_BYTES = 900 * 1024 * 1024;
1350
+ function resolveMaxRecordingBytes(value) {
1351
+ return value !== void 0 && Number.isSafeInteger(value) && value > 0 ? value : DEFAULT_MAX_RECORDING_BYTES;
1352
+ }
1353
+ function formatRecordingBytes(bytes) {
1354
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
1355
+ }
1356
+
1357
+ // src/teleprompter/recording/useNarrationRecorder.ts
1347
1358
  var TRACE_INTERVAL_MS = 250;
1348
1359
  function mixdownToMono(buffer) {
1349
1360
  const channels = buffer.numberOfChannels;
@@ -1377,6 +1388,11 @@ function stopRecorder(recorder) {
1377
1388
  function useNarrationRecorder(options) {
1378
1389
  const [state, setState] = useState4("idle");
1379
1390
  const [error, setError] = useState4(null);
1391
+ const [recordedBytes, setRecordedBytes] = useState4(0);
1392
+ const [limitReached, setLimitReached] = useState4(false);
1393
+ const maxRecordingBytes = resolveMaxRecordingBytes(options.maxRecordingBytes);
1394
+ const stopRef = useRef4(async () => {
1395
+ });
1380
1396
  const [withCamera, setWithCamera] = useState4(false);
1381
1397
  const [cameraStream, setCameraStream] = useState4(null);
1382
1398
  const [take, setTake] = useState4(null);
@@ -1423,8 +1439,11 @@ function useNarrationRecorder(options) {
1423
1439
  const superseded = () => generationRef.current !== generation;
1424
1440
  startingRef.current = true;
1425
1441
  setError(null);
1442
+ setRecordedBytes(0);
1443
+ setLimitReached(false);
1426
1444
  applyTake(null);
1427
1445
  setState("starting");
1446
+ const recordingByteLimit = resolveMaxRecordingBytes(opts.maxRecordingBytes);
1428
1447
  let camera = null;
1429
1448
  let audioRecorder = null;
1430
1449
  let cameraRecorder = null;
@@ -1493,17 +1512,30 @@ function useNarrationRecorder(options) {
1493
1512
  traceTimer: null,
1494
1513
  startedAtMs: performance.now(),
1495
1514
  audioStartMs: null,
1496
- cameraStartMs: null
1515
+ cameraStartMs: null,
1516
+ recordedBytes: 0,
1517
+ limitReached: false
1518
+ };
1519
+ const retainChunk = (chunks, chunk) => {
1520
+ if (chunk.size === 0 || superseded()) return;
1521
+ chunks.push(chunk);
1522
+ capture.recordedBytes += chunk.size;
1523
+ setRecordedBytes(capture.recordedBytes);
1524
+ if (!capture.limitReached && capture.recordedBytes >= recordingByteLimit) {
1525
+ capture.limitReached = true;
1526
+ setLimitReached(true);
1527
+ if (captureRef.current === capture) void stopRef.current();
1528
+ }
1497
1529
  };
1498
1530
  audioRecorder.ondataavailable = (e) => {
1499
- if (e.data.size > 0) capture.audioChunks.push(e.data);
1531
+ retainChunk(capture.audioChunks, e.data);
1500
1532
  };
1501
1533
  audioRecorder.onstart = () => {
1502
1534
  capture.audioStartMs = performance.now();
1503
1535
  };
1504
1536
  if (cameraRecorder) {
1505
1537
  cameraRecorder.ondataavailable = (e) => {
1506
- if (e.data.size > 0) capture.cameraChunks.push(e.data);
1538
+ retainChunk(capture.cameraChunks, e.data);
1507
1539
  };
1508
1540
  cameraRecorder.onstart = () => {
1509
1541
  capture.cameraStartMs = performance.now();
@@ -1552,8 +1584,10 @@ function useNarrationRecorder(options) {
1552
1584
  optionsRef.current.onRecordingStop?.();
1553
1585
  processingRef.current = true;
1554
1586
  setState("processing");
1555
- await stopRecorder(capture.audioRecorder);
1556
- if (capture.cameraRecorder) await stopRecorder(capture.cameraRecorder);
1587
+ await Promise.all([
1588
+ stopRecorder(capture.audioRecorder),
1589
+ ...capture.cameraRecorder ? [stopRecorder(capture.cameraRecorder)] : []
1590
+ ]);
1557
1591
  for (const track of capture.cameraStream?.getTracks() ?? []) track.stop();
1558
1592
  setCameraStream(null);
1559
1593
  const audioMime = capture.audioRecorder.mimeType || capture.audioMime;
@@ -1613,16 +1647,21 @@ function useNarrationRecorder(options) {
1613
1647
  });
1614
1648
  setState("review");
1615
1649
  }, [applyTake, cancelPendingStart]);
1650
+ stopRef.current = stop;
1616
1651
  const retake = useCallback4(() => {
1617
1652
  teardownCapture();
1618
1653
  applyTake(null);
1619
1654
  setError(null);
1655
+ setRecordedBytes(0);
1656
+ setLimitReached(false);
1620
1657
  setState("idle");
1621
1658
  }, [applyTake, teardownCapture]);
1622
1659
  const discard = useCallback4(() => {
1623
1660
  teardownCapture();
1624
1661
  applyTake(null);
1625
1662
  setError(null);
1663
+ setRecordedBytes(0);
1664
+ setLimitReached(false);
1626
1665
  setState("idle");
1627
1666
  }, [applyTake, teardownCapture]);
1628
1667
  const beginSave = useCallback4(() => setState("saving"), []);
@@ -1631,6 +1670,8 @@ function useNarrationRecorder(options) {
1631
1670
  if (ok) {
1632
1671
  applyTake(null);
1633
1672
  setError(null);
1673
+ setRecordedBytes(0);
1674
+ setLimitReached(false);
1634
1675
  setState("idle");
1635
1676
  } else {
1636
1677
  setError(saveError ?? new Error("Save failed"));
@@ -1658,6 +1699,9 @@ function useNarrationRecorder(options) {
1658
1699
  return {
1659
1700
  state,
1660
1701
  error,
1702
+ recordedBytes,
1703
+ maxRecordingBytes,
1704
+ limitReached,
1661
1705
  withCamera,
1662
1706
  setWithCamera,
1663
1707
  cameraStream,
@@ -1818,6 +1862,7 @@ function useNarrationStage(opts) {
1818
1862
  getWordPos: () => controllerRef.current.wordPos,
1819
1863
  getMicDeviceId: () => controllerRef.current.prefs.micDeviceId,
1820
1864
  cameraConstraints: opts.cameraConstraints,
1865
+ maxRecordingBytes: opts.maxRecordingBytes,
1821
1866
  audioRecorderOptions: opts.audioRecorderOptions,
1822
1867
  cameraRecorderOptions: opts.cameraRecorderOptions,
1823
1868
  onRecordingStart: () => controllerRef.current.play(),
@@ -2379,7 +2424,7 @@ function TeleprompterControls({
2379
2424
  }
2380
2425
 
2381
2426
  // src/teleprompter/NarrationStage.tsx
2382
- import { useCallback as useCallback6, useEffect as useEffect8, useMemo as useMemo5, useRef as useRef8 } from "react";
2427
+ import { useCallback as useCallback6, useEffect as useEffect8, useMemo as useMemo5, useRef as useRef8, useState as useState6 } from "react";
2383
2428
  import { createPortal } from "react-dom";
2384
2429
 
2385
2430
  // src/teleprompter/TeleprompterSelfView.tsx
@@ -2517,6 +2562,47 @@ function drawPrompterFrame(canvas, frame) {
2517
2562
 
2518
2563
  // src/teleprompter/NarrationStage.tsx
2519
2564
  import { Fragment as Fragment3, jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
2565
+ function NarrationTakeBackups({ take, baseWpm }) {
2566
+ const [downloads, setDownloads] = useState6(
2567
+ []
2568
+ );
2569
+ useEffect8(() => {
2570
+ const timing = buildNarrationSavePlan({
2571
+ script: take.script,
2572
+ alignment: take.alignment,
2573
+ durationSec: take.durationSec,
2574
+ audioExt: take.audioExt,
2575
+ cameraExt: take.cameraExt,
2576
+ cameraOffsetSec: take.cameraOffsetSec,
2577
+ baseWpm
2578
+ }).sidecarPayload;
2579
+ const assets = [
2580
+ {
2581
+ label: "Download audio backup",
2582
+ blob: take.audioBlob,
2583
+ filename: "narration" + take.audioExt
2584
+ },
2585
+ ...take.cameraBlob ? [
2586
+ {
2587
+ label: "Download camera backup",
2588
+ blob: take.cameraBlob,
2589
+ filename: "narration-camera" + (take.cameraExt ?? ".webm")
2590
+ }
2591
+ ] : [],
2592
+ {
2593
+ label: "Download timing backup",
2594
+ blob: new Blob([JSON.stringify(timing, null, 2)], { type: "application/json" }),
2595
+ filename: "narration" + take.audioExt + ".timing.json"
2596
+ }
2597
+ ];
2598
+ const next = assets.map(({ blob, ...asset }) => ({ ...asset, url: URL.createObjectURL(blob) }));
2599
+ setDownloads(next);
2600
+ return () => {
2601
+ for (const download of next) URL.revokeObjectURL(download.url);
2602
+ };
2603
+ }, [take, baseWpm]);
2604
+ return downloads.map(({ label, url, filename }) => /* @__PURE__ */ jsx4("a", { href: url, download: filename, children: label }, label));
2605
+ }
2520
2606
  function NarrationStage(props) {
2521
2607
  const {
2522
2608
  stage,
@@ -2632,32 +2718,45 @@ function NarrationStage(props) {
2632
2718
  const script = controller.script;
2633
2719
  const portalOpen = float.portalTarget !== null;
2634
2720
  const busyRecording = recorder.state === "recording" || recorder.state === "starting";
2635
- const recordSlot = recording && showRecordSlot ? /* @__PURE__ */ jsx4("span", { className: "squisq-teleprompter-group", "data-testid": "teleprompter-record", children: recorder.state === "idle" || recorder.state === "error" ? /* @__PURE__ */ jsxs3(Fragment3, { children: [
2636
- /* @__PURE__ */ jsx4(
2637
- "button",
2638
- {
2639
- type: "button",
2640
- onClick: () => void recorder.start(),
2641
- title: "Record narration while you read",
2642
- children: "\u23FA Record"
2643
- }
2644
- ),
2645
- showCameraToggleInRecordSlot ? /* @__PURE__ */ jsxs3("label", { title: "Also capture your camera as a separate video file", children: [
2721
+ const recordSlot = recording && showRecordSlot ? /* @__PURE__ */ jsxs3("span", { className: "squisq-teleprompter-group", "data-testid": "teleprompter-record", children: [
2722
+ /* @__PURE__ */ jsxs3("span", { children: [
2723
+ "Auto-stops at ",
2724
+ formatRecordingBytes(recorder.maxRecordingBytes),
2725
+ "."
2726
+ ] }),
2727
+ recorder.state === "idle" || recorder.state === "error" ? /* @__PURE__ */ jsxs3(Fragment3, { children: [
2646
2728
  /* @__PURE__ */ jsx4(
2647
- "input",
2729
+ "button",
2648
2730
  {
2649
- type: "checkbox",
2650
- checked: recorder.withCamera,
2651
- onChange: (e) => recorder.setWithCamera(e.target.checked)
2731
+ type: "button",
2732
+ onClick: () => void recorder.start(),
2733
+ title: "Record narration while you read",
2734
+ children: "\u23FA Record"
2652
2735
  }
2653
2736
  ),
2654
- "camera"
2655
- ] }) : null,
2656
- recorder.state === "error" ? /* @__PURE__ */ jsxs3("span", { title: recorder.error?.message, children: [
2657
- "\u26A0 ",
2658
- recorder.error?.message
2659
- ] }) : null
2660
- ] }) : 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;
2737
+ showCameraToggleInRecordSlot ? /* @__PURE__ */ jsxs3("label", { title: "Also capture your camera as a separate video file", children: [
2738
+ /* @__PURE__ */ jsx4(
2739
+ "input",
2740
+ {
2741
+ type: "checkbox",
2742
+ checked: recorder.withCamera,
2743
+ onChange: (e) => recorder.setWithCamera(e.target.checked)
2744
+ }
2745
+ ),
2746
+ "camera"
2747
+ ] }) : null,
2748
+ recorder.state === "error" ? /* @__PURE__ */ jsxs3("span", { title: recorder.error?.message, children: [
2749
+ "\u26A0 ",
2750
+ recorder.error?.message
2751
+ ] }) : null
2752
+ ] }) : busyRecording ? /* @__PURE__ */ jsxs3(Fragment3, { children: [
2753
+ /* @__PURE__ */ jsxs3("span", { children: [
2754
+ formatRecordingBytes(recorder.recordedBytes),
2755
+ " recorded"
2756
+ ] }),
2757
+ /* @__PURE__ */ jsx4("button", { type: "button", onClick: () => void recorder.stop(), children: "\u23F9 Stop" })
2758
+ ] }) : recorder.state === "processing" ? /* @__PURE__ */ jsx4("span", { children: "Aligning take\u2026" }) : recorder.state === "saving" ? /* @__PURE__ */ jsx4("span", { children: "Saving\u2026" }) : null
2759
+ ] }) : null;
2661
2760
  return /* @__PURE__ */ jsxs3("div", { ref: rootRef, className: "squisq-teleprompter-root", "data-testid": "teleprompter-view", children: [
2662
2761
  /* @__PURE__ */ jsxs3("div", { className: "squisq-teleprompter-stage", children: [
2663
2762
  portalOpen ? /* @__PURE__ */ jsxs3("div", { className: "squisq-teleprompter-float-note", children: [
@@ -2689,6 +2788,11 @@ function NarrationStage(props) {
2689
2788
  ) : null,
2690
2789
  showSelfView ? /* @__PURE__ */ jsx4(TeleprompterSelfView, { stream: recorder.cameraStream }) : null
2691
2790
  ] }),
2791
+ recorder.limitReached ? /* @__PURE__ */ jsxs3("div", { className: "squisq-teleprompter-review", role: "status", children: [
2792
+ "Recording stopped at the ",
2793
+ formatRecordingBytes(recorder.maxRecordingBytes),
2794
+ " size limit. The complete take is retained; save it or download a backup before leaving."
2795
+ ] }) : null,
2692
2796
  recorder.state === "review" && recorder.take ? /* @__PURE__ */ jsxs3("div", { className: "squisq-teleprompter-review", "data-testid": "teleprompter-review", children: [
2693
2797
  /* @__PURE__ */ jsxs3("span", { children: [
2694
2798
  "Take: ",
@@ -2704,14 +2808,16 @@ function NarrationStage(props) {
2704
2808
  onTimeUpdate: stage.handleReviewTimeUpdate
2705
2809
  }
2706
2810
  ) : null,
2811
+ /* @__PURE__ */ jsx4(NarrationTakeBackups, { take: recorder.take, baseWpm: controller.prefs.baseWpm }),
2707
2812
  showReviewActions ? /* @__PURE__ */ jsxs3(Fragment3, { children: [
2708
2813
  /* @__PURE__ */ jsx4("button", { type: "button", onClick: () => void stage.handleSave(), children: "\u2713 Save narration" }),
2709
2814
  /* @__PURE__ */ jsx4("button", { type: "button", onClick: stage.handleRetake, children: "\u21BA Retake" }),
2710
2815
  /* @__PURE__ */ jsx4("button", { type: "button", onClick: stage.handleDiscard, children: "\u2715 Discard" })
2711
2816
  ] }) : null,
2712
- recorder.error ? /* @__PURE__ */ jsxs3("span", { children: [
2817
+ recorder.error ? /* @__PURE__ */ jsxs3("span", { role: "alert", children: [
2713
2818
  "\u26A0 ",
2714
- recorder.error.message
2819
+ recorder.error.message,
2820
+ " Your take is still available. Download a backup before leaving."
2715
2821
  ] }) : null
2716
2822
  ] }) : null,
2717
2823
  stage.saveNotice ? /* @__PURE__ */ jsxs3("div", { className: "squisq-teleprompter-review", "data-testid": "teleprompter-save-notice", children: [
@@ -2731,6 +2837,8 @@ function NarrationStage(props) {
2731
2837
  }
2732
2838
 
2733
2839
  export {
2840
+ resolveMaxRecordingBytes,
2841
+ formatRecordingBytes,
2734
2842
  resolveFormat,
2735
2843
  supportsMediaRecorder,
2736
2844
  supportsUserMedia,
@@ -7,9 +7,11 @@ import {
7
7
  buildTimingJson,
8
8
  encodeNarrationTimingJson,
9
9
  encodeTimingJson,
10
+ formatRecordingBytes,
10
11
  requestCameraStream,
11
12
  requestMicStream,
12
13
  resolveFormat,
14
+ resolveMaxRecordingBytes,
13
15
  supportsDisplayMedia,
14
16
  supportsMediaRecorder,
15
17
  supportsSystemAudioCapture,
@@ -17,7 +19,7 @@ import {
17
19
  timingPathFor,
18
20
  useNarrationStage,
19
21
  useStreamPreview
20
- } from "./chunk-PMNNJYFD.js";
22
+ } from "./chunk-34FRFDQ6.js";
21
23
 
22
24
  // src/recorder/sources/screenStream.ts
23
25
  function mixAudioTracks(streams) {
@@ -245,6 +247,9 @@ function getCaptureKind(source) {
245
247
  }
246
248
  function useMediaRecorder(options = {}) {
247
249
  const [state, setState] = useState("idle");
250
+ const [recordedBytes, setRecordedBytes] = useState(0);
251
+ const [limitReached, setLimitReached] = useState(false);
252
+ const recordedBytesRef = useRef(0);
248
253
  const [stream, setStream] = useState(null);
249
254
  const [blob, setBlob] = useState(null);
250
255
  const [format, setFormat] = useState(null);
@@ -320,7 +325,23 @@ function useMediaRecorder(options = {}) {
320
325
  setCamera(null);
321
326
  setCameraOffsetSec(null);
322
327
  }, []);
328
+ const resetByteCount = useCallback(() => {
329
+ recordedBytesRef.current = 0;
330
+ setRecordedBytes(0);
331
+ setLimitReached(false);
332
+ }, []);
333
+ const recordChunk = useCallback((data, chunks) => {
334
+ if (data.size === 0) return;
335
+ chunks.push(data);
336
+ recordedBytesRef.current += data.size;
337
+ setRecordedBytes(recordedBytesRef.current);
338
+ if (stateRef.current === "recording" && recordedBytesRef.current >= resolveMaxRecordingBytes(optionsRef.current.maxRecordingBytes)) {
339
+ setLimitReached(true);
340
+ void stopFnRef.current?.();
341
+ }
342
+ }, []);
323
343
  const reset = useCallback(() => {
344
+ resetByteCount();
324
345
  setBlob(null);
325
346
  setDurationMs(0);
326
347
  setError(null);
@@ -336,8 +357,9 @@ function useMediaRecorder(options = {}) {
336
357
  } else {
337
358
  cancelFnRef.current?.();
338
359
  }
339
- }, [clearTicker, transition]);
360
+ }, [clearTicker, resetByteCount, transition]);
340
361
  const cancel = useCallback(() => {
362
+ resetByteCount();
341
363
  lifecycleRef.current += 1;
342
364
  const rec = recorderRef.current;
343
365
  if (rec && rec.state !== "inactive") {
@@ -363,7 +385,7 @@ function useMediaRecorder(options = {}) {
363
385
  setDurationMs(0);
364
386
  setError(null);
365
387
  transition("idle");
366
- }, [clearTicker, releaseStream, releaseSecondary, transition]);
388
+ }, [clearTicker, releaseStream, releaseSecondary, resetByteCount, transition]);
367
389
  const request = useCallback(async () => {
368
390
  if (requestPromiseRef.current) return requestPromiseRef.current;
369
391
  if (recorderRef.current?.stream.active) return;
@@ -471,7 +493,8 @@ function useMediaRecorder(options = {}) {
471
493
  stopPromiseRef.current = null;
472
494
  };
473
495
  primary.ondataavailable = (e) => {
474
- if (e.data && e.data.size > 0) chunksRef.current.push(e.data);
496
+ if (lifecycle !== lifecycleRef.current || recorderRef.current !== primary) return;
497
+ if (e.data) recordChunk(e.data, chunksRef.current);
475
498
  };
476
499
  primary.onstart = () => {
477
500
  primaryStartMsRef.current = performance.now();
@@ -479,7 +502,9 @@ function useMediaRecorder(options = {}) {
479
502
  primary.onstop = () => laneStopped(primary);
480
503
  primary.onerror = (e) => laneError(primary, e);
481
504
  secondary.ondataavailable = (e) => {
482
- if (e.data && e.data.size > 0) secondaryRef.current?.chunks.push(e.data);
505
+ const lane = secondaryRef.current;
506
+ if (lifecycle !== lifecycleRef.current || lane?.recorder !== secondary) return;
507
+ if (e.data) recordChunk(e.data, lane.chunks);
483
508
  };
484
509
  secondary.onstart = () => {
485
510
  secondaryStartMsRef.current = performance.now();
@@ -526,7 +551,8 @@ function useMediaRecorder(options = {}) {
526
551
  }
527
552
  const recorder = new MediaRecorder(nextStream, recorderOptions);
528
553
  recorder.ondataavailable = (e) => {
529
- if (e.data && e.data.size > 0) chunksRef.current.push(e.data);
554
+ if (lifecycle !== lifecycleRef.current || recorderRef.current !== recorder) return;
555
+ if (e.data) recordChunk(e.data, chunksRef.current);
530
556
  };
531
557
  recorder.onstop = () => {
532
558
  if (recorderRef.current !== recorder || lifecycle !== lifecycleRef.current) return;
@@ -585,7 +611,7 @@ function useMediaRecorder(options = {}) {
585
611
  })();
586
612
  requestPromiseRef.current = requestPromise;
587
613
  return requestPromise;
588
- }, [clearTicker, deactivateCapture, transition]);
614
+ }, [clearTicker, deactivateCapture, recordChunk, transition]);
589
615
  const start = useCallback(() => {
590
616
  const rec = recorderRef.current;
591
617
  if (!rec) {
@@ -595,6 +621,7 @@ function useMediaRecorder(options = {}) {
595
621
  return;
596
622
  }
597
623
  if (rec.state === "recording") return;
624
+ resetByteCount();
598
625
  chunksRef.current = [];
599
626
  if (secondaryRef.current) secondaryRef.current.chunks = [];
600
627
  setBlob(null);
@@ -634,7 +661,7 @@ function useMediaRecorder(options = {}) {
634
661
  setDurationMs(Date.now() - startTimestampRef.current);
635
662
  }
636
663
  }, 100);
637
- }, [clearTicker, deactivateCapture, transition]);
664
+ }, [clearTicker, deactivateCapture, resetByteCount, transition]);
638
665
  const getElapsedMs = useCallback(() => {
639
666
  if (stateRef.current !== "recording") return null;
640
667
  const startedAt = startTimestampRef.current;
@@ -647,6 +674,10 @@ function useMediaRecorder(options = {}) {
647
674
  return Promise.resolve(blob);
648
675
  }
649
676
  transition("stopping");
677
+ clearTicker();
678
+ if (startTimestampRef.current !== null) {
679
+ setDurationMs(Math.max(0, Date.now() - startTimestampRef.current));
680
+ }
650
681
  const secondary = secondaryRef.current;
651
682
  const secondaryActive = secondary != null && secondary.recorder.state !== "inactive";
652
683
  pendingLanesRef.current = secondaryActive ? 2 : 1;
@@ -706,6 +737,9 @@ function useMediaRecorder(options = {}) {
706
737
  };
707
738
  }, [releaseStream, releaseSecondary, clearTicker]);
708
739
  return {
740
+ recordedBytes,
741
+ maxRecordingBytes: resolveMaxRecordingBytes(options.maxRecordingBytes),
742
+ limitReached,
709
743
  state,
710
744
  stream,
711
745
  blob,
@@ -2397,7 +2431,8 @@ function RecorderModal({
2397
2431
  onSave,
2398
2432
  narration = null,
2399
2433
  slides = null,
2400
- title = "Record media"
2434
+ title = "Record media",
2435
+ maxRecordingBytes
2401
2436
  }) {
2402
2437
  const initialToggles = toggleStateFromMode(initialMode);
2403
2438
  const [micOn, setMicOn] = useState3(initialToggles.micOn);
@@ -2454,6 +2489,7 @@ function RecorderModal({
2454
2489
  const encoding = deviceSettings.encoding;
2455
2490
  const recorder = useMediaRecorder({
2456
2491
  source,
2492
+ maxRecordingBytes,
2457
2493
  includeMicrophone: cameraOn ? micOn : void 0,
2458
2494
  audioConstraints,
2459
2495
  videoConstraints: cameraConstraints,
@@ -2477,6 +2513,7 @@ function RecorderModal({
2477
2513
  basenameRef.current = basename;
2478
2514
  const stage = useNarrationStage({
2479
2515
  doc: narration?.doc ?? null,
2516
+ maxRecordingBytes,
2480
2517
  recording: narration?.recording ?? null,
2481
2518
  getAudioBasename: () => basenameRef.current.trim() || void 0,
2482
2519
  micConstraints: audioConstraints,
@@ -2681,6 +2718,13 @@ function RecorderModal({
2681
2718
  }
2682
2719
  }, [captureKey, recorder]);
2683
2720
  const handleClose = useCallback4(() => {
2721
+ if (!narrationOn) {
2722
+ if (isSaving || recorder.state === "stopping" || recorder.state === "requesting") return;
2723
+ if ((recorder.blob || recorder.camera?.blob || recorder.state === "recording") && !window.confirm(
2724
+ "Discard this recording? Download or save it before closing to keep a copy."
2725
+ ))
2726
+ return;
2727
+ }
2684
2728
  const s = stageRef.current;
2685
2729
  if (closeNeedsConfirm(narrationOn, s.recorder.state, s.recorder.take !== null)) {
2686
2730
  if (!window.confirm("Discard the current narration take?")) return;
@@ -2694,7 +2738,7 @@ function RecorderModal({
2694
2738
  }
2695
2739
  recorder.cancel();
2696
2740
  onClose();
2697
- }, [narrationOn, recorder, onClose]);
2741
+ }, [narrationOn, recorder, onClose, isSaving]);
2698
2742
  useModalDialog({
2699
2743
  rootRef: overlayRef,
2700
2744
  dialogRef,
@@ -2822,7 +2866,8 @@ function RecorderModal({
2822
2866
  };
2823
2867
  dualSaveProgressRef.current = {};
2824
2868
  onSave?.(result);
2825
- handleClose();
2869
+ recorder.cancel();
2870
+ onClose();
2826
2871
  } catch (err) {
2827
2872
  setSaveError(err instanceof Error ? err.message : "Failed to save recording");
2828
2873
  } finally {
@@ -2876,7 +2921,8 @@ function RecorderModal({
2876
2921
  result.sourceText = slideTiming ? void 0 : sourceText;
2877
2922
  }
2878
2923
  onSave?.(result);
2879
- handleClose();
2924
+ recorder.cancel();
2925
+ onClose();
2880
2926
  } catch (err) {
2881
2927
  setSaveError(err instanceof Error ? err.message : "Failed to save recording");
2882
2928
  } finally {
@@ -2895,7 +2941,7 @@ function RecorderModal({
2895
2941
  mediaProvider,
2896
2942
  container,
2897
2943
  onSave,
2898
- handleClose,
2944
+ onClose,
2899
2945
  writeSlideTimingSidecar
2900
2946
  ]);
2901
2947
  const handleDiscard = useCallback4(() => {
@@ -2927,8 +2973,47 @@ function RecorderModal({
2927
2973
  () => slides?.doc && showTimingCheckbox ? advanceCoverage(slides.doc, advanceLog) : null,
2928
2974
  [slides?.doc, showTimingCheckbox, advanceLog]
2929
2975
  );
2976
+ const [timingDownloadUrl, setTimingDownloadUrl] = useState3(null);
2977
+ const downloadFilename = useMemo(
2978
+ () => !recorder.blob ? "" : buildFilename(
2979
+ source === "mic" ? "audio" : "video",
2980
+ recorder.extension ?? ".webm",
2981
+ isDual && basename.trim() ? `${basename.trim()}-screen` : basename,
2982
+ filenameSeed
2983
+ ),
2984
+ [source, recorder.extension, recorder.blob, isDual, basename, filenameSeed]
2985
+ );
2986
+ const timingDownloadFilename = `${downloadFilename}.timing.json`;
2987
+ useEffect4(() => {
2988
+ if (narrationOn || !canSave) {
2989
+ setTimingDownloadUrl(null);
2990
+ return;
2991
+ }
2992
+ const duration = recorder.durationMs / 1e3;
2993
+ const encoded = slidesOn && slidesCaptureTimings && applySlideTimings && slides?.doc && advanceLog.length > 0 ? encodeNarrationTimingJson(buildAdvanceTimingJson(slides.doc, advanceLog, duration)) : source === "mic" ? encodeTimingJson(buildTimingJson(sourceText, duration)) : null;
2994
+ if (encoded === null) {
2995
+ setTimingDownloadUrl(null);
2996
+ return;
2997
+ }
2998
+ const url = URL.createObjectURL(
2999
+ new Blob([new Uint8Array(encoded)], { type: "application/json" })
3000
+ );
3001
+ setTimingDownloadUrl(url);
3002
+ return () => URL.revokeObjectURL(url);
3003
+ }, [
3004
+ narrationOn,
3005
+ canSave,
3006
+ recorder.durationMs,
3007
+ slidesOn,
3008
+ slidesCaptureTimings,
3009
+ applySlideTimings,
3010
+ slides?.doc,
3011
+ advanceLog,
3012
+ source,
3013
+ sourceText
3014
+ ]);
2930
3015
  const slideCoverageWarning = slideCoverage ? unshownSlidesWarning(slideCoverage.total, slideCoverage.unshown) : null;
2931
- const togglesLocked = recorder.state === "recording" || recorder.state === "requesting" || canSave;
3016
+ const togglesLocked = recorder.state === "recording" || recorder.state === "requesting" || recorder.state === "stopping" || canSave;
2932
3017
  const toggleLockReason = canSave ? "Save or discard this recording before changing sources" : void 0;
2933
3018
  const systemAudioHasCompanion = micOn || cameraOn || screenOn;
2934
3019
  const simpleToggleProps = (key) => {
@@ -3110,7 +3195,25 @@ function RecorderModal({
3110
3195
  }
3111
3196
  ),
3112
3197
  !narrationOn && recorder.error && /* @__PURE__ */ jsx5("div", { style: errorStyle, children: recorder.error.message }),
3113
- !narrationOn && saveError && /* @__PURE__ */ jsx5("div", { style: errorStyle, children: saveError }),
3198
+ !narrationOn && saveError && /* @__PURE__ */ jsxs4("div", { role: "alert", style: errorStyle, children: [
3199
+ saveError,
3200
+ /* @__PURE__ */ jsx5("p", { style: { marginBottom: 0 }, children: "Your recording is still available below. Download a copy before closing, or try saving again." })
3201
+ ] }),
3202
+ /* @__PURE__ */ jsxs4("p", { style: summaryStyle, children: [
3203
+ "Recording size:",
3204
+ " ",
3205
+ formatRecordingBytes(
3206
+ narrationOn ? stage.recorder.recordedBytes : recorder.recordedBytes
3207
+ ),
3208
+ ". Automatically stops at",
3209
+ " ",
3210
+ formatRecordingBytes(
3211
+ narrationOn ? stage.recorder.maxRecordingBytes : recorder.maxRecordingBytes
3212
+ ),
3213
+ " ",
3214
+ "total. Final encoding may add more data."
3215
+ ] }),
3216
+ !narrationOn && recorder.limitReached && /* @__PURE__ */ jsx5("p", { role: "status", style: recordingStatusStyle, children: "Recording stopped at the size limit. Your complete recording is retained; download a copy or save it to the document." }),
3114
3217
  narrationOn && (stage.recorder.error ?? stage.controller.mic.error) && /* @__PURE__ */ jsx5("div", { style: errorStyle, children: (stage.recorder.error ?? stage.controller.mic.error)?.message }),
3115
3218
  narrationOn && narrationCameraStream && /* @__PURE__ */ jsx5("div", { style: previewBoxStyle, children: /* @__PURE__ */ jsx5(
3116
3219
  "video",
@@ -3226,6 +3329,43 @@ function RecorderModal({
3226
3329
  ] }),
3227
3330
  /* @__PURE__ */ jsx5("audio", { src: playbackUrl, controls: true, style: { width: "100%" } })
3228
3331
  ] }),
3332
+ !narrationOn && canSave && playbackUrl && /* @__PURE__ */ jsxs4(
3333
+ "div",
3334
+ {
3335
+ style: {
3336
+ ...buttonRowStyle,
3337
+ flexWrap: "wrap",
3338
+ justifyContent: "flex-start",
3339
+ marginBottom: 12
3340
+ },
3341
+ children: [
3342
+ /* @__PURE__ */ jsx5("a", { href: playbackUrl, download: downloadFilename, style: btnSecondary, children: isDual ? "Download screen recording" : "Download recording" }),
3343
+ isDual && cameraPlaybackUrl && /* @__PURE__ */ jsx5(
3344
+ "a",
3345
+ {
3346
+ href: cameraPlaybackUrl,
3347
+ download: buildFilename(
3348
+ "video",
3349
+ recorder.camera?.extension ?? ".webm",
3350
+ basename.trim() ? `${basename.trim()}-camera` : void 0,
3351
+ micOn ? "camera-audio" : "camera"
3352
+ ),
3353
+ style: btnSecondary,
3354
+ children: "Download camera recording"
3355
+ }
3356
+ ),
3357
+ timingDownloadUrl && /* @__PURE__ */ jsx5(
3358
+ "a",
3359
+ {
3360
+ href: timingDownloadUrl,
3361
+ download: timingDownloadFilename,
3362
+ style: btnSecondary,
3363
+ children: "Download recording timings"
3364
+ }
3365
+ )
3366
+ ]
3367
+ }
3368
+ ),
3229
3369
  !narrationOn && !slidesTimingCapture && source === "mic" && /* @__PURE__ */ jsxs4(Fragment3, { children: [
3230
3370
  /* @__PURE__ */ jsx5("label", { style: labelStyle, htmlFor: "recorder-source-text", children: "Script (used to auto-match this narration to a block)" }),
3231
3371
  /* @__PURE__ */ jsx5(