UNPKG

node-mac-recorder

Version:

Native macOS screen recording package for Node.js applications

1,541 lines (1,383 loc) 61.5 kB
const { EventEmitter } = require("events"); const path = require("path"); const fs = require("fs"); // Auto-switch to Electron-safe implementation when running under Electron and binary exists let USE_ELECTRON_SAFE = false; let ElectronSafeMacRecorder = null; try { const isElectron = !!(process && process.versions && process.versions.electron); const preferElectronSafe = process.env.PREFER_ELECTRON_SAFE === "1" || process.env.USE_ELECTRON_SAFE === "1"; if (isElectron || preferElectronSafe) { const rel = path.join(__dirname, "build", "Release", "mac_recorder_electron.node"); const dbg = path.join(__dirname, "build", "Debug", "mac_recorder_electron.node"); if (fs.existsSync(rel) || fs.existsSync(dbg) || preferElectronSafe) { // Defer requiring native .node; use JS wrapper which loads it ElectronSafeMacRecorder = require("./electron-safe-index"); USE_ELECTRON_SAFE = true; console.log("✅ Auto-enabled Electron-safe MacRecorder"); } } } catch (_) { // Ignore auto-switch errors; fall back to standard binding } // Native modülü yükle let nativeBinding; if (!USE_ELECTRON_SAFE) { try { nativeBinding = require("./build/Release/mac_recorder.node"); } catch (error) { try { nativeBinding = require("./build/Debug/mac_recorder.node"); } catch (debugError) { throw new Error( 'Native module not found. Please run "npm run build" to compile the native module.\n' + "Original error: " + error.message ); } } } class MacRecorder extends EventEmitter { constructor() { super(); this.isRecording = false; this.outputPath = null; this.recordingTimer = null; this.recordingStartTime = null; // MULTI-SESSION: Unique session ID for this recorder instance this.nativeSessionId = null; // Will be generated when recording starts // Cursor capture variables this.cursorCaptureInterval = null; this.cursorCaptureFile = null; this.cursorCaptureStartTime = null; this.cursorCaptureFirstWrite = true; this.lastCapturedData = null; this.cursorDisplayInfo = null; this.recordingDisplayInfo = null; this.cameraCaptureFile = null; this.cameraCaptureActive = false; this.sessionTimestamp = null; this.syncTimestamp = null; this.audioCaptureFile = null; this.audioCaptureActive = false; this.options = { includeMicrophone: false, // Default olarak mikrofon kapalı includeSystemAudio: false, // Default olarak sistem sesi kapalı - kullanıcı explicit olarak açmalı quality: "high", frameRate: 60, captureArea: null, // { x, y, width, height } captureCursor: false, // Default olarak cursor gizli showClicks: false, displayId: null, // Hangi ekranı kaydedeceği (null = ana ekran) windowId: null, // Hangi pencereyi kaydedeceği (null = tam ekran) captureCamera: false, cameraDeviceId: null, systemAudioDeviceId: null, }; // Display cache için async initialization this.cachedDisplays = null; this.refreshDisplayCache(); // Native cursor warm-up (cold start delay'ini önlemek için) this.warmUpCursor(); } /** * macOS ses cihazlarını listeler */ async getAudioDevices() { return new Promise((resolve, reject) => { try { const devices = nativeBinding.getAudioDevices(); const formattedDevices = devices.map((device) => ({ name: device?.name || "Unknown Audio Device", id: device?.id || "", manufacturer: device?.manufacturer || null, isDefault: device?.isDefault === true, transportType: device?.transportType ?? null, })); resolve(formattedDevices); } catch (error) { reject(error); } }); } /** * macOS kamera cihazlarını listeler */ async getCameraDevices() { return new Promise((resolve, reject) => { try { const devices = nativeBinding.getCameraDevices(); if (!Array.isArray(devices)) { return resolve([]); } const formatted = devices.map((device) => ({ id: device?.id ?? "", name: device?.name ?? "Unknown Camera", model: device?.model ?? null, manufacturer: device?.manufacturer ?? null, position: device?.position ?? "unspecified", transportType: device?.transportType ?? null, isConnected: device?.isConnected ?? false, isDefault: device?.isDefault === true, hasFlash: device?.hasFlash ?? false, supportsDepth: device?.supportsDepth ?? false, deviceType: device?.deviceType ?? null, requiresContinuityCameraPermission: device?.requiresContinuityCameraPermission ?? false, maxResolution: device?.maxResolution ?? null, })); resolve(formatted); } catch (error) { reject(error); } }); } /** * macOS ekranlarını listeler */ async getDisplays() { const displays = nativeBinding.getDisplays(); return displays.map((display, index) => ({ id: display.id, // Use the actual display ID from native code name: display.name, width: display.width, height: display.height, x: display.x, y: display.y, isPrimary: display.isPrimary, resolution: `${display.width}x${display.height}`, })); } /** * macOS açık pencerelerini listeler */ async getWindows() { return new Promise((resolve, reject) => { try { const windows = nativeBinding.getWindows(); resolve(windows); } catch (error) { reject(error); } }); } /** * Kayıt seçeneklerini ayarlar */ setOptions(options = {}) { // Merge options instead of replacing to preserve previously set values if (options.sessionTimestamp !== undefined) { this.options.sessionTimestamp = options.sessionTimestamp; } if (options.includeMicrophone !== undefined) { this.options.includeMicrophone = options.includeMicrophone === true; } if (options.includeSystemAudio !== undefined) { this.options.includeSystemAudio = options.includeSystemAudio === true; } if (options.captureCursor !== undefined) { this.options.captureCursor = options.captureCursor || false; } if (options.displayId !== undefined) { this.options.displayId = options.displayId || null; } if (options.windowId !== undefined) { this.options.windowId = options.windowId || null; } if (options.audioDeviceId !== undefined) { this.options.audioDeviceId = options.audioDeviceId || null; } if (options.systemAudioDeviceId !== undefined) { this.options.systemAudioDeviceId = options.systemAudioDeviceId || null; } if (options.captureArea !== undefined) { this.options.captureArea = options.captureArea || null; } if (options.captureCamera !== undefined) { this.options.captureCamera = options.captureCamera === true; } if (options.frameRate !== undefined) { const fps = parseInt(options.frameRate, 10); if (!Number.isNaN(fps) && fps > 0) { // Clamp reasonable range 1-120 this.options.frameRate = Math.min(Math.max(fps, 1), 120); } } // Prefer ScreenCaptureKit (macOS 15+) toggle if (options.preferScreenCaptureKit !== undefined) { this.options.preferScreenCaptureKit = options.preferScreenCaptureKit === true; } if (options.cameraDeviceId !== undefined) { this.options.cameraDeviceId = typeof options.cameraDeviceId === "string" && options.cameraDeviceId.length > 0 ? options.cameraDeviceId : null; } } /** * Mikrofon kaydını açar/kapatır */ setMicrophoneEnabled(enabled) { this.options.includeMicrophone = enabled === true; return this.options.includeMicrophone; } setAudioDevice(deviceId) { if (typeof deviceId === "string" && deviceId.length > 0) { this.options.audioDeviceId = deviceId; } else { this.options.audioDeviceId = null; } return this.options.audioDeviceId; } /** * Sistem sesi kaydını açar/kapatır */ setSystemAudioEnabled(enabled) { this.options.includeSystemAudio = enabled === true; return this.options.includeSystemAudio; } setSystemAudioDevice(deviceId) { if (typeof deviceId === "string" && deviceId.length > 0) { this.options.systemAudioDeviceId = deviceId; } else { this.options.systemAudioDeviceId = null; } return this.options.systemAudioDeviceId; } /** * Kamera kaydını açar/kapatır */ setCameraEnabled(enabled) { this.options.captureCamera = enabled === true; if (!this.options.captureCamera) { this.cameraCaptureActive = false; } return this.options.captureCamera; } /** * Kamera cihazını seçer */ setCameraDevice(deviceId) { if (typeof deviceId === "string" && deviceId.length > 0) { this.options.cameraDeviceId = deviceId; } else { this.options.cameraDeviceId = null; } return this.options.cameraDeviceId; } /** * Mikrofon durumunu döndürür */ isMicrophoneEnabled() { return this.options.includeMicrophone === true; } /** * Sistem sesi durumunu döndürür */ isSystemAudioEnabled() { return this.options.includeSystemAudio === true; } /** * Kamera durumunu döndürür */ isCameraEnabled() { return this.options.captureCamera === true; } /** * Audio ayarlarını toplu olarak değiştirir */ setAudioSettings(settings = {}) { if (typeof settings.microphone === 'boolean') { this.setMicrophoneEnabled(settings.microphone); } if (typeof settings.systemAudio === 'boolean') { this.setSystemAudioEnabled(settings.systemAudio); } return { microphone: this.isMicrophoneEnabled(), systemAudio: this.isSystemAudioEnabled() }; } /** * Ekran kaydını başlatır (macOS native AVFoundation kullanarak) */ async startRecording(outputPath, options = {}) { if (this.isRecording) { throw new Error("Recording is already in progress"); } if (!outputPath) { throw new Error("Output path is required"); } // Seçenekleri güncelle this.setOptions(options); // Cache display list so we don't fetch multiple times during preparation let cachedDisplays = null; const getCachedDisplays = async () => { if (cachedDisplays) { return cachedDisplays; } try { cachedDisplays = await this.getDisplays(); } catch (error) { console.warn("Display bilgisi alınamadı:", error.message); cachedDisplays = []; } return cachedDisplays; }; /** * Normalize capture area coordinates: * - Pick correct display based on user-provided area/displayId info * - Convert global coordinates to display-relative when needed * - Clamp to valid bounds to avoid ScreenCaptureKit skipping crops */ const normalizeCaptureArea = async () => { if (!this.options.captureArea) { return; } const displays = await getCachedDisplays(); if (!Array.isArray(displays) || displays.length === 0) { return; } const rawArea = this.options.captureArea; const parsedArea = { x: Number(rawArea.x), y: Number(rawArea.y), width: Number(rawArea.width), height: Number(rawArea.height), }; if ( !Number.isFinite(parsedArea.x) || !Number.isFinite(parsedArea.y) || !Number.isFinite(parsedArea.width) || !Number.isFinite(parsedArea.height) || parsedArea.width <= 0 || parsedArea.height <= 0 ) { return; } const areaRect = { left: parsedArea.x, top: parsedArea.y, right: parsedArea.x + parsedArea.width, bottom: parsedArea.y + parsedArea.height, }; const getDisplayRect = (display) => { const dx = Number(display.x) || 0; const dy = Number(display.y) || 0; const dw = Number(display.width) || 0; const dh = Number(display.height) || 0; return { left: dx, top: dy, right: dx + dw, bottom: dy + dh, width: dw, height: dh, }; }; const requestedDisplayId = this.options.displayId === null || this.options.displayId === undefined ? null : Number(this.options.displayId); let targetDisplay = null; if (requestedDisplayId !== null && Number.isFinite(requestedDisplayId)) { targetDisplay = displays.find( (display) => Number(display.id) === requestedDisplayId ) || null; } if (!targetDisplay) { targetDisplay = displays.find((display) => { const rect = getDisplayRect(display); return ( areaRect.left >= rect.left && areaRect.right <= rect.right && areaRect.top >= rect.top && areaRect.bottom <= rect.bottom ); }) || null; } if (!targetDisplay) { let bestDisplay = null; let bestOverlap = 0; displays.forEach((display) => { const rect = getDisplayRect(display); const overlapWidth = Math.min(areaRect.right, rect.right) - Math.max(areaRect.left, rect.left); const overlapHeight = Math.min(areaRect.bottom, rect.bottom) - Math.max(areaRect.top, rect.top); if (overlapWidth > 0 && overlapHeight > 0) { const overlapArea = overlapWidth * overlapHeight; if (overlapArea > bestOverlap) { bestOverlap = overlapArea; bestDisplay = display; } } }); targetDisplay = bestDisplay; } if (!targetDisplay) { targetDisplay = displays.find((display) => display.isPrimary) || displays[0]; } if (!targetDisplay) { return; } const targetRect = getDisplayRect(targetDisplay); if (targetRect.width <= 0 || targetRect.height <= 0) { return; } const tolerance = 1; // allow sub-pixel offsets const isRelativeToDisplay = () => { const endX = parsedArea.x + parsedArea.width; const endY = parsedArea.y + parsedArea.height; return ( parsedArea.x >= -tolerance && parsedArea.y >= -tolerance && endX <= targetRect.width + tolerance && endY <= targetRect.height + tolerance ); }; let relativeX = parsedArea.x; let relativeY = parsedArea.y; if (!isRelativeToDisplay()) { relativeX = parsedArea.x - targetRect.left; relativeY = parsedArea.y - targetRect.top; } let relativeWidth = parsedArea.width; let relativeHeight = parsedArea.height; // Discard if area sits completely outside the display if ( relativeX >= targetRect.width || relativeY >= targetRect.height || relativeWidth <= 0 || relativeHeight <= 0 ) { return; } if (relativeX < 0) { relativeWidth += relativeX; relativeX = 0; } if (relativeY < 0) { relativeHeight += relativeY; relativeY = 0; } const maxWidth = targetRect.width - relativeX; const maxHeight = targetRect.height - relativeY; if (maxWidth <= 0 || maxHeight <= 0) { return; } relativeWidth = Math.min(relativeWidth, maxWidth); relativeHeight = Math.min(relativeHeight, maxHeight); if (relativeWidth <= 0 || relativeHeight <= 0) { return; } const normalizeValue = (value, minValue) => Math.max(minValue, Math.round(value)); const normalizedArea = { x: Math.max(0, Math.round(relativeX)), y: Math.max(0, Math.round(relativeY)), width: normalizeValue(relativeWidth, 1), height: normalizeValue(relativeHeight, 1), }; const originalRounded = { x: Math.round(parsedArea.x), y: Math.round(parsedArea.y), width: normalizeValue(parsedArea.width, 1), height: normalizeValue(parsedArea.height, 1), }; const displayChanged = !Number.isFinite(requestedDisplayId) || Number(targetDisplay.id) !== requestedDisplayId; const areaChanged = normalizedArea.x !== originalRounded.x || normalizedArea.y !== originalRounded.y || normalizedArea.width !== originalRounded.width || normalizedArea.height !== originalRounded.height; if (displayChanged || areaChanged) { console.log( `🎯 Capture area normalize: display=${targetDisplay.id} -> (${rawArea.x},${rawArea.y},${rawArea.width}x${rawArea.height}) ➜ (${normalizedArea.x},${normalizedArea.y},${normalizedArea.width}x${normalizedArea.height})` ); } this.options.captureArea = normalizedArea; this.options.displayId = Number(targetDisplay.id); this.recordingDisplayInfo = { displayId: Number(targetDisplay.id), x: Number(targetDisplay.x) || 0, y: Number(targetDisplay.y) || 0, width: Number(targetDisplay.width) || 0, height: Number(targetDisplay.height) || 0, logicalWidth: Number(targetDisplay.width) || 0, logicalHeight: Number(targetDisplay.height) || 0, }; }; // WindowId varsa captureArea'yı otomatik ayarla if (this.options.windowId && !this.options.captureArea) { try { const windows = await this.getWindows(); const displays = await getCachedDisplays(); const targetWindow = windows.find( (w) => w.id === this.options.windowId ); if (targetWindow) { // Pencere hangi display'de olduğunu bul let targetDisplayId = null; let adjustedX = targetWindow.x; let adjustedY = targetWindow.y; // Pencere hangi display'de? for (let i = 0; i < displays.length; i++) { const display = displays[i]; const displayWidth = parseInt(display.resolution.split("x")[0]); const displayHeight = parseInt(display.resolution.split("x")[1]); // Pencere bu display sınırları içinde mi? if ( targetWindow.x >= display.x && targetWindow.x < display.x + displayWidth && targetWindow.y >= display.y && targetWindow.y < display.y + displayHeight ) { targetDisplayId = display.id; // Use actual display ID, not array index // CRITICAL FIX: Convert global coordinates to display-relative coordinates // AVFoundation expects simple display-relative top-left coordinates (no flipping) adjustedX = targetWindow.x - display.x; adjustedY = targetWindow.y - display.y; // console.log(`🔧 macOS 14/13 coordinate fix: Global (${targetWindow.x},${targetWindow.y}) -> Display-relative (${adjustedX},${adjustedY})`); break; } } // Eğer display bulunamadıysa ana display kullan if (targetDisplayId === null) { const mainDisplay = displays.find((d) => d.x === 0 && d.y === 0); if (mainDisplay) { targetDisplayId = mainDisplay.id; // Use actual display ID, not array index adjustedX = Math.max( 0, Math.min( targetWindow.x, parseInt(mainDisplay.resolution.split("x")[0]) - targetWindow.width ) ); adjustedY = Math.max( 0, Math.min( targetWindow.y, parseInt(mainDisplay.resolution.split("x")[1]) - targetWindow.height ) ); } } // DisplayId'yi ayarla if (targetDisplayId !== null) { this.options.displayId = targetDisplayId; // Recording için display bilgisini sakla (cursor capture için) const targetDisplay = displays.find(d => d.id === targetDisplayId); this.recordingDisplayInfo = { displayId: targetDisplayId, x: targetDisplay.x, y: targetDisplay.y, width: parseInt(targetDisplay.resolution.split("x")[0]), height: parseInt(targetDisplay.resolution.split("x")[1]), // Add scaling information for cursor coordinate transformation logicalWidth: parseInt(targetDisplay.resolution.split("x")[0]), logicalHeight: parseInt(targetDisplay.resolution.split("x")[1]), }; } this.options.captureArea = { x: Math.max(0, adjustedX), y: Math.max(0, adjustedY), width: targetWindow.width, height: targetWindow.height, }; // console.log( // `Window ${targetWindow.appName}: display=${targetDisplayId}, coords=${targetWindow.x},${targetWindow.y} -> ${adjustedX},${adjustedY}` // ); } } catch (error) { console.warn( "Pencere bilgisi alınamadı, tam ekran kaydedilecek:", error.message ); } } // Ensure recordingDisplayInfo is always set for cursor tracking if (!this.recordingDisplayInfo) { try { const displays = await getCachedDisplays(); let targetDisplay; if (this.options.displayId !== null) { // Manual displayId specified targetDisplay = displays.find(d => d.id === this.options.displayId); } else { // Default to main display targetDisplay = displays.find(d => d.isPrimary) || displays[0]; } if (targetDisplay) { this.recordingDisplayInfo = { displayId: targetDisplay.id, x: targetDisplay.x || 0, y: targetDisplay.y || 0, width: parseInt(targetDisplay.resolution.split("x")[0]), height: parseInt(targetDisplay.resolution.split("x")[1]), // Add scaling information for cursor coordinate transformation logicalWidth: parseInt(targetDisplay.resolution.split("x")[0]), logicalHeight: parseInt(targetDisplay.resolution.split("x")[1]), }; } } catch (error) { console.warn("Display bilgisi alınamadı:", error.message); } } // Normalize capture area AFTER automatic window capture logic if (this.options.captureArea) { await normalizeCaptureArea(); } // Çıkış dizinini oluştur const outputDir = path.dirname(outputPath); if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }); } this.outputPath = outputPath; return new Promise(async (resolve, reject) => { try { // MULTI-SESSION: Generate unique session ID for this recording // Use provided sessionTimestamp from options, or generate new one const sessionTimestamp = this.options.sessionTimestamp || Date.now(); this.sessionTimestamp = sessionTimestamp; this.nativeSessionId = `rec_${sessionTimestamp}_${Math.random().toString(36).substr(2, 9)}`; console.log(`🎬 Starting recording with session ID: ${this.nativeSessionId}`); if (this.options.sessionTimestamp) { console.log(` ⏰ Using provided sessionTimestamp: ${this.options.sessionTimestamp}`); } else { console.log(` ⏰ Generated new sessionTimestamp: ${sessionTimestamp}`); } // CRITICAL FIX: Ensure main video file also uses sessionTimestamp // This guarantees ALL files have the exact same timestamp const outputDir = path.dirname(outputPath); const originalBaseName = path.basename(outputPath, path.extname(outputPath)); const extension = path.extname(outputPath); // Remove any existing timestamp from filename (pattern: -1234567890 or _1234567890) const cleanBaseName = originalBaseName.replace(/[-_]\d{13}$/, ''); // Reconstruct path with sessionTimestamp outputPath = path.join(outputDir, `${cleanBaseName}-${sessionTimestamp}${extension}`); this.outputPath = outputPath; const cursorFilePath = path.join(outputDir, `temp_cursor_${sessionTimestamp}.json`); // CRITICAL FIX: Use .mov extension for camera (native recorder uses .mov, not .webm) let cameraFilePath = this.options.captureCamera === true ? path.join(outputDir, `temp_camera_${sessionTimestamp}.mov`) : null; const captureAudio = this.options.includeMicrophone === true || this.options.includeSystemAudio === true; // CRITICAL FIX: Use .mov extension for audio (consistent with native recorder) let audioFilePath = captureAudio ? path.join(outputDir, `temp_audio_${sessionTimestamp}.mov`) : null; if (this.options.captureCamera === true) { this.cameraCaptureFile = cameraFilePath; this.cameraCaptureActive = false; } else { this.cameraCaptureFile = null; this.cameraCaptureActive = false; } if (captureAudio) { this.audioCaptureFile = audioFilePath; this.audioCaptureActive = false; } else { this.audioCaptureFile = null; this.audioCaptureActive = false; } // Native kayıt başlat let recordingOptions = { includeMicrophone: this.options.includeMicrophone === true, // Only if explicitly enabled includeSystemAudio: this.options.includeSystemAudio === true, // Only if explicitly enabled captureCursor: this.options.captureCursor || false, displayId: this.options.displayId || null, // null = ana ekran windowId: this.options.windowId || null, // null = tam ekran audioDeviceId: this.options.audioDeviceId || null, // null = default device systemAudioDeviceId: this.options.systemAudioDeviceId || null, // null = auto-detect system audio device captureCamera: this.options.captureCamera === true, cameraDeviceId: this.options.cameraDeviceId || null, sessionTimestamp, // MULTI-SESSION: Pass unique session ID to native code nativeSessionId: this.nativeSessionId, frameRate: this.options.frameRate || 60, quality: this.options.quality || "high", // Hint native side to use ScreenCaptureKit on macOS 15+ preferScreenCaptureKit: this.options.preferScreenCaptureKit === true, }; if (cameraFilePath) { recordingOptions = { ...recordingOptions, cameraOutputPath: cameraFilePath, }; } if (audioFilePath) { recordingOptions = { ...recordingOptions, audioOutputPath: audioFilePath, }; } // Manuel captureArea varsa onu kullan if (this.options.captureArea) { recordingOptions.captureArea = { x: this.options.captureArea.x, y: this.options.captureArea.y, width: this.options.captureArea.width, height: this.options.captureArea.height, }; } // CRITICAL SYNC FIX: Start native recording FIRST (video/audio/camera) // Then IMMEDIATELY start cursor tracking with the SAME timestamp // This ensures ALL components capture their first frame at the same time let success; try { console.log('🎯 SYNC: Starting native recording (screen/audio/camera) at timestamp:', sessionTimestamp); success = nativeBinding.startRecording( outputPath, recordingOptions ); if (success) { console.log('✅ SYNC: Native recording started successfully'); } } catch (error) { success = false; console.warn('❌ Native recording failed to start:', error.message); } // Only start cursor if native recording started successfully if (success) { // For ScreenCaptureKit (async startup), wait briefly until native fully initialized // ScreenCaptureKit needs ~150-300ms to start + ~150ms for first 10 frames const waitStart = Date.now(); try { while (Date.now() - waitStart < 600) { try { const nativeStatus = nativeBinding && nativeBinding.getRecordingStatus ? nativeBinding.getRecordingStatus() : true; if (nativeStatus) { console.log(`✅ SYNC: Native recording fully ready after ${Date.now() - waitStart}ms`); break; } } catch (_) {} await new Promise(r => setTimeout(r, 30)); } } catch (_) {} this.sessionTimestamp = sessionTimestamp; // CURSOR SYNC FIX: Wait additional 300ms for first frames to start // This ensures cursor tracking aligns with actual video timeline // ScreenCaptureKit needs ~200-350ms to actually start capturing frames // We wait 300ms to ensure cursor starts AFTER first video frame console.log('⏳ CURSOR SYNC: Waiting 300ms for first video frames...'); await new Promise(r => setTimeout(r, 300)); const syncTimestamp = Date.now(); this.syncTimestamp = syncTimestamp; this.recordingStartTime = syncTimestamp; console.log(`🎯 CURSOR SYNC: Cursor tracking will use timestamp: ${syncTimestamp}`); const standardCursorOptions = { videoRelative: true, displayInfo: this.recordingDisplayInfo, recordingType: this.options.windowId ? 'window' : this.options.captureArea ? 'area' : 'display', captureArea: this.options.captureArea, windowId: this.options.windowId, startTimestamp: syncTimestamp // Align cursor timeline to actual start }; try { console.log('🎯 SYNC: Starting cursor tracking at timestamp:', syncTimestamp); await this.startCursorCapture(cursorFilePath, standardCursorOptions); console.log('✅ SYNC: Cursor tracking started successfully'); } catch (cursorError) { console.warn('⚠️ Cursor tracking failed to start:', cursorError.message); // Continue with recording even if cursor fails - don't stop native recording } } if (success) { const timelineTimestamp = this.syncTimestamp || sessionTimestamp; const fileTimestamp = this.sessionTimestamp || sessionTimestamp; if (this.options.captureCamera === true) { try { const nativeCameraPath = nativeBinding.getCameraRecordingPath ? nativeBinding.getCameraRecordingPath() : null; if (typeof nativeCameraPath === "string" && nativeCameraPath.length > 0) { this.cameraCaptureFile = nativeCameraPath; cameraFilePath = nativeCameraPath; } } catch (pathError) { console.warn("Camera output path sync failed:", pathError.message); } } if (captureAudio) { try { const nativeAudioPath = nativeBinding.getAudioRecordingPath ? nativeBinding.getAudioRecordingPath() : null; if (typeof nativeAudioPath === "string" && nativeAudioPath.length > 0) { this.audioCaptureFile = nativeAudioPath; audioFilePath = nativeAudioPath; } } catch (pathError) { console.warn("Audio output path sync failed:", pathError.message); } } this.isRecording = true; if (this.options.captureCamera === true && cameraFilePath) { this.cameraCaptureActive = true; console.log('📹 SYNC: Camera recording started at timestamp:', timelineTimestamp); this.emit("cameraCaptureStarted", { outputPath: cameraFilePath, deviceId: this.options.cameraDeviceId || null, timestamp: timelineTimestamp, sessionTimestamp: fileTimestamp, syncTimestamp: timelineTimestamp, fileTimestamp, }); } if (captureAudio && audioFilePath) { this.audioCaptureActive = true; console.log('🎙️ SYNC: Audio recording started at timestamp:', timelineTimestamp); this.emit("audioCaptureStarted", { outputPath: audioFilePath, deviceIds: { microphone: this.options.audioDeviceId || null, system: this.options.systemAudioDeviceId || null, }, timestamp: timelineTimestamp, sessionTimestamp: fileTimestamp, syncTimestamp: timelineTimestamp, fileTimestamp, }); } // SYNC FIX: Cursor tracking already started BEFORE recording for perfect sync // (Removed duplicate cursor start code) // Log synchronized recording summary const activeComponents = []; activeComponents.push('Screen'); if (this.cursorCaptureInterval) activeComponents.push('Cursor'); if (this.cameraCaptureActive) activeComponents.push('Camera'); if (this.audioCaptureActive) activeComponents.push('Audio'); console.log(`✅ SYNC COMPLETE: All components synchronized at timestamp ${timelineTimestamp}`); console.log(` Active components: ${activeComponents.join(', ')}`); // Timer başlat (progress tracking için) this.recordingTimer = setInterval(() => { const elapsed = Math.floor( (Date.now() - this.recordingStartTime) / 1000 ); this.emit("timeUpdate", elapsed); }, 1000); // Native kayıt gerçekten başladığını kontrol etmek için polling başlat let recordingStartedEmitted = false; const checkRecordingStatus = setInterval(() => { try { const nativeStatus = nativeBinding.getRecordingStatus(); if (nativeStatus && !recordingStartedEmitted) { recordingStartedEmitted = true; clearInterval(checkRecordingStatus); // Kayıt gerçekten başladığı anda event emit et const startTimestampPayload = this.syncTimestamp || this.recordingStartTime || Date.now(); const fileTimestampPayload = this.sessionTimestamp; this.emit("recordingStarted", { outputPath: this.outputPath, timestamp: startTimestampPayload, options: this.options, nativeConfirmed: true, cameraOutputPath: this.cameraCaptureFile || null, audioOutputPath: this.audioCaptureFile || null, cursorOutputPath: cursorFilePath, sessionTimestamp: fileTimestampPayload, syncTimestamp: startTimestampPayload, fileTimestamp: fileTimestampPayload, }); } } catch (error) { // Native status check error - fallback if (!recordingStartedEmitted) { recordingStartedEmitted = true; clearInterval(checkRecordingStatus); const startTimestampPayload = this.syncTimestamp || this.recordingStartTime || Date.now(); const fileTimestampPayload = this.sessionTimestamp; this.emit("recordingStarted", { outputPath: this.outputPath, timestamp: startTimestampPayload, options: this.options, nativeConfirmed: false, cameraOutputPath: this.cameraCaptureFile || null, audioOutputPath: this.audioCaptureFile || null, cursorOutputPath: cursorFilePath, sessionTimestamp: fileTimestampPayload, syncTimestamp: startTimestampPayload, fileTimestamp: fileTimestampPayload, }); } } }, 50); // Her 50ms kontrol et // Timeout fallback - 5 saniye sonra hala başlamamışsa emit et setTimeout(() => { if (!recordingStartedEmitted) { recordingStartedEmitted = true; clearInterval(checkRecordingStatus); const startTimestampPayload = this.syncTimestamp || this.recordingStartTime || Date.now(); const fileTimestampPayload = this.sessionTimestamp; this.emit("recordingStarted", { outputPath: this.outputPath, timestamp: startTimestampPayload, options: this.options, nativeConfirmed: false, cameraOutputPath: this.cameraCaptureFile || null, audioOutputPath: this.audioCaptureFile || null, cursorOutputPath: cursorFilePath, sessionTimestamp: fileTimestampPayload, syncTimestamp: startTimestampPayload, fileTimestamp: fileTimestampPayload, }); } }, 5000); this.emit("started", this.outputPath); resolve(this.outputPath); } else { this.cameraCaptureActive = false; if (this.options.captureCamera === true) { if (cameraFilePath && fs.existsSync(cameraFilePath)) { try { fs.unlinkSync(cameraFilePath); } catch (cleanupError) { console.warn("Camera temp file cleanup failed:", cleanupError.message); } } this.cameraCaptureFile = null; } if (captureAudio) { this.audioCaptureActive = false; if (audioFilePath && fs.existsSync(audioFilePath)) { try { fs.unlinkSync(audioFilePath); } catch (cleanupError) { console.warn("Audio temp file cleanup failed:", cleanupError.message); } } this.audioCaptureFile = null; } this.sessionTimestamp = null; this.syncTimestamp = null; reject( new Error( "Recording failed to start. Check permissions, output path, and system compatibility." ) ); } } catch (error) { this.sessionTimestamp = null; this.syncTimestamp = null; reject(error); } }); } /** * Ekran kaydını durdurur - SYNCHRONIZED stop for all components */ async stopRecording() { if (!this.isRecording) { throw new Error("No recording in progress"); } return new Promise(async (resolve, reject) => { const stopRequestedAt = Date.now(); const elapsedSeconds = this.recordingStartTime && this.recordingStartTime > 0 ? (stopRequestedAt - this.recordingStartTime) / 1000 : -1; try { console.log('🛑 SYNC: Stopping all recording components simultaneously'); // SYNC FIX: Stop ALL components at the same time for perfect sync // 1. Stop cursor tracking FIRST (it's instant) if (this.cursorCaptureInterval) { try { console.log('🛑 SYNC: Stopping cursor tracking'); await this.stopCursorCapture(); console.log('✅ SYNC: Cursor tracking stopped'); } catch (cursorError) { console.warn('⚠️ Cursor tracking failed to stop:', cursorError.message); } } let success = false; // 2. Stop native screen recording try { console.log('🛑 SYNC: Stopping screen recording'); const stopLimit = elapsedSeconds > 0 ? elapsedSeconds : 0; console.log(`📊 DEBUG: elapsedSeconds=${elapsedSeconds.toFixed(3)}, stopLimit=${stopLimit.toFixed(3)}`); console.log(`📊 DEBUG: typeof nativeBinding.stopRecording = ${typeof nativeBinding.stopRecording}`); console.log(`📊 DEBUG: nativeBinding.stopRecording = ${nativeBinding.stopRecording}`); success = nativeBinding.stopRecording(stopLimit); if (success) { console.log('✅ SYNC: Screen recording stopped'); } } catch (nativeError) { console.log('⚠️ Native stop failed:', nativeError.message); success = true; // Assume success to avoid throwing } if (this.options.captureCamera === true) { try { const nativeCameraPath = nativeBinding.getCameraRecordingPath ? nativeBinding.getCameraRecordingPath() : null; if (typeof nativeCameraPath === "string" && nativeCameraPath.length > 0) { this.cameraCaptureFile = nativeCameraPath; } } catch (pathError) { console.warn("Camera output path sync failed:", pathError.message); } } const captureAudio = this.options.includeMicrophone === true || this.options.includeSystemAudio === true; if (captureAudio) { try { const nativeAudioPath = nativeBinding.getAudioRecordingPath ? nativeBinding.getAudioRecordingPath() : null; if (typeof nativeAudioPath === "string" && nativeAudioPath.length > 0) { this.audioCaptureFile = nativeAudioPath; } } catch (pathError) { console.warn("Audio output path sync failed:", pathError.message); } } if (this.cameraCaptureActive) { this.cameraCaptureActive = false; console.log('📹 SYNC: Camera recording stopped'); this.emit("cameraCaptureStopped", { outputPath: this.cameraCaptureFile || null, success: success === true, sessionTimestamp: this.sessionTimestamp, syncTimestamp: this.syncTimestamp, }); } if (this.audioCaptureActive) { this.audioCaptureActive = false; console.log('🎙️ SYNC: Audio recording stopped'); this.emit("audioCaptureStopped", { outputPath: this.audioCaptureFile || null, success: success === true, sessionTimestamp: this.sessionTimestamp, syncTimestamp: this.syncTimestamp, }); } // SYNC FIX: Cursor tracking already stopped at the beginning for sync // (Removed duplicate cursor stop code) // Log synchronized stop summary console.log('✅ SYNC STOP COMPLETE: All recording components stopped simultaneously'); // Timer durdur if (this.recordingTimer) { clearInterval(this.recordingTimer); this.recordingTimer = null; } this.isRecording = false; this.recordingDisplayInfo = null; const sessionId = this.sessionTimestamp; const result = { code: success ? 0 : 1, outputPath: this.outputPath, cameraOutputPath: this.cameraCaptureFile || null, audioOutputPath: this.audioCaptureFile || null, sessionTimestamp: sessionId, syncTimestamp: this.syncTimestamp, }; this.emit("stopped", result); if (success) { // Dosyanın oluşturulmasını bekle setTimeout(() => { if (fs.existsSync(this.outputPath)) { this.emit("completed", this.outputPath); } }, 1000); } this.sessionTimestamp = null; this.syncTimestamp = null; resolve(result); } catch (error) { this.isRecording = false; this.recordingDisplayInfo = null; this.cameraCaptureActive = false; this.audioCaptureActive = false; this.audioCaptureFile = null; this.sessionTimestamp = null; this.syncTimestamp = null; if (this.recordingTimer) { clearInterval(this.recordingTimer); this.recordingTimer = null; } reject(error); } }); } /** * Kayıt durumunu döndürür */ getStatus() { const nativeStatus = nativeBinding.getRecordingStatus(); return { isRecording: this.isRecording && nativeStatus, outputPath: this.outputPath, cameraOutputPath: this.cameraCaptureFile || null, audioOutputPath: this.audioCaptureFile || null, cameraCapturing: this.cameraCaptureActive, audioCapturing: this.audioCaptureActive, sessionTimestamp: this.sessionTimestamp, syncTimestamp: this.syncTimestamp, options: this.options, recordingTime: this.recordingStartTime ? Math.floor((Date.now() - this.recordingStartTime) / 1000) : 0, }; } /** * macOS'ta kayıt izinlerini kontrol eder */ async checkPermissions() { return new Promise((resolve) => { try { const hasPermission = nativeBinding.checkPermissions(); resolve({ screenRecording: hasPermission, accessibility: hasPermission, microphone: hasPermission, // Native modül ses izinlerini de kontrol ediyor }); } catch (error) { resolve({ screenRecording: false, accessibility: false, microphone: false, error: error.message, }); } }); } /** * Pencere önizleme görüntüsü alır (Base64 PNG) */ async getWindowThumbnail(windowId, options = {}) { if (!windowId) { throw new Error("Window ID is required"); } const { maxWidth = 300, maxHeight = 200 } = options; return new Promise((resolve, reject) => { try { const base64Image = nativeBinding.getWindowThumbnail( windowId, maxWidth, maxHeight ); if (base64Image) { resolve(`data:image/png;base64,${base64Image}`); } else { reject(new Error("Failed to capture window thumbnail")); } } catch (error) { reject(error); } }); } /** * Ekran önizleme görüntüsü alır (Base64 PNG) */ async getDisplayThumbnail(displayId, options = {}) { if (displayId === null || displayId === undefined) { throw new Error("Display ID is required"); } const { maxWidth = 300, maxHeight = 200 } = options; return new Promise((resolve, reject) => { try { // Get all displays first to validate the ID const displays = nativeBinding.getDisplays(); const display = displays.find((d) => d.id === displayId); if (!display) { throw new Error(`Display with ID ${displayId} not found`); } const base64Image = nativeBinding.getDisplayThumbnail( display.id, // Use the actual CGDirectDisplayID maxWidth, maxHeight ); if (base64Image) { resolve(`data:image/png;base64,${base64Image}`); } else { reject(new Error("Failed to capture display thumbnail")); } } catch (error) { reject(error); } }); } /** * Event'in kaydedilip kaydedilmeyeceğini belirler */ shouldCaptureEvent(currentData) { if (!this.lastCapturedData) { return true; // İlk event } const last = this.lastCapturedData; // Event type değişmişse if (currentData.type !== last.type) { return true; } // Pozisyon değişmişse (minimum 2 pixel tolerans) if ( Math.abs(currentData.x - last.x) >= 2 || Math.abs(currentData.y - last.y) >= 2 ) { return true; } // Cursor type değişmişse if (currentData.cursorType !== last.cursorType) { return true; } // Hiçbir değişiklik yoksa kaydetme return false; } /** * Unified cursor capture for all recording types - uses video-relative coordinates * @param {string|number} intervalOrFilepath - Cursor data JSON dosya yolu veya interval * @param {Object} options - Cursor capture seçenekleri * @param {boolean} options.videoRelative - Use video-relative coordinates (recommended) * @param {Object} options.displayInfo - Display information for coordinate transformation * @param {string} options.recordingType - Type of recording: 'display', 'window', 'area' * @param {Object} options.captureArea - Capture area for area recording coordinate transformation * @param {number} options.windowId - Window ID for window recording coordinate transformation * @param {number} options.startTimestamp - Pre-defined start timestamp for synchronization (optional) */ async startCursorCapture(intervalOrFilepath = 100, options = {}) { let filepath; let interval = 20; // Default 50 FPS // Parameter parsing: number = interval, string = filepath if (typeof intervalOrFilepath === "number") { interval = Math.max(10, intervalOrFilepath); // Min 10ms filepath = `cursor-data-${Date.now()}.json`; } else if (typeof intervalOrFilepath === "string") { filepath = intervalOrFilepath; } else { throw new Error( "Parameter must be interval (number) or filepath (string)" ); } if (this.cursorCaptureInterval) { throw new Error("Cursor capture is already running"); } // SYNC FIX: Use pre-defined timestamp if provided for synchronization const syncStartTime = options.startTimestamp || Date.now(); // Fetch window bounds for multi-window recording if (options.multiWindowBounds && options.multiWindowBounds.length > 0) { try { const allWindows = await this.getWindows(); // Match window IDs and populate bounds for (const windowInfo of options.multiWindowBounds) { const windowData = allWindows.find(w => w.id === windowInfo.windowId); if (windowData) { windowInfo.bounds = { x: windowData.x || 0, y: windowData.y || 0, width: windowData.width || 0, height: windowData.height || 0 }; } } } catch (error) { console.warn('Failed to fetch window bounds for multi-window cursor tracking:', error.message); } } // Use video-relative coordinate system for all recording types if (options.videoRelative && options.displayInfo) { // Calculate video offset based on recording type let videoOffsetX = 0; let videoOffsetY = 0; let videoWidth = options.displayInfo.width || options.displayInfo.logicalWidth; let videoHeight = options.displayInfo.height || options.displayInfo.logicalHeight; if (options.recordingType === 'window' && options.windowId) { // For window recording: offset = window position in display if (options.captureArea) { videoOffsetX = options.captureArea.x; videoOffsetY = options.captureArea.y; videoWidth = options.captureArea.width; videoHeight = options.captureArea.height; } } else if (options.recordingType === 'area' && options.captureArea) { // For area recording: offset = area position in display videoOffsetX = options.captureArea.x; videoOffsetY = options.captureArea.y; videoWidth = options.captureArea.width; videoHeight = options.captureArea.height; } // For display recording: offset remains 0,0 this.cursorDisplayInfo = { displayId: options.displayInfo.displayId || options.displayInfo.id, displayX: options.displayInfo.x || 0, displayY: options.displayInfo.y || 0, displayWidth: options.displayInfo.width || options.displayInfo.logicalWidth, displayHeight: options.displayInfo.height || options.displayInfo.logicalHeight, videoOffsetX: videoOffsetX, videoOffsetY: videoOffsetY, videoWidth: videoWidth, videoHeight: videoHeight, videoRelative: true, recordingType: options.recordingType || 'display', // Store additional context for debugging captureArea: options.captureArea, windowId: options.windowId, // Multi-window bounds for location detection multiWindowBounds: options.multiWindowBounds || null }; } else if (this.recordingDisplayInfo) { // Fallback: Use recording display info if available this.cursorDisplayInfo = { ...this.recordingDisplayInfo, displayX: this.recordingDisplayInfo.x || 0, displayY: this.recordingDisplayInfo.y || 0, displayWidth: this.recordingDisplayInfo.width || this.recordingDisplayInfo.logicalWidth, displayHeight: this.recordingDisplayInfo.height || this.recordingDisplayInfo.logicalHeight, videoOffsetX: 0, videoOffsetY: 0, videoWidth: this.recordingDisplayInfo.width || this.recordingDisplayInfo.logicalWidth, videoHeight: this.recordingDisplayInfo.height || this.recordingDisplayInfo.logicalHeight, videoRelative: true, recordingType: options.recordingType || 'display', multiWindowBounds: options.multiWindowBounds || null }; } else { // Final fallback: Main display global coordinates try { const displays = await this.getDisplays(); const mainDisplay = displays.find((d) => d.isPrimary) || displays[0]; if (mainDisplay) { this.cursorDisplayInfo = { displayId: mainDisplay.id, x: mainDisplay.x, y: mainDisplay.y, width: parseInt(mainDisplay.resolution.split("x")[0]), height: parseInt(mainDisplay.resolution.split("x")[1]), multiWindowBounds: options.multiWindowBounds || null }; } } catch (error) { console.warn("Main display bilgisi alınamadı:", error.message); this.cursorDisplayInfo = null; // Fallback: global koordinatlar } } return new Promise((resolve, reject) => { try { // Dosyayı oluştur ve temizle const fs = require("fs"); fs.writeFileSync(filepath, "["); this.cursorCaptureFile = filepath; // SYNC FIX: Use synchronized start time for accurate timestamp calculation this.cursorCaptureStartTime = syncStartTime; this.cursorCaptureFirstWrite = true; this.lastCapturedData = null; // Store session timestamp for sync metadata this.cursorCaptureSessionTimestamp = this.sessionTimestamp; // JavaScript interval ile polling yap (daha sık - mouse event'leri yakalamak için) this.cursorCaptureInterval = setInterval(() => { try { const position = nativeBinding.getCursorPosition(); const timestamp = Date.now() - this.cursorCaptureStartTime; // Video-relative coordinate transformation for all recording types let x = position.x; let y = position.y; let coordinateSystem = "global";