node-mac-recorder 2.21.39 โ 2.21.41
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/.claude/settings.local.json +26 -1
- package/CREAVIT_CODE_SNIPPETS.md +832 -0
- package/CREAVIT_INTEGRATION.md +590 -0
- package/CURSOR_MAPPING.md +112 -0
- package/DUAL_RECORDING_PLAN.md +243 -0
- package/MULTI_RECORDING.md +270 -0
- package/MultiWindowRecorder.js +546 -0
- package/README.md +51 -0
- package/binding.gyp +1 -0
- package/index-multiprocess.js +238 -0
- package/index.js +174 -19
- package/package.json +1 -1
- package/recorder-worker.js +399 -0
- package/src/audio_mixer.mm +269 -0
- package/src/audio_recorder.mm +9 -0
- package/src/camera_recorder.mm +452 -260
- package/src/cursor_tracker.mm +75 -60
- package/src/mac_recorder.mm +279 -68
- package/src/screen_capture_kit.h +18 -5
- package/src/screen_capture_kit.mm +968 -387
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MultiWindowRecorder - Creavit Desktop Integration
|
|
3
|
+
* Manages multiple simultaneous window recordings
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const MacRecorder = require('./index-multiprocess');
|
|
7
|
+
const MacRecorderSync = require('./index'); // For cursor tracking
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { EventEmitter } = require('events');
|
|
10
|
+
|
|
11
|
+
class MultiWindowRecorder extends EventEmitter {
|
|
12
|
+
constructor(options = {}) {
|
|
13
|
+
super();
|
|
14
|
+
|
|
15
|
+
this.recorders = [];
|
|
16
|
+
this.windows = [];
|
|
17
|
+
this.isRecording = false;
|
|
18
|
+
this.outputFiles = [];
|
|
19
|
+
this.cursorFiles = [];
|
|
20
|
+
this.cameraFile = null; // Camera output file (from first recorder)
|
|
21
|
+
this.audioFile = null; // Audio output file (from first recorder)
|
|
22
|
+
this.cursorRecorder = null; // Separate recorder for cursor tracking
|
|
23
|
+
this.timeUpdateInterval = null; // Timer for timeUpdate events
|
|
24
|
+
this.metadata = {
|
|
25
|
+
startTime: null,
|
|
26
|
+
syncTimestamps: [],
|
|
27
|
+
windowCount: 0
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
this.options = {
|
|
31
|
+
frameRate: options.frameRate || 30,
|
|
32
|
+
captureCursor: false, // Don't show system cursor in window recording
|
|
33
|
+
preferScreenCaptureKit: options.preferScreenCaptureKit !== false,
|
|
34
|
+
// Audio options
|
|
35
|
+
enableMicrophone: options.enableMicrophone || false,
|
|
36
|
+
microphoneDeviceId: options.microphoneDeviceId || null,
|
|
37
|
+
captureSystemAudio: options.captureSystemAudio || false,
|
|
38
|
+
// Camera options
|
|
39
|
+
enableCamera: options.enableCamera || false,
|
|
40
|
+
cameraDeviceId: options.cameraDeviceId || null,
|
|
41
|
+
// Cursor tracking
|
|
42
|
+
trackCursor: options.trackCursor !== false, // Default: true
|
|
43
|
+
...options
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Add a window to be recorded
|
|
49
|
+
* @param {Object} windowInfo - Window information from getWindows()
|
|
50
|
+
* @returns {number} Index of the added window
|
|
51
|
+
*/
|
|
52
|
+
async addWindow(windowInfo) {
|
|
53
|
+
const recorder = new MacRecorder();
|
|
54
|
+
|
|
55
|
+
const recorderInfo = {
|
|
56
|
+
recorder,
|
|
57
|
+
windowId: windowInfo.id,
|
|
58
|
+
windowInfo: {
|
|
59
|
+
id: windowInfo.id,
|
|
60
|
+
appName: windowInfo.appName,
|
|
61
|
+
title: windowInfo.title,
|
|
62
|
+
width: windowInfo.width,
|
|
63
|
+
height: windowInfo.height
|
|
64
|
+
},
|
|
65
|
+
outputPath: null,
|
|
66
|
+
cursorFilePath: null,
|
|
67
|
+
syncTimestamp: null,
|
|
68
|
+
index: this.recorders.length
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
this.recorders.push(recorderInfo);
|
|
72
|
+
this.windows.push(windowInfo);
|
|
73
|
+
|
|
74
|
+
// Wait for worker to be ready
|
|
75
|
+
await new Promise(r => setTimeout(r, 500));
|
|
76
|
+
|
|
77
|
+
console.log(`โ
Window added: ${windowInfo.appName} (index: ${recorderInfo.index})`);
|
|
78
|
+
|
|
79
|
+
return recorderInfo.index;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Remove a window by index
|
|
84
|
+
* @param {number} index - Window index
|
|
85
|
+
*/
|
|
86
|
+
removeWindow(index) {
|
|
87
|
+
if (index < 0 || index >= this.recorders.length) {
|
|
88
|
+
throw new Error(`Invalid window index: ${index}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const recorderInfo = this.recorders[index];
|
|
92
|
+
|
|
93
|
+
if (recorderInfo && recorderInfo.recorder) {
|
|
94
|
+
recorderInfo.recorder.destroy();
|
|
95
|
+
console.log(`๐๏ธ Window removed: ${recorderInfo.windowInfo.appName}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
this.recorders.splice(index, 1);
|
|
99
|
+
this.windows.splice(index, 1);
|
|
100
|
+
|
|
101
|
+
// Update indices
|
|
102
|
+
this.recorders.forEach((rec, i) => {
|
|
103
|
+
rec.index = i;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Get current window count
|
|
109
|
+
*/
|
|
110
|
+
getWindowCount() {
|
|
111
|
+
return this.recorders.length;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Start recording all windows
|
|
116
|
+
* @param {string} outputDir - Output directory path
|
|
117
|
+
* @param {Object} options - Recording options
|
|
118
|
+
*/
|
|
119
|
+
async startRecording(outputDir, options = {}) {
|
|
120
|
+
if (this.isRecording) {
|
|
121
|
+
throw new Error('Recording already in progress');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (this.recorders.length === 0) {
|
|
125
|
+
throw new Error('No windows added. Call addWindow() first.');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const timestamp = Date.now();
|
|
129
|
+
this.metadata.startTime = timestamp;
|
|
130
|
+
this.metadata.windowCount = this.recorders.length;
|
|
131
|
+
this.outputFiles = [];
|
|
132
|
+
|
|
133
|
+
console.log(`๐ฌ Starting ${this.recorders.length} window recordings...`);
|
|
134
|
+
console.log(`๐ Output directory: ${outputDir}`);
|
|
135
|
+
|
|
136
|
+
// Start all recorders sequentially with 1s delay between each
|
|
137
|
+
for (let i = 0; i < this.recorders.length; i++) {
|
|
138
|
+
const recInfo = this.recorders[i];
|
|
139
|
+
const appName = recInfo.windowInfo.appName.replace(/[^a-zA-Z0-9]/g, '_');
|
|
140
|
+
const outputPath = path.join(outputDir, `temp_window_${i}_${appName}_${timestamp}.mov`);
|
|
141
|
+
|
|
142
|
+
console.log(`\nโถ๏ธ Starting recorder ${i + 1}/${this.recorders.length}: ${recInfo.windowInfo.appName}`);
|
|
143
|
+
|
|
144
|
+
const recordingOptions = {
|
|
145
|
+
windowId: recInfo.windowId,
|
|
146
|
+
frameRate: this.options.frameRate,
|
|
147
|
+
captureCursor: this.options.captureCursor,
|
|
148
|
+
preferScreenCaptureKit: this.options.preferScreenCaptureKit,
|
|
149
|
+
// Use the MAIN timestamp for ALL files to keep them synchronized
|
|
150
|
+
sessionTimestamp: timestamp,
|
|
151
|
+
// Audio options - ONLY record audio on first window to avoid duplicates
|
|
152
|
+
includeMicrophone: (i === 0 && this.options.enableMicrophone) || false,
|
|
153
|
+
audioDeviceId: (i === 0 && this.options.microphoneDeviceId) || null,
|
|
154
|
+
includeSystemAudio: (i === 0 && this.options.captureSystemAudio) || false,
|
|
155
|
+
systemAudioDeviceId: null,
|
|
156
|
+
// Camera options - record on first window only
|
|
157
|
+
captureCamera: (i === 0 && this.options.enableCamera) || false,
|
|
158
|
+
cameraDeviceId: (i === 0 && this.options.cameraDeviceId) || null,
|
|
159
|
+
...options
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const startTimestamp = Date.now();
|
|
164
|
+
|
|
165
|
+
// For first recorder, pre-calculate camera and audio paths
|
|
166
|
+
// IMPORTANT: Use the MAIN timestamp, not startTimestamp, to match window files!
|
|
167
|
+
if (i === 0) {
|
|
168
|
+
if (this.options.enableCamera) {
|
|
169
|
+
this.cameraFile = path.join(outputDir, `temp_camera_${timestamp}.mov`);
|
|
170
|
+
console.log(` ๐ท Camera will be saved to: ${path.basename(this.cameraFile)}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (this.options.enableMicrophone || this.options.captureSystemAudio) {
|
|
174
|
+
this.audioFile = path.join(outputDir, `temp_audio_${timestamp}.mov`);
|
|
175
|
+
console.log(` ๐ต Audio will be saved to: ${path.basename(this.audioFile)}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
await recInfo.recorder.startRecording(outputPath, recordingOptions);
|
|
180
|
+
|
|
181
|
+
recInfo.outputPath = outputPath;
|
|
182
|
+
recInfo.syncTimestamp = startTimestamp;
|
|
183
|
+
this.metadata.syncTimestamps.push(startTimestamp);
|
|
184
|
+
this.outputFiles.push(outputPath);
|
|
185
|
+
|
|
186
|
+
console.log(` โ
Recorder ${i + 1} started`);
|
|
187
|
+
console.log(` ๐ Output: ${path.basename(outputPath)}`);
|
|
188
|
+
|
|
189
|
+
// Start cursor tracking if enabled (only once for all windows)
|
|
190
|
+
if (this.options.trackCursor && i === 0) {
|
|
191
|
+
const cursorPath = path.join(outputDir, `temp_cursor_${timestamp}.json`);
|
|
192
|
+
|
|
193
|
+
// Create cursor recorder on first use
|
|
194
|
+
if (!this.cursorRecorder) {
|
|
195
|
+
this.cursorRecorder = new MacRecorderSync();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Get main display info for global cursor tracking
|
|
199
|
+
const displays = await this.cursorRecorder.getDisplays();
|
|
200
|
+
const mainDisplay = displays.find(d => d.isPrimary) || displays[0];
|
|
201
|
+
|
|
202
|
+
// Collect all window bounds for cursor location detection
|
|
203
|
+
const windowBounds = this.recorders.map(rec => ({
|
|
204
|
+
windowId: rec.windowId,
|
|
205
|
+
appName: rec.windowInfo.appName,
|
|
206
|
+
title: rec.windowInfo.title,
|
|
207
|
+
// Window bounds will be retrieved from native API
|
|
208
|
+
bounds: null // Will be filled by cursor tracker
|
|
209
|
+
}));
|
|
210
|
+
|
|
211
|
+
// Track cursor with global coordinates (for multi-window setup)
|
|
212
|
+
// Use main display as reference for coordinate system
|
|
213
|
+
const cursorOptions = {
|
|
214
|
+
videoRelative: false, // Global screen coordinates (not video-relative)
|
|
215
|
+
displayInfo: mainDisplay ? {
|
|
216
|
+
displayId: mainDisplay.id,
|
|
217
|
+
x: mainDisplay.x || 0,
|
|
218
|
+
y: mainDisplay.y || 0,
|
|
219
|
+
width: parseInt(mainDisplay.resolution.split('x')[0]),
|
|
220
|
+
height: parseInt(mainDisplay.resolution.split('x')[1]),
|
|
221
|
+
logicalWidth: parseInt(mainDisplay.resolution.split('x')[0]),
|
|
222
|
+
logicalHeight: parseInt(mainDisplay.resolution.split('x')[1])
|
|
223
|
+
} : null,
|
|
224
|
+
recordingType: 'multi-window', // Multi-window recording type
|
|
225
|
+
startTimestamp: startTimestamp, // Use same timestamp as video
|
|
226
|
+
// Pass window information for location detection
|
|
227
|
+
multiWindowBounds: windowBounds
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
await this.cursorRecorder.startCursorCapture(cursorPath, cursorOptions);
|
|
231
|
+
|
|
232
|
+
// Store cursor file path for all windows
|
|
233
|
+
this.recorders.forEach(rec => {
|
|
234
|
+
rec.cursorFilePath = cursorPath;
|
|
235
|
+
});
|
|
236
|
+
this.cursorFiles.push(cursorPath);
|
|
237
|
+
|
|
238
|
+
console.log(` ๐ฑ๏ธ Cursor tracking started (multi-window mode)`);
|
|
239
|
+
console.log(` ๐ Cursor file: ${path.basename(cursorPath)}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
this.emit('recorderStarted', {
|
|
243
|
+
index: i,
|
|
244
|
+
windowInfo: recInfo.windowInfo,
|
|
245
|
+
outputPath: outputPath,
|
|
246
|
+
cursorFilePath: recInfo.cursorFilePath,
|
|
247
|
+
timestamp: startTimestamp
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Wait for ScreenCaptureKit initialization (except for last recorder)
|
|
251
|
+
if (i < this.recorders.length - 1) {
|
|
252
|
+
console.log(` โณ Waiting 1s for ScreenCaptureKit init...`);
|
|
253
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
254
|
+
}
|
|
255
|
+
} catch (error) {
|
|
256
|
+
console.error(` โ Failed to start recorder ${i + 1}:`, error.message);
|
|
257
|
+
|
|
258
|
+
// Stop all previously started recorders and cursor tracking
|
|
259
|
+
for (let j = 0; j < i; j++) {
|
|
260
|
+
try {
|
|
261
|
+
await this.recorders[j].recorder.stopRecording();
|
|
262
|
+
} catch (stopError) {
|
|
263
|
+
console.error(`Failed to stop recorder ${j}:`, stopError.message);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Stop cursor tracking if it was started
|
|
268
|
+
if (this.options.trackCursor && this.cursorRecorder) {
|
|
269
|
+
try {
|
|
270
|
+
await this.cursorRecorder.stopCursorCapture();
|
|
271
|
+
} catch (cursorError) {
|
|
272
|
+
console.error(`Failed to stop cursor tracking:`, cursorError.message);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
throw new Error(`Failed to start recorder ${i + 1}: ${error.message}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
this.isRecording = true;
|
|
281
|
+
|
|
282
|
+
// Start timeUpdate timer (emit every second)
|
|
283
|
+
this.timeUpdateInterval = setInterval(() => {
|
|
284
|
+
if (this.isRecording && this.metadata.startTime) {
|
|
285
|
+
const elapsed = Math.floor((Date.now() - this.metadata.startTime) / 1000);
|
|
286
|
+
this.emit('timeUpdate', elapsed);
|
|
287
|
+
}
|
|
288
|
+
}, 1000);
|
|
289
|
+
|
|
290
|
+
console.log(`\nโ
All ${this.recorders.length} recordings started successfully!`);
|
|
291
|
+
console.log(`๐ด Multi-window recording in progress...`);
|
|
292
|
+
|
|
293
|
+
this.emit('allStarted', {
|
|
294
|
+
windowCount: this.recorders.length,
|
|
295
|
+
outputFiles: this.outputFiles,
|
|
296
|
+
metadata: this.metadata
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
windowCount: this.recorders.length,
|
|
301
|
+
outputFiles: this.outputFiles,
|
|
302
|
+
metadata: this.metadata
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Stop all recordings
|
|
308
|
+
*/
|
|
309
|
+
async stopRecording() {
|
|
310
|
+
if (!this.isRecording) {
|
|
311
|
+
throw new Error('No recording in progress');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
console.log(`\n๐ Stopping ${this.recorders.length} recordings...`);
|
|
315
|
+
|
|
316
|
+
const stopTimestamp = Date.now();
|
|
317
|
+
|
|
318
|
+
// Stop timeUpdate timer
|
|
319
|
+
if (this.timeUpdateInterval) {
|
|
320
|
+
clearInterval(this.timeUpdateInterval);
|
|
321
|
+
this.timeUpdateInterval = null;
|
|
322
|
+
console.log(` โฑ๏ธ Timer stopped`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Stop cursor tracking first (before stopping video recordings)
|
|
326
|
+
if (this.options.trackCursor && this.cursorRecorder) {
|
|
327
|
+
try {
|
|
328
|
+
console.log(` ๐ฑ๏ธ Stopping cursor tracking...`);
|
|
329
|
+
await this.cursorRecorder.stopCursorCapture();
|
|
330
|
+
console.log(` โ
Cursor tracking stopped`);
|
|
331
|
+
} catch (error) {
|
|
332
|
+
console.error(` โ Failed to stop cursor tracking:`, error.message);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Stop all recorders in parallel
|
|
337
|
+
const stopPromises = this.recorders.map(async (recInfo, index) => {
|
|
338
|
+
try {
|
|
339
|
+
console.log(` Stopping recorder ${index + 1}: ${recInfo.windowInfo.appName}...`);
|
|
340
|
+
|
|
341
|
+
await recInfo.recorder.stopRecording();
|
|
342
|
+
console.log(` โ
Recorder ${index + 1} stopped`);
|
|
343
|
+
|
|
344
|
+
this.emit('recorderStopped', {
|
|
345
|
+
index,
|
|
346
|
+
windowInfo: recInfo.windowInfo,
|
|
347
|
+
outputPath: recInfo.outputPath,
|
|
348
|
+
cursorFilePath: recInfo.cursorFilePath
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
index,
|
|
353
|
+
success: true,
|
|
354
|
+
outputPath: recInfo.outputPath,
|
|
355
|
+
cursorFilePath: recInfo.cursorFilePath
|
|
356
|
+
};
|
|
357
|
+
} catch (error) {
|
|
358
|
+
console.error(` โ Failed to stop recorder ${index + 1}:`, error.message);
|
|
359
|
+
|
|
360
|
+
this.emit('recorderError', {
|
|
361
|
+
index,
|
|
362
|
+
error: error.message
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
index,
|
|
367
|
+
success: false,
|
|
368
|
+
error: error.message
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
const results = await Promise.all(stopPromises);
|
|
374
|
+
|
|
375
|
+
this.isRecording = false;
|
|
376
|
+
|
|
377
|
+
// Get camera and audio paths from first recorder's status
|
|
378
|
+
if (this.recorders.length > 0) {
|
|
379
|
+
try {
|
|
380
|
+
const firstRecorderStatus = this.recorders[0].recorder.getStatus();
|
|
381
|
+
if (firstRecorderStatus.cameraOutputPath) {
|
|
382
|
+
this.cameraFile = firstRecorderStatus.cameraOutputPath;
|
|
383
|
+
console.log(` ๐ท Camera file from recorder: ${path.basename(this.cameraFile)}`);
|
|
384
|
+
}
|
|
385
|
+
if (firstRecorderStatus.audioOutputPath) {
|
|
386
|
+
this.audioFile = firstRecorderStatus.audioOutputPath;
|
|
387
|
+
console.log(` ๐ต Audio file from recorder: ${path.basename(this.audioFile)}`);
|
|
388
|
+
}
|
|
389
|
+
} catch (error) {
|
|
390
|
+
console.error(` โ ๏ธ Could not get camera/audio paths from first recorder:`, error.message);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Calculate duration
|
|
395
|
+
const duration = stopTimestamp - this.metadata.startTime;
|
|
396
|
+
|
|
397
|
+
const result = {
|
|
398
|
+
success: results.every(r => r.success),
|
|
399
|
+
windowCount: this.recorders.length,
|
|
400
|
+
outputFiles: this.outputFiles,
|
|
401
|
+
cursorFiles: this.cursorFiles,
|
|
402
|
+
cameraFile: this.cameraFile, // Camera output path (from first recorder)
|
|
403
|
+
audioFile: this.audioFile, // Audio output path (from first recorder)
|
|
404
|
+
duration: duration,
|
|
405
|
+
metadata: {
|
|
406
|
+
...this.metadata,
|
|
407
|
+
stopTime: stopTimestamp,
|
|
408
|
+
duration: duration,
|
|
409
|
+
windows: this.recorders.map((recInfo, i) => ({
|
|
410
|
+
index: i,
|
|
411
|
+
windowInfo: recInfo.windowInfo,
|
|
412
|
+
outputPath: recInfo.outputPath,
|
|
413
|
+
cursorFilePath: recInfo.cursorFilePath,
|
|
414
|
+
syncTimestamp: recInfo.syncTimestamp,
|
|
415
|
+
syncOffset: recInfo.syncTimestamp - this.metadata.startTime
|
|
416
|
+
}))
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
console.log(`\nโ
All recordings stopped successfully!`);
|
|
421
|
+
console.log(`๐ Duration: ${(duration / 1000).toFixed(2)}s`);
|
|
422
|
+
console.log(`๐ Output files: ${this.outputFiles.length}`);
|
|
423
|
+
if (this.options.trackCursor) {
|
|
424
|
+
console.log(`๐ฑ๏ธ Cursor files: ${this.cursorFiles.length}`);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
this.emit('allStopped', result);
|
|
428
|
+
|
|
429
|
+
return result;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Get recording status
|
|
434
|
+
*/
|
|
435
|
+
getStatus() {
|
|
436
|
+
return {
|
|
437
|
+
isRecording: this.isRecording,
|
|
438
|
+
windowCount: this.recorders.length,
|
|
439
|
+
outputFiles: this.outputFiles,
|
|
440
|
+
metadata: this.metadata,
|
|
441
|
+
windows: this.recorders.map(rec => ({
|
|
442
|
+
index: rec.index,
|
|
443
|
+
windowInfo: rec.windowInfo,
|
|
444
|
+
outputPath: rec.outputPath
|
|
445
|
+
}))
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Get metadata for CRVT file creation
|
|
451
|
+
* @param {Object} options - Options for metadata generation
|
|
452
|
+
* @param {string} options.clipId - Clip ID for media paths (e.g., 'clip_1762961230017')
|
|
453
|
+
*/
|
|
454
|
+
getMetadataForCRVT(options = {}) {
|
|
455
|
+
const { clipId } = options;
|
|
456
|
+
|
|
457
|
+
// Helper function to convert full path to media path
|
|
458
|
+
const toMediaPath = (fullPath) => {
|
|
459
|
+
if (!fullPath || !clipId) return fullPath;
|
|
460
|
+
const filename = path.basename(fullPath);
|
|
461
|
+
return `media/${clipId}/${filename}`;
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
version: '2.0',
|
|
466
|
+
timestamp: this.metadata.startTime,
|
|
467
|
+
duration: this.metadata.duration || 0,
|
|
468
|
+
multiWindow: {
|
|
469
|
+
enabled: true,
|
|
470
|
+
windowCount: this.recorders.length,
|
|
471
|
+
windows: this.recorders.map((recInfo, i) => ({
|
|
472
|
+
index: i,
|
|
473
|
+
windowInfo: {
|
|
474
|
+
id: recInfo.windowId,
|
|
475
|
+
appName: recInfo.windowInfo.appName,
|
|
476
|
+
title: recInfo.windowInfo.title,
|
|
477
|
+
width: recInfo.windowInfo.width,
|
|
478
|
+
height: recInfo.windowInfo.height
|
|
479
|
+
},
|
|
480
|
+
outputPath: toMediaPath(recInfo.outputPath), // media/clip_xxx/filename.mov
|
|
481
|
+
cursorFilePath: toMediaPath(recInfo.cursorFilePath), // media/clip_xxx/filename.json
|
|
482
|
+
syncTimestamp: recInfo.syncTimestamp,
|
|
483
|
+
syncOffset: recInfo.syncTimestamp - this.metadata.startTime
|
|
484
|
+
})),
|
|
485
|
+
syncTimestamps: this.metadata.syncTimestamps
|
|
486
|
+
},
|
|
487
|
+
// Recording options
|
|
488
|
+
options: {
|
|
489
|
+
enableCamera: this.options.enableCamera,
|
|
490
|
+
cameraDeviceId: this.options.cameraDeviceId,
|
|
491
|
+
enableMicrophone: this.options.enableMicrophone,
|
|
492
|
+
microphoneDeviceId: this.options.microphoneDeviceId,
|
|
493
|
+
captureSystemAudio: this.options.captureSystemAudio,
|
|
494
|
+
trackCursor: this.options.trackCursor
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Destroy all recorders and cleanup
|
|
501
|
+
*/
|
|
502
|
+
destroy() {
|
|
503
|
+
console.log('๐งน Cleaning up multi-window recorder...');
|
|
504
|
+
|
|
505
|
+
// Stop timeUpdate timer
|
|
506
|
+
if (this.timeUpdateInterval) {
|
|
507
|
+
clearInterval(this.timeUpdateInterval);
|
|
508
|
+
this.timeUpdateInterval = null;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
this.recorders.forEach((recInfo, index) => {
|
|
512
|
+
try {
|
|
513
|
+
recInfo.recorder.destroy();
|
|
514
|
+
console.log(` โ Recorder ${index + 1} destroyed`);
|
|
515
|
+
} catch (error) {
|
|
516
|
+
console.error(` โ Failed to destroy recorder ${index + 1}:`, error.message);
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// Destroy cursor recorder if exists
|
|
521
|
+
if (this.cursorRecorder) {
|
|
522
|
+
try {
|
|
523
|
+
// MacRecorderSync uses cleanup() instead of destroy()
|
|
524
|
+
if (typeof this.cursorRecorder.cleanup === 'function') {
|
|
525
|
+
this.cursorRecorder.cleanup();
|
|
526
|
+
} else if (typeof this.cursorRecorder.destroy === 'function') {
|
|
527
|
+
this.cursorRecorder.destroy();
|
|
528
|
+
}
|
|
529
|
+
console.log(` โ Cursor recorder destroyed`);
|
|
530
|
+
} catch (error) {
|
|
531
|
+
console.error(` โ Failed to destroy cursor recorder:`, error.message);
|
|
532
|
+
}
|
|
533
|
+
this.cursorRecorder = null;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
this.recorders = [];
|
|
537
|
+
this.windows = [];
|
|
538
|
+
this.outputFiles = [];
|
|
539
|
+
this.cursorFiles = [];
|
|
540
|
+
this.isRecording = false;
|
|
541
|
+
|
|
542
|
+
console.log('โ
Multi-window recorder cleaned up');
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
module.exports = MultiWindowRecorder;
|
package/README.md
CHANGED
|
@@ -18,6 +18,7 @@ A powerful native macOS screen recording Node.js package with advanced window se
|
|
|
18
18
|
- ๐ **Audio Capture** - Record microphone/system audio into synchronized companion files
|
|
19
19
|
- ๐ซ **Automatic Overlay Exclusion** - Overlay windows automatically excluded from recordings
|
|
20
20
|
- โก **Electron Compatible** - Enhanced crash protection for Electron applications
|
|
21
|
+
- ๐ฌ **Multi-Window Recording** - โจ NEW! Record multiple windows/displays simultaneously
|
|
21
22
|
|
|
22
23
|
๐ต **Granular Audio Controls**
|
|
23
24
|
|
|
@@ -93,6 +94,56 @@ await new Promise((resolve) => setTimeout(resolve, 5000)); // Record for 5 secon
|
|
|
93
94
|
await recorder.stopRecording();
|
|
94
95
|
```
|
|
95
96
|
|
|
97
|
+
## Multi-Window Recording โจ NEW!
|
|
98
|
+
|
|
99
|
+
Record multiple windows or displays simultaneously using child processes:
|
|
100
|
+
|
|
101
|
+
```javascript
|
|
102
|
+
const MacRecorder = require("node-mac-recorder/index-multiprocess");
|
|
103
|
+
|
|
104
|
+
// Create separate recorders for each window
|
|
105
|
+
const recorder1 = new MacRecorder();
|
|
106
|
+
const recorder2 = new MacRecorder();
|
|
107
|
+
|
|
108
|
+
// Get available windows
|
|
109
|
+
const windows = await recorder1.getWindows();
|
|
110
|
+
|
|
111
|
+
// Record first window (e.g., Finder)
|
|
112
|
+
await recorder1.startRecording("window1.mov", {
|
|
113
|
+
windowId: windows[0].id,
|
|
114
|
+
frameRate: 30
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Wait for ScreenCaptureKit initialization
|
|
118
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
119
|
+
|
|
120
|
+
// Record second window (e.g., Chrome)
|
|
121
|
+
await recorder2.startRecording("window2.mov", {
|
|
122
|
+
windowId: windows[1].id,
|
|
123
|
+
frameRate: 30
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Both recordings are now running in parallel! ๐
|
|
127
|
+
|
|
128
|
+
// Stop both after 10 seconds
|
|
129
|
+
await new Promise(r => setTimeout(r, 10000));
|
|
130
|
+
await recorder1.stopRecording();
|
|
131
|
+
await recorder2.stopRecording();
|
|
132
|
+
|
|
133
|
+
// Cleanup
|
|
134
|
+
recorder1.destroy();
|
|
135
|
+
recorder2.destroy();
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
**Key Benefits:**
|
|
139
|
+
- โ
No native code changes required
|
|
140
|
+
- โ
Each recorder runs in its own process
|
|
141
|
+
- โ
True parallel recording
|
|
142
|
+
- โ
Separate output files
|
|
143
|
+
- โ
Independent control
|
|
144
|
+
|
|
145
|
+
**See:** `MULTI_RECORDING.md` for detailed documentation and examples.
|
|
146
|
+
|
|
96
147
|
## API Reference
|
|
97
148
|
|
|
98
149
|
### Constructor
|