@torrent-tv/proxy 2.97.5 → 2.97.7

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.5",
3
+ "version": "2.97.7",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
package/server.js CHANGED
@@ -802,6 +802,7 @@ export async function startProxyServer({
802
802
  // thread, so the map crosses the worker channel; what it does with it —
803
803
  // seconds into bytes, what to ask the swarm for, what to keep in memory —
804
804
  // is its own business.
805
+ setDownloadRate: (bytesPerSecond, because) => torrentPool.setDownloadRate?.(bytesPerSecond, because),
805
806
  setPriorityMap: async ({ sourceKey, fileIndex, durationSeconds, zones }) => {
806
807
  void sourcePreparation?.refresh();
807
808
  const record = sourceRegistry.get(sourceKey);
@@ -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, onItsCutClock } 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
 
@@ -371,18 +383,16 @@ export class EncodeRuns {
371
383
  * @param {Buffer | null} [bytes]
372
384
  * @param {EncodeRun | null} [run] - The run that closed it, which keeps where its muxer counts cuts from.
373
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.
374
387
  * @returns {object | null | false}
375
388
  */
376
- #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) {
377
390
 
378
391
  const format = session.segmentFormat;
379
392
  if (!format?.readMediaRanges || !Number.isInteger(index) || index < 0) {
380
393
  return null;
381
394
  }
382
395
  const address = session.outputKey ?? "";
383
- // `bytes` is the piece the proxy cut again by presentation time at the
384
- // published grid; without it the piece is as the muxer cut it.
385
- const cutByProxy = bytes !== null;
386
396
  try {
387
397
  const mediaRanges = format.readMediaRanges(bytes ?? this.#host.segmentFiles.closedBytesOf(address, name));
388
398
 
@@ -394,32 +404,35 @@ export class EncodeRuns {
394
404
  .map(input => [input.track.type === "video" ? "vide" : "soun", input.sourceEndSeconds])) } : {}),
395
405
  requiredKinds: [session.spec.carries !== "audio-only" ? "vide" : null,
396
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
+ }
397
413
  if (run) {
398
414
  // Where the muxer counts this run's cuts from is known once its first
399
415
  // piece has closed, and holds for every piece after it. It is a fact
400
416
  // of each piece the run makes, so it travels with the piece.
401
417
  if (run.cutShiftSeconds === null && index === run.from) {
402
- // Read from the piece as the muxer closed it: a piece cut again by
403
- // the proxy starts where the grid says, whatever the muxer did.
404
- const asMuxed = cutByProxy ? format.readMediaRanges(this.#host.segmentFiles.closedBytesOf(address, name)) : mediaRanges;
405
- run.cutShiftSeconds = cutShiftOf(asMuxed, grid?.[run.from], session.spec.carries !== "audio-only" ? "vide" : "soun",
418
+ run.cutShiftSeconds = cutShiftOf(mediaRanges, grid?.[run.from], session.spec.carries !== "audio-only" ? "vide" : "soun",
406
419
  timing?.endMicros ?? null);
407
420
  // Said once per run: a piece judged on the wrong clock is refused by
408
421
  // a millisecond, and nothing else in the log says which clock it was
409
422
  // judged on (torrent-tv/meta#178).
410
423
  this.#host.logger.info(`encode-run #${run.from}..#${run.to} on ${address}: its muxer counts cuts ` +
411
424
  (run.cutShiftSeconds === null ? "from a first packet more than a frame away from its start (shift unknown)"
412
- : `${run.cutShiftSeconds.toFixed(6)}s from its start at ${grid?.[run.from]}s`) +
413
- (cutByProxy ? "; its pieces are cut again by the proxy at the published grid, so the shift does not apply to them" : ""));
425
+ : `${run.cutShiftSeconds.toFixed(6)}s from its start at ${grid?.[run.from]}s`));
414
426
  }
415
- onItsCutClock(mediaRanges, run.cutShiftSeconds, cutByProxy);
427
+ if (mediaRanges && run.cutShiftSeconds !== null) mediaRanges.cutShiftSeconds = run.cutShiftSeconds;
416
428
  }
417
429
  const { whole, throughSeconds, reason } = judgePiece(format, mediaRanges, cut, interval);
418
430
  if (!whole) {
419
431
  this.#host.logger.warn(
420
432
  `encode: not publishing piece ${index} of ${address.slice(0, 60)}: ` +
421
433
  (reason ?? (throughSeconds === null ? "it holds no playable media" : `produced through ${throughSeconds}s, cut ${cut}s`)) +
422
- ` cutBy=${cutByProxy ? "proxy" : "muxer"} shift=${Number.isFinite(mediaRanges?.cutShiftSeconds) ? mediaRanges.cutShiftSeconds : "none"}` +
434
+ ` shift=${Number.isFinite(mediaRanges?.cutShiftSeconds) ? mediaRanges.cutShiftSeconds : "none"}` +
435
+ ` nextLeading=${mediaRanges?.nextLeadingSeconds ?? "none"}` +
423
436
  (interval ? ` expected=${interval.from}..${interval.to} tracks=${JSON.stringify(mediaRanges.tracks.map(track => ({
424
437
  kind: track.kind, timescale: track.timescale, frame: track.productionFrame,
425
438
  first: track.ranges[0], last: track.ranges.at(-1), count: track.ranges.length })),
@@ -434,7 +447,18 @@ export class EncodeRuns {
434
447
  const ranges = this.#host.segmentFiles.mediaRangesOf(address, neighbor,
435
448
  { startSeconds: grid[neighbor] });
436
449
  if (!ranges) continue;
437
- 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
+ }
438
462
  if (!judged.whole) {
439
463
  this.#host.logger.warn(`encode: not publishing piece ${index} of ${address}: ${judged.reason}`);
440
464
  return false;
@@ -450,30 +474,25 @@ export class EncodeRuns {
450
474
  }
451
475
 
452
476
  /**
453
- * The media intervals of a stored piece of this output that holds the whole
454
- * of its stretch, or undefined. A stored piece short of its cut — left by an
455
- * 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).
456
487
  *
457
488
  * @param {HlsSession} output
458
489
  * @param {number} index
459
490
  * @returns {object | undefined}
460
491
  */
461
492
  finishedMediaRangesOf(output, index) {
462
- const address = output.outputKey ?? "";
463
- const timeline = output.timeline;
464
- const ranges = this.#host.segmentFiles.mediaRangesOf(address, index, {
465
- startSeconds: timeline.publishedStartOf(index)
493
+ return this.#host.segmentFiles.mediaRangesOf(output.outputKey ?? "", index, {
494
+ startSeconds: output.timeline.publishedStartOf(index)
466
495
  });
467
- if (!ranges || !output.segmentFormat?.producedThroughSeconds) {
468
- return ranges;
469
- }
470
- const cut = cutOf(timeline, index);
471
- const { whole, throughSeconds } = judgePiece(output.segmentFormat, ranges, cut);
472
- if (whole || throughSeconds === null) {
473
- return ranges;
474
- }
475
- this.#host.segmentFiles.remove(address, index, `produced through ${throughSeconds}s, short of its cut at ${cut}s`);
476
- return undefined;
477
496
  }
478
497
 
479
498
  progressOf(output, index = null) {
@@ -598,10 +617,49 @@ export class EncodeRuns {
598
617
  return byOutput;
599
618
  }
600
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
+
601
658
  forgetEncodingOfGone(output) {
602
659
  this.#host.encodeInputs?.forget(output);
603
660
  if (this.#host.outputs.outputsOn(output.outputKey).length === 0) {
604
661
  this.#inputFailures.forget(output.outputKey);
662
+ this.#leadingByOutput.delete(output.outputKey);
605
663
  this.#host.encodeOrchestrator.forgetOpens?.(output.outputKey);
606
664
  this.#host.encodeOrchestrator.forgetOutput(output.outputKey);
607
665
  }
@@ -821,10 +879,17 @@ export class EncodeRuns {
821
879
  if (!this.isLive(session)) {
822
880
  return null;
823
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;
824
889
  // A copy held for a start that no live run ends before and that is not
825
890
  // being started now goes back (torrent-tv/meta#184).
826
891
  this.#host.encodeInputs?.retain(session, this.#host.encodeOrchestrator.wantedSegmentsOn(session.outputKey),
827
- new Set([startIndex, ...this.liveRunsOf(session).map((run) => run.to + 1)]));
892
+ new Set([inputStart, ...this.liveRunsOf(session).map((run) => run.to + 1)]));
828
893
  // THE LAST NUMBER THIS RUN MAY REACH is the plan's; how far its input
829
894
  // really reaches is the input's answer. An original input is one copied
830
895
  // stretch, and the run is given exactly that stretch (torrent-tv/meta#158).
@@ -834,7 +899,7 @@ export class EncodeRuns {
834
899
  const mayReach = this.#inputFailures.boundFor(session.outputKey, startIndex,
835
900
  Number.isInteger(ordered?.to) && ordered.to >= startIndex ? ordered.to
836
901
  : segmentCount > startIndex ? segmentCount - 1 : startIndex);
837
- const admittedInput = this.#host.encodeInputs?.take(session, startIndex, mayReach) ?? null;
902
+ const admittedInput = this.#host.encodeInputs?.take(session, inputStart, mayReach) ?? null;
838
903
  if (!admittedInput) return null;
839
904
  const inputEnd = admittedInput.original ? admittedInput.to : startIndex;
840
905
  // The stretch is part of an original input's identity: two stretches from
@@ -929,7 +994,7 @@ export class EncodeRuns {
929
994
  const inputToken = this.#lastInputToken;
930
995
  if (admittedInput) admittedInput.runTag = `${startIndex}r${inputToken}`;
931
996
  const buildCommand = admittedInput.original ? buildOriginalCommand : buildAdmittedCommand;
932
- const { args, safeIndex, startSeconds, cutTimes } = buildCommand({
997
+ const built = buildCommand({
933
998
  admittedInput,
934
999
  inputToken,
935
1000
  baseUrl: this.#host.localBaseUrl,
@@ -944,12 +1009,18 @@ export class EncodeRuns {
944
1009
  audioSeparate: this.#host.servesAudioSeparately(session),
945
1010
  audioSourceTrackIndex: session.spec.audioSourceTrackIndex,
946
1011
  rateControl: session.spec.video?.encode?.rateControl ?? null,
947
- startIndex,
1012
+ startIndex: inputStart,
948
1013
  endIndex: admittedInput.original ? inputEnd
949
1014
  : Number.isInteger(ordered?.to) ? ordered.to : runEnd >= startIndex ? runEnd : session.timeline.segmentCount - 1,
950
1015
  videoEncoder: this.#host.videoEncoder,
951
1016
  segmentDurationSec: this.#host.segmentDurationSec
952
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;
953
1024
 
954
1025
  // The exact command, every run. An encode failure is otherwise reported
955
1026
  // with ffmpeg's message and nothing about what it was asked to do, and the
@@ -1020,27 +1091,39 @@ export class EncodeRuns {
1020
1091
  // complete input or overwrite a neighboring run's segment.
1021
1092
  if (Number.isInteger(index) && (index < safeIndex || index > run.to)) return null;
1022
1093
  let bytes = null;
1023
- if (admittedInput && session.segmentFormat.id === "fmp4" && session.spec.carries !== "audio-only" &&
1024
- (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) {
1025
1102
  try {
1026
- const address = session.outputKey ?? "";
1027
1103
  const grid = session.timeline.published ?? session.timeline.boundaries;
1028
- bytes = presentationSegment(this.#host.segmentFiles.closedBytesOf(address, name),
1029
- following ? this.#host.segmentFiles.closedBytesOf(address, following) : null,
1030
- { 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 });
1031
1108
  } catch (error) {
1032
- 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}`);
1033
1110
  throw error;
1034
1111
  }
1035
1112
  }
1036
- const mediaRanges = this.#wholeClosedPiece(session, name, index, admittedInput, bytes, run, timing);
1113
+ const mediaRanges = this.#wholeClosedPiece(session, name, index, admittedInput, bytes, run, timing, following);
1037
1114
  if (mediaRanges === false) throw Object.assign(new Error(`Closed piece #${index} has incomplete media.`),
1038
1115
  { code: "ERR_INCOMPLETE_MEDIA" });
1039
- 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 });
1040
1117
  if (!published) throw new Error(`Closed piece #${index} could not be stored.`);
