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],