@torrent-tv/proxy 2.91.0 → 2.92.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/bin/cli.js +3 -2
  2. package/package.json +18 -2
  3. package/routes/api/playback-plan/post.js +21 -4
  4. package/routes/api/sources/files/get.js +20 -28
  5. package/routes/api/sources/post.js +4 -2
  6. package/routes/api/sources/stats/get.js +4 -11
  7. package/routes/api/sources/viewer/post.js +16 -0
  8. package/routes/api/sources/warm/post.js +6 -215
  9. package/routes/api/subtitles/get.js +22 -125
  10. package/routes/api/subtitles/post.js +26 -0
  11. package/routes/api/transcode-sessions/post.js +24 -2
  12. package/routes/media/get.js +80 -0
  13. package/routes/stream/get.js +26 -94
  14. package/routes/transcode/audio-warm/get.js +9 -21
  15. package/routes/transcode/variant-warm/get.js +8 -14
  16. package/server.js +410 -46
  17. package/services/encode/AdmittedInput.js +136 -0
  18. package/services/encode/CushionReport.js +1 -76
  19. package/services/encode/EncodeInputs.js +142 -0
  20. package/services/encode/EncodeOrchestrator.js +4 -1
  21. package/services/encode/EncodeRun.js +13 -4
  22. package/services/encode/EncodeRuns.js +132 -52
  23. package/services/encode/InputFailures.js +37 -0
  24. package/services/encode/MatroskaInput.js +158 -0
  25. package/services/encode/OutputOpening.js +16 -64
  26. package/services/encode/Renditions.js +1 -17
  27. package/services/encode/admitted-command.js +60 -0
  28. package/services/encode/annex-b-input.js +37 -0
  29. package/services/encode/bit-payload.js +29 -0
  30. package/services/encode/output/OutputCatalog.js +1 -1
  31. package/services/encode/output/Timeline.js +11 -0
  32. package/services/encode/pcm-normalization.js +30 -0
  33. package/services/encode/piece-completeness.js +54 -1
  34. package/services/encode/quality/HostLoad.js +19 -73
  35. package/services/encode/quality/QualityOffer.js +1 -1
  36. package/services/encode/run-command.js +1 -589
  37. package/services/encode/segment-formats/mp4-boxes.js +3 -1
  38. package/services/encode/segment-formats/presentation-segment.js +114 -0
  39. package/services/media/ContainerOrchestrator.js +118 -48
  40. package/services/media/KeyframeTables.js +16 -115
  41. package/services/media/MediaReadRequests.js +120 -0
  42. package/services/media/ProbeRequests.js +135 -0
  43. package/services/media/SegmentInputs.js +48 -0
  44. package/services/media/SourceFile.js +6 -0
  45. package/services/media/SubtitleOrchestrator.js +190 -4
  46. package/services/media/await-plan.js +47 -0
  47. package/services/media/container/AsfContainer.js +217 -0
  48. package/services/media/container/AviContainer.js +343 -34
  49. package/services/media/container/CadencedPictureOrder.js +45 -0
  50. package/services/media/container/Container.js +7 -3
  51. package/services/media/container/ContainerFactory.js +10 -3
  52. package/services/media/container/FfprobeContainer.js +222 -0
  53. package/services/media/container/MatroskaContainer.js +72 -46
  54. package/services/media/container/Mp4Container.js +472 -139
  55. package/services/media/container/MpegPsContainer.js +281 -0
  56. package/services/media/container/MpegTsContainer.js +277 -0
  57. package/services/media/container/PacketIndex.js +270 -0
  58. package/services/media/container/PacketRecords.js +237 -0
  59. package/services/media/container/RetainedBytes.js +39 -0
  60. package/services/media/container/RetainedReads.js +51 -0
  61. package/services/media/container/ac3-frame.js +46 -0
  62. package/services/media/container/asf-packets.js +153 -0
  63. package/services/media/container/avc-picture-order.js +50 -0
  64. package/services/media/container/avi-open-dml.js +47 -0
  65. package/services/media/container/dts-frame.js +51 -0
  66. package/services/media/container/ebml-stream.js +12 -1
  67. package/services/media/container/ffprobe-record.js +45 -0
  68. package/services/media/container/flv-packet-ranges.js +27 -0
  69. package/services/media/container/following-packet-time.js +37 -0
  70. package/services/media/container/h264-configuration.js +183 -0
  71. package/services/media/container/h264-elementary-index.js +245 -0
  72. package/services/media/container/hevc-configuration.js +165 -0
  73. package/services/media/container/hevc-elementary-index.js +132 -0
  74. package/services/media/container/hevc-picture-order.js +66 -0
  75. package/services/media/container/latm-frame.js +99 -0
  76. package/services/media/container/matroska-packets.js +207 -0
  77. package/services/media/container/memory-unavailable.js +8 -0
  78. package/services/media/container/mp3-packet-dependencies.js +41 -0
  79. package/services/media/container/mp4-fragments.js +148 -0
  80. package/services/media/container/mpeg-audio-frame.js +19 -0
  81. package/services/media/container/mpeg-audio-reservoir.js +27 -0
  82. package/services/media/container/mpeg-elementary-index.js +196 -0
  83. package/services/media/container/mpeg-sequence-reader.js +69 -0
  84. package/services/media/container/mpeg-ts-packets.js +93 -0
  85. package/services/media/container/mpeg-video-configuration.js +50 -0
  86. package/services/media/container/mpeg4-picture-timing.js +108 -0
  87. package/services/media/container/pes-header.js +34 -0
  88. package/services/media/playback-declarations.js +57 -0
  89. package/services/media/playback-planner.js +72 -230
  90. package/services/media/probe-packets.js +61 -0
  91. package/services/media/read-playback-declarations.js +18 -0
  92. package/services/media/tracks/AudioTrack.js +9 -0
  93. package/services/media/tracks/TextSubtitleTrack.js +4 -1
  94. package/services/server/OutputLifecycle.js +2 -0
  95. package/services/server/SegmentServing.js +40 -33
  96. package/services/server/ViewerRequests.js +22 -41
  97. package/services/server/controllers/PlaybackController.js +4 -0
  98. package/services/server/controllers/SubtitleController.js +8 -29
  99. package/services/server/transcode-session-files.js +58 -99
  100. package/services/server/wire-outputs.js +58 -15
  101. package/services/storage/IndexMemory.js +85 -0
  102. package/services/storage/MachineBudget.js +27 -3
  103. package/services/storage/allowance.js +8 -1
  104. package/services/storage/piece-store/piece-disk-store.js +18 -3
  105. package/services/storage/piece-store/piece-lru.js +8 -4
  106. package/services/storage/piece-store/shared-piece-store.js +26 -11
  107. package/services/storage/segment-store/SegmentStore.js +49 -18
  108. package/services/storage/wire.js +7 -0
  109. package/services/torrent/Contents.js +52 -20
  110. package/services/torrent/demand/DemandRegister.js +13 -4
  111. package/services/torrent/demand/Window.js +13 -2
  112. package/services/torrent/demand-progress.js +25 -0
  113. package/services/torrent/download/DeadlineScheduler.js +167 -0
  114. package/services/torrent/download/FutureDownload.js +166 -0
  115. package/services/torrent/download/SwarmSelection.js +30 -59
  116. package/services/torrent/download/registry.js +45 -5
  117. package/services/torrent/download/withdraw-claim.js +2 -0
  118. package/services/torrent/torrent-pool.js +39 -600
  119. package/services/torrent/worker/channel.js +8 -1
  120. package/services/torrent/worker/client.js +74 -72
  121. package/services/torrent/worker/files-in-use.js +9 -0
  122. package/services/torrent/worker/handle-state.js +12 -0
  123. package/services/torrent/worker/held-bytes.js +82 -99
  124. package/services/torrent/worker/held-file-bytes.js +17 -0
  125. package/services/torrent/worker/piece-reader.js +67 -683
  126. package/services/torrent/worker/pool-adapter.js +37 -123
  127. package/services/torrent/worker/protocol.js +6 -17
  128. package/services/torrent/worker/whole-file-stats.js +2 -0
  129. package/services/torrent/worker/worker.js +86 -156
  130. package/services/transport/data-channel-handler.js +56 -13
  131. package/services/viewer/DownloadMaps.js +168 -0
  132. package/services/viewer/NativeSourceMap.js +33 -0
  133. package/services/viewer/PriorityMap.js +49 -40
  134. package/services/viewer/PriorityOrchestrator.js +42 -13
  135. package/services/viewer/SourcePreparation.js +278 -0
  136. package/services/viewer/Viewer.js +11 -0
  137. package/services/viewer/Viewers.js +109 -2
  138. package/services/viewer/playback-readiness.js +50 -63
  139. package/services/viewer/segment-demands.js +37 -0
  140. package/services/viewer/urgent-output-ready.js +26 -0
  141. package/services/encode/run-inputs.js +0 -53
  142. package/services/media/keyframe-probe.js +0 -157
  143. package/services/media/media-info-probe.js +0 -93
  144. package/services/torrent/worker/background-fill.js +0 -213
  145. package/services/torrent/worker/resume-warm.js +0 -107
