av-kit
Version:
AVFoundation Recorder kit for Node.js
299 lines (253 loc) • 7.94 kB
text/typescript
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);
}
}