node-mac-recorder 2.21.40 → 2.21.42

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.
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Multi-Process MacRecorder
3
+ * Spawns each recorder in its own child process for true parallel recording
4
+ */
5
+
6
+ const { EventEmitter } = require('events');
7
+ const { fork } = require('child_process');
8
+ const path = require('path');
9
+
10
+ class MacRecorderMultiProcess extends EventEmitter {
11
+ constructor(options = {}) {
12
+ super();
13
+
14
+ this.worker = null;
15
+ this.isRecording = false;
16
+ this.outputPath = null;
17
+ this.ready = false;
18
+ this.pendingRequests = new Map();
19
+ this.requestId = 0;
20
+
21
+ // Auto-spawn worker
22
+ this._spawnWorker();
23
+ }
24
+
25
+ _spawnWorker() {
26
+ const workerPath = path.join(__dirname, 'recorder-worker.js');
27
+
28
+ console.log(`🚀 Spawning recorder worker: ${workerPath}`);
29
+
30
+ this.worker = fork(workerPath, [], {
31
+ stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
32
+ env: { ...process.env }
33
+ });
34
+
35
+ this.worker.on('message', (msg) => this._handleWorkerMessage(msg));
36
+
37
+ this.worker.on('error', (error) => {
38
+ console.error('❌ Worker error:', error);
39
+ this.emit('error', error);
40
+ });
41
+
42
+ this.worker.on('exit', (code, signal) => {
43
+ console.log(`🛑 Worker exited: code=${code}, signal=${signal}`);
44
+ this.ready = false;
45
+ this.isRecording = false;
46
+
47
+ // Reject all pending requests
48
+ for (const [id, { reject }] of this.pendingRequests) {
49
+ reject(new Error('Worker process exited'));
50
+ }
51
+ this.pendingRequests.clear();
52
+ });
53
+
54
+ this.worker.stdout.on('data', (data) => {
55
+ console.log(`[Worker] ${data.toString().trim()}`);
56
+ });
57
+
58
+ this.worker.stderr.on('data', (data) => {
59
+ console.error(`[Worker Error] ${data.toString().trim()}`);
60
+ });
61
+ }
62
+
63
+ _handleWorkerMessage(msg) {
64
+ // Handle ready message
65
+ if (msg.type === 'ready') {
66
+ this.ready = true;
67
+ console.log('✅ Worker ready');
68
+ return;
69
+ }
70
+
71
+ // Handle events
72
+ if (msg.type === 'event') {
73
+ this.emit(msg.event, msg.data);
74
+
75
+ // Update local state based on events
76
+ if (msg.event === 'recordingStarted') {
77
+ this.isRecording = true;
78
+ } else if (msg.event === 'stopped') {
79
+ this.isRecording = false;
80
+ }
81
+ return;
82
+ }
83
+
84
+ // Handle errors
85
+ if (msg.type === 'error') {
86
+ console.error('❌ Worker error:', msg.message);
87
+ this.emit('error', new Error(msg.message));
88
+ return;
89
+ }
90
+
91
+ // Handle responses to specific requests
92
+ if (msg.type.endsWith(':response')) {
93
+ const requestType = msg.type.replace(':response', '');
94
+
95
+ // Find matching pending request
96
+ for (const [id, { type, resolve, reject }] of this.pendingRequests) {
97
+ if (type === requestType) {
98
+ this.pendingRequests.delete(id);
99
+
100
+ if (msg.success === false) {
101
+ reject(new Error(msg.error || 'Request failed'));
102
+ } else {
103
+ resolve(msg.data);
104
+ }
105
+ break;
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ _sendRequest(type, data = null, timeout = 30000) {
112
+ return new Promise((resolve, reject) => {
113
+ if (!this.worker) {
114
+ return reject(new Error('Worker not initialized'));
115
+ }
116
+
117
+ if (!this.ready) {
118
+ return reject(new Error('Worker not ready'));
119
+ }
120
+
121
+ const id = ++this.requestId;
122
+
123
+ // Store pending request
124
+ this.pendingRequests.set(id, { type, resolve, reject });
125
+
126
+ // Set timeout
127
+ const timeoutId = setTimeout(() => {
128
+ if (this.pendingRequests.has(id)) {
129
+ this.pendingRequests.delete(id);
130
+ reject(new Error(`Request timeout: ${type}`));
131
+ }
132
+ }, timeout);
133
+
134
+ // Send message to worker
135
+ this.worker.send({ type, data, id });
136
+
137
+ // Clear timeout on completion
138
+ const originalResolve = resolve;
139
+ const originalReject = reject;
140
+
141
+ this.pendingRequests.set(id, {
142
+ type,
143
+ resolve: (value) => {
144
+ clearTimeout(timeoutId);
145
+ originalResolve(value);
146
+ },
147
+ reject: (error) => {
148
+ clearTimeout(timeoutId);
149
+ originalReject(error);
150
+ }
151
+ });
152
+ });
153
+ }
154
+
155
+ async getWindows() {
156
+ return this._sendRequest('getWindows');
157
+ }
158
+
159
+ async getDisplays() {
160
+ const displays = await this._sendRequest('getDisplays');
161
+ return displays.map((display, index) => ({
162
+ id: display.id,
163
+ name: display.name,
164
+ width: display.width,
165
+ height: display.height,
166
+ x: display.x,
167
+ y: display.y,
168
+ isPrimary: display.isPrimary,
169
+ resolution: `${display.width}x${display.height}`
170
+ }));
171
+ }
172
+
173
+ async startRecording(outputPath, options = {}) {
174
+ if (this.isRecording) {
175
+ throw new Error('Recording already in progress');
176
+ }
177
+
178
+ if (!outputPath) {
179
+ throw new Error('Output path is required');
180
+ }
181
+
182
+ this.outputPath = outputPath;
183
+
184
+ const result = await this._sendRequest('startRecording', {
185
+ outputPath,
186
+ options
187
+ }, 60000); // Longer timeout for recording start
188
+
189
+ return result.outputPath;
190
+ }
191
+
192
+ async stopRecording() {
193
+ if (!this.isRecording) {
194
+ throw new Error('No recording in progress');
195
+ }
196
+
197
+ const result = await this._sendRequest('stopRecording', null, 10000);
198
+ this.isRecording = false;
199
+
200
+ return result;
201
+ }
202
+
203
+ async startCursorCapture(filepath, options = {}) {
204
+ if (!this.ready) {
205
+ throw new Error('Worker not ready');
206
+ }
207
+
208
+ const result = await this._sendRequest('startCursorCapture', {
209
+ filepath,
210
+ options
211
+ });
212
+
213
+ return result;
214
+ }
215
+
216
+ async stopCursorCapture() {
217
+ const result = await this._sendRequest('stopCursorCapture');
218
+ return result;
219
+ }
220
+
221
+ async getStatus() {
222
+ return this._sendRequest('getStatus');
223
+ }
224
+
225
+ // Cleanup
226
+ destroy() {
227
+ if (this.worker) {
228
+ this.worker.kill();
229
+ this.worker = null;
230
+ }
231
+
232
+ this.ready = false;
233
+ this.isRecording = false;
234
+ this.pendingRequests.clear();
235
+ }
236
+ }
237
+
238
+ module.exports = MacRecorderMultiProcess;
package/index.js CHANGED
@@ -2,20 +2,42 @@ const { EventEmitter } = require("events");
2
2
  const path = require("path");
3
3
  const fs = require("fs");
4
4
 
5
+ // Auto-switch to Electron-safe implementation when running under Electron and binary exists
6
+ let USE_ELECTRON_SAFE = false;
7
+ let ElectronSafeMacRecorder = null;
8
+ try {
9
+ const isElectron = !!(process && process.versions && process.versions.electron);
10
+ const preferElectronSafe = process.env.PREFER_ELECTRON_SAFE === "1" || process.env.USE_ELECTRON_SAFE === "1";
11
+ if (isElectron || preferElectronSafe) {
12
+ const rel = path.join(__dirname, "build", "Release", "mac_recorder_electron.node");
13
+ const dbg = path.join(__dirname, "build", "Debug", "mac_recorder_electron.node");
14
+ if (fs.existsSync(rel) || fs.existsSync(dbg) || preferElectronSafe) {
15
+ // Defer requiring native .node; use JS wrapper which loads it
16
+ ElectronSafeMacRecorder = require("./electron-safe-index");
17
+ USE_ELECTRON_SAFE = true;
18
+ console.log("✅ Auto-enabled Electron-safe MacRecorder");
19
+ }
20
+ }
21
+ } catch (_) {
22
+ // Ignore auto-switch errors; fall back to standard binding
23
+ }
24
+
5
25
  // Native modülü yükle
6
26
  let nativeBinding;
7
- try {
8
- nativeBinding = require("./build/Release/mac_recorder.node");
9
- } catch (error) {
10
- try {
11
- nativeBinding = require("./build/Debug/mac_recorder.node");
12
- } catch (debugError) {
13
- throw new Error(
14
- 'Native module not found. Please run "npm run build" to compile the native module.\n' +
15
- "Original error: " +
16
- error.message
17
- );
18
- }
27
+ if (!USE_ELECTRON_SAFE) {
28
+ try {
29
+ nativeBinding = require("./build/Release/mac_recorder.node");
30
+ } catch (error) {
31
+ try {
32
+ nativeBinding = require("./build/Debug/mac_recorder.node");
33
+ } catch (debugError) {
34
+ throw new Error(
35
+ 'Native module not found. Please run "npm run build" to compile the native module.\n' +
36
+ "Original error: " +
37
+ error.message
38
+ );
39
+ }
40
+ }
19
41
  }
20
42
 
21
43
  class MacRecorder extends EventEmitter {
@@ -26,6 +48,9 @@ class MacRecorder extends EventEmitter {
26
48
  this.recordingTimer = null;
27
49
  this.recordingStartTime = null;
28
50
 
51
+ // MULTI-SESSION: Unique session ID for this recorder instance
52
+ this.nativeSessionId = null; // Will be generated when recording starts
53
+
29
54
  // Cursor capture variables
30
55
  this.cursorCaptureInterval = null;
31
56
  this.cursorCaptureFile = null;
@@ -155,6 +180,9 @@ class MacRecorder extends EventEmitter {
155
180
  */
156
181
  setOptions(options = {}) {
157
182
  // Merge options instead of replacing to preserve previously set values
183
+ if (options.sessionTimestamp !== undefined) {
184
+ this.options.sessionTimestamp = options.sessionTimestamp;
185
+ }
158
186
  if (options.includeMicrophone !== undefined) {
159
187
  this.options.includeMicrophone = options.includeMicrophone === true;
160
188
  }
@@ -189,6 +217,10 @@ class MacRecorder extends EventEmitter {
189
217
  this.options.frameRate = Math.min(Math.max(fps, 1), 120);
190
218
  }
191
219
  }
220
+ // Prefer ScreenCaptureKit (macOS 15+) toggle
221
+ if (options.preferScreenCaptureKit !== undefined) {
222
+ this.options.preferScreenCaptureKit = options.preferScreenCaptureKit === true;
223
+ }
192
224
  if (options.cameraDeviceId !== undefined) {
193
225
  this.options.cameraDeviceId =
194
226
  typeof options.cameraDeviceId === "string" && options.cameraDeviceId.length > 0
@@ -448,9 +480,18 @@ class MacRecorder extends EventEmitter {
448
480
 
449
481
  return new Promise(async (resolve, reject) => {
450
482
  try {
451
- // SYNC FIX: Create unified session timestamp FIRST for all components
452
- const sessionTimestamp = Date.now();
483
+ // MULTI-SESSION: Generate unique session ID for this recording
484
+ // Use provided sessionTimestamp from options, or generate new one
485
+ const sessionTimestamp = this.options.sessionTimestamp || Date.now();
453
486
  this.sessionTimestamp = sessionTimestamp;
487
+ this.nativeSessionId = `rec_${sessionTimestamp}_${Math.random().toString(36).substr(2, 9)}`;
488
+
489
+ console.log(`🎬 Starting recording with session ID: ${this.nativeSessionId}`);
490
+ if (this.options.sessionTimestamp) {
491
+ console.log(` ⏰ Using provided sessionTimestamp: ${this.options.sessionTimestamp}`);
492
+ } else {
493
+ console.log(` ⏰ Generated new sessionTimestamp: ${sessionTimestamp}`);
494
+ }
454
495
 
455
496
  // CRITICAL FIX: Ensure main video file also uses sessionTimestamp
456
497
  // This guarantees ALL files have the exact same timestamp
@@ -505,8 +546,12 @@ class MacRecorder extends EventEmitter {
505
546
  captureCamera: this.options.captureCamera === true,
506
547
  cameraDeviceId: this.options.cameraDeviceId || null,
507
548
  sessionTimestamp,
549
+ // MULTI-SESSION: Pass unique session ID to native code
550
+ nativeSessionId: this.nativeSessionId,
508
551
  frameRate: this.options.frameRate || 60,
509
552
  quality: this.options.quality || "high",
553
+ // Hint native side to use ScreenCaptureKit on macOS 15+
554
+ preferScreenCaptureKit: this.options.preferScreenCaptureKit === true,
510
555
  };
511
556
 
512
557
  if (cameraFilePath) {
@@ -554,10 +599,34 @@ class MacRecorder extends EventEmitter {
554
599
 
555
600
  // Only start cursor if native recording started successfully
556
601
  if (success) {
602
+ // For ScreenCaptureKit (async startup), wait briefly until native fully initialized
603
+ // ScreenCaptureKit needs ~150-300ms to start + ~150ms for first 10 frames
604
+ const waitStart = Date.now();
605
+ try {
606
+ while (Date.now() - waitStart < 600) {
607
+ try {
608
+ const nativeStatus = nativeBinding && nativeBinding.getRecordingStatus ? nativeBinding.getRecordingStatus() : true;
609
+ if (nativeStatus) {
610
+ console.log(`✅ SYNC: Native recording fully ready after ${Date.now() - waitStart}ms`);
611
+ break;
612
+ }
613
+ } catch (_) {}
614
+ await new Promise(r => setTimeout(r, 30));
615
+ }
616
+ } catch (_) {}
557
617
  this.sessionTimestamp = sessionTimestamp;
618
+
619
+ // CURSOR SYNC FIX: Wait additional 300ms for first frames to start
620
+ // This ensures cursor tracking aligns with actual video timeline
621
+ // ScreenCaptureKit needs ~200-350ms to actually start capturing frames
622
+ // We wait 300ms to ensure cursor starts AFTER first video frame
623
+ console.log('⏳ CURSOR SYNC: Waiting 300ms for first video frames...');
624
+ await new Promise(r => setTimeout(r, 300));
625
+
558
626
  const syncTimestamp = Date.now();
559
627
  this.syncTimestamp = syncTimestamp;
560
628
  this.recordingStartTime = syncTimestamp;
629
+ console.log(`🎯 CURSOR SYNC: Cursor tracking will use timestamp: ${syncTimestamp}`);
561
630
 
562
631
  const standardCursorOptions = {
563
632
  videoRelative: true,
@@ -1100,6 +1169,27 @@ class MacRecorder extends EventEmitter {
1100
1169
  // SYNC FIX: Use pre-defined timestamp if provided for synchronization
1101
1170
  const syncStartTime = options.startTimestamp || Date.now();
1102
1171
 
1172
+ // Fetch window bounds for multi-window recording
1173
+ if (options.multiWindowBounds && options.multiWindowBounds.length > 0) {
1174
+ try {
1175
+ const allWindows = await this.getWindows();
1176
+ // Match window IDs and populate bounds
1177
+ for (const windowInfo of options.multiWindowBounds) {
1178
+ const windowData = allWindows.find(w => w.id === windowInfo.windowId);
1179
+ if (windowData) {
1180
+ windowInfo.bounds = {
1181
+ x: windowData.x || 0,
1182
+ y: windowData.y || 0,
1183
+ width: windowData.width || 0,
1184
+ height: windowData.height || 0
1185
+ };
1186
+ }
1187
+ }
1188
+ } catch (error) {
1189
+ console.warn('Failed to fetch window bounds for multi-window cursor tracking:', error.message);
1190
+ }
1191
+ }
1192
+
1103
1193
  // Use video-relative coordinate system for all recording types
1104
1194
  if (options.videoRelative && options.displayInfo) {
1105
1195
  // Calculate video offset based on recording type
@@ -1139,7 +1229,9 @@ class MacRecorder extends EventEmitter {
1139
1229
  recordingType: options.recordingType || 'display',
1140
1230
  // Store additional context for debugging
1141
1231
  captureArea: options.captureArea,
1142
- windowId: options.windowId
1232
+ windowId: options.windowId,
1233
+ // Multi-window bounds for location detection
1234
+ multiWindowBounds: options.multiWindowBounds || null
1143
1235
  };
1144
1236
  } else if (this.recordingDisplayInfo) {
1145
1237
  // Fallback: Use recording display info if available
@@ -1154,7 +1246,8 @@ class MacRecorder extends EventEmitter {
1154
1246
  videoWidth: this.recordingDisplayInfo.width || this.recordingDisplayInfo.logicalWidth,
1155
1247
  videoHeight: this.recordingDisplayInfo.height || this.recordingDisplayInfo.logicalHeight,
1156
1248
  videoRelative: true,
1157
- recordingType: options.recordingType || 'display'
1249
+ recordingType: options.recordingType || 'display',
1250
+ multiWindowBounds: options.multiWindowBounds || null
1158
1251
  };
1159
1252
  } else {
1160
1253
  // Final fallback: Main display global coordinates
@@ -1168,6 +1261,7 @@ class MacRecorder extends EventEmitter {
1168
1261
  y: mainDisplay.y,
1169
1262
  width: parseInt(mainDisplay.resolution.split("x")[0]),
1170
1263
  height: parseInt(mainDisplay.resolution.split("x")[1]),
1264
+ multiWindowBounds: options.multiWindowBounds || null
1171
1265
  };
1172
1266
  }
1173
1267
  } catch (error) {
@@ -1187,6 +1281,8 @@ class MacRecorder extends EventEmitter {
1187
1281
  this.cursorCaptureStartTime = syncStartTime;
1188
1282
  this.cursorCaptureFirstWrite = true;
1189
1283
  this.lastCapturedData = null;
1284
+ // Store session timestamp for sync metadata
1285
+ this.cursorCaptureSessionTimestamp = this.sessionTimestamp;
1190
1286
 
1191
1287
  // JavaScript interval ile polling yap (daha sık - mouse event'leri yakalamak için)
1192
1288
  this.cursorCaptureInterval = setInterval(() => {
@@ -1236,14 +1332,73 @@ class MacRecorder extends EventEmitter {
1236
1332
  height: this.cursorDisplayInfo.videoHeight,
1237
1333
  offsetX: this.cursorDisplayInfo.videoOffsetX,
1238
1334
  offsetY: this.cursorDisplayInfo.videoOffsetY
1239
- } : null,
1335
+ } : {},
1240
1336
  displayInfo: this.cursorDisplayInfo ? {
1241
1337
  displayId: this.cursorDisplayInfo.displayId,
1242
1338
  width: this.cursorDisplayInfo.displayWidth,
1243
1339
  height: this.cursorDisplayInfo.displayHeight
1244
- } : null
1340
+ } : {}
1245
1341
  };
1246
1342
 
1343
+ // Multi-window location detection with window-relative coordinates
1344
+ if (this.cursorDisplayInfo?.multiWindowBounds && this.cursorDisplayInfo.multiWindowBounds.length > 0) {
1345
+ const location = { hover: null, click: null };
1346
+ let windowRelativeCoords = null;
1347
+
1348
+ // Detect which window the cursor is over
1349
+ for (const windowInfo of this.cursorDisplayInfo.multiWindowBounds) {
1350
+ if (windowInfo.bounds) {
1351
+ const { x: wx, y: wy, width: ww, height: wh } = windowInfo.bounds;
1352
+ // Check if cursor is inside window bounds (using global coordinates)
1353
+ if (position.x >= wx && position.x <= wx + ww &&
1354
+ position.y >= wy && position.y <= wy + wh) {
1355
+ location.hover = windowInfo.windowId;
1356
+
1357
+ // Calculate window-relative coordinates
1358
+ // These coords are relative to the window's top-left corner (0,0)
1359
+ // This allows the desktop app to position cursor correctly
1360
+ // regardless of where the window is placed on canvas
1361
+ windowRelativeCoords = {
1362
+ windowId: windowInfo.windowId,
1363
+ x: position.x - wx,
1364
+ y: position.y - wy,
1365
+ // Also include window dimensions for reference
1366
+ windowWidth: ww,
1367
+ windowHeight: wh
1368
+ };
1369
+
1370
+ // If this is a click event, mark click location
1371
+ // Native eventType values: 'mousedown', 'mouseup', 'rightmousedown', 'rightmouseup'
1372
+ const eventType = position.eventType || '';
1373
+ if (eventType === 'mousedown' ||
1374
+ eventType === 'mouseup' ||
1375
+ eventType === 'rightmousedown' ||
1376
+ eventType === 'rightmouseup') {
1377
+ location.click = windowInfo.windowId;
1378
+ }
1379
+ break; // Found the window, stop searching
1380
+ }
1381
+ }
1382
+ }
1383
+
1384
+ // Add location info to cursor data
1385
+ cursorData.location = location;
1386
+
1387
+ // Add window-relative coordinates if cursor is over a window
1388
+ if (windowRelativeCoords) {
1389
+ cursorData.windowRelative = windowRelativeCoords;
1390
+ }
1391
+ }
1392
+
1393
+ // Add sync metadata to first event only
1394
+ if (this.cursorCaptureFirstWrite && this.cursorCaptureSessionTimestamp) {
1395
+ cursorData._syncMetadata = {
1396
+ videoStartTime: this.cursorCaptureSessionTimestamp,
1397
+ cursorStartTime: this.cursorCaptureStartTime,
1398
+ offsetMs: this.cursorCaptureStartTime - this.cursorCaptureSessionTimestamp
1399
+ };
1400
+ }
1401
+
1247
1402
  // Sadece eventType değiştiğinde veya pozisyon değiştiğinde kaydet
1248
1403
  if (this.shouldCaptureEvent(cursorData)) {
1249
1404
  // Dosyaya ekle
@@ -1519,4 +1674,4 @@ class MacRecorder extends EventEmitter {
1519
1674
  // WindowSelector modülünü de export edelim
1520
1675
  MacRecorder.WindowSelector = require('./window-selector');
1521
1676
 
1522
- module.exports = MacRecorder;
1677
+ module.exports = USE_ELECTRON_SAFE ? ElectronSafeMacRecorder : MacRecorder;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-mac-recorder",
3
- "version": "2.21.40",
3
+ "version": "2.21.42",
4
4
  "description": "Native macOS screen recording package for Node.js applications",
5
5
  "main": "index.js",
6
6
  "keywords": [