@homebridge-plugins/homebridge-eufy-security 5.0.0-beta.32 → 5.0.0-beta.34
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 +6 -0
- package/dist/configuration.js +2 -0
- package/dist/configuration.js.map +1 -1
- package/dist/diagnostics.js +86 -2
- package/dist/diagnostics.js.map +1 -1
- package/dist/homekit/adapters/lock.js +21 -0
- package/dist/homekit/adapters/lock.js.map +1 -1
- package/dist/media/live-preview.js +96 -0
- package/dist/media/live-preview.js.map +1 -0
- package/dist/media/live-stream.js +310 -32
- package/dist/media/live-stream.js.map +1 -1
- package/dist/platform.js +33 -2
- package/dist/platform.js.map +1 -1
- package/dist/runtime/channel.js +54 -1
- package/dist/runtime/channel.js.map +1 -1
- package/dist/runtime/owner.js +14 -1
- package/dist/runtime/owner.js.map +1 -1
- package/dist/ui/dashboard.js +4 -2
- package/dist/ui/dashboard.js.map +1 -1
- package/dist/ui/runtime-channel-client.js +46 -1
- package/dist/ui/runtime-channel-client.js.map +1 -1
- package/dist/ui/server.js +20 -0
- package/dist/ui/server.js.map +1 -1
- package/homebridge-ui/public/app.css +181 -0
- package/homebridge-ui/public/assets/icons/auto_fix_high.svg +1 -0
- package/homebridge-ui/public/assets/icons/play_arrow.svg +1 -0
- package/homebridge-ui/public/assets/icons/stop.svg +1 -0
- package/homebridge-ui/public/i18n/en.json +29 -1
- package/homebridge-ui/public/i18n/fr.json +29 -1
- package/homebridge-ui/public/index.html +63 -0
- package/homebridge-ui/public/js/app.js +318 -3
- package/homebridge-ui/public/js/dashboard.js +5 -2
- package/homebridge-ui/public/js/setup-wizard.js +52 -0
- package/i18n/runtime/en.json +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { commonArguments, PROCESS_STOP_GRACE_MS } from './live-stream.js';
|
|
3
|
+
/** How long one preview runs before it ends on its own, so a page left open does not hold a camera. */
|
|
4
|
+
export const LIVE_PREVIEW_MAX_MS = 3 * 60_000;
|
|
5
|
+
/** The most bytes one picture may reach before FFmpeg's output is taken as no longer pictures. */
|
|
6
|
+
const MAX_PICTURE_BYTES = 1024 * 1024;
|
|
7
|
+
const JPEG_END = Buffer.from([0xff, 0xd9]);
|
|
8
|
+
/** The FFmpeg input format each SDK codec is read in. AV1 has none, so such a camera has no preview. */
|
|
9
|
+
const INPUT_FORMATS = { h264: 'h264', h265: 'hevc' };
|
|
10
|
+
/**
|
|
11
|
+
* A camera's live video as JPEG pictures, five a second and 640 pixels wide, for the settings page.
|
|
12
|
+
*
|
|
13
|
+
* Starts at the first keyframe and has no audio. A write FFmpeg refuses holds the SDK source until it drains,
|
|
14
|
+
* so the SDK's keyframe-aware queue is the only one. A codec change, a source or FFmpeg failure, the time limit
|
|
15
|
+
* and the returned stop all end it, and `onEnd` is called exactly once whichever came first.
|
|
16
|
+
*/
|
|
17
|
+
export function startLivePreview(executable, source, onPicture, onEnd) {
|
|
18
|
+
const abandonment = new AbortController();
|
|
19
|
+
let live;
|
|
20
|
+
let ffmpeg;
|
|
21
|
+
let codec;
|
|
22
|
+
let ended = false;
|
|
23
|
+
const end = () => {
|
|
24
|
+
if (ended)
|
|
25
|
+
return;
|
|
26
|
+
ended = true;
|
|
27
|
+
clearTimeout(deadline);
|
|
28
|
+
abandonment.abort();
|
|
29
|
+
try {
|
|
30
|
+
live?.stop();
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
live = undefined;
|
|
34
|
+
}
|
|
35
|
+
if (ffmpeg) {
|
|
36
|
+
const process = ffmpeg;
|
|
37
|
+
process.stdin?.destroy();
|
|
38
|
+
process.kill('SIGTERM');
|
|
39
|
+
const kill = setTimeout(() => process.kill('SIGKILL'), PROCESS_STOP_GRACE_MS);
|
|
40
|
+
kill.unref();
|
|
41
|
+
process.once('exit', () => clearTimeout(kill));
|
|
42
|
+
}
|
|
43
|
+
onEnd();
|
|
44
|
+
};
|
|
45
|
+
const deadline = setTimeout(end, LIVE_PREVIEW_MAX_MS);
|
|
46
|
+
deadline.unref();
|
|
47
|
+
const adapt = (format) => {
|
|
48
|
+
const process = spawn(executable, [...commonArguments(format), '-an', '-vf', 'fps=5,scale=640:-2', '-q:v', '8', '-f', 'image2pipe', '-c:v', 'mjpeg', 'pipe:1'], { stdio: ['pipe', 'pipe', 'ignore'] });
|
|
49
|
+
process.once('error', end);
|
|
50
|
+
process.once('exit', end);
|
|
51
|
+
process.stdin.on('error', end);
|
|
52
|
+
let pending = Buffer.alloc(0);
|
|
53
|
+
process.stdout.on('data', (chunk) => {
|
|
54
|
+
pending = Buffer.concat([pending, chunk]);
|
|
55
|
+
for (let at = pending.indexOf(JPEG_END); at !== -1; at = pending.indexOf(JPEG_END)) {
|
|
56
|
+
onPicture(pending.subarray(0, at + JPEG_END.length));
|
|
57
|
+
pending = pending.subarray(at + JPEG_END.length);
|
|
58
|
+
}
|
|
59
|
+
if (pending.length > MAX_PICTURE_BYTES)
|
|
60
|
+
end();
|
|
61
|
+
});
|
|
62
|
+
return process;
|
|
63
|
+
};
|
|
64
|
+
source.live({ signal: abandonment.signal }).then((consumer) => {
|
|
65
|
+
if (ended) {
|
|
66
|
+
consumer.stop();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
live = consumer;
|
|
70
|
+
consumer.on('error', end);
|
|
71
|
+
consumer.on('stop', end);
|
|
72
|
+
consumer.on('budget', (notice) => notice.extend());
|
|
73
|
+
consumer.on('video', (frame) => {
|
|
74
|
+
if (ended)
|
|
75
|
+
return;
|
|
76
|
+
if (!ffmpeg) {
|
|
77
|
+
const format = INPUT_FORMATS[frame.codec];
|
|
78
|
+
if (!frame.keyframe)
|
|
79
|
+
return;
|
|
80
|
+
if (!format)
|
|
81
|
+
return end();
|
|
82
|
+
codec = frame.codec;
|
|
83
|
+
ffmpeg = adapt(format);
|
|
84
|
+
}
|
|
85
|
+
if (frame.codec !== codec)
|
|
86
|
+
return end();
|
|
87
|
+
const stdin = ffmpeg.stdin;
|
|
88
|
+
if (!stdin.write(frame.data)) {
|
|
89
|
+
consumer.pause();
|
|
90
|
+
stdin.once('drain', () => consumer.resume());
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}, end);
|
|
94
|
+
return end;
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=live-preview.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"live-preview.js","sourceRoot":"","sources":["../../src/media/live-preview.ts"],"names":[],"mappings":"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"}
|
|
@@ -182,6 +182,252 @@ export async function resolveFfmpegIdentity(path, source, probe = probeFfmpegVer
|
|
|
182
182
|
}
|
|
183
183
|
/** The `libx264` constant rate factor a live adaptation codes at when the negotiated bit rate is ignored. */
|
|
184
184
|
const CONSTANT_QUALITY_CRF = 23;
|
|
185
|
+
/** `libx264`'s profile, level, speed and pixel format, shared by its rate-capped and constant-quality forms. */
|
|
186
|
+
function libx264Arguments(selection) {
|
|
187
|
+
return [
|
|
188
|
+
'-preset',
|
|
189
|
+
'superfast',
|
|
190
|
+
'-tune',
|
|
191
|
+
'zerolatency',
|
|
192
|
+
'-profile:v',
|
|
193
|
+
selection.profile,
|
|
194
|
+
'-level:v',
|
|
195
|
+
selection.level,
|
|
196
|
+
'-pix_fmt',
|
|
197
|
+
'yuv420p',
|
|
198
|
+
];
|
|
199
|
+
}
|
|
200
|
+
/** The software encoder every selection no qualified hardware encoder serves is coded by. */
|
|
201
|
+
const LIBX264 = {
|
|
202
|
+
name: 'libx264',
|
|
203
|
+
args: (selection, kbps) => [...libx264Arguments(selection), '-b:v', kbps, '-maxrate', kbps, '-bufsize', kbps],
|
|
204
|
+
};
|
|
205
|
+
/** `libx264` at {@link CONSTANT_QUALITY_CRF} with no rate control, which no hardware encoder replaces. */
|
|
206
|
+
const CONSTANT_QUALITY = {
|
|
207
|
+
name: 'libx264',
|
|
208
|
+
args: (selection) => [...libx264Arguments(selection), '-crf', String(CONSTANT_QUALITY_CRF)],
|
|
209
|
+
};
|
|
210
|
+
/** The H.264 `profile_idc` of each negotiable profile. */
|
|
211
|
+
const PROFILE_IDC = { baseline: 66, main: 77, high: 100 };
|
|
212
|
+
/**
|
|
213
|
+
* The hardware encoders a selection may be served by, in order of preference, each with the hosts whose
|
|
214
|
+
* binary may carry it.
|
|
215
|
+
*
|
|
216
|
+
* Each states profile, level and rate control in its own encoder's spelling, with B-frames off and its
|
|
217
|
+
* lowest-latency mode, as docs/reference/hardware-encoder-viability.md records them. None is used for a
|
|
218
|
+
* selection until {@link FfmpegLiveMedia.qualify} has proven it against that selection.
|
|
219
|
+
*/
|
|
220
|
+
const HARDWARE_ENCODERS = [
|
|
221
|
+
{
|
|
222
|
+
name: 'h264_videotoolbox',
|
|
223
|
+
triedOn: (host) => host.platform === 'darwin',
|
|
224
|
+
args: (selection, kbps) => [
|
|
225
|
+
'-profile:v',
|
|
226
|
+
selection.profile,
|
|
227
|
+
'-level:v',
|
|
228
|
+
selection.level,
|
|
229
|
+
'-realtime',
|
|
230
|
+
'1',
|
|
231
|
+
'-prio_speed',
|
|
232
|
+
'1',
|
|
233
|
+
'-bf',
|
|
234
|
+
'0',
|
|
235
|
+
'-pix_fmt',
|
|
236
|
+
'yuv420p',
|
|
237
|
+
'-b:v',
|
|
238
|
+
kbps,
|
|
239
|
+
'-maxrate',
|
|
240
|
+
kbps,
|
|
241
|
+
],
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
name: 'h264_qsv',
|
|
245
|
+
triedOn: (host) => host.platform === 'win32' || host.configured,
|
|
246
|
+
args: (selection, kbps) => [
|
|
247
|
+
'-profile:v',
|
|
248
|
+
selection.profile,
|
|
249
|
+
'-level:v',
|
|
250
|
+
selection.level.replace('.', ''),
|
|
251
|
+
'-pix_fmt',
|
|
252
|
+
'nv12',
|
|
253
|
+
'-bf',
|
|
254
|
+
'0',
|
|
255
|
+
'-look_ahead',
|
|
256
|
+
'0',
|
|
257
|
+
'-async_depth',
|
|
258
|
+
'1',
|
|
259
|
+
'-b:v',
|
|
260
|
+
kbps,
|
|
261
|
+
'-maxrate',
|
|
262
|
+
kbps,
|
|
263
|
+
],
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
name: 'h264_nvenc',
|
|
267
|
+
triedOn: (host) => host.configured,
|
|
268
|
+
args: (selection, kbps) => [
|
|
269
|
+
'-profile:v',
|
|
270
|
+
selection.profile,
|
|
271
|
+
'-level:v',
|
|
272
|
+
selection.level,
|
|
273
|
+
'-preset',
|
|
274
|
+
'p1',
|
|
275
|
+
'-tune',
|
|
276
|
+
'ull',
|
|
277
|
+
'-zerolatency',
|
|
278
|
+
'1',
|
|
279
|
+
'-bf',
|
|
280
|
+
'0',
|
|
281
|
+
'-pix_fmt',
|
|
282
|
+
'yuv420p',
|
|
283
|
+
'-rc',
|
|
284
|
+
'cbr',
|
|
285
|
+
'-b:v',
|
|
286
|
+
kbps,
|
|
287
|
+
'-maxrate',
|
|
288
|
+
kbps,
|
|
289
|
+
'-bufsize',
|
|
290
|
+
kbps,
|
|
291
|
+
],
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
name: 'h264_amf',
|
|
295
|
+
triedOn: (host) => host.platform === 'linux' && host.arch === 'x64',
|
|
296
|
+
args: (selection, kbps) => [
|
|
297
|
+
'-profile:v',
|
|
298
|
+
selection.profile === 'baseline' ? 'constrained_baseline' : selection.profile,
|
|
299
|
+
'-level:v',
|
|
300
|
+
selection.level,
|
|
301
|
+
'-usage',
|
|
302
|
+
'ultralowlatency',
|
|
303
|
+
'-bf',
|
|
304
|
+
'0',
|
|
305
|
+
'-pix_fmt',
|
|
306
|
+
'yuv420p',
|
|
307
|
+
'-b:v',
|
|
308
|
+
kbps,
|
|
309
|
+
'-maxrate',
|
|
310
|
+
kbps,
|
|
311
|
+
'-bufsize',
|
|
312
|
+
kbps,
|
|
313
|
+
],
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
name: 'h264_vaapi',
|
|
317
|
+
triedOn: (host) => host.platform === 'linux' && host.configured,
|
|
318
|
+
upload: ',format=nv12,hwupload',
|
|
319
|
+
args: (selection, kbps) => [
|
|
320
|
+
'-vaapi_device',
|
|
321
|
+
'/dev/dri/renderD128',
|
|
322
|
+
'-profile:v',
|
|
323
|
+
selection.profile === 'baseline' ? 'constrained_baseline' : selection.profile,
|
|
324
|
+
'-level:v',
|
|
325
|
+
selection.level,
|
|
326
|
+
'-bf',
|
|
327
|
+
'0',
|
|
328
|
+
'-async_depth',
|
|
329
|
+
'1',
|
|
330
|
+
'-b:v',
|
|
331
|
+
kbps,
|
|
332
|
+
'-maxrate',
|
|
333
|
+
kbps,
|
|
334
|
+
],
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
name: 'h264_v4l2m2m',
|
|
338
|
+
triedOn: (host) => host.platform === 'linux' && (host.arch === 'arm' || host.arch === 'arm64'),
|
|
339
|
+
args: (selection, kbps) => [
|
|
340
|
+
'-profile:v',
|
|
341
|
+
String(PROFILE_IDC[selection.profile]),
|
|
342
|
+
'-pix_fmt',
|
|
343
|
+
'yuv420p',
|
|
344
|
+
'-b:v',
|
|
345
|
+
kbps,
|
|
346
|
+
],
|
|
347
|
+
},
|
|
348
|
+
];
|
|
349
|
+
/** How many seconds of synthetic picture one qualification run codes. */
|
|
350
|
+
const QUALIFICATION_SECONDS = 2;
|
|
351
|
+
/** The longest one qualification process may run before its selection counts as unqualified. */
|
|
352
|
+
const QUALIFICATION_RUN_TIMEOUT_MS = 10_000;
|
|
353
|
+
/** The longest qualification as a whole may take; a selection it has not finished by then stays unqualified. */
|
|
354
|
+
const QUALIFICATION_BUDGET_MS = 60_000;
|
|
355
|
+
/**
|
|
356
|
+
* The selections every hardware encoder is qualified against: each advertised profile and level, at the
|
|
357
|
+
* largest advertised geometry that level's frame-size limit admits, at the advertised frame-rate ceiling, and
|
|
358
|
+
* at 299 kbps, the lowest bit rate a controller has been measured selecting.
|
|
359
|
+
*/
|
|
360
|
+
const QUALIFICATION_SELECTIONS = ['baseline', 'main', 'high'].flatMap((profile) => [
|
|
361
|
+
['3.1', 1280, 720],
|
|
362
|
+
['3.2', 1280, 720],
|
|
363
|
+
['4.0', 1920, 1080],
|
|
364
|
+
].map(([level, width, height]) => ({
|
|
365
|
+
profile,
|
|
366
|
+
level,
|
|
367
|
+
width,
|
|
368
|
+
height,
|
|
369
|
+
fps: 30,
|
|
370
|
+
maxBitRate: 299,
|
|
371
|
+
mtu: 1378,
|
|
372
|
+
payloadType: 99,
|
|
373
|
+
ssrc: 1,
|
|
374
|
+
rtcpInterval: 0.5,
|
|
375
|
+
})));
|
|
376
|
+
const runQualification = (executable, args, input, timeoutMs) => new Promise((resolve) => {
|
|
377
|
+
const child = execFile(executable, args, { encoding: 'buffer', timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024, windowsHide: true }, (error, stdout, stderr) => resolve(error ? undefined : { stdout, stderr: stderr.toString() }));
|
|
378
|
+
child.stdin?.on('error', () => undefined);
|
|
379
|
+
child.stdin?.end(input);
|
|
380
|
+
});
|
|
381
|
+
/**
|
|
382
|
+
* Qualifies `encoder` for `selection` before `deadline`.
|
|
383
|
+
*
|
|
384
|
+
* The encoder codes {@link QUALIFICATION_SECONDS} of moving synthetic picture through the session's own
|
|
385
|
+
* argument list, which proves it is named by the binary, accepts those arguments, and opens on this host's
|
|
386
|
+
* devices. A second FFmpeg then traces the coded parameter sets, which proves what was coded: a profile at or
|
|
387
|
+
* below the negotiated one, the negotiated level and macroblock geometry, every frame, and no more bytes than
|
|
388
|
+
* the encoder budget plus the one second of it the session's rate control may borrow.
|
|
389
|
+
*/
|
|
390
|
+
async function qualification(executable, encoder, selection, run, deadline) {
|
|
391
|
+
const within = (args, input) => {
|
|
392
|
+
const left = deadline - Date.now();
|
|
393
|
+
return left > 0 ? run(executable, args, input, Math.min(left, QUALIFICATION_RUN_TIMEOUT_MS)) : 'budget';
|
|
394
|
+
};
|
|
395
|
+
const coded = await within([
|
|
396
|
+
'-hide_banner',
|
|
397
|
+
'-loglevel',
|
|
398
|
+
'error',
|
|
399
|
+
'-f',
|
|
400
|
+
'lavfi',
|
|
401
|
+
'-i',
|
|
402
|
+
`testsrc2=size=${selection.width}x${selection.height}:rate=${selection.fps}:duration=${QUALIFICATION_SECONDS}`,
|
|
403
|
+
...encodeArguments(encoder, selection),
|
|
404
|
+
'-f',
|
|
405
|
+
'h264',
|
|
406
|
+
'pipe:1',
|
|
407
|
+
]);
|
|
408
|
+
if (!coded || coded === 'budget') {
|
|
409
|
+
return coded ?? 'encode';
|
|
410
|
+
}
|
|
411
|
+
const traced = await within(['-hide_banner', '-f', 'h264', '-i', 'pipe:0', '-c', 'copy', '-bsf:v', 'trace_headers', '-f', 'null', '-'], coded.stdout);
|
|
412
|
+
if (!traced || traced === 'budget') {
|
|
413
|
+
return traced ?? 'trace';
|
|
414
|
+
}
|
|
415
|
+
const field = (name) => Number(new RegExp(`\\b${name}\\s.*= (\\d+)$`, 'm').exec(traced.stderr)?.[1]);
|
|
416
|
+
const profile = field('profile_idc');
|
|
417
|
+
const wanted = PROFILE_IDC[selection.profile];
|
|
418
|
+
const checks = [
|
|
419
|
+
['profile', profile === wanted || (profile === 66 && field('constraint_set1_flag') === 1) || (profile === 77 && wanted === 100)],
|
|
420
|
+
['level', field('level_idc') === Number(selection.level.replace('.', ''))],
|
|
421
|
+
[
|
|
422
|
+
'geometry',
|
|
423
|
+
field('pic_width_in_mbs_minus1') + 1 === Math.ceil(selection.width / 16) &&
|
|
424
|
+
field('pic_height_in_map_units_minus1') + 1 === Math.ceil(selection.height / 16),
|
|
425
|
+
],
|
|
426
|
+
['frames', (traced.stderr.match(/\bPacket: /g)?.length ?? 0) === selection.fps * QUALIFICATION_SECONDS],
|
|
427
|
+
['bytes', coded.stdout.length * 8 <= videoBudgetInsideCeiling(selection) * 1_000 * (QUALIFICATION_SECONDS + 1)],
|
|
428
|
+
];
|
|
429
|
+
return checks.find(([, passed]) => !passed)?.[0] ?? 'qualified';
|
|
430
|
+
}
|
|
185
431
|
/** The live video packet size an administrator can choose for networks that drop the negotiated one. */
|
|
186
432
|
export const SMALL_VIDEO_PACKET_SIZE = 1128;
|
|
187
433
|
/** Adapts separate SDK elementary streams into independently failing HomeKit SRTP outputs. */
|
|
@@ -204,6 +450,40 @@ export class FfmpegLiveMedia {
|
|
|
204
450
|
this.copyVideo = copyVideo;
|
|
205
451
|
this.constantQuality = constantQuality;
|
|
206
452
|
}
|
|
453
|
+
/**
|
|
454
|
+
* Each hardware encoder's qualified selections, as `profile@level`, for the rest of this run.
|
|
455
|
+
*
|
|
456
|
+
* A spawn reads it, so a selection is served by hardware from the moment it qualifies, and an encoder that
|
|
457
|
+
* exits before output in a session is removed from it and serves nothing again.
|
|
458
|
+
*/
|
|
459
|
+
qualified = new Map();
|
|
460
|
+
/**
|
|
461
|
+
* Qualifies every hardware encoder `host`'s binary may carry against every selection it is qualified for,
|
|
462
|
+
* one run after another and within {@link QUALIFICATION_BUDGET_MS}, and returns each tried encoder's
|
|
463
|
+
* outcome per `profile@level`.
|
|
464
|
+
*
|
|
465
|
+
* Until a selection qualifies it is coded by `libx264`, and a selection no encoder qualifies for stays there.
|
|
466
|
+
*/
|
|
467
|
+
async qualify(host, run = runQualification) {
|
|
468
|
+
const deadline = Date.now() + QUALIFICATION_BUDGET_MS;
|
|
469
|
+
const outcomes = {};
|
|
470
|
+
for (const encoder of HARDWARE_ENCODERS.filter((candidate) => candidate.triedOn(host))) {
|
|
471
|
+
const tried = (outcomes[encoder.name] = {});
|
|
472
|
+
for (const selection of QUALIFICATION_SELECTIONS) {
|
|
473
|
+
const key = `${selection.profile}@${selection.level}`;
|
|
474
|
+
tried[key] = await qualification(this.executable, encoder, selection, run, deadline);
|
|
475
|
+
if (tried[key] === 'qualified') {
|
|
476
|
+
this.qualified.set(encoder.name, (this.qualified.get(encoder.name) ?? new Set()).add(key));
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return outcomes;
|
|
481
|
+
}
|
|
482
|
+
/** The first hardware encoder qualified for `selection`, or `libx264` where none is. */
|
|
483
|
+
encoderFor(selection) {
|
|
484
|
+
const key = `${selection.profile}@${selection.level}`;
|
|
485
|
+
return HARDWARE_ENCODERS.find((encoder) => this.qualified.get(encoder.name)?.has(key)) ?? LIBX264;
|
|
486
|
+
}
|
|
207
487
|
/**
|
|
208
488
|
* Reserves the negotiated output ports so `SetupEndpoints` can be answered with them, and returns a
|
|
209
489
|
* session that holds nothing else: no SDK handle, adaptation process, or device session exists until
|
|
@@ -504,9 +784,21 @@ export class FfmpegLiveMedia {
|
|
|
504
784
|
if (!videoProcess) {
|
|
505
785
|
adaptationConfig = videoConfig;
|
|
506
786
|
videoSequence = (videoSequence + OUTPUT_SEQUENCE_STEP) & 0xffff;
|
|
507
|
-
const
|
|
787
|
+
const encoder = copyVideo && videoConfig.codec === 'h264'
|
|
788
|
+
? 'copy'
|
|
789
|
+
: constantQuality
|
|
790
|
+
? CONSTANT_QUALITY
|
|
791
|
+
: this.encoderFor(negotiated.video);
|
|
792
|
+
const child = this.createProcess(this.executable, videoArguments(videoConfig, negotiated.video, targetAddress, transport.video, videoSequence, encoder));
|
|
508
793
|
videoProcess = child;
|
|
509
|
-
|
|
794
|
+
const encoderName = encoder === 'copy' ? encoder : encoder.name;
|
|
795
|
+
adaptationDiagnostics?.report({
|
|
796
|
+
role: 'live-video',
|
|
797
|
+
event: 'started',
|
|
798
|
+
cause: videoCause,
|
|
799
|
+
encoder: encoderName,
|
|
800
|
+
source: videoConfig.codec,
|
|
801
|
+
});
|
|
510
802
|
const stderr = new AdaptationStderr();
|
|
511
803
|
let producedOutput = false;
|
|
512
804
|
child.stderr.on('data', (chunk) => {
|
|
@@ -534,6 +826,9 @@ export class FfmpegLiveMedia {
|
|
|
534
826
|
return;
|
|
535
827
|
}
|
|
536
828
|
reportAdaptation('live-video', producedOutput ? 'exited-while-streaming' : 'exited-before-output', stderr, code, signal);
|
|
829
|
+
if (!producedOutput && this.qualified.delete(encoderName)) {
|
|
830
|
+
adaptationDiagnostics?.report({ role: 'live-video', event: 'encoder-disqualified', encoder: encoderName });
|
|
831
|
+
}
|
|
537
832
|
failVideo(producedOutput ? 'adaptation-exited-while-streaming' : 'adaptation-exited-before-output');
|
|
538
833
|
});
|
|
539
834
|
}
|
|
@@ -851,7 +1146,7 @@ export class FfmpegLiveMedia {
|
|
|
851
1146
|
* leading keyframe the caller waited for, and a raw A-law demuxer stops emitting timestamps entirely, so both
|
|
852
1147
|
* cost media that was already in hand.
|
|
853
1148
|
*/
|
|
854
|
-
function commonArguments(inputFormat, inputOptions = []) {
|
|
1149
|
+
export function commonArguments(inputFormat, inputOptions = []) {
|
|
855
1150
|
return [
|
|
856
1151
|
'-hide_banner',
|
|
857
1152
|
'-loglevel',
|
|
@@ -941,41 +1236,26 @@ function outputArguments(targetAddress, target, payloadType, ssrc, packetSize, s
|
|
|
941
1236
|
* the geometry, frame rate, bit rate, profile and keyframe interval are the camera's own, not the negotiated
|
|
942
1237
|
* selection. Only the RTP output is the negotiated one. An H.265 source is transcoded either way.
|
|
943
1238
|
*
|
|
944
|
-
*
|
|
1239
|
+
* {@link CONSTANT_QUALITY} codes at {@link CONSTANT_QUALITY_CRF} with no rate control, so the negotiated bit rate is not
|
|
945
1240
|
* honoured: the output spends what the picture needs. A controller asks 299 kbps for 720p and does not raise it
|
|
946
1241
|
* during a session, which the ceiling above codes as a blurred, then blocky, picture.
|
|
947
1242
|
*/
|
|
948
|
-
function videoArguments(input, selection, targetAddress, target, sequence,
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
'-an',
|
|
953
|
-
'-c:v',
|
|
954
|
-
'copy',
|
|
955
|
-
...outputArguments(targetAddress, target, selection.payloadType, selection.ssrc, selection.mtu, sequence),
|
|
956
|
-
];
|
|
1243
|
+
function videoArguments(input, selection, targetAddress, target, sequence, encoder) {
|
|
1244
|
+
const output = outputArguments(targetAddress, target, selection.payloadType, selection.ssrc, selection.mtu, sequence);
|
|
1245
|
+
if (encoder === 'copy') {
|
|
1246
|
+
return [...commonArguments('h264'), '-an', '-c:v', 'copy', ...output];
|
|
957
1247
|
}
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
1248
|
+
return [...commonArguments(input.codec === 'h265' ? 'hevc' : input.codec), ...encodeArguments(encoder, selection), ...output];
|
|
1249
|
+
}
|
|
1250
|
+
/** What one encoder is given to code `selection`, between its input and its output, in a session and in qualification alike. */
|
|
1251
|
+
function encodeArguments(encoder, selection) {
|
|
962
1252
|
return [
|
|
963
|
-
...commonArguments(input.codec === 'h265' ? 'hevc' : input.codec),
|
|
964
1253
|
'-an',
|
|
965
1254
|
'-c:v',
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
'superfast',
|
|
969
|
-
'-tune',
|
|
970
|
-
'zerolatency',
|
|
971
|
-
'-profile:v',
|
|
972
|
-
selection.profile,
|
|
973
|
-
'-level:v',
|
|
974
|
-
selection.level,
|
|
975
|
-
'-pix_fmt',
|
|
976
|
-
'yuv420p',
|
|
1255
|
+
encoder.name,
|
|
1256
|
+
...encoder.args(selection, `${videoBudgetInsideCeiling(selection)}k`),
|
|
977
1257
|
'-vf',
|
|
978
|
-
`scale=${selection.width}:${selection.height}:force_original_aspect_ratio=decrease,pad=${selection.width}:${selection.height}:(ow-iw)/2:(oh-ih)/2`,
|
|
1258
|
+
`scale=${selection.width}:${selection.height}:force_original_aspect_ratio=decrease,pad=${selection.width}:${selection.height}:(ow-iw)/2:(oh-ih)/2${encoder.upload ?? ''}`,
|
|
979
1259
|
'-fpsmax',
|
|
980
1260
|
String(selection.fps),
|
|
981
1261
|
'-g',
|
|
@@ -984,8 +1264,6 @@ function videoArguments(input, selection, targetAddress, target, sequence, copy
|
|
|
984
1264
|
String(selection.fps * 2),
|
|
985
1265
|
'-sc_threshold',
|
|
986
1266
|
'0',
|
|
987
|
-
...rate,
|
|
988
|
-
...outputArguments(targetAddress, target, selection.payloadType, selection.ssrc, selection.mtu, sequence),
|
|
989
1267
|
];
|
|
990
1268
|
}
|
|
991
1269
|
/** FLV signature, version 1, the audio-only flag, header size 9, and the zero size of the tag before the first. */
|