UNPKG

av-kit

Version:

AVFoundation Recorder kit for Node.js

299 lines (253 loc) 7.94 kB
import path from "path"; import { FFmpegProcessManager } from "./ffmpeg-manager"; import { DisplayRecordingItem, RecorderOptions, RecordingMode, RecordingResult, WebcamRecordingItem, } from "./types"; import { createOutputDirectory, getAbsolutePath, getDeviceIndex, getVideoMetadata, waitForFile, } from "./utils"; export class Recorder { private options: RecorderOptions; private outputDirectory: string; private processManager: FFmpegProcessManager = new FFmpegProcessManager(); private prepared: boolean = false; private recording: boolean = false; private recordingFiles: { path: string; type: RecordingMode; deviceName?: string; }[] = []; constructor(options: RecorderOptions) { this.options = options; this.outputDirectory = createOutputDirectory(options.output_directory); } /** * Prepare the recorder */ public async prepare(): Promise<void> { if (this.prepared) { throw new Error("Recorder is already prepared"); } // Validate that we have recording items if (!this.options.items || this.options.items.length === 0) { throw new Error("No recording items provided"); } // Reset recording files this.recordingFiles = []; // Prepare output paths for each recording item 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 : undefined; this.recordingFiles.push({ path: path.join(this.outputDirectory, filename), type: item.type, deviceName, }); } this.prepared = true; } /** * Start recording */ public async start(): Promise<void> { if (this.recording) { throw new Error("Recording is already in progress"); } if (!this.prepared) { await this.prepare(); } try { // Start recording for each item for (let i = 0; i < this.options.items.length; i++) { const item = this.options.items[i]; if (!item) continue; // Skip if item is undefined const outputPath = this.recordingFiles[i]?.path; if (!outputPath) continue; // Skip if path is undefined if (item.type === "display") { await this.startDisplayRecording(item, outputPath); } else if (item.type === "webcam") { await this.startWebcamRecording(item, outputPath); } else { console.warn( `Skipping invalid recording item: ${JSON.stringify(item)}` ); } } this.recording = true; } catch (error) { // If there's an error during start, make sure to clean up any started processes await this.processManager.stopAllProcesses(); // Re-throw the error after cleanup throw error; } } /** * Stop recording and return result */ public async stop(): Promise<RecordingResult> { if (!this.recording) { throw new Error("No recording in progress"); } // Stop all processes await this.processManager.stopAllProcesses(); // Wait for all recording files to be ready and collect metadata const result: RecordingResult = { files: [] }; for (const file of this.recordingFiles) { const fileReady = await waitForFile(file.path); if (fileReady) { const metadata = await 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 */ private async startDisplayRecording( item: DisplayRecordingItem, outputPath: string ): Promise<void> { const processName = `display-${item.display.id}`; // Get display device index const displayIndex = await getDeviceIndex("display"); if (displayIndex === null) { throw new Error("Could not find display device"); } const ffmpegArgs: string[] = ["-f", "avfoundation", "-framerate", "60"]; // Check if microphone is enabled if (item.microphone) { const microphoneIndex = await 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}:`); } // Add video settings 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 ); // Add audio settings if microphone is enabled if (item.microphone) { ffmpegArgs.push( "-c:a", "aac", // AAC audio codec "-b:a", "128k" // 128kbps bitrate ); } else { ffmpegArgs.push("-an"); // No audio } // Add faststart for streaming capability ffmpegArgs.push("-movflags", "+faststart"); // Set output file ffmpegArgs.push(outputPath); // Start the process using the process manager return this.processManager.startProcess(processName, ffmpegArgs); } /** * Start recording a webcam */ private async startWebcamRecording( item: WebcamRecordingItem, outputPath: string ): Promise<void> { const processName = `webcam-${item.camera.id}`; // Get camera device index const cameraIndex = await getDeviceIndex("camera"); if (cameraIndex === null) { throw new Error("Could not find camera device"); } const ffmpegArgs: string[] = ["-f", "avfoundation", "-framerate", "30"]; // Check if microphone is enabled if (item.microphone) { const microphoneIndex = await 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}:`); } // Add video settings for webcam (optimize for lower CPU usage) 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 ); // Add audio settings if microphone is enabled if (item.microphone) { ffmpegArgs.push( "-c:a", "aac", // AAC audio codec "-b:a", "128k" // 128kbps bitrate ); } else { ffmpegArgs.push("-an"); // No audio } // Add faststart for streaming capability ffmpegArgs.push("-movflags", "+faststart"); // Set output file ffmpegArgs.push(outputPath); // Start the process using the process manager return this.processManager.startProcess(processName, ffmpegArgs); } }