diff --git a/.gitignore b/.gitignore index 366d8d08..498b968f 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,5 @@ dist # *.DS_Store +/tmp/* +*.code-workspace \ No newline at end of file diff --git a/src/recordingDelegate.ts b/src/recordingDelegate.ts index 30d15401..be996dbf 100644 --- a/src/recordingDelegate.ts +++ b/src/recordingDelegate.ts @@ -101,37 +101,140 @@ export class RecordingDelegate implements CameraRecordingDelegate { return Promise.resolve() } - updateRecordingConfiguration(): Promise { + updateRecordingConfiguration(configuration: CameraRecordingConfiguration | undefined): Promise { this.log.info('Recording configuration updated', this.cameraName) + this.currentRecordingConfiguration = configuration return Promise.resolve() } async *handleRecordingStreamRequest(streamId: number): AsyncGenerator { this.log.info(`Recording stream request received for stream ID: ${streamId}`, this.cameraName) - // Implement the logic to handle the recording stream request here - // For now, just yield an empty RecordingPacket - yield {} as RecordingPacket + + if (!this.currentRecordingConfiguration) { + this.log.error('No recording configuration available', this.cameraName) + return + } + + // Create abort controller for this stream + const abortController = new AbortController() + this.streamAbortControllers.set(streamId, abortController) + + try { + // Use existing handleFragmentsRequests method but track the process + const fragmentGenerator = this.handleFragmentsRequests(this.currentRecordingConfiguration, streamId) + + let fragmentCount = 0 + let totalBytes = 0 + + for await (const fragmentBuffer of fragmentGenerator) { + // Check if stream was aborted + if (abortController.signal.aborted) { + this.log.debug(`Recording stream ${streamId} aborted, stopping generator`, this.cameraName) + break + } + + fragmentCount++ + totalBytes += fragmentBuffer.length + + // Enhanced logging for HKSV debugging + this.log.debug(`HKSV: Yielding fragment #${fragmentCount}, size: ${fragmentBuffer.length}, total: ${totalBytes} bytes`, this.cameraName) + + yield { + data: fragmentBuffer, + isLast: false // We'll handle the last fragment properly when the stream ends + } + } + + // Send final packet to indicate end of stream + this.log.info(`HKSV: Recording stream ${streamId} completed. Total fragments: ${fragmentCount}, total bytes: ${totalBytes}`, this.cameraName) + + } catch (error) { + this.log.error(`Recording stream error: ${error}`, this.cameraName) + // Send error indication + yield { + data: Buffer.alloc(0), + isLast: true + } + } finally { + // Cleanup + this.streamAbortControllers.delete(streamId) + this.log.debug(`Recording stream ${streamId} generator finished`, this.cameraName) + } } closeRecordingStream(streamId: number, reason: HDSProtocolSpecificErrorReason | undefined): void { this.log.info(`Recording stream closed for stream ID: ${streamId}, reason: ${reason}`, this.cameraName) + + // Enhanced reason code diagnostics for HKSV debugging + switch (reason) { + case 0: + this.log.info(`✅ HKSV: Recording ended normally (reason 0)`, this.cameraName) + break + case 1: + this.log.warn(`⚠️ HKSV: Recording ended due to generic error (reason 1)`, this.cameraName) + break + case 2: + this.log.warn(`⚠️ HKSV: Recording ended due to network issues (reason 2)`, this.cameraName) + break + case 3: + this.log.warn(`⚠️ HKSV: Recording ended due to insufficient resources (reason 3)`, this.cameraName) + break + case 4: + this.log.warn(`⚠️ HKSV: Recording ended due to HomeKit busy (reason 4)`, this.cameraName) + break + case 5: + this.log.warn(`⚠️ HKSV: Recording ended due to insufficient buffer space (reason 5)`, this.cameraName) + break + case 6: + this.log.warn(`❌ HKSV: Recording ended due to STREAM FORMAT INCOMPATIBILITY (reason 6) - Check H.264 parameters!`, this.cameraName) + break + case 7: + this.log.warn(`⚠️ HKSV: Recording ended due to maximum recording time exceeded (reason 7)`, this.cameraName) + break + case 8: + this.log.warn(`⚠️ HKSV: Recording ended due to HomeKit storage full (reason 8)`, this.cameraName) + break + default: + this.log.warn(`❓ HKSV: Unknown reason ${reason}`, this.cameraName) + } + + // Abort the stream generator + const abortController = this.streamAbortControllers.get(streamId) + if (abortController) { + abortController.abort() + this.streamAbortControllers.delete(streamId) + } + + // Kill any active FFmpeg processes for this stream + const process = this.activeFFmpegProcesses.get(streamId) + if (process && !process.killed) { + this.log.debug(`Terminating FFmpeg process for stream ${streamId}`, this.cameraName) + process.kill('SIGTERM') + this.activeFFmpegProcesses.delete(streamId) + } } private readonly hap: HAP private readonly log: Logger private readonly cameraName: string - private readonly videoConfig?: VideoConfig + private readonly videoConfig: VideoConfig private process!: ChildProcess private readonly videoProcessor: string readonly controller?: CameraController private preBufferSession?: Mp4Session private preBuffer?: PreBuffer + + // Add fields for recording configuration and process management + private currentRecordingConfiguration?: CameraRecordingConfiguration + private activeFFmpegProcesses = new Map() + private streamAbortControllers = new Map() constructor(log: Logger, cameraName: string, videoConfig: VideoConfig, api: API, hap: HAP, videoProcessor?: string) { this.log = log this.hap = hap this.cameraName = cameraName + this.videoConfig = videoConfig this.videoProcessor = videoProcessor || ffmpegPathString || 'ffmpeg' api.on(APIEvent.SHUTDOWN, () => { @@ -139,6 +242,16 @@ export class RecordingDelegate implements CameraRecordingDelegate { this.preBufferSession.process?.kill() this.preBufferSession.server?.close() } + + // Cleanup active streams on shutdown + this.activeFFmpegProcesses.forEach((process, streamId) => { + if (!process.killed) { + this.log.debug(`Shutdown: Terminating FFmpeg process for stream ${streamId}`, this.cameraName) + process.kill('SIGTERM') + } + }) + this.activeFFmpegProcesses.clear() + this.streamAbortControllers.clear() }) } @@ -155,19 +268,19 @@ export class RecordingDelegate implements CameraRecordingDelegate { } } - async * handleFragmentsRequests(configuration: CameraRecordingConfiguration): AsyncGenerator { - this.log.debug('video fragments requested', this.cameraName) - - const iframeIntervalSeconds = 4 - + async * handleFragmentsRequests(configuration: CameraRecordingConfiguration, streamId: number): AsyncGenerator { + let moofBuffer: Buffer | null = null + let fragmentCount = 0 + + this.log.debug('HKSV: Starting recording request', this.cameraName) const audioArgs: Array = [ '-acodec', - 'libfdk_aac', + 'aac', ...(configuration.audioCodec.type === AudioRecordingCodecType.AAC_LC ? ['-profile:a', 'aac_low'] : ['-profile:a', 'aac_eld']), - '-ar', - `${configuration.audioCodec.samplerate}k`, + '-ar', '32000', + //`${configuration.audioCodec.samplerate * 1000}`, // i see 3k here before, 3000 also will not work '-b:a', `${configuration.audioCodec.bitrate}k`, '-ac', @@ -182,134 +295,197 @@ export class RecordingDelegate implements CameraRecordingDelegate { ? '4.0' : configuration.videoCodec.parameters.level === H264Level.LEVEL3_2 ? '3.2' : '3.1' + // Clean H.264 parameters for HKSV compatibility const videoArgs: Array = [ - '-an', - '-sn', - '-dn', - '-codec:v', - 'libx264', - '-pix_fmt', - 'yuv420p', - - '-profile:v', - profile, - '-level:v', - level, - '-b:v', - `${configuration.videoCodec.parameters.bitRate}k`, - '-force_key_frames', - `expr:eq(t,n_forced*${iframeIntervalSeconds})`, - '-r', - configuration.videoCodec.resolution[2].toString(), + '-an', '-sn', '-dn', // Disable audio/subtitles/data (audio handled separately) + '-vcodec', 'libx264', + '-pix_fmt', 'yuv420p', + '-profile:v', profile, // 'baseline' tested + '-level:v', level, // '3.1' tested + '-preset', 'ultrafast', + '-tune', 'zerolatency', + '-b:v', '600k', + '-maxrate', '700k', + '-bufsize', '1400k', + '-g', '30', + '-keyint_min', '15', + '-sc_threshold', '0', + '-force_key_frames', 'expr:gte(t,n_forced*1)' ] - const ffmpegInput: Array = [] + if (configuration?.audioCodec) { + // Remove the '-an' flag to enable audio + const anIndex = videoArgs.indexOf('-an') + if (anIndex !== -1) { + videoArgs.splice(anIndex, 1, ...audioArgs) + } + } + // Get input configuration + const ffmpegInput: Array = [] if (this.videoConfig?.prebuffer) { - const input: Array = this.preBuffer ? await this.preBuffer.getVideo(configuration.mediaContainerConfiguration.fragmentLength ?? PREBUFFER_LENGTH) : [] + const input: Array = this.preBuffer ? + await this.preBuffer.getVideo(configuration.mediaContainerConfiguration.fragmentLength ?? PREBUFFER_LENGTH) : [] ffmpegInput.push(...input) } else { - ffmpegInput.push(...(this.videoConfig?.source ?? '').split(' ')) + if (!this.videoConfig?.source) { + throw new Error('No video source configured') + } + ffmpegInput.push(...this.videoConfig.source.trim().split(/\s+/).filter(arg => arg.length > 0)) + } + + if (ffmpegInput.length === 0) { + throw new Error('No video source configured for recording') } - this.log.debug('Start recording...', this.cameraName) - - const session = await this.startFFMPegFragmetedMP4Session(this.videoProcessor, ffmpegInput, audioArgs, videoArgs) - this.log.info('Recording started', this.cameraName) + // Start FFmpeg session + const session = await this.startFFMPegFragmetedMP4Session(this.videoProcessor, ffmpegInput, videoArgs) + const { cp, generator } = session + + // Track process for cleanup + this.activeFFmpegProcesses.set(streamId, cp) - const { socket, cp, generator } = session let pending: Array = [] - let filebuffer: Buffer = Buffer.alloc(0) + let isFirstFragment = true + try { for await (const box of generator) { - const { header, type, length, data } = box - + const { header, type, data } = box pending.push(header, data) - if (type === 'moov' || type === 'mdat') { - const fragment = Buffer.concat(pending) - filebuffer = Buffer.concat([filebuffer, Buffer.concat(pending)]) - pending = [] - yield fragment + if (isFirstFragment) { + if (type === 'moov') { + const fragment = Buffer.concat(pending) + pending = [] + isFirstFragment = false + this.log.debug(`HKSV: Sending initialization segment, size: ${fragment.length}`, this.cameraName) + yield fragment + } + } else { + if (type === 'moof') { + moofBuffer = Buffer.concat([header, data]) + } else if (type === 'mdat' && moofBuffer) { + const fragment = Buffer.concat([moofBuffer, header, data]) + fragmentCount++ + this.log.debug(`HKSV: Fragment ${fragmentCount}, size: ${fragment.length}`, this.cameraName) + yield fragment + moofBuffer = null + } } - this.log.debug(`mp4 box type ${type} and lenght: ${length}`, this.cameraName) } } catch (e) { - this.log.info(`Recoding completed. ${e}`, this.cameraName) - /* - const homedir = require('os').homedir(); - const path = require('path'); - const writeStream = fs.createWriteStream(homedir+path.sep+Date.now()+'_video.mp4'); - writeStream.write(filebuffer); - writeStream.end(); - */ + this.log.debug(`Recording completed: ${e}`, this.cameraName) } finally { - socket.destroy() - cp.kill() - // this.server.close; + // Fast cleanup + if (cp && !cp.killed) { + cp.kill('SIGTERM') + setTimeout(() => cp.killed || cp.kill('SIGKILL'), 2000) + } + this.activeFFmpegProcesses.delete(streamId) } } - async startFFMPegFragmetedMP4Session(ffmpegPath: string, ffmpegInput: Array, audioOutputArgs: Array, videoOutputArgs: Array): Promise { - return new Promise((resolve) => { - const server = createServer((socket) => { - server.close() - async function* generator(): AsyncGenerator { - while (true) { - const header = await readLength(socket, 8) + private startFFMPegFragmetedMP4Session(ffmpegPath: string, ffmpegInput: string[], videoOutputArgs: string[]): Promise<{ + generator: AsyncIterable<{ header: Buffer; length: number; type: string; data: Buffer }>; + cp: import('node:child_process').ChildProcess; + }> { + return new Promise((resolve, reject) => { + const args: string[] = ['-hide_banner', ...ffmpegInput] + + // Add dummy audio for HKSV compatibility if needed + if (this.videoConfig?.audio === false) { + args.push( + '-f', 'lavfi', '-i', 'anullsrc=cl=mono:r=32000', + ) + } + + args.push( + '-f', 'mp4', + ...videoOutputArgs, + '-movflags', 'frag_keyframe+empty_moov+default_base_moof+omit_tfhd_offset', + 'pipe:1' + ) + + // Terminate any previous process quickly + if (this.process && !this.process.killed) { + this.process.kill('SIGKILL') + } + + this.process = spawn(ffmpegPath, args, { + env, + stdio: ['pipe', 'pipe', 'pipe'] + }) + + const cp = this.process + let processKilledIntentionally = false + + // Optimized MP4 generator + async function* generator() { + if (!cp.stdout) throw new Error('FFmpeg stdout unavailable') + + while (true) { + try { + const header = await readLength(cp.stdout, 8) const length = header.readInt32BE(0) - 8 const type = header.slice(4).toString() - const data = await readLength(socket, length) - - yield { - header, - length, - type, - data, + + if (length < 0 || length > 50 * 1024 * 1024) { // Max 50MB + throw new Error(`Invalid MP4 box: ${length}B for ${type}`) } + + const data = await readLength(cp.stdout, length) + yield { header, length, type, data } + } catch (error) { + if (!processKilledIntentionally) throw error + break } } - const cp = this.process - resolve({ - socket, - cp, - generator: generator(), + } + + // Minimal stderr handling + if (cp.stderr) { + cp.stderr.on('data', (data) => { + const output = data.toString() + if (output.includes('error') || output.includes('Error')) { + this.log.error(`FFmpeg: ${output.trim()}`, this.cameraName) + } }) + } + + cp.on('spawn', () => { + resolve({ generator: generator(), cp }) }) - listenServer(server, this.log).then((serverPort) => { - const args: Array = [] - - args.push(...ffmpegInput) - - // args.push(...audioOutputArgs); - - args.push('-f', 'mp4') - args.push(...videoOutputArgs) - args.push('-fflags', '+genpts', '-reset_timestamps', '1') - args.push( - '-movflags', - 'frag_keyframe+empty_moov+default_base_moof', - `tcp://127.0.0.1:${serverPort}`, - ) - - this.log.debug(`${ffmpegPath} ${args.join(' ')}`, this.cameraName) - - const debug = false - - const stdioValue = debug ? 'pipe' : 'ignore' - this.process = spawn(ffmpegPath, args, { env, stdio: stdioValue }) - const cp = this.process - - if (debug) { - if (cp.stdout) { - cp.stdout.on('data', (data: Buffer) => this.log.debug(data.toString(), this.cameraName)) - } - if (cp.stderr) { - cp.stderr.on('data', (data: Buffer) => this.log.debug(data.toString(), this.cameraName)) - } + cp.on('error', reject) + + cp.on('exit', (code, signal) => { + if (code !== 0 && !processKilledIntentionally && code !== 255) { + this.log.warn(`FFmpeg exited with code ${code}`, this.cameraName) } + + // Enhanced process cleanup and error handling + cp.on('exit', (code, signal) => { + this.log.debug(`DEBUG: FFmpeg process ${cp.pid} exited with code ${code}, signal ${signal}`, this.cameraName) + if (code !== 0 && code !== null) { + this.log.warn(`HKSV: FFmpeg exited with non-zero code ${code}, this may indicate stream issues`, this.cameraName) + } + }) + + cp.on('error', (error) => { + this.log.error(`DEBUG: FFmpeg process error: ${error}`, this.cameraName) + }) }) + + // Fast cleanup + const cleanup = () => { + processKilledIntentionally = true + if (cp && !cp.killed) { + cp.kill('SIGTERM') + setTimeout(() => cp.killed || cp.kill('SIGKILL'), 2000) + } + } + + ;(cp as any).cleanup = cleanup }) } } diff --git a/src/streamingDelegate.ts b/src/streamingDelegate.ts index 91b00733..44fe9888 100644 --- a/src/streamingDelegate.ts +++ b/src/streamingDelegate.ts @@ -114,7 +114,7 @@ export class StreamingDelegate implements CameraStreamingDelegate { ], }, }, - recording: /*! this.recording ? undefined : */ { + recording: !this.recording ? undefined : { options: { prebufferLength: PREBUFFER_LENGTH, overrideEventTriggerOptions: [hap.EventTriggerOption.MOTION, hap.EventTriggerOption.DOORBELL], pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy