@torrent-tv/proxy 2.90.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 (146) hide show
  1. package/bin/cli.js +5 -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/transport/tunnel-client.js +111 -20
  132. package/services/viewer/DownloadMaps.js +168 -0
  133. package/services/viewer/NativeSourceMap.js +33 -0
  134. package/services/viewer/PriorityMap.js +49 -40
  135. package/services/viewer/PriorityOrchestrator.js +42 -13
  136. package/services/viewer/SourcePreparation.js +278 -0
  137. package/services/viewer/Viewer.js +11 -0
  138. package/services/viewer/Viewers.js +109 -2
  139. package/services/viewer/playback-readiness.js +50 -63
  140. package/services/viewer/segment-demands.js +37 -0
  141. package/services/viewer/urgent-output-ready.js +26 -0
  142. package/services/encode/run-inputs.js +0 -53
  143. package/services/media/keyframe-probe.js +0 -157
  144. package/services/media/media-info-probe.js +0 -93
  145. package/services/torrent/worker/background-fill.js +0 -213
  146. package/services/torrent/worker/resume-warm.js +0 -107
@@ -14,8 +14,12 @@ import { ENCODE_RUN_STATE, liveRunsOf, runStateOf, wireState } from "./encode-ru
14
14
  import { ENCODE_EXIT } from "./encode-exit.js";
15
15
  import { EncodeRun } from "./EncodeRun.js";
16
16
  import { computeOutputDimensions } from "./args.js";
17
- import { buildRunCommand, trueStartOf } from "./run-command.js";
18
- import { cutOf, judgePiece, originOf, productionOf } from "./piece-completeness.js";
17
+ import { buildAdmittedCommand } from "./admitted-command.js";
18
+ import { writeAdmittedInput } from "./AdmittedInput.js";
19
+ import { cutOf, judgeNeighbors, judgePiece } from "./piece-completeness.js";
20
+ import { presentationSegment } from "./segment-formats/presentation-segment.js";
21
+ import { InputFailures, failedAdmittedInput } from "./InputFailures.js";
22
+
19
23
 
20
24
  /**
21
25
  * @typedef {Object} SegmentFiles
@@ -158,6 +162,7 @@ export class EncodeRuns {
158
162
  #runsByInputToken = new Map();
159
163
 
160
164
  #lastInputToken = 0;
165
+ #inputFailures = new InputFailures();
161
166
  /** What this reads and asks of the rest of the proxy, and nothing else. @type {object} */
162
167
  #host;
163
168
 
