av-kit
Version:
AVFoundation Recorder kit for Node.js
222 lines (190 loc) • 6.31 kB
text/typescript
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}` },
];
}