Skip to content

Commit 8e4c838

Browse files
Resolve HKSV recording issues (#1496)
2 parents 2455ff2 + dd2cfdc commit 8e4c838

File tree

2 files changed

+214
-28
lines changed

2 files changed

+214
-28
lines changed

src/recordingDelegate.ts

Lines changed: 213 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -101,44 +101,124 @@ export class RecordingDelegate implements CameraRecordingDelegate {
101101
return Promise.resolve()
102102
}
103103

104-
updateRecordingConfiguration(): Promise<void> {
104+
updateRecordingConfiguration(configuration: CameraRecordingConfiguration | undefined): Promise<void> {
105105
this.log.info('Recording configuration updated', this.cameraName)
106+
this.currentRecordingConfiguration = configuration
106107
return Promise.resolve()
107108
}
108109

109110
async *handleRecordingStreamRequest(streamId: number): AsyncGenerator<RecordingPacket, any, any> {
110111
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+
}
114163
}
115164

116165
closeRecordingStream(streamId: number, reason: HDSProtocolSpecificErrorReason | undefined): void {
117166
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+
}
118182
}
119183

120184
private readonly hap: HAP
121185
private readonly log: Logger
122186
private readonly cameraName: string
123-
private readonly videoConfig?: VideoConfig
187+
private readonly videoConfig: VideoConfig
124188
private process!: ChildProcess
125189

126190
private readonly videoProcessor: string
127191
readonly controller?: CameraController
128192
private preBufferSession?: Mp4Session
129193
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>()
130199

131200
constructor(log: Logger, cameraName: string, videoConfig: VideoConfig, api: API, hap: HAP, videoProcessor?: string) {
132201
this.log = log
133202
this.hap = hap
134203
this.cameraName = cameraName
204+
this.videoConfig = videoConfig
135205
this.videoProcessor = videoProcessor || ffmpegPathString || 'ffmpeg'
136206

137207
api.on(APIEvent.SHUTDOWN, () => {
138208
if (this.preBufferSession) {
139209
this.preBufferSession.process?.kill()
140210
this.preBufferSession.server?.close()
141211
}
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()
142222
})
143223
}
144224

@@ -155,8 +235,9 @@ export class RecordingDelegate implements CameraRecordingDelegate {
155235
}
156236
}
157237

158-
async * handleFragmentsRequests(configuration: CameraRecordingConfiguration): AsyncGenerator<Buffer, void, unknown> {
238+
async * handleFragmentsRequests(configuration: CameraRecordingConfiguration, streamId: number): AsyncGenerator<Buffer, void, unknown> {
159239
this.log.debug('video fragments requested', this.cameraName)
240+
this.log.debug(`DEBUG: handleFragmentsRequests called for stream ${streamId}`, this.cameraName)
160241

161242
const iframeIntervalSeconds = 4
162243

@@ -174,6 +255,7 @@ export class RecordingDelegate implements CameraRecordingDelegate {
174255
`${configuration.audioCodec.audioChannels}`,
175256
]
176257

258+
// Use HomeKit provided codec parameters instead of hardcoded values
177259
const profile = configuration.videoCodec.parameters.profile === H264Profile.HIGH
178260
? 'high'
179261
: configuration.videoCodec.parameters.profile === H264Profile.MAIN ? 'main' : 'baseline'
@@ -183,26 +265,43 @@ export class RecordingDelegate implements CameraRecordingDelegate {
183265
: configuration.videoCodec.parameters.level === H264Level.LEVEL3_2 ? '3.2' : '3.1'
184266

185267
const videoArgs: Array<string> = [
186-
'-an',
268+
'-an', // Will be enabled later if audio is configured
187269
'-sn',
188270
'-dn',
189271
'-codec:v',
190272
'libx264',
191273
'-pix_fmt',
192274
'yuv420p',
193-
194275
'-profile:v',
195-
profile,
276+
profile, // Use HomeKit provided profile
196277
'-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',
198280
'-b:v',
199-
`${configuration.videoCodec.parameters.bitRate}k`,
281+
'800k',
282+
'-maxrate',
283+
'1000k',
284+
'-bufsize',
285+
'1000k',
200286
'-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',
204294
]
205295

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+
206305
const ffmpegInput: Array<string> = []
207306

208307
if (this.videoConfig?.prebuffer) {
@@ -218,24 +317,48 @@ export class RecordingDelegate implements CameraRecordingDelegate {
218317
this.log.info('Recording started', this.cameraName)
219318

220319
const { socket, cp, generator } = session
320+
321+
// Track the FFmpeg process for this stream
322+
this.activeFFmpegProcesses.set(streamId, cp)
323+
221324
let pending: Array<Buffer> = []
222325
let filebuffer: Buffer = Buffer.alloc(0)
326+
let isFirstFragment = true
327+
223328
try {
224329
for await (const box of generator) {
225330
const { header, type, length, data } = box
226331

227332
pending.push(header, data)
228333

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+
}
234356
}
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)
236359
}
237360
} catch (e) {
238-
this.log.info(`Recoding completed. ${e}`, this.cameraName)
361+
this.log.info(`Recording completed. ${e}`, this.cameraName)
239362
/*
240363
const homedir = require('os').homedir();
241364
const path = require('path');
@@ -246,6 +369,8 @@ export class RecordingDelegate implements CameraRecordingDelegate {
246369
} finally {
247370
socket.destroy()
248371
cp.kill()
372+
// Remove from active processes tracking
373+
this.activeFFmpegProcesses.delete(streamId)
249374
// this.server.close;
250375
}
251376
}
@@ -282,33 +407,94 @@ export class RecordingDelegate implements CameraRecordingDelegate {
282407

283408
args.push(...ffmpegInput)
284409

285-
// args.push(...audioOutputArgs);
410+
// Include audio args if recording audio is active
411+
if (this.currentRecordingConfiguration?.audioCodec) {
412+
args.push(...audioOutputArgs)
413+
}
286414

287415
args.push('-f', 'mp4')
288416
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
290425
args.push(
291426
'-movflags',
292-
'frag_keyframe+empty_moov+default_base_moof',
427+
'frag_keyframe+empty_moov+default_base_moof+skip_sidx+skip_trailer+separate_moof',
293428
`tcp://127.0.0.1:${serverPort}`,
294429
)
295430

296431
this.log.debug(`${ffmpegPath} ${args.join(' ')}`, this.cameraName)
297432

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
299444

300445
const stdioValue = debug ? 'pipe' : 'ignore'
301446
this.process = spawn(ffmpegPath, args, { env, stdio: stdioValue })
302447
const cp = this.process
303448

449+
this.log.debug(`DEBUG: FFmpeg started with PID ${cp.pid}`, this.cameraName)
450+
304451
if (debug) {
452+
let frameCount = 0
453+
let lastLogTime = Date.now()
454+
const logInterval = 5000 // Log every 5 seconds
455+
305456
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+
})
307461
}
308462
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(/frame=\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+
})
310484
}
311485
}
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+
})
312498
})
313499
})
314500
}

src/streamingDelegate.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ export class StreamingDelegate implements CameraStreamingDelegate {
114114
],
115115
},
116116
},
117-
recording: /*! this.recording ? undefined : */ {
117+
recording: !this.recording ? undefined : {
118118
options: {
119119
prebufferLength: PREBUFFER_LENGTH,
120120
overrideEventTriggerOptions: [hap.EventTriggerOption.MOTION, hap.EventTriggerOption.DOORBELL],

0 commit comments

Comments
 (0)