@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.
Files changed (35) hide show
  1. package/config.schema.json +6 -0
  2. package/dist/configuration.js +2 -0
  3. package/dist/configuration.js.map +1 -1
  4. package/dist/diagnostics.js +86 -2
  5. package/dist/diagnostics.js.map +1 -1
  6. package/dist/homekit/adapters/lock.js +21 -0
  7. package/dist/homekit/adapters/lock.js.map +1 -1
  8. package/dist/media/live-preview.js +96 -0
  9. package/dist/media/live-preview.js.map +1 -0
  10. package/dist/media/live-stream.js +310 -32
  11. package/dist/media/live-stream.js.map +1 -1
  12. package/dist/platform.js +33 -2
  13. package/dist/platform.js.map +1 -1
  14. package/dist/runtime/channel.js +54 -1
  15. package/dist/runtime/channel.js.map +1 -1
  16. package/dist/runtime/owner.js +14 -1
  17. package/dist/runtime/owner.js.map +1 -1
  18. package/dist/ui/dashboard.js +4 -2
  19. package/dist/ui/dashboard.js.map +1 -1
  20. package/dist/ui/runtime-channel-client.js +46 -1
  21. package/dist/ui/runtime-channel-client.js.map +1 -1
  22. package/dist/ui/server.js +20 -0
  23. package/dist/ui/server.js.map +1 -1
  24. package/homebridge-ui/public/app.css +181 -0
  25. package/homebridge-ui/public/assets/icons/auto_fix_high.svg +1 -0
  26. package/homebridge-ui/public/assets/icons/play_arrow.svg +1 -0
  27. package/homebridge-ui/public/assets/icons/stop.svg +1 -0
  28. package/homebridge-ui/public/i18n/en.json +29 -1
  29. package/homebridge-ui/public/i18n/fr.json +29 -1
  30. package/homebridge-ui/public/index.html +63 -0
  31. package/homebridge-ui/public/js/app.js +318 -3
  32. package/homebridge-ui/public/js/dashboard.js +5 -2
  33. package/homebridge-ui/public/js/setup-wizard.js +52 -0
  34. package/i18n/runtime/en.json +1 -0
  35. 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 child = this.createProcess(this.executable, videoArguments(videoConfig, negotiated.video, targetAddress, transport.video, videoSequence, copyVideo, constantQuality));
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
- adaptationDiagnostics?.report({ role: 'live-video', event: 'started', cause: videoCause });
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
- * `constantQuality` codes at {@link CONSTANT_QUALITY_CRF} with no rate control, so the negotiated bit rate is not
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, copy = false, constantQuality = false) {
949
- if (copy && input.codec === 'h264') {
950
- return [
951
- ...commonArguments('h264'),
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
- const budget = videoBudgetInsideCeiling(selection);
959
- const rate = constantQuality
960
- ? ['-crf', String(CONSTANT_QUALITY_CRF)]
961
- : ['-b:v', `${budget}k`, '-maxrate', `${budget}k`, '-bufsize', `${budget}k`];
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
- 'libx264',
967
- '-preset',
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. */