1118
+ this.#leadingByOutput.get(address)?.delete(index);
1041
1119
  return published;
1042
1120
 
1043
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),
1044
1127
  onSpeedMeasured: () => this.#host.noteRunSpeedMeasured?.(session, run),
1045
1128
  onEnded: (ended) => {
1046
1129
  this.#runsByInputToken.delete(inputToken);
@@ -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
  }
@@ -71,26 +72,51 @@ export function judgePiece(format, ranges, cutSeconds, interval) {
71
72
  }
72
73
 
73
74
  /**
74
- * Put a piece on the clock it was cut by, for {@link judgePiece}.
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.
75
78
  *
76
- * The run's cut shift ({@link cutShiftOf}) says where the segment MUXER counts
77
- * its cuts from, so it belongs only to a piece the muxer cut. A copied picture
78
- * read from the original file is cut again by the proxy, by presentation time
79
- * at the published grid (`presentationSegment`), and that piece is judged on
80
- * the grid as published. Field 2026-10-11, torrent-tv/meta#178: an open-GOP
81
- * HEVC piece cut again at 2511.676..2521.686 holds the next keyframe, which its
82
- * leading pictures need, and so ends one frame (672 of 16 000 ticks) past the
83
- * cut, as it must; moved by the muxer's shift of −0.001 s the allowance ended
84
- * one millisecond before that frame, the piece was refused and playback ended.
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.
85
84
  *
86
- * @param {object | null | undefined} ranges - The piece's media intervals.
87
- * @param {number | null} shift - The run's cut shift, or null when not known.
88
- * @param {boolean} cutByProxy - Whether the proxy cut the piece again.
89
- * @returns {object | null | undefined} The same intervals.
85
+ * @param {object | null | undefined} ranges - A piece's media intervals.
86
+ * @returns {number}
90
87
  */
