node-mac-recorder 2.24.10 → 2.24.12
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/MultiWindowRecorder.js +85 -4
- package/README.md +18 -1
- package/binding.gyp +2 -1
- package/index-multiprocess.js +50 -6
- package/index.js +154 -41
- package/package.json +1 -1
- package/recorder-worker.js +159 -69
- package/recorder_runtime_safety.cjs +101 -0
- package/src/audio_recorder.mm +43 -43
- package/src/avfoundation_recorder.mm +45 -62
- package/src/camera_recorder.mm +69 -78
- package/src/ios_device_recorder.mm +651 -78
- package/src/keyboard_tracker.mm +8 -1
- package/src/mac_recorder.mm +84 -13
- package/src/recording_writer_safety.h +66 -0
- package/src/screen_capture_kit.h +4 -2
- package/src/screen_capture_kit.mm +175 -195
- package/src/sync_timeline.h +9 -0
- package/src/sync_timeline.mm +54 -0
package/MultiWindowRecorder.js
CHANGED
|
@@ -15,6 +15,9 @@ class MultiWindowRecorder extends EventEmitter {
|
|
|
15
15
|
this.recorders = [];
|
|
16
16
|
this.windows = [];
|
|
17
17
|
this.isRecording = false;
|
|
18
|
+
this.isPaused = false;
|
|
19
|
+
this.pauseStartedAt = null;
|
|
20
|
+
this.pausedDurationMs = 0;
|
|
18
21
|
this.outputFiles = [];
|
|
19
22
|
this.cursorFiles = [];
|
|
20
23
|
this.cameraFile = null; // Camera output file (from first recorder)
|
|
@@ -51,6 +54,11 @@ class MultiWindowRecorder extends EventEmitter {
|
|
|
51
54
|
*/
|
|
52
55
|
async addWindow(windowInfo) {
|
|
53
56
|
const recorder = new MacRecorder();
|
|
57
|
+
// Child-process errors use EventEmitter's special "error" event.
|
|
58
|
+
// Listen before the worker can fail (including during addWindow).
|
|
59
|
+
recorder.on('error', (error) => {
|
|
60
|
+
this.emit('recorderError', { windowId: windowInfo.id, error: error.message });
|
|
61
|
+
});
|
|
54
62
|
|
|
55
63
|
const recorderInfo = {
|
|
56
64
|
recorder,
|
|
@@ -255,8 +263,9 @@ class MultiWindowRecorder extends EventEmitter {
|
|
|
255
263
|
} catch (error) {
|
|
256
264
|
console.error(` ❌ Failed to start recorder ${i + 1}:`, error.message);
|
|
257
265
|
|
|
258
|
-
//
|
|
259
|
-
|
|
266
|
+
// Cursor setup can fail AFTER the current video started.
|
|
267
|
+
// Reclaim that recorder too, not just previous windows.
|
|
268
|
+
for (let j = 0; j <= i; j++) {
|
|
260
269
|
try {
|
|
261
270
|
await this.recorders[j].recorder.stopRecording();
|
|
262
271
|
} catch (stopError) {
|
|
@@ -282,7 +291,7 @@ class MultiWindowRecorder extends EventEmitter {
|
|
|
282
291
|
// Start timeUpdate timer (emit every second)
|
|
283
292
|
this.timeUpdateInterval = setInterval(() => {
|
|
284
293
|
if (this.isRecording && this.metadata.startTime) {
|
|
285
|
-
const elapsed =
|
|
294
|
+
const elapsed = this._getRecordingTimeSeconds();
|
|
286
295
|
this.emit('timeUpdate', elapsed);
|
|
287
296
|
}
|
|
288
297
|
}, 1000);
|
|
@@ -303,6 +312,66 @@ class MultiWindowRecorder extends EventEmitter {
|
|
|
303
312
|
};
|
|
304
313
|
}
|
|
305
314
|
|
|
315
|
+
_getPausedDurationMs(now = Date.now()) {
|
|
316
|
+
return this.pausedDurationMs +
|
|
317
|
+
(this.isPaused && this.pauseStartedAt ? Math.max(0, now - this.pauseStartedAt) : 0);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
_getRecordingTimeSeconds(now = Date.now()) {
|
|
321
|
+
if (!this.metadata.startTime) return 0;
|
|
322
|
+
return Math.floor(Math.max(0, now - this.metadata.startTime - this._getPausedDurationMs(now)) / 1000);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async pauseRecording() {
|
|
326
|
+
if (!this.isRecording) throw new Error('No recording in progress');
|
|
327
|
+
if (this.isPaused) return this.getStatus();
|
|
328
|
+
|
|
329
|
+
const pauseResults = await Promise.allSettled(
|
|
330
|
+
this.recorders.map(recInfo => recInfo.recorder.pauseRecording())
|
|
331
|
+
);
|
|
332
|
+
const pauseFailure = pauseResults.find(result => result.status === 'rejected');
|
|
333
|
+
if (pauseFailure) {
|
|
334
|
+
const pausedRecorders = this.recorders
|
|
335
|
+
.filter((_, index) => pauseResults[index].status === 'fulfilled')
|
|
336
|
+
.map(recInfo => recInfo.recorder);
|
|
337
|
+
await Promise.allSettled(pausedRecorders.map(recorder => recorder.resumeRecording()));
|
|
338
|
+
throw pauseFailure.reason;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const pausedAt = Date.now();
|
|
342
|
+
this.cursorRecorder?.pauseCursorCapture?.(pausedAt);
|
|
343
|
+
this.isPaused = true;
|
|
344
|
+
this.pauseStartedAt = pausedAt;
|
|
345
|
+
const status = this.getStatus();
|
|
346
|
+
this.emit('paused', status);
|
|
347
|
+
return status;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async resumeRecording() {
|
|
351
|
+
if (!this.isRecording) throw new Error('No recording in progress');
|
|
352
|
+
if (!this.isPaused) return this.getStatus();
|
|
353
|
+
|
|
354
|
+
const resumeResults = await Promise.allSettled(
|
|
355
|
+
this.recorders.map(recInfo => recInfo.recorder.resumeRecording())
|
|
356
|
+
);
|
|
357
|
+
const resumeFailure = resumeResults.find(result => result.status === 'rejected');
|
|
358
|
+
if (resumeFailure) {
|
|
359
|
+
const resumedRecorders = this.recorders
|
|
360
|
+
.filter((_, index) => resumeResults[index].status === 'fulfilled')
|
|
361
|
+
.map(recInfo => recInfo.recorder);
|
|
362
|
+
await Promise.allSettled(resumedRecorders.map(recorder => recorder.pauseRecording()));
|
|
363
|
+
throw resumeFailure.reason;
|
|
364
|
+
}
|
|
365
|
+
const resumedAt = Date.now();
|
|
366
|
+
this.cursorRecorder?.resumeCursorCapture?.(resumedAt);
|
|
367
|
+
this.pausedDurationMs += Math.max(0, resumedAt - this.pauseStartedAt);
|
|
368
|
+
this.pauseStartedAt = null;
|
|
369
|
+
this.isPaused = false;
|
|
370
|
+
const status = this.getStatus();
|
|
371
|
+
this.emit('resumed', status);
|
|
372
|
+
return status;
|
|
373
|
+
}
|
|
374
|
+
|
|
306
375
|
/**
|
|
307
376
|
* Stop all recordings
|
|
308
377
|
*/
|
|
@@ -392,7 +461,8 @@ class MultiWindowRecorder extends EventEmitter {
|
|
|
392
461
|
}
|
|
393
462
|
|
|
394
463
|
// Calculate duration
|
|
395
|
-
const
|
|
464
|
+
const pausedDuration = this._getPausedDurationMs(stopTimestamp);
|
|
465
|
+
const duration = Math.max(0, stopTimestamp - this.metadata.startTime - pausedDuration);
|
|
396
466
|
|
|
397
467
|
const result = {
|
|
398
468
|
success: results.every(r => r.success),
|
|
@@ -402,6 +472,7 @@ class MultiWindowRecorder extends EventEmitter {
|
|
|
402
472
|
cameraFile: this.cameraFile, // Camera output path (from first recorder)
|
|
403
473
|
audioFile: this.audioFile, // Audio output path (from first recorder)
|
|
404
474
|
duration: duration,
|
|
475
|
+
pausedDuration,
|
|
405
476
|
metadata: {
|
|
406
477
|
...this.metadata,
|
|
407
478
|
stopTime: stopTimestamp,
|
|
@@ -426,6 +497,10 @@ class MultiWindowRecorder extends EventEmitter {
|
|
|
426
497
|
|
|
427
498
|
this.emit('allStopped', result);
|
|
428
499
|
|
|
500
|
+
this.isPaused = false;
|
|
501
|
+
this.pauseStartedAt = null;
|
|
502
|
+
this.pausedDurationMs = 0;
|
|
503
|
+
|
|
429
504
|
return result;
|
|
430
505
|
}
|
|
431
506
|
|
|
@@ -435,6 +510,9 @@ class MultiWindowRecorder extends EventEmitter {
|
|
|
435
510
|
getStatus() {
|
|
436
511
|
return {
|
|
437
512
|
isRecording: this.isRecording,
|
|
513
|
+
isPaused: this.isPaused,
|
|
514
|
+
recordingTime: this._getRecordingTimeSeconds(),
|
|
515
|
+
pausedDuration: this._getPausedDurationMs() / 1000,
|
|
438
516
|
windowCount: this.recorders.length,
|
|
439
517
|
outputFiles: this.outputFiles,
|
|
440
518
|
metadata: this.metadata,
|
|
@@ -538,6 +616,9 @@ class MultiWindowRecorder extends EventEmitter {
|
|
|
538
616
|
this.outputFiles = [];
|
|
539
617
|
this.cursorFiles = [];
|
|
540
618
|
this.isRecording = false;
|
|
619
|
+
this.isPaused = false;
|
|
620
|
+
this.pauseStartedAt = null;
|
|
621
|
+
this.pausedDurationMs = 0;
|
|
541
622
|
|
|
542
623
|
console.log('✅ Multi-window recorder cleaned up');
|
|
543
624
|
}
|
package/README.md
CHANGED
|
@@ -188,6 +188,21 @@ await recorder.startRecording("./recording.mov", {
|
|
|
188
188
|
});
|
|
189
189
|
```
|
|
190
190
|
|
|
191
|
+
#### `pauseRecording()` / `resumeRecording()`
|
|
192
|
+
|
|
193
|
+
Pauses every active track without closing the output files, then resumes the
|
|
194
|
+
same recording session. Time spent paused is removed from the screen, camera,
|
|
195
|
+
audio, cursor, and keyboard timelines.
|
|
196
|
+
|
|
197
|
+
```javascript
|
|
198
|
+
await recorder.pauseRecording();
|
|
199
|
+
// The output writers remain open while paused.
|
|
200
|
+
await recorder.resumeRecording();
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Both calls are idempotent and emit `paused` / `resumed` events with the current
|
|
204
|
+
status. `stopRecording()` may also be called while paused.
|
|
205
|
+
|
|
191
206
|
#### `stopRecording()`
|
|
192
207
|
|
|
193
208
|
Stops the current recording.
|
|
@@ -334,6 +349,7 @@ const status = recorder.getStatus();
|
|
|
334
349
|
console.log(status);
|
|
335
350
|
// {
|
|
336
351
|
// isRecording: true,
|
|
352
|
+
// isPaused: false,
|
|
337
353
|
// outputPath: "./recording.mov",
|
|
338
354
|
// cameraOutputPath: "./temp_camera_1720000000000.webm",
|
|
339
355
|
// audioOutputPath: "./temp_audio_1720000000000.webm",
|
|
@@ -341,7 +357,8 @@ console.log(status);
|
|
|
341
357
|
// audioCapturing: true,
|
|
342
358
|
// sessionTimestamp: 1720000000000,
|
|
343
359
|
// options: { ... },
|
|
344
|
-
// recordingTime: 15
|
|
360
|
+
// recordingTime: 15,
|
|
361
|
+
// pausedDuration: 3.5
|
|
345
362
|
// }
|
|
346
363
|
```
|
|
347
364
|
|
package/binding.gyp
CHANGED
package/index-multiprocess.js
CHANGED
|
@@ -13,6 +13,7 @@ class MacRecorderMultiProcess extends EventEmitter {
|
|
|
13
13
|
|
|
14
14
|
this.worker = null;
|
|
15
15
|
this.isRecording = false;
|
|
16
|
+
this.isPaused = false;
|
|
16
17
|
this.outputPath = null;
|
|
17
18
|
this.ready = false;
|
|
18
19
|
this.pendingRequests = new Map();
|
|
@@ -36,6 +37,8 @@ class MacRecorderMultiProcess extends EventEmitter {
|
|
|
36
37
|
|
|
37
38
|
this.worker.on('error', (error) => {
|
|
38
39
|
console.error('❌ Worker error:', error);
|
|
40
|
+
for (const { reject } of this.pendingRequests.values()) reject(error);
|
|
41
|
+
this.pendingRequests.clear();
|
|
39
42
|
this.emit('error', error);
|
|
40
43
|
});
|
|
41
44
|
|
|
@@ -43,6 +46,7 @@ class MacRecorderMultiProcess extends EventEmitter {
|
|
|
43
46
|
console.log(`🛑 Worker exited: code=${code}, signal=${signal}`);
|
|
44
47
|
this.ready = false;
|
|
45
48
|
this.isRecording = false;
|
|
49
|
+
this.isPaused = false;
|
|
46
50
|
|
|
47
51
|
// Reject all pending requests
|
|
48
52
|
for (const [id, { reject }] of this.pendingRequests) {
|
|
@@ -75,8 +79,13 @@ class MacRecorderMultiProcess extends EventEmitter {
|
|
|
75
79
|
// Update local state based on events
|
|
76
80
|
if (msg.event === 'recordingStarted') {
|
|
77
81
|
this.isRecording = true;
|
|
82
|
+
} else if (msg.event === 'paused') {
|
|
83
|
+
this.isPaused = true;
|
|
84
|
+
} else if (msg.event === 'resumed') {
|
|
85
|
+
this.isPaused = false;
|
|
78
86
|
} else if (msg.event === 'stopped') {
|
|
79
87
|
this.isRecording = false;
|
|
88
|
+
this.isPaused = false;
|
|
80
89
|
}
|
|
81
90
|
return;
|
|
82
91
|
}
|
|
@@ -84,7 +93,10 @@ class MacRecorderMultiProcess extends EventEmitter {
|
|
|
84
93
|
// Handle errors
|
|
85
94
|
if (msg.type === 'error') {
|
|
86
95
|
console.error('❌ Worker error:', msg.message);
|
|
87
|
-
|
|
96
|
+
const error = new Error(msg.message);
|
|
97
|
+
for (const { reject } of this.pendingRequests.values()) reject(error);
|
|
98
|
+
this.pendingRequests.clear();
|
|
99
|
+
this.emit('error', error);
|
|
88
100
|
return;
|
|
89
101
|
}
|
|
90
102
|
|
|
@@ -110,7 +122,7 @@ class MacRecorderMultiProcess extends EventEmitter {
|
|
|
110
122
|
|
|
111
123
|
_sendRequest(type, data = null, timeout = 30000) {
|
|
112
124
|
return new Promise((resolve, reject) => {
|
|
113
|
-
if (!this.worker) {
|
|
125
|
+
if (!this.worker || !this.worker.connected) {
|
|
114
126
|
return reject(new Error('Worker not initialized'));
|
|
115
127
|
}
|
|
116
128
|
|
|
@@ -131,9 +143,6 @@ class MacRecorderMultiProcess extends EventEmitter {
|
|
|
131
143
|
}
|
|
132
144
|
}, timeout);
|
|
133
145
|
|
|
134
|
-
// Send message to worker
|
|
135
|
-
this.worker.send({ type, data, id });
|
|
136
|
-
|
|
137
146
|
// Clear timeout on completion
|
|
138
147
|
const originalResolve = resolve;
|
|
139
148
|
const originalReject = reject;
|
|
@@ -149,6 +158,16 @@ class MacRecorderMultiProcess extends EventEmitter {
|
|
|
149
158
|
originalReject(error);
|
|
150
159
|
}
|
|
151
160
|
});
|
|
161
|
+
|
|
162
|
+
// A closed IPC pipe may fail asynchronously. Passing a callback
|
|
163
|
+
// contains that error and releases the pending request/timer.
|
|
164
|
+
const sendFailed = (error) => {
|
|
165
|
+
if (!error) return;
|
|
166
|
+
this.pendingRequests.get(id)?.reject(error);
|
|
167
|
+
this.pendingRequests.delete(id);
|
|
168
|
+
};
|
|
169
|
+
try { this.worker.send({ type, data, id }, sendFailed); }
|
|
170
|
+
catch (error) { sendFailed(error); }
|
|
152
171
|
});
|
|
153
172
|
}
|
|
154
173
|
|
|
@@ -186,16 +205,37 @@ class MacRecorderMultiProcess extends EventEmitter {
|
|
|
186
205
|
options
|
|
187
206
|
}, 60000); // Longer timeout for recording start
|
|
188
207
|
|
|
208
|
+
// The native first-frame event can arrive later than this response.
|
|
209
|
+
// Stop must already be available during that interval.
|
|
210
|
+
this.isRecording = true;
|
|
211
|
+
this.isPaused = false;
|
|
189
212
|
return result.outputPath;
|
|
190
213
|
}
|
|
191
214
|
|
|
215
|
+
async pauseRecording() {
|
|
216
|
+
if (!this.isRecording) throw new Error('No recording in progress');
|
|
217
|
+
if (this.isPaused) return this.getStatus();
|
|
218
|
+
const status = await this._sendRequest('pauseRecording');
|
|
219
|
+
this.isPaused = true;
|
|
220
|
+
return status;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async resumeRecording() {
|
|
224
|
+
if (!this.isRecording) throw new Error('No recording in progress');
|
|
225
|
+
if (!this.isPaused) return this.getStatus();
|
|
226
|
+
const status = await this._sendRequest('resumeRecording');
|
|
227
|
+
this.isPaused = false;
|
|
228
|
+
return status;
|
|
229
|
+
}
|
|
230
|
+
|
|
192
231
|
async stopRecording() {
|
|
193
232
|
if (!this.isRecording) {
|
|
194
233
|
throw new Error('No recording in progress');
|
|
195
234
|
}
|
|
196
235
|
|
|
197
|
-
const result = await this._sendRequest('stopRecording', null,
|
|
236
|
+
const result = await this._sendRequest('stopRecording', null, 45000);
|
|
198
237
|
this.isRecording = false;
|
|
238
|
+
this.isPaused = false;
|
|
199
239
|
|
|
200
240
|
return result;
|
|
201
241
|
}
|
|
@@ -231,6 +271,10 @@ class MacRecorderMultiProcess extends EventEmitter {
|
|
|
231
271
|
|
|
232
272
|
this.ready = false;
|
|
233
273
|
this.isRecording = false;
|
|
274
|
+
this.isPaused = false;
|
|
275
|
+
for (const { reject } of this.pendingRequests.values()) {
|
|
276
|
+
reject(new Error('Recorder worker destroyed'));
|
|
277
|
+
}
|
|
234
278
|
this.pendingRequests.clear();
|
|
235
279
|
}
|
|
236
280
|
}
|