import { spawn } from 'child_process' import utils from '../../lib/utils.js' import pathToFfmpeg from 'ffmpeg-for-homebridge' export class SnapshotStream { constructor(device) { this.mqttCamera = device this.status = 'inactive' this.interval = null this.livesnaps = { active: false, session: false, timeout: 0 } this.keepalive = { active: false, session: false } this.rtsp = { active: false, session: false } this.snapshot = { active: false, session: false } } async start(rtspPublishUrl) { if (!this.mqttCamera.data.snapshot.image) { this.mqttCamera.debug('Snapshot stream failed to start - No available snapshot') this.status = 'failed' this.mqttCamera.publishStreamState() return } try { await this.startSnapshotStream(rtspPublishUrl) } catch { this.mqttCamera.debug('Snapshot stream failed to start - Failed to spawn ffmpeg') this.status = 'failed' this.mqttCamera.publishStreamState() } } async startSnapshotStream(rtspPublishUrl) { this.rtsp.session = spawn(pathToFfmpeg, [ '-f', 'mpegts', '-probesize', '32k', '-analyzeduration', '0', '-fflags', '+discardcorrupt+genpts', '-r', '5', '-ss', '0.2', '-i', 'pipe:', '-c:v', 'copy', '-avioflags', 'direct', '-flags', '+global_header', '-f', 'rtsp', '-rtsp_transport', 'tcp', rtspPublishUrl ]) // Handle process exit this.rtsp.session.on('close', () => { this.mqttCamera.debug('Snapshot stream transcoding session has ended') this.stop() }) // Return a promise that resolves when the process is ready return new Promise((resolve, reject) => { this.rtsp.session.on('spawn', async () => { this.snapshot.session = spawn(pathToFfmpeg, [ '-hide_banner', '-f', 'image2pipe', '-probesize', '32k', '-analyzeduration', '0', '-r', '20', '-i', 'pipe:', '-vf', 'scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2', '-sws_flags', 'lanczos', '-c:v', 'libx264', '-b:v', '3M', '-r', '5', '-g', '1', '-preset', 'ultrafast', '-tune', 'zerolatency', '-avioflags', 'direct', '-f', 'mpegts', 'pipe:1' ]) this.snapshot.session.on('spawn', async () => { this.startSnapshotInterval() this.mqttCamera.debug('Snapshot stream transcoding session has started') this.status = 'active' this.mqttCamera.publishStreamState() resolve() }) this.snapshot.session.stdout.once('data', () => { this.snapshot.session.stdout.pipe(this.rtsp.session.stdin) }) }) this.rtsp.session.on('error', reject) }) } async startLivesnaps(duration) { if (this.livesnaps.active) { return } this.livesnaps.active = true this.mqttCamera.debug('Starting a live snapshot stream for camera') // We need a live stream, but don't want to rely on global keepalive so we start one here this.keepalive.session = spawn(pathToFfmpeg, [ '-i', `rtsp://${this.mqttCamera.rtspCredentials}localhost:8554/${this.mqttCamera.deviceId}_live`, '-map', '0:a:0', '-c:a', 'copy', '-f', 'null', '/dev/null' ]) this.keepalive.session.on('spawn', async () => { this.keepalive.active = true const liveStream = this.mqttCamera.streams.live const liveStreamStartTimeout = Date.now() + 5000 while (!liveStream.altVideoData && Date.now() < liveStreamStartTimeout) { await utils.msleep(50) } if (liveStream.altVideoData) { this.livesnaps.session = spawn(pathToFfmpeg, [ '-hide_banner', '-protocol_whitelist', 'pipe,udp,rtp,fd,file,crypto', '-flags', 'low_delay', '-probesize', '32K', '-analyzeduration', '0', '-fflags', '+genpts', '-r', '20', '-f', 'sdp', '-i', 'pipe:', '-vf', 'scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2', '-sws_flags', 'lanczos', '-c:v', 'libx264', '-b:v', '3M', '-preset', 'ultrafast', '-tune', 'zerolatency', '-r', '5', '-g', '1', '-avioflags', 'direct', '-f', 'mpegts', 'pipe:1' ]) this.livesnaps.session.on('spawn', async () => { liveStream.unbindAltVideoPorts() this.livesnaps.session.stdin.write(liveStream.altVideoData.sdp) this.livesnaps.session.stdin.end() }) this.livesnaps.session.stdout.once('data', () => { this.snapshot.session.stdout.unpipe(this.rtsp.session.stdin) this.livesnaps.session.stdout.pipe(this.rtsp.session.stdin) }) this.livesnaps.session.on('close', async () => { liveStream.bindAltVideoPorts() this.mqttCamera.debug('The live snapshot stream has stopped') this.mqttCamera.updateSnapshot('interval') this.livesnaps.session.stdout.unpipe(this.rtsp.session.stdin) this.snapshot.session.stdout.pipe(this.rtsp.session.stdin) Object.assign(this.livesnaps, { active: false, session: false, image: false }) }) // The livesnap stream will stop after the specified duration const livesnapsTimeout = Date.now() + 5000 + (duration * 1000) while (this.livesnaps.active && (Date.now() < livesnapsTimeout)) { await utils.sleep(1) } } else { this.mqttCamera.debug('The live snapshot stream failed starting the live stream') } if (this.livesnaps.active && this.livesnaps.session) { this.livesnaps.session?.kill() } if (this.keepalive.active && this.keepalive.session) { this.keepalive.session?.kill() } }) this.keepalive.session.on('close', async () => { this.keepalive.active = false this.keepalive.session = false }) } startSnapshotInterval() { this.interval = setInterval(async () => { if (this.status === 'active') { try { this.snapshot.session?.stdin?.write(this.mqttCamera.data.snapshot.image) } catch { this.mqttCamera.debug('Writing image to snapshot stream failed') this.stop() } } else { this.stop() } }, 50) } async stop() { clearInterval(this.interval) this.interval = null this.status = 'inactive' this.mqttCamera.publishStreamState() if (!this.rtsp.session) return const rtspSession = this.rtsp.session this.rtsp.session = false try { rtspSession.stdin.end() await Promise.race([ new Promise((resolve) => { rtspSession.stdin.once('finish', () => { rtspSession.kill() resolve() }) }), new Promise((_, reject) => { setTimeout(() => { reject() }, 2000) }) ]) } catch { rtspSession.kill('SIGKILL') } } }