91
- export function onItsCutClock(ranges, shift, cutByProxy) {
92
- if (ranges && !cutByProxy && Number.isFinite(shift)) ranges.cutShiftSeconds = shift;
93
- return ranges;
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;
94
120
  }
95
121
 
96
122
  /**
@@ -166,13 +192,14 @@ export function judgeNeighbors(left, right) {
166
192
  * soundtrack's at 1419.904 s against a final cut of 1419.993 s, and both last
167
193
  * pieces were refused, so no viewer could reach the end of the episode.
168
194
  */
169
- 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 }) {
170
196
  if (!Number.isFinite(from) || !Number.isFinite(to) || !(to > from) || !requiredKinds?.length) return "segment-interval-is-not-declared";
171
197
  for (const kind of requiredKinds) {
172
198
  const track = coverage?.tracks?.find(track => track.kind === kind);
173
199
  if (!track?.ranges?.length || !(track.timescale > 0n)) return `segment-missing-${kind}`;
174
200
  const start = BigInt(Math.round((from + shift) * Number(track.timescale)));
175
- 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;
176
203
  if (!(through > from + shift)) return `segment-interval-is-empty-${kind}`;
177
204
  const end = BigInt(Math.round(through * Number(track.timescale)));
178
205
  const first = track.ranges[0];
@@ -181,7 +208,11 @@ function intervalFailure(coverage, { from, to, requiredKinds, sourceEnds = {}, s
181
208
  // cadence. Account for that recorded position uncertainty at the boundary,
182
209
  // without admitting another missing frame or widening internal gaps.
183
210
  const positionError = track.positionErrorTicks ?? 0n;
184
- 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}`;
185
216
  // The first sample's cadence already bounds the interval start. A shorter
186
217
  // final sample must not replace that bound when this range is traversed.
187
218
  let reached = first.start;
@@ -89,6 +89,11 @@ export class HostLoad {
89
89
  /**
90
90
  * @param {object} host - `liveRunsOf`, `runStateOf`, the readings of `encode/host-load.js` (`readMachineState`, `readProcessCpuSeconds`, `readProxyCpuSeconds`, `readSystemCpu`, `shareOfMachine`), `getSourceStats`, `getTorrentTotals`, `outputs`
91
91
  */
92
+ /** The share of one core this process draws doing nothing priced; zero until measured. */
93
+ get baseDraw() {
94
+ return this.#observedBaseDraw ?? 0;
95
+ }
96
+
92
97
  constructor(host) {
93
98
  this.#host = host;
94
99
  }
@@ -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) {
@@ -97,6 +97,9 @@ import path from "node:path";
97
97
  import { contentOf, LocalObservations, PROXY_ROOT } from "../encode/LocalObservations.js";
98
98
  import { configurationKeyOf } from "../encode/fingerprint.js";
99
99
  import { qualityStateOf } from "../encode/quality/OutputQualityState.js";
100
+ import os from "node:os";
101
+ import { downloadShare } from "../storage/processor-share.js";
102
+ import { ENCODE_RUN_STATE, processCanBeSignalled } from "../encode/encode-run-state.js";
100
103
 
101
104
  // Where a variant and an audio rendition live under a session — `v/<height>/…`
102
105
  // and `a/<track>/…` — is stated in `encode/output/playlists.js`, beside the lines that
@@ -193,6 +196,7 @@ export function wireOutputs({
193
196
  decodeCostModel = null,
194
197
  getSourceStats = null,
195
198
  setPriorityMap = null,
199
+ setDownloadRate = null,
196
200
  // What a second job costs on this host, measured at startup. Null when it
197
201
  // could not be measured, and then nothing is corrected — the alternative
198
202
  // is inventing a penalty, which is the same fault as inventing a fill rate.
@@ -342,7 +346,7 @@ export function wireOutputs({
342
346
  get encoders() { return parts.encoders; },
343
347
  invalidateWaits: (output) => parts.serving.invalidateWaits(output),
344
348
  productionFailed: (output) => parts.renditions.noteProductionFailed(output),
345
- encodersChanged: () => onEncodersChanged?.(),
349
+ encodersChanged: () => { parts.applyDownloadShare?.(); onEncodersChanged?.(); },
346
350
  });
347
351
  // How much film is ready in front of the viewers, said; and the spare soundtracks fetched once it is full.
348
352
  parts.cushion = new CushionReport({
@@ -648,6 +652,31 @@ export function wireOutputs({
648
652
  // (sourceKey, fileIndex) => Promise<{ downloadSpeed, fileLength, fileProgress } | null>.
649
653
  parts.getSourceStats = typeof getSourceStats === "function" ? getSourceStats : null;
650
654
  parts.setPriorityMap = typeof setPriorityMap === "function" ? setPriorityMap : null;
655
+ parts.setDownloadRate = typeof setDownloadRate === "function" ? setDownloadRate : null;
656
+ // HOW FAST THE TORRENTS MAY DOWNLOAD: the processor the encoders leave
657
+ // (`storage/processor-share.js`, torrent-tv/meta#178). Worked out again
658
+ // when the machine is read and when an encoder starts or ends, over every
659
+ // watched file of every torrent.
660
+ parts.applyDownloadShare = () => {
661
+ if (!parts.setDownloadRate || !parts.hostLoad || !parts.encodeCost) return;
662
+ const files = new Map();
663
+ for (const output of parts.outputs.values()) {
664
+ if (!(parts.viewers?.forOutput(output)?.size > 0)) continue;
665
+ const key = output.file.key;
666
+ const length = Number(output.file.lengthBytes ?? parts.hostLoad.fileLengthByKey.get(key));
667
+ const file = files.get(key) ?? { bytesPerFilmSecond: length / Number(output.file.durationSeconds), encodeCostSec: 0 };
668
+ const state = parts.encodeRuns.runStateOf(output);
669
+ if (processCanBeSignalled(state) && state !== ENCODE_RUN_STATE.SUSPENDED) {
670
+ file.encodeCostSec += parts.encodeCost.loadOfOutput(output.outputKey)?.costSec ?? 0;
671
+ }
672
+ files.set(key, file);
673
+ }
674
+ const watched = [...files.values()].filter((file) => Number.isFinite(file.bytesPerFilmSecond) && file.bytesPerFilmSecond > 0);
675
+ const share = downloadShare({ cores: os.cpus().length, availableShare: parts.hostLoad.hostAvailability?.share ?? 1,
676
+ baseDraw: parts.hostLoad.baseDraw, costPerMegabyte: parts.hostLoad.observedTorrentCostPerMegabyte, files: watched });
677
+ void Promise.resolve(parts.setDownloadRate(share.bytesPerSecond, `film speed ${share.filmSpeed.toFixed(2)}x ` +
678
+ `over ${watched.length} file(s), ${share.capacity.toFixed(2)} core(s) for the proxy`)).catch(() => {});
679
+ };
651
680
  parts.contentionPenalties = contentionPenalties instanceof Map ? contentionPenalties : null;
652
681
  // Seconds of film per second when the picture is COPIED, measured at
653
682
  // startup. Nothing else prices that branch: the other benchmarks measure
@@ -1060,7 +1089,7 @@ export function wireOutputs({
1060
1089
  liveConsumers: (...args) => parts.renditions.liveConsumers(...args),
1061
1090
  liveRunsOf: (...args) => parts.encodeRuns.liveRunsOf(...args),
1062
1091
  producedNumbers: (...args) => parts.serving.producedNumbers(...args),
1063
- reportHostLoad: (...args) => parts.hostLoad.reportHostLoad(...args),
1092
+ reportHostLoad: async (...args) => { await parts.hostLoad.reportHostLoad(...args); parts.applyDownloadShare?.(); },
1064
1093
  runStateOf: (...args) => parts.encodeRuns.runStateOf(...args),
1065
1094
  sampleDownloadRates: (...args) => parts.hostLoad.sampleDownloadRates(...args),
1066
1095
  get encodeCost() { return parts.encodeCost; },
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @file How fast the torrents may download, so that downloading does not take
3
+ * the processor the encoders need (torrent-tv/meta#178).
4
+ *
5
+ * Field 2026-10-11, Home Assistant: an AVI re-encoded at 720x400 needs 2.3 s
6
+ * of processor per second of film. While the torrent downloaded 4.5-9.8 MB/s —
7
+ * mostly far ahead of the viewer — its thread took 56-63 % of a core, ffmpeg
8
+ * got 176-182 %, below real time, and the viewer saw the spinner; once the
9
+ * download stopped ffmpeg got 216-261 % and there were no stops.
10
+ *
11
+ * THE MODEL. The processor the proxy has, `C`, is what other processes leave
12
+ * of every core, less what the proxy draws doing nothing priced. Every watched
13
+ * file `f` of every torrent advances at a film speed `s`: its encoders take
14
+ * `s·k_f` (seconds of processor per second of film, as measured for the
15
+ * encoders running now) and its download `s·w_f·c` (`w_f` its bytes per second
16
+ * of film, `c` the measured processor per byte moved). One speed for all is
17
+ * the division under which the slowest file is as fast as it can be:
18
+ *
19
+ * s* = C / Σ_f (k_f + w_f·c), download rate = max(s*, 1) · Σ_f w_f
20
+ *
21
+ * Downloading faster than `s*·w` only piles bytes ahead while the encoders
22
+ * starve; what is fetched first within the rate is the priority map's order.
23
+ * The rate never falls below the films' own byte rate, `Σ w`, because below it
24
+ * the viewer stops whatever the encoders get. Where `s* < 1` or the swarm
25
+ * gives less than the rate, stops cannot be avoided, and the start forecast,
26
+ * reading the speeds measured under this rate, waits for them at the start
27
+ * and after a seek. With nothing encoding `k` is zero and the download has the
28
+ * processor. A term nobody has measured contributes nothing: an unmeasured
29
+ * price of a byte leaves the download unlimited.
30
+ */
31
+
32
+ /**
33
+ * @param {object} params
34
+ * @param {number} params.cores
35
+ * @param {number} params.availableShare - The share of the machine other processes leave, 0..1.
36
+ * @param {number} params.baseDraw - Processor the proxy draws doing nothing priced, in cores.
37
+ * @param {number | null} params.costPerMegabyte - Processor seconds per megabyte the torrent moves.
38
+ * @param {{ bytesPerFilmSecond: number, encodeCostSec: number }[]} params.files - Watched files.
39
+ * @returns {{ filmSpeed: number, bytesPerSecond: number, capacity: number }}
40
+ * `bytesPerSecond` is Infinity where the download is not limited.
41
+ */
42
+ export function downloadShare({ cores, availableShare, baseDraw, costPerMegabyte, files }) {
43
+ const capacity = Math.max(0, cores * availableShare - baseDraw);
44
+ // A byte whose price is not measured, or costs nothing, takes nothing from
45
+ // the encoders: there is then no share to keep the download to.
46
+ const perByte = Number.isFinite(costPerMegabyte) && costPerMegabyte > 0 ? costPerMegabyte / 1e6 : 0;
47
+ const bytes = files.reduce((sum, file) => sum + file.bytesPerFilmSecond, 0);
48
+ const demand = files.reduce((sum, file) => sum + file.encodeCostSec + file.bytesPerFilmSecond * perByte, 0);
49
+ const filmSpeed = capacity / demand;
50
+ const bytesPerSecond = perByte > 0 ? Math.max(filmSpeed, 1) * bytes : Infinity;
51
+ return { filmSpeed, capacity, bytesPerSecond: Number.isFinite(bytesPerSecond) && bytes > 0 ? bytesPerSecond : Infinity };
52
+ }
@@ -1261,6 +1261,35 @@ export class TorrentPool {
1261
1261
  * film against a number, highest first in urgency.
1262
1262
  * @param {number} durationSeconds
1263
1263
  */
1264
+ /**
1265
+ * How fast every torrent may download in all: the share of the processor
1266
+ * the encoders leave (`storage/processor-share.js`, torrent-tv/meta#178).
1267
+ * Within it the swarm is asked for what the priority map puts first.
1268
+ *
1269
+ * @param {number | null} bytesPerSecond - Infinity or null for no limit.
1270
+ * @param {string} [because]
1271
+ */
1272
+ setDownloadRate(bytesPerSecond, because = "") {
1273
+ if (!this.client || this.client.destroyed || typeof this.client.throttleDownload !== "function") return;
1274
+ const rate = Number.isFinite(bytesPerSecond) ? Math.max(1, Math.round(bytesPerSecond)) : -1;
1275
+ if (rate === this.#downloadLimit) return;
1276
+ this.client.throttleDownload(rate);
1277
+ // Said when the limit is set or lifted, and when it moves by a factor of
1278
+ // two either way — the scale on which a limit changes what the swarm gives.
1279
+ const scale = rate < 0 ? null : Math.round(Math.log2(rate));
1280
+ if (scale !== this.#downloadLimitScale) {
1281
+ this.#downloadLimitScale = scale;
1282
+ logger.info(`torrent-pool: download limit -> ${rate < 0 ? "none" : `${Math.round(rate / 1024)} KB/s`}${because ? ` (${because})` : ""}`);
1283
+ }
1284
+ this.#downloadLimit = rate;
1285
+ }
1286
+
1287
+ /** The download limit in force, -1 for none. */
1288
+ #downloadLimit = -1;
1289
+
1290
+ /** The power of two the last limit said was nearest, null for none. */
1291
+ #downloadLimitScale = null;
1292
+
1264
1293
  applyPriorityMap(torrent, fileIndex, zones, durationSeconds) {
1265
1294
  // A MAP WITH NOTHING IN IT IS A STATEMENT, and it is answered before
1266
1295
  // anything else is looked at. It says nobody wants any of this file, and
@@ -475,6 +475,17 @@ export class TorrentWorkerClient {
475
475
  * @param {{ sourceKey: string, fileIndex: number, durationSeconds: number, zones: { from: number, to: number, priority: number }[] }} params
476
476
  * @returns {Promise<void>}
477
477
  */
478
+ /**
479
+ * How fast the torrents may download in all (`Command.DOWNLOAD_RATE`).
480
+ *
481
+ * @param {number} bytesPerSecond - Infinity for no limit.
482
+ * @param {string} [because]
483
+ * @returns {Promise<void>}
484
+ */
485
+ async setDownloadRate(bytesPerSecond, because = "") {
486
+ return this.#caller.call(Command.DOWNLOAD_RATE, { bytesPerSecond: Number.isFinite(bytesPerSecond) ? bytesPerSecond : null, because });
487
+ }
488
+
478
489
  async setPriorityMap({ sourceKey, fileIndex, zones, durationSeconds, postedAt }) {
479
490
  return this.#caller.call(Command.PRIORITY_MAP, {
480
491
  sourceKey,
@@ -164,6 +164,17 @@ export class WorkerTorrentPool {
164
164
  * @param {{ sourceKey: string, fileIndex: number, durationSeconds: number, zones: object[] }} params
165
165
  * @returns {Promise<void>}
166
166
  */
167
+ /**
168
+ * How fast the torrents may download in all, Infinity for no limit.
169
+ *
170
+ * @param {number} bytesPerSecond
171
+ * @param {string} [because]
172
+ * @returns {Promise<void>}
173
+ */
174
+ async setDownloadRate(bytesPerSecond, because = "") {
175
+ return this.#client.setDownloadRate(bytesPerSecond, because);
176
+ }
177
+
167
178
  async setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones, postedAt }) {
168
179
  if (!sourceKey) {
169
180
  return;
@@ -66,6 +66,12 @@ export const Command = {
66
66
  * sixteen pieces is what tore a film apart on 2026-09-05.
67
67
  */
68
68
  PRIORITY_MAP: "priority-map",
69
+ /**
70
+ * How fast the torrents may download in all, in bytes per second, or
71
+ * Infinity for no limit: the share of the processor the encoders leave
72
+ * (`storage/processor-share.js`, torrent-tv/meta#178).
73
+ */
74
+ DOWNLOAD_RATE: "download-rate",
69
75
  /** Read a byte range; the body arrives as CHUNK messages. */
70
76
  READ_RANGE: "read-range",
71
77
  /** Abandon an in-flight READ_RANGE (viewer gone, seek superseded). */
@@ -476,6 +476,11 @@ async function runCommand(command, params, id) {
476
476
  return { appliedMs: performance.now() - startedAt, receivedAt, repliedAt: performance.timeOrigin + performance.now() };
477
477
  }
478
478
 
479
+ case Command.DOWNLOAD_RATE: {
480
+ pool.setDownloadRate(params.bytesPerSecond, params.because);
481
+ return true;
482
+ }
483
+
479
484
  case Command.READ_RANGE: {
480
485
  // Streams its own reply; the caller's promise resolves once the body has
481
486
  // been fully sent, which is what lets the client await completion.