@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
@@ -0,0 +1,136 @@
1
+ import { createHash } from "node:crypto";
2
+ import { matroskaInput, matroskaTrackIdentity } from "./MatroskaInput.js";
3
+ import { pcmNormalization, normalizePcmSlices } from "./pcm-normalization.js";
4
+
5
+ /** Acquire complete source ranges before any process can consume the input. */
6
+ export async function admitInput({ sources, reserve, readRanges }) {
7
+ if (!Array.isArray(sources) || sources.length === 0 || typeof reserve !== "function" || typeof readRanges !== "function") {
8
+ throw new TypeError("Admission requires resolved source inputs, memory reservation and an available-only reader.");
9
+ }
10
+ const bytes = sources.reduce((total, source) => total + source.input.ranges.reduce((sum, [start, end]) => sum + end - start + 1, 0) +
11
+ source.input.tracks.reduce((sum, input) => sum + (pcmNormalization(input.track)
12
+ ? input.packets.reduce((size, packet) => size + packet.ranges.reduce((n, [start, end]) => n + end - start + 1, 0), 0) : 0), 0), 0);
13
+ if (!Number.isSafeInteger(bytes) || bytes <= 0) throw new TypeError("Input byte count must be a positive safe integer.");
14
+ const release = await reserve(bytes);
15
+ if (typeof release !== "function") return release?.kind === "terminal" ? release : { kind: "needs-memory", bytes };
16
+ let retained = false;
17
+ try {
18
+ const hash = createHash("sha256");
19
+ const tracks = [];
20
+ const held = new Map();
21
+ const normalized = new Map();
22
+ for (const source of sources) {
23
+ const buffers = await readRanges(source, source.input.ranges, bytes);
24
+ if (buffers === null) return { kind: "needs-bytes", sourceKey: source.sourceKey, fileIndex: source.fileIndex, ranges: source.input.ranges };
25
+ if (!Array.isArray(buffers) || buffers.length !== source.input.ranges.length || buffers.some((buffer, index) =>
26
+ !Buffer.isBuffer(buffer) || buffer.length !== source.input.ranges[index][1] - source.input.ranges[index][0] + 1)) {
27
+ throw new Error("Available-only input reader returned incomplete ranges.");
28
+ }
29
+ hash.update(JSON.stringify(source.input.ranges));
30
+ for (const buffer of buffers) {
31
+ hash.update(String(buffer.length));
32
+ hash.update(":");
33
+ hash.update(buffer);
34
+ }
35
+ for (const input of source.input.tracks) {
36
+ const shifted = { ...input,
37
+ ...(Number.isFinite(input.sourceEndSeconds) ? { sourceEndSeconds: input.sourceEndSeconds - (source.timeShiftSeconds ?? 0) } : {}),
38
+ packets: input.packets.map(packet => ({ ...packet,
39
+ pts: packet.pts - (source.timeShiftSeconds ?? 0),
40
+ ...(packet.dts === undefined ? {} : { dts: packet.dts - (source.timeShiftSeconds ?? 0) }) })) };
41
+ tracks.push(shifted);
42
+ hash.update(matroskaTrackIdentity(input.track));
43
+ hash.update(JSON.stringify(shifted.packets.map(packet => [packet.pts, packet.dts ?? null,
44
+ packet.duration, packet.keyframe === true, packet.ranges, packet.discardPaddingSeconds ?? 0,
45
+ packet.bitOffset ?? null, packet.bitLength ?? null])));
46
+ held.set(shifted, { ranges: source.input.ranges, buffers });
47
+ }
48
+ }
49
+ let released = false;
50
+ const originSeconds = tracks.reduce((origin, input) => input.packets.reduce((value, packet) => Math.min(value, packet.pts), origin), Infinity);
51
+ if (!Number.isFinite(originSeconds)) throw new Error("Admitted input contains no packets.");
52
+ const packetSlices = (input, packet) => {
53
+ if (released) throw new Error("Admitted input has been released.");
54
+ const source = held.get(input);
55
+ if (!source) throw new Error("Packet does not belong to this admitted input.");
56
+ return packet.ranges.map(([start, end]) => {
57
+ const position = source.ranges.findIndex(([from, to]) => from <= start && end <= to);
58
+ if (position < 0) throw new Error("Packet is outside its admitted ranges.");
59
+ return source.buffers[position].subarray(start - source.ranges[position][0], end - source.ranges[position][0] + 1);
60
+ });
61
+ };
62
+ const readPacket = (input, packet) => {
63
+ const slices = packetSlices(input, packet);
64
+ return slices.length === 1 ? slices[0] : Buffer.concat(slices);
65
+ };
66
+ for (const input of tracks) {
67
+ for (const packet of input.packets) {
68
+ const slices = packetSlices(input, packet);
69
+ if (packet.expectedHash !== undefined) {
70
+ const payloadHash = createHash("sha256");
71
+ for (const slice of slices) payloadHash.update(slice);
72
+ if (payloadHash.digest("hex") !== packet.expectedHash) {
73
+ return { kind: "terminal", reason: "packet-address-does-not-match-payload", trackId: input.track.trackNumber,
74
+ ranges: packet.ranges };
75
+ }
76
+ }
77
+ const format = pcmNormalization(input.track);
78
+ if (format) normalized.set(packet, normalizePcmSlices(slices, format));
79
+ }
80
+ }
81
+ retained = true;
82
+ return {
83
+ kind: "result", bytes, fingerprint: hash.digest("hex"), originSeconds, tracks, readPacket,
84
+ stream: options => matroskaInput({ tracks,
85
+ readPacket: (input, packet) => normalized.has(packet) ? [normalized.get(packet)] : packetSlices(input, packet),
86
+ originSeconds, ...options }),
87
+ release: () => {
88
+ if (released) return;
89
+ released = true;
90
+ held.clear();
91
+ normalized.clear();
92
+ release();
93
+ }
94
+ };
95
+ } finally {
96
+ if (!retained) release();
97
+ }
98
+ }
99
+
100
+ /** Respect stdin backpressure and release input on every completion path. */
101
+ export async function writeAdmittedInput(input, stdin, { next = null } = {}) {
102
+ if (input?.kind !== "result") throw new TypeError("Only complete input may be written to an encoder.");
103
+ const originSeconds = input.originSeconds;
104
+ const declarations = JSON.stringify(input.tracks.map(input => matroskaTrackIdentity(input.track)));
105
+ const seen = new Map();
106
+ let current = input, includeHeader = true;
107
+ try {
108
+ while (current) {
109
+ if (JSON.stringify(current.tracks.map(input => matroskaTrackIdentity(input.track))) !== declarations) {
110
+ throw new Error("Consecutive encoder input changed its declared tracks.");
111
+ }
112
+ for (let index = 0; index < current.tracks.length; index++) {
113
+ const end = current.tracks[index].sourceEndSeconds;
114
+ if (Number.isFinite(end)) input.tracks[index].sourceEndSeconds = end;
115
+ }
116
+ for await (const chunk of current.stream({ originSeconds, includeHeader, seen })) {
117
+ await new Promise((resolve, reject) => {
118
+ const failed = error => { stdin.off("error", failed); reject(error); };
119
+ stdin.once("error", failed);
120
+ stdin.write(chunk, error => {
121
+ stdin.off("error", failed);
122
+ if (error) reject(error);
123
+ else resolve();
124
+ });
125
+ });
126
+ }
127
+ current.release();
128
+ current = next ? await next() : null;
129
+ includeHeader = false;
130
+ }
131
+ stdin.end();
132
+ } finally {
133
+ current?.release();
134
+ input.release();
135
+ }
136
+ }
@@ -42,13 +42,11 @@ export class CushionReport {
42
42
  /** What this reads and asks of the rest of the proxy, and nothing else. @type {object} */
43
43
  #host;
44
44
 
45
- /** Files whose spare soundtracks have been fetched whole, once each. @type {Set<string>} */
46
- #spareSoundtracksFetched = new Set();
47
45
  #lastSaidAt = new WeakMap();
48
46
  #lookAheadDisagreementSince = new WeakMap();
49
47
 
50
48
  /**
51
- * @param {object} host - `viewerSecondsOn`, `viewersOf`, `SourceFiles`, `logger`, `producedNumbers`, `encodeRuns`, `fetchWholeFile`, `getCachedAudioTracks`, `hostLoad`, `lookaheadSeconds`, `outputTimes`, `outputs`
49
+ * @param {object} host - `viewerSecondsOn`, `viewersOf`, `SourceFiles`, `logger`, `producedNumbers`, `encodeRuns`, `hostLoad`, `lookaheadSeconds`, `outputTimes`, `outputs`
52
50
  */
53
51
  constructor(host) {
54
52
  this.#host = host;
@@ -201,79 +199,6 @@ export class CushionReport {
201
199
  `${viewers} viewer(s) holding up to ` +
202
200
  `${deepestBuffer === null ? "?" : deepestBuffer.toFixed(1)}s`
203
201
  );
204
- this.#fetchSpareSoundtracks(session, aheadOfPicture);
205
- }
206
-
207
- /**
208
- * Fetch the soundtracks that ship beside this picture, whole, while the swarm
209
- * has capacity to spare.
210
- *
211
- * WHY IT WAITS FOR THE CUSHION. A soundtrack nobody has chosen is worth having
212
- * on disk — it is a twentieth of the picture (30 MB against 566 MB on the
213
- * field torrent) and having it makes every later switch instant instead of
214
- * paying for its first pieces. But fetching it takes swarm capacity from the
215
- * picture, and there is exactly one moment when that capacity is demonstrably
216
- * spare: when the encoder is already as far ahead of the viewer as it is
217
- * allowed to get. That is not a guess about the swarm — it is the measurement
218
- * the line above just printed.
219
- *
220
- * WHY IT IS A READ AND NOT A SELECTION. `file.select()` claims every piece of
221
- * a file at once, and `#syncSelections` in `torrent/torrent-pool.js` records what that
222
- * cost when it was done alongside the readers' own windows: a claim covering
223
- * everything always outranked the window, and a seek to 89.1% of a 4.7 GB film
224
- * waited 93 s while the swarm fetched 2.47 GB in file order. So this goes
225
- * through the same bounded read the edge warm-up uses, which claims a moving
226
- * window like any other reader and gives it back when it ends.
227
- *
228
- * Once per file, and only for a soundtrack in a file of its own — the
229
- * picture's own tracks are already in the bytes being played.
230
- *
231
- * @param {HlsSession} session
232
- * @param {number} aheadOfPicture - Seconds of film ready ahead of the viewer.
233
- * @returns {void}
234
- */
235
- #fetchSpareSoundtracks(session, aheadOfPicture) {
236
- if (typeof this.#host.fetchWholeFile !== "function") {
237
- return;
238
- }
239
- // The encoder is held at this distance and no further, so reaching it is the
240
- // signal that nothing more is being asked of the swarm on the picture's
241
- // behalf.
242
- if (!(aheadOfPicture >= this.#host.lookaheadSeconds)) {
243
- return;
244
- }
245
- const inventory = this.#host.getCachedAudioTracks?.({
246
- sourceKey: session.file.sourceKey,
247
- fileIndex: session.file.fileIndex
248
- }) ?? [];
249
- const wanted = new Set(
250
- inventory
251
- .filter((entry) => entry?.kind === "sidecar" && Number.isInteger(entry.fileIndex))
252
- .map((entry) => entry.fileIndex)
253
- );
254
- for (const fileIndex of wanted) {
255
- const key = this.#host.SourceFiles.keyFor(session.file.sourceKey, fileIndex);
256
- if (this.#spareSoundtracksFetched.has(key)) {
257
- continue;
258
- }
259
- this.#spareSoundtracksFetched.add(key);
260
- this.#host.logger.info(
261
- `transcode ${session.id.slice(0, 8)} the picture is ${Math.round(aheadOfPicture)}s ahead of ` +
262
- `the viewer, so file ${fileIndex} — a soundtrack beside it — is fetched whole now; ` +
263
- "a switch to it will not wait for the swarm"
264
- );
265
- // Not awaited: nothing depends on it finishing, and a failure costs only
266
- // that the switch pays for its own pieces, as it did before this existed.
267
- Promise.resolve(this.#host.fetchWholeFile({ sourceKey: session.file.sourceKey, fileIndex })).catch(
268
- (error) => {
269
- this.#host.logger.info(
270
- `transcode: fetching soundtrack file ${fileIndex} whole failed ` +
271
- `(${error instanceof Error ? error.message : String(error)}) — ` +
272
- "it will be read when it is played"
273
- );
274
- }
275
- );
276
- }
277
202
  }
