homebridge-eufy-security
Version:
Control Eufy Security from homebridge.
843 lines • 32.3 kB
JavaScript
// Node built-ins
import { execFileSync, spawn } from 'child_process';
import EventEmitter from 'events';
import net from 'net';
import os from 'os';
// External packages
import ffmpegPath from 'ffmpeg-for-homebridge';
import { pickPort } from 'pick-port';
import { ffmpegLogger } from './utils.js';
/** Timeout for one-shot TCP servers waiting for a connection (ms) */
const TCP_SERVER_TIMEOUT_MS = 30_000;
/** Timeout for ffmpeg getResult() before force-killing the process (ms) */
const PROCESS_RESULT_TIMEOUT_MS = 15_000;
/** Grace period after SIGTERM before sending SIGKILL (ms) */
const KILL_GRACE_PERIOD_MS = 2_000;
/** Returns true when the value is a non-empty string (guards `undefined | ''`). */
function isNonEmpty(value) {
return !!value && value !== '';
}
/**
* Cached result of probing the ffmpeg binary for libfdk_aac support.
* `undefined` means we haven't checked yet.
*/
let _hasFdkAac;
/**
* Returns true when the ffmpeg binary on this system supports the libfdk_aac
* encoder. The result is cached after the first call.
*/
export function hasFdkAac() {
if (_hasFdkAac !== undefined) {
return _hasFdkAac;
}
const ffmpegExec = ffmpegPath || 'ffmpeg';
try {
const output = execFileSync(ffmpegExec, ['-encoders'], {
timeout: 5_000,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});
_hasFdkAac = output.includes('libfdk_aac');
}
catch {
_hasFdkAac = false;
}
if (_hasFdkAac) {
ffmpegLogger.info('libfdk_aac encoder available.');
}
else {
ffmpegLogger.warn('libfdk_aac encoder is NOT available — falling back to the native aac encoder (AAC-LC instead of AAC-ELD). ' +
'This usually means the ffmpeg-for-homebridge binary could not be installed on your platform. ' +
'You can install a compatible ffmpeg manually (built with --enable-libfdk-aac --enable-nonfree) ' +
'and set the path in your camera\'s videoConfig.videoProcessor setting.');
}
return _hasFdkAac;
}
/**
* Returns the preferred AAC encoder and codec options for AAC-ELD output.
* Falls back to the built-in `aac` encoder when `libfdk_aac` is absent.
*/
function getAacEldCodecAndOptions() {
if (hasFdkAac()) {
return { codec: 'libfdk_aac', codecOptions: '-profile:a aac_eld' };
}
// The native aac encoder doesn't support ELD, but LC works with HomeKit.
return { codec: 'aac', codecOptions: '-profile:a aac_low' };
}
/**
* Returns the preferred AAC encoder for a given recording codec type.
* Falls back to the built-in `aac` encoder when `libfdk_aac` is absent.
*/
function getAacRecordingCodecAndOptions(codecType) {
const isLC = codecType === 0 /* AudioRecordingCodecType.AAC_LC */;
if (hasFdkAac()) {
return {
codec: 'libfdk_aac',
codecOptions: isLC ? '-profile:a aac_low' : '-profile:a aac_eld',
};
}
return {
codec: 'aac',
codecOptions: '-profile:a aac_low',
};
}
/** Map HomeKit audio samplerate enum values to kHz strings for ffmpeg `-ar`. */
const SAMPLERATE_MAP = new Map([
[0 /* AudioRecordingSamplerate.KHZ_8 */, '8'],
[1 /* AudioRecordingSamplerate.KHZ_16 */, '16'],
[2 /* AudioRecordingSamplerate.KHZ_24 */, '24'],
[3 /* AudioRecordingSamplerate.KHZ_32 */, '32'],
[4 /* AudioRecordingSamplerate.KHZ_44_1 */, '44.1'],
[5 /* AudioRecordingSamplerate.KHZ_48 */, '48'],
]);
/**
* Creates a TCP server that accepts exactly one connection, then auto-closes.
* If no connection arrives within the timeout, the server closes anyway.
* @returns The port the server is listening on.
*/
async function createOneShotTcpServer(onConnection, existingPort) {
const port = existingPort ?? await pickPort({ type: 'tcp' });
// eslint-disable-next-line prefer-const
let killTimeout;
const server = net.createServer((socket) => {
if (killTimeout) {
clearTimeout(killTimeout);
}
server.close();
socket.on('error', () => { }); // ignore — handled elsewhere
onConnection(socket);
});
server.on('error', () => { }); // ignore — handled elsewhere
killTimeout = setTimeout(() => {
server.close();
}, TCP_SERVER_TIMEOUT_MS);
server.listen(port);
return { port, server };
}
class FFmpegProgress extends EventEmitter {
started = false;
constructor() {
super();
}
static async create(port) {
const instance = new FFmpegProgress();
const { server } = await createOneShotTcpServer((socket) => {
socket.on('data', instance.analyzeProgress.bind(instance));
}, port);
server.on('close', () => instance.emit('progress stopped'));
return instance;
}
analyzeProgress(progressData) {
const progress = new Map();
progressData.toString().split(/\r?\n/).forEach((line) => {
const split = line.split('=', 2);
if (split.length !== 2) {
return;
}
progress.set(split[0], split[1]);
});
if (!this.started) {
if (progress.get('progress') !== undefined) {
this.started = true;
this.emit('progress started');
}
}
}
}
export class FFmpegParameters {
progressPort;
debug;
// default parameters
processor;
hideBanner = true;
useWallclockAsTimestamp = true;
inputSource = '-i pipe:';
protocolWhitelist;
inputCodec;
inputFormat;
output = 'pipe:1';
isVideo;
isAudio;
isSnapshot;
// generic options
analyzeDuration;
probeSize;
stimeout;
readrate;
codec = 'copy';
codecOptions;
bitrate;
// output options
payloadType;
ssrc;
srtpSuite;
srtpParams;
format;
// video options
fps;
pixFormat;
colorRange;
filters;
width;
height;
bufsize;
maxrate;
crop = false;
// audio options
sampleRate;
channels;
flagsGlobalHeader = false;
// snapshot options
numberFrames;
delaySnapshot = false;
// recording options / fragmented mp4
movflags;
maxMuxingQueueSize;
iFrameInterval;
processAudio = true;
constructor(port, isVideo, isAudio, isSnapshot, debug = false) {
this.progressPort = port;
this.isVideo = isVideo;
this.isAudio = isAudio;
this.isSnapshot = isSnapshot;
this.debug = debug;
}
/** Allocate a progress port and construct an instance. */
static async create(isVideo, isAudio, isSnapshot, debug) {
const port = await pickPort({ type: 'tcp' });
return new FFmpegParameters(port, isVideo, isAudio, isSnapshot, debug);
}
static async forAudio(debug = false) {
const ffmpeg = await FFmpegParameters.create(false, true, false, debug);
ffmpeg.useWallclockAsTimestamp = false;
ffmpeg.flagsGlobalHeader = true;
return ffmpeg;
}
static async forVideo(debug = false) {
return FFmpegParameters.create(true, false, false, debug);
}
static async forSnapshot(debug = false) {
const ffmpeg = await FFmpegParameters.create(false, false, true, debug);
ffmpeg.useWallclockAsTimestamp = false;
ffmpeg.numberFrames = 1;
ffmpeg.format = 'image2';
return ffmpeg;
}
static async forVideoRecording(debug = false) {
const ffmpeg = await FFmpegParameters.create(true, false, false, debug);
ffmpeg.useWallclockAsTimestamp = true;
return ffmpeg;
}
static async forAudioRecording(debug = false) {
return FFmpegParameters.create(false, true, false, debug);
}
setResolution(width, height) {
this.width = width;
this.height = height;
}
usesStdInAsInput() {
return this.inputSource === '-i pipe:';
}
setInputSource(value) {
this.inputSource = `-i ${value}`;
}
async setInputStream(input) {
const { port } = await createOneShotTcpServer((socket) => {
input.pipe(socket);
});
this.setInputSource(`tcp://127.0.0.1:${port}`);
}
setDelayedSnapshot() {
this.delaySnapshot = true;
}
setup(cameraConfig, request) {
const videoConfig = cameraConfig.videoConfig ??= {};
if (isNonEmpty(videoConfig.videoProcessor)) {
this.processor = videoConfig.videoProcessor;
}
if (videoConfig.readRate) {
this.readrate = videoConfig.readRate;
}
if (videoConfig.stimeout) {
this.stimeout = videoConfig.stimeout;
}
if (videoConfig.probeSize) {
this.probeSize = videoConfig.probeSize;
}
if (videoConfig.analyzeDuration) {
this.analyzeDuration = videoConfig.analyzeDuration;
}
if (this.isVideo) {
const req = request;
this.codec = isNonEmpty(videoConfig.vcodec) ? videoConfig.vcodec : 'libx264';
if (this.codec !== 'copy') {
this.fps = videoConfig.maxFPS ?? req.video.fps;
const bitrate = videoConfig.maxBitrate ?? req.video.max_bit_rate;
this.bitrate = bitrate;
this.bufsize = bitrate * 2;
this.maxrate = bitrate;
this.codecOptions = videoConfig.encoderOptions
?? (this.codec === 'libx264' ? '-preset ultrafast -tune zerolatency' : '');
this.pixFormat = 'yuv420p';
this.colorRange = 'mpeg';
this.applyVisualConfig(req.video.width, req.video.height, videoConfig);
}
}
if (this.isAudio) {
const req = request;
let codec;
let codecOptions;
switch (req.audio.codec) {
case "OPUS" /* AudioStreamingCodecType.OPUS */:
codec = 'libopus';
codecOptions = '-application lowdelay';
break;
default: {
const aac = getAacEldCodecAndOptions();
codec = aac.codec;
codecOptions = aac.codecOptions;
break;
}
}
if (isNonEmpty(videoConfig.acodec)) {
codec = videoConfig.acodec;
codecOptions = '';
}
if (videoConfig.acodecOptions !== undefined) {
codecOptions = videoConfig.acodecOptions;
}
if (this.flagsGlobalHeader) {
if (codecOptions !== '') {
codecOptions += ' ';
}
codecOptions += '-flags +global_header';
}
this.codec = codec;
this.codecOptions = codecOptions;
if (this.codec !== 'copy') {
this.sampleRate = req.audio.sample_rate;
this.channels = req.audio.channel;
this.bitrate = videoConfig.audioBitrate ? videoConfig.audioBitrate : req.audio.max_bit_rate;
}
}
if (this.isSnapshot) {
const req = request;
this.applyVisualConfig(req.width, req.height, videoConfig);
}
}
setRTPTarget(sessionInfo, request) {
const isVideo = this.isVideo;
const mediaRequest = isVideo ? request.video : request.audio;
const port = isVideo ? sessionInfo.videoPort : sessionInfo.audioPort;
const pktSize = isVideo ? 1128 : 188;
this.payloadType = mediaRequest.pt;
this.ssrc = isVideo ? sessionInfo.videoSSRC : sessionInfo.audioSSRC;
this.srtpParams = (isVideo ? sessionInfo.videoSRTP : sessionInfo.audioSRTP).toString('base64');
this.srtpSuite = 'AES_CM_128_HMAC_SHA1_80';
this.format = 'rtp';
this.output = `srtp://${sessionInfo.address}:${port}?rtcpport=${port}&pkt_size=${pktSize}`;
}
setOutput(output) {
this.output = output;
}
setupForRecording(videoConfig, configuration) {
this.movflags = 'frag_keyframe+empty_moov+default_base_moof+omit_tfhd_offset';
this.maxMuxingQueueSize = 1024;
if (isNonEmpty(videoConfig.videoProcessor)) {
this.processor = videoConfig.videoProcessor;
}
if (this.isVideo) {
if (isNonEmpty(videoConfig.vcodec)) {
this.codec = videoConfig.vcodec;
}
else {
this.codec = 'libx264';
}
if (this.codec === 'libx264') {
this.pixFormat = 'yuv420p';
const profile = configuration.videoCodec.parameters.profile === 2 /* H264Profile.HIGH */
? 'high'
: configuration.videoCodec.parameters.profile === 1 /* H264Profile.MAIN */
? 'main'
: 'baseline';
const level = configuration.videoCodec.parameters.level === 2 /* H264Level.LEVEL4_0 */
? '4.0'
: configuration.videoCodec.parameters.level === 1 /* H264Level.LEVEL3_2 */
? '3.2'
: '3.1';
this.codecOptions = `-preset ultrafast -tune zerolatency -profile:v ${profile} -level:v ${level}`;
}
if (this.codec !== 'copy') {
this.bitrate = videoConfig.maxBitrate ?? configuration.videoCodec.parameters.bitRate;
this.width = configuration.videoCodec.resolution[0];
this.height = configuration.videoCodec.resolution[1];
this.fps = videoConfig.maxFPS ?? configuration.videoCodec.resolution[2];
this.crop = (videoConfig.crop !== false); // only false if 'crop: false' was specifically set
}
this.iFrameInterval = configuration.videoCodec.parameters.iFrameInterval;
}
if (this.isAudio) {
if (isNonEmpty(videoConfig.acodec)) {
this.codec = videoConfig.acodec;
}
else {
const aac = getAacRecordingCodecAndOptions(configuration.audioCodec.type);
this.codec = aac.codec;
this.codecOptions = aac.codecOptions + ' -flags +global_header';
}
if (isNonEmpty(videoConfig.acodec) && (this.codec === 'libfdk_aac' || this.codec === 'aac')) {
this.codecOptions = (configuration.audioCodec.type === 0 /* AudioRecordingCodecType.AAC_LC */)
? '-profile:a aac_low'
: '-profile:a aac_eld';
this.codecOptions += ' -flags +global_header';
}
if (this.codec !== 'copy') {
const samplerate = SAMPLERATE_MAP.get(configuration.audioCodec.samplerate);
if (!samplerate) {
throw new Error(`Unsupported audio samplerate: ${configuration.audioCodec.samplerate}`);
}
this.sampleRate = samplerate;
this.bitrate = configuration.audioCodec.bitrate;
this.channels = configuration.audioCodec.audioChannels;
}
}
}
async setTalkbackInput(sessionInfo) {
this.useWallclockAsTimestamp = false;
this.protocolWhitelist = 'pipe,udp,rtp,file,crypto,tcp';
this.inputFormat = 'sdp';
const talkbackCodec = hasFdkAac() ? 'libfdk_aac' : 'aac';
this.inputCodec = talkbackCodec;
this.codec = talkbackCodec;
this.sampleRate = 16;
this.channels = 1;
this.bitrate = 20;
this.format = 'adts';
const ipVer = sessionInfo.ipv6 ? 'IP6' : 'IP4';
const sdpInput = 'v=0\r\n' +
'o=- 0 0 IN ' + ipVer + ' ' + sessionInfo.address + '\r\n' +
's=Talk\r\n' +
'c=IN ' + ipVer + ' ' + sessionInfo.address + '\r\n' +
't=0 0\r\n' +
'm=audio ' + sessionInfo.audioReturnPort + ' RTP/AVP 110\r\n' +
'b=AS:24\r\n' +
'a=rtpmap:110 MPEG4-GENERIC/16000/1\r\n' +
'a=rtcp-mux\r\n' + // FFmpeg ignores this, but might as well
'a=fmtp:110 ' +
'profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3; ' +
'config=F8F0212C00BC00\r\n' +
'a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:' + sessionInfo.audioSRTP.toString('base64') + '\r\n';
const { port } = await createOneShotTcpServer((socket) => {
socket.end(sdpInput);
});
this.setInputSource(`tcp://127.0.0.1:${port}`);
}
setTalkbackChannels(channels) {
this.channels = channels;
}
buildGenericParameters() {
const params = [];
if (this.hideBanner)
params.push('-hide_banner');
params.push('-loglevel level+verbose');
if (this.useWallclockAsTimestamp)
params.push('-use_wallclock_as_timestamps 1');
return params;
}
buildInputParameters() {
const params = [];
if (this.analyzeDuration)
params.push(`-analyzeduration ${this.analyzeDuration}`);
if (this.probeSize)
params.push(`-probesize ${this.probeSize}`);
if (this.stimeout)
params.push(`-stimeout ${this.stimeout * 10000000}`);
if (this.readrate)
params.push('-re');
if (this.protocolWhitelist)
params.push(`-protocol_whitelist ${this.protocolWhitelist}`);
if (this.inputFormat)
params.push(`-f ${this.inputFormat}`);
if (this.inputCodec)
params.push(`-c:a ${this.inputCodec}`);
params.push(this.inputSource);
if (this.isVideo)
params.push('-an -sn -dn');
if (this.isAudio)
params.push('-vn -sn -dn');
return params;
}
/** Clamp a requested dimension to an optional max from the video config. */
static clampDimension(requested, max) {
return (max && max < requested) ? max : requested;
}
/** Apply common visual settings (dimensions, filters, crop) from the video config. */
applyVisualConfig(width, height, videoConfig) {
this.width = FFmpegParameters.clampDimension(width, videoConfig.maxWidth);
this.height = FFmpegParameters.clampDimension(height, videoConfig.maxHeight);
if (isNonEmpty(videoConfig.videoFilter)) {
this.filters = videoConfig.videoFilter;
}
if (videoConfig.crop) {
this.crop = videoConfig.crop;
}
}
/**
* Builds scale/crop video filter arguments based on current width, height, crop settings,
* and any user-specified filters. Shared between video and snapshot encoding.
*/
buildVideoFilterParams() {
const filters = this.filters ? this.filters.split(',') : [];
const noneFilter = filters.indexOf('none');
if (noneFilter >= 0) {
filters.splice(noneFilter, 1);
}
if (noneFilter < 0 && this.width && this.height) {
if (this.crop) {
filters.push(`scale=${this.width}:${this.height}:force_original_aspect_ratio=increase`);
filters.push(`crop=${this.width}:${this.height}`);
filters.push(`scale='trunc(${this.width}/2)*2:trunc(${this.height}/2)*2'`);
}
else {
filters.push(`scale='min(${this.width},iw)':'min(${this.height},ih)':force_original_aspect_ratio=decrease`);
filters.push('scale=\'trunc(iw/2)*2:trunc(ih/2)*2\'');
}
}
if (filters.length > 0) {
return ['-filter:v ' + filters.join(',')];
}
return [];
}
buildEncodingParameters() {
const params = [];
if (this.isVideo) {
if (this.fps)
params.push(`-r ${this.fps}`);
params.push(`-vcodec ${this.codec}`);
if (this.pixFormat)
params.push(`-pix_fmt ${this.pixFormat}`);
if (this.colorRange)
params.push(`-color_range ${this.colorRange}`);
if (this.codecOptions)
params.push(this.codecOptions);
params.push(...this.buildVideoFilterParams());
if (this.bitrate)
params.push(`-b:v ${this.bitrate}k`);
if (this.bufsize)
params.push(`-bufsize ${this.bufsize}k`);
if (this.maxrate)
params.push(`-maxrate ${this.maxrate}k`);
}
if (this.isAudio && this.processAudio) {
params.push(`-acodec ${this.codec}`);
if (this.codecOptions)
params.push(this.codecOptions);
if (this.bitrate)
params.push(`-b:a ${this.bitrate}k`);
if (this.sampleRate)
params.push(`-ar ${this.sampleRate}k`);
if (this.channels)
params.push(`-ac ${this.channels}`);
}
if (this.isSnapshot) {
if (this.numberFrames)
params.push(`-frames:v ${this.numberFrames}`);
if (this.delaySnapshot)
params.push('-ss 00:00:00.500');
params.push(...this.buildVideoFilterParams());
}
return params;
}
buildOutputParameters() {
const params = [];
if (this.payloadType)
params.push(`-payload_type ${this.payloadType}`);
if (this.ssrc)
params.push(`-ssrc ${this.ssrc}`);
if (this.format)
params.push(`-f ${this.format}`);
if (this.srtpSuite)
params.push(`-srtp_out_suite ${this.srtpSuite}`);
if (this.srtpParams)
params.push(`-srtp_out_params ${this.srtpParams}`);
params.push(this.output);
return params;
}
buildParameters() {
const params = [
...this.buildGenericParameters(),
...this.buildInputParameters(),
...this.buildEncodingParameters(),
...this.buildOutputParameters(),
`-progress tcp://127.0.0.1:${this.progressPort}`,
];
return params;
}
static getRecordingArguments(parameters) {
if (parameters.length === 0) {
return [];
}
const params = [...parameters[0].buildGenericParameters()];
// input
params.push(parameters[0].inputSource);
if (parameters.length > 1 && parameters[0].inputSource !== parameters[1].inputSource) {
if (parameters[1].processAudio) {
params.push(parameters[1].inputSource);
}
else {
params.push('-f lavfi -i anullsrc -shortest');
}
}
if (parameters.length === 1) {
params.push('-an');
}
params.push('-sn -dn');
// video encoding
params.push(...parameters[0].buildEncodingParameters());
if (parameters[0].iFrameInterval) {
params.push(`-force_key_frames expr:gte(t,n_forced*${parameters[0].iFrameInterval / 1000})`);
}
// audio encoding
if (parameters.length > 1) {
if (parameters[1].processAudio) {
params.push('-bsf:a aac_adtstoasc');
}
params.push(...parameters[1].buildEncodingParameters());
}
// fragmented mp4 options
if (parameters[0].movflags)
params.push(`-movflags ${parameters[0].movflags}`);
if (parameters[0].maxMuxingQueueSize)
params.push(`-max_muxing_queue_size ${parameters[0].maxMuxingQueueSize}`);
// output
params.push('-f mp4');
params.push(parameters[0].output);
params.push(`-progress tcp://127.0.0.1:${parameters[0].progressPort}`);
return params;
}
static getCombinedArguments(parameters) {
if (parameters.length === 0) {
return [];
}
const params = [...parameters[0].buildGenericParameters()];
for (const p of parameters) {
params.push(...p.buildInputParameters());
params.push(...p.buildEncodingParameters());
params.push(...p.buildOutputParameters());
}
params.push(`-progress tcp://127.0.0.1:${parameters[0].progressPort}`);
return params;
}
getStreamStartText() {
if (this.isVideo) {
const detail = this.codec === 'copy' ? 'native' : `${this.width}x${this.height}, ${this.fps} fps, ${this.bitrate} kbps`;
return `Starting video stream: ${detail}`;
}
if (this.isAudio) {
const detail = this.codec === 'copy' ? 'native' : `${this.sampleRate} kHz, ${this.bitrate} kbps, codec: ${this.codec}`;
return `Starting audio stream: ${detail}`;
}
return 'Starting unknown stream';
}
}
export class FFmpeg extends EventEmitter {
process;
name;
progress;
parameters;
ffmpegExec = ffmpegPath || 'ffmpeg';
stdin;
stdout;
starttime;
killTimeout;
constructor(name, parameters) {
super();
this.name = name;
if (Array.isArray(parameters)) {
if (parameters.length === 0) {
throw new Error('No ffmpeg parameters found.');
}
this.parameters = parameters;
}
else {
this.parameters = [parameters];
}
if (this.parameters[0].processor) {
this.ffmpegExec = this.parameters[0].processor;
}
}
/**
* Shared initialisation: timestamps the start, creates the progress monitor,
* spawns the ffmpeg process, and wires up stderr logging.
*/
async spawnProcess(processArgs, label, opts) {
this.starttime = Date.now();
this.progress = await FFmpegProgress.create(this.parameters[0].progressPort);
this.progress.on('progress started', this.onProgressStarted.bind(this));
ffmpegLogger.debug(this.name, `${label}: ${this.ffmpegExec} ${processArgs.join(' ')}`);
this.parameters.forEach((p) => ffmpegLogger.info(this.name, p.getStreamStartText()));
const child = spawn(this.ffmpegExec, processArgs.join(' ').split(/\s+/), { env: process.env });
child.stderr.on('data', this.handleStderrData.bind(this));
if (opts?.attachLifecycle) {
child.on('error', this.onProcessError.bind(this));
child.on('exit', this.onProcessExit.bind(this));
}
this.process = child;
this.stdin = child.stdin;
this.stdout = child.stdout;
return child;
}
async start() {
const processArgs = FFmpegParameters.getCombinedArguments(this.parameters);
await this.spawnProcess(processArgs, 'Stream command', { attachLifecycle: true });
}
async getResult(input) {
const processArgs = FFmpegParameters.getCombinedArguments(this.parameters);
const child = await this.spawnProcess(processArgs, 'Process command');
return new Promise((resolve, reject) => {
const killTimeout = setTimeout(() => {
this.stop();
reject('ffmpeg process timed out.');
}, PROCESS_RESULT_TIMEOUT_MS);
child.on('error', (error) => {
reject(error);
this.onProcessError(error);
});
let resultBuffer = Buffer.alloc(0);
child.stdout.on('data', (data) => {
resultBuffer = Buffer.concat([resultBuffer, data]);
});
child.on('exit', () => {
if (killTimeout) {
clearTimeout(killTimeout);
}
if (resultBuffer.length > 0) {
resolve(resultBuffer);
}
else {
reject('Failed to fetch data.');
}
});
if (input) {
child.stdin.end(input);
}
});
}
async startFragmentedMP4Session() {
const port = await pickPort({ type: 'tcp' });
return new Promise((resolve) => {
const server = net.createServer((socket) => {
server.close();
resolve({
socket: socket,
process: this.process,
generator: this.parseFragmentedMP4(socket),
});
});
server.listen(port, async () => {
this.parameters[0].setOutput(`tcp://127.0.0.1:${port}`);
const processArgs = FFmpegParameters.getRecordingArguments(this.parameters);
await this.spawnProcess(processArgs, 'Stream command', { attachLifecycle: true });
});
});
}
async *parseFragmentedMP4(socket) {
while (true) {
const header = await this.readLength(socket, 8);
const length = header.readInt32BE(0) - 8;
const type = header.slice(4).toString();
const data = await this.readLength(socket, length);
yield {
header,
length,
type,
data,
};
}
}
async readLength(socket, length) {
if (length <= 0) {
return Buffer.alloc(0);
}
const value = socket.read(length);
if (value) {
return value;
}
return new Promise((resolve, reject) => {
const readHandler = () => {
const value = socket.read(length);
if (value) {
cleanup();
resolve(value);
}
};
const endHandler = () => {
cleanup();
reject(new Error(`FFMPEG socket closed during read for ${length} bytes!`));
};
const cleanup = () => {
socket.removeListener('readable', readHandler);
socket.removeListener('close', endHandler);
};
if (!socket) {
throw new Error('FFMPEG socket is closed now!');
}
socket.on('readable', readHandler);
socket.on('close', endHandler);
});
}
stop() {
const usesStdIn = this.parameters.some(p => p.usesStdInAsInput());
if (usesStdIn) {
this.process?.stdin.destroy();
this.process?.kill('SIGTERM');
}
else {
this.process?.stdin.write('q' + os.EOL);
}
this.killTimeout = setTimeout(() => {
this.process?.kill('SIGKILL');
}, KILL_GRACE_PERIOD_MS);
}
onProgressStarted() {
this.emit('started');
const runtime = this.starttime ? (Date.now() - this.starttime) / 1000 : undefined;
ffmpegLogger.debug(this.name, `process started. Getting the first response took ${runtime} seconds.`);
}
handleStderrData(chunk) {
const output = chunk.toString();
const isError = output.includes('[panic]') || output.includes('[error]') || output.includes('[fatal]');
if (isError) {
ffmpegLogger.error(this.name, 'ffmpeg log message:\n' + output);
}
else if (this.parameters[0].debug) {
ffmpegLogger.debug(this.name, 'ffmpeg log message:\n' + output);
}
}
onProcessError(error) {
this.emit('error', error);
}
onProcessExit(code, signal) {
this.emit('exit');
if (this.killTimeout) {
clearTimeout(this.killTimeout);
}
const message = 'FFmpeg exited with code: ' + code + ' and signal: ' + signal;
if (this.killTimeout && code === 0) {
ffmpegLogger.info(this.name, message + ' (Expected)');
}
else if (code === null || code === 255) {
if (this.process?.killed) {
ffmpegLogger.info(this.name, message + ' (Forced)');
}
else {
ffmpegLogger.error(this.name, message + ' (Unexpected)');
}
}
else {
this.emit('error', message + ' (Error)');
// ffmpegLogger.error(this.name, message + ' (Error)');
}
}
}
//# sourceMappingURL=ffmpeg.js.map