UNPKG

av-kit

Version:

AVFoundation Recorder kit for Node.js

222 lines (190 loc) 6.31 kB
import ffmpegInstaller from "@ffmpeg-installer/ffmpeg"; import { exec } from "child_process"; import fs from "fs"; import os from "os"; import path from "path"; import { Camera, DeviceType, Display, Microphone } from "./types"; // FFmpeg path setup export const ffmpegPath = ffmpegInstaller.path.replace( "app.asar", "app.asar.unpacked" ); // Utility function to create a unique session directory export function createOutputDirectory(customPath?: string): string { const baseDir = customPath || path.join(os.homedir(), "recorder", new Date().toISOString()); if (!fs.existsSync(baseDir)) { fs.mkdirSync(baseDir, { recursive: true }); } return baseDir; } // Get absolute path export function getAbsolutePath(filePath: string): string { return path.isAbsolute(filePath) ? filePath : path.resolve(filePath); } // Wait for a file to be ready (useful after stopping a recording) export function waitForFile( filePath: string, maxAttempts = 10 ): Promise<boolean> { 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}`); // Continue to next attempt } } if (attempts >= maxAttempts) { resolve(false); return; } setTimeout(checkFile, 500); }; checkFile(); }); } // Get video metadata export function getVideoMetadata(filePath: string): Promise<{ dimensions: { width: number; height: number }; durationInSeconds: number; } | null> { 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); } }); }); } // Get device index using AVFoundation export function getDeviceIndex(deviceType: DeviceType): Promise<number | null> { 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: number | null = null; // Different search patterns based on device type let searchPattern: RegExp; 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: // This should never happen due to typescript, but just in case console.error(`Unknown device type: ${deviceType}`); resolve(null); return; } // Search through output lines for matching device 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); }); // Set a timeout to avoid hanging 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); } }, 5000); childProcess.on("exit", () => clearTimeout(timeout)); } } catch (error) { console.error("Error in getDeviceIndex:", error); resolve(null); } }); } // Get available displays (screens) export async function getDisplays(): Promise<Display[]> { const displayIndex = await getDeviceIndex("display"); if (displayIndex === null) { return []; } return [{ id: displayIndex.toString(), name: `Display ${displayIndex}` }]; } // Get available cameras export async function getCameras(): Promise<Camera[]> { const cameraIndex = await getDeviceIndex("camera"); if (cameraIndex === null) { return []; } return [{ id: cameraIndex.toString(), name: `Camera ${cameraIndex}` }]; } // Get available microphones export async function getMicrophones(): Promise<Microphone[]> { const microphoneIndex = await getDeviceIndex("microphone"); if (microphoneIndex === null) { return []; } return [ { id: microphoneIndex.toString(), name: `Microphone ${microphoneIndex}` }, ]; }