278
203
 
279
204
  /**
@@ -0,0 +1,142 @@
1
+ import { admitInput } from "./AdmittedInput.js";
2
+
3
+ /** Event-driven preparation; a synchronous run request can take only ready input. */
4
+ export class EncodeInputs {
5
+ #requests = new Map();
6
+ #revision = 0;
7
+ #held = 0;
8
+ #allowed = 0;
9
+ #wanted = new Map();
10
+ #resolve;
11
+ #read;
12
+ #revise;
13
+ #changed;
14
+ #failed;
15
+ #capacity;
16
+ #urgent;
17
+
18
+ constructor({ resolve, readRanges, reviseBudget, changed, failed, capacity = () => null, urgent = () => true }) {
19
+ this.#resolve = resolve;
20
+ this.#read = readRanges;
21
+ this.#revise = reviseBudget;
22
+ this.#changed = changed;
23
+ this.#failed = failed;
24
+ this.#capacity = capacity;
25
+ this.#urgent = urgent;
26
+ }
27
+
28
+ held() { return this.#held; }
29
+ wanted() { return this.#held + [...this.#wanted.values()].reduce((sum, bytes) => sum + bytes, 0); }
30
+ allow(bytes) {
31
+ const changed = this.#allowed !== Math.max(0, bytes);
32
+ this.#allowed = Math.max(0, bytes);
33
+ if (changed) this.#retryMemory();
34
+ }
35
+
36
+ #retryMemory() {
37
+ for (const request of this.#requests.values()) {
38
+ if (!request.pending && request.result?.kind === "needs-memory") this.#prepare(request);
39
+ }
40
+ }
41
+
42
+ failureOf(output) {
43
+ for (const request of this.#requests.values()) {
44
+ if (request.output === output && request.result?.kind === "terminal") return request.result;
45
+ }
46
+ return null;
47
+ }
48
+
49
+ retain(output, windows) {
50
+ for (const [key, request] of this.#requests) {
51
+ if (request.output !== output || windows.some(window => request.from <= window.to && request.to >= window.from)) continue;
52
+ this.#requests.delete(key);
53
+ this.#wanted.delete(key);
54
+ request.result?.release?.();
55
+ }
56
+ this.#retryMemory();
57
+ }
58
+
59
+ take(output, from, to) {
60
+ const key = `${output.outputKey ?? output.id}:${from}:${to}`;
61
+ const existing = this.#requests.get(key);
62
+ if (existing?.result?.kind === "result") {
63
+ this.#requests.delete(key);
64
+ return existing.result;
65
+ }
66
+ if (existing?.pending || existing?.revision === this.#revision || existing?.result?.kind === "terminal") return null;
67
+ const request = existing ?? { output, from, to, key };
68
+ this.#requests.set(key, request);
69
+ this.#prepare(request);
70
+ return null;
71
+ }
72
+
73
+ async acquire(output, from, to) {
74
+ const key = `${output.outputKey ?? output.id}:${from}:${to}`;
75
+ const ready = this.take(output, from, to);
76
+ if (ready) return ready;
77
+ await this.#requests.get(key)?.promise;
78
+ const request = this.#requests.get(key);
79
+ if (request?.result?.kind !== "result") return null;
80
+ return this.take(output, from, to);
81
+ }
82
+
83
+ bytesChanged() {
84
+ this.#revision++;
85
+ for (const request of this.#requests.values()) {
86
+ if (!request.pending && request.result?.kind !== "result" && request.result?.kind !== "terminal") this.#prepare(request);
87
+ }
88
+ }
89
+
90
+ forget(output) {
91
+ for (const [key, request] of this.#requests) {
92
+ if (request.output !== output) continue;
93
+ this.#requests.delete(key);
94
+ this.#wanted.delete(key);
95
+ request.result?.release?.();
96
+ }
97
+ }
98
+
99
+ #prepare(request) {
100
+ request.pending = true;
101
+ request.revision = this.#revision;
102
+ request.promise = (async () => {
103
+ const resolved = await this.#resolve(request.output, request.from, request.to);
104
+ if (this.#requests.get(request.key) !== request) return;
105
+ const result = resolved.kind === "result" ? await admitInput({
106
+ sources: resolved.sources,
107
+ readRanges: this.#read,
108
+ reserve: async bytes => {
109
+ this.#wanted.set(request.key, bytes);
110
+ await this.#revise();
111
+ const capacity = this.#capacity();
112
+ if (capacity !== null && bytes > capacity && this.#urgent(request.output, request.from)) return {
113
+ kind: "terminal", reason: "source-input-exceeds-memory-capacity", bytes, capacity
114
+ };
115
+ if (this.#requests.get(request.key) !== request || this.#allowed - this.#held < bytes) return null;
116
+ this.#wanted.delete(request.key);
117
+ this.#held += bytes;
118
+ let released = false;
119
+ return () => {
120
+ if (!released) {
121
+ released = true; this.#held -= bytes;
122
+ queueMicrotask(() => this.#retryMemory());
123
+ }
124
+ };
125
+ }
126
+ }) : resolved;
127
+ if (this.#requests.get(request.key) !== request) { result.release?.(); return; }
128
+ request.result = result;
129
+ if (result.kind === "result" || result.kind === "terminal") this.#changed(request.output, result);
130
+ })().catch(error => {
131
+ if (this.#requests.get(request.key) === request) {
132
+ request.result = { kind: "terminal", reason: "input-preparation-failed", message: error.message };
133
+ this.#failed(request.output, error);
134
+ }
135
+ }).finally(() => {
136
+ request.pending = false;
137
+ if (request.result?.kind !== "needs-memory") this.#wanted.delete(request.key);
138
+ if (this.#requests.get(request.key) === request && request.revision !== this.#revision &&
139
+ request.result?.kind !== "result" && request.result?.kind !== "terminal") this.#prepare(request);
140
+ });
141
+ }
142
+ }
@@ -167,6 +167,7 @@ export class EncodeOrchestrator {
167
167
  startingSpeedFor = () => 0,
168
168
  segmentCoverage = null,
169
169
  planSoon = null,
170
+ inputDemandChanged = null,
170
171
  describeWaits = null,
171
172
  admission = null,
172
173
  logger,
@@ -202,6 +203,7 @@ export class EncodeOrchestrator {
202
203
  // Asked for, not commanded: this class decides, and something else owns the
203
204
  // loop that calls it. Absent in a test, where the clock is the test's own.
204
205
  this.planSoon = typeof planSoon === "function" ? planSoon : () => undefined;
206
+ this.inputDemandChanged = typeof inputDemandChanged === "function" ? inputDemandChanged : () => undefined;
205
207
  }
206
208
 
207
209
  /**
@@ -536,6 +538,7 @@ export class EncodeOrchestrator {
536
538
  */
537
539
  notePriorityMap(address, zones) {
538
540
  this.#demand.state(address, zones);
541
+ this.inputDemandChanged(address, this.#demand.mapOn(address));
539
542
  // NOBODY IS COMING HERE, so what was remembered about this output's input
540
543
  // is about nobody. Kept, those three entries would stay for the life of the
541
544
  // process, and the wait they describe would greet whoever opens this output
@@ -994,7 +997,7 @@ export class EncodeOrchestrator {
994
997
  // is a name match: no stretch to search and no bytes to judge. Done for
995
998
  // every ending, the normal one included, since a run that finished cleanly
996
999
  // has nothing under a working name and the sweep then removes nothing.
997
- this.#segmentCoverage?.clearUpAfter(ended.address, ended.from);
1000
+ this.#segmentCoverage?.clearUpAfter(ended.address, ended.run?.makingTag ?? ended.from);
998
1001
  this.coverageOf(ended.address).release(ended.run);
999
1002
  const remaining = this.runsOn(ended.address).filter((run) => run !== ended.run);
1000
1003
  if (remaining.length === 0) {
@@ -199,7 +199,7 @@ export class EncodeRun {
199
199
  * @param {(reading: { speed: number, at: number }) => void} [params.onSpeedMeasured] -
200
200
  * Told each time this run has measured its processing speed, once per
201
201
  * reading.
202
- * @param {(name: string) => string | null} [params.onClosed] - Called with the
202
+ * @param {(name: string, following: string | null) => string | null} [params.onClosed] - Called with the
203
203
  * WORKING name of every piece the encoder has finished writing, as the
204
204
  * encoder itself names it on its own channel, and answers with the name that
205
205
  * piece is served under — because making it servable is a rename, and only
@@ -230,6 +230,7 @@ export class EncodeRun {
230
230
  encoder,
231
231
  from,
232
232
  to,
233
+ makingTag = String(from),
233
234
  buildArgs,
234
235
  spawn,
235
236
  logger,
@@ -251,6 +252,7 @@ export class EncodeRun {
251
252
  this.encoder = encoder;
252
253
  this.from = from;
253
254
  this.to = to;
255
+ this.makingTag = makingTag;
254
256
  this.buildArgs = buildArgs;
255
257
  this.spawnProcess = spawn;
256
258
  this.logger = logger;
@@ -452,6 +454,12 @@ export class EncodeRun {
452
454
  * @param {import("node:child_process").ChildProcess} process
453
455
  */
454
456
  #wire(process) {
457
+ // The process owns this listener even between admitted input writes.
458
+ process.stdin?.on("error", (error) => {
459
+ const message = error instanceof Error ? error.message : String(error);
460
+ this.lastError ||= message;
461
+ this.logger.warn(`ffmpeg input #${this.from}..#${this.to} of ${this.address}: ${message}`);
462
+ });
455
463
  process.stdout?.on("data", (chunk) => this.#readProgress(String(chunk)));
456
464
  // THE CHANNEL THE ENCODER NAMES ITS FINISHED PIECES ON.
457
465
  //
@@ -567,14 +575,14 @@ export class EncodeRun {
567
575
  // A subsequent closed file proves the previous one reached a cut.
568
576
  // The last file can instead have been flushed by an input failure.
569
577
  if (this.#pendingClosed !== null) {
570
- this.#publishClosed(this.#pendingClosed);
578
+ this.#publishClosed(this.#pendingClosed, name);
571
579
  }
572
580
  if (this.#publicationError) break;
573
581
  this.#pendingClosed = name;
574
582
  }
575
583
  }
576
584
 
577
- #publishClosed(name) {
585
+ #publishClosed(name, following = null) {
578
586
  this.#measureSpeed();
579
587
  // ITS SERVED NAME, which is what whoever owns the disk gives it in answer.
580
588
  // ffmpeg writes a piece under a working name and reports that; the piece
@@ -582,7 +590,7 @@ export class EncodeRun {
582
590
  // name a request can actually ask for.
583
591
  let served;
584
592
  try {
585
- served = this.onClosed(name, this.#closedTimings.get(name) ?? null);
593
+ served = this.onClosed(name, following, this.#closedTimings.get(name) ?? null);
586
594
  } catch (error) {
587
595
  this.#publicationError = error instanceof Error ? error.message : String(error);
588
596
  this.lastError = this.#publicationError;
@@ -594,6 +602,7 @@ export class EncodeRun {
594
602
  } finally {
595
603
  this.#closedTimings.delete(name);
596
604
  }
605
+
597
606
  if (!served) return false;
598
607
  if (!this.#stopping) {
599
608
  this.#provenName = served;