@homebridge-plugins/homebridge-eufy-security 5.0.0-beta.3 → 5.0.0-beta.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config.schema.json +48 -0
- package/dist/account/temporary-authentication.js +3 -1
- package/dist/account/temporary-authentication.js.map +1 -1
- package/dist/configuration.js +44 -2
- package/dist/configuration.js.map +1 -1
- package/dist/device/member-evidence.js +10 -3
- package/dist/device/member-evidence.js.map +1 -1
- package/dist/device/snapshot.js +1 -1
- package/dist/device/snapshot.js.map +1 -1
- package/dist/diagnostics.js +694 -83
- package/dist/diagnostics.js.map +1 -1
- package/dist/homekit/adapters/camera-streaming.js +56 -27
- package/dist/homekit/adapters/camera-streaming.js.map +1 -1
- package/dist/homekit/adapters/lock.js +45 -16
- package/dist/homekit/adapters/lock.js.map +1 -1
- package/dist/homekit/adapters/motion.js +1 -1
- package/dist/homekit/adapters/motion.js.map +1 -1
- package/dist/homekit/adapters/package-sensor.js +63 -0
- package/dist/homekit/adapters/package-sensor.js.map +1 -0
- package/dist/homekit/adapters/registry.js +2 -0
- package/dist/homekit/adapters/registry.js.map +1 -1
- package/dist/homekit/adapters/security-system.js +101 -45
- package/dist/homekit/adapters/security-system.js.map +1 -1
- package/dist/homekit/coverage-matrix.js +1 -3
- package/dist/homekit/coverage-matrix.js.map +1 -1
- package/dist/homekit/reconciler.js +21 -5
- package/dist/homekit/reconciler.js.map +1 -1
- package/dist/media/last-successful-image.js +5 -3
- package/dist/media/last-successful-image.js.map +1 -1
- package/dist/media/live-stream.js +200 -84
- package/dist/media/live-stream.js.map +1 -1
- package/dist/media/recording.js +11 -5
- package/dist/media/recording.js.map +1 -1
- package/dist/media/snapshot.js +21 -8
- package/dist/media/snapshot.js.map +1 -1
- package/dist/media/station-live-sessions.js +47 -43
- package/dist/media/station-live-sessions.js.map +1 -1
- package/dist/platform.js +27 -7
- package/dist/platform.js.map +1 -1
- package/dist/runtime/channel.js +116 -4
- package/dist/runtime/channel.js.map +1 -1
- package/dist/runtime/device-settings.js +88 -0
- package/dist/runtime/device-settings.js.map +1 -0
- package/dist/runtime/owner.js +12 -0
- package/dist/runtime/owner.js.map +1 -1
- package/dist/runtime/sdk-client.js +19 -4
- package/dist/runtime/sdk-client.js.map +1 -1
- package/dist/ui/dashboard.js +32 -14
- package/dist/ui/dashboard.js.map +1 -1
- package/dist/ui/runtime-channel-client.js +64 -1
- package/dist/ui/runtime-channel-client.js.map +1 -1
- package/dist/ui/server.js +64 -5
- package/dist/ui/server.js.map +1 -1
- package/homebridge-ui/public/app.css +359 -88
- package/homebridge-ui/public/assets/icons/battery_0.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_1.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_2.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_3.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_4.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_5.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_6.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_full.svg +1 -0
- package/homebridge-ui/public/assets/icons/contact_support.svg +1 -0
- package/homebridge-ui/public/assets/icons/delete.svg +1 -0
- package/homebridge-ui/public/assets/icons/download.svg +1 -0
- package/homebridge-ui/public/assets/icons/extension_off.svg +1 -0
- package/homebridge-ui/public/assets/icons/info.svg +1 -0
- package/homebridge-ui/public/assets/icons/key_off.svg +1 -0
- package/homebridge-ui/public/assets/icons/keyboard_arrow_left.svg +1 -0
- package/homebridge-ui/public/assets/icons/keyboard_arrow_right.svg +1 -0
- package/homebridge-ui/public/assets/icons/keyboard_double_arrow_left.svg +1 -0
- package/homebridge-ui/public/assets/icons/keyboard_double_arrow_right.svg +1 -0
- package/homebridge-ui/public/assets/icons/movie_off.svg +1 -0
- package/homebridge-ui/public/assets/icons/refresh.svg +1 -0
- package/homebridge-ui/public/assets/icons/satellite_alt.svg +1 -0
- package/homebridge-ui/public/assets/icons/search_off.svg +1 -0
- package/homebridge-ui/public/assets/icons/sensors_off.svg +1 -0
- package/homebridge-ui/public/assets/icons/settings_photo_camera.svg +1 -0
- package/homebridge-ui/public/assets/icons/solar_power.svg +1 -0
- package/homebridge-ui/public/assets/icons/videocam_off.svg +1 -0
- package/homebridge-ui/public/i18n/en.json +119 -78
- package/homebridge-ui/public/i18n/fr.json +119 -78
- package/homebridge-ui/public/index.html +115 -106
- package/homebridge-ui/public/js/app.js +354 -359
- package/homebridge-ui/public/js/dashboard.js +139 -10
- package/homebridge-ui/public/js/profile-wizard.js +17 -54
- package/i18n/runtime/en.json +8 -3
- package/package.json +9 -6
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LiveStreamStartError } from '@mega-yfue/eufy-sdk';
|
|
1
|
+
import { LiveStreamStartError, StationKeyUnavailableError, StationUnreachableError } from '@mega-yfue/eufy-sdk';
|
|
2
2
|
import { createSocket } from 'node:dgram';
|
|
3
3
|
import { execFile, spawn } from 'node:child_process';
|
|
4
4
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
@@ -34,7 +34,18 @@ function canCode(running, announced) {
|
|
|
34
34
|
*/
|
|
35
35
|
const MAX_SOURCE_INPUT_REPLACEMENTS = 3;
|
|
36
36
|
const INITIAL_RTCP_GRACE_MS = 15_000;
|
|
37
|
-
|
|
37
|
+
/**
|
|
38
|
+
* The longest a session waits for the SDK to hand over a source, after which it cancels the acquisition.
|
|
39
|
+
*
|
|
40
|
+
* Reaching a station is inside this wait: an unconnected station is reconnected within it, and a HomeBase
|
|
41
|
+
* resolves its session before it can start one. The bound is therefore above what reaching a station costs and
|
|
42
|
+
* below both {@link SOURCE_START_BACKSTOP_MS} and the window a started request is owed an outcome within, so
|
|
43
|
+
* the acquisition is what this expiry reports.
|
|
44
|
+
*
|
|
45
|
+
* Expiry cancels the acquisition. A station serves one media channel, and an uncancelled acquisition holds it
|
|
46
|
+
* until the SDK's own wait elapses, which refuses every camera behind that station meanwhile.
|
|
47
|
+
*/
|
|
48
|
+
const SOURCE_ACQUISITION_DEADLINE_MS = 25_000;
|
|
38
49
|
const RETURN_AUDIO_BIND_GRACE_MS = 250;
|
|
39
50
|
/**
|
|
40
51
|
* Backstop for a started media session that never produces adapted output. The SDK source owns the
|
|
@@ -44,6 +55,20 @@ const RETURN_AUDIO_BIND_GRACE_MS = 250;
|
|
|
44
55
|
* reports nothing at all.
|
|
45
56
|
*/
|
|
46
57
|
export const SOURCE_START_BACKSTOP_MS = 30_000;
|
|
58
|
+
/**
|
|
59
|
+
* How far the RTP sequence space is advanced for each adaptation a session sends on, in packets.
|
|
60
|
+
*
|
|
61
|
+
* One session keeps one SSRC and one SRTP key across a replaced adaptation, and the SRTP packet index is built
|
|
62
|
+
* from the sequence number — so a replacement that started where FFmpeg would have, at a random base, is read
|
|
63
|
+
* by the controller as replay half the time and its packets are discarded under a key that still
|
|
64
|
+
* authenticates. Nothing reports it: the controller goes on acknowledging what it received before, so RTCP
|
|
65
|
+
* liveness holds while the picture never advances again.
|
|
66
|
+
*
|
|
67
|
+
* Advancing instead of restarting keeps the index monotonic. The step is well under half the sequence space,
|
|
68
|
+
* which is what keeps the receiver's own index estimate correct across the wrap it eventually crosses; a gap
|
|
69
|
+
* this size reads as loss, which a controller already tolerates.
|
|
70
|
+
*/
|
|
71
|
+
const OUTPUT_SEQUENCE_STEP = 4_096;
|
|
47
72
|
const SOURCE_ACQUISITION_TIMEOUT = Symbol('source-acquisition-timeout');
|
|
48
73
|
/**
|
|
49
74
|
* The bounded reason one SDK source failure reports.
|
|
@@ -55,8 +80,11 @@ const SOURCE_ACQUISITION_TIMEOUT = Symbol('source-acquisition-timeout');
|
|
|
55
80
|
* from a transport that failed.
|
|
56
81
|
*/
|
|
57
82
|
function sourceFailure(error) {
|
|
58
|
-
if (error instanceof
|
|
59
|
-
return 'station-
|
|
83
|
+
if (error instanceof StationUnreachableError) {
|
|
84
|
+
return 'station-unreachable';
|
|
85
|
+
}
|
|
86
|
+
if (error instanceof StationKeyUnavailableError) {
|
|
87
|
+
return 'station-key-unavailable';
|
|
60
88
|
}
|
|
61
89
|
return error instanceof LiveStreamStartError && error.stage === 'audio-only' ? 'source-audio-only' : 'source-error';
|
|
62
90
|
}
|
|
@@ -152,6 +180,10 @@ export async function resolveFfmpegIdentity(path, source, probe = probeFfmpegVer
|
|
|
152
180
|
const version = await probe(path).catch(() => undefined);
|
|
153
181
|
return { path, source, ...(version === undefined ? {} : { version }) };
|
|
154
182
|
}
|
|
183
|
+
/** The `libx264` constant rate factor a live adaptation codes at when the negotiated bit rate is ignored. */
|
|
184
|
+
const CONSTANT_QUALITY_CRF = 23;
|
|
185
|
+
/** The live video packet size an administrator can choose for networks that drop the negotiated one. */
|
|
186
|
+
export const SMALL_VIDEO_PACKET_SIZE = 1128;
|
|
155
187
|
/** Adapts separate SDK elementary streams into independently failing HomeKit SRTP outputs. */
|
|
156
188
|
export class FfmpegLiveMedia {
|
|
157
189
|
executable;
|
|
@@ -159,12 +191,18 @@ export class FfmpegLiveMedia {
|
|
|
159
191
|
createProcess;
|
|
160
192
|
reservePort;
|
|
161
193
|
createReturnAudioProcess;
|
|
162
|
-
|
|
194
|
+
maxVideoPacketSize;
|
|
195
|
+
copyVideo;
|
|
196
|
+
constantQuality;
|
|
197
|
+
constructor(executable, adaptationDiagnostics, createProcess = spawnLiveMediaProcess, reservePort = reserveMediaPort, createReturnAudioProcess = spawnReturnAudioProcess, maxVideoPacketSize = Number.POSITIVE_INFINITY, copyVideo = false, constantQuality = false) {
|
|
163
198
|
this.executable = executable;
|
|
164
199
|
this.adaptationDiagnostics = adaptationDiagnostics;
|
|
165
200
|
this.createProcess = createProcess;
|
|
166
201
|
this.reservePort = reservePort;
|
|
167
202
|
this.createReturnAudioProcess = createReturnAudioProcess;
|
|
203
|
+
this.maxVideoPacketSize = maxVideoPacketSize;
|
|
204
|
+
this.copyVideo = copyVideo;
|
|
205
|
+
this.constantQuality = constantQuality;
|
|
168
206
|
}
|
|
169
207
|
/**
|
|
170
208
|
* Reserves the negotiated output ports so `SetupEndpoints` can be answered with them, and returns a
|
|
@@ -175,6 +213,9 @@ export class FfmpegLiveMedia {
|
|
|
175
213
|
async prepare(transport) {
|
|
176
214
|
const videoPort = await this.reservePort(transport.addressVersion);
|
|
177
215
|
const targetAddress = transport.addressVersion === 'ipv6' ? `[${transport.targetAddress}]` : transport.targetAddress;
|
|
216
|
+
const maxVideoPacketSize = this.maxVideoPacketSize;
|
|
217
|
+
const copyVideo = this.copyVideo;
|
|
218
|
+
const constantQuality = this.constantQuality;
|
|
178
219
|
let audioPort;
|
|
179
220
|
try {
|
|
180
221
|
audioPort = transport.audio ? await this.reservePort(transport.addressVersion) : undefined;
|
|
@@ -184,6 +225,13 @@ export class FfmpegLiveMedia {
|
|
|
184
225
|
throw error;
|
|
185
226
|
}
|
|
186
227
|
let source;
|
|
228
|
+
/**
|
|
229
|
+
* Cancels the source acquisition in flight, and is held for exactly as long as one is.
|
|
230
|
+
*
|
|
231
|
+
* The station's one media channel is held for the whole of an uncancelled acquisition, so a session that
|
|
232
|
+
* ends during one cancels it rather than leaving it to the SDK's own wait.
|
|
233
|
+
*/
|
|
234
|
+
let acquiring;
|
|
187
235
|
let videoProcess;
|
|
188
236
|
let audioProcess;
|
|
189
237
|
let returnAudioProcess;
|
|
@@ -194,7 +242,6 @@ export class FfmpegLiveMedia {
|
|
|
194
242
|
let negotiated;
|
|
195
243
|
let stopped = false;
|
|
196
244
|
let receivedVideoKeyframe = false;
|
|
197
|
-
let reconfigurationPending = false;
|
|
198
245
|
/**
|
|
199
246
|
* The coded configuration the source last announced, and the one the running adaptation was opened for.
|
|
200
247
|
*
|
|
@@ -213,6 +260,13 @@ export class FfmpegLiveMedia {
|
|
|
213
260
|
let videoStartBackstop;
|
|
214
261
|
let videoFailed = false;
|
|
215
262
|
let rtcpObserved = false;
|
|
263
|
+
/** How many receiver reports this session was acknowledged by, and when the last one arrived. */
|
|
264
|
+
/** Why the video adaptation now running was started, which the record reporting it carries. */
|
|
265
|
+
let videoCause = 'first';
|
|
266
|
+
/** Where the next adaptation this session sends on starts its RTP sequence, advanced for every one. */
|
|
267
|
+
let videoSequence = 0;
|
|
268
|
+
let rtcpReports = 0;
|
|
269
|
+
let lastRtcpAt;
|
|
216
270
|
let streaming = false;
|
|
217
271
|
const stoppingProcesses = new WeakSet();
|
|
218
272
|
const congested = new Set();
|
|
@@ -320,33 +374,43 @@ export class FfmpegLiveMedia {
|
|
|
320
374
|
return;
|
|
321
375
|
}
|
|
322
376
|
stopped = true;
|
|
377
|
+
acquiring?.abort(new Error('live media session stopped'));
|
|
323
378
|
clearTimeout(rtcpDeadline);
|
|
324
379
|
clearTimeout(initialRtcpGrace);
|
|
325
380
|
clearTimeout(videoStartBackstop);
|
|
326
381
|
stopTalkback();
|
|
327
382
|
stopProcess(videoProcess);
|
|
328
383
|
stopProcess(audioProcess);
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
transport.onSessionReleased?.(
|
|
384
|
+
source?.stop();
|
|
385
|
+
if (negotiated) {
|
|
386
|
+
transport.onSessionReleased?.(videoFailed ? 'failed' : 'requested', {
|
|
387
|
+
reports: rtcpReports,
|
|
388
|
+
...(lastRtcpAt === undefined ? {} : { sinceLastReportMs: Date.now() - lastRtcpAt }),
|
|
389
|
+
}, ...(source === undefined ? ['sdk-source-acquisition'] : []));
|
|
332
390
|
}
|
|
333
391
|
videoPort.close();
|
|
334
392
|
audioPort?.close();
|
|
335
393
|
};
|
|
336
|
-
const failVideo = (reason) => {
|
|
394
|
+
const failVideo = (reason, error) => {
|
|
337
395
|
if (stopped || videoFailed) {
|
|
338
396
|
return;
|
|
339
397
|
}
|
|
340
398
|
videoFailed = true;
|
|
341
399
|
stop();
|
|
400
|
+
const warmUp = error instanceof LiveStreamStartError;
|
|
342
401
|
const stage = reason === 'source-acquisition-timeout' || source === undefined
|
|
343
402
|
? 'sdk-source-acquisition'
|
|
344
403
|
: reason === 'rtcp-timeout'
|
|
345
404
|
? 'controller-rtcp'
|
|
346
|
-
: !receivedVideoKeyframe
|
|
405
|
+
: warmUp || !receivedVideoKeyframe
|
|
347
406
|
? 'first-source-keyframe'
|
|
348
407
|
: 'first-adapted-output';
|
|
349
|
-
transport.onSessionOutcome?.({
|
|
408
|
+
transport.onSessionOutcome?.({
|
|
409
|
+
outcome: 'failed',
|
|
410
|
+
reason,
|
|
411
|
+
stage,
|
|
412
|
+
...(warmUp ? { sourceStage: error.stage, sourceReason: error.reason } : {}),
|
|
413
|
+
});
|
|
350
414
|
transport.onVideoFailure?.();
|
|
351
415
|
};
|
|
352
416
|
/**
|
|
@@ -357,6 +421,8 @@ export class FfmpegLiveMedia {
|
|
|
357
421
|
*/
|
|
358
422
|
const resetRtcpDeadline = () => {
|
|
359
423
|
rtcpObserved = true;
|
|
424
|
+
rtcpReports += 1;
|
|
425
|
+
lastRtcpAt = Date.now();
|
|
360
426
|
clearTimeout(initialRtcpGrace);
|
|
361
427
|
clearTimeout(rtcpDeadline);
|
|
362
428
|
if (!streaming) {
|
|
@@ -371,13 +437,9 @@ export class FfmpegLiveMedia {
|
|
|
371
437
|
* Adaptation reached the negotiated output, so the session is bounded from here by RTCP liveness
|
|
372
438
|
* rather than by the start backstop. The initial grace is armed from this point because media may
|
|
373
439
|
* legitimately start well after the session does.
|
|
374
|
-
*
|
|
375
|
-
* A selection HomeKit has already replaced does not discharge the deadline for its replacement, however
|
|
376
|
-
* much it keeps reporting: FFmpeg reports progress on a timer whether or not new media reaches it, so
|
|
377
|
-
* only the adaptation that carries the current selection can say that selection is being served.
|
|
378
440
|
*/
|
|
379
|
-
const observeAdaptationProgress = (
|
|
380
|
-
if (stopped
|
|
441
|
+
const observeAdaptationProgress = () => {
|
|
442
|
+
if (stopped) {
|
|
381
443
|
return;
|
|
382
444
|
}
|
|
383
445
|
clearTimeout(videoStartBackstop);
|
|
@@ -406,13 +468,12 @@ export class FfmpegLiveMedia {
|
|
|
406
468
|
* Writes one source access unit to the adaptation that is entitled to code it.
|
|
407
469
|
*
|
|
408
470
|
* A source configuration the running adaptation was not opened for cannot be coded by it at all, so those
|
|
409
|
-
* frames wait for the keyframe that lets a replacement start.
|
|
410
|
-
* only the output, so the current process keeps coding and keeps the previous selection on the wire until
|
|
411
|
-
* that same keyframe arrives; a controller reconfigures precisely when it is unhappy with what it
|
|
412
|
-
* receives, and the source is then often the very thing not producing keyframes.
|
|
471
|
+
* frames wait for the keyframe that lets a replacement start.
|
|
413
472
|
*
|
|
414
473
|
* Source-driven replacement is bounded by {@link MAX_SOURCE_INPUT_REPLACEMENTS}, past which the session
|
|
415
|
-
* fails rather than spawn again.
|
|
474
|
+
* fails rather than spawn again. It is the only replacement a session makes: a source that changes what it
|
|
475
|
+
* delivers cannot be coded by the running process at all, whereas a controller's renegotiated selection is
|
|
476
|
+
* a request this session declines in favour of the one it started on.
|
|
416
477
|
*/
|
|
417
478
|
const writeVideo = (frame) => {
|
|
418
479
|
if (stopped || !negotiated || !videoConfig) {
|
|
@@ -428,29 +489,28 @@ export class FfmpegLiveMedia {
|
|
|
428
489
|
if (videoProcess && inputChanged && !frame.keyframe) {
|
|
429
490
|
return;
|
|
430
491
|
}
|
|
431
|
-
if (videoProcess && frame.keyframe &&
|
|
432
|
-
|
|
433
|
-
sourceInputReplacements += 1;
|
|
434
|
-
}
|
|
492
|
+
if (videoProcess && frame.keyframe && inputChanged) {
|
|
493
|
+
sourceInputReplacements += 1;
|
|
435
494
|
if (sourceInputReplacements > MAX_SOURCE_INPUT_REPLACEMENTS) {
|
|
436
495
|
failVideo('source-input-unstable');
|
|
437
496
|
return;
|
|
438
497
|
}
|
|
439
|
-
|
|
498
|
+
videoCause = 'source-configuration';
|
|
440
499
|
stopProcess(videoProcess);
|
|
441
500
|
videoProcess = undefined;
|
|
442
501
|
}
|
|
443
502
|
if (!videoProcess) {
|
|
444
503
|
adaptationConfig = videoConfig;
|
|
445
|
-
|
|
504
|
+
videoSequence = (videoSequence + OUTPUT_SEQUENCE_STEP) & 0xffff;
|
|
505
|
+
const child = this.createProcess(this.executable, videoArguments(videoConfig, negotiated.video, targetAddress, transport.video, videoSequence, copyVideo, constantQuality));
|
|
446
506
|
videoProcess = child;
|
|
447
|
-
adaptationDiagnostics?.report({ role: 'live-video', event: 'started' });
|
|
507
|
+
adaptationDiagnostics?.report({ role: 'live-video', event: 'started', cause: videoCause });
|
|
448
508
|
const stderr = new AdaptationStderr();
|
|
449
509
|
let producedOutput = false;
|
|
450
510
|
child.stderr.on('data', (chunk) => {
|
|
451
511
|
if (stderr.observe(chunk).some((line) => line.startsWith('progress='))) {
|
|
452
512
|
producedOutput = true;
|
|
453
|
-
observeAdaptationProgress(
|
|
513
|
+
observeAdaptationProgress();
|
|
454
514
|
}
|
|
455
515
|
});
|
|
456
516
|
child.stdin.on('error', () => {
|
|
@@ -485,6 +545,9 @@ export class FfmpegLiveMedia {
|
|
|
485
545
|
audioInputCodec = frame.codec;
|
|
486
546
|
const child = this.createProcess(this.executable, audioArguments(frame, negotiated.audio, targetAddress, transport.audio));
|
|
487
547
|
audioProcess = child;
|
|
548
|
+
if (frame.config) {
|
|
549
|
+
writeToAdaptation(child.stdin, Buffer.concat([FLV_FILE_HEADER, flvAacTag(0, frame.config)]));
|
|
550
|
+
}
|
|
488
551
|
adaptationDiagnostics?.report({ role: 'live-audio', event: 'started' });
|
|
489
552
|
const stderr = new AdaptationStderr();
|
|
490
553
|
let producedOutput = false;
|
|
@@ -535,7 +598,7 @@ export class FfmpegLiveMedia {
|
|
|
535
598
|
writeAudio(frame);
|
|
536
599
|
return;
|
|
537
600
|
}
|
|
538
|
-
writeToAdaptation(audioProcess.stdin, frame.data);
|
|
601
|
+
writeToAdaptation(audioProcess.stdin, frame.config ? flvAacTag(1, frame.data) : frame.data);
|
|
539
602
|
};
|
|
540
603
|
/**
|
|
541
604
|
* Write one decoded return-audio chunk through the SDK writable that owns ADTS frame recovery,
|
|
@@ -668,7 +731,9 @@ export class FfmpegLiveMedia {
|
|
|
668
731
|
return;
|
|
669
732
|
}
|
|
670
733
|
reportAdaptation('return-audio', producedOutput ? 'exited-while-streaming' : 'exited-before-output', stderr, code, signal);
|
|
671
|
-
failTalkback('
|
|
734
|
+
failTalkback(stderr.tail().some((line) => line.includes('Error during demuxing: Operation timed out'))
|
|
735
|
+
? 'no-controller-audio'
|
|
736
|
+
: 'adaptation-failed');
|
|
672
737
|
});
|
|
673
738
|
child.stdin.end(returnAudioSdp(audioPort.port, selection, transport.audio, transport.addressVersion));
|
|
674
739
|
await delay(RETURN_AUDIO_BIND_GRACE_MS);
|
|
@@ -684,12 +749,14 @@ export class FfmpegLiveMedia {
|
|
|
684
749
|
if (stopped) {
|
|
685
750
|
throw new Error('live media session is already stopped');
|
|
686
751
|
}
|
|
687
|
-
negotiated = selection;
|
|
752
|
+
negotiated = { ...selection, video: { ...selection.video, mtu: Math.min(selection.video.mtu, maxVideoPacketSize) } };
|
|
688
753
|
const returnAudioReady = startReturnAudio(camera, selection.audio);
|
|
689
754
|
let sourcePromise;
|
|
690
755
|
let acquisitionDeadline;
|
|
756
|
+
const acquisition = new AbortController();
|
|
757
|
+
acquiring = acquisition;
|
|
691
758
|
try {
|
|
692
|
-
sourcePromise = camera.live();
|
|
759
|
+
sourcePromise = camera.live({ signal: acquisition.signal });
|
|
693
760
|
void sourcePromise.then((lateSource) => {
|
|
694
761
|
if (stopped && source !== lateSource) {
|
|
695
762
|
lateSource.stop();
|
|
@@ -704,11 +771,13 @@ export class FfmpegLiveMedia {
|
|
|
704
771
|
]);
|
|
705
772
|
}
|
|
706
773
|
catch (error) {
|
|
707
|
-
|
|
774
|
+
acquisition.abort(new Error('live media source acquisition was cancelled'));
|
|
775
|
+
failVideo(error === SOURCE_ACQUISITION_TIMEOUT ? 'source-acquisition-timeout' : sourceFailure(error), error);
|
|
708
776
|
throw error === SOURCE_ACQUISITION_TIMEOUT ? new Error('live media source acquisition timed out') : error;
|
|
709
777
|
}
|
|
710
778
|
finally {
|
|
711
779
|
clearTimeout(acquisitionDeadline);
|
|
780
|
+
acquiring = undefined;
|
|
712
781
|
}
|
|
713
782
|
if (stopped) {
|
|
714
783
|
source.stop();
|
|
@@ -724,43 +793,25 @@ export class FfmpegLiveMedia {
|
|
|
724
793
|
notice.extend();
|
|
725
794
|
}
|
|
726
795
|
});
|
|
727
|
-
source.on('error', (error) => failVideo(sourceFailure(error)));
|
|
796
|
+
source.on('error', (error) => failVideo(sourceFailure(error), error));
|
|
728
797
|
source.on('stop', () => failVideo('source-stopped'));
|
|
729
798
|
await returnAudioReady;
|
|
730
799
|
},
|
|
731
|
-
/**
|
|
732
|
-
* Acknowledges a reconfigured selection and applies it at the next source keyframe.
|
|
733
|
-
*
|
|
734
|
-
* Nothing is torn down here: the current adaptation keeps the previous selection on the wire until the
|
|
735
|
-
* replacement has a keyframe to start from. The deferral is still bounded, because a session that never
|
|
736
|
-
* applies the selection HomeKit asked for must end and be renegotiated rather than silently serve the
|
|
737
|
-
* old one forever.
|
|
738
|
-
*/
|
|
739
|
-
reconfigure(video) {
|
|
740
|
-
if (!negotiated) {
|
|
741
|
-
return;
|
|
742
|
-
}
|
|
743
|
-
negotiated = { ...negotiated, video };
|
|
744
|
-
reconfigurationPending = videoProcess !== undefined;
|
|
745
|
-
clearTimeout(videoStartBackstop);
|
|
746
|
-
videoStartBackstop = setTimeout(() => failVideo('no-video-within-backstop'), SOURCE_START_BACKSTOP_MS);
|
|
747
|
-
videoStartBackstop.unref?.();
|
|
748
|
-
},
|
|
749
800
|
stop,
|
|
750
801
|
};
|
|
751
802
|
}
|
|
752
803
|
}
|
|
753
804
|
/**
|
|
754
|
-
* Options every adapted elementary stream
|
|
805
|
+
* Options every adapted input applies to itself: a piped elementary stream, or audio framed as FLV.
|
|
755
806
|
*
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
*
|
|
807
|
+
* FFmpeg's initial stream analysis is the only thing standing between the first written access unit and the
|
|
808
|
+
* first coded frame. The caller already declares the format, and an FLV input states its decoder config in its
|
|
809
|
+
* first tag, so that analysis has nothing left to discover and is bounded to its minimum; leaving it at the
|
|
759
810
|
* default delays first output by seconds and scales that delay with the source keyframe interval.
|
|
760
811
|
*
|
|
761
|
-
* Every such input is read on one clock: when its media arrived.
|
|
762
|
-
* arrival is the only timeline there is, and two
|
|
763
|
-
* only while both are read on it.
|
|
812
|
+
* Every such input is read on one clock: when its media arrived. An elementary stream carries no timeline of its
|
|
813
|
+
* own and the FLV tags this plugin writes carry none either, so arrival is the only timeline there is, and two
|
|
814
|
+
* adapted streams of one session can be presented together only while both are read on it.
|
|
764
815
|
*
|
|
765
816
|
* Nothing asks FFmpeg to discard or reinterpret what it read. Discarding the analysed packets throws away the
|
|
766
817
|
* leading keyframe the caller waited for, and a raw A-law demuxer stops emitting timestamps entirely, so both
|
|
@@ -787,12 +838,21 @@ function commonArguments(inputFormat, inputOptions = []) {
|
|
|
787
838
|
'pipe:0',
|
|
788
839
|
];
|
|
789
840
|
}
|
|
790
|
-
|
|
841
|
+
/**
|
|
842
|
+
* The RTP output one adaptation sends on: the controller's endpoint, the identity the session negotiated for
|
|
843
|
+
* it, and where its sequence numbering starts.
|
|
844
|
+
*
|
|
845
|
+
* `sequence` is stated wherever a session may send on more than one adaptation, because the identity and the
|
|
846
|
+
* SRTP key outlive the process: see {@link OUTPUT_SEQUENCE_STEP} for what a restarted numbering costs. An
|
|
847
|
+
* output no replacement can follow omits it and keeps FFmpeg's own base.
|
|
848
|
+
*/
|
|
849
|
+
function outputArguments(targetAddress, target, payloadType, ssrc, packetSize, sequence) {
|
|
791
850
|
return [
|
|
792
851
|
'-payload_type',
|
|
793
852
|
String(payloadType),
|
|
794
853
|
'-ssrc',
|
|
795
854
|
String(ssrc),
|
|
855
|
+
...(sequence === undefined ? [] : ['-seq', String(sequence)]),
|
|
796
856
|
'-f',
|
|
797
857
|
'rtp',
|
|
798
858
|
'-srtp_out_suite',
|
|
@@ -811,11 +871,19 @@ function outputArguments(targetAddress, target, payloadType, ssrc, packetSize) {
|
|
|
811
871
|
* every frame it was given.
|
|
812
872
|
*
|
|
813
873
|
* A negotiated frame rate is a CEILING, not a cadence, so the rate is bounded rather than pinned. Pinning it
|
|
814
|
-
* makes the encoder
|
|
815
|
-
*
|
|
816
|
-
*
|
|
817
|
-
*
|
|
818
|
-
*
|
|
874
|
+
* with `-r` makes the encoder interpolate a cadence a bare Annex-B pipe never states, which collapses a session
|
|
875
|
+
* onto one instant.
|
|
876
|
+
*
|
|
877
|
+
* The output is constant-rate, which fills a gap in arrival with a duplicate of the last picture rather than
|
|
878
|
+
* carrying the gap. A duplicate costs a full encode and a share of the negotiated bit rate for a frame carrying
|
|
879
|
+
* nothing new, and a source over this transport gaps as its normal case: measured on the bundled encoder against
|
|
880
|
+
* a 15 fps arrival stalled 1.5 seconds after every second of media, a constant rate emitted 266 frames of which
|
|
881
|
+
* 146 were duplicates, against 120 frames and none when the arrival timestamps were passed through, at the same
|
|
882
|
+
* 0.90x real time either way. What the duplicates buy is a dense presentation timeline: a controller shown the
|
|
883
|
+
* gaps instead displays a picture that visibly jumps at every one of them.
|
|
884
|
+
*
|
|
885
|
+
* `-fpsmax` states that ceiling for a constant-rate output only, and FFmpeg refuses it beside any other
|
|
886
|
+
* frame-rate mode, so the two cannot be combined.
|
|
819
887
|
*
|
|
820
888
|
* A negotiated selection states no refresh cadence, so the keyframe interval is plugin policy: `-g` and
|
|
821
889
|
* `-keyint_min` are the negotiated rate doubled and scene-cut detection is off, which is a refresh every two
|
|
@@ -834,9 +902,29 @@ function outputArguments(targetAddress, target, payloadType, ssrc, packetSize) {
|
|
|
834
902
|
* coded stream can carry a negotiated Main or High profile; `ultrafast` drops CABAC and codes Constrained
|
|
835
903
|
* Baseline whatever `-profile:v` asks for. `-tune zerolatency` pins the same `sliced_threads`, `bframes`
|
|
836
904
|
* and `rc_lookahead` at either preset, so this costs computation rather than frame delay.
|
|
905
|
+
*
|
|
906
|
+
* `copy` sends an H.264 source as the camera coded it, with no encoder in the process. Nothing above then holds:
|
|
907
|
+
* the geometry, frame rate, bit rate, profile and keyframe interval are the camera's own, not the negotiated
|
|
908
|
+
* selection. Only the RTP output is the negotiated one. An H.265 source is transcoded either way.
|
|
909
|
+
*
|
|
910
|
+
* `constantQuality` codes at {@link CONSTANT_QUALITY_CRF} with no rate control, so the negotiated bit rate is not
|
|
911
|
+
* honoured: the output spends what the picture needs. A controller asks 299 kbps for 720p and does not raise it
|
|
912
|
+
* during a session, which the ceiling above codes as a blurred, then blocky, picture.
|
|
837
913
|
*/
|
|
838
|
-
function videoArguments(input, selection, targetAddress, target) {
|
|
914
|
+
function videoArguments(input, selection, targetAddress, target, sequence, copy = false, constantQuality = false) {
|
|
915
|
+
if (copy && input.codec === 'h264') {
|
|
916
|
+
return [
|
|
917
|
+
...commonArguments('h264'),
|
|
918
|
+
'-an',
|
|
919
|
+
'-c:v',
|
|
920
|
+
'copy',
|
|
921
|
+
...outputArguments(targetAddress, target, selection.payloadType, selection.ssrc, selection.mtu, sequence),
|
|
922
|
+
];
|
|
923
|
+
}
|
|
839
924
|
const budget = videoBudgetInsideCeiling(selection);
|
|
925
|
+
const rate = constantQuality
|
|
926
|
+
? ['-crf', String(CONSTANT_QUALITY_CRF)]
|
|
927
|
+
: ['-b:v', `${budget}k`, '-maxrate', `${budget}k`, '-bufsize', `${budget}k`];
|
|
840
928
|
return [
|
|
841
929
|
...commonArguments(input.codec === 'h265' ? 'hevc' : input.codec),
|
|
842
930
|
'-an',
|
|
@@ -862,22 +950,51 @@ function videoArguments(input, selection, targetAddress, target) {
|
|
|
862
950
|
String(selection.fps * 2),
|
|
863
951
|
'-sc_threshold',
|
|
864
952
|
'0',
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
'-maxrate',
|
|
868
|
-
`${budget}k`,
|
|
869
|
-
'-bufsize',
|
|
870
|
-
`${budget}k`,
|
|
871
|
-
...outputArguments(targetAddress, target, selection.payloadType, selection.ssrc, selection.mtu),
|
|
953
|
+
...rate,
|
|
954
|
+
...outputArguments(targetAddress, target, selection.payloadType, selection.ssrc, selection.mtu, sequence),
|
|
872
955
|
];
|
|
873
956
|
}
|
|
957
|
+
/** FLV signature, version 1, the audio-only flag, header size 9, and the zero size of the tag before the first. */
|
|
958
|
+
const FLV_FILE_HEADER = Buffer.from([0x46, 0x4c, 0x56, 0x01, 0x04, 0x00, 0x00, 0x00, 0x09, 0x00, 0x00, 0x00, 0x00]);
|
|
874
959
|
/**
|
|
875
|
-
*
|
|
960
|
+
* One FLV audio tag of an AAC elementary stream and its trailing size. The body is the AAC sound flags 0xaf, the AAC
|
|
961
|
+
* packet type (0 for the AudioSpecificConfig, 1 for one access unit) and `payload` unchanged. The sound flags fix the
|
|
962
|
+
* 44 kHz, 16-bit, stereo values the format requires; an AAC decoder takes the real parameters from the
|
|
963
|
+
* AudioSpecificConfig. The timestamp, its extension and the stream id are zero: the stream carries no timeline of its
|
|
964
|
+
* own, because its reader stamps every tag with the instant it arrived.
|
|
965
|
+
*/
|
|
966
|
+
function flvAacTag(packetType, payload) {
|
|
967
|
+
const header = Buffer.alloc(13);
|
|
968
|
+
header[0] = 8;
|
|
969
|
+
header.writeUIntBE(payload.length + 2, 1, 3);
|
|
970
|
+
header[11] = 0xaf;
|
|
971
|
+
header[12] = packetType;
|
|
972
|
+
const previousTagSize = Buffer.alloc(4);
|
|
973
|
+
previousTagSize.writeUInt32BE(13 + payload.length);
|
|
974
|
+
return Buffer.concat([header, payload, previousTagSize]);
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* The FFmpeg input format and input options for one source audio stream.
|
|
876
978
|
*
|
|
877
|
-
*
|
|
878
|
-
*
|
|
879
|
-
*
|
|
880
|
-
* the
|
|
979
|
+
* A stream whose decoder config travels apart from its access units is framed as FLV, which states that config
|
|
980
|
+
* once ahead of them, and is decoded by `libfdk_aac`, which decodes AAC-ELD in the config the SDK declares. Outside
|
|
981
|
+
* such a config the SDK reports no sample rate or channel count, because a station sends neither;
|
|
982
|
+
* 16 kHz mono is the assumption every Eufy client applies. Raw A-law carries nothing at all and must be told that
|
|
983
|
+
* assumption, while an ADTS input states its own rate in every frame header and rejects the option outright, which
|
|
984
|
+
* fails the process before it reads a byte.
|
|
985
|
+
*/
|
|
986
|
+
function audioInput(frame) {
|
|
987
|
+
if (frame.config) {
|
|
988
|
+
return ['flv', ['-c:a', 'libfdk_aac']];
|
|
989
|
+
}
|
|
990
|
+
if (frame.codec === 'g711a') {
|
|
991
|
+
return ['alaw', ['-ar', '16k', '-ac', '1']];
|
|
992
|
+
}
|
|
993
|
+
return ['aac', []];
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Adapts one source audio elementary stream to the negotiated AAC-ELD output, reading it as {@link audioInput}
|
|
997
|
+
* describes.
|
|
881
998
|
*
|
|
882
999
|
* Asynchronous resampling is what makes this output honour the arrival clock its input is read on. An encoder
|
|
883
1000
|
* advances its output timeline by the samples in each coded frame it is handed, whatever timestamp arrived
|
|
@@ -890,13 +1007,12 @@ function videoArguments(input, selection, targetAddress, target) {
|
|
|
890
1007
|
* accident of the filter: a live view owes its controller the newest audio, and audio which is already
|
|
891
1008
|
* seconds stale by the time it arrives cannot be presented beside a picture timestamped on arrival.
|
|
892
1009
|
*
|
|
893
|
-
* `libfdk_aac` selects its transport from the requested output framing, and AAC-ELD cannot be carried in
|
|
1010
|
+
* The `libfdk_aac` encoder selects its transport from the requested output framing, and AAC-ELD cannot be carried in
|
|
894
1011
|
* ADTS, so without an explicit global header the encoder refuses to initialise at all.
|
|
895
1012
|
*/
|
|
896
1013
|
function audioArguments(frame, selection, targetAddress, target) {
|
|
897
|
-
const rawAlaw = frame.codec === 'g711a';
|
|
898
1014
|
return [
|
|
899
|
-
...commonArguments(
|
|
1015
|
+
...commonArguments(...audioInput(frame)),
|
|
900
1016
|
'-vn',
|
|
901
1017
|
'-c:a',
|
|
902
1018
|
'libfdk_aac',
|