@torrent-tv/proxy 2.97.4 → 2.97.6
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/package.json +1 -1
- package/services/encode/EncodeInputs.js +14 -0
- package/services/encode/EncodeRun.js +42 -4
- package/services/encode/EncodeRuns.js +150 -41
- package/services/encode/InputFailures.js +51 -4
- package/services/encode/piece-completeness.js +60 -6
- package/services/encode/segment-formats/presentation-segment.js +139 -22
package/package.json
CHANGED
|
@@ -25,6 +25,8 @@ function keep(readings, value) {
|
|
|
25
25
|
/** Event-driven preparation; a synchronous run request can take only ready input. */
|
|
26
26
|
export class EncodeInputs {
|
|
27
27
|
#requests = new Map();
|
|
28
|
+
/** Whether an output's last resolved input was its original file. @type {WeakMap<object, boolean>} */
|
|
29
|
+
#original = new WeakMap();
|
|
28
30
|
#revision = 0;
|
|
29
31
|
#memoryRevision = 0;
|
|
30
32
|
#held = 0;
|
|
@@ -130,6 +132,17 @@ export class EncodeInputs {
|
|
|
130
132
|
}
|
|
131
133
|
}
|
|
132
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Whether this output reads its original file, as its last resolved input
|
|
137
|
+
* did, or undefined before any was resolved.
|
|
138
|
+
*
|
|
139
|
+
* @param {object} output
|
|
140
|
+
* @returns {boolean | undefined}
|
|
141
|
+
*/
|
|
142
|
+
readsOriginal(output) {
|
|
143
|
+
return this.#original.get(output);
|
|
144
|
+
}
|
|
145
|
+
|
|
133
146
|
failureOf(output) {
|
|
134
147
|
for (const request of this.#requests.values()) {
|
|
135
148
|
if (request.output === output && request.result?.kind === "terminal") return request.result;
|
|
@@ -262,6 +275,7 @@ export class EncodeInputs {
|
|
|
262
275
|
request.timing.attempts += 1;
|
|
263
276
|
request.promise = (async () => {
|
|
264
277
|
const resolved = await this.#resolveTimed(request.timing, request.output, request.from, request.from);
|
|
278
|
+
if (resolved.kind === "result") this.#original.set(request.output, resolved.sources?.every(source => source.input.original === true) === true);
|
|
265
279
|
if (this.#requests.get(request.key) !== request) return;
|
|
266
280
|
let result;
|
|
267
281
|
if (resolved.kind !== "result") {
|
|
@@ -116,6 +116,12 @@ export class EncodeRun {
|
|
|
116
116
|
/** Half a name left over from the last chunk of the encoder's own channel. */
|
|
117
117
|
#closedTail = "";
|
|
118
118
|
#pendingClosed = null;
|
|
119
|
+
/**
|
|
120
|
+
* The last closed piece when this run was stopped, published once the file
|
|
121
|
+
* the muxer flushes after it is named — the file that says where the next
|
|
122
|
+
* keyframe's leading pictures end it — or at the exit when none is.
|
|
123
|
+
*/
|
|
124
|
+
#pendingAtStop = null;
|
|
119
125
|
#closedTimings = new Map();
|
|
120
126
|
#publicationError = null;
|
|
121
127
|
#publicationCode = null;
|
|
@@ -226,6 +232,9 @@ export class EncodeRun {
|
|
|
226
232
|
* encoder itself names it on its own channel, and answers with the name that
|
|
227
233
|
* piece is served under — because making it servable is a rename, and only
|
|
228
234
|
* whoever owns the disk can perform one.
|
|
235
|
+
* @param {(name: string) => void} [params.onTail] - Called with the working
|
|
236
|
+
* name of a file this run closed but will not publish: past its last cut, or
|
|
237
|
+
* flushed when it was stopped. It begins with the next keyframe.
|
|
229
238
|
* @param {(index: number) => void} [params.onProduced] - Called with every
|
|
230
239
|
* segment number this run has closed, as it closes it.
|
|
231
240
|
* @param {(progress: object) => void} [params.onProgress] - Called with this
|
|
@@ -262,6 +271,7 @@ export class EncodeRun {
|
|
|
262
271
|
onEnded,
|
|
263
272
|
onSpeedMeasured,
|
|
264
273
|
onClosed,
|
|
274
|
+
onTail,
|
|
265
275
|
onProgress,
|
|
266
276
|
onProduced,
|
|
267
277
|
lastSegmentIndex,
|
|
@@ -292,6 +302,7 @@ export class EncodeRun {
|
|
|
292
302
|
// Told the NAME of every piece the encoder has closed. The name is the
|
|
293
303
|
// proof it is whole; nothing else here can prove that.
|
|
294
304
|
this.onClosed = typeof onClosed === "function" ? onClosed : (name) => name;
|
|
305
|
+
this.onTail = typeof onTail === "function" ? onTail : () => {};
|
|
295
306
|
this.lastSegmentIndex = typeof lastSegmentIndex === "function" ? lastSegmentIndex : () => null;
|
|
296
307
|
this.inputUnavailable = typeof inputUnavailable === "function" ? inputUnavailable : () => false;
|
|
297
308
|
this.argsDescribed = argsDescribed;
|
|
@@ -605,7 +616,18 @@ export class EncodeRun {
|
|
|
605
616
|
if (name.length === 0) {
|
|
606
617
|
continue;
|
|
607
618
|
}
|
|
608
|
-
if (this.#
|
|
619
|
+
if (this.#publicationError) continue;
|
|
620
|
+
if (this.#stopping) {
|
|
621
|
+
// Flushed by our stop. Never published; it tells where the piece closed
|
|
622
|
+
// before the stop ends, and it begins with the next keyframe.
|
|
623
|
+
if (this.#pendingAtStop !== null) {
|
|
624
|
+
const pending = this.#pendingAtStop;
|
|
625
|
+
this.#pendingAtStop = null;
|
|
626
|
+
this.#publishClosed(pending, name, { quiet: true });
|
|
627
|
+
}
|
|
628
|
+
this.onTail(name);
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
609
631
|
if (csv) {
|
|
610
632
|
this.#closedTimings.set(name, {
|
|
611
633
|
endMicros: BigInt(Math.round(Number(csv[3]) * 1_000_000))
|
|
@@ -626,6 +648,7 @@ export class EncodeRun {
|
|
|
626
648
|
// A packet crossing the final cut can flush a following file. It may
|
|
627
649
|
// complete its predecessor, but no unadmitted interval can be published.
|
|
628
650
|
if (Number.isInteger(index) && Number.isInteger(endOfWork) && index > endOfWork) {
|
|
651
|
+
this.onTail(name);
|
|
629
652
|
this.#closedTimings.delete(name);
|
|
630
653
|
this.#pendingClosed = null;
|
|
631
654
|
continue;
|
|
@@ -634,7 +657,7 @@ export class EncodeRun {
|
|
|
634
657
|
}
|
|
635
658
|
}
|
|
636
659
|
|
|
637
|
-
#publishClosed(name, following = null) {
|
|
660
|
+
#publishClosed(name, following = null, { quiet = false } = {}) {
|
|
638
661
|
this.#measureSpeed();
|
|
639
662
|
// ITS SERVED NAME, which is what whoever owns the disk gives it in answer.
|
|
640
663
|
// ffmpeg writes a piece under a working name and reports that; the piece
|
|
@@ -644,6 +667,14 @@ export class EncodeRun {
|
|
|
644
667
|
try {
|
|
645
668
|
served = this.onClosed(name, following, this.#closedTimings.get(name) ?? null);
|
|
646
669
|
} catch (error) {
|
|
670
|
+
if (quiet) {
|
|
671
|
+
// A piece closed before our stop that cannot be proved whole is not
|
|
672
|
+
// published, and it is not a failure of the run: it is made again
|
|
673
|
+
// where it is wanted.
|
|
674
|
+
this.logger?.warn?.(`encode-run #${this.from}..#${this.to} on ${this.address}: ${name} closed before the stop is not published: ` +
|
|
675
|
+
`${error instanceof Error ? error.message : String(error)}`);
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
647
678
|
this.#publicationError = error instanceof Error ? error.message : String(error);
|
|
648
679
|
this.#publicationCode = error?.code ?? null;
|
|
649
680
|
this.lastError = this.#publicationError;
|
|
@@ -713,9 +744,11 @@ export class EncodeRun {
|
|
|
713
744
|
return;
|
|
714
745
|
}
|
|
715
746
|
// A cut closed before our stop stays complete; the file flushed by the
|
|
716
|
-
// subsequent signal must never be published as that same proof.
|
|
747
|
+
// subsequent signal must never be published as that same proof. It is
|
|
748
|
+
// waited for all the same: it says where the next keyframe's leading
|
|
749
|
+
// pictures end the piece before it (torrent-tv/meta#178).
|
|
717
750
|
if (this.#pendingClosed !== null && !this.#processExited && !this.#inputTruncated) {
|
|
718
|
-
this.#
|
|
751
|
+
this.#pendingAtStop = this.#pendingClosed;
|
|
719
752
|
this.#pendingClosed = null;
|
|
720
753
|
}
|
|
721
754
|
this.#stopping = true;
|
|
@@ -845,6 +878,11 @@ export class EncodeRun {
|
|
|
845
878
|
return;
|
|
846
879
|
}
|
|
847
880
|
if (this.#stopping) {
|
|
881
|
+
if (this.#pendingAtStop !== null) {
|
|
882
|
+
const pending = this.#pendingAtStop;
|
|
883
|
+
this.#pendingAtStop = null;
|
|
884
|
+
this.#publishClosed(pending, null, { quiet: true });
|
|
885
|
+
}
|
|
848
886
|
this.#finish(ENCODE_EXIT.STOPPED, this.#stopReason, code, signal);
|
|
849
887
|
return;
|
|
850
888
|
}
|
|
@@ -12,13 +12,13 @@ import { spawn } from "node:child_process";
|
|
|
12
12
|
import { contentionPenalty } from "./contention.js";
|
|
13
13
|
import { ENCODE_RUN_STATE, liveRunsOf, runStateOf, wireState } from "./encode-run-state.js";
|
|
14
14
|
import { ENCODE_EXIT } from "./encode-exit.js";
|
|
15
|
-
import { EncodeRun } from "./EncodeRun.js";
|
|
15
|
+
import { EncodeRun, endOfRun } from "./EncodeRun.js";
|
|
16
16
|
import { computeOutputDimensions } from "./args.js";
|
|
17
17
|
import { buildAdmittedCommand } from "./admitted-command.js";
|
|
18
18
|
import { buildOriginalCommand } from "./source-command.js";
|
|
19
19
|
import { writeAdmittedInput } from "./AdmittedInput.js";
|
|
20
|
-
import { cutOf, cutShiftOf, judgeNeighbors, judgePiece } from "./piece-completeness.js";
|
|
21
|
-
import { presentationSegment } from "./segment-formats/presentation-segment.js";
|
|
20
|
+
import { cutOf, cutShiftOf, judgeNeighbors, judgePiece, leadingSecondsOf, lacksOnlyItsLeadingPictures } from "./piece-completeness.js";
|
|
21
|
+
import { leadingPictures, presentationSegment } from "./segment-formats/presentation-segment.js";
|
|
22
22
|
import { InputFailures, failedAdmittedInput } from "./InputFailures.js";
|
|
23
23
|
import { readProcessCpuSeconds } from "./host-load.js";
|
|
24
24
|
|
|
@@ -174,6 +174,18 @@ export class EncodeRuns {
|
|
|
174
174
|
|
|
175
175
|
#lastInputToken = 0;
|
|
176
176
|
#inputFailures = new InputFailures();
|
|
177
|
+
/**
|
|
178
|
+
* The leading pictures of a keyframe as a run that had the group before it
|
|
179
|
+
* in hand made them, by output and the number of the piece that keyframe
|
|
180
|
+
* opens: taken from the file a run closes past its last cut, and given to
|
|
181
|
+
* the run that starts at that keyframe, which cannot make them
|
|
182
|
+
* (torrent-tv/meta#178). Null where that file said the keyframe has none,
|
|
183
|
+
* which is as much an answer; an entry goes when that piece is published or
|
|
184
|
+
* the output is gone.
|
|
185
|
+
*
|
|
186
|
+
* @type {Map<string, Map<number, Buffer | null>>}
|
|
187
|
+
*/
|
|
188
|
+
#leadingByOutput = new Map();
|
|
177
189
|
/** What this reads and asks of the rest of the proxy, and nothing else. @type {object} */
|
|
178
190
|
#host;
|
|
179
191
|
|
|
@@ -302,7 +314,13 @@ export class EncodeRuns {
|
|
|
302
314
|
runStateOf(output) {
|
|
303
315
|
if (this.#host.encodeInputs?.failureOf(output)) return ENCODE_RUN_STATE.ENDED_FAILED;
|
|
304
316
|
if (output && this.liveRunsOf(output).length === 0 && this.#inputFailures.reason(output.outputKey)) return ENCODE_RUN_STATE.ENDED_FAILED;
|
|
305
|
-
|
|
317
|
+
const state = output ? this.#host.encodeOrchestrator.stateOf(output.outputKey) : runStateOf([]);
|
|
318
|
+
// A piece of a longer input failed and is to be made from an input holding
|
|
319
|
+
// it alone: production has not ended, and a request for it is held rather
|
|
320
|
+
// than refused (torrent-tv/meta#178).
|
|
321
|
+
if (state === ENCODE_RUN_STATE.ENDED_FAILED && this.liveRunsOf(output).length === 0 &&
|
|
322
|
+
this.#inputFailures.retrying(output.outputKey)) return ENCODE_RUN_STATE.IDLE;
|
|
323
|
+
return state;
|
|
306
324
|
}
|
|
307
325
|
|
|
308
326
|
lastErrorOf(output) {
|
|
@@ -365,9 +383,10 @@ export class EncodeRuns {
|
|
|
365
383
|
* @param {Buffer | null} [bytes]
|
|
366
384
|
* @param {EncodeRun | null} [run] - The run that closed it, which keeps where its muxer counts cuts from.
|
|
367
385
|
* @param {{ endMicros: bigint } | null} [timing] - The piece's end in the muxer's clock, from its segment list.
|
|
386
|
+
* @param {string | null} [following] - The working name of the file the muxer closed after it.
|
|
368
387
|
* @returns {object | null | false}
|
|
369
388
|
*/
|
|
370
|
-
#wholeClosedPiece(session, name, index, admitted = false, bytes = null, run = null, timing = null) {
|
|
389
|
+
#wholeClosedPiece(session, name, index, admitted = false, bytes = null, run = null, timing = null, following = null) {
|
|
371
390
|
|
|
372
391
|
const format = session.segmentFormat;
|
|
373
392
|
if (!format?.readMediaRanges || !Number.isInteger(index) || index < 0) {
|
|
@@ -385,6 +404,12 @@ export class EncodeRuns {
|
|
|
385
404
|
.map(input => [input.track.type === "video" ? "vide" : "soun", input.sourceEndSeconds])) } : {}),
|
|
386
405
|
requiredKinds: [session.spec.carries !== "audio-only" ? "vide" : null,
|
|
387
406
|
session.spec.audio && !this.#host.servesAudioSeparately(session) ? "soun" : null].filter(Boolean) } : undefined;
|
|
407
|
+
// THE NEXT KEYFRAME'S LEADING PICTURES end this piece before its cut. The
|
|
408
|
+
// muxer cut where that keyframe is, and the file it closed next begins
|
|
409
|
+
// with it: its pictures shown before it are the span (torrent-tv/meta#178).
|
|
410
|
+
if (mediaRanges && following && session.spec.carries !== "audio-only") {
|
|
411
|
+
mediaRanges.nextLeadingSeconds = leadingSecondsOf(format.readMediaRanges(this.#host.segmentFiles.closedBytesOf(address, following)));
|
|
412
|
+
}
|
|
388
413
|
if (run) {
|
|
389
414
|
// Where the muxer counts this run's cuts from is known once its first
|
|
390
415
|
// piece has closed, and holds for every piece after it. It is a fact
|
|
@@ -392,6 +417,12 @@ export class EncodeRuns {
|
|
|
392
417
|
if (run.cutShiftSeconds === null && index === run.from) {
|
|
393
418
|
run.cutShiftSeconds = cutShiftOf(mediaRanges, grid?.[run.from], session.spec.carries !== "audio-only" ? "vide" : "soun",
|
|
394
419
|
timing?.endMicros ?? null);
|
|
420
|
+
// Said once per run: a piece judged on the wrong clock is refused by
|
|
421
|
+
// a millisecond, and nothing else in the log says which clock it was
|
|
422
|
+
// judged on (torrent-tv/meta#178).
|
|
423
|
+
this.#host.logger.info(`encode-run #${run.from}..#${run.to} on ${address}: its muxer counts cuts ` +
|
|
424
|
+
(run.cutShiftSeconds === null ? "from a first packet more than a frame away from its start (shift unknown)"
|
|
425
|
+
: `${run.cutShiftSeconds.toFixed(6)}s from its start at ${grid?.[run.from]}s`));
|
|
395
426
|
}
|
|
396
427
|
if (mediaRanges && run.cutShiftSeconds !== null) mediaRanges.cutShiftSeconds = run.cutShiftSeconds;
|
|
397
428
|
}
|
|
@@ -400,6 +431,8 @@ export class EncodeRuns {
|
|
|
400
431
|
this.#host.logger.warn(
|
|
401
432
|
`encode: not publishing piece ${index} of ${address.slice(0, 60)}: ` +
|
|
402
433
|
(reason ?? (throughSeconds === null ? "it holds no playable media" : `produced through ${throughSeconds}s, cut ${cut}s`)) +
|
|
434
|
+
` shift=${Number.isFinite(mediaRanges?.cutShiftSeconds) ? mediaRanges.cutShiftSeconds : "none"}` +
|
|
435
|
+
` nextLeading=${mediaRanges?.nextLeadingSeconds ?? "none"}` +
|
|
403
436
|
(interval ? ` expected=${interval.from}..${interval.to} tracks=${JSON.stringify(mediaRanges.tracks.map(track => ({
|
|
404
437
|
kind: track.kind, timescale: track.timescale, frame: track.productionFrame,
|
|
405
438
|
first: track.ranges[0], last: track.ranges.at(-1), count: track.ranges.length })),
|
|
@@ -414,7 +447,18 @@ export class EncodeRuns {
|
|
|
414
447
|
const ranges = this.#host.segmentFiles.mediaRangesOf(address, neighbor,
|
|
415
448
|
{ startSeconds: grid[neighbor] });
|
|
416
449
|
if (!ranges) continue;
|
|
417
|
-
const
|
|
450
|
+
const [left, right] = neighbor < index ? [ranges, mediaRanges] : [mediaRanges, ranges];
|
|
451
|
+
const judged = judgeNeighbors(left, right);
|
|
452
|
+
// The one discontinuity that is known and not a fault of either
|
|
453
|
+
// piece: the right one's leading pictures, which a run starting at
|
|
454
|
+
// its keyframe cannot make and no run before it left. The gap is
|
|
455
|
+
// exactly the span the left one states it ends short by, and the
|
|
456
|
+
// right one begins at its keyframe.
|
|
457
|
+
if (!judged.whole && judged.reason?.startsWith("neighbor-discontinuity") && lacksOnlyItsLeadingPictures(left, right)) {
|
|
458
|
+
this.#host.logger.warn(`encode: publishing piece ${index} of ${address} beside a piece without its leading ` +
|
|
459
|
+
`pictures (${left.nextLeadingSeconds}s): the picture stands that long at #${Math.max(index, neighbor)}`);
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
418
462
|
if (!judged.whole) {
|
|
419
463
|
this.#host.logger.warn(`encode: not publishing piece ${index} of ${address}: ${judged.reason}`);
|
|
420
464
|
return false;
|
|
@@ -430,30 +474,25 @@ export class EncodeRuns {
|
|
|
430
474
|
}
|
|
431
475
|
|
|
432
476
|
/**
|
|
433
|
-
* The media intervals of a stored piece of this output
|
|
434
|
-
*
|
|
435
|
-
*
|
|
477
|
+
* The media intervals of a stored piece of this output, or undefined.
|
|
478
|
+
*
|
|
479
|
+
* A piece is stored under its served name only once it was judged whole,
|
|
480
|
+
* when it was published — by this process, or by the one before it, whose
|
|
481
|
+
* pieces are adopted on that ground (`SegmentStore.adoptWhatSurvived`). So
|
|
482
|
+
* this only reads. It used to judge every piece again on every progress
|
|
483
|
+
* report and delete what it found short: a second place for one verdict,
|
|
484
|
+
* with a read that removed files, and a rule that could not know a piece
|
|
485
|
+
* ends before its cut by the next keyframe's leading pictures
|
|
486
|
+
* (torrent-tv/meta#178).
|
|
436
487
|
*
|
|
437
488
|
* @param {HlsSession} output
|
|
438
489
|
* @param {number} index
|
|
439
490
|
* @returns {object | undefined}
|
|
440
491
|
*/
|
|
441
492
|
finishedMediaRangesOf(output, index) {
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
const ranges = this.#host.segmentFiles.mediaRangesOf(address, index, {
|
|
445
|
-
startSeconds: timeline.publishedStartOf(index)
|
|
493
|
+
return this.#host.segmentFiles.mediaRangesOf(output.outputKey ?? "", index, {
|
|
494
|
+
startSeconds: output.timeline.publishedStartOf(index)
|
|
446
495
|
});
|
|
447
|
-
if (!ranges || !output.segmentFormat?.producedThroughSeconds) {
|
|
448
|
-
return ranges;
|
|
449
|
-
}
|
|
450
|
-
const cut = cutOf(timeline, index);
|
|
451
|
-
const { whole, throughSeconds } = judgePiece(output.segmentFormat, ranges, cut);
|
|
452
|
-
if (whole || throughSeconds === null) {
|
|
453
|
-
return ranges;
|
|
454
|
-
}
|
|
455
|
-
this.#host.segmentFiles.remove(address, index, `produced through ${throughSeconds}s, short of its cut at ${cut}s`);
|
|
456
|
-
return undefined;
|
|
457
496
|
}
|
|
458
497
|
|
|
459
498
|
progressOf(output, index = null) {
|
|
@@ -578,10 +617,49 @@ export class EncodeRuns {
|
|
|
578
617
|
return byOutput;
|
|
579
618
|
}
|
|
580
619
|
|
|
620
|
+
/** Whether a run copies the picture from the original file it reads. */
|
|
621
|
+
#copiesOriginalPicture(session, admittedInput) {
|
|
622
|
+
return Boolean(admittedInput?.original) && !session.spec.transcodesVideo && session.spec.carries !== "audio-only";
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Whether a run placed at `startIndex` reads from the keyframe before it:
|
|
627
|
+
* a copied picture read from its original file, the piece before made or
|
|
628
|
+
* being made — so a viewer goes on from it into this one — and nothing
|
|
629
|
+
* known of the leading pictures of the keyframe at the start.
|
|
630
|
+
*/
|
|
631
|
+
#startsAtKeyframeBefore(session, startIndex) {
|
|
632
|
+
if (!(startIndex > 0) || session.spec.transcodesVideo || session.spec.carries === "audio-only" ||
|
|
633
|
+
session.segmentFormat?.id !== "fmp4" || this.#host.encodeInputs?.readsOriginal?.(session) !== true) return false;
|
|
634
|
+
if (this.#leadingByOutput.get(session.outputKey ?? "")?.has(startIndex)) return false;
|
|
635
|
+
const before = startIndex - 1;
|
|
636
|
+
return this.#host.producedNumbers(session).includes(before) ||
|
|
637
|
+
this.liveRunsOf(session).some(run => run.from <= before && endOfRun(run) >= before);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Keep what a run's unpublished file says of the keyframe it begins with:
|
|
642
|
+
* its leading pictures, or that it has none. Only for a piece not made yet.
|
|
643
|
+
*/
|
|
644
|
+
#keepLeadingPictures(session, admittedInput, name) {
|
|
645
|
+
if (!this.#copiesOriginalPicture(session, admittedInput) || session.segmentFormat?.id !== "fmp4") return;
|
|
646
|
+
const address = session.outputKey ?? "";
|
|
647
|
+
const index = session.segmentFormat.segmentIndexFromName(session.segmentFormat.servedNameOf?.(name) ?? name);
|
|
648
|
+
if (!Number.isInteger(index) || this.#host.producedNumbers(session).includes(index)) return;
|
|
649
|
+
try {
|
|
650
|
+
const byIndex = this.#leadingByOutput.get(address) ?? new Map();
|
|
651
|
+
byIndex.set(index, leadingPictures(this.#host.segmentFiles.closedBytesOf(address, name)));
|
|
652
|
+
this.#leadingByOutput.set(address, byIndex);
|
|
653
|
+
} catch (error) {
|
|
654
|
+
this.#host.logger.warn(`encode: could not read the leading pictures of ${name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
581
658
|
forgetEncodingOfGone(output) {
|
|
582
659
|
this.#host.encodeInputs?.forget(output);
|
|
583
660
|
if (this.#host.outputs.outputsOn(output.outputKey).length === 0) {
|
|
584
661
|
this.#inputFailures.forget(output.outputKey);
|
|
662
|
+
this.#leadingByOutput.delete(output.outputKey);
|
|
585
663
|
this.#host.encodeOrchestrator.forgetOpens?.(output.outputKey);
|
|
586
664
|
this.#host.encodeOrchestrator.forgetOutput(output.outputKey);
|
|
587
665
|
}
|
|
@@ -801,17 +879,27 @@ export class EncodeRuns {
|
|
|
801
879
|
if (!this.isLive(session)) {
|
|
802
880
|
return null;
|
|
803
881
|
}
|
|
882
|
+
// A COPIED PICTURE STARTED AT ITS KEYFRAME cannot make that keyframe's
|
|
883
|
+
// leading pictures: they reference the group before. Where the piece
|
|
884
|
+
// before is made and nothing is known of this keyframe's leading pictures,
|
|
885
|
+
// the run starts one keyframe earlier and publishes from its start, so the
|
|
886
|
+
// group before is in hand (torrent-tv/meta#178).
|
|
887
|
+
const fromKeyframeBefore = this.#startsAtKeyframeBefore(session, startIndex);
|
|
888
|
+
const inputStart = fromKeyframeBefore ? startIndex - 1 : startIndex;
|
|
804
889
|
// A copy held for a start that no live run ends before and that is not
|
|
805
890
|
// being started now goes back (torrent-tv/meta#184).
|
|
806
891
|
this.#host.encodeInputs?.retain(session, this.#host.encodeOrchestrator.wantedSegmentsOn(session.outputKey),
|
|
807
|
-
new Set([
|
|
892
|
+
new Set([inputStart, ...this.liveRunsOf(session).map((run) => run.to + 1)]));
|
|
808
893
|
// THE LAST NUMBER THIS RUN MAY REACH is the plan's; how far its input
|
|
809
894
|
// really reaches is the input's answer. An original input is one copied
|
|
810
895
|
// stretch, and the run is given exactly that stretch (torrent-tv/meta#158).
|
|
811
896
|
const segmentCount = Number(session.timeline?.segmentCount) || 0;
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
const
|
|
897
|
+
// A piece whose longer input failed is made from an input holding it alone,
|
|
898
|
+
// so a run reaches no further than the number before it.
|
|
899
|
+
const mayReach = this.#inputFailures.boundFor(session.outputKey, startIndex,
|
|
900
|
+
Number.isInteger(ordered?.to) && ordered.to >= startIndex ? ordered.to
|
|
901
|
+
: segmentCount > startIndex ? segmentCount - 1 : startIndex);
|
|
902
|
+
const admittedInput = this.#host.encodeInputs?.take(session, inputStart, mayReach) ?? null;
|
|
815
903
|
if (!admittedInput) return null;
|
|
816
904
|
const inputEnd = admittedInput.original ? admittedInput.to : startIndex;
|
|
817
905
|
// The stretch is part of an original input's identity: two stretches from
|
|
@@ -906,7 +994,7 @@ export class EncodeRuns {
|
|
|
906
994
|
const inputToken = this.#lastInputToken;
|
|
907
995
|
if (admittedInput) admittedInput.runTag = `${startIndex}r${inputToken}`;
|
|
908
996
|
const buildCommand = admittedInput.original ? buildOriginalCommand : buildAdmittedCommand;
|
|
909
|
-
const
|
|
997
|
+
const built = buildCommand({
|
|
910
998
|
admittedInput,
|
|
911
999
|
inputToken,
|
|
912
1000
|
baseUrl: this.#host.localBaseUrl,
|
|
@@ -921,12 +1009,18 @@ export class EncodeRuns {
|
|
|
921
1009
|
audioSeparate: this.#host.servesAudioSeparately(session),
|
|
922
1010
|
audioSourceTrackIndex: session.spec.audioSourceTrackIndex,
|
|
923
1011
|
rateControl: session.spec.video?.encode?.rateControl ?? null,
|
|
924
|
-
startIndex,
|
|
1012
|
+
startIndex: inputStart,
|
|
925
1013
|
endIndex: admittedInput.original ? inputEnd
|
|
926
1014
|
: Number.isInteger(ordered?.to) ? ordered.to : runEnd >= startIndex ? runEnd : session.timeline.segmentCount - 1,
|
|
927
1015
|
videoEncoder: this.#host.videoEncoder,
|
|
928
1016
|
segmentDurationSec: this.#host.segmentDurationSec
|
|
929
1017
|
});
|
|
1018
|
+
const { args, cutTimes } = built;
|
|
1019
|
+
// The piece before the start, made only to have its group in hand, is
|
|
1020
|
+
// closed under a number below the run's and never published; the run
|
|
1021
|
+
// itself begins where it publishes.
|
|
1022
|
+
const safeIndex = fromKeyframeBefore ? startIndex : built.safeIndex;
|
|
1023
|
+
const startSeconds = fromKeyframeBefore ? session.timeline.publishedStartOf(startIndex) : built.startSeconds;
|
|
930
1024
|
|
|
931
1025
|
// The exact command, every run. An encode failure is otherwise reported
|
|
932
1026
|
// with ffmpeg's message and nothing about what it was asked to do, and the
|
|
@@ -997,27 +1091,39 @@ export class EncodeRuns {
|
|
|
997
1091
|
// complete input or overwrite a neighboring run's segment.
|
|
998
1092
|
if (Number.isInteger(index) && (index < safeIndex || index > run.to)) return null;
|
|
999
1093
|
let bytes = null;
|
|
1000
|
-
|
|
1001
|
-
|
|
1094
|
+
const address = session.outputKey ?? "";
|
|
1095
|
+
const fmp4 = admittedInput && session.segmentFormat.id === "fmp4" && session.spec.carries !== "audio-only";
|
|
1096
|
+
// The first piece of a run that read the picture from its keyframe:
|
|
1097
|
+
// its leading pictures are the ones the run before made, or none.
|
|
1098
|
+
const firstOfRun = fmp4 && this.#copiesOriginalPicture(session, admittedInput) &&
|
|
1099
|
+
index === run.from && index > 0 && !fromKeyframeBefore;
|
|
1100
|
+
const soundInPiece = fmp4 && session.spec.audio && !this.#host.servesAudioSeparately(session);
|
|
1101
|
+
if (firstOfRun || soundInPiece) {
|
|
1002
1102
|
try {
|
|
1003
|
-
const address = session.outputKey ?? "";
|
|
1004
1103
|
const grid = session.timeline.published ?? session.timeline.boundaries;
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1104
|
+
const raw = this.#host.segmentFiles.closedBytesOf(address, name);
|
|
1105
|
+
const leading = firstOfRun ? this.#leadingByOutput.get(address)?.get(index) ?? null : null;
|
|
1106
|
+
bytes = presentationSegment(raw, soundInPiece && following ? this.#host.segmentFiles.closedBytesOf(address, following) : null,
|
|
1107
|
+
{ from: grid[index], to: grid[index + 1] }, { firstOfRun, leading });
|
|
1008
1108
|
} catch (error) {
|
|
1009
|
-
this.#host.logger.warn(`encode: could not
|
|
1109
|
+
this.#host.logger.warn(`encode: could not prepare closed segment ${index}: ${error.message}`);
|
|
1010
1110
|
throw error;
|
|
1011
1111
|
}
|
|
1012
1112
|
}
|
|
1013
|
-
const mediaRanges = this.#wholeClosedPiece(session, name, index, admittedInput, bytes, run, timing);
|
|
1113
|
+
const mediaRanges = this.#wholeClosedPiece(session, name, index, admittedInput, bytes, run, timing, following);
|
|
1014
1114
|
if (mediaRanges === false) throw Object.assign(new Error(`Closed piece #${index} has incomplete media.`),
|
|
1015
1115
|
{ code: "ERR_INCOMPLETE_MEDIA" });
|
|
1016
|
-
const published = this.#host.segmentFiles.publish(
|
|
1116
|
+
const published = this.#host.segmentFiles.publish(address, name, session.segmentFormat, { mediaRanges, bytes });
|
|
1017
1117
|
if (!published) throw new Error(`Closed piece #${index} could not be stored.`);
|
|
1118
|
+
this.#leadingByOutput.get(address)?.delete(index);
|
|
1018
1119
|
return published;
|
|
1019
1120
|
|
|
1020
1121
|
},
|
|
1122
|
+
// A file closed past the run's last cut, or flushed when it was stopped:
|
|
1123
|
+
// never published, but it begins with the next keyframe, made with the
|
|
1124
|
+
// group before it in hand — the leading pictures the run that starts
|
|
1125
|
+
// there cannot make.
|
|
1126
|
+
onTail: (name) => this.#keepLeadingPictures(session, admittedInput, name),
|
|
1021
1127
|
onSpeedMeasured: () => this.#host.noteRunSpeedMeasured?.(session, run),
|
|
1022
1128
|
onEnded: (ended) => {
|
|
1023
1129
|
this.#runsByInputToken.delete(inputToken);
|
|
@@ -1099,7 +1205,7 @@ export class EncodeRuns {
|
|
|
1099
1205
|
if (inputKey && failedAdmittedInput({ ending: ENCODE_EXIT.FAILED,
|
|
1100
1206
|
code: error.code ? null : 1, because: error.message },
|
|
1101
1207
|
session.spec.transcodesVideo && this.#host.videoEncoder.kind !== "software")) {
|
|
1102
|
-
this.#inputFailures.note(session.outputKey, startIndex, inputKey, error.message);
|
|
1208
|
+
this.#inputFailures.note(session.outputKey, startIndex, inputKey, error.message, { pieces: inputEnd - startIndex + 1 });
|
|
1103
1209
|
this.#host.invalidateWaits(session);
|
|
1104
1210
|
this.#host.productionFailed(session);
|
|
1105
1211
|
}
|
|
@@ -1234,8 +1340,11 @@ export class EncodeRuns {
|
|
|
1234
1340
|
const index = run.head;
|
|
1235
1341
|
const key = run.admittedInputKeys.get(index);
|
|
1236
1342
|
if (key) {
|
|
1237
|
-
|
|
1238
|
-
this.#
|
|
1343
|
+
const pieces = [...run.admittedInputKeys.values()].filter(held => held === key).length;
|
|
1344
|
+
this.#inputFailures.note(session.outputKey, index, key, ended.because, { pieces });
|
|
1345
|
+
this.#host.logger.warn(pieces > 1
|
|
1346
|
+
? `encode: piece ${index} will be made again from an input holding it alone output=${session.outputKey} input=${key} (held ${pieces} pieces) reason=${ended.because}`
|
|
1347
|
+
: `encode: unchanged input will not be retried output=${session.outputKey} segment=${index} input=${key} reason=${ended.because}`);
|
|
1239
1348
|
}
|
|
1240
1349
|
}
|
|
1241
1350
|
this.#host.encodeOrchestrator.noteEnded(ended);
|
|
@@ -12,7 +12,18 @@ export function failedAdmittedInput(ended, hardwareEncode = false) {
|
|
|
12
12
|
return ended.ending === ENCODE_EXIT.SHORT || ended.ending === ENCODE_EXIT.FAILED;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* A failed input is retried only after its identity — source, file, ranges and
|
|
17
|
+
* what is stated about them — or the output parameters change.
|
|
18
|
+
*
|
|
19
|
+
* A failure of an input that held more pieces than the one that failed is not
|
|
20
|
+
* the end of the output: the piece is made again from an input holding it
|
|
21
|
+
* alone, which is another input (`retryAlone`). Only the failure of that input
|
|
22
|
+
* — or of any input holding one piece — ends production. Field 2026-10-11,
|
|
23
|
+
* torrent-tv/meta#178: one refused piece of a six-piece copy ended the viewer's
|
|
24
|
+
* playback with a 500 while the same piece made alone was published in an
|
|
25
|
+
* earlier session.
|
|
26
|
+
*/
|
|
16
27
|
export class InputFailures {
|
|
17
28
|
#failures = new Map();
|
|
18
29
|
|
|
@@ -28,11 +39,47 @@ export class InputFailures {
|
|
|
28
39
|
return null;
|
|
29
40
|
}
|
|
30
41
|
|
|
31
|
-
|
|
42
|
+
/** Why production of this output has ended, or null while every failed piece can still be made alone. */
|
|
43
|
+
reason(address) {
|
|
44
|
+
for (const entry of this.#failures.get(address)?.values() ?? []) if (!entry.retryAlone) return entry.reason;
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Whether a failed piece of this output waits to be made from an input holding it alone. */
|
|
49
|
+
retrying(address) {
|
|
50
|
+
for (const entry of this.#failures.get(address)?.values() ?? []) if (entry.retryAlone) return true;
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The last number a run starting at `from` may reach so that a piece waiting
|
|
56
|
+
* to be made alone is not taken again inside a longer input: the number
|
|
57
|
+
* before it, or `from` itself when the run starts there.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} address
|
|
60
|
+
* @param {number} from
|
|
61
|
+
* @param {number} to
|
|
62
|
+
* @returns {number}
|
|
63
|
+
*/
|
|
64
|
+
boundFor(address, from, to) {
|
|
65
|
+
let bound = to;
|
|
66
|
+
for (const [index, entry] of this.#failures.get(address) ?? []) {
|
|
67
|
+
if (!entry.retryAlone || index < from || index > bound) continue;
|
|
68
|
+
bound = index === from ? from : index - 1;
|
|
69
|
+
}
|
|
70
|
+
return bound;
|
|
71
|
+
}
|
|
32
72
|
|
|
33
|
-
|
|
73
|
+
/**
|
|
74
|
+
* @param {string} address
|
|
75
|
+
* @param {number} index - The piece that failed.
|
|
76
|
+
* @param {string} key
|
|
77
|
+
* @param {string} reason
|
|
78
|
+
* @param {{ pieces?: number }} [held] - How many pieces the failed input held.
|
|
79
|
+
*/
|
|
80
|
+
note(address, index, key, reason, { pieces = 1 } = {}) {
|
|
34
81
|
const entries = this.#failures.get(address) ?? new Map();
|
|
35
|
-
entries.set(index, { key, reason });
|
|
82
|
+
entries.set(index, { key, reason, retryAlone: pieces > 1 });
|
|
36
83
|
this.#failures.set(address, entries);
|
|
37
84
|
}
|
|
38
85
|
|
|
@@ -53,14 +53,15 @@ export function judgePiece(format, ranges, cutSeconds, interval) {
|
|
|
53
53
|
}
|
|
54
54
|
const shift = Number.isFinite(ranges?.cutShiftSeconds) ? ranges.cutShiftSeconds : 0;
|
|
55
55
|
if (interval) {
|
|
56
|
-
const reason = intervalFailure(ranges, { ...interval, shift });
|
|
56
|
+
const reason = intervalFailure(ranges, { ...interval, shift, nextLeadingSeconds: leadingOfNext(ranges) });
|
|
57
57
|
return reason ? { whole: false, throughSeconds, reason } : { whole: true, throughSeconds };
|
|
58
58
|
}
|
|
59
59
|
const cutMicros = Number.isFinite(cutSeconds) ? BigInt(Math.round((cutSeconds + shift) * 1_000_000)) : null;
|
|
60
60
|
if (cutMicros !== null && ranges?.tracks?.length) {
|
|
61
|
-
const
|
|
61
|
+
const leadMicros = BigInt(Math.round(leadingOfNext(ranges) * 1_000_000));
|
|
62
|
+
const whole = ranges.tracks.every(({ kind, timescale, ranges: held, positionErrorTicks = 0n }) => {
|
|
62
63
|
const end = held.at(-1)?.end;
|
|
63
|
-
return end !== undefined && (end + positionErrorTicks) * 1_000_000n >= cutMicros * timescale;
|
|
64
|
+
return end !== undefined && (end + positionErrorTicks) * 1_000_000n >= (cutMicros - (kind === "vide" ? leadMicros : 0n)) * timescale;
|
|
64
65
|
});
|
|
65
66
|
return { whole, throughSeconds };
|
|
66
67
|
}
|
|
@@ -70,6 +71,54 @@ export function judgePiece(format, ranges, cutSeconds, interval) {
|
|
|
70
71
|
return { whole: true, throughSeconds };
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
/**
|
|
75
|
+
* How long a picture's leading pictures are shown before it: the span from the
|
|
76
|
+
* first presented picture of a piece to its first decoded one, which for a
|
|
77
|
+
* copied picture is its keyframe. Zero without a picture or leading pictures.
|
|
78
|
+
*
|
|
79
|
+
* In an open GOP the pictures decoded right after a keyframe can be shown
|
|
80
|
+
* before it. They belong to the piece of their keyframe, so the piece before
|
|
81
|
+
* ends that much before its cut and this piece begins that much before it
|
|
82
|
+
* (torrent-tv/meta#178). A piece states the next keyframe's span as
|
|
83
|
+
* `nextLeadingSeconds`, read from the piece the muxer closed after it.
|
|
84
|
+
*
|
|
85
|
+
* @param {object | null | undefined} ranges - A piece's media intervals.
|
|
86
|
+
* @returns {number}
|
|
87
|
+
*/
|
|
88
|
+
export function leadingSecondsOf(ranges) {
|
|
89
|
+
const track = ranges?.tracks?.find(candidate => candidate.kind === "vide");
|
|
90
|
+
const first = track?.ranges?.[0];
|
|
91
|
+
if (!first || track.firstSampleStart === undefined || !(track.timescale > 0n)) return 0;
|
|
92
|
+
const lead = track.firstSampleStart - first.start;
|
|
93
|
+
return lead > 0n ? Number(lead) / Number(track.timescale) : 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Whether the only gap between two adjacent pieces is the right one's leading
|
|
98
|
+
* pictures, which it lacks: it begins at its keyframe, and the gap is the span
|
|
99
|
+
* the left one states it ends short by, within a frame. A run that starts at a
|
|
100
|
+
* keyframe cannot make those pictures and none was left by a run before it, so
|
|
101
|
+
* the picture stands that long — known, and no fault of either piece.
|
|
102
|
+
*
|
|
103
|
+
* @param {object} left
|
|
104
|
+
* @param {object} right
|
|
105
|
+
* @returns {boolean}
|
|
106
|
+
*/
|
|
107
|
+
export function lacksOnlyItsLeadingPictures(left, right) {
|
|
108
|
+
const before = left?.tracks?.find(track => track.kind === "vide");
|
|
109
|
+
const after = right?.tracks?.find(track => track.kind === "vide");
|
|
110
|
+
if (!before?.ranges?.length || !after?.ranges?.length || before.timescale !== after.timescale || leadingSecondsOf(right) !== 0) return false;
|
|
111
|
+
const lead = BigInt(Math.round(leadingOfNext(left) * Number(before.timescale)));
|
|
112
|
+
const gap = after.ranges[0].start - before.ranges.at(-1).end;
|
|
113
|
+
const frame = before.productionFrame ?? before.ranges.at(-1).frame;
|
|
114
|
+
return lead > 0n && gap > 0n && gap - lead <= frame && lead - gap <= frame;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The span a piece ends before its cut: the next keyframe's leading pictures. */
|
|
118
|
+
function leadingOfNext(ranges) {
|
|
119
|
+
return Number.isFinite(ranges?.nextLeadingSeconds) && ranges.nextLeadingSeconds > 0 ? ranges.nextLeadingSeconds : 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
73
122
|
/**
|
|
74
123
|
* How far after its requested start a run's segment muxer counts its cuts
|
|
75
124
|
* from, read from the run's FIRST piece, or `null` when that piece does not
|
|
@@ -143,13 +192,14 @@ export function judgeNeighbors(left, right) {
|
|
|
143
192
|
* soundtrack's at 1419.904 s against a final cut of 1419.993 s, and both last
|
|
144
193
|
* pieces were refused, so no viewer could reach the end of the episode.
|
|
145
194
|
*/
|
|
146
|
-
function intervalFailure(coverage, { from, to, requiredKinds, sourceEnds = {}, shift = 0, endsWithSource = false }) {
|
|
195
|
+
function intervalFailure(coverage, { from, to, requiredKinds, sourceEnds = {}, shift = 0, endsWithSource = false, nextLeadingSeconds = 0 }) {
|
|
147
196
|
if (!Number.isFinite(from) || !Number.isFinite(to) || !(to > from) || !requiredKinds?.length) return "segment-interval-is-not-declared";
|
|
148
197
|
for (const kind of requiredKinds) {
|
|
149
198
|
const track = coverage?.tracks?.find(track => track.kind === kind);
|
|
150
199
|
if (!track?.ranges?.length || !(track.timescale > 0n)) return `segment-missing-${kind}`;
|
|
151
200
|
const start = BigInt(Math.round((from + shift) * Number(track.timescale)));
|
|
152
|
-
const
|
|
201
|
+
const cutAt = to + shift - (kind === "vide" && !endsWithSource ? nextLeadingSeconds : 0);
|
|
202
|
+
const through = Number.isFinite(sourceEnds[kind]) ? Math.min(cutAt, sourceEnds[kind]) : cutAt;
|
|
153
203
|
if (!(through > from + shift)) return `segment-interval-is-empty-${kind}`;
|
|
154
204
|
const end = BigInt(Math.round(through * Number(track.timescale)));
|
|
155
205
|
const first = track.ranges[0];
|
|
@@ -158,7 +208,11 @@ function intervalFailure(coverage, { from, to, requiredKinds, sourceEnds = {}, s
|
|
|
158
208
|
// cadence. Account for that recorded position uncertainty at the boundary,
|
|
159
209
|
// without admitting another missing frame or widening internal gaps.
|
|
160
210
|
const positionError = track.positionErrorTicks ?? 0n;
|
|
161
|
-
|
|
211
|
+
// A piece starts at its first DECODED sample — a copied picture's keyframe;
|
|
212
|
+
// pictures shown before it are its leading pictures, and the walk below
|
|
213
|
+
// holds them to the same continuity as every other range.
|
|
214
|
+
const anchor = track.firstSampleStart ?? first.start;
|
|
215
|
+
if (anchor > start + frame + positionError || anchor < start - frame - positionError) return `segment-start-outside-interval-${kind}`;
|
|
162
216
|
// The first sample's cadence already bounds the interval start. A shorter
|
|
163
217
|
// final sample must not replace that bound when this range is traversed.
|
|
164
218
|
let reached = first.start;
|
|
@@ -1,48 +1,165 @@
|
|
|
1
1
|
import { readTrackEdits, walkBoxes } from "./mp4-boxes.js";
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* A closed piece as it is served.
|
|
5
|
+
*
|
|
6
|
+
* THE PICTURE STAYS CUT WHERE THE MUXER CUT IT: from its keyframe to the next,
|
|
7
|
+
* in decode order. In an open GOP the pictures decoded right after a keyframe
|
|
8
|
+
* can be shown before it — its leading pictures — and they reference the
|
|
9
|
+
* group before; they belong to the piece of their keyframe and are shown a
|
|
10
|
+
* little before its cut. Moving them into the previous piece with a copy of
|
|
11
|
+
* the keyframe (torrent-tv/meta#154) left a hole on every such cut: appending
|
|
12
|
+
* the next piece replaces that copy, and Media Source coded frame processing
|
|
13
|
+
* then removes every frame decoded after it up to the next random access point
|
|
14
|
+
* — the leading pictures. Measured in Chrome Canary 2026-10-11 on the pieces
|
|
15
|
+
* of a 1036p HEVC film: a hole of 0.292 s at 74.283 s; with the muxer's cut the
|
|
16
|
+
* buffer is continuous (torrent-tv/meta#178).
|
|
17
|
+
*
|
|
18
|
+
* A SOUNDTRACK carried in the same piece is cut by presentation time at the
|
|
19
|
+
* published grid, taking from the following piece what belongs before the cut.
|
|
20
|
+
*
|
|
21
|
+
* THE FIRST PIECE OF A RUN that started at its keyframe has leading pictures
|
|
22
|
+
* the run could not make: their references lie before the start, and FFmpeg,
|
|
23
|
+
* having no earlier packets, gives them presentation times at its own decode
|
|
24
|
+
* clock (74.2000 s for pictures shown at 73.991-74.241 s). They are removed,
|
|
25
|
+
* and where `leading` — the same keyframe's leading pictures from the run that
|
|
26
|
+
* made the piece before, which FFmpeg closes past that run's last cut — is
|
|
27
|
+
* given, those are put in their place on that run's decode clock.
|
|
28
|
+
*
|
|
29
|
+
* @param {Buffer} current - The closed piece, with its own init.
|
|
30
|
+
* @param {Buffer | null} following - The piece the muxer closed after it.
|
|
31
|
+
* @param {{ from: number, to: number }} interval - The piece's published stretch.
|
|
32
|
+
* @param {{ firstOfRun?: boolean, leading?: Buffer | null }} [options]
|
|
33
|
+
* @returns {Buffer}
|
|
34
|
+
*/
|
|
35
|
+
export function presentationSegment(current, following, { from, to }, { firstOfRun = false, leading = null } = {}) {
|
|
5
36
|
const own = packetsOf(current);
|
|
6
37
|
const next = following ? packetsOf(following) : null;
|
|
38
|
+
const before = leading ? packetsOf(leading) : null;
|
|
39
|
+
// A piece given the leading pictures of the run before is written on that
|
|
40
|
+
// run's clock, under its init: that run decoded the group before the
|
|
41
|
+
// keyframe, so its decode times lie earlier than this run's, and a decode
|
|
42
|
+
// time cannot lie before the clock's origin. Every track moves by the
|
|
43
|
+
// difference of the two inits' positions.
|
|
44
|
+
const spliced = firstOfRun && before ? splicedPicture(own, before) : null;
|
|
45
|
+
const clock = spliced ? before : own;
|
|
7
46
|
const tracks = [];
|
|
8
47
|
for (const [id, track] of own.tracks) {
|
|
9
48
|
const edit = own.edits.get(id);
|
|
10
49
|
if (!edit) throw new Error("A segment track has no declared clock.");
|
|
11
|
-
const
|
|
50
|
+
const move = spliced ? edit.offset - clock.edits.get(id).offset : 0n;
|
|
51
|
+
if (track.kind === "vide") {
|
|
52
|
+
const samples = spliced?.id === id ? spliced.samples : firstOfRun ? withoutLeading(track.samples, edit) : track.samples;
|
|
53
|
+
tracks.push({ id, samples: spliced?.id === id ? samples : samples.map(sample => ({ ...sample, dts: sample.dts + move })),
|
|
54
|
+
timedByDecode: firstOfRun });
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const samples = [...track.samples].map(sample => ({ ...sample, dts: sample.dts + move }));
|
|
12
58
|
if (next?.tracks.has(id)) {
|
|
13
59
|
const nextEdit = next.edits.get(id);
|
|
14
60
|
if (!nextEdit || nextEdit.timescale !== edit.timescale) throw new Error("Adjacent segment clocks differ.");
|
|
15
61
|
for (const sample of next.tracks.get(id).samples) {
|
|
16
|
-
samples.push({ ...sample, dts: sample.dts + nextEdit.offset - edit.offset });
|
|
62
|
+
samples.push({ ...sample, dts: sample.dts + nextEdit.offset - edit.offset + move });
|
|
17
63
|
}
|
|
18
64
|
}
|
|
19
65
|
const start = BigInt(Math.round(from * Number(edit.timescale)));
|
|
20
66
|
const end = BigInt(Math.round(to * Number(edit.timescale)));
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
67
|
+
const offset = clock.edits.get(id).offset;
|
|
68
|
+
const selected = samples.filter(sample => {
|
|
69
|
+
const pts = sample.dts + sample.composition + offset;
|
|
70
|
+
return pts < end && pts + sample.duration > start;
|
|
24
71
|
});
|
|
25
|
-
if (!
|
|
26
|
-
// An open-GOP picture before the cut can follow the next CRA in decode
|
|
27
|
-
// order. Retain the reference pictures through its last required DTS;
|
|
28
|
-
// dropping the CRA would make the recovered leading pictures undecodable.
|
|
29
|
-
const lastDecode = wanted.reduce((last, sample) => sample.dts > last ? sample.dts : last, -1n);
|
|
30
|
-
const firstDecode = wanted.reduce((first, sample) => first === null || sample.dts < first ? sample.dts : first, null);
|
|
31
|
-
const selected = track.kind === "vide" ? samples.filter(sample =>
|
|
32
|
-
sample.dts >= firstDecode && sample.dts <= lastDecode && sample.dts + sample.composition + edit.offset >= start) : wanted;
|
|
72
|
+
if (!selected.length) throw new Error("A presentation segment lacks required media.");
|
|
33
73
|
selected.sort((left, right) => left.dts < right.dts ? -1 : left.dts > right.dts ? 1 : 0);
|
|
34
74
|
tracks.push({ id, samples: selected });
|
|
35
75
|
}
|
|
76
|
+
return writePiece(clock.init, tracks);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The leading pictures of a piece's keyframe, as a piece of their own (the
|
|
81
|
+
* keyframe and the pictures decoded right after it that are shown before it),
|
|
82
|
+
* or null when the keyframe has none. Kept from a run's flushed file past its
|
|
83
|
+
* last cut for the run that starts at that keyframe.
|
|
84
|
+
*
|
|
85
|
+
* @param {Buffer} piece - A closed file, with its own init.
|
|
86
|
+
* @returns {Buffer | null}
|
|
87
|
+
*/
|
|
88
|
+
export function leadingPictures(piece) {
|
|
89
|
+
const own = packetsOf(piece);
|
|
90
|
+
const tracks = [];
|
|
91
|
+
for (const [id, track] of own.tracks) {
|
|
92
|
+
if (track.kind !== "vide") continue;
|
|
93
|
+
const edit = own.edits.get(id);
|
|
94
|
+
const count = leadingCount(track.samples, edit);
|
|
95
|
+
if (count > 0) tracks.push({ id, samples: track.samples.slice(0, count + 1) });
|
|
96
|
+
}
|
|
97
|
+
return tracks.length ? writePiece(own.init, tracks) : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** How many pictures decoded right after the first one are shown before it. */
|
|
101
|
+
function leadingCount(samples, edit) {
|
|
102
|
+
const first = samples[0];
|
|
103
|
+
if (!first || (first.flags & SAMPLE_IS_NON_SYNC) !== 0) return 0;
|
|
104
|
+
const shown = first.dts + first.composition + edit.offset;
|
|
105
|
+
let count = 0;
|
|
106
|
+
while (count + 1 < samples.length) {
|
|
107
|
+
const sample = samples[count + 1];
|
|
108
|
+
if (sample.dts + sample.composition + edit.offset >= shown) break;
|
|
109
|
+
count += 1;
|
|
110
|
+
}
|
|
111
|
+
return count;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A run's first picture samples without the leading pictures it could not make. */
|
|
115
|
+
function withoutLeading(samples, edit) {
|
|
116
|
+
return [samples[0], ...samples.slice(1 + leadingCount(samples, edit))];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The picture of a run's first piece with its own leading pictures replaced by
|
|
121
|
+
* the run before's, on the run before's clock: its keyframe and leading
|
|
122
|
+
* pictures as that run decoded them, then this run's pictures from the first
|
|
123
|
+
* one after them. Null — and the leading pictures are only removed — where the
|
|
124
|
+
* two do not begin with the same keyframe, the clocks differ, any track has no
|
|
125
|
+
* position in the other init, or the decode order would not move forward.
|
|
126
|
+
*/
|
|
127
|
+
function splicedPicture(own, before) {
|
|
128
|
+
for (const [id, track] of own.tracks) {
|
|
129
|
+
if (track.kind !== "vide") continue;
|
|
130
|
+
const edit = own.edits.get(id);
|
|
131
|
+
const theirs = before.tracks.get(id)?.samples;
|
|
132
|
+
const theirEdit = before.edits.get(id);
|
|
133
|
+
if (!edit || !theirs || theirs.length < 2 || !theirEdit || theirEdit.timescale !== edit.timescale) return null;
|
|
134
|
+
if ([...own.tracks.keys()].some(other => !before.edits.has(other))) return null;
|
|
135
|
+
const keyframe = track.samples[0];
|
|
136
|
+
if (keyframe.dts + keyframe.composition + edit.offset !== theirs[0].dts + theirs[0].composition + theirEdit.offset ||
|
|
137
|
+
!keyframe.bytes.equals(theirs[0].bytes)) return null;
|
|
138
|
+
const move = edit.offset - theirEdit.offset;
|
|
139
|
+
const rest = track.samples.slice(1 + leadingCount(track.samples, edit)).map(sample => ({ ...sample, dts: sample.dts + move }));
|
|
140
|
+
if (rest.length && theirs.at(-1).dts >= rest[0].dts) return null;
|
|
141
|
+
return { id, samples: [...theirs, ...rest] };
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** `sample_is_non_sync_sample` in the sample flags of ISO/IEC 14496-12. */
|
|
147
|
+
const SAMPLE_IS_NON_SYNC = 0x10000;
|
|
148
|
+
|
|
149
|
+
function writePiece(init, tracks) {
|
|
36
150
|
const makeMoof = offset => box("moof", full("mfhd", 0, 0, u32(1)), ...tracks.map(track => {
|
|
37
151
|
let decodeTime = track.samples[0].dts;
|
|
38
|
-
const entries = track.samples.map(sample => {
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
152
|
+
const entries = track.samples.map((sample, index) => {
|
|
153
|
+
// A picture taken from another run's decode clock keeps that clock, so
|
|
154
|
+
// its duration in decode order is the step to the next decode time. Any
|
|
155
|
+
// other sample keeps its own duration, and its presentation time is kept
|
|
156
|
+
// relative to the decode clock actually written here.
|
|
157
|
+
const following = track.samples[index + 1];
|
|
158
|
+
const duration = track.timedByDecode && following ? following.dts - sample.dts : sample.duration;
|
|
42
159
|
const composition = sample.dts + sample.composition - decodeTime;
|
|
43
|
-
decodeTime +=
|
|
160
|
+
decodeTime += duration;
|
|
44
161
|
return Buffer.concat([
|
|
45
|
-
u32(Number(
|
|
162
|
+
u32(Number(duration)), u32(sample.bytes.length), u32(sample.flags), i32(Number(composition))
|
|
46
163
|
]);
|
|
47
164
|
});
|
|
48
165
|
const payload = Buffer.concat([u32(track.samples.length), i32(offset), ...entries]);
|
|
@@ -52,7 +169,7 @@ export function presentationSegment(current, following, { from, to }) {
|
|
|
52
169
|
return box("traf", full("tfhd", 0, 0x20000, u32(track.id)), full("tfdt", 1, 0, clock), full("trun", 1, 0xf01, payload));
|
|
53
170
|
}));
|
|
54
171
|
const size = makeMoof(0).length;
|
|
55
|
-
return Buffer.concat([
|
|
172
|
+
return Buffer.concat([init, makeMoof(size + 8), box("mdat", ...tracks.flatMap(track => track.samples.map(sample => sample.bytes)))]);
|
|
56
173
|
}
|
|
57
174
|
|
|
58
175
|
function packetsOf(raw) {
|