@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.
Files changed (55) hide show
  1. package/dist/index.d.ts +1 -2
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -3
  4. package/dist/index.js.map +1 -1
  5. package/dist/past-submissions.d.ts.map +1 -1
  6. package/dist/past-submissions.js +1 -10
  7. package/dist/past-submissions.js.map +1 -1
  8. package/dist/recorder/camera-overlay.d.ts +9 -0
  9. package/dist/recorder/camera-overlay.d.ts.map +1 -0
  10. package/dist/recorder/camera-overlay.js +3 -0
  11. package/dist/recorder/camera-overlay.js.map +1 -0
  12. package/dist/recorder/composeRecordingWithCamera.d.ts +8 -0
  13. package/dist/recorder/composeRecordingWithCamera.d.ts.map +1 -0
  14. package/dist/recorder/composeRecordingWithCamera.js +183 -0
  15. package/dist/recorder/composeRecordingWithCamera.js.map +1 -0
  16. package/dist/recorder/finalize-recording-worker-source.d.ts +1 -1
  17. package/dist/recorder/finalize-recording-worker-source.d.ts.map +1 -1
  18. package/dist/recorder/finalize-recording-worker-source.js +1 -1
  19. package/dist/recorder/finalize-recording-worker-source.js.map +1 -1
  20. package/dist/recorder/finalize-recording-worker.js +4 -1
  21. package/dist/recorder/finalize-recording-worker.js.map +1 -1
  22. package/dist/recorder/finalizeRecording.d.ts +18 -1
  23. package/dist/recorder/finalizeRecording.d.ts.map +1 -1
  24. package/dist/recorder/finalizeRecording.js +167 -40
  25. package/dist/recorder/finalizeRecording.js.map +1 -1
  26. package/dist/recorder/finalizeRecordingInWorker.d.ts +6 -2
  27. package/dist/recorder/finalizeRecordingInWorker.d.ts.map +1 -1
  28. package/dist/recorder/finalizeRecordingInWorker.js +36 -11
  29. package/dist/recorder/finalizeRecordingInWorker.js.map +1 -1
  30. package/dist/screen-recorder-context.d.ts.map +1 -1
  31. package/dist/screen-recorder-context.js +7 -1
  32. package/dist/screen-recorder-context.js.map +1 -1
  33. package/dist/screen-recorder.d.ts +2 -0
  34. package/dist/screen-recorder.d.ts.map +1 -1
  35. package/dist/screen-recorder.js +78 -12
  36. package/dist/screen-recorder.js.map +1 -1
  37. package/dist/triage-button.d.ts.map +1 -1
  38. package/dist/triage-button.js +39 -6
  39. package/dist/triage-button.js.map +1 -1
  40. package/dist/triage-ui-controller.d.ts +1 -0
  41. package/dist/triage-ui-controller.d.ts.map +1 -1
  42. package/dist/triage-ui-controller.js.map +1 -1
  43. package/dist/use-recorder-controller.d.ts +18 -3
  44. package/dist/use-recorder-controller.d.ts.map +1 -1
  45. package/dist/use-recorder-controller.js +367 -36
  46. package/dist/use-recorder-controller.js.map +1 -1
  47. package/dist/use-screen-recorder.d.ts +7 -0
  48. package/dist/use-screen-recorder.d.ts.map +1 -1
  49. package/dist/use-screen-recorder.js +7 -0
  50. package/dist/use-screen-recorder.js.map +1 -1
  51. package/package.json +5 -5
  52. package/dist/loom-recorder.d.ts +0 -9
  53. package/dist/loom-recorder.d.ts.map +0 -1
  54. package/dist/loom-recorder.js +0 -92
  55. package/dist/loom-recorder.js.map +0 -1
@@ -1,14 +1,15 @@
1
1
  "use strict";
2
2
  /**
3
- * Simple screen recorder: getDisplayMedia one MediaRecorder chunks blob on stop.
4
- * Plus pause/resume. No camera PiP or audio mixing. Finalization starts after stop,
5
- * but preview becomes available immediately so optimization can continue in the background.
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
- }, [cleanup, previewUrl]);
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
- if (previewUrl) {
83
- URL.revokeObjectURL(previewUrl);
84
- setPreviewUrl(null);
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 stream = await navigator.mediaDevices.getDisplayMedia({
95
- video: true,
96
- audio: true,
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
- const recorder = new MediaRecorder(stream, {
100
- mimeType: MIME,
101
- videoBitsPerSecond: videoBitsPerSecond ?? 2500000,
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
- recorder.onstop = async () => {
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 previewObjectUrl = URL.createObjectURL(rawBlob);
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 = buildFallbackMetadata(rawBlob);
126
- setPreviewUrl(previewObjectUrl);
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 (err) {
402
+ catch (error) {
141
403
  if (discardingRef.current || finalizationRunIdRef.current !== runId) {
142
404
  return;
143
405
  }
144
- setError(err instanceof Error ? err : new Error("Failed to finalize recording"));
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
- stream.getVideoTracks()[0]?.addEventListener("ended", () => {
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
- setError(err instanceof Error ? err : new Error("Failed to start recording"));
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
- }, [previewUrl, maxDurationMs, videoBitsPerSecond, cleanup, scheduleMaxTimer]);
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
- if (previewUrl)
244
- URL.revokeObjectURL(previewUrl);
245
- setPreviewUrl(null);
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, previewUrl]);
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: blobMetadataRef.current ?? buildFallbackMetadata(blob),
611
+ metadata,
288
612
  };
289
- }, [buildFallbackMetadata]);
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,