UNPKG

av-kit

Version:

AVFoundation Recorder kit for Node.js

556 lines (550 loc) 16.6 kB
var __async = (__this, __arguments, generator) => { return new Promise((resolve, reject) => { var fulfilled = (value) => { try { step(generator.next(value)); } catch (e) { reject(e); } }; var rejected = (value) => { try { step(generator.throw(value)); } catch (e) { reject(e); } }; var step = (x) => x.done ? resolve(x.value) : Promise.resolve(x.value).then(fulfilled, rejected); step((generator = generator.apply(__this, __arguments)).next()); }); }; // src/recorder.ts import path2 from "path"; // src/ffmpeg-manager.ts import { spawn } from "child_process"; // src/utils.ts import ffmpegInstaller from "@ffmpeg-installer/ffmpeg"; import { exec } from "child_process"; import fs from "fs"; import os from "os"; import path from "path"; var ffmpegPath = ffmpegInstaller.path.replace( "app.asar", "app.asar.unpacked" ); function createOutputDirectory(customPath) { const baseDir = customPath || path.join(os.homedir(), "recorder", (/* @__PURE__ */ new Date()).toISOString()); if (!fs.existsSync(baseDir)) { fs.mkdirSync(baseDir, { recursive: true }); } return baseDir; } function getAbsolutePath(filePath) { return path.isAbsolute(filePath) ? filePath : path.resolve(filePath); } function waitForFile(filePath, maxAttempts = 10) { return new Promise((resolve) => { let attempts = 0; const checkFile = () => { attempts++; if (fs.existsSync(filePath)) { try { const stats = fs.statSync(filePath); if (stats.size > 0) { resolve(true); return; } } catch (error) { console.error(`Error checking file stats: ${error}`); } } if (attempts >= maxAttempts) { resolve(false); return; } setTimeout(checkFile, 500); }; checkFile(); }); } function getVideoMetadata(filePath) { return new Promise((resolve) => { if (!fs.existsSync(filePath)) { console.error(`File does not exist: ${filePath}`); resolve(null); return; } const command = `"${ffmpegPath}" -i "${filePath}" -hide_banner -v error`; exec(command, (error, _, stderr) => { try { const dimensionsMatch = stderr.match(/Stream #0:0.*?(\d+)x(\d+)/); const durationMatch = stderr.match(/Duration: (\d+):(\d+):(\d+\.\d+)/); if (dimensionsMatch && dimensionsMatch[1] && dimensionsMatch[2] && durationMatch && durationMatch[1] && durationMatch[2] && durationMatch[3]) { const width = parseInt(dimensionsMatch[1], 10); const height = parseInt(dimensionsMatch[2], 10); const hours = parseInt(durationMatch[1], 10); const minutes = parseInt(durationMatch[2], 10); const seconds = parseFloat(durationMatch[3]); const durationInSeconds = hours * 3600 + minutes * 60 + seconds; resolve({ dimensions: { width, height }, durationInSeconds }); } else { console.warn(`Could not parse metadata for ${filePath}`); resolve(null); } } catch (e) { console.error("Error parsing video metadata:", e); resolve(null); } }); }); } function getDeviceIndex(deviceType) { return new Promise((resolve) => { try { const command = `"${ffmpegPath}" -f avfoundation -list_devices true -i ""`; const childProcess = exec(command, (error, _, stderr) => { if (error && !stderr) { console.error(`Failed to get AVFoundation devices: ${error.message}`); resolve(null); return; } const output = stderr || ""; let deviceIndex = null; let searchPattern; switch (deviceType) { case "display": searchPattern = /\[(\d+)\]\s+Capture screen/i; break; case "camera": searchPattern = /\[(\d+)\]\s+(?:FaceTime|Camera|Webcam|HD Camera)/i; break; case "microphone": searchPattern = /\[(\d+)\]\s+(?:Built-in|System|MacBook\s+Pro)\s+(?:Microphone|Mic|Input)/i; break; default: console.error(`Unknown device type: ${deviceType}`); resolve(null); return; } const lines = output.split("\n"); for (const line of lines) { const match = line.match(searchPattern); if (match && match[1]) { deviceIndex = parseInt(match[1], 10); break; } } resolve(deviceIndex); }); if (childProcess) { const timeout = setTimeout(() => { if (childProcess && !childProcess.killed) { try { childProcess.kill(); } catch (e) { console.error("Error killing ffmpeg process:", e); } console.warn("getDeviceIndex process timed out"); resolve(null); } }, 5e3); childProcess.on("exit", () => clearTimeout(timeout)); } } catch (error) { console.error("Error in getDeviceIndex:", error); resolve(null); } }); } function getDisplays() { return __async(this, null, function* () { const displayIndex = yield getDeviceIndex("display"); if (displayIndex === null) { return []; } return [{ id: displayIndex.toString(), name: `Display ${displayIndex}` }]; }); } function getCameras() { return __async(this, null, function* () { const cameraIndex = yield getDeviceIndex("camera"); if (cameraIndex === null) { return []; } return [{ id: cameraIndex.toString(), name: `Camera ${cameraIndex}` }]; }); } function getMicrophones() { return __async(this, null, function* () { const microphoneIndex = yield getDeviceIndex("microphone"); if (microphoneIndex === null) { return []; } return [ { id: microphoneIndex.toString(), name: `Microphone ${microphoneIndex}` } ]; }); } // src/ffmpeg-manager.ts var FFmpegProcessManager = class { constructor() { this.processes = /* @__PURE__ */ new Map(); } /** * Start a new FFmpeg process * * @param processName Unique identifier for the process * @param args FFmpeg command line arguments * @returns Promise that resolves when the process has started successfully */ startProcess(processName, args) { if (this.processes.has(processName)) { return Promise.reject(new Error(`Process ${processName} already exists`)); } const process = spawn(ffmpegPath, args); this.processes.set(processName, process); return new Promise((resolve, reject) => { process.on("error", (error) => { console.error(`Error starting ${processName}:`, error); this.processes.delete(processName); reject(error); }); let earlyExitHandlerActive = true; process.once("exit", (code) => { if (earlyExitHandlerActive && code !== 0 && code !== null) { const errorMsg = `${processName} exited early with code ${code}`; console.error(errorMsg); this.processes.delete(processName); reject(new Error(errorMsg)); } }); setTimeout(() => { earlyExitHandlerActive = false; process.on("exit", (code) => { if (code !== 0 && code !== null) { console.error(`${processName} exited with code ${code}`); } this.processes.delete(processName); }); resolve(); }, 500); let stderrData = ""; process.stderr.on("data", (data) => { stderrData += data.toString(); if (stderrData.includes("Cannot open") || stderrData.includes("Error") || stderrData.includes("Invalid")) { console.error(`FFmpeg error: ${stderrData}`); } }); }); } /** * Stop a specific process * * @param processName Identifier of the process to stop * @returns Promise that resolves when the process has been stopped */ stopProcess(processName) { return new Promise((resolve) => { const process = this.processes.get(processName); if (!process) { resolve(); return; } const cleanup = () => { process.removeAllListeners(); this.processes.delete(processName); resolve(); }; process.on("exit", cleanup); if (!process.killed) { process.kill("SIGTERM"); setTimeout(() => { if (!process.killed) { try { process.kill("SIGKILL"); } catch (e) { console.error(`Error killing ${processName}:`, e); } } cleanup(); }, 2e3); } else { cleanup(); } }); } /** * Stop all processes * * @returns Promise that resolves when all processes have been stopped */ stopAllProcesses() { const processes = Array.from(this.processes.keys()); const stopPromises = processes.map( (processName) => this.stopProcess(processName) ); return Promise.all(stopPromises); } /** * Check if a process exists * * @param processName Name of the process to check * @returns true if the process exists */ hasProcess(processName) { return this.processes.has(processName); } /** * Get a process by name * * @param processName Name of the process to get * @returns The ChildProcess or undefined if not found */ getProcess(processName) { return this.processes.get(processName); } /** * Get the count of running processes * * @returns Number of running processes */ getProcessCount() { return this.processes.size; } }; // src/recorder.ts var Recorder = class { constructor(options) { this.processManager = new FFmpegProcessManager(); this.prepared = false; this.recording = false; this.recordingFiles = []; this.options = options; this.outputDirectory = createOutputDirectory(options.output_directory); } /** * Prepare the recorder */ prepare() { return __async(this, null, function* () { if (this.prepared) { throw new Error("Recorder is already prepared"); } if (!this.options.items || this.options.items.length === 0) { throw new Error("No recording items provided"); } this.recordingFiles = []; for (const item of this.options.items) { const filename = `${item.type}-recording.mp4`; const deviceName = item.type === "display" ? item.display.name : item.type === "webcam" ? item.camera.name : void 0; this.recordingFiles.push({ path: path2.join(this.outputDirectory, filename), type: item.type, deviceName }); } this.prepared = true; }); } /** * Start recording */ start() { return __async(this, null, function* () { var _a; if (this.recording) { throw new Error("Recording is already in progress"); } if (!this.prepared) { yield this.prepare(); } try { for (let i = 0; i < this.options.items.length; i++) { const item = this.options.items[i]; if (!item) continue; const outputPath = (_a = this.recordingFiles[i]) == null ? void 0 : _a.path; if (!outputPath) continue; if (item.type === "display") { yield this.startDisplayRecording(item, outputPath); } else if (item.type === "webcam") { yield this.startWebcamRecording(item, outputPath); } else { console.warn( `Skipping invalid recording item: ${JSON.stringify(item)}` ); } } this.recording = true; } catch (error) { yield this.processManager.stopAllProcesses(); throw error; } }); } /** * Stop recording and return result */ stop() { return __async(this, null, function* () { if (!this.recording) { throw new Error("No recording in progress"); } yield this.processManager.stopAllProcesses(); const result = { files: [] }; for (const file of this.recordingFiles) { const fileReady = yield waitForFile(file.path); if (fileReady) { const metadata = yield getVideoMetadata(file.path); if (metadata) { result.files.push({ path: getAbsolutePath(file.path), type: file.type, deviceName: file.deviceName, dimensions: metadata.dimensions, durationInSeconds: metadata.durationInSeconds }); } else { result.files.push({ path: getAbsolutePath(file.path), type: file.type, deviceName: file.deviceName }); } } else { console.warn(`File not ready after waiting: ${file.path}`); } } this.recording = false; this.prepared = false; return result; }); } /** * Start recording a display */ startDisplayRecording(item, outputPath) { return __async(this, null, function* () { const processName = `display-${item.display.id}`; const displayIndex = yield getDeviceIndex("display"); if (displayIndex === null) { throw new Error("Could not find display device"); } const ffmpegArgs = ["-f", "avfoundation", "-framerate", "60"]; if (item.microphone) { const microphoneIndex = yield getDeviceIndex("microphone"); if (microphoneIndex !== null) { ffmpegArgs.push("-i", `${displayIndex}:${microphoneIndex}`); } else { console.warn("Requested microphone not found, recording without audio"); ffmpegArgs.push("-i", `${displayIndex}:`); } } else { ffmpegArgs.push("-i", `${displayIndex}:`); } ffmpegArgs.push( "-r", "60", // 60fps "-c:v", "libx264", // H.264 codec "-preset", "medium", // Balance quality and speed "-crf", "23", // Quality level "-profile:v", "high", // High profile for better quality "-pix_fmt", "yuv420p" // Standard pixel format ); if (item.microphone) { ffmpegArgs.push( "-c:a", "aac", // AAC audio codec "-b:a", "128k" // 128kbps bitrate ); } else { ffmpegArgs.push("-an"); } ffmpegArgs.push("-movflags", "+faststart"); ffmpegArgs.push(outputPath); return this.processManager.startProcess(processName, ffmpegArgs); }); } /** * Start recording a webcam */ startWebcamRecording(item, outputPath) { return __async(this, null, function* () { const processName = `webcam-${item.camera.id}`; const cameraIndex = yield getDeviceIndex("camera"); if (cameraIndex === null) { throw new Error("Could not find camera device"); } const ffmpegArgs = ["-f", "avfoundation", "-framerate", "30"]; if (item.microphone) { const microphoneIndex = yield getDeviceIndex("microphone"); if (microphoneIndex !== null) { ffmpegArgs.push("-i", `${cameraIndex}:${microphoneIndex}`); } else { console.warn("Requested microphone not found, recording without audio"); ffmpegArgs.push("-i", `${cameraIndex}:`); } } else { ffmpegArgs.push("-i", `${cameraIndex}:`); } ffmpegArgs.push( "-r", "30", // 30fps "-c:v", "libx264", // H.264 codec "-preset", "ultrafast", // Prioritize speed over quality "-crf", "28", // Lower quality for better performance "-profile:v", "baseline", // Baseline profile for better compatibility "-pix_fmt", "yuv420p" // Standard pixel format ); if (item.microphone) { ffmpegArgs.push( "-c:a", "aac", // AAC audio codec "-b:a", "128k" // 128kbps bitrate ); } else { ffmpegArgs.push("-an"); } ffmpegArgs.push("-movflags", "+faststart"); ffmpegArgs.push(outputPath); return this.processManager.startProcess(processName, ffmpegArgs); }); } }; export { Recorder, createOutputDirectory, getCameras, getDisplays, getMicrophones };