@torrent-tv/proxy 2.80.10 → 2.80.12
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/CHANGELOG.md +1690 -1675
- package/CLAUDE.md +6 -0
- package/docs/download-architecture.md +22 -1
- package/docs/encode-architecture.md +150 -0
- package/package.json +50 -51
- package/services/encode/run-command.js +729 -707
- package/services/hls-session-manager.js +10172 -10388
- package/services/orchestrators/EncodeOrchestrator.js +726 -726
- package/services/output/LiveOutputs.js +267 -233
- package/services/priority/PriorityOrchestrator.js +278 -174
- package/test/decode-cost-fit.test.js +57 -0
- package/{test-measured/decode-cost.measured.js → test/decode-cost.test.js} +13 -36
- package/test/keyframe-table-budget.test.js +7 -1
- package/test/one-authority.test.js +220 -105
- package/test/piece-store-slow-disk.test.js +32 -2
- package/test/priority-map-per-output.test.js +211 -0
- package/test/quality-variants.test.js +1222 -1209
- package/test/tracks-begin-together.test.js +32 -13
- package/test/tunnel-renewal.test.js +34 -5
- package/test/two-viewers-one-picture.test.js +374 -347
- package/test/viewer-outputs.test.js +277 -278
- package/test-measured/decode-measurement.measured.js +0 -83
|
@@ -1,707 +1,729 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file The full argument list for one encoder run.
|
|
3
|
-
*
|
|
4
|
-
* What a run is given is a fact about WHAT is being produced and WHERE it
|
|
5
|
-
* begins, and about nothing else — not the session it belongs to, not who is
|
|
6
|
-
* watching, not how many viewers there are. It was a 377-line method of the
|
|
7
|
-
* session manager reading fifteen of its fields, which is why a run could only
|
|
8
|
-
* ever be built by that class, for the one session it holds.
|
|
9
|
-
*
|
|
10
|
-
* Stated here, over the material and the stretch alone, a run can be built by
|
|
11
|
-
* whoever needs one. That is what lets an output have more than a single
|
|
12
|
-
* encoder.
|
|
13
|
-
*
|
|
14
|
-
* **Nothing in this file runs anything.** It returns an argument list; spawning,
|
|
15
|
-
* killing and resuming belong to whoever owns the process.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
/** The name ffmpeg writes its own playlist to, and the name that is served. */
|
|
19
|
-
export const PLAYLIST_FILE_NAME = "index.m3u8";
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* What ffmpeg's own CLI subtracts from an input seek, and therefore what has to
|
|
23
|
-
* be added back to land where we asked.
|
|
24
|
-
*
|
|
25
|
-
* `fftools/ffmpeg_demux.c`, in `ifile_open`: when the container does not
|
|
26
|
-
* declare `AVFMT_SEEK_TO_PTS` — Matroska does not — and any stream carries
|
|
27
|
-
* B-frames, the seek target is moved back by `3*AV_TIME_BASE / 23` before
|
|
28
|
-
* `avformat_seek_file` is called. Its purpose is sound: such containers seek in
|
|
29
|
-
* decode order while the caller asks in presentation order, and with B-frames
|
|
30
|
-
* the two differ, so it backs off far enough to be sure of reaching the frame
|
|
31
|
-
* asked for.
|
|
32
|
-
*
|
|
33
|
-
* The consequence for a COPY is that asking for a keyframe lands on the one
|
|
34
|
-
* BEFORE it — deterministically, every time. Measured 2026-08-21 on a Matroska
|
|
35
|
-
* file with keyframes every 2 s: `-ss 10` produced a first segment starting at
|
|
36
|
-
* 8.000; `-ss 10.130435` produced one starting at 10.000. On MP4, where the
|
|
37
|
-
* heuristic does not fire, all of 10, 10.130435 and 10.2 produced 10.000 — so
|
|
38
|
-
* adding this is right in one case and harmless in the other.
|
|
39
|
-
*
|
|
40
|
-
* That landing is what `-segment_times` is measured from, while this code
|
|
41
|
-
* computes those offsets from the time it ASKED for. One keyframe interval
|
|
42
|
-
* apart, inherited by every cut of the run: 119 of 125 segments arriving a
|
|
43
|
-
* uniform 2.002 s early in the field, four times what a player bridges.
|
|
44
|
-
*
|
|
45
|
-
* Not applied when the picture is re-encoded: a re-encode decodes from the
|
|
46
|
-
* keyframe and discards frames up to the requested time, so its output already
|
|
47
|
-
* begins exactly where asked (measured the same day: `-ss 11` copied starts at
|
|
48
|
-
* 10.000, re-encoded at 11.000).
|
|
49
|
-
*/
|
|
50
|
-
export const SEEK_LANDING_OFFSET_SEC = 3 / 23;
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* A number of seconds as ffmpeg will accept it.
|
|
54
|
-
*
|
|
55
|
-
* `String(n)` switches to exponential notation below 1e-6, and ffmpeg's
|
|
56
|
-
* duration parser rejects that outright: a field session died on
|
|
57
|
-
* `Invalid duration for option ss: 3.3333333249174757e-7`, after which the
|
|
58
|
-
* transcode was in state `failed` and every segment request answered 500 for
|
|
59
|
-
* as long as the viewer kept trying. Anything under a millisecond is also not a
|
|
60
|
-
* real offset — it is the residue of subtracting two nearly equal floats — so
|
|
61
|
-
* it is dropped rather than passed on.
|
|
62
|
-
*
|
|
63
|
-
* @param {number} value
|
|
64
|
-
* @returns {string}
|
|
65
|
-
*/
|
|
66
|
-
export function ffmpegSeconds(value) {
|
|
67
|
-
if (!Number.isFinite(value) || Math.abs(value) < 0.001) {
|
|
68
|
-
return "0";
|
|
69
|
-
}
|
|
70
|
-
// Microsecond resolution, fixed notation, no trailing zero noise.
|
|
71
|
-
return value.toFixed(6).replace(/\.?0+$/, "");
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Which timeline an output's own ffmpeg works on.
|
|
76
|
-
*
|
|
77
|
-
* True — the COPY branch: the source's timestamps are kept (`-copyts`) and the
|
|
78
|
-
* output is re-labelled 0-based. Everything handed to the muxer is therefore
|
|
79
|
-
* stated in the source's terms, and everything read back out of a produced
|
|
80
|
-
* piece is 0-based.
|
|
81
|
-
*
|
|
82
|
-
* False — the re-encode branch: the output is labelled from the run's start on
|
|
83
|
-
* the 0-based timeline, and the muxer is addressed in those same terms.
|
|
84
|
-
*
|
|
85
|
-
* One predicate for both callers, because the two used to answer it separately
|
|
86
|
-
* and a disagreement between them is exactly what desynced picture from sound.
|
|
87
|
-
*
|
|
88
|
-
* @param {{ audioOnly?: boolean, timeline?: { cutGrid?: string }, transcodeVideo?: boolean }} material
|
|
89
|
-
* @returns {boolean}
|
|
90
|
-
*/
|
|
91
|
-
export function onKeyframeGridFor(material) {
|
|
92
|
-
return material?.audioOnly === true
|
|
93
|
-
? material?.timeline?.cutGrid === "keyframe"
|
|
94
|
-
: material?.transcodeVideo !== true;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* The boundary table the player is working from: the one its playlist was
|
|
99
|
-
* written from, falling back to the live table when no playlist was built from
|
|
100
|
-
* a table at all (no duration, so no synthetic playlist — and then nothing the
|
|
101
|
-
* player holds contradicts it).
|
|
102
|
-
*
|
|
103
|
-
* @param {{ published?: number[], boundaries?: number[] }} timeline
|
|
104
|
-
* @returns {number[]}
|
|
105
|
-
*/
|
|
106
|
-
export function publishedGridFor(timeline) {
|
|
107
|
-
return Array.isArray(timeline?.published) && timeline.published.length > 0
|
|
108
|
-
? timeline.published
|
|
109
|
-
: (timeline?.boundaries ?? []);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Where a run beginning at `index` must be positioned: the time the PLAYER was
|
|
114
|
-
* told that segment starts at.
|
|
115
|
-
*
|
|
116
|
-
* Two tables, deliberately: the live one is corrected as produced segments
|
|
117
|
-
* reveal where the file's cuts truly are, and those corrections are what let a
|
|
118
|
-
* re-encoded rung be forced onto a copied stream's real grid. But the playlist
|
|
119
|
-
* a player is holding was written once and never changes, so a position taken
|
|
120
|
-
* from the corrected table describes a timeline nobody sent the player. That is
|
|
121
|
-
* not a subtlety: it cost ten minutes of a dead film on 2026-08-17, the browser
|
|
122
|
-
* asking for two segments 1908 times each.
|
|
123
|
-
*
|
|
124
|
-
* @param {{ published?: number[], boundaries?: number[] }} timeline
|
|
125
|
-
* @param {number} index
|
|
126
|
-
* @param {number} segmentDurationSec - Used only when the file has no table at
|
|
127
|
-
* all, where a segment is a plain multiple of the nominal length.
|
|
128
|
-
* @returns {number}
|
|
129
|
-
*/
|
|
130
|
-
export function publishedStartTime(timeline, index, segmentDurationSec) {
|
|
131
|
-
const published = Array.isArray(timeline?.published) && timeline.published.length > 0 ? timeline.published : null;
|
|
132
|
-
const table = published ?? (Array.isArray(timeline?.boundaries) ? timeline.boundaries : []);
|
|
133
|
-
if (table.length === 0) {
|
|
134
|
-
return index * segmentDurationSec;
|
|
135
|
-
}
|
|
136
|
-
const clamped = Math.max(0, Math.min(index, table.length - 1));
|
|
137
|
-
return table[clamped];
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
* @param {number
|
|
266
|
-
*
|
|
267
|
-
* @param {
|
|
268
|
-
* @param {number} params.
|
|
269
|
-
*
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
// that
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
//
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
// the
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
// the
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
// file
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
const
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
"
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
//
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
"-
|
|
682
|
-
"
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
"-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
//
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
//
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @file The full argument list for one encoder run.
|
|
3
|
+
*
|
|
4
|
+
* What a run is given is a fact about WHAT is being produced and WHERE it
|
|
5
|
+
* begins, and about nothing else — not the session it belongs to, not who is
|
|
6
|
+
* watching, not how many viewers there are. It was a 377-line method of the
|
|
7
|
+
* session manager reading fifteen of its fields, which is why a run could only
|
|
8
|
+
* ever be built by that class, for the one session it holds.
|
|
9
|
+
*
|
|
10
|
+
* Stated here, over the material and the stretch alone, a run can be built by
|
|
11
|
+
* whoever needs one. That is what lets an output have more than a single
|
|
12
|
+
* encoder.
|
|
13
|
+
*
|
|
14
|
+
* **Nothing in this file runs anything.** It returns an argument list; spawning,
|
|
15
|
+
* killing and resuming belong to whoever owns the process.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The name ffmpeg writes its own playlist to, and the name that is served. */
|
|
19
|
+
export const PLAYLIST_FILE_NAME = "index.m3u8";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What ffmpeg's own CLI subtracts from an input seek, and therefore what has to
|
|
23
|
+
* be added back to land where we asked.
|
|
24
|
+
*
|
|
25
|
+
* `fftools/ffmpeg_demux.c`, in `ifile_open`: when the container does not
|
|
26
|
+
* declare `AVFMT_SEEK_TO_PTS` — Matroska does not — and any stream carries
|
|
27
|
+
* B-frames, the seek target is moved back by `3*AV_TIME_BASE / 23` before
|
|
28
|
+
* `avformat_seek_file` is called. Its purpose is sound: such containers seek in
|
|
29
|
+
* decode order while the caller asks in presentation order, and with B-frames
|
|
30
|
+
* the two differ, so it backs off far enough to be sure of reaching the frame
|
|
31
|
+
* asked for.
|
|
32
|
+
*
|
|
33
|
+
* The consequence for a COPY is that asking for a keyframe lands on the one
|
|
34
|
+
* BEFORE it — deterministically, every time. Measured 2026-08-21 on a Matroska
|
|
35
|
+
* file with keyframes every 2 s: `-ss 10` produced a first segment starting at
|
|
36
|
+
* 8.000; `-ss 10.130435` produced one starting at 10.000. On MP4, where the
|
|
37
|
+
* heuristic does not fire, all of 10, 10.130435 and 10.2 produced 10.000 — so
|
|
38
|
+
* adding this is right in one case and harmless in the other.
|
|
39
|
+
*
|
|
40
|
+
* That landing is what `-segment_times` is measured from, while this code
|
|
41
|
+
* computes those offsets from the time it ASKED for. One keyframe interval
|
|
42
|
+
* apart, inherited by every cut of the run: 119 of 125 segments arriving a
|
|
43
|
+
* uniform 2.002 s early in the field, four times what a player bridges.
|
|
44
|
+
*
|
|
45
|
+
* Not applied when the picture is re-encoded: a re-encode decodes from the
|
|
46
|
+
* keyframe and discards frames up to the requested time, so its output already
|
|
47
|
+
* begins exactly where asked (measured the same day: `-ss 11` copied starts at
|
|
48
|
+
* 10.000, re-encoded at 11.000).
|
|
49
|
+
*/
|
|
50
|
+
export const SEEK_LANDING_OFFSET_SEC = 3 / 23;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A number of seconds as ffmpeg will accept it.
|
|
54
|
+
*
|
|
55
|
+
* `String(n)` switches to exponential notation below 1e-6, and ffmpeg's
|
|
56
|
+
* duration parser rejects that outright: a field session died on
|
|
57
|
+
* `Invalid duration for option ss: 3.3333333249174757e-7`, after which the
|
|
58
|
+
* transcode was in state `failed` and every segment request answered 500 for
|
|
59
|
+
* as long as the viewer kept trying. Anything under a millisecond is also not a
|
|
60
|
+
* real offset — it is the residue of subtracting two nearly equal floats — so
|
|
61
|
+
* it is dropped rather than passed on.
|
|
62
|
+
*
|
|
63
|
+
* @param {number} value
|
|
64
|
+
* @returns {string}
|
|
65
|
+
*/
|
|
66
|
+
export function ffmpegSeconds(value) {
|
|
67
|
+
if (!Number.isFinite(value) || Math.abs(value) < 0.001) {
|
|
68
|
+
return "0";
|
|
69
|
+
}
|
|
70
|
+
// Microsecond resolution, fixed notation, no trailing zero noise.
|
|
71
|
+
return value.toFixed(6).replace(/\.?0+$/, "");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Which timeline an output's own ffmpeg works on.
|
|
76
|
+
*
|
|
77
|
+
* True — the COPY branch: the source's timestamps are kept (`-copyts`) and the
|
|
78
|
+
* output is re-labelled 0-based. Everything handed to the muxer is therefore
|
|
79
|
+
* stated in the source's terms, and everything read back out of a produced
|
|
80
|
+
* piece is 0-based.
|
|
81
|
+
*
|
|
82
|
+
* False — the re-encode branch: the output is labelled from the run's start on
|
|
83
|
+
* the 0-based timeline, and the muxer is addressed in those same terms.
|
|
84
|
+
*
|
|
85
|
+
* One predicate for both callers, because the two used to answer it separately
|
|
86
|
+
* and a disagreement between them is exactly what desynced picture from sound.
|
|
87
|
+
*
|
|
88
|
+
* @param {{ audioOnly?: boolean, timeline?: { cutGrid?: string }, transcodeVideo?: boolean }} material
|
|
89
|
+
* @returns {boolean}
|
|
90
|
+
*/
|
|
91
|
+
export function onKeyframeGridFor(material) {
|
|
92
|
+
return material?.audioOnly === true
|
|
93
|
+
? material?.timeline?.cutGrid === "keyframe"
|
|
94
|
+
: material?.transcodeVideo !== true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The boundary table the player is working from: the one its playlist was
|
|
99
|
+
* written from, falling back to the live table when no playlist was built from
|
|
100
|
+
* a table at all (no duration, so no synthetic playlist — and then nothing the
|
|
101
|
+
* player holds contradicts it).
|
|
102
|
+
*
|
|
103
|
+
* @param {{ published?: number[], boundaries?: number[] }} timeline
|
|
104
|
+
* @returns {number[]}
|
|
105
|
+
*/
|
|
106
|
+
export function publishedGridFor(timeline) {
|
|
107
|
+
return Array.isArray(timeline?.published) && timeline.published.length > 0
|
|
108
|
+
? timeline.published
|
|
109
|
+
: (timeline?.boundaries ?? []);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Where a run beginning at `index` must be positioned: the time the PLAYER was
|
|
114
|
+
* told that segment starts at.
|
|
115
|
+
*
|
|
116
|
+
* Two tables, deliberately: the live one is corrected as produced segments
|
|
117
|
+
* reveal where the file's cuts truly are, and those corrections are what let a
|
|
118
|
+
* re-encoded rung be forced onto a copied stream's real grid. But the playlist
|
|
119
|
+
* a player is holding was written once and never changes, so a position taken
|
|
120
|
+
* from the corrected table describes a timeline nobody sent the player. That is
|
|
121
|
+
* not a subtlety: it cost ten minutes of a dead film on 2026-08-17, the browser
|
|
122
|
+
* asking for two segments 1908 times each.
|
|
123
|
+
*
|
|
124
|
+
* @param {{ published?: number[], boundaries?: number[] }} timeline
|
|
125
|
+
* @param {number} index
|
|
126
|
+
* @param {number} segmentDurationSec - Used only when the file has no table at
|
|
127
|
+
* all, where a segment is a plain multiple of the nominal length.
|
|
128
|
+
* @returns {number}
|
|
129
|
+
*/
|
|
130
|
+
export function publishedStartTime(timeline, index, segmentDurationSec) {
|
|
131
|
+
const published = Array.isArray(timeline?.published) && timeline.published.length > 0 ? timeline.published : null;
|
|
132
|
+
const table = published ?? (Array.isArray(timeline?.boundaries) ? timeline.boundaries : []);
|
|
133
|
+
if (table.length === 0) {
|
|
134
|
+
return index * segmentDurationSec;
|
|
135
|
+
}
|
|
136
|
+
const clamped = Math.max(0, Math.min(index, table.length - 1));
|
|
137
|
+
return table[clamped];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Where a segment REALLY begins, when a produced piece has said so.
|
|
142
|
+
*
|
|
143
|
+
* The live table is corrected as produced pieces reveal where a file's cuts
|
|
144
|
+
* truly are; the published one is what the player was told and may never move.
|
|
145
|
+
* Where the two disagree, the live table is a measurement and the published one
|
|
146
|
+
* a prediction — and this answers with the measurement, or with nothing when
|
|
147
|
+
* they agree or nothing has been measured.
|
|
148
|
+
*
|
|
149
|
+
* @param {object} timeline
|
|
150
|
+
* @param {number} index
|
|
151
|
+
* @returns {number | undefined}
|
|
152
|
+
*/
|
|
153
|
+
export function trueStartOf(timeline, index) {
|
|
154
|
+
const live = Array.isArray(timeline?.boundaries) ? timeline.boundaries : null;
|
|
155
|
+
const published = Array.isArray(timeline?.published) ? timeline.published : null;
|
|
156
|
+
if (!live || !published || index < 0 || index >= live.length || index >= published.length) {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
return live[index] === published[index] ? undefined : live[index];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The cut times to hand ffmpeg for a run that starts at `startIndex`.
|
|
164
|
+
*
|
|
165
|
+
* Two adjustments, both of which cost a broken session to learn:
|
|
166
|
+
*
|
|
167
|
+
* - **Rebased.** `-segment_times` is measured from the start of the run, not
|
|
168
|
+
* of the file. Measured: starting at 12 s and asking for a cut at 18 s put
|
|
169
|
+
* it at 29.4 s — 12 + 18. So every boundary has the run's own start
|
|
170
|
+
* subtracted.
|
|
171
|
+
* - **Interior only.** The first boundary is where the run begins and the last
|
|
172
|
+
* is where the file ends; neither is a cut. Sending them would produce an
|
|
173
|
+
* empty leading segment and a spurious trailing one.
|
|
174
|
+
*
|
|
175
|
+
* @param {number[]} boundaries
|
|
176
|
+
* @param {number} startIndex
|
|
177
|
+
* @returns {number[] | null}
|
|
178
|
+
*/
|
|
179
|
+
export function segmentCutTimesFrom(boundaries, startIndex) {
|
|
180
|
+
if (!Array.isArray(boundaries) || boundaries.length < 2) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
const index = Number.isInteger(startIndex) && startIndex > 0 ? startIndex : 0;
|
|
184
|
+
if (index >= boundaries.length - 1) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
const base = boundaries[index];
|
|
188
|
+
const times = [];
|
|
189
|
+
for (let at = index + 1; at < boundaries.length - 1; at += 1) {
|
|
190
|
+
times.push(Number((boundaries[at] - base).toFixed(6)));
|
|
191
|
+
}
|
|
192
|
+
return times;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The largest keyframe time that does not exceed `target`, from a SORTED
|
|
197
|
+
* (ascending) array of keyframe times. Null when `target` is before the first
|
|
198
|
+
* keyframe or the array is empty — the caller then falls back to its unsnapped
|
|
199
|
+
* target.
|
|
200
|
+
*
|
|
201
|
+
* @param {number[]} keyframeTimes - Sorted ascending.
|
|
202
|
+
* @param {number} target
|
|
203
|
+
* @returns {number | null}
|
|
204
|
+
*/
|
|
205
|
+
export function nearestKeyframeAtOrBefore(keyframeTimes, target) {
|
|
206
|
+
let result = null;
|
|
207
|
+
for (const time of keyframeTimes) {
|
|
208
|
+
if (time > target) {
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
result = time;
|
|
212
|
+
}
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* How much later than a keyframe to ASK, so that ffmpeg lands on that keyframe.
|
|
218
|
+
*
|
|
219
|
+
* Bounded by half the distance to the next keyframe, which matters only where
|
|
220
|
+
* keyframes stand closer together than twice the offset. There no single value
|
|
221
|
+
* can satisfy both worlds — asking too little lands a keyframe early when the
|
|
222
|
+
* heuristic fires, asking too much lands a keyframe late when it does not — and
|
|
223
|
+
* the bound picks the smaller error, which is then under one keyframe interval
|
|
224
|
+
* and therefore under what a player bridges.
|
|
225
|
+
*
|
|
226
|
+
* @param {{ transcodeVideo?: boolean, file?: { keyframeTimes?: number[], keyframeTolerance?: number } }} material
|
|
227
|
+
* @param {number} keyframe - A real keyframe time the run is to begin at.
|
|
228
|
+
* @returns {number} Seconds to add to the request.
|
|
229
|
+
*/
|
|
230
|
+
export function seekLandingOffsetFor(material, keyframe) {
|
|
231
|
+
// A re-encode trims to the requested time itself, so it needs no help and
|
|
232
|
+
// must not be pushed past what it was asked for.
|
|
233
|
+
//
|
|
234
|
+
// AN OUTPUT CARRYING ONLY SOUND IS SUCH A RE-ENCODE, and asking about the
|
|
235
|
+
// picture missed it: the flag says whether the PICTURE is re-encoded, and an
|
|
236
|
+
// output with no picture answers no. So the whole offset was added to the
|
|
237
|
+
// soundtrack's own seek, and the sound then played 130 ms ahead of the picture
|
|
238
|
+
// from every restart onward — reported by the viewer 2026-09-06 as lips out of
|
|
239
|
+
// step with the voice from two minutes in, and measured: both outputs were
|
|
240
|
+
// repositioned to the same boundary and both given `-ss 166.963435`, after
|
|
241
|
+
// which the copied picture landed on the keyframe at 166.833 while the
|
|
242
|
+
// re-encoded sound began where it was asked.
|
|
243
|
+
if (material?.transcodeVideo === true || material?.audioOnly === true) {
|
|
244
|
+
return 0;
|
|
245
|
+
}
|
|
246
|
+
// A grid whose times are approximate needs that error added on top, or a name
|
|
247
|
+
// sitting just below its real keyframe seeks to before it and lands on the
|
|
248
|
+
// one before that. Only AVI declares one.
|
|
249
|
+
const tolerance = Number.isFinite(material?.file?.keyframeTolerance)
|
|
250
|
+
? Math.max(0, material.file.keyframeTolerance)
|
|
251
|
+
: 0;
|
|
252
|
+
const wanted = SEEK_LANDING_OFFSET_SEC + tolerance;
|
|
253
|
+
const times = Array.isArray(material?.file?.keyframeTimes) ? material.file.keyframeTimes : [];
|
|
254
|
+
const next = times.find((time) => time > keyframe + 0.001);
|
|
255
|
+
if (next === undefined) {
|
|
256
|
+
return wanted;
|
|
257
|
+
}
|
|
258
|
+
return Math.min(wanted, (next - keyframe) / 2);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Everything ffmpeg is told for one run.
|
|
263
|
+
*
|
|
264
|
+
* @param {object} params
|
|
265
|
+
* @param {{ keyframeTimes?: number[], keyframeTolerance?: number }} params.file - The
|
|
266
|
+
* PICTURE's file: whose keyframes a seek snaps to.
|
|
267
|
+
* @param {{ startTime: number }} params.inputFile - The file this run reads.
|
|
268
|
+
* @param {{ startTime: number }} params.audioFile - The file the chosen
|
|
269
|
+
* soundtrack lives in, which for a dub shipped beside the picture is not the
|
|
270
|
+
* picture's own.
|
|
271
|
+
* @param {string} params.inputUrl
|
|
272
|
+
* @param {string} params.audioInputUrl - Empty unless a browser that takes its
|
|
273
|
+
* audio muxed is watching a release whose soundtrack is a file of its own.
|
|
274
|
+
* @param {{ published?: number[], boundaries?: number[], cutGrid?: string }} params.timeline
|
|
275
|
+
* @param {{ encodeWidth: number, encodeHeight: number, outputFps: number, softwarePreset: string | null, applyTonemap: boolean }} params.output
|
|
276
|
+
* @param {object} params.segmentFormat
|
|
277
|
+
* @param {boolean} params.transcodeVideo
|
|
278
|
+
* @param {boolean} params.transcodeAudio
|
|
279
|
+
* @param {boolean} params.audioOnly - An audio rendition: one track, no picture.
|
|
280
|
+
* @param {boolean} params.audioSeparate - The picture's sound is published as a
|
|
281
|
+
* rendition, so this output carries none.
|
|
282
|
+
* @param {number} params.audioSourceTrackIndex - `0:a:N` within its own file.
|
|
283
|
+
* @param {number | null} params.rateCapKbps
|
|
284
|
+
* @param {number} params.startIndex - First segment number this run makes.
|
|
285
|
+
* @param {number} params.endIndex - Last it makes, inclusive; below the start
|
|
286
|
+
* means it has no end.
|
|
287
|
+
* @param {number | undefined} params.positionSecondsOverride - Where to begin,
|
|
288
|
+
* when the caller knows better than the table.
|
|
289
|
+
* @param {object} params.videoEncoder
|
|
290
|
+
* @param {number} params.segmentDurationSec
|
|
291
|
+
* @returns {{ args: string[], safeIndex: number, startSeconds: number, cutTimes: number[] | null }}
|
|
292
|
+
*/
|
|
293
|
+
export function buildRunCommand({
|
|
294
|
+
file,
|
|
295
|
+
inputFile,
|
|
296
|
+
audioFile,
|
|
297
|
+
inputUrl,
|
|
298
|
+
audioInputUrl: audioInputUrlGiven,
|
|
299
|
+
timeline,
|
|
300
|
+
output,
|
|
301
|
+
segmentFormat,
|
|
302
|
+
transcodeVideo,
|
|
303
|
+
transcodeAudio,
|
|
304
|
+
audioOnly,
|
|
305
|
+
audioSeparate,
|
|
306
|
+
audioSourceTrackIndex,
|
|
307
|
+
rateCapKbps,
|
|
308
|
+
startIndex,
|
|
309
|
+
endIndex,
|
|
310
|
+
positionSecondsOverride,
|
|
311
|
+
videoEncoder,
|
|
312
|
+
segmentDurationSec
|
|
313
|
+
}) {
|
|
314
|
+
const safeIndex = Number.isInteger(startIndex) && startIndex > 0 ? startIndex : 0;
|
|
315
|
+
// 0-based output time of this segment, from the table the PLAYER holds —
|
|
316
|
+
// the same one the cut list below is taken from.
|
|
317
|
+
//
|
|
318
|
+
// These two were read from different tables until 2026-08-21, and that is
|
|
319
|
+
// one fault, not two: `-segment_times` are measured from wherever the run
|
|
320
|
+
// really began, so any distance between the position and the cut list moves
|
|
321
|
+
// EVERY cut of that run by it. The live table keeps being corrected as
|
|
322
|
+
// produced segments reveal where the file's cuts truly are, and those
|
|
323
|
+
// corrections run backwards, so each restart began a little earlier than
|
|
324
|
+
// the grid the cuts were stated on — and since the corrections accumulate,
|
|
325
|
+
// so did the distance. Measured on `JUFD665.mp4`: after one seek restart a
|
|
326
|
+
// produced segment held the boundary two places before its own number
|
|
327
|
+
// (16.684 s, exactly 2.0000 segments), after the next it held the one four
|
|
328
|
+
// places before (33.5 s). The player's buffer then stops extending at all,
|
|
329
|
+
// because the content of every fragment lands before the time its playlist
|
|
330
|
+
// entry names: `bufferEnd` stood still at 4571.1 s through four `frag-far`
|
|
331
|
+
// warnings until hls.js gave up and jumped the viewer 16.8 s forward.
|
|
332
|
+
//
|
|
333
|
+
// 2.45.0 moved the CUT LIST onto the published table for this same reason
|
|
334
|
+
// and left the position on the live one. Both belong on the published
|
|
335
|
+
// table: a run must begin where the player was told the segment begins.
|
|
336
|
+
const startSeconds = Number.isFinite(positionSecondsOverride)
|
|
337
|
+
? positionSecondsOverride
|
|
338
|
+
: publishedStartTime(timeline, safeIndex, segmentDurationSec);
|
|
339
|
+
// Where each of the two timelines begins, asked of the files themselves.
|
|
340
|
+
// Fresh by construction: the session may have been created before the
|
|
341
|
+
// soundtrack file's header could be read, and the reading lands on the file
|
|
342
|
+
// object this session holds — so there is nothing to re-read and nothing
|
|
343
|
+
// that can be stale. Same property as `file.keyframeTimes`,
|
|
344
|
+
// which is one table shared by every session of the file.
|
|
345
|
+
// Which timeline this run works on. Asked once, because the closure that
|
|
346
|
+
// adds the second input reads it too, and two readings of one predicate is
|
|
347
|
+
// how the picture and the sound came apart before.
|
|
348
|
+
const keyframeGrid = onKeyframeGridFor({ audioOnly, timeline, transcodeVideo });
|
|
349
|
+
const servesAudioSeparately = audioOnly !== true && audioSeparate === true;
|
|
350
|
+
const audioFileStartTime = audioFile.startTime;
|
|
351
|
+
// The start time of the file this run READS, which is the picture's own for
|
|
352
|
+
// every session except one whose soundtrack is a separate file.
|
|
353
|
+
const sourceStartTime = inputFile.startTime;
|
|
354
|
+
// Cut where this session's grid says, whoever is producing the frames. The
|
|
355
|
+
// times are measured from the start of THIS run; the same list serves as
|
|
356
|
+
// the cut points and, when re-encoding, as the keyframes to force — one
|
|
357
|
+
// list, so the two cannot drift apart.
|
|
358
|
+
const explicitTimes = segmentFormat.explicitTimesMuxerArgs?.() ?? null;
|
|
359
|
+
// A COPY is cut by this list whatever grid it ended up on. Even when no
|
|
360
|
+
// keyframe index could be read and the boundaries are a plain grid, saying
|
|
361
|
+
// them outright is what keeps the playlist and the muxer agreeing — ffmpeg
|
|
362
|
+
// moves each cut forward to the first real keyframe, and serving reads back
|
|
363
|
+
// where the piece truly begins. Requiring a keyframe grid here dropped a
|
|
364
|
+
// copy with no index onto the `hls` muxer, which takes no cut list and
|
|
365
|
+
// writes no self-contained pieces, so nothing could read a true start and
|
|
366
|
+
// segments were stamped with times the file does not have — the 4.17 s
|
|
367
|
+
// speech-against-subtitles drift, back again.
|
|
368
|
+
//
|
|
369
|
+
// Cut on the grid the PLAYER WAS GIVEN, not on the corrected one. A player
|
|
370
|
+
// places a fragment by the playlist it holds, and that text was written
|
|
371
|
+
// once and never changes; the live table keeps moving as produced segments
|
|
372
|
+
// reveal where the file's cuts really are. Cutting on the moved table makes
|
|
373
|
+
// every run faithful to a timeline nobody sent the player — measured
|
|
374
|
+
// 2026-08-20, the picture's segments arriving a uniform 2.002 s before the
|
|
375
|
+
// times the playlist named for them, which is four times what hls.js will
|
|
376
|
+
// bridge, so the fragment does not land and is asked for again.
|
|
377
|
+
//
|
|
378
|
+
// The corrections keep their purpose: they describe the file, and a variant
|
|
379
|
+
// created later inherits the corrected table and PUBLISHES it, so its own
|
|
380
|
+
// playlist and its own cuts agree from the start. What they may not do is
|
|
381
|
+
// move the cuts of a session whose playlist is already being read.
|
|
382
|
+
const gridCutTimes = explicitTimes && (!transcodeVideo || timeline.cutGrid === "keyframe")
|
|
383
|
+
? segmentCutTimesFrom(publishedGridFor(timeline), safeIndex)
|
|
384
|
+
: null;
|
|
385
|
+
// Cut times are stated on the grid, for both branches.
|
|
386
|
+
//
|
|
387
|
+
// 2.28.0 added `sourceStartTime` to them on the copy branch, reasoning that
|
|
388
|
+
// the muxer decides its cuts before the output is relabelled. The field
|
|
389
|
+
// measured it the next session and the reasoning was wrong: of 75 pieces
|
|
390
|
+
// the picture produced, only NINE began at a time the container's own
|
|
391
|
+
// keyframe table names (the soundtrack, untouched by the change, scored 70
|
|
392
|
+
// of 75). Before it, every piece began exactly on a named keyframe and it
|
|
393
|
+
// was the PLAYLIST that disagreed with them. So the shift moved the cuts
|
|
394
|
+
// OFF the keyframes rather than onto them, and it is gone.
|
|
395
|
+
//
|
|
396
|
+
// What remains true, and is what that measurement is really about: the
|
|
397
|
+
// picture cuts where the source's keyframes are, and the playlist must be
|
|
398
|
+
// built from those same times. That is the correction path's job, not the
|
|
399
|
+
// cut list's.
|
|
400
|
+
const cutTimes = gridCutTimes;
|
|
401
|
+
|
|
402
|
+
// Video: re-encode only when required, using the detected encoder
|
|
403
|
+
// (hardware-accelerated or software). The descriptor builds the filter +
|
|
404
|
+
// codec args (including keyframe alignment on segment boundaries).
|
|
405
|
+
const videoCodecArgs = transcodeVideo
|
|
406
|
+
? videoEncoder.buildVideoArgs({
|
|
407
|
+
// Budget-selected encode box (may be below the client target on weak
|
|
408
|
+
// software hosts); falls back to the client target for hardware.
|
|
409
|
+
targetWidth: output.encodeWidth,
|
|
410
|
+
targetHeight: output.encodeHeight,
|
|
411
|
+
segmentDurationSec: segmentDurationSec,
|
|
412
|
+
// Source-inherited output rate (integer, capped); descriptors that
|
|
413
|
+
// use time-based keyframes just apply it as the frame rate.
|
|
414
|
+
fps: output.outputFps,
|
|
415
|
+
// Software-only; hardware descriptors ignore it.
|
|
416
|
+
preset: output.softwarePreset ?? undefined,
|
|
417
|
+
// HDR→SDR tone map (software path only; gated on filter availability).
|
|
418
|
+
tonemap: output.applyTonemap === true,
|
|
419
|
+
// On the source's grid the cuts are not evenly spaced, so no frame
|
|
420
|
+
// count can describe them: the encoder is told the times outright,
|
|
421
|
+
// the same ones the muxer will cut at.
|
|
422
|
+
forcedKeyframeTimes: cutTimes,
|
|
423
|
+
// A ceiling the VIEWER's measured link put on this picture, when one
|
|
424
|
+
// has been measured. Null means the rung's own nominal rate stands.
|
|
425
|
+
nominalKbps: rateCapKbps ?? null
|
|
426
|
+
})
|
|
427
|
+
: ["-c:v", "copy"];
|
|
428
|
+
const audioCodecArgs = transcodeAudio
|
|
429
|
+
? ["-c:a", "aac", "-ac", "2", "-b:a", "128k"]
|
|
430
|
+
: ["-c:a", "copy"];
|
|
431
|
+
|
|
432
|
+
const args = ["-hide_banner", "-nostats", "-loglevel", "error", "-progress", "pipe:1"];
|
|
433
|
+
// Hardware decode/encode setup (e.g. VAAPI device) must precede -i, and
|
|
434
|
+
// only applies when we actually re-encode the video track.
|
|
435
|
+
if (transcodeVideo && Array.isArray(videoEncoder.inputArgs)) {
|
|
436
|
+
args.push(...videoEncoder.inputArgs);
|
|
437
|
+
}
|
|
438
|
+
// Seek position in SOURCE time. On the keyframe grid `startSeconds` is a
|
|
439
|
+
// real keyframe's offset from zero, so the container's own start time goes
|
|
440
|
+
// back on to reach it; on the uniform grid it is a plain offset. This
|
|
441
|
+
// follows the GRID, not whether the video is re-encoded — a variant cut on
|
|
442
|
+
// the source's keyframes has to seek to them like the copy it accompanies.
|
|
443
|
+
const seekSeconds = timeline.cutGrid === "keyframe"
|
|
444
|
+
? startSeconds + sourceStartTime
|
|
445
|
+
: startSeconds;
|
|
446
|
+
// Two-step seek when we have a real keyframe map: jump to a KNOWN-valid
|
|
447
|
+
// keyframe (coarse, before -i — safe because WE sourced it from ffprobe,
|
|
448
|
+
// not the container's own on-the-fly seek/index) and trim the short
|
|
449
|
+
// residual (bounded by the keyframe interval) precisely AFTER -i, which is
|
|
450
|
+
// always frame-accurate regardless of -accurate_seek.
|
|
451
|
+
//
|
|
452
|
+
// Root cause this works around: `-accurate_seek -ss X` before -i trusts the
|
|
453
|
+
// CONTAINER's own seek to land near X. For some containers (observed: AVI
|
|
454
|
+
// with VBR MP3 audio) that on-the-fly seek can point at a position with no
|
|
455
|
+
// valid frame boundary at all — ffmpeg fails outright ("Seek failed" /
|
|
456
|
+
// "Header missing"), not just imprecisely, and repeatedly so since every
|
|
457
|
+
// retry re-tries the SAME bad container-computed position. A keyframe we
|
|
458
|
+
// read directly from the packet list is a position ffmpeg has already
|
|
459
|
+
// proven it can decode.
|
|
460
|
+
//
|
|
461
|
+
// The keyframe is CARRIED from the table that named this boundary, not looked
|
|
462
|
+
// up by value. A boundary is stored on the player's clock, rounded, and
|
|
463
|
+
// `seekSeconds` above puts the container's start time back on to reach the
|
|
464
|
+
// file's clock — a lossy round trip. A keyframe at 26.234 s in a container
|
|
465
|
+
// starting at 0.083 s comes back as 26.233999999999998, and "the keyframe at
|
|
466
|
+
// or before that" is then the PREVIOUS one, 8.717 s earlier: two parts in a
|
|
467
|
+
// quadrillion, one whole keyframe interval. That interval became a trim, the
|
|
468
|
+
// trim moved every cut of the run backwards by another interval, and the
|
|
469
|
+
// run's files were numbered from #36 while carrying film 17.4 s before what
|
|
470
|
+
// the playlist says #36 holds (field 2026-09-05: the picture covered the
|
|
471
|
+
// playhead, the sound had a 17.4 s hole across it, and the viewer waited two
|
|
472
|
+
// minutes for a buffer that could never fill).
|
|
473
|
+
//
|
|
474
|
+
// The search stays for a grid restored without its source clock, which is the
|
|
475
|
+
// only case that has nothing to carry.
|
|
476
|
+
const carriedKeyframe = timeline.cutGrid === "keyframe" && typeof timeline.sourceStartOf === "function"
|
|
477
|
+
? timeline.sourceStartOf(safeIndex)
|
|
478
|
+
: null;
|
|
479
|
+
const snappedKeyframe = Number.isFinite(carriedKeyframe)
|
|
480
|
+
? carriedKeyframe
|
|
481
|
+
: (Array.isArray(file.keyframeTimes) && file.keyframeTimes.length > 0
|
|
482
|
+
? nearestKeyframeAtOrBefore(file.keyframeTimes, seekSeconds)
|
|
483
|
+
: null);
|
|
484
|
+
// A second input, and it exists for exactly one case: a browser that takes
|
|
485
|
+
// its audio muxed into the picture, watching a release whose soundtrack is a
|
|
486
|
+
// file of its own. An audio RENDITION reads that file as its only input and
|
|
487
|
+
// has none of this — which is why the ordinary path, and every browser that
|
|
488
|
+
// understands rendition groups, still runs on a single input.
|
|
489
|
+
const audioInputUrl =
|
|
490
|
+
typeof audioInputUrlGiven === "string" && audioInputUrlGiven.length > 0
|
|
491
|
+
? audioInputUrlGiven
|
|
492
|
+
: "";
|
|
493
|
+
// Where the picture's own start sits on the soundtrack file's timeline. Both
|
|
494
|
+
// files begin at their own container start time, and those need not be the
|
|
495
|
+
// same number; the difference is what keeps the two aligned.
|
|
496
|
+
const audioTimelineShift = audioInputUrl
|
|
497
|
+
? audioFileStartTime - sourceStartTime
|
|
498
|
+
: 0;
|
|
499
|
+
/**
|
|
500
|
+
* Add the second input, if there is one, with its own seek.
|
|
501
|
+
*
|
|
502
|
+
* Called between the first `-i` and any OUTPUT option, because ffmpeg reads
|
|
503
|
+
* these positionally: an option written after the last `-i` applies to the
|
|
504
|
+
* output, and the residual seek below is exactly such an option. Getting the
|
|
505
|
+
* order wrong would silently turn the audio file's seek into a trim of the
|
|
506
|
+
* finished stream.
|
|
507
|
+
*
|
|
508
|
+
* @param {number} inputSeekSeconds - Where to start, on the PICTURE's
|
|
509
|
+
* timeline. Translated to the soundtrack file's own here.
|
|
510
|
+
*/
|
|
511
|
+
const pushAudioInput = (inputSeekSeconds) => {
|
|
512
|
+
if (!audioInputUrl) {
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
// `-itsoffset` states the soundtrack's timestamps on the picture's
|
|
516
|
+
// timeline, so everything after this point — `-copyts`, the output offset,
|
|
517
|
+
// the cut list — goes on treating the two as one timeline, unchanged.
|
|
518
|
+
//
|
|
519
|
+
// ONLY on the branch that keeps the source's own timestamps. Without
|
|
520
|
+
// `-copyts` ffmpeg rebases each input from its own seek point, and both
|
|
521
|
+
// inputs are seeked to the same instant just below — so the two are
|
|
522
|
+
// already aligned and adding the offset would pull them apart by exactly
|
|
523
|
+
// the amount it exists to remove.
|
|
524
|
+
if (audioTimelineShift !== 0 && keyframeGrid) {
|
|
525
|
+
args.push("-itsoffset", ffmpegSeconds(-audioTimelineShift));
|
|
526
|
+
}
|
|
527
|
+
const audioSeek = Math.max(0, inputSeekSeconds + audioTimelineShift);
|
|
528
|
+
if (audioSeek > 0) {
|
|
529
|
+
// No keyframe to snap to and none needed: every audio frame is a sync
|
|
530
|
+
// point, so the seek can be accurate outright.
|
|
531
|
+
args.push("-accurate_seek", "-ss", ffmpegSeconds(audioSeek));
|
|
532
|
+
}
|
|
533
|
+
args.push("-i", audioInputUrl);
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
if (snappedKeyframe !== null) {
|
|
537
|
+
const residualSeconds = Math.max(0, seekSeconds - snappedKeyframe);
|
|
538
|
+
if (snappedKeyframe > 0) {
|
|
539
|
+
args.push("-ss", ffmpegSeconds(snappedKeyframe + seekLandingOffsetFor({ audioOnly, transcodeVideo, file }, snappedKeyframe)));
|
|
540
|
+
}
|
|
541
|
+
args.push("-i", inputUrl);
|
|
542
|
+
// The coarse landing, not the exact target: the residual below is discarded
|
|
543
|
+
// from the OUTPUT and so takes the same slice off every stream. Seeking the
|
|
544
|
+
// soundtrack to the exact target as well would take that slice twice and
|
|
545
|
+
// leave the sound running ahead of the picture by it.
|
|
546
|
+
pushAudioInput(snappedKeyframe);
|
|
547
|
+
// An output-side trim, and ONLY where the output is labelled from zero.
|
|
548
|
+
//
|
|
549
|
+
// Beside `-copyts` it does the opposite of what it says. Measured
|
|
550
|
+
// 2026-09-06 on a file with a 5 s keyframe interval: a run landed at 15 s
|
|
551
|
+
// and asked to trim to the cut at 20 s produced its first file starting at
|
|
552
|
+
// 10 s, and a run asked to trim to 17 s produced cuts at 13.129, 18.129,
|
|
553
|
+
// 23.129 — the whole grid moved back by the trim itself. The muxer's cut
|
|
554
|
+
// times are absolute under `-copyts` while the trim is not, so every cut of
|
|
555
|
+
// the run inherits the difference and the numbering, fixed at spawn, is
|
|
556
|
+
// wrong by however many cuts that is.
|
|
557
|
+
//
|
|
558
|
+
// On the keyframe grid the run begins AT a cut, so there is nothing to
|
|
559
|
+
// trim: the carried keyframe above makes this exactly zero rather than
|
|
560
|
+
// nearly zero.
|
|
561
|
+
if (residualSeconds > 0 && !keyframeGrid) {
|
|
562
|
+
args.push("-ss", ffmpegSeconds(residualSeconds));
|
|
563
|
+
}
|
|
564
|
+
} else {
|
|
565
|
+
if (seekSeconds > 0) {
|
|
566
|
+
// No keyframe map (probe failed/timed out) — fall back to the previous
|
|
567
|
+
// behaviour: trust the container's own accurate seek.
|
|
568
|
+
args.push("-accurate_seek", "-ss", ffmpegSeconds(seekSeconds));
|
|
569
|
+
}
|
|
570
|
+
args.push("-i", inputUrl);
|
|
571
|
+
pushAudioInput(seekSeconds);
|
|
572
|
+
}
|
|
573
|
+
// Which timeline the output is labelled on. An audio rendition has no
|
|
574
|
+
// picture of its own to follow, so it follows the grid it was given — the
|
|
575
|
+
// same one the video it plays with is on. Deciding by `transcodeVideo`, as
|
|
576
|
+
// everything else here does, would put the audio of a re-encoded stream on
|
|
577
|
+
// the copy branch: `-copyts` and a shift by the container's start time,
|
|
578
|
+
// against a picture labelled from zero. The two would be offset by
|
|
579
|
+
// `sourceStartTime` for the whole file.
|
|
580
|
+
if (!keyframeGrid) {
|
|
581
|
+
// Branch A (re-encode): fixed GOP makes keyframes land exactly on the
|
|
582
|
+
// segment grid; relabel output onto the original timeline so segment N
|
|
583
|
+
// carries PTS = N × segmentDuration.
|
|
584
|
+
if (startSeconds > 0) {
|
|
585
|
+
args.push("-output_ts_offset", ffmpegSeconds(startSeconds));
|
|
586
|
+
}
|
|
587
|
+
} else {
|
|
588
|
+
// Branch B (video copied — only audio is transcoded): we cannot insert
|
|
589
|
+
// keyframes, so segments are cut at the source's own keyframes (the
|
|
590
|
+
// playlist boundaries were built from those keyframes). Keep the source's
|
|
591
|
+
// real timestamps (`-copyts`) so copied frames stay continuous across
|
|
592
|
+
// boundaries/seeks, and shift by -startTime so the output timeline is
|
|
593
|
+
// 0-based (a non-zero container start otherwise puts a hole at the very
|
|
594
|
+
// beginning and desyncs audio/video). Audio is transcoded on this timeline.
|
|
595
|
+
args.push("-copyts");
|
|
596
|
+
if (sourceStartTime !== 0) {
|
|
597
|
+
args.push("-output_ts_offset", ffmpegSeconds(-sourceStartTime));
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// Where this run STOPS. Until now a run had a start and no end — neither
|
|
601
|
+
// `-to` nor `-t` appeared anywhere in the arguments this proxy builds — so
|
|
602
|
+
// every stop was a kill from outside, and two runs on one output could only
|
|
603
|
+
// be kept apart by giving each its own directory. With an end they cannot
|
|
604
|
+
// reach each other's numbers at all, and a run that finishes its stretch
|
|
605
|
+
// exits by itself instead of having to be noticed and killed.
|
|
606
|
+
//
|
|
607
|
+
// WHICH argument states it is a property of the branch, and it is measured
|
|
608
|
+
// rather than reasoned (2026-09-04, `research/encoder-layer-2026-09-04.md`
|
|
609
|
+
// §11): `-t` is a duration on the output's own clock, and `-to` a point on
|
|
610
|
+
// the input's. The copy branch runs with `-copyts`, where the input's clock
|
|
611
|
+
// IS the source's, so `-to` takes the absolute time; the re-encode branch
|
|
612
|
+
// has no `-copyts` and takes the duration. Swapping them is not a near
|
|
613
|
+
// miss — on the copy branch `-t` produced one segment where five were
|
|
614
|
+
// wanted, because the time it names is already past when the run starts.
|
|
615
|
+
const runEnd = Number.isInteger(endIndex) ? endIndex : -1;
|
|
616
|
+
const publishedGrid = publishedGridFor(timeline);
|
|
617
|
+
if (runEnd >= safeIndex && Array.isArray(publishedGrid) && publishedGrid[runEnd + 1] > 0) {
|
|
618
|
+
const endsAt = publishedGrid[runEnd + 1];
|
|
619
|
+
if (transcodeVideo) {
|
|
620
|
+
args.push("-t", ffmpegSeconds(Math.max(0.1, endsAt - publishedGrid[safeIndex])));
|
|
621
|
+
} else {
|
|
622
|
+
args.push("-to", ffmpegSeconds(endsAt));
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
if (audioOnly === true) {
|
|
626
|
+
// An audio RENDITION: one track, no picture. Published as its own
|
|
627
|
+
// `#EXT-X-MEDIA` and shared by every video variant, so the track is
|
|
628
|
+
// encoded once for the file instead of once per rung, and changing it is
|
|
629
|
+
// the player switching rendition rather than this proxy rebuilding the
|
|
630
|
+
// session. Cut on the same grid as the video it accompanies, which is
|
|
631
|
+
// what lets the two be played together.
|
|
632
|
+
// `0:` because a rendition's only input IS the file its track lives in —
|
|
633
|
+
// the picture's own file, or the one beside it that carries this dub.
|
|
634
|
+
args.push("-vn", "-map", `0:a:${audioSourceTrackIndex}?`, ...audioCodecArgs);
|
|
635
|
+
} else if (servesAudioSeparately) {
|
|
636
|
+
// The other half of the same arrangement: the picture alone, because its
|
|
637
|
+
// audio is published as a rendition and would otherwise play twice.
|
|
638
|
+
args.push("-an", "-map", "0:v:0?", ...videoCodecArgs);
|
|
639
|
+
} else {
|
|
640
|
+
args.push(
|
|
641
|
+
"-map",
|
|
642
|
+
"0:v:0?",
|
|
643
|
+
"-map",
|
|
644
|
+
// The audio track the viewer chose: input 1 when their choice is a
|
|
645
|
+
// soundtrack shipped as its own file, input 0 when it is one of the
|
|
646
|
+
// picture's own. Type-relative within that input, which is what
|
|
647
|
+
// `audioSourceTrackIndex` holds — the number the browser sent is flat
|
|
648
|
+
// across both files and was resolved when the session was made.
|
|
649
|
+
`${audioInputUrl ? 1 : 0}:a:${audioSourceTrackIndex}?`,
|
|
650
|
+
...videoCodecArgs,
|
|
651
|
+
...audioCodecArgs
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Where the cuts come from. On the copy path they are the source's own
|
|
656
|
+
// keyframes, and until now they were only ever GUESSED: ffmpeg got a target
|
|
657
|
+
// duration and chose its own cut points, while the playlist was built from
|
|
658
|
+
// the container index — two independent calculations with nothing tying
|
|
659
|
+
// them together but the hope that they agree. They do not. The index is a
|
|
660
|
+
// navigation table and is not obliged to list every keyframe; for a field
|
|
661
|
+
// file it held 1902 while ffmpeg found roughly twice as many and cut twice
|
|
662
|
+
// as often. Segment #876 then meant 1:26:50 to the player and about minute
|
|
663
|
+
// 58 to ffmpeg, which is why a seek landed nowhere near where it was aimed
|
|
664
|
+
// and the reported duration drifted.
|
|
665
|
+
//
|
|
666
|
+
// So stop guessing and say it: the `segment` muxer takes the list of times
|
|
667
|
+
// outright. Passing the very boundaries the playlist was built from makes
|
|
668
|
+
// the two agree by construction. Only cut points already known to be real
|
|
669
|
+
// keyframes are sent, so ffmpeg never has to move one forward.
|
|
670
|
+
//
|
|
671
|
+
// The list is built above, before the encoder args, because a re-encoded
|
|
672
|
+
// variant of a copied stream needs the same times twice over: once as the
|
|
673
|
+
// cuts, once as the keyframes to force at them.
|
|
674
|
+
if (cutTimes && cutTimes.length > 0) {
|
|
675
|
+
args.push(
|
|
676
|
+
"-f",
|
|
677
|
+
"segment",
|
|
678
|
+
// Times are measured from the START OF THIS RUN, not from the start of
|
|
679
|
+
// the file — verified: starting at 12 s and asking for a cut at 18 s
|
|
680
|
+
// produced one at 29.4 s. `segmentCutTimesFrom` rebases them.
|
|
681
|
+
"-segment_times",
|
|
682
|
+
cutTimes.join(","),
|
|
683
|
+
// A cut lands on the first keyframe at or after its time, so a boundary
|
|
684
|
+
// recorded a hair late would skip to the next one and double the
|
|
685
|
+
// segment. The tolerance absorbs that rounding.
|
|
686
|
+
"-segment_time_delta",
|
|
687
|
+
"0.05",
|
|
688
|
+
"-segment_start_number",
|
|
689
|
+
String(safeIndex),
|
|
690
|
+
// THE ENCODER SAYS WHEN A PIECE IS FINISHED, on a channel of its own.
|
|
691
|
+
//
|
|
692
|
+
// Measured on the addon host 2026-09-05: a name appears in this list when
|
|
693
|
+
// the file is CLOSED, not when it is created — at the third sample
|
|
694
|
+
// `seg-000.mp4` was on disk and absent from the list, and it appeared at
|
|
695
|
+
// the fourth, in the same moment `seg-001.mp4` came into being. So a name
|
|
696
|
+
// here is the writer's own statement that the piece is whole.
|
|
697
|
+
//
|
|
698
|
+
// Without it, a finished file is indistinguishable from one still being
|
|
699
|
+
// written, and the only proof available was the existence of the NEXT
|
|
700
|
+
// one — which never comes for the last piece of every run.
|
|
701
|
+
"-segment_list",
|
|
702
|
+
"pipe:3",
|
|
703
|
+
"-segment_list_flags",
|
|
704
|
+
"+live",
|
|
705
|
+
...explicitTimes,
|
|
706
|
+
segmentFormat.segmentFileNameTemplate()
|
|
707
|
+
);
|
|
708
|
+
} else {
|
|
709
|
+
args.push(
|
|
710
|
+
"-f",
|
|
711
|
+
"hls",
|
|
712
|
+
"-hls_time",
|
|
713
|
+
String(segmentDurationSec),
|
|
714
|
+
"-hls_list_size",
|
|
715
|
+
"0",
|
|
716
|
+
"-hls_flags",
|
|
717
|
+
"independent_segments+temp_file",
|
|
718
|
+
// Container selection + segment naming, from the active format module.
|
|
719
|
+
...segmentFormat.muxerArgs(),
|
|
720
|
+
"-start_number",
|
|
721
|
+
String(safeIndex),
|
|
722
|
+
// ffmpeg writes its own playlist here; we ignore it and serve the
|
|
723
|
+
// synthetic VOD playlist instead (see getFileStream).
|
|
724
|
+
PLAYLIST_FILE_NAME
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
return { args, safeIndex, startSeconds, cutTimes };
|
|
728
|
+
|
|
729
|
+
}
|