@@ -185,7 +190,6 @@ export class EncodeRuns {
185
190
  let state = this.#inputState.get(output);
186
191
  if (!state) {
187
192
  state = {
188
- readWindowBytes: 0,
189
193
  inputRetryCount: 0,
190
194
  backwardRestarts: { count: 0, segmentsBack: 0, worstBack: 0, remade: 0 },
191
195
  firstWantedAt: new Map(),
@@ -199,14 +203,6 @@ export class EncodeRuns {
199
203
  return state;
200
204
  }
201
205
 
202
- setReadWindow(output, bytes) {
203
- this.#inputStateFor(output).readWindowBytes = Number.isFinite(bytes) ? bytes : 0;
204
- }
205
-
206
- readWindowFor(output) {
207
- return this.#inputStateFor(output).readWindowBytes;
208
- }
209
-
210
206
  noteWanted(output, index, at = Date.now()) {
211
207
  const wanted = this.#inputStateFor(output).firstWantedAt;
212
208
  if (!wanted.has(index)) wanted.set(index, at);
@@ -289,10 +285,16 @@ export class EncodeRuns {
289
285
  }
290
286
 
291
287
  runStateOf(output) {
288
+ if (this.#host.encodeInputs?.failureOf(output)) return ENCODE_RUN_STATE.ENDED_FAILED;
289
+ if (output && this.liveRunsOf(output).length === 0 && this.#inputFailures.reason(output.outputKey)) return ENCODE_RUN_STATE.ENDED_FAILED;
292
290
  return output ? this.#host.encodeOrchestrator.stateOf(output.outputKey) : runStateOf([]);
293
291
  }
294
292
 
295
293
  lastErrorOf(output) {
294
+ const failedInput = this.#host.encodeInputs?.failureOf(output);
295
+ if (failedInput) return `${failedInput.reason}: ${failedInput.message ?? ""}`;
296
+ const failed = output && this.#inputFailures.reason(output.outputKey);
297
+ if (failed) return failed;
296
298
  return output ? this.#host.encodeOrchestrator.errorOf(output.outputKey) : "";
297
299
  }
298
300
 
@@ -346,24 +348,46 @@ export class EncodeRuns {
346
348
  * @param {number} index
347
349
  * @returns {object | null | false}
348
350
  */
349
- #wholeClosedPiece(session, name, index, production = null) {
351
+ #wholeClosedPiece(session, name, index, admitted = false, bytes = null) {
352
+
350
353
  const format = session.segmentFormat;
351
354
  if (!format?.readMediaRanges || !Number.isInteger(index) || index < 0) {
352
355
  return null;
353
356
  }
354
357
  const address = session.outputKey ?? "";
355
358
  try {
356
- const mediaRanges = format.readMediaRanges(this.#host.segmentFiles.closedBytesOf(address, name));
357
- if (mediaRanges && production) mediaRanges.production = production;
359
+ const mediaRanges = format.readMediaRanges(bytes ?? this.#host.segmentFiles.closedBytesOf(address, name));
360
+
358
361
  const cut = cutOf(session.timeline, index);
359
- const { whole, throughSeconds } = judgePiece(format, mediaRanges, cut);
362
+ const grid = session.timeline.published ?? session.timeline.boundaries;
363
+ const interval = admitted ? { from: grid?.[index], to: grid?.[index + 1],
364
+ ...(index === session.timeline.segmentCount - 1 ? { sourceEnds: Object.fromEntries(
365
+ (admitted.tracks ?? []).filter(input => Number.isFinite(input.sourceEndSeconds))
366
+ .map(input => [input.track.type === "video" ? "vide" : "soun", input.sourceEndSeconds])) } : {}),
367
+ requiredKinds: [session.spec.carries !== "audio-only" ? "vide" : null,
368
+ session.spec.audio && !this.#host.servesAudioSeparately(session) ? "soun" : null].filter(Boolean) } : undefined;
369
+ const { whole, throughSeconds, reason } = judgePiece(format, mediaRanges, cut, interval);
360
370
  if (!whole) {
361
371
  this.#host.logger.warn(
362
372
  `encode: not publishing piece ${index} of ${address.slice(0, 60)}: ` +
363
- (throughSeconds === null ? "it holds no playable media" : `produced through ${throughSeconds}s, cut ${cut}s`)
373
+ (reason ?? (throughSeconds === null ? "it holds no playable media" : `produced through ${throughSeconds}s, cut ${cut}s`))
364
374
  );
365
375
  return false;
366
376
  }
377
+ if (admitted) {
378
+ const produced = this.#host.producedNumbers(session);
379
+ for (const neighbor of [index - 1, index + 1]) {
380
+ if (!produced.includes(neighbor)) continue;
381
+ const ranges = this.#host.segmentFiles.mediaRangesOf(address, neighbor,
382
+ { startSeconds: grid[neighbor] });
383
+ if (!ranges) continue;
384
+ const judged = neighbor < index ? judgeNeighbors(ranges, mediaRanges) : judgeNeighbors(mediaRanges, ranges);
385
+ if (!judged.whole) {
386
+ this.#host.logger.warn(`encode: not publishing piece ${index} of ${address}: ${judged.reason}`);
387
+ return false;
388
+ }
389
+ }
390
+ }
367
391
  return mediaRanges;
368
392
  } catch (error) {
369
393
  this.#host.logger.warn(`encode: could not read the media of ${name}: ${error instanceof Error ? error.message : String(error)}`);
@@ -519,7 +543,9 @@ export class EncodeRuns {
519
543
  }
520
544
 
521
545
  forgetEncodingOfGone(output) {
546
+ this.#host.encodeInputs?.forget(output);
522
547
  if (this.#host.outputs.outputsOn(output.outputKey).length === 0) {
548
+ this.#inputFailures.forget(output.outputKey);
523
549
  this.#host.encodeOrchestrator.forgetOutput(output.outputKey);
524
550
  }
525
551
  }
@@ -567,7 +593,7 @@ export class EncodeRuns {
567
593
  //
568
594
  // A DIFFERENT position is unaffected and gets its own budget, and the count
569
595
  // resets the moment a run at this one does real work.
570
- if (!this.#host.encodeOrchestrator.mayStartAt(address, from)) {
596
+ if (!this.#host.encodeInputs && !this.#host.encodeOrchestrator.mayStartAt(address, from)) {
571
597
  return null;
572
598
  }
573
599
  // The encoder is built and handed back in this same call. Nothing here
@@ -772,6 +798,19 @@ export class EncodeRuns {
772
798
  if (!this.isLive(session)) {
773
799
  return null;
774
800
  }
801
+ this.#host.encodeInputs?.retain(session, this.#host.encodeOrchestrator.wantedSegmentsOn(session.outputKey));
802
+ const admittedInput = this.#host.encodeInputs?.take(session, startIndex, startIndex) ?? null;
803
+ if (!admittedInput) return null;
804
+ const inputParameters = admittedInput ? { outputKey: session.outputKey, encoder: this.#host.videoEncoder.name,
805
+ width: session.output.encodeWidth, height: session.output.encodeHeight, fps: session.output.outputFps,
806
+ preset: session.output.softwarePreset, tonemap: session.output.applyTonemap,
807
+ rateControl: session.spec.video?.encode?.rateControl ?? null } : null;
808
+ const inputKey = admittedInput ? this.#inputFailures.key(admittedInput, inputParameters) : null;
809
+ if (inputKey && this.#inputFailures.failure(session.outputKey, startIndex, inputKey)) {
810
+ admittedInput.release();
811
+ return null;
812
+ }
813
+ try {
775
814
  // Where a restart's seconds go. A seek costs 5-8 s in the field and the
776
815
  // recorded reason — waiting for the previous ffmpeg to exit, measured at
777
816
  // 0.54-1.47 s — does not account for it. Before rebuilding the hottest path
@@ -793,7 +832,7 @@ export class EncodeRuns {
793
832
  // an argument. Reading it off the coverage map a second time was the last
794
833
  // remaining second answer to that question: the plan computed `to`, passed
795
834
  // it, and the parameter list did not name it.
796
- const runEnd = Number.isInteger(ordered?.to)
835
+ const runEnd = admittedInput ? startIndex : Number.isInteger(ordered?.to)
797
836
  ? ordered.to
798
837
  : this.#runEndFrom(session, startIndex, null);
799
838
  // The restart backs off a segment or two from what was asked for, so the
@@ -839,10 +878,6 @@ export class EncodeRuns {
839
878
  // time and landed apart again. It is a fact of the FILE's cutting, held in
840
879
  // the live table every session of the file shares, so it is read from
841
880
  // there.
842
- const positionSecondsOverride = session.spec.carries === "audio-only"
843
- ? trueStartOf(session.timeline, startIndex)
844
- : undefined;
845
-
846
881
  // What is still read off the session for a run. The list is the measure of
847
882
  // how far a session still is from being the three things a run is built
848
883
  // from — the material, what is produced of it, and the stretch — and it
@@ -851,13 +886,10 @@ export class EncodeRuns {
851
886
  // delete, and this is a thing that disappears by being emptied.
852
887
  this.#lastInputToken += 1;
853
888
  const inputToken = this.#lastInputToken;
854
- const inputs = this.#host.inputOf(session, inputToken);
855
- const { args, safeIndex, inputIndex, startSeconds, cutTimes } = buildRunCommand({
856
- keyframes: session.keyframes,
857
- inputFile: inputs.inputFile,
858
- audioFile: inputs.audioFile,
859
- inputUrl: inputs.inputUrl,
860
- audioInputUrl: inputs.audioInputUrl,
889
+ if (admittedInput) admittedInput.runTag = `${startIndex}r${inputToken}`;
890
+ const { args, safeIndex, startSeconds, cutTimes } = buildAdmittedCommand({
891
+ admittedInput,
892
+
861
893
  timeline: session.timeline,
862
894
  output: session.output,
863
895
  segmentFormat: session.segmentFormat,
@@ -868,8 +900,7 @@ export class EncodeRuns {
868
900
  audioSourceTrackIndex: session.spec.audioSourceTrackIndex,
869
901
  rateControl: session.spec.video?.encode?.rateControl ?? null,
870
902
  startIndex,
871
- endIndex: runEnd,
872
- positionSecondsOverride,
903
+ endIndex: Number.isInteger(ordered?.to) ? ordered.to : runEnd >= startIndex ? runEnd : session.timeline.segmentCount - 1,
873
904
  videoEncoder: this.#host.videoEncoder,
874
905
  segmentDurationSec: this.#host.segmentDurationSec
875
906
  });
@@ -898,18 +929,17 @@ export class EncodeRuns {
898
929
  // killed mid-piece leaving a partial file — is not a correctness problem
899
930
  // either, because the store serves a segment only once its closure is
900
931
  // proven, and it is cleared up when the run ends.
901
- let originMicros = null;
902
- const referenceKind = session.spec.video ? "vide" : "soun";
903
932
  const run = new EncodeRun({
904
933
  address: session.outputKey ?? session.id,
905
934
  encoder: this.#host.videoEncoder,
906
935
  from: safeIndex,
907
936
  to: runEnd,
937
+ makingTag: admittedInput?.runTag ?? String(safeIndex),
908
938
  buildArgs: () => args,
909
939
  argsDescribed: describeFfmpegArgs(args),
910
940
  // Whether this run cuts at times we gave it. Decides how a segment is
911
941
  // judged finished — see getFileStream.
912
- usesExplicitCuts: Boolean(cutTimes && cutTimes.length > 0),
942
+ usesExplicitCuts: Boolean(admittedInput || (cutTimes && cutTimes.length > 0)),
913
943
  startSeconds,
914
944
  totalSeconds: Number(session.file.durationSeconds) || null,
915
945
  spawn: (spawnArgs) =>
@@ -917,14 +947,14 @@ export class EncodeRuns {
917
947
  cwd: this.#host.segmentFiles.directoryFor(session.outputKey ?? ""),
918
948
  // A fourth channel: the encoder names every piece it has CLOSED on it,
919
949
  // which is the only proof a piece is whole.
920
- stdio: ["ignore", "pipe", "pipe", "pipe"]
950
+ stdio: ["pipe", "pipe", "pipe", "pipe"]
921
951
  }),
922
952
  logger: this.#host.logger,
923
953
  // The film's last segment number, which is what tells "it reached the
924
954
  // end" from "its input dried up": ffmpeg exits zero for both and over a
925
955
  // torrent cannot tell them apart.
926
956
  lastSegmentIndex: () =>
927
- session.timeline?.segmentCount > 0 ? session.timeline.segmentCount - 1 : null,
957
+ run.to,
928
958
  inputUnavailable: (message) => isInputUnavailable(message),
929
959
  onProgress: (report) => this.#noteRunProgress(session, run, report),
930
960
  indexOfName: (name) => session.segmentFormat.segmentIndexFromName(
@@ -932,22 +962,31 @@ export class EncodeRuns {
932
962
  // Why this encoder exists, recorded with its argument list. It used to be
933
963
  // handed to a separate `start` call; there is no separate call now.
934
964
  because,
935
- onClosed: (name, timing) => {
965
+ onClosed: (name, following) => {
966
+
936
967
  const index = session.segmentFormat.segmentIndexFromName(
937
968
  session.segmentFormat.servedNameOf?.(name) ?? name);
938
- if (timing && originMicros === null && session.segmentFormat.readMediaRanges) {
939
- const ranges = session.segmentFormat.readMediaRanges(
940
- this.#host.segmentFiles.closedBytesOf(session.outputKey ?? "", name));
941
- originMicros = originOf(ranges, timing.endMicros, referenceKind);
942
- }
943
969
  if (Number.isInteger(index) && index < safeIndex) return null;
944
- const production = productionOf(timing ? { ...timing, originMicros } : null,
945
- cutTimes, index - inputIndex, referenceKind);
946
- const mediaRanges = this.#wholeClosedPiece(session, name, index, production);
947
- if (mediaRanges === false) throw new Error(`Closed piece #${index} could not be published: its media does not reach its muxer cut or is unreadable.`);
948
- const published = this.#host.segmentFiles.publish(session.outputKey ?? "", name, session.segmentFormat, { mediaRanges });
949
- if (!published) throw new Error(`Closed piece #${index} could not be published to the segment store.`);
970
+ let bytes = null;
971
+ if (admittedInput && session.segmentFormat.id === "fmp4" && session.spec.carries !== "audio-only" &&
972
+ session.spec.audio && !this.#host.servesAudioSeparately(session)) {
973
+ try {
974
+ const address = session.outputKey ?? "";
975
+ const grid = session.timeline.published ?? session.timeline.boundaries;
976
+ bytes = presentationSegment(this.#host.segmentFiles.closedBytesOf(address, name),
977
+ following ? this.#host.segmentFiles.closedBytesOf(address, following) : null,
978
+ { from: grid[index], to: grid[index + 1] });
979
+ } catch (error) {
980
+ this.#host.logger.warn(`encode: could not partition closed segment ${index}: ${error.message}`);
981
+ throw error;
982
+ }
983
+ }
984
+ const mediaRanges = this.#wholeClosedPiece(session, name, index, admittedInput, bytes);
985
+ if (mediaRanges === false) throw new Error(`Closed piece #${index} has incomplete media.`);
986
+ const published = this.#host.segmentFiles.publish(session.outputKey ?? "", name, session.segmentFormat, { mediaRanges, bytes });
987
+ if (!published) throw new Error(`Closed piece #${index} could not be stored.`);
950
988
  return published;
989
+
951
990
  },
952
991
  onSpeedMeasured: () => this.#host.noteRunSpeedMeasured?.(session, run),
953
992
  onEnded: (ended) => {
@@ -955,6 +994,27 @@ export class EncodeRuns {
955
994
  this.noteRunEnded(session, run, ended);
956
995
  }
957
996
  });
997
+ if (admittedInput) {
998
+ run.inputFingerprint = admittedInput.fingerprint;
999
+ run.admittedInputKeys = new Map([[safeIndex, inputKey]]);
1000
+ let nextIndex = startIndex + 1;
1001
+ const canAppend = index => this.isLive(session) && run.isAlive &&
1002
+ this.#host.encodeOrchestrator.wantedSegmentsOn(session.outputKey).some(window => index >= window.from && index <= window.to) &&
1003
+ !this.liveRunsOf(session).some(other => other !== run && index >= other.from && index <= other.to) &&
1004
+ !this.#host.producedNumbers(session).includes(index);
1005
+ void writeAdmittedInput(admittedInput, run.process.stdin, { next: async () => {
1006
+ if (nextIndex > (ordered?.to ?? startIndex) || !canAppend(nextIndex)) return null;
1007
+ const next = await this.#host.encodeInputs.acquire(session, nextIndex, nextIndex);
1008
+ if (!next) return null;
1009
+ if (!canAppend(nextIndex)) { next.release(); return null; }
1010
+ run.admittedInputKeys.set(nextIndex, this.#inputFailures.key(next, inputParameters));
1011
+ run.to = nextIndex++;
1012
+ return next;
1013
+ } }).catch(error => {
1014
+ this.#host.logger.warn(`encode input output=${session.outputKey} segment=${safeIndex} write failed: ${error.message}`);
1015
+ run.process?.stdin?.destroy();
1016
+ });
1017
+ }
958
1018
  this.#runsByInputToken.set(inputToken, run);
959
1019
  // THE ONE FAULT THAT IS OTHERWISE SILENT, asked before this run produces a
960
1020
  // frame. It lost its caller in a refactor on 2026-09-04 and had none until
@@ -978,6 +1038,17 @@ export class EncodeRuns {
978
1038
  `numbering from #${safeIndex}`
979
1039
  );
980
1040
  return run;
1041
+ } catch (error) {
1042
+ admittedInput?.release();
1043
+ if (inputKey && failedAdmittedInput({ ending: ENCODE_EXIT.FAILED,
1044
+ code: error.code ? null : 1, because: error.message },
1045
+ session.spec.transcodesVideo && this.#host.videoEncoder.kind !== "software")) {
1046
+ this.#inputFailures.note(session.outputKey, startIndex, inputKey, error.message);
1047
+ this.#host.invalidateWaits(session);
1048
+ this.#host.productionFailed(session);
1049
+ }
1050
+ throw error;
1051
+ }
981
1052
  }
982
1053
 
983
1054
  /**
@@ -1040,6 +1111,16 @@ export class EncodeRuns {
1040
1111
  // been dead code — which is also why nothing ever stopped the restart loop
1041
1112
  // recorded in the field the same day.
1042
1113
  const wasCurrent = this.runsOf(session).includes(run);
1114
+ const inputFailed = run.admittedInputKeys && failedAdmittedInput(ended,
1115
+ session.spec.transcodesVideo && this.#host.videoEncoder.kind !== "software");
1116
+ if (inputFailed) {
1117
+ const index = run.head;
1118
+ const key = run.admittedInputKeys.get(index);
1119
+ if (key) {
1120
+ this.#inputFailures.note(session.outputKey, index, key, ended.because);
1121
+ this.#host.logger.warn(`encode: unchanged input will not be retried output=${session.outputKey} segment=${index} input=${key} reason=${ended.because}`);
1122
+ }
1123
+ }
1043
1124
  this.#host.encodeOrchestrator.noteEnded(ended);
1044
1125
  // What this admitted encode was seen to do, kept for the next time this
1045
1126
  // host prices the same mode (roadmap item 97, step 14). Whatever the
@@ -1178,7 +1259,7 @@ export class EncodeRuns {
1178
1259
  // an unbounded loop: measured 2026-09-05, ffmpeg failing to spawn produced
1179
1260
  // fifty passes of the plan before a probe stopped it, as fast as the
1180
1261
  // failures arrived.
1181
- const failure = this.#host.encodeOrchestrator.noteStartFailure(
1262
+ const failure = inputFailed ? { count: 0 } : this.#host.encodeOrchestrator.noteStartFailure(
1182
1263
  session.outputKey,
1183
1264
  ended.from,
1184
1265
  ended.livedMs
@@ -1223,9 +1304,8 @@ export class EncodeRuns {
1223
1304
  // Named exactly as the command line names them, second input included: a
1224
1305
  // refusal whose message describes a different mapping than the one that was
1225
1306
  // refused is the reading that cost a wrong diagnosis before.
1226
- const inputs = this.#host.inputOf(session);
1227
- const audioInput = inputs.audioInputUrl.length > 0 ? 1 : 0;
1228
- const audioTrack = session.spec.audioSourceTrackIndex;
1307
+ const audioInput = 0;
1308
+ const audioTrack = 0;
1229
1309
  if (session.spec.carries === "audio-only") {
1230
1310
  wanted.push(`audio 0:a:${audioTrack}`);
1231
1311
  } else if (this.#host.servesAudioSeparately(session)) {
@@ -0,0 +1,37 @@
1
+ import { createHash } from "node:crypto";
2
+ import { ENCODE_EXIT } from "./encode-exit.js";
3
+
4
+ /** Process and resource failures are not evidence against the admitted bytes. */
5
+ export function failedAdmittedInput(ended, hardwareEncode = false) {
6
+ if (hardwareEncode || ended.signal || ended.code === null || ended.code === undefined) return false;
7
+ if (!Number.isInteger(ended.code) || ended.code < 0) return false;
8
+ if (/cannot allocate memory|out of memory|no space left|resource temporarily unavailable|too many open files|ENOMEM|ENOSPC|EMFILE|EAGAIN/i.test(ended.because ?? "")) return false;
9
+ return ended.ending === ENCODE_EXIT.SHORT || ended.ending === ENCODE_EXIT.FAILED;
10
+ }
11
+
12
+ /** A failed input is retried only after its bytes or output parameters change. */
13
+ export class InputFailures {
14
+ #failures = new Map();
15
+
16
+ key(input, parameters) {
17
+ return createHash("sha256").update(input.fingerprint).update(JSON.stringify(parameters)).digest("hex");
18
+ }
19
+
20
+ failure(address, index, key) {
21
+ const entries = this.#failures.get(address);
22
+ const entry = entries?.get(index);
23
+ if (entry?.key === key) return entry.reason;
24
+ if (entry) entries.delete(index);
25
+ return null;
26
+ }
27
+
28
+ reason(address) { return this.#failures.get(address)?.values().next().value?.reason ?? null; }
29
+
30
+ note(address, index, key, reason) {
31
+ const entries = this.#failures.get(address) ?? new Map();
32
+ entries.set(index, { key, reason });
33
+ this.#failures.set(address, entries);
34
+ }
35
+
36
+ forget(address) { this.#failures.delete(address); }
37
+ }
@@ -0,0 +1,158 @@
1
+ import { avcPacketSlices } from "./annex-b-input.js";
2
+ import { bitPayloadSlices } from "./bit-payload.js";
3
+
4
+ /** Emit already admitted packets as one timestamped Matroska input. */
5
+ export async function* matroskaInput({ tracks, readPacket, originSeconds = 0, includeHeader = true, seen = new Map() }) {
6
+ if (!Array.isArray(tracks) || tracks.length === 0 || typeof readPacket !== "function" || !Number.isFinite(originSeconds)) {
7
+ throw new TypeError("A Matroska input requires tracks, admitted packets and a timeline origin.");
8
+ }
9
+ if (includeHeader) {
10
+ const declarations = tracks.map((input, position) => trackElement(input.track, position + 1));
11
+ yield element("1a45dfa3", Buffer.concat([
12
+ uintElement("4286", 1), uintElement("42f7", 1), uintElement("42f2", 4), uintElement("42f3", 8),
13
+ textElement("4282", "matroska"), uintElement("4287", 4), uintElement("4285", 2)
14
+ ]));
15
+ yield Buffer.from("1853806701ffffffffffffff", "hex");
16
+ yield element("1549a966", uintElement("2ad7b1", 1000));
17
+ yield element("1654ae6b", Buffer.concat(declarations));
18
+ }
19
+ const positions = tracks.map(() => 0);
20
+ const currentSeen = new Map();
21
+ while (true) {
22
+ let selected = -1, time = Infinity;
23
+ for (let position = 0; position < tracks.length; position++) {
24
+ const packet = tracks[position].packets[positions[position]];
25
+ if (!packet) continue;
26
+ const decodeTime = packet.dts ?? packet.pts;
27
+ if (selected < 0 || decodeTime < time) { selected = position; time = decodeTime; }
28
+ }
29
+ if (selected < 0) {
30
+ seen.clear();
31
+ for (const [position, identities] of currentSeen) seen.set(position, identities);
32
+ return;
33
+ }
34
+ const input = tracks[selected];
35
+ const packet = input.packets[positions[selected]++];
36
+ const identity = `${packet.pts}:${packet.ranges.map(range => range.join("-")).join(",")}`;
37
+ const identities = currentSeen.get(selected) ?? new Set();
38
+ if (identities.has(identity)) continue;
39
+ identities.add(identity);
40
+ currentSeen.set(selected, identities);
41
+ if (seen.get(selected)?.has(identity)) continue;
42
+ const stamp = Math.round((packet.pts + (input.track.codecDelaySeconds ?? 0) - originSeconds) * 1e6);
43
+ if (!Number.isSafeInteger(stamp) || stamp < 0) throw new Error("Admitted packet precedes its Matroska timeline origin.");
44
+ const payload = await readPacket(input, packet);
45
+ let slices = Array.isArray(payload) ? payload : [payload];
46
+ let payloadBytes = slices.reduce((sum, slice) => sum + (Buffer.isBuffer(slice) ? slice.length : 0), 0);
47
+ const expected = packet.ranges.reduce((sum, [start, end]) => sum + end - start + 1, 0);
48
+ if (!slices.length || slices.some(slice => !Buffer.isBuffer(slice)) || payloadBytes !== expected) throw new Error("Admitted packet bytes are incomplete.");
49
+ if (packet.bitOffset !== undefined) {
50
+ slices = bitPayloadSlices(slices, packet.bitOffset, packet.bitLength);
51
+ payloadBytes = packet.bitLength / 8;
52
+ }
53
+ if (input.track.packetFraming === "annex-b") {
54
+ slices = avcPacketSlices(slices);
55
+ payloadBytes = slices.reduce((sum, slice) => sum + slice.length, 0);
56
+ }
57
+ const blockHeader = Buffer.concat([vint(selected + 1), Buffer.from([0, 0, 0])]);
58
+ const duration = Math.round(packet.duration * 1e6);
59
+ const afterBlock = Buffer.concat([
60
+ ...(duration > 0 ? [uintElement("9b", duration)] : []),
61
+ ...(packet.discardPaddingSeconds ? [signedElement("75a2", Math.round(packet.discardPaddingSeconds * 1e9))] : []),
62
+ ...(packet.keyframe ? [] : [element("fb", Buffer.from([0]))])]);
63
+ const blockPrefix = prefix("a1", blockHeader.length + payloadBytes);
64
+ const groupPrefix = prefix("a0", blockPrefix.length + blockHeader.length + payloadBytes + afterBlock.length);
65
+ const timestamp = uintElement("e7", stamp);
66
+ const clusterPrefix = prefix("1f43b675", timestamp.length + groupPrefix.length + blockPrefix.length +
67
+ blockHeader.length + payloadBytes + afterBlock.length);
68
+ yield Buffer.concat([clusterPrefix, timestamp, groupPrefix, blockPrefix, blockHeader]);
69
+ for (const slice of slices) yield slice;
70
+ if (afterBlock.length) yield afterBlock;
71
+ }
72
+ }
73
+
74
+ function trackElement(track, number) {
75
+ const type = track.type === "video" ? 1 : track.type === "audio" ? 2 : null;
76
+ if (type === null) throw new Error("Admitted input carries an unsupported track type.");
77
+ const codec = track.matroskaCodecId ?? matroskaCodec(track.codecId);
78
+ const fields = [uintElement("d7", number), uintElement("73c5", number), uintElement("83", type),
79
+ uintElement("9c", 0), textElement("86", codec)];
80
+ const privateBytes = track.matroskaCodecPrivateB64 ?? track.codecPrivateB64;
81
+ if (privateBytes) fields.push(element("63a2", Buffer.from(privateBytes, "base64")));
82
+ if (track.language) fields.push(textElement("22b59c", track.language));
83
+ if (track.codecDelaySeconds > 0) fields.push(uintElement("56aa", Math.round(track.codecDelaySeconds * 1e9)));
84
+ if (track.seekPrerollSeconds > 0) fields.push(uintElement("56bb", Math.round(track.seekPrerollSeconds * 1e9)));
85
+ if (type === 1) {
86
+ if (!(track.width > 0 && track.height > 0)) throw new Error("Admitted video has no declared dimensions.");
87
+ fields.push(element("e0", Buffer.concat([uintElement("b0", track.width), uintElement("ba", track.height)])));
88
+ } else {
89
+ if (!(track.samplingFrequency > 0 && track.channels > 0)) throw new Error("Admitted audio has no declared format.");
90
+ const frequency = Buffer.alloc(8);
91
+ frequency.writeDoubleBE(track.samplingFrequency);
92
+ if (codec.startsWith("A_PCM/") && !(track.bitDepth > 0)) throw new Error("Admitted PCM has no declared sample bit depth.");
93
+ fields.push(element("e1", Buffer.concat([element("b5", frequency), uintElement("9f", track.channels),
94
+ ...(track.bitDepth > 0 ? [uintElement("6264", track.bitDepth)] : [])])));
95
+ }
96
+ return element("ae", Buffer.concat(fields));
97
+ }
98
+
99
+ /** Stable identity of every declaration emitted into the input stream. */
100
+ export function matroskaTrackIdentity(track) {
101
+ return JSON.stringify([
102
+ track.type, track.matroskaCodecId ?? matroskaCodec(track.codecId),
103
+ track.matroskaCodecPrivateB64 ?? track.codecPrivateB64 ?? "",
104
+ track.language ?? "", track.packetFraming ?? "", track.width ?? null, track.height ?? null,
105
+ track.samplingFrequency ?? null, track.channels ?? null, track.bitDepth ?? null,
106
+ track.codecDelaySeconds ?? 0, track.seekPrerollSeconds ?? 0
107
+ ]);
108
+ }
109
+
110
+ function matroskaCodec(codec) {
111
+ if (/^[VA]_/.test(codec)) return codec;
112
+ if (/^pcm_[su](?:16|24|32|64)le$/.test(codec) || /^pcm_[su]8(?:le|be)?$/.test(codec)) return "A_PCM/INT/LIT";
113
+ if (/^pcm_[su](?:16|24|32|64)be$/.test(codec)) return "A_PCM/INT/BIG";
114
+ if (/^pcm_f(?:32|64)(?:le|be)$/.test(codec)) return "A_PCM/FLOAT/IEEE";
115
+ const declared = new Map([
116
+ ["avc1", "V_MPEG4/ISO/AVC"], ["avc3", "V_MPEG4/ISO/AVC"], ["h264", "V_MPEG4/ISO/AVC"],
117
+ ["hvc1", "V_MPEGH/ISO/HEVC"], ["hev1", "V_MPEGH/ISO/HEVC"], ["hevc", "V_MPEGH/ISO/HEVC"],
118
+ ["av01", "V_AV1"], ["av1", "V_AV1"], ["vp9", "V_VP9"], ["vp8", "V_VP8"],
119
+ ["mjpeg", "V_MJPEG"], ["ffv1", "V_FFV1"], ["theora", "V_THEORA"], ["jpeg2000", "V_JPEG2000"],
120
+ ["mpeg1video", "V_MPEG1"], ["mpeg2video", "V_MPEG2"], ["mpeg4", "V_MPEG4/ISO/ASP"],
121
+ ["FMP4", "V_MPEG4/ISO/ASP"], ["XVID", "V_MPEG4/ISO/ASP"], ["DIVX", "V_MPEG4/ISO/ASP"],
122
+ ["mp4a", "A_AAC"], ["aac", "A_AAC"], ["mp3", "A_MPEG/L3"], ["mp2", "A_MPEG/L2"], ["mp1", "A_MPEG/L1"],
123
+ ["ac-3", "A_AC3"], ["ac3", "A_AC3"], ["ec-3", "A_EAC3"], ["eac3", "A_EAC3"],
124
+ ["Opus", "A_OPUS"], ["opus", "A_OPUS"], ["fLaC", "A_FLAC"], ["flac", "A_FLAC"], ["dts", "A_DTS"],
125
+ ["vorbis", "A_VORBIS"], ["alac", "A_ALAC"], ["truehd", "A_TRUEHD"]
126
+ ]).get(codec);
127
+ if (!declared) throw new Error(`Admitted codec ${codec} has no Matroska declaration.`);
128
+ return declared;
129
+ }
130
+
131
+ function vint(value) {
132
+ if (!Number.isSafeInteger(value) || value < 0) throw new TypeError("EBML value is outside its integer range.");
133
+ for (let width = 1; width <= 8; width++) {
134
+ if (BigInt(value) >= (1n << BigInt(7 * width)) - 1n) continue;
135
+ const encoded = Buffer.alloc(width);
136
+ let bits = BigInt(value) | (1n << BigInt(7 * width));
137
+ for (let at = width - 1; at >= 0; at--) { encoded[at] = Number(bits & 255n); bits >>= 8n; }
138
+ return encoded;
139
+ }
140
+ throw new TypeError("EBML value has no valid width.");
141
+ }
142
+
143
+ function prefix(id, size) { return Buffer.concat([Buffer.from(id, "hex"), vint(size)]); }
144
+ function element(id, payload) { return Buffer.concat([prefix(id, payload.length), payload]); }
145
+ function textElement(id, text) { return element(id, Buffer.from(text, "utf8")); }
146
+ function signedElement(id, value) {
147
+ if (!Number.isSafeInteger(value)) throw new TypeError("EBML signed integer is outside its exact range.");
148
+ const bytes = Buffer.alloc(8);
149
+ bytes.writeBigInt64BE(BigInt(value));
150
+ return element(id, bytes);
151
+ }
152
+ function uintElement(id, value) {
153
+ if (!Number.isSafeInteger(value) || value < 0) throw new TypeError("EBML unsigned integer is invalid.");
154
+ let bits = BigInt(value);
155
+ const bytes = [];
156
+ do { bytes.unshift(Number(bits & 255n)); bits >>= 8n; } while (bits > 0n);
157
+ return element(id, Buffer.from(bytes));
158
+ }