@taskp3/react 0.1.0 → 0.1.2
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/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -3
- package/dist/index.js.map +1 -1
- package/dist/past-submissions.d.ts.map +1 -1
- package/dist/past-submissions.js +1 -10
- package/dist/past-submissions.js.map +1 -1
- package/dist/recorder/camera-overlay.d.ts +9 -0
- package/dist/recorder/camera-overlay.d.ts.map +1 -0
- package/dist/recorder/camera-overlay.js +3 -0
- package/dist/recorder/camera-overlay.js.map +1 -0
- package/dist/recorder/composeRecordingWithCamera.d.ts +8 -0
- package/dist/recorder/composeRecordingWithCamera.d.ts.map +1 -0
- package/dist/recorder/composeRecordingWithCamera.js +183 -0
- package/dist/recorder/composeRecordingWithCamera.js.map +1 -0
- package/dist/recorder/finalize-recording-worker-source.d.ts +1 -1
- package/dist/recorder/finalize-recording-worker-source.d.ts.map +1 -1
- package/dist/recorder/finalize-recording-worker-source.js +1 -1
- package/dist/recorder/finalize-recording-worker-source.js.map +1 -1
- package/dist/recorder/finalize-recording-worker.js +4 -1
- package/dist/recorder/finalize-recording-worker.js.map +1 -1
- package/dist/recorder/finalizeRecording.d.ts +18 -1
- package/dist/recorder/finalizeRecording.d.ts.map +1 -1
- package/dist/recorder/finalizeRecording.js +167 -40
- package/dist/recorder/finalizeRecording.js.map +1 -1
- package/dist/recorder/finalizeRecordingInWorker.d.ts +6 -2
- package/dist/recorder/finalizeRecordingInWorker.d.ts.map +1 -1
- package/dist/recorder/finalizeRecordingInWorker.js +36 -11
- package/dist/recorder/finalizeRecordingInWorker.js.map +1 -1
- package/dist/screen-recorder-context.d.ts.map +1 -1
- package/dist/screen-recorder-context.js +7 -1
- package/dist/screen-recorder-context.js.map +1 -1
- package/dist/screen-recorder.d.ts +2 -0
- package/dist/screen-recorder.d.ts.map +1 -1
- package/dist/screen-recorder.js +78 -12
- package/dist/screen-recorder.js.map +1 -1
- package/dist/triage-button.d.ts.map +1 -1
- package/dist/triage-button.js +39 -6
- package/dist/triage-button.js.map +1 -1
- package/dist/triage-ui-controller.d.ts +1 -0
- package/dist/triage-ui-controller.d.ts.map +1 -1
- package/dist/triage-ui-controller.js.map +1 -1
- package/dist/use-recorder-controller.d.ts +18 -3
- package/dist/use-recorder-controller.d.ts.map +1 -1
- package/dist/use-recorder-controller.js +367 -36
- package/dist/use-recorder-controller.js.map +1 -1
- package/dist/use-screen-recorder.d.ts +7 -0
- package/dist/use-screen-recorder.d.ts.map +1 -1
- package/dist/use-screen-recorder.js +7 -0
- package/dist/use-screen-recorder.js.map +1 -1
- package/package.json +5 -5
- package/dist/loom-recorder.d.ts +0 -9
- package/dist/loom-recorder.d.ts.map +0 -1
- package/dist/loom-recorder.js +0 -92
- package/dist/loom-recorder.js.map +0 -1
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* Screen recorder: capture a window-oriented display stream, mix in microphone audio
|
|
4
|
+
* when available, and finalize after stop. Preview becomes available immediately,
|
|
5
|
+
* while attach stays disabled until finalization finishes.
|
|
6
6
|
*/
|
|
7
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
8
|
exports.useRecorderController = useRecorderController;
|
|
9
9
|
const react_1 = require("react");
|
|
10
10
|
const finalizeRecordingInWorker_1 = require("./recorder/finalizeRecordingInWorker");
|
|
11
11
|
const DEFAULT_MAX_MS = 5 * 60 * 1000;
|
|
12
|
+
const DEFAULT_CAMERA_BITRATE = 500000;
|
|
12
13
|
const MIME = typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported("video/webm;codecs=vp9")
|
|
13
14
|
? "video/webm;codecs=vp9"
|
|
14
15
|
: "video/webm";
|
|
@@ -17,17 +18,201 @@ function isSupported() {
|
|
|
17
18
|
typeof MediaRecorder !== "undefined" &&
|
|
18
19
|
!!navigator.mediaDevices?.getDisplayMedia);
|
|
19
20
|
}
|
|
21
|
+
function buildDisplayMediaOptions() {
|
|
22
|
+
return {
|
|
23
|
+
video: true,
|
|
24
|
+
audio: true,
|
|
25
|
+
preferCurrentTab: false,
|
|
26
|
+
selfBrowserSurface: "exclude",
|
|
27
|
+
surfaceSwitching: "exclude",
|
|
28
|
+
systemAudio: "include",
|
|
29
|
+
windowAudio: "window",
|
|
30
|
+
monitorTypeSurfaces: "include",
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function createMicrophoneCaptureError(message) {
|
|
34
|
+
const error = new Error(message);
|
|
35
|
+
error.name = "MicrophoneCaptureError";
|
|
36
|
+
return error;
|
|
37
|
+
}
|
|
38
|
+
function createCameraCaptureError(message) {
|
|
39
|
+
const error = new Error(message);
|
|
40
|
+
error.name = "CameraCaptureError";
|
|
41
|
+
return error;
|
|
42
|
+
}
|
|
43
|
+
function describeCameraAccessFailure(error) {
|
|
44
|
+
const errorName = error instanceof Error ? error.name : "";
|
|
45
|
+
if (errorName === "NotAllowedError" || errorName === "SecurityError") {
|
|
46
|
+
return "Camera recording is enabled, but camera access was blocked. Allow camera access in Firefox and macOS Settings, then try again.";
|
|
47
|
+
}
|
|
48
|
+
if (errorName === "NotFoundError" || errorName === "DevicesNotFoundError") {
|
|
49
|
+
return "Camera recording is enabled, but no camera device was found. Connect a camera, make sure Firefox can see it, or turn off camera recording.";
|
|
50
|
+
}
|
|
51
|
+
if (errorName === "NotReadableError" || errorName === "TrackStartError") {
|
|
52
|
+
return "Camera recording is enabled, but the camera could not be opened. Another app may already be using it.";
|
|
53
|
+
}
|
|
54
|
+
if (errorName === "OverconstrainedError") {
|
|
55
|
+
return "Camera recording is enabled, but Firefox rejected the requested camera settings. Try again or use a different camera.";
|
|
56
|
+
}
|
|
57
|
+
return "Camera recording is enabled, but camera access failed. Allow camera access or turn off camera recording.";
|
|
58
|
+
}
|
|
59
|
+
async function tryAcquireMicrophone(required) {
|
|
60
|
+
if (!required) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const microphoneStream = await navigator.mediaDevices.getUserMedia({
|
|
65
|
+
audio: {
|
|
66
|
+
echoCancellation: true,
|
|
67
|
+
noiseSuppression: true,
|
|
68
|
+
autoGainControl: true,
|
|
69
|
+
},
|
|
70
|
+
video: false,
|
|
71
|
+
});
|
|
72
|
+
const microphoneTrack = microphoneStream.getAudioTracks()[0];
|
|
73
|
+
if (!microphoneTrack || microphoneTrack.readyState !== "live") {
|
|
74
|
+
microphoneStream.getTracks().forEach((track) => track.stop());
|
|
75
|
+
throw createMicrophoneCaptureError("Microphone recording is enabled, but no working microphone input was found.");
|
|
76
|
+
}
|
|
77
|
+
return microphoneStream;
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error instanceof Error && error.name === "MicrophoneCaptureError") {
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
throw createMicrophoneCaptureError("Microphone recording is enabled, but microphone access failed. Allow microphone access or turn off microphone recording.");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function tryAcquireCamera(required) {
|
|
87
|
+
if (!required) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const videoInputs = await navigator.mediaDevices
|
|
92
|
+
.enumerateDevices()
|
|
93
|
+
.then((devices) => devices.filter((device) => device.kind === "videoinput"))
|
|
94
|
+
.catch(() => []);
|
|
95
|
+
if (videoInputs.length === 0) {
|
|
96
|
+
throw createCameraCaptureError("Camera recording is enabled, but no camera device was found. Connect a camera, make sure Firefox can see it, or turn off camera recording.");
|
|
97
|
+
}
|
|
98
|
+
const cameraStream = await navigator.mediaDevices.getUserMedia({
|
|
99
|
+
video: true,
|
|
100
|
+
audio: false,
|
|
101
|
+
});
|
|
102
|
+
const cameraTrack = cameraStream.getVideoTracks()[0];
|
|
103
|
+
if (!cameraTrack || cameraTrack.readyState !== "live") {
|
|
104
|
+
cameraStream.getTracks().forEach((track) => track.stop());
|
|
105
|
+
throw createCameraCaptureError("Camera recording is enabled, but no working camera input was found.");
|
|
106
|
+
}
|
|
107
|
+
cameraTrack
|
|
108
|
+
.applyConstraints({
|
|
109
|
+
width: { ideal: 320 },
|
|
110
|
+
height: { ideal: 240 },
|
|
111
|
+
})
|
|
112
|
+
.catch(() => { });
|
|
113
|
+
return cameraStream;
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (error instanceof Error && error.name === "CameraCaptureError") {
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
throw createCameraCaptureError(describeCameraAccessFailure(error));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function createVideoRecorder(stream, bitsPerSecond) {
|
|
123
|
+
const candidates = [MIME, "video/webm;codecs=vp8", "video/webm"].filter((value, index, values) => values.indexOf(value) === index);
|
|
124
|
+
for (const mimeType of candidates) {
|
|
125
|
+
if (!MediaRecorder.isTypeSupported(mimeType))
|
|
126
|
+
continue;
|
|
127
|
+
try {
|
|
128
|
+
return new MediaRecorder(stream, { mimeType, videoBitsPerSecond: bitsPerSecond });
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// Try the next supported candidate.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return new MediaRecorder(stream, { videoBitsPerSecond: bitsPerSecond });
|
|
135
|
+
}
|
|
136
|
+
function createRecordingStream(displayStream, microphoneStream) {
|
|
137
|
+
const tracks = [...displayStream.getVideoTracks()];
|
|
138
|
+
const audioTracks = [
|
|
139
|
+
...displayStream.getAudioTracks(),
|
|
140
|
+
...(microphoneStream?.getAudioTracks() ?? []),
|
|
141
|
+
];
|
|
142
|
+
if (audioTracks.length === 0) {
|
|
143
|
+
return {
|
|
144
|
+
stream: new MediaStream(tracks),
|
|
145
|
+
release: () => { },
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const ctx = new AudioContext();
|
|
149
|
+
const dest = ctx.createMediaStreamDestination();
|
|
150
|
+
for (const track of audioTracks) {
|
|
151
|
+
const source = ctx.createMediaStreamSource(new MediaStream([track]));
|
|
152
|
+
source.connect(dest);
|
|
153
|
+
}
|
|
154
|
+
tracks.push(...dest.stream.getAudioTracks());
|
|
155
|
+
return {
|
|
156
|
+
stream: new MediaStream(tracks),
|
|
157
|
+
release: () => {
|
|
158
|
+
ctx.close().catch(() => { });
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function toError(error, fallbackMessage) {
|
|
163
|
+
return error instanceof Error ? error : new Error(fallbackMessage);
|
|
164
|
+
}
|
|
165
|
+
function getFinalizationHints(displayStream, microphoneStream, cameraEnabled, pipPosition, pipShape) {
|
|
166
|
+
const videoTrack = displayStream.getVideoTracks()[0];
|
|
167
|
+
const settings = videoTrack?.getSettings();
|
|
168
|
+
return {
|
|
169
|
+
hasAudio: displayStream.getAudioTracks().length > 0
|
|
170
|
+
|| (microphoneStream?.getAudioTracks().length ?? 0) > 0,
|
|
171
|
+
width: typeof settings?.width === "number" && settings.width > 0
|
|
172
|
+
? settings.width
|
|
173
|
+
: undefined,
|
|
174
|
+
height: typeof settings?.height === "number" && settings.height > 0
|
|
175
|
+
? settings.height
|
|
176
|
+
: undefined,
|
|
177
|
+
cameraOverlayEnabled: cameraEnabled || undefined,
|
|
178
|
+
cameraOverlayPosition: cameraEnabled ? pipPosition : undefined,
|
|
179
|
+
cameraOverlayShape: cameraEnabled ? pipShape : undefined,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
20
182
|
function useRecorderController(options = {}) {
|
|
21
|
-
const { maxDurationMs = DEFAULT_MAX_MS, videoBitsPerSecond = 2500000 } = options;
|
|
183
|
+
const { maxDurationMs = DEFAULT_MAX_MS, videoBitsPerSecond = 2500000, cameraBitsPerSecond = DEFAULT_CAMERA_BITRATE, defaultMicrophoneEnabled = true, microphoneEnabled: controlledMicrophoneEnabled, defaultCameraEnabled = false, cameraEnabled: controlledCameraEnabled, pipPosition = "bottom-right", pipShape = "circle", } = options;
|
|
22
184
|
const [state, setState] = (0, react_1.useState)("idle");
|
|
23
185
|
const [duration, setDuration] = (0, react_1.useState)(0);
|
|
24
186
|
const [previewUrl, setPreviewUrl] = (0, react_1.useState)(null);
|
|
25
187
|
const [cameraPreviewUrl, setCameraPreviewUrl] = (0, react_1.useState)(null);
|
|
188
|
+
const [liveCameraStream, setLiveCameraStream] = (0, react_1.useState)(null);
|
|
26
189
|
const [error, setError] = (0, react_1.useState)(null);
|
|
190
|
+
const [microphoneError, setMicrophoneError] = (0, react_1.useState)(null);
|
|
191
|
+
const [cameraError, setCameraError] = (0, react_1.useState)(null);
|
|
27
192
|
const [isFinalizing, setIsFinalizing] = (0, react_1.useState)(false);
|
|
193
|
+
const [uncontrolledMicrophoneEnabled, setUncontrolledMicrophoneEnabled] = (0, react_1.useState)(defaultMicrophoneEnabled);
|
|
194
|
+
const [uncontrolledCameraEnabled, setUncontrolledCameraEnabled] = (0, react_1.useState)(defaultCameraEnabled);
|
|
195
|
+
const microphoneEnabled = controlledMicrophoneEnabled ?? uncontrolledMicrophoneEnabled;
|
|
196
|
+
const cameraEnabled = controlledCameraEnabled ?? uncontrolledCameraEnabled;
|
|
197
|
+
const setMicrophoneEnabled = (0, react_1.useCallback)((enabled) => {
|
|
198
|
+
if (controlledMicrophoneEnabled === undefined) {
|
|
199
|
+
setUncontrolledMicrophoneEnabled(enabled);
|
|
200
|
+
}
|
|
201
|
+
}, [controlledMicrophoneEnabled]);
|
|
202
|
+
const setCameraEnabled = (0, react_1.useCallback)((enabled) => {
|
|
203
|
+
if (controlledCameraEnabled === undefined) {
|
|
204
|
+
setUncontrolledCameraEnabled(enabled);
|
|
205
|
+
}
|
|
206
|
+
}, [controlledCameraEnabled]);
|
|
28
207
|
const streamRef = (0, react_1.useRef)(null);
|
|
208
|
+
const displayStreamRef = (0, react_1.useRef)(null);
|
|
209
|
+
const microphoneStreamRef = (0, react_1.useRef)(null);
|
|
210
|
+
const cameraStreamRef = (0, react_1.useRef)(null);
|
|
211
|
+
const releaseAudioRef = (0, react_1.useRef)(null);
|
|
29
212
|
const recorderRef = (0, react_1.useRef)(null);
|
|
213
|
+
const cameraRecorderRef = (0, react_1.useRef)(null);
|
|
30
214
|
const chunksRef = (0, react_1.useRef)([]);
|
|
215
|
+
const cameraChunksRef = (0, react_1.useRef)([]);
|
|
31
216
|
const blobRef = (0, react_1.useRef)(null);
|
|
32
217
|
const blobMetadataRef = (0, react_1.useRef)(null);
|
|
33
218
|
const finalizePromiseRef = (0, react_1.useRef)(null);
|
|
@@ -49,14 +234,41 @@ function useRecorderController(options = {}) {
|
|
|
49
234
|
}
|
|
50
235
|
streamRef.current?.getTracks().forEach((t) => t.stop());
|
|
51
236
|
streamRef.current = null;
|
|
237
|
+
displayStreamRef.current?.getTracks().forEach((t) => t.stop());
|
|
238
|
+
displayStreamRef.current = null;
|
|
239
|
+
microphoneStreamRef.current?.getTracks().forEach((t) => t.stop());
|
|
240
|
+
microphoneStreamRef.current = null;
|
|
241
|
+
cameraStreamRef.current?.getTracks().forEach((t) => t.stop());
|
|
242
|
+
cameraStreamRef.current = null;
|
|
243
|
+
releaseAudioRef.current?.();
|
|
244
|
+
releaseAudioRef.current = null;
|
|
52
245
|
recorderRef.current = null;
|
|
246
|
+
cameraRecorderRef.current = null;
|
|
247
|
+
setLiveCameraStream(null);
|
|
53
248
|
}, []);
|
|
54
|
-
const buildFallbackMetadata = (0, react_1.useCallback)((blob) => ({
|
|
249
|
+
const buildFallbackMetadata = (0, react_1.useCallback)((blob, conversionReason) => ({
|
|
55
250
|
durationSeconds: Math.max(1, Math.round(Math.max(elapsedMsRef.current, 1) / 1000)),
|
|
56
251
|
mimeType: blob.type || "video/webm",
|
|
57
252
|
fileExtension: blob.type.includes("mp4") ? "mp4" : "webm",
|
|
58
253
|
conversionMode: "passthrough",
|
|
254
|
+
conversionReason,
|
|
59
255
|
}), []);
|
|
256
|
+
const setPreviewBlob = (0, react_1.useCallback)((blob) => {
|
|
257
|
+
setPreviewUrl((currentUrl) => {
|
|
258
|
+
if (currentUrl) {
|
|
259
|
+
URL.revokeObjectURL(currentUrl);
|
|
260
|
+
}
|
|
261
|
+
return blob ? URL.createObjectURL(blob) : null;
|
|
262
|
+
});
|
|
263
|
+
}, []);
|
|
264
|
+
const setCameraPreviewBlob = (0, react_1.useCallback)((blob) => {
|
|
265
|
+
setCameraPreviewUrl((currentUrl) => {
|
|
266
|
+
if (currentUrl) {
|
|
267
|
+
URL.revokeObjectURL(currentUrl);
|
|
268
|
+
}
|
|
269
|
+
return blob ? URL.createObjectURL(blob) : null;
|
|
270
|
+
});
|
|
271
|
+
}, []);
|
|
60
272
|
const scheduleMaxTimer = (0, react_1.useCallback)(() => {
|
|
61
273
|
if (maxTimerRef.current) {
|
|
62
274
|
clearTimeout(maxTimerRef.current);
|
|
@@ -70,7 +282,9 @@ function useRecorderController(options = {}) {
|
|
|
70
282
|
cleanup();
|
|
71
283
|
if (previewUrl)
|
|
72
284
|
URL.revokeObjectURL(previewUrl);
|
|
73
|
-
|
|
285
|
+
if (cameraPreviewUrl)
|
|
286
|
+
URL.revokeObjectURL(cameraPreviewUrl);
|
|
287
|
+
}, [cameraPreviewUrl, cleanup, previewUrl]);
|
|
74
288
|
const start = (0, react_1.useCallback)(async () => {
|
|
75
289
|
if (!isSupported()) {
|
|
76
290
|
setError(new Error("Screen recording is not supported"));
|
|
@@ -78,12 +292,12 @@ function useRecorderController(options = {}) {
|
|
|
78
292
|
return;
|
|
79
293
|
}
|
|
80
294
|
setError(null);
|
|
295
|
+
setMicrophoneError(null);
|
|
296
|
+
setCameraError(null);
|
|
81
297
|
discardingRef.current = false;
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
setCameraPreviewUrl(null);
|
|
298
|
+
setPreviewBlob(null);
|
|
299
|
+
setCameraPreviewBlob(null);
|
|
300
|
+
setLiveCameraStream(null);
|
|
87
301
|
blobRef.current = null;
|
|
88
302
|
blobMetadataRef.current = null;
|
|
89
303
|
finalizePromiseRef.current = null;
|
|
@@ -91,24 +305,45 @@ function useRecorderController(options = {}) {
|
|
|
91
305
|
setIsFinalizing(false);
|
|
92
306
|
setState("requesting");
|
|
93
307
|
try {
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
});
|
|
308
|
+
const displayStream = await navigator.mediaDevices.getDisplayMedia(buildDisplayMediaOptions());
|
|
309
|
+
const microphoneStream = await tryAcquireMicrophone(microphoneEnabled);
|
|
310
|
+
const cameraStream = await tryAcquireCamera(cameraEnabled);
|
|
311
|
+
const { stream, release } = createRecordingStream(displayStream, microphoneStream);
|
|
312
|
+
const finalizationHints = getFinalizationHints(displayStream, microphoneStream, cameraEnabled, pipPosition, pipShape);
|
|
313
|
+
displayStreamRef.current = displayStream;
|
|
314
|
+
microphoneStreamRef.current = microphoneStream;
|
|
315
|
+
cameraStreamRef.current = cameraStream;
|
|
316
|
+
releaseAudioRef.current = release;
|
|
98
317
|
streamRef.current = stream;
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
318
|
+
setLiveCameraStream(cameraStream ? new MediaStream(cameraStream.getVideoTracks()) : null);
|
|
319
|
+
const recorder = createVideoRecorder(stream, videoBitsPerSecond ?? 2500000);
|
|
320
|
+
const cameraRecorder = cameraStream && cameraStream.getVideoTracks().length > 0
|
|
321
|
+
? createVideoRecorder(new MediaStream(cameraStream.getVideoTracks()), cameraBitsPerSecond)
|
|
322
|
+
: null;
|
|
103
323
|
recorderRef.current = recorder;
|
|
324
|
+
cameraRecorderRef.current = cameraRecorder;
|
|
104
325
|
chunksRef.current = [];
|
|
326
|
+
cameraChunksRef.current = [];
|
|
105
327
|
recorder.ondataavailable = (e) => {
|
|
106
328
|
if (discardingRef.current)
|
|
107
329
|
return;
|
|
108
330
|
if (e.data.size > 0)
|
|
109
331
|
chunksRef.current.push(e.data);
|
|
110
332
|
};
|
|
111
|
-
|
|
333
|
+
cameraRecorder?.addEventListener("dataavailable", (e) => {
|
|
334
|
+
if (discardingRef.current)
|
|
335
|
+
return;
|
|
336
|
+
if (e.data.size > 0)
|
|
337
|
+
cameraChunksRef.current.push(e.data);
|
|
338
|
+
});
|
|
339
|
+
let screenStopped = false;
|
|
340
|
+
let cameraStopped = cameraRecorder == null;
|
|
341
|
+
let finalizationStarted = false;
|
|
342
|
+
const maybeFinalizeStoppedRecorders = async () => {
|
|
343
|
+
if (finalizationStarted || !screenStopped || !cameraStopped) {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
finalizationStarted = true;
|
|
112
347
|
cleanup();
|
|
113
348
|
if (discardingRef.current) {
|
|
114
349
|
discardingRef.current = false;
|
|
@@ -120,14 +355,35 @@ function useRecorderController(options = {}) {
|
|
|
120
355
|
const chunks = chunksRef.current;
|
|
121
356
|
const type = chunks[0]?.type || MIME.split(";")[0];
|
|
122
357
|
const rawBlob = new Blob(chunks, { type });
|
|
123
|
-
const
|
|
358
|
+
const cameraType = cameraChunksRef.current[0]?.type || cameraRecorder?.mimeType || MIME;
|
|
359
|
+
const rawCameraBlob = cameraChunksRef.current.length > 0
|
|
360
|
+
? new Blob(cameraChunksRef.current, { type: cameraType.split(";")[0] })
|
|
361
|
+
: null;
|
|
362
|
+
if (cameraEnabled && !rawCameraBlob) {
|
|
363
|
+
setError(new Error("Camera recording was enabled, but the camera overlay could not be captured."));
|
|
364
|
+
setState("error");
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
124
367
|
blobRef.current = rawBlob;
|
|
125
|
-
blobMetadataRef.current =
|
|
126
|
-
|
|
368
|
+
blobMetadataRef.current = {
|
|
369
|
+
...buildFallbackMetadata(rawBlob),
|
|
370
|
+
cameraOverlayEnabled: cameraEnabled || undefined,
|
|
371
|
+
cameraOverlayIncluded: false,
|
|
372
|
+
cameraOverlayPosition: cameraEnabled ? pipPosition : undefined,
|
|
373
|
+
cameraOverlayShape: cameraEnabled ? pipShape : undefined,
|
|
374
|
+
};
|
|
375
|
+
setPreviewBlob(rawBlob);
|
|
376
|
+
setCameraPreviewBlob(rawCameraBlob);
|
|
127
377
|
setState("preview");
|
|
128
378
|
setIsFinalizing(true);
|
|
129
379
|
const runId = ++finalizationRunIdRef.current;
|
|
130
|
-
const finalizePromise = (0, finalizeRecordingInWorker_1.finalizeRecordingInWorker)(rawBlob, Math.max(elapsedMsRef.current, 1)
|
|
380
|
+
const finalizePromise = (0, finalizeRecordingInWorker_1.finalizeRecordingInWorker)(rawBlob, Math.max(elapsedMsRef.current, 1), finalizationHints, {
|
|
381
|
+
enabled: cameraEnabled,
|
|
382
|
+
cameraBlob: rawCameraBlob,
|
|
383
|
+
pipScale: options.pipScale,
|
|
384
|
+
position: pipPosition,
|
|
385
|
+
shape: pipShape,
|
|
386
|
+
});
|
|
131
387
|
finalizePromiseRef.current = finalizePromise;
|
|
132
388
|
try {
|
|
133
389
|
const finalizedRecording = await finalizePromise;
|
|
@@ -136,12 +392,23 @@ function useRecorderController(options = {}) {
|
|
|
136
392
|
}
|
|
137
393
|
blobRef.current = finalizedRecording.blob;
|
|
138
394
|
blobMetadataRef.current = finalizedRecording.metadata;
|
|
395
|
+
setPreviewBlob(finalizedRecording.blob);
|
|
396
|
+
setCameraPreviewBlob(null);
|
|
397
|
+
if (finalizedRecording.metadata.conversionMode !== "mp4"
|
|
398
|
+
|| finalizedRecording.metadata.mimeType !== "video/mp4") {
|
|
399
|
+
throw new Error("Recorder finalized to a non-MP4 output");
|
|
400
|
+
}
|
|
139
401
|
}
|
|
140
|
-
catch (
|
|
402
|
+
catch (error) {
|
|
141
403
|
if (discardingRef.current || finalizationRunIdRef.current !== runId) {
|
|
142
404
|
return;
|
|
143
405
|
}
|
|
144
|
-
|
|
406
|
+
blobRef.current = null;
|
|
407
|
+
blobMetadataRef.current = null;
|
|
408
|
+
setPreviewBlob(null);
|
|
409
|
+
setCameraPreviewBlob(null);
|
|
410
|
+
setError(toError(error, "Recording processing failed. MP4 conversion is required before upload."));
|
|
411
|
+
setState("error");
|
|
145
412
|
}
|
|
146
413
|
finally {
|
|
147
414
|
if (finalizationRunIdRef.current === runId) {
|
|
@@ -150,12 +417,27 @@ function useRecorderController(options = {}) {
|
|
|
150
417
|
}
|
|
151
418
|
}
|
|
152
419
|
};
|
|
420
|
+
recorder.onstop = () => {
|
|
421
|
+
screenStopped = true;
|
|
422
|
+
void maybeFinalizeStoppedRecorders();
|
|
423
|
+
};
|
|
153
424
|
recorder.onerror = () => {
|
|
154
425
|
setError(new Error("Recording failed"));
|
|
155
426
|
cleanup();
|
|
156
427
|
setState("error");
|
|
157
428
|
};
|
|
158
|
-
|
|
429
|
+
if (cameraRecorder) {
|
|
430
|
+
cameraRecorder.onstop = () => {
|
|
431
|
+
cameraStopped = true;
|
|
432
|
+
void maybeFinalizeStoppedRecorders();
|
|
433
|
+
};
|
|
434
|
+
cameraRecorder.onerror = () => {
|
|
435
|
+
setCameraError(new Error("Camera recording failed"));
|
|
436
|
+
cleanup();
|
|
437
|
+
setState("error");
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
displayStream.getVideoTracks()[0]?.addEventListener("ended", () => {
|
|
159
441
|
if (recorder.state === "recording") {
|
|
160
442
|
const activeMs = Date.now() - timerStartedAtRef.current;
|
|
161
443
|
elapsedMsRef.current += activeMs;
|
|
@@ -164,6 +446,7 @@ function useRecorderController(options = {}) {
|
|
|
164
446
|
}
|
|
165
447
|
});
|
|
166
448
|
recorder.start();
|
|
449
|
+
cameraRecorder?.start();
|
|
167
450
|
remainingMaxMsRef.current = maxDurationMs;
|
|
168
451
|
elapsedMsRef.current = 0;
|
|
169
452
|
timerStartedAtRef.current = Date.now();
|
|
@@ -177,10 +460,33 @@ function useRecorderController(options = {}) {
|
|
|
177
460
|
}
|
|
178
461
|
catch (err) {
|
|
179
462
|
cleanup();
|
|
180
|
-
|
|
463
|
+
const resolvedError = err instanceof Error ? err : new Error("Failed to start recording");
|
|
464
|
+
if (resolvedError.name === "MicrophoneCaptureError") {
|
|
465
|
+
setMicrophoneError(resolvedError);
|
|
466
|
+
}
|
|
467
|
+
else if (resolvedError.name === "CameraCaptureError") {
|
|
468
|
+
setCameraError(resolvedError);
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
setError(resolvedError);
|
|
472
|
+
}
|
|
181
473
|
setState("error");
|
|
182
474
|
}
|
|
183
|
-
}, [
|
|
475
|
+
}, [
|
|
476
|
+
cameraBitsPerSecond,
|
|
477
|
+
cameraEnabled,
|
|
478
|
+
buildFallbackMetadata,
|
|
479
|
+
cleanup,
|
|
480
|
+
maxDurationMs,
|
|
481
|
+
microphoneEnabled,
|
|
482
|
+
options.pipScale,
|
|
483
|
+
pipPosition,
|
|
484
|
+
pipShape,
|
|
485
|
+
scheduleMaxTimer,
|
|
486
|
+
setCameraPreviewBlob,
|
|
487
|
+
setPreviewBlob,
|
|
488
|
+
videoBitsPerSecond,
|
|
489
|
+
]);
|
|
184
490
|
const stop = (0, react_1.useCallback)(() => {
|
|
185
491
|
if (state !== "recording" && state !== "paused")
|
|
186
492
|
return;
|
|
@@ -198,17 +504,25 @@ function useRecorderController(options = {}) {
|
|
|
198
504
|
maxTimerRef.current = null;
|
|
199
505
|
}
|
|
200
506
|
const rec = recorderRef.current;
|
|
507
|
+
const cameraRec = cameraRecorderRef.current;
|
|
201
508
|
if (rec && (rec.state === "recording" || rec.state === "paused")) {
|
|
202
509
|
rec.requestData();
|
|
203
510
|
rec.stop();
|
|
204
511
|
}
|
|
512
|
+
if (cameraRec && (cameraRec.state === "recording" || cameraRec.state === "paused")) {
|
|
513
|
+
cameraRec.requestData();
|
|
514
|
+
cameraRec.stop();
|
|
515
|
+
}
|
|
205
516
|
}, [state]);
|
|
206
517
|
const pause = (0, react_1.useCallback)(() => {
|
|
207
518
|
if (state !== "recording")
|
|
208
519
|
return;
|
|
209
520
|
const rec = recorderRef.current;
|
|
521
|
+
const cameraRec = cameraRecorderRef.current;
|
|
210
522
|
if (rec?.state === "recording")
|
|
211
523
|
rec.pause();
|
|
524
|
+
if (cameraRec?.state === "recording")
|
|
525
|
+
cameraRec.pause();
|
|
212
526
|
if (timerRef.current) {
|
|
213
527
|
clearInterval(timerRef.current);
|
|
214
528
|
timerRef.current = null;
|
|
@@ -223,8 +537,11 @@ function useRecorderController(options = {}) {
|
|
|
223
537
|
if (state !== "paused")
|
|
224
538
|
return;
|
|
225
539
|
const rec = recorderRef.current;
|
|
540
|
+
const cameraRec = cameraRecorderRef.current;
|
|
226
541
|
if (rec?.state === "paused")
|
|
227
542
|
rec.resume();
|
|
543
|
+
if (cameraRec?.state === "paused")
|
|
544
|
+
cameraRec.resume();
|
|
228
545
|
timerStartedAtRef.current = Date.now();
|
|
229
546
|
timerRef.current = setInterval(() => {
|
|
230
547
|
const totalMs = elapsedMsRef.current + (Date.now() - timerStartedAtRef.current);
|
|
@@ -240,16 +557,16 @@ function useRecorderController(options = {}) {
|
|
|
240
557
|
blobMetadataRef.current = null;
|
|
241
558
|
finalizePromiseRef.current = null;
|
|
242
559
|
finalizationRunIdRef.current += 1;
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
setCameraPreviewUrl(null);
|
|
560
|
+
setPreviewBlob(null);
|
|
561
|
+
setCameraPreviewBlob(null);
|
|
562
|
+
setLiveCameraStream(null);
|
|
247
563
|
setIsFinalizing(false);
|
|
248
564
|
setDuration(0);
|
|
249
565
|
remainingMaxMsRef.current = maxDurationMs;
|
|
250
566
|
elapsedMsRef.current = 0;
|
|
251
567
|
setState("idle");
|
|
252
568
|
const rec = recorderRef.current;
|
|
569
|
+
const cameraRec = cameraRecorderRef.current;
|
|
253
570
|
if (rec && (rec.state === "recording" || rec.state === "paused")) {
|
|
254
571
|
if (timerRef.current) {
|
|
255
572
|
clearInterval(timerRef.current);
|
|
@@ -260,11 +577,14 @@ function useRecorderController(options = {}) {
|
|
|
260
577
|
maxTimerRef.current = null;
|
|
261
578
|
}
|
|
262
579
|
rec.stop();
|
|
580
|
+
if (cameraRec && (cameraRec.state === "recording" || cameraRec.state === "paused")) {
|
|
581
|
+
cameraRec.stop();
|
|
582
|
+
}
|
|
263
583
|
return;
|
|
264
584
|
}
|
|
265
585
|
cleanup();
|
|
266
586
|
discardingRef.current = false;
|
|
267
|
-
}, [cleanup, maxDurationMs,
|
|
587
|
+
}, [cleanup, maxDurationMs, setCameraPreviewBlob, setPreviewBlob]);
|
|
268
588
|
const restart = (0, react_1.useCallback)(async () => {
|
|
269
589
|
discard();
|
|
270
590
|
await start();
|
|
@@ -282,19 +602,30 @@ function useRecorderController(options = {}) {
|
|
|
282
602
|
if (!blob) {
|
|
283
603
|
return null;
|
|
284
604
|
}
|
|
605
|
+
const metadata = blobMetadataRef.current;
|
|
606
|
+
if (metadata?.conversionMode !== "mp4" || metadata.mimeType !== "video/mp4") {
|
|
607
|
+
throw new Error("Recording is not finalized as MP4");
|
|
608
|
+
}
|
|
285
609
|
return {
|
|
286
610
|
blob,
|
|
287
|
-
metadata
|
|
611
|
+
metadata,
|
|
288
612
|
};
|
|
289
|
-
}, [
|
|
613
|
+
}, []);
|
|
290
614
|
return {
|
|
291
615
|
state,
|
|
292
616
|
duration,
|
|
293
617
|
previewUrl,
|
|
294
618
|
cameraPreviewUrl,
|
|
619
|
+
liveCameraStream,
|
|
295
620
|
error,
|
|
621
|
+
microphoneError,
|
|
622
|
+
cameraError,
|
|
296
623
|
isSupported: isSupported(),
|
|
297
624
|
isFinalizing,
|
|
625
|
+
microphoneEnabled,
|
|
626
|
+
cameraEnabled,
|
|
627
|
+
setMicrophoneEnabled,
|
|
628
|
+
setCameraEnabled,
|
|
298
629
|
start,
|
|
299
630
|
stop,
|
|
300
631
|
pause,
|