av-kit
Version:
AVFoundation Recorder kit for Node.js
556 lines (550 loc) • 16.6 kB
JavaScript
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
};