@@ -10,7 +10,7 @@
10
10
  * and nothing about a viewer enters what is decided here.
11
11
  *
12
12
  * What the file says about itself is read by the media component and handed in
13
- * (`probeMediaInfo`, `probeKeyframeTimes`), as is everything else this reads.
13
+ * (`readSourceMedia`), as is everything else this reads.
14
14
  */
15
15
 
16
16
  import { createRequire } from "node:module";
@@ -44,7 +44,7 @@ export class OutputOpening {
44
44
  #host;
45
45
 
46
46
  /**
47
- * @param {object} host - `logger`, `probeMediaInfo`, `probeKeyframeTimes`, `enabled`, `segmentFormat`, `renditions`, `sourceFiles`, `getCachedMediaInfo`, `localBaseUrl`, `tonemapSupported`, `videoEncoder`, `hostLoad`, `keyframeTables`, `timelines`, `segmentDurationSec`, `softwarePresetBenchmark`, `decodeCostModel`, `encodeCost`, `admission`, `outputs`, `segmentOutputFiles`, `hostTimings`, `returns`, `encodeRuns`
47
+ * @param {object} host - `logger`, `readSourceMedia`, `enabled`, `segmentFormat`, `renditions`, `sourceFiles`, `getCachedMediaInfo`, `localBaseUrl`, `tonemapSupported`, `videoEncoder`, `hostLoad`, `keyframeTables`, `timelines`, `segmentDurationSec`, `softwarePresetBenchmark`, `decodeCostModel`, `encodeCost`, `admission`, `outputs`, `segmentOutputFiles`, `hostTimings`, `returns`, `encodeRuns`
48
48
  * @param {SegmentOutputFiles} host.segmentOutputFiles - The storage
49
49
  * operations needed while selecting or opening an output.
50
50
  */
@@ -292,7 +292,6 @@ export class OutputOpening {
292
292
  // statement as "the file it reads is not the file of the picture", so it is
293
293
  // that comparison now and there is nothing to keep in step.
294
294
  const audioFile = this.#host.sourceFiles.get(sourceKey, audioSource.fileIndex, audioSource.name);
295
- const inputFile = audioOnly === true && audioSource.isSidecar ? audioFile : file;
296
295
  // Media info (duration/resolution/fps/startTime/HDR) up front, so we can
297
296
  // serve a complete VOD playlist (#EXT-X-ENDLIST) with the correct total
298
297
  // duration and a fully seekable timeline before a single segment exists.
@@ -317,10 +316,16 @@ export class OutputOpening {
317
316
  // No session id on it: this read is a probe of the picture, not this
318
317
  // session's own delivery, and counting it against the session would tell a
319
318
  // waiting browser that its film is arriving when what arrived was a header.
320
- const pictureUrl = file.streamUrl(this.#host.localBaseUrl);
321
319
  const mediaInfo = cachedUsable
322
320
  ? cachedMediaInfo
323
- : await this.#host.probeMediaInfo(pictureUrl.toString());
321
+ : this.#host.readSourceMedia
322
+ ? await this.#host.readSourceMedia({ sourceKey, fileIndex })
323
+ : (() => {
324
+ const error = new Error("Source media declarations are not configured.");
325
+ error.code = "SOURCE_MEDIA_READER_UNAVAILABLE";
326
+ error.canRetry = false;
327
+ throw error;
328
+ })();
324
329
  const mediaInfoMs = Date.now() - mediaInfoStartMs;
325
330
  const mediaInfoSource = cachedUsable ? "cached" : "probed";
326
331
  // The file takes in what the probe said. It is the same answer for every
@@ -329,6 +334,7 @@ export class OutputOpening {
329
334
  // fresher reading updates it: on a cold torrent the first probe can come
330
335
  // back without a duration, and the second is the one that has it.
331
336
  file.learn(mediaInfo);
337
+ await this.#host.hostLoad.readFileLength(sourceKey, fileIndex);
332
338
  const durationSeconds = file.durationSeconds ?? 0;
333
339
  const sourceWidth = file.width;
334
340
  const sourceHeight = file.height;
@@ -383,11 +389,6 @@ export class OutputOpening {
383
389
  // duration, so the picture's byte rate would buy a window twenty times
384
390
  // wider than the seconds it is meant to represent, and the piece store
385
391
  // would hold it.
386
- const readWindowBytes = await this.#host.hostLoad.readWindowBytesFor(
387
- inputFile.sourceKey,
388
- inputFile.fileIndex,
389
- durationSeconds
390
- );
391
392
  if (!hasDuration) {
392
393
  this.#host.logger.warn(
393
394
  `transcode "${logName}": could not probe duration; falling back to ` +
@@ -428,13 +429,6 @@ export class OutputOpening {
428
429
  // segment boundaries (the playlist itself), so this MUST block session
429
430
  // creation — an incorrect playlist is worse than a slower start.
430
431
  //
431
- // The wait is bounded, and the bound is what the read costs on a real
432
- // host rather than a figure picked here (`KEYFRAME_TABLE_BUDGET_MS`). A
433
- // comment in this place used to promise a short timeout and "never more
434
- // than ~6 s to session start" when no timeout existed at all; the file
435
- // comes off a torrent, so the bytes the table lives in may still be
436
- // arriving, and a session used to wait for them without limit.
437
- //
438
432
  // What is read is the container's OWN table (Cues/stss) rather than a
439
433
  // scan of the media. On the copy path ffmpeg can only cut at the source's
440
434
  // existing keyframes, so these times ARE the segment boundaries —
@@ -449,18 +443,9 @@ export class OutputOpening {
449
443
  const { arrived } = await this.#host.keyframeTables.within({ sourceKey, fileIndex, logName });
450
444
  keyframeMs = Date.now() - keyframeStartMs;
451
445
  if (!arrived) {
452
- // A read that ran out of its budget is still running, and one that
453
- // found its bytes not downloaded yet is made again when they arrive;
454
- // either way the table is still unanswered — which is not the same as a file with no keyframes,
455
- // and the distinction is the table's own (`answered` against
456
- // `readable`). Recorded as an absence it would make a passing shortage
457
- // of bytes look like a property of the bytes, and every later session
458
- // of the file would re-encode a picture that can be copied.
459
- this.#host.logger.warn(
460
- `transcode: the keyframe table for "${logName}" has not arrived after ${keyframeMs}ms ` +
461
- `(budget ${Math.round(this.#host.keyframeTables.budgetMs / 1000)}s), so this session re-encodes the picture ` +
462
- "instead of copying it; the read goes on and the next session of this file gets the copy"
463
- );
446
+ const error = new Error("Source keyframe declarations require more available bytes.");
447
+ error.code = "MEDIA_BYTES_UNAVAILABLE";
448
+ throw error;
464
449
  }
465
450
  if (!keyframes.readable) {
466
451
  // No index, so there is no honest grid for a COPY: a copied picture can
@@ -474,9 +459,7 @@ export class OutputOpening {
474
459
  // then PLACED at our own cut times rather than found, so the grid is
475
460
  // correct by construction whatever the container. MPEG-TS is the case
476
461
  // this exists for — measured 2026-08-21, 669 real keyframes and no
477
- // index of any kind to read them from — and a container whose index
478
- // could not be read in the budget lands here too, which is right for
479
- // the same reason.
462
+ // index of any kind to read them from.
480
463
  transcodeVideo = true;
481
464
  if (keyframes.answered) {
482
465
  this.#host.logger.warn(
@@ -486,36 +469,6 @@ export class OutputOpening {
486
469
  );
487
470
  }
488
471
  }
489
- } else if (hasDuration && transcodeVideo) {
490
- // Re-encode path: keyframeTimes are ONLY used to snap a LATER seek (see
491
- // #startEncodeRun) — segment boundaries stay on the uniform grid either
492
- // way. So this does NOT need to block session creation / the first
493
- // segment's start. Run it in the background with a FULL budget instead of
494
- // the 6 s cap: AVI-class containers need a full packet scan, which 6 s can
495
- // never afford without delaying playback start — that starved budget is
496
- // exactly why the probe kept missing on the container where the seek bug
497
- // was field-diagnosed. A run reads the file's table on every call, so a
498
- // seek that happens AFTER this finishes picks it up automatically; one
499
- // that happens before falls back to the existing circuit breaker as a
500
- // safety net (no regression either way).
501
- keyframeMs = -2;
502
- const backgroundStartedAt = Date.now();
503
- void this.#host.probeKeyframeTimes(inputFile.streamUrl(this.#host.localBaseUrl).toString(), 25_000).then((times) => {
504
- // Into the FILE's table, which the picture, its quality steps and a
505
- // second viewer's session all hold — so nothing has to be alive for the
506
- // answer to be kept, and the session this probe was started for may
507
- // long since have gone. It used to be written onto whichever session
508
- // was still there, and dropped outright when none was.
509
- this.#host.keyframeTables.learn({ sourceKey, fileIndex }, { times, format: "packet probe" });
510
- const elapsedMs = Date.now() - backgroundStartedAt;
511
- this.#host.logger.info(
512
- times
513
- ? `transcode: background keyframe probe found ${times.length} keyframes ` +
514
- `(${elapsedMs}ms) for "${logName}" — later seeks will snap to them`
515
- : `transcode: background keyframe probe unavailable (${elapsedMs}ms) for "${logName}" ` +
516
- `— seeks keep using the raw target (falls back to the circuit breaker on failure)`
517
- );
518
- });
519
472
  }
520
473
  this.#host.logger.info(
521
474
  `cold-start "${logName}": media-info=${mediaInfoMs}ms (${mediaInfoSource}) ` +
@@ -584,7 +537,7 @@ export class OutputOpening {
584
537
  encodesPicture: transcodeVideo && carriesVideo,
585
538
  exact: forceExactSize,
586
539
  target: this.#targetFor({ normalizedTargetWidth, normalizedTargetHeight, visiblePicture, sourceWidth, sourceHeight }),
587
- source: { width: sourceWidth, height: sourceHeight, megabitsPerSecond: file.decode?.megabitsPerSecond ?? null, decode: file.decode },
540
+ source: { width: sourceWidth, height: sourceHeight, megabitsPerSecond: file.megabitsPerSecond, decode: file.decode },
588
541
  fps: outputFps,
589
542
  encoder: this.#host.videoEncoder,
590
543
  benchmark: this.#host.softwarePresetBenchmark,
@@ -722,7 +675,6 @@ export class OutputOpening {
722
675
  this.#host.segmentOutputFiles.directoryFor(spec.toKey());
723
676
  this.#host.segmentOutputFiles.useFormat(spec.toKey(), segmentFormat);
724
677
  this.#host.hostTimings.noteOutputCreated(session, createEntryMs);
725
- this.#host.encodeRuns.setReadWindow(session, readWindowBytes);
726
678
  this.#host.encodeCost.notePredictionFor(session, output.encodeHeight);
727
679
  // Decided before the key was built and only recorded here. Whether the audio
728
680
  // travels separately decides the ffmpeg arguments, what the master says,
@@ -14,7 +14,6 @@ import { variantHeightsFor } from "./output/ladder.js";
14
14
  import { isSameMaterial, outputSuits } from "./quality/serving-output.js";
15
15
  import { SOUNDTRACK_MODE_CAUSE, chooseSoundtrackMode, linkAnswerFigures, linkCouldCarry, loadOf, soundtrackLoadOf, videoLoadOfLimit, videoLoadOfSpec } from "./quality/link-budget.js";
16
16
  import { maxrateKbpsFor } from "./args.js";
17
- import { encoderInputs } from "./run-inputs.js";
18
17
  /**
19
18
  * How a base files the audio renditions it has made.
20
19
  *
@@ -132,7 +131,7 @@ export class Renditions {
132
131
  wanted,
133
132
  judge: (spec) => linkCouldCarry(
134
133
  linkMbps,
135
- loadOf(videoLoadOfSpec(spec, base.file.decode?.megabitsPerSecond ?? null, this.#host.observedPeakMbps?.(spec) ?? null), audioLoad)
134
+ loadOf(videoLoadOfSpec(spec, base.file.megabitsPerSecond, this.#host.observedPeakMbps?.(spec) ?? null), audioLoad)
136
135
  )
137
136
  });
138
137
  }
@@ -1661,21 +1660,6 @@ export class Renditions {
1661
1660
  ) ?? null;
1662
1661
  }
1663
1662
 
1664
- inputOf(session, runToken) {
1665
- return encoderInputs({
1666
- picture: session.file,
1667
- soundtrack: session.spec.audio
1668
- ? this.#host.sourceFiles.get(session.file.sourceKey, session.spec.audioFileIndex)
1669
- : session.file,
1670
- carries: session.spec.carries,
1671
- audioSeparate: this.servesAudioSeparately(session),
1672
- sessionId: session.id,
1673
- runToken,
1674
- readWindowBytes: this.#host.encodeRuns.readWindowFor(session),
1675
- baseUrl: this.#host.localBaseUrl
1676
- });
1677
- }
1678
-
1679
1663
  /**
1680
1664
  * One file of an audio rendition: its playlist, its init segment or one of
1681
1665
  * its segments.
@@ -0,0 +1,60 @@
1
+ import { AUDIO_TRANSCODE_KBPS } from "./args.js";
2
+ import { ffmpegSeconds, publishedStartTime, publishedGridFor } from "./run-command.js";
3
+ import { SEGMENT_CUT_TIME_DELTA_SECONDS } from "./output/index.js";
4
+
5
+ /** One held Matroska stdin, with no container seek or second input. */
6
+ export function buildAdmittedCommand({ admittedInput, timeline, output, segmentFormat, transcodeVideo,
7
+ transcodeAudio, audioOnly, audioSeparate, rateControl, startIndex, endIndex, videoEncoder, segmentDurationSec }) {
8
+ const safeIndex = Math.max(0, Number.isInteger(startIndex) ? startIndex : 0);
9
+ const startSeconds = publishedStartTime(timeline, safeIndex, segmentDurationSec);
10
+ const grid = publishedGridFor(timeline);
11
+ const endSeconds = grid?.[endIndex + 1];
12
+ if (!(endSeconds > startSeconds)) throw new Error("An admitted run requires a complete finite output interval.");
13
+ const origin = admittedInput.originSeconds;
14
+ const relativeStart = Math.max(0, startSeconds - origin);
15
+ const relativeEnd = endSeconds - origin;
16
+ const cutTimes = grid.slice(safeIndex + 1, endIndex + 1).map(time => time - startSeconds);
17
+ const args = ["-hide_banner", "-nostats", "-loglevel", "error", "-progress", "pipe:1"];
18
+ if (transcodeVideo && Array.isArray(videoEncoder.inputArgs)) args.push(...videoEncoder.inputArgs);
19
+ args.push("-f", "matroska", "-i", "pipe:0", "-copyts", "-avoid_negative_ts", "disabled", "-output_ts_offset", ffmpegSeconds(origin));
20
+ const video = transcodeVideo ? videoEncoder.buildVideoArgs({
21
+ targetWidth: output.encodeWidth, targetHeight: output.encodeHeight, segmentDurationSec,
22
+ fps: output.outputFps, preset: output.softwarePreset ?? undefined, tonemap: output.applyTonemap === true,
23
+ forcedKeyframeTimes: [relativeStart, ...cutTimes.map(time => relativeStart + time)], rateControl: rateControl ?? null
24
+ }) : ["-c:v", "copy"];
25
+ if (transcodeVideo) {
26
+ video.push("-fps_mode:v", "passthrough", "-enc_time_base:v", "1:1000000");
27
+ const existing = video.indexOf("-vf");
28
+ const trim = `trim=start=${ffmpegSeconds(relativeStart)}:end=${ffmpegSeconds(relativeEnd)}`;
29
+ if (existing >= 0) {
30
+ // Anchor the frame cadence at the requested interval, rather than the
31
+ // earlier audio preroll that determined the shared stdin origin.
32
+ const filters = video[existing + 1].replace(/fps=(\d+)(?=,|$)/g,
33
+ (_match, fps) => `setpts=PTS+${ffmpegSeconds(origin)}/TB,fps=${fps}:start_time=${ffmpegSeconds(startSeconds)},settb=1/1000000,setpts=PTS-${ffmpegSeconds(origin)}/TB`);
34
+ video[existing + 1] = `${trim},${filters},${trim}`;
35
+ }
36
+ else video.push("-vf", trim);
37
+ }
38
+ const audio = transcodeAudio ? ["-c:a", "aac", "-ac", "2", "-b:a", `${AUDIO_TRANSCODE_KBPS}k`,
39
+ "-af", `atrim=start=${ffmpegSeconds(relativeStart)}:end=${ffmpegSeconds(relativeEnd)}`] : ["-c:a", "copy"];
40
+ if (audioOnly) args.push("-vn", "-map", "0:a:0", ...audio);
41
+ else if (audioSeparate) args.push("-an", "-map", "0:v:0", ...video);
42
+ else args.push("-map", "0:v:0", "-map", "0:a:0?", ...video, ...audio);
43
+ args.push("-to", ffmpegSeconds(relativeEnd), "-f", "segment");
44
+ if (cutTimes.length) args.push("-segment_times", cutTimes.join(","));
45
+ else args.push("-segment_time", ffmpegSeconds(endSeconds - startSeconds));
46
+ const formatArgs = segmentFormat.explicitTimesMuxerArgs?.();
47
+ if (!Array.isArray(formatArgs)) throw new Error("The segment format cannot publish admitted packet input.");
48
+ const muxOptions = formatArgs.indexOf("-segment_format_options");
49
+ const muxFormat = formatArgs.indexOf("-segment_format");
50
+ if (muxFormat >= 0 && formatArgs[muxFormat + 1] === "mp4" && muxOptions >= 0) {
51
+ // Millisecond movie edits cannot retain a sample-accurate AAC origin.
52
+ formatArgs[muxOptions + 1] += ":movie_timescale=1000000";
53
+ }
54
+ // Audio has no keyframe rounding: the video tolerance can remove several
55
+ // audio frames from the declared interval before the requested cut.
56
+ args.push("-segment_time_delta", String(audioOnly ? 0 : SEGMENT_CUT_TIME_DELTA_SECONDS), "-segment_start_number", String(safeIndex),
57
+ "-segment_list", "pipe:3", "-segment_list_flags", "+live", ...formatArgs,
58
+ segmentFormat.makingFileNameTemplate(admittedInput.runTag ?? `${safeIndex}-${admittedInput.fingerprint.slice(0, 12)}`));
59
+ return { args, safeIndex, startSeconds, cutTimes };
60
+ }
@@ -0,0 +1,37 @@
1
+ /** Convert borrowed Annex B payload slices to Matroska AVC length framing. */
2
+ export function avcPacketSlices(slices) {
3
+ if (!Array.isArray(slices) || slices.some(slice => !Buffer.isBuffer(slice))) throw new TypeError("AVC packet slices must be buffers.");
4
+ const units = [];
5
+ let position = 0, zeroes = 0, start = null;
6
+ for (const slice of slices) {
7
+ for (const byte of slice) {
8
+ if (byte === 1 && zeroes >= 2) {
9
+ const end = position - zeroes;
10
+ if (start !== null) {
11
+ if (end <= start) throw new Error("AVC Annex B contains an empty NAL unit.");
12
+ units.push([start, end]);
13
+ } else if (end !== 0) throw new Error("AVC Annex B precedes its first start code with data.");
14
+ start = position + 1;
15
+ }
16
+ zeroes = byte === 0 ? zeroes + 1 : 0;
17
+ position++;
18
+ }
19
+ }
20
+ if (start === null || position - zeroes <= start) throw new Error("AVC Annex B has no complete NAL unit.");
21
+ units.push([start, position - zeroes]);
22
+ const result = [];
23
+ for (const [from, to] of units) {
24
+ const length = to - from;
25
+ if (length > 0xffffffff) throw new Error("AVC NAL unit exceeds its length declaration.");
26
+ const header = Buffer.alloc(4);
27
+ header.writeUInt32BE(length);
28
+ result.push(header);
29
+ let offset = 0;
30
+ for (const slice of slices) {
31
+ if (offset < to && offset + slice.length > from) result.push(slice.subarray(Math.max(0, from - offset), Math.min(slice.length, to - offset)));
32
+ offset += slice.length;
33
+ if (offset >= to) break;
34
+ }
35
+ }
36
+ return result;
37
+ }
@@ -0,0 +1,29 @@
1
+ /** Normalize an admitted byte-sized payload that starts between source bytes. */
2
+ export function bitPayloadSlices(slices, bitOffset, bitLength) {
3
+ const total = slices.reduce((sum, slice) => sum + slice.length, 0);
4
+ if (!Number.isSafeInteger(bitOffset) || bitOffset < 0 || !Number.isSafeInteger(bitLength) ||
5
+ bitLength <= 0 || bitLength % 8 !== 0 || bitOffset + bitLength > total * 8) throw new Error("Admitted packet bit payload is incomplete.");
6
+ const first = Math.floor(bitOffset / 8), length = bitLength / 8, shift = bitOffset & 7;
7
+ if (!shift) {
8
+ let offset = 0;
9
+ const result = [];
10
+ for (const slice of slices) {
11
+ if (offset < first + length && offset + slice.length > first) result.push(slice.subarray(Math.max(0, first - offset), Math.min(slice.length, first + length - offset)));
12
+ offset += slice.length;
13
+ }
14
+ return result;
15
+ }
16
+ const result = Buffer.alloc(length);
17
+ let sliceIndex = 0, base = 0;
18
+ const byteAt = position => {
19
+ while (base + slices[sliceIndex].length <= position) { base += slices[sliceIndex].length; sliceIndex++; }
20
+ return slices[sliceIndex][position - base];
21
+ };
22
+ let before = byteAt(first);
23
+ for (let offset = 0; offset < length; offset++) {
24
+ const after = byteAt(first + offset + 1);
25
+ result[offset] = ((before << shift) | (after >> (8 - shift))) & 255;
26
+ before = after;
27
+ }
28
+ return [result];
29
+ }
@@ -401,7 +401,7 @@ export class OutputCatalog {
401
401
  // The probe's own reading of the video stream, which is known from the
402
402
  // moment the session exists and covers the gap before the torrent has
403
403
  // reported a length.
404
- streamBitsPerSecond: (Number(session.file?.decode?.megabitsPerSecond) || 0) * 1_000_000,
404
+ streamBitsPerSecond: (Number(session.file?.megabitsPerSecond) || 0) * 1_000_000,
405
405
  largest: this.largestPieceOf(session.outputKey ?? ""),
406
406
  boundaries: session.timeline?.published ?? session.timeline?.boundaries ?? null,
407
407
  producedHeight: this.producedHeightOf(session),
@@ -118,6 +118,17 @@ export class Timeline {
118
118
  return this.sourceTimes[Math.min(index, this.sourceTimes.length - 1)] ?? null;
119
119
  }
120
120
 
121
+ /** Address source packets with exact cuts, translated only for another file. */
122
+ sourceInterval(fromIndex, toIndex, sourceStartTime = 0) {
123
+ const base = this.sourceStartOf(0);
124
+ const from = this.sourceStartOf(fromIndex), to = this.sourceStartOf(toIndex + 1);
125
+ const shift = sourceStartTime - (base ?? 0);
126
+ return {
127
+ from: from === null ? this.publishedStartOf(fromIndex) + sourceStartTime : from + shift,
128
+ to: to === null ? this.publishedStartOf(toIndex + 1) + sourceStartTime : to + shift
129
+ };
130
+ }
131
+
121
132
  /**
122
133
  * Where segment `index` begins on the live table — where a run cutting now
123
134
  * will really put it.
@@ -0,0 +1,30 @@
1
+ /** PCM variants that Matroska requires in a different byte representation. */
2
+ export function pcmNormalization(track) {
3
+ if (track.matroskaCodecId) return null;
4
+ const format = /^pcm_([suf])(8|16|24|32|64)(le|be)?$/.exec(track.codecId ?? "");
5
+ if (!format) return null;
6
+ const [, kind, bits, endian] = format;
7
+ const width = Number(bits) / 8;
8
+ const swap = kind === "f" && endian === "be";
9
+ const flipSign = (kind === "s" && width === 1) || (kind === "u" && width > 1);
10
+ return swap || flipSign ? { width, swap, flipSign, little: endian === "le" } : null;
11
+ }
12
+
13
+ /** Convert owned output bytes without modifying any shared source slice. */
14
+ export function normalizePcmSlices(slices, format) {
15
+ const length = slices.reduce((sum, slice) => sum + slice.length, 0);
16
+ if (length % format.width) throw new Error("PCM packet ends inside a sample.");
17
+ const output = Buffer.alloc(length);
18
+ let source = 0;
19
+ for (const slice of slices) {
20
+ for (const byte of slice) {
21
+ const sample = Math.floor(source / format.width) * format.width;
22
+ const within = source % format.width;
23
+ const target = sample + (format.swap ? format.width - 1 - within : within);
24
+ const signByte = format.little ? format.width - 1 : 0;
25
+ output[target] = format.flipSign && within === signByte ? byte ^ 0x80 : byte;
26
+ source++;
27
+ }
28
+ }
29
+ return output;
30
+ }
@@ -40,11 +40,15 @@ export function cutOf(timeline, index) {
40
40
  * @param {number | undefined} cutSeconds - From {@link cutOf}.
41
41
  * @returns {{ whole: boolean, throughSeconds: number | null }}
42
42
  */
43
- export function judgePiece(format, ranges, cutSeconds) {
43
+ export function judgePiece(format, ranges, cutSeconds, interval) {
44
44
  const throughSeconds = format.producedThroughSeconds?.(ranges) ?? null;
45
45
  if (throughSeconds === null) {
46
46
  return { whole: false, throughSeconds };
47
47
  }
48
+ if (interval) {
49
+ const reason = intervalFailure(ranges, interval);
50
+ return reason ? { whole: false, throughSeconds, reason } : { whole: true, throughSeconds };
51
+ }
48
52
  const cutMicros = ranges?.production?.cutMicros ??
49
53
  (Number.isFinite(cutSeconds) ? BigInt(Math.round(cutSeconds * 1_000_000)) : null);
50
54
  if (cutMicros !== null && ranges?.tracks?.length) {
@@ -72,6 +76,7 @@ export function judgePiece(format, ranges, cutSeconds) {
72
76
  return { whole, throughSeconds };
73
77
  }
74
78
  if (cutMicros !== null && throughSeconds < Number(cutMicros) / 1_000_000) {
79
+
75
80
  return { whole: false, throughSeconds };
76
81
  }
77
82
  return { whole: true, throughSeconds };
@@ -102,3 +107,51 @@ export function originOf(ranges, endMicros, kind) {
102
107
  return ticks >= 0n ? (2n * ticks + track.timescale) / (2n * track.timescale) :
103
108
  -((-2n * ticks + track.timescale) / (2n * track.timescale));
104
109
  }
110
+
111
+ /** Compare adjacent production ranges in their declared track clocks. */
112
+ export function judgeNeighbors(left, right) {
113
+ if (!left?.tracks?.length || !right?.tracks?.length) return { whole: false, reason: "neighbor-tracks-are-missing" };
114
+ for (const track of right.tracks) {
115
+ if (!left.tracks.some(previous => previous.kind === track.kind)) return { whole: false, reason: `neighbor-missing-${track.kind}` };
116
+ }
117
+ for (const track of left?.tracks ?? []) {
118
+ const next = right?.tracks?.find(candidate => candidate.kind === track.kind);
119
+ if (!next?.ranges?.length || !track.ranges?.length) return { whole: false, reason: `neighbor-missing-${track.kind}` };
120
+ if (!(track.timescale > 0n) || !(next.timescale > 0n)) return { whole: false, reason: "neighbor-clock-is-invalid" };
121
+ const end = track.ranges.at(-1), start = next.ranges[0];
122
+ const difference = start.start * track.timescale - end.end * next.timescale;
123
+ const leftFrame = (track.productionFrame ?? end.frame) * next.timescale;
124
+ const rightFrame = (next.productionFrame ?? start.frame) * track.timescale;
125
+ if (!(leftFrame > 0n) || !(rightFrame > 0n)) return { whole: false, reason: "neighbor-frame-is-invalid" };
126
+ const allowance = leftFrame > rightFrame ? leftFrame : rightFrame;
127
+ if (difference > allowance || difference < -allowance) return { whole: false, reason: `neighbor-discontinuity-${track.kind}` };
128
+ }
129
+ return { whole: true };
130
+ }
131
+
132
+ /** Every required track must cover the interval with at most one-frame error. */
133
+ function intervalFailure(coverage, { from, to, requiredKinds, sourceEnds = {} }) {
134
+ if (!Number.isFinite(from) || !Number.isFinite(to) || !(to > from) || !requiredKinds?.length) return "segment-interval-is-not-declared";
135
+ for (const kind of requiredKinds) {
136
+ const track = coverage?.tracks?.find(track => track.kind === kind);
137
+ if (!track?.ranges?.length || !(track.timescale > 0n)) return `segment-missing-${kind}`;
138
+ const start = BigInt(Math.round(from * Number(track.timescale)));
139
+ const through = Number.isFinite(sourceEnds[kind]) ? Math.min(to, sourceEnds[kind]) : to;
140
+ if (!(through > from)) return `segment-interval-is-empty-${kind}`;
141
+ const end = BigInt(Math.round(through * Number(track.timescale)));
142
+ const first = track.ranges[0];
143
+ const frame = track.productionFrame ?? first.frame;
144
+ if (first.start > start + frame || first.start < start - frame) return `segment-start-outside-interval-${kind}`;
145
+ // The first sample's cadence already bounds the interval start. A shorter
146
+ // final sample must not replace that bound when this range is traversed.
147
+ let reached = first.start;
148
+ for (const range of track.ranges) {
149
+ if (!(range.frame > 0n) || range.end <= range.start || range.start > reached + range.frame) return `segment-gap-within-interval-${kind}`;
150
+ if (range.end > reached) reached = range.end;
151
+ }
152
+ const finalFrame = track.productionFrame ?? track.ranges.at(-1).frame;
153
+ if (reached < end - finalFrame || reached > end + finalFrame) return `segment-end-outside-interval-${kind}`;
154
+ }
155
+ return null;
156
+
157
+ }
@@ -15,19 +15,6 @@ import { medianOf, movedBeyondScatter, READINGS_KEPT, scatterOf } from "../learn
15
15
  import { ENCODE_RUN_STATE, processCanBeSignalled } from "../encode-run-state.js";
16
16
  import { SourceFiles } from "../../media/SourceFile.js";
17
17
 
18
- // How far ahead of its own read head a reader asks the swarm for, expressed in
19
- // seconds of PLAYBACK. The torrent thread can only think in bytes, and a fixed
20
- // byte window is wrong at both ends of the range: 32 MB is half a minute of a
21
- // 1080p film and about four seconds of a disc remux. Duration and file size are
22
- // both known here, so the window is sized where the knowledge is and sent down
23
- // on the ffmpeg input URL.
24
- const READ_WINDOW_SECONDS = 30;
25
- // Bounds, so a wrong or unusual byte rate cannot ask for something absurd. The
26
- // floor keeps a few pieces in flight on a low-bitrate file; the ceiling keeps
27
- // one reader from claiming more than a fraction of the piece store.
28
- const READ_WINDOW_MIN_BYTES = 16 * 1024 * 1024;
29
- const READ_WINDOW_MAX_BYTES = 96 * 1024 * 1024;
30
-
31
18
  export class HostLoad {
32
19
  /** What this reads and asks of the rest of the proxy, and nothing else. @type {object} */
33
20
  #host;
@@ -42,8 +29,7 @@ export class HostLoad {
42
29
  #idleLoadSample = null;
43
30
 
44
31
  /**
45
- * How long each file is in bytes, from the stats call the read window already
46
- * makes. The torrent moves the CONTAINER, so this — not the video stream's
32
+ * How long each file is in bytes, from its declared source statistics. The torrent moves the CONTAINER, so this — not the video stream's
47
33
  * bitrate — is what its work should be priced against.
48
34
  *
49
35
  * @type {Map<string, number>}
@@ -96,67 +82,18 @@ export class HostLoad {
96
82
  this.#host = host;
97
83
  }
98
84
 
99
- /**
100
- * The read-ahead window for a file, in bytes, sized from how many seconds of
101
- * playback it holds.
102
- *
103
- * @param {string} sourceKey
104
- * @param {number} fileIndex
105
- * @param {number} durationSeconds
106
- * @returns {Promise<number>} Zero when the byte rate cannot be established,
107
- * which leaves the reader on its own default.
108
- */
109
- async readWindowBytesFor(sourceKey, fileIndex, durationSeconds) {
110
- // The length read here is also what prices the torrent's own work for this
111
- // file, so it is remembered rather than discarded.
112
-
113
- if (!this.#host.getSourceStats || !Number.isFinite(durationSeconds) || durationSeconds <= 0) {
114
- return 0;
115
- }
116
- let fileLength = 0;
85
+ /** Read the declared source length for pricing; this does not request file bytes. */
86
+ async readFileLength(sourceKey, fileIndex) {
87
+ if (!this.#host.getSourceStats) return null;
117
88
  try {
118
89
  const stats = await this.#host.getSourceStats(sourceKey, fileIndex);
119
- fileLength = Number(stats?.fileLength);
90
+ const length = Number(stats?.fileLength);
91
+ if (!Number.isFinite(length) || length <= 0) return null;
92
+ this.fileLengthByKey.set(SourceFiles.keyFor(sourceKey, fileIndex), length);
93
+ return length;
120
94
  } catch {
121
- return 0;
122
- }
123
- if (!Number.isFinite(fileLength) || fileLength <= 0) {
124
- return 0;
125
- }
126
- this.fileLengthByKey.set(SourceFiles.keyFor(sourceKey, fileIndex), fileLength);
127
- const bytesPerSecond = fileLength / durationSeconds;
128
- // Shared between the readers this file already has. The window is stated in
129
- // seconds of playback and the store's memory is one budget for the whole
130
- // torrent, so N readers asking for thirty seconds each ask for N times what
131
- // was provided for — and on 2026-08-15 that is exactly what happened: a
132
- // viewer with a picture and an audio track had every resident piece held at
133
- // once, a read ended with zero bytes, and every encoder on the file took
134
- // that for the end of it.
135
- //
136
- // Dividing keeps the promise the budget was written against. It is not the
137
- // sliding window of roadmap item 8 — pieces still leave only by the store's
138
- // own eviction — but it removes the multiplication that broke it.
139
- const readers = Math.max(1, this.#readersOn(sourceKey, fileIndex));
140
- const wanted = Math.round((bytesPerSecond * READ_WINDOW_SECONDS) / readers);
141
- return Math.min(READ_WINDOW_MAX_BYTES, Math.max(READ_WINDOW_MIN_BYTES, wanted));
142
- }
143
-
144
- /**
145
- * How many live sessions read this file: the picture, any rung being warmed
146
- * beside it, and any audio track published on its own.
147
- *
148
- * @param {string} sourceKey
149
- * @param {number} fileIndex
150
- * @returns {number}
151
- */
152
- #readersOn(sourceKey, fileIndex) {
153
- let readers = 0;
154
- for (const session of this.#host.outputs.values()) {
155
- if (session?.file.sourceKey === sourceKey && session.file.fileIndex === fileIndex) {
156
- readers += 1;
157
- }
95
+ return null;
158
96
  }
159
- return readers;
160
97
  }
161
98
 
162
99
  /**
@@ -172,16 +109,23 @@ export class HostLoad {
172
109
  * is the torrent's.
173
110
  */
174
111
  async #learnTorrentCost() {
112
+ const activityBefore = this.#host.readMetadataActivity?.();
113
+ const bytes = await this.#torrentBytesMoved();
175
114
  const now = {
176
115
  takenAt: Date.now(),
177
116
  cpuSeconds: this.#host.readProxyCpuSeconds(),
178
- bytes: await this.#torrentBytesMoved()
117
+ bytes,
118
+ metadata: this.#host.readMetadataActivity?.()
179
119
  };
180
120
  const previous = this.#idleLoadSample;
181
121
  this.#idleLoadSample = now;
182
122
  if (previous === null || now.bytes === null || previous.bytes === null) {
183
123
  return;
184
124
  }
125
+ // Process CPU includes metadata parsing. Keep the sample as a baseline,
126
+ // but never attribute an interval containing that work to torrent bytes.
127
+ if (activityBefore?.active || now.metadata?.active || previous.metadata?.active ||
128
+ activityBefore?.epoch !== now.metadata?.epoch || previous.metadata?.epoch !== now.metadata?.epoch) return;
185
129
  const elapsedSec = (now.takenAt - previous.takenAt) / 1000;
186
130
  const megabytes = (now.bytes - previous.bytes) / 1e6;
187
131
  // Divided by the cores, because `process.cpuUsage()` adds up every thread
@@ -260,6 +204,8 @@ export class HostLoad {
260
204
  // client, whichever of its files are being read. Kept per source and
261
205
  // divided among the files being watched, so two episodes of one pack
262
206
  // do not each charge the machine for the whole download.
207
+ const length = Number(stats?.fileLength);
208
+ if (Number.isFinite(length) && length > 0) this.fileLengthByKey.set(key, length);
263
209
  const rate = Number(stats?.downloadSpeed);
264
210
  if (Number.isFinite(rate) && rate >= 0) {
265
211
  measured.set(source.sourceKey, rate);
@@ -409,7 +409,7 @@ export class QualityOffer {
409
409
  return videoLoadForFrame({
410
410
  sourceHeight,
411
411
  copiesAtSource: !base.spec.transcodesVideo,
412
- sourceMbps: base.file.decode?.megabitsPerSecond ?? null,
412
+ sourceMbps: base.file.megabitsPerSecond,
413
413
  encoderKind
414
414
  }, frame);
415
415
  }