forked from getopenscreen/openscreen
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathuseScreenRecorder.ts
More file actions
2305 lines (2117 loc) · 76.4 KB
/
Copy pathuseScreenRecorder.ts
File metadata and controls
2305 lines (2117 loc) · 76.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { fixWebmDuration } from "@fix-webm-duration/fix";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useScopedT } from "@/contexts/I18nContext";
import { MIC_GAIN_BOOST, mixAudioTracks } from "@/lib/audioMix";
import {
type NativeLinuxRecordingRequest,
portalOwnsSourceSelection,
} from "@/lib/nativeLinuxRecording";
import {
type NativeMacRecordingRequest,
parseMacDisplayIdFromSourceId,
parseMacWindowIdFromSourceId,
} from "@/lib/nativeMacRecording";
import {
type NativeWindowsRecordingRequest,
parseWindowHandleFromSourceId,
} from "@/lib/nativeWindowsRecording";
import type { CursorCaptureMode, RecordedVideoAssetInput } from "@/lib/recordingSession";
import { requestCameraAccess } from "@/lib/requestCameraAccess";
import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences";
import { createRecorderHandle, type RecorderHandle } from "./recorderHandle";
import { webcamDeviceIdentityFrom } from "./webcamDeviceIdentity";
const TARGET_FRAME_RATE = 60;
const MIN_FRAME_RATE = 30;
const TARGET_WIDTH = 3840;
const TARGET_HEIGHT = 2160;
const FOUR_K_PIXELS = TARGET_WIDTH * TARGET_HEIGHT;
const QHD_WIDTH = 2560;
const QHD_HEIGHT = 1440;
const QHD_PIXELS = QHD_WIDTH * QHD_HEIGHT;
const BITRATE_4K = 45_000_000;
const BITRATE_QHD = 28_000_000;
const BITRATE_BASE = 18_000_000;
const HIGH_FRAME_RATE_THRESHOLD = 60;
const HIGH_FRAME_RATE_BOOST = 1.7;
const DEFAULT_WIDTH = 1920;
const DEFAULT_HEIGHT = 1080;
const CODEC_ALIGNMENT = 2;
const BITS_PER_MEGABIT = 1_000_000;
const CHROME_MEDIA_SOURCE = "desktop";
const RECORDING_FILE_PREFIX = "recording-";
const VIDEO_FILE_EXTENSION = ".webm";
const WEBCAM_FILE_SUFFIX = "-webcam";
const AUDIO_BITRATE_VOICE = 128_000;
const AUDIO_BITRATE_SYSTEM = 192_000;
const WEBCAM_TARGET_FRAME_RATE = 30;
type UseScreenRecorderReturn = {
recording: boolean;
paused: boolean;
saving: boolean;
elapsedSeconds: number;
toggleRecording: () => void;
/** Starts recording with no countdown overlay. Used by the headless CLI runner. */
startRecordingImmediately: () => Promise<void>;
togglePaused: () => void;
canPauseRecording: boolean;
restartRecording: () => void;
cancelRecording: () => void;
microphoneEnabled: boolean;
setMicrophoneEnabled: (enabled: boolean) => void;
microphoneDeviceId: string | undefined;
setMicrophoneDeviceId: (deviceId: string | undefined) => void;
microphoneDeviceName: string | undefined;
setMicrophoneDeviceName: (deviceName: string | undefined) => void;
webcamDeviceId: string | undefined;
setWebcamDeviceId: (deviceId: string | undefined) => void;
webcamDeviceName: string | undefined;
setWebcamDeviceName: (deviceName: string | undefined) => void;
systemAudioEnabled: boolean;
setSystemAudioEnabled: (enabled: boolean) => void;
webcamEnabled: boolean;
setWebcamEnabled: (enabled: boolean) => Promise<boolean>;
cursorCaptureMode: CursorCaptureMode;
setCursorCaptureMode: (mode: CursorCaptureMode) => void;
softwareEncoderFallbackNoticeVisible: boolean;
dismissSoftwareEncoderFallbackNotice: (dontShowAgain?: boolean) => void;
};
type NativeWindowsRecordingHandle = {
recordingId: number;
finalizing: boolean;
paused: boolean;
};
type NativeMacRecordingHandle = {
recordingId: number;
finalizing: boolean;
paused: boolean;
/**
* Milliseconds the browser-recorded webcam clip started before the native
* macOS helper confirmed its screen recording actually began (negative --
* the webcam MediaRecorder starts immediately in the renderer, but the
* ScreenCaptureKit helper needs to spawn a process and start capturing
* before its own recording truly starts). `null` if webcam wasn't
* recorded via the browser sidecar for this session.
*/
webcamOffsetMs: number | null;
};
type NativeLinuxRecordingHandle = {
recordingId: number;
finalizing: boolean;
paused: boolean;
/**
* As on macOS: the webcam MediaRecorder starts immediately in the renderer,
* while the helper has to spawn, negotiate a portal session and WAIT FOR THE
* USER to answer a picker before its first frame exists. That last part makes
* the gap here unbounded rather than merely a process spawn, so trimming it
* matters more than it does on macOS. `null` when no webcam was recorded.
*/
webcamOffsetMs: number | null;
};
/**
* How far AHEAD of the native screen recording the browser-recorded webcam
* started, in whole milliseconds (negative, since the webcam always starts
* first). `null` when this session recorded no webcam.
*
* WHOLE milliseconds on purpose. Both timestamps come from `performance.now()`,
* whose resolution is 100 µs, so the raw subtraction is almost never an integer
* — and this number ends up in `cameraTrack.offsetMs`, which the document schema
* declares as an int. A fractional value failed validation, the camera link was
* dropped as if the recording had no camera, and the editor drew the screen
* video in the camera's place. Rounding loses nothing: one frame at 60 fps is
* 16.7 ms.
*/
export function webcamOffsetMsFrom(
webcamRecorder: RecorderHandle | null,
webcamStartedAtMs: number | null,
nativeStartedAtMs: number,
): number | null {
if (!webcamRecorder || webcamStartedAtMs === null) {
return null;
}
return -Math.round(nativeStartedAtMs - webcamStartedAtMs);
}
/**
* Turn a finished webcam recorder into the asset the native attach IPC wants, or
* into the reason it cannot be saved. Shared by the macOS and Linux finalizers,
* which differ only in the name they log under.
*
* A streamed recording resolves an empty blob by design — its bytes are already
* on disk — so it hands over the file name alone and the main process closes the
* stream and patches the duration there. Only a buffered recording is read into
* memory, and flattening one of those into a single ArrayBuffer is exactly what
* used to throw past ~2 GB and cost the user the whole camera track (#253).
*
* Never resolves to "nothing happened": every failure comes back with a reason,
* because the screen recording still saves and a silent drop just opens the
* editor with the camera mysteriously absent.
*/
export async function finalizeWebcamAsset(
webcamRecorder: RecorderHandle,
fileName: string,
durationMs: number,
platformLabel: string,
): Promise<{ asset?: RecordedVideoAssetInput; error?: string }> {
try {
if (webcamRecorder.recorder.state !== "inactive") {
webcamRecorder.recorder.stop();
}
// Rejects on a mid-stream write failure, so a truncated recording lands in
// the catch below rather than passing for a good one.
const webcamBlob = await webcamRecorder.recordedBlobPromise;
if (webcamRecorder.isStreaming()) {
return { asset: { videoData: new ArrayBuffer(0), fileName } };
}
if (!webcamBlob || webcamBlob.size === 0) {
return { error: "the webcam produced no data" };
}
const fixedWebcamBlob = await fixWebmDuration(webcamBlob, durationMs);
return { asset: { videoData: await fixedWebcamBlob.arrayBuffer(), fileName } };
} catch (error) {
console.error(`Failed to finalize native ${platformLabel} webcam recording:`, error);
return { error: error instanceof Error ? error.message : String(error) };
}
}
export function useScreenRecorder(): UseScreenRecorderReturn {
const t = useScopedT("editor");
/**
* `t` through a ref, for the callbacks that must not be rebuilt when it
* changes identity.
*
* `finalizeNativeWindowsRecording` is one of them: it sits in the dependency
* array of the unmount effect below, whose cleanup bumps `countdownRunId` and
* discards any native recording in flight. Recreating that callback therefore
* re-runs the effect, and its cleanup silently cancels the countdown a
* recording is starting from — the take never begins, with nothing logged.
*/
const tRef = useRef(t);
// In an effect, not during render: a render React discards still leaves a ref
// written there, and a later recording error would then be worded by a UI that
// never reached the screen.
useEffect(() => {
tRef.current = t;
}, [t]);
const [recording, setRecording] = useState(false);
const [paused, setPaused] = useState(false);
const [saving, setSaving] = useState(false);
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
const [microphoneDeviceId, setMicrophoneDeviceId] = useState<string | undefined>(undefined);
const [microphoneDeviceName, setMicrophoneDeviceName] = useState<string | undefined>(undefined);
const [webcamDeviceId, setWebcamDeviceId] = useState<string | undefined>(undefined);
const [webcamDeviceName, setWebcamDeviceName] = useState<string | undefined>(undefined);
const [systemAudioEnabled, setSystemAudioEnabled] = useState(false);
const [webcamEnabled, setWebcamEnabledState] = useState(false);
const [cursorCaptureMode, setCursorCaptureMode] = useState<CursorCaptureMode>("editable-overlay");
const [softwareEncoderFallbackNoticeVisible, setSoftwareEncoderFallbackNoticeVisible] =
useState(false);
// Seed from the main-process recording-prefs SSOT on mount, so choices
// made in the editor's Rec-mode stage (a different renderer window) carry
// over instead of this hook silently reverting to its own hardcoded
// defaults every time startNewRecording() switches to the HUD window.
useEffect(() => {
let cancelled = false;
void window.electronAPI
?.getRecordingPrefs?.()
.then((prefs) => {
if (cancelled || !prefs) return;
setMicrophoneEnabled(prefs.micEnabled);
if (prefs.micDeviceId) setMicrophoneDeviceId(prefs.micDeviceId);
// The name matters as much as the id: the native Windows helper picks
// the microphone by NAME, and falls back to the Windows default
// endpoint when it is empty. Seeding only the id left an auto-started
// recording racing this window's own device enumeration for it, and
// losing (getopenscreen/openscreen#404).
if (prefs.micDeviceName) setMicrophoneDeviceName(prefs.micDeviceName);
setWebcamEnabledState(prefs.camEnabled);
if (prefs.camDeviceId) setWebcamDeviceId(prefs.camDeviceId);
setSystemAudioEnabled(prefs.systemAudioEnabled);
setCursorCaptureMode(prefs.cursorCaptureMode);
})
.catch((err) => {
// Bare ipcRenderer.invoke — rejects if the main handler throws. Falling
// back to this hook's own defaults is acceptable; an unhandled rejection
// on every HUD mount is not.
console.warn("Failed to seed the recording prefs:", err);
});
return () => {
cancelled = true;
};
}, []);
const screenRecorder = useRef<RecorderHandle | null>(null);
const webcamRecorder = useRef<RecorderHandle | null>(null);
const nativeWindowsRecording = useRef<NativeWindowsRecordingHandle | null>(null);
const nativeMacRecording = useRef<NativeMacRecordingHandle | null>(null);
const nativeLinuxRecording = useRef<NativeLinuxRecordingHandle | null>(null);
const stream = useRef<MediaStream | null>(null);
const screenStream = useRef<MediaStream | null>(null);
const microphoneStream = useRef<MediaStream | null>(null);
const webcamStream = useRef<MediaStream | null>(null);
const mixingContext = useRef<AudioContext | null>(null);
const recordingId = useRef<number>(0);
const accumulatedDurationMs = useRef(0);
const segmentStartedAt = useRef<number | null>(null);
const finalizingRecordingId = useRef<number | null>(null);
const allowAutoFinalize = useRef(false);
const discardRecordingId = useRef<number | null>(null);
const restarting = useRef(false);
const countdownRunId = useRef(0);
const [countdownActive, setCountdownActive] = useState(false);
const webcamReady = useRef(false);
const webcamAcquireId = useRef(0);
const canPauseRecording =
recording &&
Boolean(
(nativeWindowsRecording.current && !nativeWindowsRecording.current.finalizing) ||
(nativeMacRecording.current && !nativeMacRecording.current.finalizing) ||
(nativeLinuxRecording.current && !nativeLinuxRecording.current.finalizing) ||
(screenRecorder.current && screenRecorder.current.recorder.state !== "inactive"),
);
const getRecordingDurationMs = useCallback(() => {
const segmentDuration =
segmentStartedAt.current === null ? 0 : Date.now() - segmentStartedAt.current;
return accumulatedDurationMs.current + segmentDuration;
}, []);
const selectMimeType = () => {
// H.264 first: hardware-accelerated, so sharp real-time output. AV1/VP9 are
// better for distribution but too CPU-heavy for live 60 fps capture (software
// encoder falls behind and produces blurry frames).
const preferred = [
"video/webm;codecs=h264",
"video/webm;codecs=vp8",
"video/webm;codecs=vp9",
"video/webm;codecs=av1",
"video/webm",
];
return preferred.find((type) => MediaRecorder.isTypeSupported(type)) ?? "video/webm";
};
const computeBitrate = (width: number, height: number) => {
const pixels = width * height;
const highFrameRateBoost =
TARGET_FRAME_RATE >= HIGH_FRAME_RATE_THRESHOLD ? HIGH_FRAME_RATE_BOOST : 1;
if (pixels >= FOUR_K_PIXELS) {
return Math.round(BITRATE_4K * highFrameRateBoost);
}
if (pixels >= QHD_PIXELS) {
return Math.round(BITRATE_QHD * highFrameRateBoost);
}
return Math.round(BITRATE_BASE * highFrameRateBoost);
};
const teardownMedia = useCallback(() => {
if (stream.current) {
stream.current.getTracks().forEach((track) => track.stop());
stream.current = null;
}
if (screenStream.current) {
screenStream.current.getTracks().forEach((track) => track.stop());
screenStream.current = null;
}
if (microphoneStream.current) {
microphoneStream.current.getTracks().forEach((track) => track.stop());
microphoneStream.current = null;
}
if (mixingContext.current) {
mixingContext.current.close().catch(() => {
// Ignore close errors during recorder teardown.
});
mixingContext.current = null;
}
}, []);
/**
* The camera to name in a native capture request. See
* `webcamDeviceIdentityFrom` for why it is read off the live track rather than
* off this hook's two separate pieces of state. Must be called before
* `stopWebcamPreviewStream()`, which is why the Windows path captures it up
* front instead of at the point of use.
*/
const readWebcamDeviceIdentity = useCallback(
() => webcamDeviceIdentityFrom(webcamStream.current, webcamDeviceId, webcamDeviceName),
[webcamDeviceId, webcamDeviceName],
);
const stopWebcamPreviewStream = useCallback(() => {
if (!webcamStream.current) {
return;
}
webcamAcquireId.current++;
webcamStream.current.getTracks().forEach((track) => {
track.onended = null;
track.stop();
});
webcamStream.current = null;
webcamReady.current = true;
}, []);
const setWebcamEnabled = useCallback(
async (enabled: boolean) => {
if (!enabled) {
setWebcamEnabledState(false);
return true;
}
const accessResult = await requestCameraAccess();
if (!accessResult.success) {
toast.error(t("recording.failedCameraAccess"));
return false;
}
if (!accessResult.granted) {
toast.error(t("recording.cameraBlocked"));
return false;
}
setWebcamEnabledState(true);
return true;
},
[t],
);
useEffect(() => {
if (!webcamEnabled) return;
let cancelled = false;
let acquiredStream: MediaStream | null = null;
const thisAcquireId = ++webcamAcquireId.current;
webcamReady.current = false;
const acquire = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: webcamDeviceId
? {
deviceId: { exact: webcamDeviceId },
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
}
: {
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
},
});
if (cancelled || thisAcquireId !== webcamAcquireId.current) {
stream.getTracks().forEach((track) => {
track.onended = null;
track.stop();
});
return;
}
acquiredStream = stream;
stream.getVideoTracks().forEach((track) => {
track.onended = () => {
webcamStream.current = null;
if (!restarting.current) {
setWebcamEnabledState(false);
toast.error(t("recording.cameraDisconnected"));
}
};
});
webcamStream.current = stream;
webcamReady.current = true;
} catch (cameraError) {
if (!cancelled) {
console.warn("Failed to get webcam access:", cameraError);
setWebcamEnabledState(false);
const isDeviceError =
cameraError instanceof DOMException &&
[
"NotFoundError",
"DevicesNotFoundError",
"OverconstrainedError",
"NotReadableError",
].includes(cameraError.name);
toast.error(t(isDeviceError ? "recording.cameraNotFound" : "recording.cameraBlocked"));
webcamReady.current = true;
}
}
};
void acquire();
return () => {
cancelled = true;
webcamReady.current = false;
if (acquiredStream) {
acquiredStream.getTracks().forEach((track) => {
track.onended = null;
track.stop();
});
webcamStream.current = null;
}
};
}, [webcamEnabled, webcamDeviceId, t]);
const finalizeRecording = useCallback(
(
activeScreenRecorder: RecorderHandle,
activeWebcamRecorder: RecorderHandle | null,
duration: number,
activeRecordingId: number,
) => {
if (finalizingRecordingId.current === activeRecordingId) {
return;
}
finalizingRecordingId.current = activeRecordingId;
// Only show the "Saving…" spinner for genuine saves — not for cancel/restart
// flows where discardRecordingId has already been set.
const isDiscarded = discardRecordingId.current === activeRecordingId;
if (!isDiscarded) {
setSaving(true);
}
if (screenRecorder.current === activeScreenRecorder) {
screenRecorder.current = null;
}
if (activeWebcamRecorder && webcamRecorder.current === activeWebcamRecorder) {
webcamRecorder.current = null;
}
teardownMedia();
setRecording(false);
setPaused(false);
setElapsedSeconds(0);
accumulatedDurationMs.current = 0;
segmentStartedAt.current = null;
window.electronAPI?.setRecordingState(false);
void (async () => {
// Each disk stream must end up either saved or explicitly discarded.
// store-recorded-session finalizes the streams included in a successful
// save; the finally block discards everything else.
let storeSucceeded = false;
let webcamIncludedInSave = false;
try {
const screenBlob = await activeScreenRecorder.recordedBlobPromise;
if (discardRecordingId.current === activeRecordingId) {
window.electronAPI?.discardCursorTelemetry(activeRecordingId);
return;
}
// When streaming succeeded the blob is empty; the data is already on disk.
if (!activeScreenRecorder.isStreaming() && screenBlob.size === 0) {
return;
}
const screenFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${VIDEO_FILE_EXTENSION}`;
const webcamFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`;
// Only fix duration / convert to ArrayBuffer for in-memory data;
// streamed recordings are patched on disk by the main process.
let screenVideoData: ArrayBuffer = new ArrayBuffer(0);
if (!activeScreenRecorder.isStreaming() && screenBlob.size > 0) {
const fixedScreenBlob = await fixWebmDuration(screenBlob, duration);
screenVideoData = await fixedScreenBlob.arrayBuffer();
}
let webcamVideoData: ArrayBuffer | undefined;
if (activeWebcamRecorder) {
const webcamBlob = await activeWebcamRecorder.recordedBlobPromise.catch(() => null);
if (!activeWebcamRecorder.isStreaming() && webcamBlob && webcamBlob.size > 0) {
const fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration);
webcamVideoData = await fixedWebcamBlob.arrayBuffer();
} else if (activeWebcamRecorder.isStreaming()) {
webcamVideoData = new ArrayBuffer(0);
}
}
webcamIncludedInSave = webcamVideoData !== undefined;
const result = await window.electronAPI.storeRecordedSession({
screen: {
videoData: screenVideoData,
fileName: screenFileName,
},
webcam:
webcamVideoData !== undefined
? { videoData: webcamVideoData, fileName: webcamFileName }
: undefined,
createdAt: activeRecordingId,
cursorCaptureMode,
durationMs: duration,
});
if (!result.success) {
console.error("Failed to store recording session:", result.message);
return;
}
// store-recorded-session has flushed and closed the saved streams.
storeSucceeded = true;
if (result.session) {
await window.electronAPI.setCurrentRecordingSession(result.session);
} else if (result.path) {
await window.electronAPI.setCurrentVideoPath(result.path);
}
await window.electronAPI.switchToEditor();
} catch (error) {
console.error("Error saving recording:", error);
} finally {
// Discard any recorder whose data wasn't part of a successful save (discarded
// run, failed save, or a webcam whose disk write failed while the screen still
// saved) so no stream or partial file is left open or orphaned.
if (!storeSucceeded) {
await activeScreenRecorder.discard().catch(() => undefined);
}
if (activeWebcamRecorder && !(storeSucceeded && webcamIncludedInSave)) {
await activeWebcamRecorder.discard().catch(() => undefined);
}
if (finalizingRecordingId.current === activeRecordingId) {
finalizingRecordingId.current = null;
}
if (discardRecordingId.current === activeRecordingId) {
discardRecordingId.current = null;
}
setSaving(false);
}
})();
},
[cursorCaptureMode, teardownMedia],
);
const finalizeNativeWindowsRecording = useCallback(async (discard = false) => {
const activeNativeRecording = nativeWindowsRecording.current;
if (!activeNativeRecording || activeNativeRecording.finalizing) {
return false;
}
activeNativeRecording.finalizing = true;
if (!discard) {
setSaving(true);
}
const clearNativeRecordingState = () => {
nativeWindowsRecording.current = null;
setRecording(false);
setPaused(false);
setElapsedSeconds(0);
accumulatedDurationMs.current = 0;
segmentStartedAt.current = null;
};
try {
const result = await window.electronAPI.stopNativeWindowsRecording(discard);
if (discard || result.discarded) {
clearNativeRecordingState();
return true;
}
if (!result.success) {
console.error("Failed to stop native Windows recording:", result.error);
toast.error(result.error ?? "Failed to stop native Windows recording");
// Clear anyway. The main process releases its helper handle
// unconditionally, so holding on here left the two sides
// disagreeing about whether anything was recording: the HUD kept
// showing a stop button, and pressing it sent a second stop that
// came back "Native Windows capture is not running." (issue #252).
// Reaching here now means the take really is unreadable -- a failed
// stop that left a playable fragmented file comes back `success`
// with a session and takes the editor path below, so this branch no
// longer decides the fate of a recoverable recording.
clearNativeRecordingState();
return true;
}
clearNativeRecordingState();
// The other way a camera goes missing, and the quieter one: the device
// opened, so nothing warned at start, but it never produced a frame and
// the file it left behind was empty. Say so before the editor opens
// without a camera and leaves the user to work out why. Through `tRef`
// because this callback has to stay referentially stable — see the ref's
// own comment.
if (result.webcamDropped) {
toast.error(tRef.current("recording.cameraCaptureUnavailable"));
}
if (result.session) {
await window.electronAPI.setCurrentRecordingSession(result.session);
} else if (result.path) {
await window.electronAPI.setCurrentVideoPath(result.path);
}
await window.electronAPI.switchToEditor();
return true;
} catch (error) {
console.error("Error saving native Windows recording:", error);
toast.error(
error instanceof Error ? error.message : "Failed to save native Windows recording",
);
clearNativeRecordingState();
return true;
} finally {
if (discardRecordingId.current === activeNativeRecording.recordingId) {
discardRecordingId.current = null;
}
setSaving(false);
}
}, []);
const finalizeNativeMacRecording = useCallback(
async (discard = false) => {
const activeNativeRecording = nativeMacRecording.current;
if (!activeNativeRecording || activeNativeRecording.finalizing) {
return false;
}
activeNativeRecording.finalizing = true;
if (!discard) {
setSaving(true);
}
const duration = Math.max(0, getRecordingDurationMs());
const activeWebcamRecorder = webcamRecorder.current;
if (activeWebcamRecorder && webcamRecorder.current === activeWebcamRecorder) {
webcamRecorder.current = null;
}
// The webcam MediaRecorder started before the native recording did (see
// webcamOffsetMs on NativeMacRecordingHandle), so its real content is
// longer than the screen's active `duration` by that same head start.
// Patching the WebM's declared duration to the screen's shorter duration
// would make that extra leading footage unseekable in a standard <video>
// element (which trusts the container's declared duration/seek range) --
// exactly the footage the editor needs to skip into to compensate for
// webcamOffsetMs, so it must stay reachable.
const webcamHeadStartMs = Math.max(0, -(activeNativeRecording.webcamOffsetMs ?? 0));
const webcamDurationMs = duration + webcamHeadStartMs;
const webcamFileName = `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`;
const webcamResultPromise: Promise<{
asset?: RecordedVideoAssetInput;
error?: string;
}> = activeWebcamRecorder
? finalizeWebcamAsset(activeWebcamRecorder, webcamFileName, webcamDurationMs, "macOS")
: Promise.resolve({});
const clearNativeRecordingState = () => {
nativeMacRecording.current = null;
setRecording(false);
setPaused(false);
setElapsedSeconds(0);
accumulatedDurationMs.current = 0;
segmentStartedAt.current = null;
};
let webcamSaved = false;
try {
const result = await window.electronAPI.stopNativeMacRecording(discard);
const webcamResult = await webcamResultPromise;
if (discard || result.discarded) {
clearNativeRecordingState();
return true;
}
if (!result.success) {
console.error("Failed to stop native macOS recording:", result.error);
toast.error(result.error ?? "Failed to stop native macOS recording");
// See the Windows finalizer: the main process has already
// released its helper handle, so keeping ours leaves the HUD
// stuck in a recording state the app can never be stopped out
// of (issue #252).
clearNativeRecordingState();
return true;
}
if (webcamResult.asset && result.path) {
const attachResult = await window.electronAPI.attachNativeMacWebcamRecording({
screenVideoPath: result.path,
recordingId: activeNativeRecording.recordingId,
webcam: webcamResult.asset,
cursorCaptureMode,
durationMs: webcamDurationMs,
...(typeof activeNativeRecording.webcamOffsetMs === "number"
? { webcamOffsetMs: activeNativeRecording.webcamOffsetMs }
: {}),
});
if (attachResult.success) {
result.session = attachResult.session;
webcamSaved = true;
} else {
console.error("Failed to attach native macOS webcam recording:", attachResult.error);
toast.error(attachResult.error ?? "Failed to store webcam recording");
}
} else if (webcamResult.error) {
// The screen recording still saves, so without this the editor just
// opens with the camera missing and nothing said about it (#253).
toast.error(`Webcam not saved (${webcamResult.error}). The screen recording was kept.`);
}
clearNativeRecordingState();
if (result.session) {
await window.electronAPI.setCurrentRecordingSession(result.session);
} else if (result.path) {
await window.electronAPI.setCurrentVideoPath(result.path);
}
await window.electronAPI.switchToEditor();
return true;
} catch (error) {
console.error("Error saving native macOS recording:", error);
toast.error(
error instanceof Error ? error.message : "Failed to save native macOS recording",
);
clearNativeRecordingState();
return true;
} finally {
// A webcam stream that wasn't folded into a saved session has to be closed
// and its partial file removed, or a discarded or failed take orphans a
// half-written .webm now that the bytes go to disk as they arrive.
if (activeWebcamRecorder && !webcamSaved) {
await activeWebcamRecorder.discard().catch(() => undefined);
}
if (discardRecordingId.current === activeNativeRecording.recordingId) {
discardRecordingId.current = null;
}
setSaving(false);
}
},
[cursorCaptureMode, getRecordingDurationMs],
);
/**
* The Linux twin of `finalizeNativeMacRecording`. Same shape, because the two
* platforms make the same split: helper owns screen and audio, renderer owns
* the webcam, and the two are reconciled here.
*/
const finalizeNativeLinuxRecording = useCallback(
async (discard = false) => {
const activeNativeRecording = nativeLinuxRecording.current;
if (!activeNativeRecording || activeNativeRecording.finalizing) {
return false;
}
activeNativeRecording.finalizing = true;
if (!discard) {
setSaving(true);
}
const duration = Math.max(0, getRecordingDurationMs());
const activeWebcamRecorder = webcamRecorder.current;
if (activeWebcamRecorder && webcamRecorder.current === activeWebcamRecorder) {
webcamRecorder.current = null;
}
// See the identical comment in finalizeNativeMacRecording: the
// leading footage recorded while the portal picker was up must
// stay seekable, so the declared duration includes it.
const webcamHeadStartMs = Math.max(0, -(activeNativeRecording.webcamOffsetMs ?? 0));
const webcamDurationMs = duration + webcamHeadStartMs;
const webcamFileName = `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`;
const webcamResultPromise: Promise<{
asset?: RecordedVideoAssetInput;
error?: string;
}> = activeWebcamRecorder
? finalizeWebcamAsset(activeWebcamRecorder, webcamFileName, webcamDurationMs, "Linux")
: Promise.resolve({});
const clearNativeRecordingState = () => {
nativeLinuxRecording.current = null;
setRecording(false);
setPaused(false);
setElapsedSeconds(0);
accumulatedDurationMs.current = 0;
segmentStartedAt.current = null;
};
let webcamSaved = false;
try {
const result = await window.electronAPI.stopNativeLinuxRecording(discard);
const webcamResult = await webcamResultPromise;
if (discard || result.discarded) {
clearNativeRecordingState();
return true;
}
if (!result.success) {
console.error("Failed to stop native Linux recording:", result.error);
toast.error(result.error ?? "Failed to stop native Linux recording");
// See the Windows finalizer: the main process has already
// released its helper handle, so keeping ours leaves the HUD
// stuck in a recording state the app can never be stopped out
// of (issue #252).
clearNativeRecordingState();
return true;
}
if (webcamResult.asset && result.path) {
const attachResult = await window.electronAPI.attachNativeLinuxWebcamRecording({
screenVideoPath: result.path,
recordingId: activeNativeRecording.recordingId,
webcam: webcamResult.asset,
cursorCaptureMode,
durationMs: webcamDurationMs,
...(typeof activeNativeRecording.webcamOffsetMs === "number"
? { webcamOffsetMs: activeNativeRecording.webcamOffsetMs }
: {}),
});
if (attachResult.success) {
result.session = attachResult.session;
webcamSaved = true;
} else {
console.error("Failed to attach native Linux webcam recording:", attachResult.error);
toast.error(attachResult.error ?? "Failed to store webcam recording");
}
} else if (webcamResult.error) {
// The screen recording still saves, so without this the editor just
// opens with the camera missing and nothing said about it (#253).
toast.error(`Webcam not saved (${webcamResult.error}). The screen recording was kept.`);
}
clearNativeRecordingState();
if (result.session) {
await window.electronAPI.setCurrentRecordingSession(result.session);
} else if (result.path) {
await window.electronAPI.setCurrentVideoPath(result.path);
}
await window.electronAPI.switchToEditor();
return true;
} catch (error) {
console.error("Error saving native Linux recording:", error);
toast.error(
error instanceof Error ? error.message : "Failed to save native Linux recording",
);
clearNativeRecordingState();
return true;
} finally {
// A webcam stream that wasn't folded into a saved session has to be closed
// and its partial file removed, or a discarded or failed take orphans a
// half-written .webm now that the bytes go to disk as they arrive.
if (activeWebcamRecorder && !webcamSaved) {
await activeWebcamRecorder.discard().catch(() => undefined);
}
if (discardRecordingId.current === activeNativeRecording.recordingId) {
discardRecordingId.current = null;
}
setSaving(false);
}
},
[cursorCaptureMode, getRecordingDurationMs],
);
const stopRecording = useRef(() => {
if (nativeWindowsRecording.current) {
void finalizeNativeWindowsRecording(false);
return;
}
if (nativeMacRecording.current) {
void finalizeNativeMacRecording(false);
return;
}
if (nativeLinuxRecording.current) {
void finalizeNativeLinuxRecording(false);
return;
}
const activeScreenRecorder = screenRecorder.current;
if (!activeScreenRecorder) {
return;
}
const activeWebcamRecorder = webcamRecorder.current;
const duration = getRecordingDurationMs();
const activeRecordingId = recordingId.current;
finalizeRecording(
activeScreenRecorder,
activeWebcamRecorder ?? null,
duration,
activeRecordingId,
);
if (
activeScreenRecorder.recorder.state === "recording" ||
activeScreenRecorder.recorder.state === "paused"
) {
try {
activeScreenRecorder.recorder.stop();
} catch {
// Recorder may already be stopping.
}
}
if (activeWebcamRecorder) {
if (
activeWebcamRecorder.recorder.state === "recording" ||
activeWebcamRecorder.recorder.state === "paused"
) {
try {
activeWebcamRecorder.recorder.stop();
} catch {
// Recorder may already be stopping.
}
}
}
});
const safeHideCountdownOverlay = useCallback(async (runId: number) => {
try {
await window.electronAPI.hideCountdownOverlay(runId);
} catch (error) {
console.warn("Failed to hide countdown overlay:", error);
}
}, []);
useEffect(() => {
let cleanup: (() => void) | undefined;
if (window.electronAPI?.onStopRecordingFromTray) {
cleanup = window.electronAPI.onStopRecordingFromTray(() => {
stopRecording.current();
});
}
return () => {
const activeRunId = countdownRunId.current;
if (cleanup) cleanup();
countdownRunId.current += 1;
void safeHideCountdownOverlay(activeRunId);
allowAutoFinalize.current = false;
restarting.current = false;
discardRecordingId.current = null;
if (nativeWindowsRecording.current) {
void finalizeNativeWindowsRecording(true);
}
if (nativeMacRecording.current) {
void finalizeNativeMacRecording(true);
}
if (nativeLinuxRecording.current) {
void finalizeNativeLinuxRecording(true);
}
if (
screenRecorder.current?.recorder.state === "recording" ||
screenRecorder.current?.recorder.state === "paused"
) {
try {