@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.97.4",
3
+ "version": "2.97.6",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -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.#stopping || this.#publicationError) continue;
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.#publishClosed(this.#pendingClosed);
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
- return output ? this.#host.encodeOrchestrator.stateOf(output.outputKey) : runStateOf([]);
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 judged = neighbor < index ? judgeNeighbors(ranges, mediaRanges) : judgeNeighbors(mediaRanges, ranges);
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 that holds the whole
434
- * of its stretch, or undefined. A stored piece short of its cut — left by an
435
- * earlier process — is taken off the disk here, where its cut is known.
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
- const address = output.outputKey ?? "";
443
- const timeline = output.timeline;
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([startIndex, ...this.liveRunsOf(session).map((run) => run.to + 1)]));
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
- const mayReach = Number.isInteger(ordered?.to) && ordered.to >= startIndex ? ordered.to
813
- : segmentCount > startIndex ? segmentCount - 1 : startIndex;
814
- const admittedInput = this.#host.encodeInputs?.take(session, startIndex, mayReach) ?? null;
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 { args, safeIndex, startSeconds, cutTimes } = buildCommand({
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
- if (admittedInput && session.segmentFormat.id === "fmp4" && session.spec.carries !== "audio-only" &&
1001
- (admittedInput.original || session.spec.audio && !this.#host.servesAudioSeparately(session))) {
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
- bytes = presentationSegment(this.#host.segmentFiles.closedBytesOf(address, name),
1006
- following ? this.#host.segmentFiles.closedBytesOf(address, following) : null,
1007
- { from: grid[index], to: grid[index + 1] });
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 partition closed segment ${index}: ${error.message}`);
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(session.outputKey ?? "", name, session.segmentFormat, { mediaRanges, bytes });
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
- this.#inputFailures.note(session.outputKey, index, key, ended.because);
1238
- this.#host.logger.warn(`encode: unchanged input will not be retried output=${session.outputKey} segment=${index} input=${key} reason=${ended.because}`);
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
- /** A failed input is retried only after its identity — source, file, ranges and what is stated about them — or the output parameters change. */
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
- reason(address) { return this.#failures.get(address)?.values().next().value?.reason ?? null; }
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
- note(address, index, key, reason) {
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 whole = ranges.tracks.every(({ timescale, ranges: held, positionErrorTicks = 0n }) => {
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 through = Number.isFinite(sourceEnds[kind]) ? Math.min(to + shift, sourceEnds[kind]) : to + shift;
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
- if (first.start > start + frame + positionError || first.start < start - frame - positionError) return `segment-start-outside-interval-${kind}`;
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
- /** Repartition closed muxer output by presentation interval, preserving decode order. */
4
- export function presentationSegment(current, following, { from, to }) {
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 samples = [...track.samples];
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 wanted = samples.filter(sample => {
22
- const pts = sample.dts + sample.composition + edit.offset;
23
- return pts < end && (track.kind === "vide" ? pts >= start : pts + sample.duration > start);
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 (!wanted.length) throw new Error("A presentation segment lacks required media.");
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
- // Removing leading pictures can leave a gap in decode timestamps.
40
- // trun advances by durations only; retain each original presentation
41
- // timestamp relative to the decode clock actually written here.
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 += sample.duration;
160
+ decodeTime += duration;
44
161
  return Buffer.concat([
45
- u32(Number(sample.duration)), u32(sample.bytes.length), u32(sample.flags), i32(Number(composition))
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([own.init, makeMoof(size + 8), box("mdat", ...tracks.flatMap(track => track.samples.map(sample => sample.bytes)))]);
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) {