@@ -101,44 +101,124 @@ export class RecordingDelegate implements CameraRecordingDelegate {
101
101
return Promise . resolve ( )
102
102
}
103
103
104
- updateRecordingConfiguration ( ) : Promise < void > {
104
+ updateRecordingConfiguration ( configuration : CameraRecordingConfiguration | undefined ) : Promise < void > {
105
105
this . log . info ( 'Recording configuration updated' , this . cameraName )
106
+ this . currentRecordingConfiguration = configuration
106
107
return Promise . resolve ( )
107
108
}
108
109
109
110
async * handleRecordingStreamRequest ( streamId : number ) : AsyncGenerator < RecordingPacket , any , any > {
110
111
this . log . info ( `Recording stream request received for stream ID: ${ streamId } ` , this . cameraName )
111
- // Implement the logic to handle the recording stream request here
112
- // For now, just yield an empty RecordingPacket
113
- yield { } as RecordingPacket
112
+
113
+ if ( ! this . currentRecordingConfiguration ) {
114
+ this . log . error ( 'No recording configuration available' , this . cameraName )
115
+ return
116
+ }
117
+
118
+ // Create abort controller for this stream
119
+ const abortController = new AbortController ( )
120
+ this . streamAbortControllers . set ( streamId , abortController )
121
+
122
+ try {
123
+ // Use existing handleFragmentsRequests method but track the process
124
+ const fragmentGenerator = this . handleFragmentsRequests ( this . currentRecordingConfiguration , streamId )
125
+
126
+ let fragmentCount = 0
127
+ let totalBytes = 0
128
+
129
+ for await ( const fragmentBuffer of fragmentGenerator ) {
130
+ // Check if stream was aborted
131
+ if ( abortController . signal . aborted ) {
132
+ this . log . debug ( `Recording stream ${ streamId } aborted, stopping generator` , this . cameraName )
133
+ break
134
+ }
135
+
136
+ fragmentCount ++
137
+ totalBytes += fragmentBuffer . length
138
+
139
+ // Enhanced logging for HKSV debugging
140
+ this . log . debug ( `HKSV: Yielding fragment #${ fragmentCount } , size: ${ fragmentBuffer . length } , total: ${ totalBytes } bytes` , this . cameraName )
141
+
142
+ yield {
143
+ data : fragmentBuffer ,
144
+ isLast : false // We'll handle the last fragment properly when the stream ends
145
+ }
146
+ }
147
+
148
+ // Send final packet to indicate end of stream
149
+ this . log . info ( `HKSV: Recording stream ${ streamId } completed. Total fragments: ${ fragmentCount } , total bytes: ${ totalBytes } ` , this . cameraName )
150
+
151
+ } catch ( error ) {
152
+ this . log . error ( `Recording stream error: ${ error } ` , this . cameraName )
153
+ // Send error indication
154
+ yield {
155
+ data : Buffer . alloc ( 0 ) ,
156
+ isLast : true
157
+ }
158
+ } finally {
159
+ // Cleanup
160
+ this . streamAbortControllers . delete ( streamId )
161
+ this . log . debug ( `Recording stream ${ streamId } generator finished` , this . cameraName )
162
+ }
114
163
}
115
164
116
165
closeRecordingStream ( streamId : number , reason : HDSProtocolSpecificErrorReason | undefined ) : void {
117
166
this . log . info ( `Recording stream closed for stream ID: ${ streamId } , reason: ${ reason } ` , this . cameraName )
167
+
168
+ // Abort the stream generator
169
+ const abortController = this . streamAbortControllers . get ( streamId )
170
+ if ( abortController ) {
171
+ abortController . abort ( )
172
+ this . streamAbortControllers . delete ( streamId )
173
+ }
174
+
175
+ // Kill any active FFmpeg processes for this stream
176
+ const process = this . activeFFmpegProcesses . get ( streamId )
177
+ if ( process && ! process . killed ) {
178
+ this . log . debug ( `Terminating FFmpeg process for stream ${ streamId } ` , this . cameraName )
179
+ process . kill ( 'SIGTERM' )
180
+ this . activeFFmpegProcesses . delete ( streamId )
181
+ }
118
182
}
119
183
120
184
private readonly hap : HAP
121
185
private readonly log : Logger
122
186
private readonly cameraName : string
123
- private readonly videoConfig ? : VideoConfig
187
+ private readonly videoConfig : VideoConfig
124
188
private process ! : ChildProcess
125
189
126
190
private readonly videoProcessor : string
127
191
readonly controller ?: CameraController
128
192
private preBufferSession ?: Mp4Session
129
193
private preBuffer ?: PreBuffer
194
+
195
+ // Add fields for recording configuration and process management
196
+ private currentRecordingConfiguration ?: CameraRecordingConfiguration
197
+ private activeFFmpegProcesses = new Map < number , ChildProcess > ( )
198
+ private streamAbortControllers = new Map < number , AbortController > ( )
130
199
131
200
constructor ( log : Logger , cameraName : string , videoConfig : VideoConfig , api : API , hap : HAP , videoProcessor ?: string ) {
132
201
this . log = log
133
202
this . hap = hap
134
203
this . cameraName = cameraName
204
+ this . videoConfig = videoConfig
135
205
this . videoProcessor = videoProcessor || ffmpegPathString || 'ffmpeg'
136
206
137
207
api . on ( APIEvent . SHUTDOWN , ( ) => {
138
208
if ( this . preBufferSession ) {
139
209
this . preBufferSession . process ?. kill ( )
140
210
this . preBufferSession . server ?. close ( )
141
211
}
212
+
213
+ // Cleanup active streams on shutdown
214
+ this . activeFFmpegProcesses . forEach ( ( process , streamId ) => {
215
+ if ( ! process . killed ) {
216
+ this . log . debug ( `Shutdown: Terminating FFmpeg process for stream ${ streamId } ` , this . cameraName )
217
+ process . kill ( 'SIGTERM' )
218
+ }
219
+ } )
220
+ this . activeFFmpegProcesses . clear ( )
221
+ this . streamAbortControllers . clear ( )
142
222
} )
143
223
}
144
224
@@ -155,8 +235,9 @@ export class RecordingDelegate implements CameraRecordingDelegate {
155
235
}
156
236
}
157
237
158
- async * handleFragmentsRequests ( configuration : CameraRecordingConfiguration ) : AsyncGenerator < Buffer , void , unknown > {
238
+ async * handleFragmentsRequests ( configuration : CameraRecordingConfiguration , streamId : number ) : AsyncGenerator < Buffer , void , unknown > {
159
239
this . log . debug ( 'video fragments requested' , this . cameraName )
240
+ this . log . debug ( `DEBUG: handleFragmentsRequests called for stream ${ streamId } ` , this . cameraName )
160
241
161
242
const iframeIntervalSeconds = 4
162
243
@@ -174,6 +255,7 @@ export class RecordingDelegate implements CameraRecordingDelegate {
174
255
`${ configuration . audioCodec . audioChannels } ` ,
175
256
]
176
257
258
+ // Use HomeKit provided codec parameters instead of hardcoded values
177
259
const profile = configuration . videoCodec . parameters . profile === H264Profile . HIGH
178
260
? 'high'
179
261
: configuration . videoCodec . parameters . profile === H264Profile . MAIN ? 'main' : 'baseline'
@@ -183,26 +265,43 @@ export class RecordingDelegate implements CameraRecordingDelegate {
183
265
: configuration . videoCodec . parameters . level === H264Level . LEVEL3_2 ? '3.2' : '3.1'
184
266
185
267
const videoArgs : Array < string > = [
186
- '-an' ,
268
+ '-an' , // Will be enabled later if audio is configured
187
269
'-sn' ,
188
270
'-dn' ,
189
271
'-codec:v' ,
190
272
'libx264' ,
191
273
'-pix_fmt' ,
192
274
'yuv420p' ,
193
-
194
275
'-profile:v' ,
195
- profile ,
276
+ profile , // Use HomeKit provided profile
196
277
'-level:v' ,
197
- level ,
278
+ level , // Use HomeKit provided level
279
+ '-vf' , 'scale=\'min(1280,iw)\':\'min(720,ih)\':force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2' ,
198
280
'-b:v' ,
199
- `${ configuration . videoCodec . parameters . bitRate } k` ,
281
+ '800k' ,
282
+ '-maxrate' ,
283
+ '1000k' ,
284
+ '-bufsize' ,
285
+ '1000k' ,
200
286
'-force_key_frames' ,
201
- `expr:eq(t,n_forced*${ iframeIntervalSeconds } )` ,
202
- '-r' ,
203
- configuration . videoCodec . resolution [ 2 ] . toString ( ) ,
287
+ 'expr:gte(t,0)' ,
288
+ '-tune' ,
289
+ 'zerolatency' ,
290
+ '-preset' ,
291
+ 'ultrafast' ,
292
+ '-x264opts' ,
293
+ 'no-scenecut:ref=1:bframes=0:cabac=0:no-deblock:intra-refresh=1' ,
204
294
]
205
295
296
+ // Enable audio if recording audio is active
297
+ if ( this . currentRecordingConfiguration ?. audioCodec ) {
298
+ // Remove the '-an' flag to enable audio
299
+ const anIndex = videoArgs . indexOf ( '-an' )
300
+ if ( anIndex !== - 1 ) {
301
+ videoArgs . splice ( anIndex , 1 )
302
+ }
303
+ }
304
+
206
305
const ffmpegInput : Array < string > = [ ]
207
306
208
307
if ( this . videoConfig ?. prebuffer ) {
@@ -218,24 +317,48 @@ export class RecordingDelegate implements CameraRecordingDelegate {
218
317
this . log . info ( 'Recording started' , this . cameraName )
219
318
220
319
const { socket, cp, generator } = session
320
+
321
+ // Track the FFmpeg process for this stream
322
+ this . activeFFmpegProcesses . set ( streamId , cp )
323
+
221
324
let pending : Array < Buffer > = [ ]
222
325
let filebuffer : Buffer = Buffer . alloc ( 0 )
326
+ let isFirstFragment = true
327
+
223
328
try {
224
329
for await ( const box of generator ) {
225
330
const { header, type, length, data } = box
226
331
227
332
pending . push ( header , data )
228
333
229
- if ( type === 'moov' || type === 'mdat' ) {
230
- const fragment = Buffer . concat ( pending )
231
- filebuffer = Buffer . concat ( [ filebuffer , Buffer . concat ( pending ) ] )
232
- pending = [ ]
233
- yield fragment
334
+ // HKSV requires specific MP4 structure:
335
+ // 1. First packet: ftyp + moov (initialization data)
336
+ // 2. Subsequent packets: moof + mdat (media fragments)
337
+ if ( isFirstFragment ) {
338
+ // For initialization segment, wait for both ftyp and moov
339
+ if ( type === 'moov' ) {
340
+ const fragment = Buffer . concat ( pending )
341
+ filebuffer = Buffer . concat ( [ filebuffer , fragment ] )
342
+ pending = [ ]
343
+ isFirstFragment = false
344
+ this . log . debug ( `HKSV: Sending initialization segment (ftyp+moov), size: ${ fragment . length } ` , this . cameraName )
345
+ yield fragment
346
+ }
347
+ } else {
348
+ // For media segments, send moof+mdat pairs
349
+ if ( type === 'mdat' ) {
350
+ const fragment = Buffer . concat ( pending )
351
+ filebuffer = Buffer . concat ( [ filebuffer , fragment ] )
352
+ pending = [ ]
353
+ this . log . debug ( `HKSV: Sending media fragment (moof+mdat), size: ${ fragment . length } ` , this . cameraName )
354
+ yield fragment
355
+ }
234
356
}
235
- this . log . debug ( `mp4 box type ${ type } and lenght: ${ length } ` , this . cameraName )
357
+
358
+ this . log . debug ( `mp4 box type ${ type } and length: ${ length } ` , this . cameraName )
236
359
}
237
360
} catch ( e ) {
238
- this . log . info ( `Recoding completed. ${ e } ` , this . cameraName )
361
+ this . log . info ( `Recording completed. ${ e } ` , this . cameraName )
239
362
/*
240
363
const homedir = require('os').homedir();
241
364
const path = require('path');
@@ -246,6 +369,8 @@ export class RecordingDelegate implements CameraRecordingDelegate {
246
369
} finally {
247
370
socket . destroy ( )
248
371
cp . kill ( )
372
+ // Remove from active processes tracking
373
+ this . activeFFmpegProcesses . delete ( streamId )
249
374
// this.server.close;
250
375
}
251
376
}
@@ -282,33 +407,94 @@ export class RecordingDelegate implements CameraRecordingDelegate {
282
407
283
408
args . push ( ...ffmpegInput )
284
409
285
- // args.push(...audioOutputArgs);
410
+ // Include audio args if recording audio is active
411
+ if ( this . currentRecordingConfiguration ?. audioCodec ) {
412
+ args . push ( ...audioOutputArgs )
413
+ }
286
414
287
415
args . push ( '-f' , 'mp4' )
288
416
args . push ( ...videoOutputArgs )
289
- args . push ( '-fflags' , '+genpts' , '-reset_timestamps' , '1' )
417
+
418
+ // Enhanced HKSV-specific flags for better compatibility
419
+ args . push ( '-err_detect' , 'ignore_err' )
420
+ args . push ( '-fflags' , '+genpts+igndts+ignidx' )
421
+ args . push ( '-reset_timestamps' , '1' )
422
+ args . push ( '-max_delay' , '5000000' )
423
+
424
+ // HKSV requires specific fragmentation settings
290
425
args . push (
291
426
'-movflags' ,
292
- 'frag_keyframe+empty_moov+default_base_moof' ,
427
+ 'frag_keyframe+empty_moov+default_base_moof+skip_sidx+skip_trailer+separate_moof ' ,
293
428
`tcp://127.0.0.1:${ serverPort } ` ,
294
429
)
295
430
296
431
this . log . debug ( `${ ffmpegPath } ${ args . join ( ' ' ) } ` , this . cameraName )
297
432
298
- const debug = false
433
+ // Enhanced debugging and logging for HomeKit Secure Video recording
434
+ this . log . debug ( `DEBUG: startFFMPegFragmetedMP4Session called` , this . cameraName )
435
+ this . log . debug ( `DEBUG: Video source: "${ ffmpegInput . join ( ' ' ) } "` , this . cameraName )
436
+ this . log . debug ( `DEBUG: FFmpeg input args: ${ JSON . stringify ( ffmpegInput ) } ` , this . cameraName )
437
+ this . log . debug ( `DEBUG: Audio enabled: ${ ! ! this . currentRecordingConfiguration ?. audioCodec } ` , this . cameraName )
438
+ this . log . debug ( `DEBUG: Creating server` , this . cameraName )
439
+ this . log . debug ( `DEBUG: Server listening on port ${ serverPort } ` , this . cameraName )
440
+ this . log . debug ( `DEBUG: Complete FFmpeg command: ${ ffmpegPath } ${ args . join ( ' ' ) } ` , this . cameraName )
441
+ this . log . debug ( `DEBUG: Starting FFmpeg` , this . cameraName )
442
+
443
+ const debug = true // Enable debug for HKSV troubleshooting
299
444
300
445
const stdioValue = debug ? 'pipe' : 'ignore'
301
446
this . process = spawn ( ffmpegPath , args , { env, stdio : stdioValue } )
302
447
const cp = this . process
303
448
449
+ this . log . debug ( `DEBUG: FFmpeg started with PID ${ cp . pid } ` , this . cameraName )
450
+
304
451
if ( debug ) {
452
+ let frameCount = 0
453
+ let lastLogTime = Date . now ( )
454
+ const logInterval = 5000 // Log every 5 seconds
455
+
305
456
if ( cp . stdout ) {
306
- cp . stdout . on ( 'data' , ( data : Buffer ) => this . log . debug ( data . toString ( ) , this . cameraName ) )
457
+ cp . stdout . on ( 'data' , ( data : Buffer ) => {
458
+ const output = data . toString ( )
459
+ this . log . debug ( `FFmpeg stdout: ${ output } ` , this . cameraName )
460
+ } )
307
461
}
308
462
if ( cp . stderr ) {
309
- cp . stderr . on ( 'data' , ( data : Buffer ) => this . log . debug ( data . toString ( ) , this . cameraName ) )
463
+ cp . stderr . on ( 'data' , ( data : Buffer ) => {
464
+ const output = data . toString ( )
465
+
466
+ // Count frames for progress tracking
467
+ const frameMatch = output . match ( / f r a m e = \s * ( \d + ) / )
468
+ if ( frameMatch ) {
469
+ frameCount = parseInt ( frameMatch [ 1 ] )
470
+ const now = Date . now ( )
471
+ if ( now - lastLogTime >= logInterval ) {
472
+ this . log . info ( `Recording progress: ${ frameCount } frames processed` , this . cameraName )
473
+ lastLogTime = now
474
+ }
475
+ }
476
+
477
+ // Check for HKSV specific errors
478
+ if ( output . includes ( 'invalid NAL unit size' ) || output . includes ( 'decode_slice_header error' ) ) {
479
+ this . log . warn ( `HKSV: Potential stream compatibility issue detected: ${ output . trim ( ) } ` , this . cameraName )
480
+ }
481
+
482
+ this . log . debug ( `FFmpeg stderr: ${ output } ` , this . cameraName )
483
+ } )
310
484
}
311
485
}
486
+
487
+ // Enhanced process cleanup and error handling
488
+ cp . on ( 'exit' , ( code , signal ) => {
489
+ this . log . debug ( `DEBUG: FFmpeg process ${ cp . pid } exited with code ${ code } , signal ${ signal } ` , this . cameraName )
490
+ if ( code !== 0 && code !== null ) {
491
+ this . log . warn ( `HKSV: FFmpeg exited with non-zero code ${ code } , this may indicate stream issues` , this . cameraName )
492
+ }
493
+ } )
494
+
495
+ cp . on ( 'error' , ( error ) => {
496
+ this . log . error ( `DEBUG: FFmpeg process error: ${ error } ` , this . cameraName )
497
+ } )
312
498
} )
313
499
} )
314
500
}
0 commit comments