UNPKG

camera.ui

Version:

NVR like user interface for RTSP capable cameras.

378 lines (308 loc) 9.86 kB
/* eslint-disable unicorn/number-literal-case */ 'use-strict'; import compareVersions from 'compare-versions'; import { createServer } from 'net'; import { once } from 'events'; import readline from 'readline'; import { spawn } from 'child_process'; import LoggerService from '../../../services/logger/logger.service.js'; const findSyncFrame = (streamChunks) => { return streamChunks; }; export const ignoredFfmpegError = (line) => { const mutedErrors = ['no frame', 'decode_slice_header', 'non-existing PPS 0']; return mutedErrors.some((key) => line.includes(key)); }; export const listenServer = async (server) => { // eslint-disable-next-line no-constant-condition while (true) { const port = 10000 + Math.round(Math.random() * 30000); server.listen(port); try { await once(server, 'listening'); return server.address().port; } catch { //ignore } } }; export const readLength = (socket, length) => { if (!socket) { throw new Error('FFMPEG tried reading from closed socket!'); } if (!length) { return Buffer.alloc(0); } const value = socket.read(length); if (value) { return value; } // eslint-disable-next-line no-unused-vars 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); }); }; export const parseFragmentedMP4 = async function* (socket) { if (!socket) { throw new Error('Unexpected state!'); } while (true) { const header = await readLength(socket, 8); const length = header.readInt32BE(0) - 8; const type = header.slice(4).toString(); const data = await readLength(socket, length); yield { header, length, type, data, }; } }; /** * * @url https://github.com/koush/scrypted/blob/fcfdadc9849099134e3f6ee6002fa1203bccdc91/common/src/stream-parser.ts#L173 * (c) koush <https://github.com/koush> * **/ export const createFragmentedMp4Parser = () => { return { container: 'mp4', outputArguments: '[movflags=frag_keyframe+empty_moov+default_base_moof:f=mp4]', async *parse(socket) { const parser = parseFragmentedMP4(socket); let ftyp; let moov; let startStream; for await (const atom of parser) { if (!ftyp) { ftyp = atom; } else if (!moov) { moov = atom; } yield { startStream, chunks: [atom.header, atom.data], type: atom.type, }; if (ftyp && moov && !startStream) { startStream = Buffer.concat([ftyp.header, ftyp.data, moov.header, moov.data]); } } }, findSyncFrame, }; }; /** * * @url https://github.com/koush/scrypted/blob/fcfdadc9849099134e3f6ee6002fa1203bccdc91/common/src/stream-parser.ts#L44 * (c) koush <https://github.com/koush> * **/ export const createLengthParser = (length, verify) => { async function* parse(socket) { let pending = []; let pendingSize = 0; while (true) { const data = socket.read(); if (!data) { await once(socket, 'readable'); continue; } pending.push(data); pendingSize += data.length; if (pendingSize < length) { continue; } const concat = Buffer.concat(pending); verify?.(concat); const remaining = concat.length % length; const left = concat.slice(0, concat.length - remaining); const right = concat.slice(concat.length - remaining); pending = [right]; pendingSize = right.length; yield { chunks: [left], }; } } return parse; }; /** * * @url https://github.com/koush/scrypted/blob/fcfdadc9849099134e3f6ee6002fa1203bccdc91/common/src/stream-parser.ts#L92 * (c) koush <https://github.com/koush> * **/ export const createMpegTsParser = () => { return { container: 'mpegts', outputArguments: '[f=mpegts]', parse: createLengthParser(188, (concat) => { if (concat[0] != 0x47) { throw new Error('Invalid sync byte in mpeg-ts packet. Terminating stream.'); } }), findSyncFrame(streamChunks) { for (let prebufferIndex = 0; prebufferIndex < streamChunks.length; prebufferIndex++) { const streamChunk = streamChunks[prebufferIndex]; for (let chunkIndex = 0; chunkIndex < streamChunk.chunks.length; chunkIndex++) { const chunk = streamChunk.chunks[chunkIndex]; let offset = 0; while (offset + 188 < chunk.length) { const pkt = chunk.subarray(offset, offset + 188); const pid = ((pkt[1] & 0x1f) << 8) | pkt[2]; if ( pid == 256 && // found video stream pkt[3] & 0x20 && pkt[4] > 0 && // have AF pkt[5] & 0x40 ) { return streamChunks.slice(prebufferIndex); } offset += 188; } } } return findSyncFrame(streamChunks); }, }; }; export const startFFMPegFragmetedMP4Session = async ( cameraName, cameraDebug, videoProcessor, ffmpegInput, audioOutputArguments, videoOutputArguments ) => { const { log } = LoggerService; log.debug('Start recording...', cameraName); // eslint-disable-next-line no-async-promise-executor return new Promise(async (resolve) => { const server = createServer((socket) => { server.close(); resolve({ socket, cp, generator: parseFragmentedMP4(socket), }); }); const serverPort = await listenServer(server); const arguments_ = [ '-hide_banner', //'-fflags', //'+genpts+igndts', ...ffmpegInput, '-f', 'mp4', ...videoOutputArguments, ...audioOutputArguments, '-movflags', 'frag_keyframe+empty_moov+default_base_moof', '-max_muxing_queue_size', '1024', //'-vsync', //'cfr', 'tcp://127.0.0.1:' + serverPort, ]; log.debug(`Recording command: ${videoProcessor} ${arguments_.join(' ')}`, cameraName); const cp = spawn(videoProcessor, arguments_, { env: process.env }); const stderr = readline.createInterface({ input: cp.stderr, terminal: false, }); let errors = []; stderr.on('line', (line) => { if (/\[(panic|fatal|error)]/.test(line)) { errors = errors.slice(-5); errors.push(line); log.debug(line, cameraName); } else if (cameraDebug) { log.debug(line, cameraName); } }); cp.on('exit', (code, signal) => { if (code === 1) { errors.unshift(`FFmpeg recording process exited with error! (${signal})`); log.error(errors.join(' - '), cameraName, 'Homebridge'); } else { log.debug('FFmpeg recording process exited (expected)', cameraName); } }); }); }; export const generateInputSource = (videoConfig, source) => { let inputSource = source || videoConfig.source; if (inputSource) { if (videoConfig.readRate && !inputSource.includes('-re')) { inputSource = `-re ${inputSource}`; } if (videoConfig.stimeout > 0 && !inputSource.includes('-stimeout')) { inputSource = `-stimeout ${videoConfig.stimeout * 10000000} ${inputSource}`; } if (videoConfig.maxDelay >= 0 && !inputSource.includes('-max_delay')) { inputSource = `-max_delay ${videoConfig.maxDelay} ${inputSource}`; } if (videoConfig.reorderQueueSize >= 0 && !inputSource.includes('-reorder_queue_size')) { inputSource = `-reorder_queue_size ${videoConfig.reorderQueueSize} ${inputSource}`; } if (videoConfig.probeSize >= 32 && !inputSource.includes('-probesize')) { inputSource = `-probesize ${videoConfig.probeSize} ${inputSource}`; } if (videoConfig.analyzeDuration >= 0 && !inputSource.includes('-analyzeduration')) { inputSource = `-analyzeduration ${videoConfig.analyzeDuration} ${inputSource}`; } if (videoConfig.rtspTransport && !inputSource.includes('-rtsp_transport')) { inputSource = `-rtsp_transport ${videoConfig.rtspTransport} ${inputSource}`; } } return inputSource; }; export const generateVideoConfig = (videoConfig) => { const config = { ...videoConfig }; config.maxWidth = config.maxWidth || 1280; config.maxHeight = config.maxHeight || 720; config.maxFPS = config.maxFPS >= 20 ? videoConfig.maxFPS : 20; config.maxStreams = config.maxStreams >= 1 ? videoConfig.maxStreams : 4; config.maxBitrate = config.maxBitrate || 299; config.vcodec = config.vcodec || 'libx264'; config.acodec = config.acodec || 'libfdk_aac'; config.encoderOptions = config.encoderOptions || ''; config.packetSize = config.packetSize || 1318; return config; }; export const checkDeprecatedFFmpegArguments = (ffmpegVersion, ffmpegArguments) => { if (!ffmpegVersion || !compareVersions.validate(ffmpegVersion)) { return ffmpegArguments; } let ffmpegArgumentsArray = !Array.isArray(ffmpegArguments) ? ffmpegArguments.split(' ') : [...ffmpegArguments]; if (compareVersions.compare(ffmpegVersion, '5.0.0', '>=')) { ffmpegArgumentsArray = ffmpegArgumentsArray.map((argument) => { if (argument === '-stimeout') { argument = '-timeout'; } return argument; }); } return ffmpegArgumentsArray; };