@torrent-tv/proxy 2.95.30 → 2.95.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.95.30",
3
+ "version": "2.95.32",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
package/server.js CHANGED
@@ -690,6 +690,13 @@ export async function startProxyServer({
690
690
  const torrent = torrentPool.knownTorrent(source.sourceKey);
691
691
  return torrent ? torrentPool.readHeldRangesOf(torrent, source.fileIndex, ranges, maxBytes) : null;
692
692
  },
693
+ // Which bytes of a file are downloaded whole now — what an original
694
+ // input's stretch may extend over. A snapshot: the copy that follows
695
+ // holds the pieces it reads, and refuses where one has gone since.
696
+ readEncodeHeldRanges: async (source) => {
697
+ const torrent = torrentPool.knownTorrent(source.sourceKey);
698
+ return torrent ? torrentPool.heldRangesOf(torrent, source.fileIndex) : [];
699
+ },
693
700
  enabled: transcodeAudio,
694
701
  keyframeTables,
695
702
  ffmpegBin,
@@ -1,5 +1,26 @@
1
1
  import { admitInput } from "./AdmittedInput.js";
2
- import { admitOriginalInput } from "./OriginalInput.js";
2
+ import { admitOriginalInput, originalInputBytes } from "./OriginalInput.js";
3
+
4
+ /**
5
+ * How many recent readings a learned figure here is taken from: long enough
6
+ * that one reading does not move the answer, short enough that the answer
7
+ * still follows the host. The same count as `run-costs.js`.
8
+ */
9
+ const RECENT_READINGS = 20;
10
+
11
+ /** @param {number[]} values @returns {number | null} */
12
+ function middleOf(values) {
13
+ if (values.length === 0) return null;
14
+ const sorted = [...values].sort((left, right) => left - right);
15
+ const middle = Math.floor(sorted.length / 2);
16
+ return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle];
17
+ }
18
+
19
+ /** @param {number[]} readings @param {number} value */
20
+ function keep(readings, value) {
21
+ readings.push(value);
22
+ while (readings.length > RECENT_READINGS) readings.shift();
23
+ }
3
24
 
4
25
  /** Event-driven preparation; a synchronous run request can take only ready input. */
5
26
  export class EncodeInputs {
@@ -8,25 +29,34 @@ export class EncodeInputs {
8
29
  #memoryRevision = 0;
9
30
  #held = 0;
10
31
  #allowed = 0;
32
+ /** What each preparing request asks of memory: all it would use, and the least it can run with. */
11
33
  #wanted = new Map();
34
+ /** Bytes per millisecond of recent original-input copies, read where the copy is made. */
35
+ #copyRates = [];
36
+ /** Seconds an encoder took to open its original input, per output. */
37
+ #opens = new Map();
12
38
  #resolve;
13
39
  #read;
40
+ #heldRanges;
14
41
  #revise;
15
42
  #changed;
16
43
  #failed;
17
44
  #capacity;
18
45
  #urgent;
19
46
  #log;
47
+ #now;
20
48
 
21
- constructor({ resolve, readRanges, reviseBudget, changed, failed, capacity = () => null, urgent = () => true, log = () => {} }) {
49
+ constructor({ resolve, readRanges, heldRanges = null, reviseBudget, changed, failed, capacity = () => null, urgent = () => true, log = () => {}, now = Date.now }) {
22
50
  this.#resolve = resolve;
23
51
  this.#read = readRanges;
52
+ this.#heldRanges = heldRanges;
24
53
  this.#revise = reviseBudget;
25
54
  this.#changed = changed;
26
55
  this.#failed = failed;
27
56
  this.#capacity = capacity;
28
57
  this.#urgent = urgent;
29
58
  this.#log = log;
59
+ this.#now = now;
30
60
  }
31
61
 
32
62
  /** One line when a request's state changes, so a wait that never ends names what it waits for. */
@@ -38,16 +68,16 @@ export class EncodeInputs {
38
68
  }
39
69
 
40
70
  held() { return this.#held; }
41
- wanted() { return this.#held + [...this.#wanted.values()].reduce((sum, bytes) => sum + bytes, 0); }
71
+ wanted() { return this.#held + [...this.#wanted.values()].reduce((sum, asked) => sum + asked.wanted, 0); }
42
72
  required() {
43
73
  let bytes = this.#held;
44
- for (const [key, wanted] of this.#wanted) {
45
- const request = this.#requests.get(key);
46
- // The encode plan already chose this finite input. It must fit before
47
- // production can advance, even when playback has not reached it yet.
48
- // Sharing against speculative whole-file demand can otherwise deny it
49
- // forever, so urgency must not decide its minimum allocation again.
50
- if (request) bytes += wanted;
74
+ for (const [key, asked] of this.#wanted) {
75
+ // The encode plan already chose this finite input. Its least runnable
76
+ // part must fit before production can advance, even when playback has
77
+ // not reached it yet. Sharing against speculative whole-file demand can
78
+ // otherwise deny it forever, so urgency must not decide that minimum
79
+ // again. What a stretch asks beyond it is shared like any other want.
80
+ if (this.#requests.has(key)) bytes += asked.required;
51
81
  }
52
82
  return bytes;
53
83
  }
@@ -87,9 +117,30 @@ export class EncodeInputs {
87
117
  this.#retryMemory();
88
118
  }
89
119
 
120
+ /**
121
+ * The input of a run starting at `from`, once it is prepared.
122
+ *
123
+ * `to` is the last number the run may reach. A packet input is prepared for
124
+ * `from` alone; an original input for the longest stretch from `from` that
125
+ * is present, fits memory and copies no longer than an open of that input.
126
+ * The answer carries the stretch it holds as `from` and `to`.
127
+ */
90
128
  take(output, from, to) {
91
- const key = `${output.outputKey ?? output.id}:${from}:${to}`;
92
- const existing = this.#requests.get(key);
129
+ const key = keyOf(output, from);
130
+ let existing = this.#requests.get(key);
131
+ if (existing && existing.to !== to) {
132
+ // The plan's bound for this start moved. A copy already made beyond it
133
+ // would be a run over somebody else's stretch, so it is given back; one
134
+ // being made is checked when it is done, and one waiting for bytes or
135
+ // memory takes the new bound when its own event prepares it again.
136
+ existing.to = to;
137
+ if (existing.result?.kind === "result" && existing.result.to > to) {
138
+ this.#requests.delete(key);
139
+ this.#wanted.delete(key);
140
+ existing.result.release();
141
+ existing = undefined;
142
+ }
143
+ }
93
144
  if (existing?.result?.kind === "result") {
94
145
  this.#requests.delete(key);
95
146
  return existing.result;
@@ -102,7 +153,7 @@ export class EncodeInputs {
102
153
  }
103
154
 
104
155
  async acquire(output, from, to) {
105
- const key = `${output.outputKey ?? output.id}:${from}:${to}`;
156
+ const key = keyOf(output, from);
106
157
  const ready = this.take(output, from, to);
107
158
  if (ready) return ready;
108
159
  await this.#requests.get(key)?.promise;
@@ -125,41 +176,70 @@ export class EncodeInputs {
125
176
  this.#wanted.delete(key);
126
177
  request.result?.release?.();
127
178
  }
179
+ this.#opens.delete(addressOf(output));
180
+ }
181
+
182
+ /**
183
+ * How long an encoder took to open this output's original input: from its
184
+ * spawn to the name of its first closed piece, less what encoding that piece
185
+ * costs. Measured by whoever ran it, kept here where a stretch is sized.
186
+ *
187
+ * @param {object} output
188
+ * @param {number} seconds
189
+ */
190
+ noteOpen(output, seconds) {
191
+ if (!Number.isFinite(seconds) || seconds < 0) return;
192
+ const readings = this.#opens.get(addressOf(output)) ?? [];
193
+ keep(readings, seconds);
194
+ this.#opens.set(addressOf(output), readings);
195
+ }
196
+
197
+ /**
198
+ * How many bytes a stretch may hold so that copying it takes no longer than
199
+ * opening the input it replaces: the recent copy rate times this output's
200
+ * recent open. Beyond it the open's share of each piece falls below the
201
+ * copy's, which is the same for every piece; a longer stretch buys less and
202
+ * delays the run's start more. Null until both have been measured, and the
203
+ * run then gets one piece, as it did before either reading existed.
204
+ *
205
+ * @param {object} output
206
+ * @returns {number | null}
207
+ */
208
+ stretchBytesFor(output) {
209
+ const rate = middleOf(this.#copyRates);
210
+ const open = middleOf(this.#opens.get(addressOf(output)) ?? []);
211
+ return rate === null || open === null ? null : Math.floor(rate * open * 1000);
128
212
  }
129
213
 
130
214
  #prepare(request) {
131
215
  request.pending = true;
216
+ request.stale = false;
132
217
  request.revision = this.#revision;
133
218
  request.memoryRevision = this.#memoryRevision;
134
219
  request.promise = (async () => {
135
- const resolved = await this.#resolve(request.output, request.from, request.to);
220
+ const resolved = await this.#resolve(request.output, request.from, request.from);
136
221
  if (this.#requests.get(request.key) !== request) return;
137
- const admit = resolved.sources?.every(source => source.input.original === true) ? admitOriginalInput : admitInput;
138
- const result = resolved.kind === "result" ? await admit({
139
- sources: resolved.sources,
140
- readRanges: this.#read,
141
- reserve: async bytes => {
142
- this.#wanted.set(request.key, bytes);
143
- await this.#revise();
144
- const capacity = this.#capacity();
145
- if (capacity !== null && bytes > capacity && this.#urgent(request.output, request.from)) return {
146
- kind: "terminal", reason: "source-input-exceeds-memory-capacity", bytes, capacity
147
- };
148
- if (this.#requests.get(request.key) !== request || this.#allowed - this.#held < bytes) return null;
149
- this.#wanted.delete(request.key);
150
- this.#held += bytes;
151
- let released = false;
152
- return () => {
153
- if (!released) {
154
- released = true; this.#held -= bytes;
155
- queueMicrotask(() => this.memoryChanged());
156
- }
157
- };
158
- }
159
- }) : resolved;
222
+ let result;
223
+ if (resolved.kind !== "result") {
224
+ result = resolved;
225
+ } else if (resolved.sources?.every(source => source.input.original === true)) {
226
+ result = await this.#admitStretch(request, resolved.sources);
227
+ if (!result) return;
228
+ } else {
229
+ result = await admitInput({ sources: resolved.sources, readRanges: this.#read, reserve: this.#reserveFor(request) });
230
+ if (result.kind === "result") Object.assign(result, { from: request.from, to: request.from });
231
+ }
160
232
  if (this.#requests.get(request.key) !== request) { result.release?.(); return; }
233
+ if (result.kind === "result" && result.to > request.to) {
234
+ // The plan narrowed this start while the copy was being made.
235
+ result.release();
236
+ request.result = null;
237
+ request.stale = true;
238
+ return;
239
+ }
161
240
  request.result = result;
162
- this.#note(request, result.kind === "result" ? `ready, ${result.bytes ?? "?"} bytes`
241
+ this.#note(request, result.kind === "result" ? `ready #${result.from}..#${result.to}, ${result.bytes ?? "?"} bytes` +
242
+ (Number.isFinite(result.copyMs) ? `, copied in ${result.copyMs}ms` : "")
163
243
  : `${result.kind}${result.reason ? ` ${result.reason}` : ""}${Number.isFinite(result.bytes) ? ` ${result.bytes} bytes` : ""}`);
164
244
  if (result.kind === "result" || result.kind === "terminal") this.#changed(request.output, result);
165
245
  })().catch(error => {
@@ -170,10 +250,137 @@ export class EncodeInputs {
170
250
  }).finally(() => {
171
251
  request.pending = false;
172
252
  if (request.result?.kind !== "needs-memory") this.#wanted.delete(request.key);
173
- const changed = request.result?.kind === "needs-memory"
174
- ? request.memoryRevision !== this.#memoryRevision : request.revision !== this.#revision;
253
+ const changed = request.stale || (request.result?.kind === "needs-memory"
254
+ ? request.memoryRevision !== this.#memoryRevision : request.revision !== this.#revision);
175
255
  if (this.#requests.get(request.key) === request && changed &&
176
256
  request.result?.kind !== "result" && request.result?.kind !== "terminal") this.#prepare(request);
177
257
  });
178
258
  }
259
+
260
+ /** The reservation a packet input makes: all of it, or nothing. */
261
+ #reserveFor(request) {
262
+ return async bytes => {
263
+ this.#wanted.set(request.key, { wanted: bytes, required: bytes });
264
+ await this.#revise();
265
+ const capacity = this.#capacity();
266
+ if (capacity !== null && bytes > capacity && this.#urgent(request.output, request.from)) return {
267
+ kind: "terminal", reason: "source-input-exceeds-memory-capacity", bytes, capacity
268
+ };
269
+ return this.#hold(request, bytes);
270
+ };
271
+ }
272
+
273
+ /** Take `bytes` of the allowance for this request now, or answer null. */
274
+ #hold(request, bytes) {
275
+ if (this.#requests.get(request.key) !== request || this.#allowed - this.#held < bytes) return null;
276
+ this.#wanted.delete(request.key);
277
+ this.#held += bytes;
278
+ let released = false;
279
+ return () => {
280
+ if (!released) {
281
+ released = true; this.#held -= bytes;
282
+ queueMicrotask(() => this.memoryChanged());
283
+ }
284
+ };
285
+ }
286
+
287
+ /**
288
+ * Choose and copy one finite stretch of an original input, starting at the
289
+ * request's `from` and ending at the largest `e <= to` such that every byte
290
+ * of `from … e` is present now, the stretch copies no longer than an open,
291
+ * and it fits the memory the budget allows. One piece is the least it runs
292
+ * with; only that least is required of the budget, and only that least being
293
+ * larger than the policy's whole capacity is terminal.
294
+ *
295
+ * @returns {Promise<object | undefined>} Undefined when the request was withdrawn.
296
+ */
297
+ async #admitStretch(request, firstSources) {
298
+ const { output, from } = request;
299
+ const minimum = originalInputBytes(firstSources);
300
+ const stretches = new Map([[from, { sources: firstSources, bytes: minimum }]]);
301
+ const held = new Map();
302
+ const at = async end => {
303
+ if (!stretches.has(end)) {
304
+ // A longer stretch that cannot be stated is not one to copy; the
305
+ // shorter one already stated still runs.
306
+ const resolved = await this.#resolve(output, from, end).catch(() => null);
307
+ stretches.set(end, resolved?.kind === "result" && resolved.sources?.every(source => source.input.original === true)
308
+ ? { sources: resolved.sources, bytes: originalInputBytes(resolved.sources) } : null);
309
+ }
310
+ return stretches.get(end);
311
+ };
312
+ const fits = async (end, maxBytes) => {
313
+ const stretch = await at(end);
314
+ return Boolean(stretch) && stretch.bytes <= maxBytes && await this.#present(stretch.sources, held);
315
+ };
316
+ // Grows by doubling and then halves back: a handful of resolutions find
317
+ // the end whether it is a piece away or the rest of the film.
318
+ const longest = async maxBytes => {
319
+ let good = from, bad = null, step = 1;
320
+ while (bad === null) {
321
+ const end = good + step;
322
+ if (end > request.to) bad = request.to + 1;
323
+ else if (await fits(end, maxBytes)) { good = end; step *= 2; }
324
+ else bad = end;
325
+ }
326
+ while (bad - good > 1) {
327
+ const middle = Math.floor((good + bad) / 2);
328
+ if (await fits(middle, maxBytes)) good = middle; else bad = middle;
329
+ }
330
+ return good;
331
+ };
332
+ const target = this.stretchBytesFor(output);
333
+ let end = target === null || this.#heldRanges === null ? from : await longest(target);
334
+ if (this.#requests.get(request.key) !== request) return undefined;
335
+ this.#wanted.set(request.key, { wanted: stretches.get(end).bytes, required: minimum });
336
+ await this.#revise();
337
+ if (this.#requests.get(request.key) !== request) return undefined;
338
+ const capacity = this.#capacity();
339
+ if (capacity !== null && minimum > capacity && this.#urgent(output, from)) {
340
+ return { kind: "terminal", reason: "source-input-exceeds-memory-capacity", bytes: minimum, capacity };
341
+ }
342
+ const roomNow = () => Math.min(capacity ?? Number.POSITIVE_INFINITY, this.#allowed - this.#held);
343
+ let room = roomNow();
344
+ let result;
345
+ for (;;) {
346
+ if (minimum > room) {
347
+ // Wait for memory with the least it can run with, not the larger stretch.
348
+ this.#wanted.set(request.key, { wanted: minimum, required: minimum });
349
+ return { kind: "needs-memory", bytes: minimum };
350
+ }
351
+ if (stretches.get(end).bytes > room) {
352
+ end = await longest(Math.min(target ?? minimum, room));
353
+ if (this.#requests.get(request.key) !== request) return undefined;
354
+ }
355
+ const chosen = stretches.get(end);
356
+ this.#wanted.set(request.key, { wanted: chosen.bytes, required: chosen.bytes });
357
+ result = await admitOriginalInput({ sources: chosen.sources, readRanges: this.#read, now: this.#now,
358
+ reserve: async bytes => this.#hold(request, bytes) });
359
+ // Another output's copy can take the room this one was sized for while
360
+ // it was being chosen. A shorter stretch is tried at once rather than
361
+ // waiting for a memory change that may never come.
362
+ if (result.kind !== "needs-memory" || this.#requests.get(request.key) !== request || roomNow() >= room) break;
363
+ room = roomNow();
364
+ }
365
+ if (result.kind === "needs-memory") this.#wanted.set(request.key, { wanted: minimum, required: minimum });
366
+ if (result.kind === "result") {
367
+ Object.assign(result, { from, to: end });
368
+ if (result.copyMs > 0) keep(this.#copyRates, result.bytes / result.copyMs);
369
+ }
370
+ return result;
371
+ }
372
+
373
+ /** Whether every range of these sources is downloaded whole now. */
374
+ async #present(sources, held) {
375
+ for (const source of sources) {
376
+ const name = `${source.sourceKey}:${source.fileIndex}`;
377
+ if (!held.has(name)) held.set(name, await Promise.resolve().then(() => this.#heldRanges(source)).catch(() => null) ?? []);
378
+ const ranges = held.get(name);
379
+ if (!source.input.ranges.every(([start, end]) => ranges.some(([first, last]) => first <= start && last >= end))) return false;
380
+ }
381
+ return true;
382
+ }
179
383
  }
384
+
385
+ function addressOf(output) { return output.outputKey ?? output.id; }
386
+ function keyOf(output, from) { return `${addressOf(output)}:${from}`; }
@@ -847,7 +847,24 @@ export class EncodeOrchestrator {
847
847
  // between is synchronous, and a process cannot say a word before the next
848
848
  // tick. What it removes is the second act — building a run and starting it
849
849
  // were two steps, and two owners each performed the second one.
850
- this.coverageOf(address).claim(run, from, endOfRun({ from, to }));
850
+ //
851
+ // A run whose input is one copied stretch can make nothing past it, so it
852
+ // claims that stretch and not the plan's longer bound: the rest stays free
853
+ // for the next run (torrent-tv/meta#158). Every other run claims as before.
854
+ const through = Number.isInteger(run.inputThrough) ? run.inputThrough : to;
855
+ this.coverageOf(address).claim(run, from, endOfRun({ from, to: through }));
856
+ }
857
+
858
+ /**
859
+ * The speed one encoder on this output is taken to run at: the fastest live
860
+ * run's measurement, else what this output last measured or the startup
861
+ * figures predict. Zero where none exists.
862
+ *
863
+ * @param {string} address
864
+ * @returns {number}
865
+ */
866
+ speedOn(address) {
867
+ return this.#speedOn(address, this.runsOn(address).filter((run) => run.isAlive));
851
868
  }
852
869
 
853
870
  /**
@@ -63,6 +63,9 @@ const MICROSECONDS_PER_SECOND = 1_000_000;
63
63
  * @property {number} reached - The last number it finished, or `from - 1` when
64
64
  * it finished none.
65
65
  * @property {number} livedMs
66
+ * @property {number | null} [firstNamedMs] - Spawn to the moment the encoder
67
+ * named its first closed piece: the open of its input plus that one piece's
68
+ * encoding, without the wait for the following piece that publication adds.
66
69
  * @property {boolean} normal - Whether this ending is the expected one.
67
70
  * @property {string} lastError - The last thing ffmpeg said on stderr.
68
71
  * @property {string | null} provenName - The last piece this run named while it
@@ -156,6 +159,13 @@ export class EncodeRun {
156
159
  */
157
160
  #firstOutputAt = 0;
158
161
 
162
+ /**
163
+ * When the encoder first NAMED a closed piece of its own stretch. A piece is
164
+ * published only once the next one is named, so the first output above holds
165
+ * two pieces' encoding; this holds one, and is what an open is read from.
166
+ */
167
+ #firstNamedAt = 0;
168
+
159
169
  /** The film made and the run's own working time, as of the last progress report. */
160
170
  #workSample = null;
161
171
 
@@ -583,6 +593,7 @@ export class EncodeRun {
583
593
  }
584
594
  if (this.#publicationError) break;
585
595
  const index = this.indexOfName(name);
596
+ if (this.#firstNamedAt === 0 && Number.isInteger(index) && index >= this.from) this.#firstNamedAt = this.now();
586
597
  const endOfWork = Number.isFinite(endOfRun(this)) ? this.to : this.lastSegmentIndex();
587
598
  // A packet crossing the final cut can flush a following file. It may
588
599
  // complete its predecessor, but no unadmitted interval can be published.
@@ -899,6 +910,7 @@ export class EncodeRun {
899
910
  this.#firstOutputAt > 0 && this.#startedAt > 0
900
911
  ? this.#firstOutputAt - this.#startedAt
901
912
  : null,
913
+ firstNamedMs: this.#firstNamedAt > 0 && this.#startedAt > 0 ? this.#firstNamedAt - this.#startedAt : null,
902
914
  normal: ending === ENCODE_EXIT.COMPLETE,
903
915
  lastError: this.lastError
904
916
  };
@@ -809,12 +809,23 @@ export class EncodeRuns {
809
809
  return null;
810
810
  }
811
811
  this.#host.encodeInputs?.retain(session, this.#host.encodeOrchestrator.wantedSegmentsOn(session.outputKey));
812
- const admittedInput = this.#host.encodeInputs?.take(session, startIndex, startIndex) ?? null;
812
+ // THE LAST NUMBER THIS RUN MAY REACH is the plan's; how far its input
813
+ // really reaches is the input's answer. An original input is one copied
814
+ // stretch, and the run is given exactly that stretch (torrent-tv/meta#158).
815
+ const segmentCount = Number(session.timeline?.segmentCount) || 0;
816
+ const mayReach = Number.isInteger(ordered?.to) && ordered.to >= startIndex ? ordered.to
817
+ : segmentCount > startIndex ? segmentCount - 1 : startIndex;
818
+ const admittedInput = this.#host.encodeInputs?.take(session, startIndex, mayReach) ?? null;
813
819
  if (!admittedInput) return null;
820
+ const inputEnd = admittedInput.original ? admittedInput.to : startIndex;
821
+ // The stretch is part of an original input's identity: two stretches from
822
+ // one start can hold the same padded bytes, and a stretch that failed is
823
+ // refused only while it is the same stretch.
814
824
  const inputParameters = admittedInput ? { outputKey: session.outputKey, encoder: this.#host.videoEncoder.name,
815
825
  width: session.output.encodeWidth, height: session.output.encodeHeight, fps: session.output.outputFps,
816
826
  preset: session.output.softwarePreset, tonemap: session.output.applyTonemap,
817
- rateControl: session.spec.video?.encode?.rateControl ?? null } : null;
827
+ rateControl: session.spec.video?.encode?.rateControl ?? null,
828
+ ...(admittedInput.original ? { interval: [startIndex, inputEnd] } : {}) } : null;
818
829
  const inputKey = admittedInput ? this.#inputFailures.key(admittedInput, inputParameters) : null;
819
830
  if (inputKey && this.#inputFailures.failure(session.outputKey, startIndex, inputKey)) {
820
831
  admittedInput.release();
@@ -842,7 +853,7 @@ export class EncodeRuns {
842
853
  // an argument. Reading it off the coverage map a second time was the last
843
854
  // remaining second answer to that question: the plan computed `to`, passed
844
855
  // it, and the parameter list did not name it.
845
- const runEnd = admittedInput ? startIndex : Number.isInteger(ordered?.to)
856
+ const runEnd = admittedInput ? inputEnd : Number.isInteger(ordered?.to)
846
857
  ? ordered.to
847
858
  : this.#runEndFrom(session, startIndex, null);
848
859
  // The restart backs off a segment or two from what was asked for, so the
@@ -914,7 +925,8 @@ export class EncodeRuns {
914
925
  audioSourceTrackIndex: session.spec.audioSourceTrackIndex,
915
926
  rateControl: session.spec.video?.encode?.rateControl ?? null,
916
927
  startIndex,
917
- endIndex: Number.isInteger(ordered?.to) ? ordered.to : runEnd >= startIndex ? runEnd : session.timeline.segmentCount - 1,
928
+ endIndex: admittedInput.original ? inputEnd
929
+ : Number.isInteger(ordered?.to) ? ordered.to : runEnd >= startIndex ? runEnd : session.timeline.segmentCount - 1,
918
930
  videoEncoder: this.#host.videoEncoder,
919
931
  segmentDurationSec: this.#host.segmentDurationSec
920
932
  });
@@ -1017,6 +1029,7 @@ export class EncodeRuns {
1017
1029
  `${reads.count} read(s), ${reads.bytes} bytes, first at ${since(reads.firstAt)}, last at ${since(reads.lastAt)}, ` +
1018
1030
  `ended at ${Date.now() - run.startedAt}ms`);
1019
1031
  }
1032
+ this.#noteOpen(session, run, ended);
1020
1033
  admittedInput.release();
1021
1034
  }
1022
1035
  this.noteRunEnded(session, run, ended);
@@ -1025,7 +1038,13 @@ export class EncodeRuns {
1025
1038
  if (admittedInput.original) {
1026
1039
  run.originalInput = admittedInput;
1027
1040
  run.inputFingerprint = admittedInput.fingerprint;
1028
- run.admittedInputKeys = new Map([[safeIndex, inputKey]]);
1041
+ // Every number of the stretch answers to its one key, so a failure
1042
+ // anywhere inside it is recorded against the stretch that was commanded.
1043
+ run.admittedInputKeys = new Map(Array.from({ length: inputEnd - safeIndex + 1 }, (_, offset) => [safeIndex + offset, inputKey]));
1044
+ // What this run can make is what its input holds, and that is what it
1045
+ // claims; the plan's longer bound would read as made by a run that
1046
+ // cannot reach it.
1047
+ run.inputThrough = inputEnd;
1029
1048
  run.process.stdin.end();
1030
1049
  } else if (admittedInput) {
1031
1050
  run.inputFingerprint = admittedInput.fingerprint;
@@ -1084,6 +1103,28 @@ export class EncodeRuns {
1084
1103
  }
1085
1104
  }
1086
1105
 
1106
+ /**
1107
+ * How long this run took to open its original input, for sizing the next
1108
+ * stretch: from its spawn to the name of its first closed piece, less what
1109
+ * encoding that piece costs at the run's speed — its own where it measured
1110
+ * one, else the output's. Nothing is noted where either is unknown.
1111
+ *
1112
+ * @param {object} session
1113
+ * @param {import("./EncodeRun.js").EncodeRun} run
1114
+ * @param {import("./EncodeRun.js").RunEnded} ended
1115
+ * @returns {void}
1116
+ */
1117
+ #noteOpen(session, run, ended) {
1118
+ const grid = session.timeline?.published ?? session.timeline?.boundaries;
1119
+ const pieceSeconds = Number(grid?.[run.from + 1]) - Number(grid?.[run.from]);
1120
+ const speed = run.speedX > 0 ? run.speedX : this.#host.encodeOrchestrator.speedOn?.(session.outputKey) ?? 0;
1121
+ if (!Number.isFinite(ended.firstNamedMs) || !(pieceSeconds > 0) || !(speed > 0)) return;
1122
+ const openSeconds = Math.max(0, ended.firstNamedMs / 1000 - pieceSeconds / speed);
1123
+ this.#host.encodeInputs?.noteOpen(session, openSeconds);
1124
+ this.#host.logger.info(`encode input of run #${run.from}..#${run.to} on ${session.outputKey}: opened in ` +
1125
+ `${openSeconds.toFixed(2)}s (first piece named at ${ended.firstNamedMs}ms, ${pieceSeconds.toFixed(3)}s of film at ${speed.toFixed(2)}x)`);
1126
+ }
1127
+
1087
1128
  /**
1088
1129
  * Log a report from a run that still belongs to this output. The run owns the
1089
1130
  * clock and has already placed its relative ffmpeg time on the source
@@ -4,18 +4,22 @@ import { createHash } from "node:crypto";
4
4
  * The available-only reader acquires complete pieces atomically before copying;
5
5
  * owned buffers then outlive torrent eviction without keeping duplicate pins.
6
6
  */
7
- export async function admitOriginalInput({ sources, reserve, readRanges }) {
8
- const bytes = sources.reduce((total, source) => total + source.input.ranges
9
- .reduce((sum, [start, end]) => sum + end - start + 1, 0), 0);
7
+ export async function admitOriginalInput({ sources, reserve, readRanges, now = Date.now }) {
8
+ const bytes = originalInputBytes(sources);
10
9
  if (!Number.isSafeInteger(bytes) || bytes <= 0) throw new TypeError("Original input requires complete finite byte ranges.");
11
10
  const releaseBudget = await reserve(bytes);
12
11
  if (typeof releaseBudget !== "function") return releaseBudget?.kind === "terminal" ? releaseBudget : { kind: "needs-memory", bytes };
13
12
  const held = new Map();
14
13
  const hash = createHash("sha256");
15
14
  let retained = false;
15
+ // How long the copy itself took, apart from any wait for memory or bytes
16
+ // before it: the figure a run's stretch is sized from, read where the copy is.
17
+ let copyMs = 0;
16
18
  try {
17
19
  for (const source of sources) {
20
+ const startedAt = now();
18
21
  const buffers = await readRanges(source, source.input.ranges, bytes);
22
+ copyMs += Math.max(0, now() - startedAt);
19
23
  if (buffers === null) return { kind: "needs-bytes", sourceKey: source.sourceKey, fileIndex: source.fileIndex, ranges: source.input.ranges };
20
24
  if (!Array.isArray(buffers) || buffers.length !== source.input.ranges.length || buffers.some((buffer, index) =>
21
25
  !Buffer.isBuffer(buffer) || buffer.length !== source.input.ranges[index][1] - source.input.ranges[index][0] + 1)) {
@@ -32,7 +36,7 @@ export async function admitOriginalInput({ sources, reserve, readRanges }) {
32
36
  // 1.6 s from a file, and nothing said whether it was waiting on its input.
33
37
  const reads = { count: 0, bytes: 0, firstAt: 0, lastAt: 0 };
34
38
  return {
35
- kind: "result", original: true, sources, bytes, fingerprint: hash.digest("hex"),
39
+ kind: "result", original: true, sources, bytes, copyMs, fingerprint: hash.digest("hex"),
36
40
  tracks: sources.flatMap(source => (source.input.selections ?? []).map(selection => ({ track: selection.track,
37
41
  ...(Number.isFinite(source.input.sourceEnds?.[selection.track.trackNumber]) ? {
38
42
  sourceEndSeconds: source.input.sourceEnds[selection.track.trackNumber] - source.timeShiftSeconds
@@ -67,3 +71,9 @@ export async function admitOriginalInput({ sources, reserve, readRanges }) {
67
71
  if (!retained) releaseBudget();
68
72
  }
69
73
  }
74
+
75
+ /** The bytes an original input of these sources holds once copied. */
76
+ export function originalInputBytes(sources) {
77
+ return sources.reduce((total, source) => total + source.input.ranges
78
+ .reduce((sum, [start, end]) => sum + end - start + 1, 0), 0);
79
+ }
@@ -129,6 +129,8 @@ export function buildRunCommand({
129
129
  args.push("-i", audioInputUrl);
130
130
  };
131
131
 
132
+ // Whether an output `-ss` already cuts at the interval's start.
133
+ let trimmedAtStart = false;
132
134
  if (snappedKeyframe !== null) {
133
135
  const residualSeconds = Math.max(0, seekAt - snappedKeyframe);
134
136
  if (snappedKeyframe > sourceStartTime) {
@@ -138,6 +140,7 @@ export function buildRunCommand({
138
140
  pushAudioInput(snappedKeyframe);
139
141
  if (residualSeconds > 0 && !keyframeGrid) {
140
142
  args.push("-ss", ffmpegSeconds(residualSeconds));
143
+ trimmedAtStart = true;
141
144
  }
142
145
  } else {
143
146
  if (seekAt > sourceStartTime) {
@@ -146,6 +149,15 @@ export function buildRunCommand({
146
149
  args.push("-i", inputUrl);
147
150
  pushAudioInput(seekAt);
148
151
  }
152
+ // An input seek lands on the picture's keyframe at or before the time asked
153
+ // for, in every stream (AVI `avi_read_seek`, Matroska Cues). `-accurate_seek`
154
+ // trims only what is decoded, so a copied soundtrack began at that keyframe
155
+ // and `-t` counted its length from there: a piece of a LostFilm AVI with a
156
+ // keyframe every 11 s carried the film's first four seconds (torrent-tv/meta#159).
157
+ // Without `-copyts` the time asked for is zero on the output's input clock.
158
+ if (!keyframeGrid && !servesAudioSeparately && !transcodeAudio && !trimmedAtStart) {
159
+ args.push("-ss", "0");
160
+ }
149
161
  if (!keyframeGrid) {
150
162
  if (startSeconds > 0) {
151
163
  args.push("-output_ts_offset", ffmpegSeconds(startSeconds));
@@ -233,20 +245,34 @@ export function buildRunCommand({
233
245
 
234
246
  }
235
247
 
236
- /** One finite original-source run, preserving the published interval and track choice. */
248
+ /**
249
+ * One finite original-source run over the stretch its input holds, `startIndex`
250
+ * through `endIndex`, preserving the published intervals and track choice.
251
+ *
252
+ * The command works on a local timeline holding the stretch's own boundaries
253
+ * plus one beyond its end, so every published boundary inside it, the end
254
+ * included, is a cut, and what the muxer flushes after the end is a piece
255
+ * nobody publishes. Numbering continues from `startIndex`.
256
+ */
237
257
  export function buildOriginalCommand(params) {
238
258
  const { admittedInput, timeline, startIndex, inputToken, baseUrl, audioOnly } = params;
259
+ const endIndex = Number.isInteger(params.endIndex) && params.endIndex >= startIndex ? params.endIndex : startIndex;
239
260
  const grid = publishedGridFor(timeline);
240
- const from = grid[startIndex], to = grid[startIndex + 1];
241
- if (!(to > from)) throw new Error("Original-source encoding requires a finite published interval.");
261
+ const stretch = grid.slice(startIndex, endIndex + 2);
262
+ const from = stretch[0], to = stretch.at(-1);
263
+ if (stretch.length !== endIndex - startIndex + 2 || !stretch.every((at, index) => index === 0 || at > stretch[index - 1])) {
264
+ throw new Error("Original-source encoding requires a finite published interval.");
265
+ }
266
+ const last = stretch.at(-2);
242
267
  const sourceFor = kind => admittedInput.sources.find(source => source.input.selections.some(selection => selection.track.type === kind));
243
268
  const primary = sourceFor(audioOnly ? "audio" : "video");
244
269
  const audio = sourceFor("audio") ?? primary;
245
270
  if (!primary) throw new Error("The selected source track is absent from original input.");
246
271
  const url = source => new URL(`/encode-input/${inputToken}/${source.fileIndex}`, baseUrl).href;
247
- const localTimeline = { ...timeline, published: [from, to, to + (to - from)],
248
- boundaries: [from, to, to + (to - from)], sourceStartOf: () => primary.input.from ?? from + primary.timeShiftSeconds };
249
- const command = buildRunCommand({ ...params, startIndex: 0, endIndex: 0, timeline: localTimeline,
272
+ const local = [...stretch, to + (to - last)];
273
+ const localTimeline = { ...timeline, published: local,
274
+ boundaries: local, sourceStartOf: () => primary.input.from ?? from + primary.timeShiftSeconds };
275
+ const command = buildRunCommand({ ...params, startIndex: 0, endIndex: endIndex - startIndex, timeline: localTimeline,
250
276
  videoSourceTrackIndex: primary.input.selections.find(selection => selection.track.type === "video")?.index ?? 0,
251
277
  reorderDepth: primary.input.selections.find(selection => selection.track.type === "video")?.track.reorderDepth ?? 0,
252
278
  inputFile: { startTime: primary.timeShiftSeconds }, audioFile: { startTime: audio.timeShiftSeconds },
@@ -232,8 +232,13 @@ export class AviContainer extends Container {
232
232
  } else if (type === "audio") {
233
233
  if (strf.length < 16) throw new Error("AVI wave format is truncated.");
234
234
  const tag = strf.readUInt16LE(0);
235
+ // WAVEFORMATEX states the average bytes per second the stream carries
236
+ // (`nAvgBytesPerSec`); zero states nothing. Without it a soundtrack the
237
+ // browser plays is re-encoded only because its copy has no figure to
238
+ // ask the link about (torrent-tv/meta#159).
235
239
  track = new AudioTrack({ ...params, codecId: waveCodec(tag),
236
240
  channels: strf.readUInt16LE(2), samplingFrequency: strf.readUInt32LE(4),
241
+ bitrateKbps: strf.readUInt32LE(8) * 8 / 1000,
237
242
  codecPrivateB64: strf.subarray(18).toString("base64") });
238
243
  track.blockAlign = strf.readUInt16LE(12);
239
244
  track.matroskaCodecId = "A_MS/ACM";
@@ -78,6 +78,8 @@ export class AudioTrack extends ContainerTrack {
78
78
  * @param {boolean} params.isVisualImpaired - FlagVisualImpaired / descriptive audio
79
79
  * @param {number | null} params.channels
80
80
  * @param {number | null} params.samplingFrequency
81
+ * @param {number | null} [params.bitrateKbps] - The rate the file states for
82
+ * this track, in kbit/s: an average, not a bound.
81
83
  */
82
84
  constructor(params) {
83
85
  super({ ...params, type: "audio" });
@@ -86,6 +88,7 @@ export class AudioTrack extends ContainerTrack {
86
88
  this.isVisualImpaired = params.isVisualImpaired === true;
87
89
  this.channels = Number.isFinite(params.channels) ? params.channels : null;
88
90
  this.samplingFrequency = Number.isFinite(params.samplingFrequency) ? params.samplingFrequency : null;
91
+ this.bitrateKbps = Number.isFinite(params.bitrateKbps) && params.bitrateKbps > 0 ? params.bitrateKbps : null;
89
92
  this.bitDepth = Number.isSafeInteger(params.bitDepth) && params.bitDepth > 0 ? params.bitDepth : null;
90
93
  this.codecDelaySeconds = Number.isFinite(params.codecDelaySeconds) && params.codecDelaySeconds >= 0 ? params.codecDelaySeconds : 0;
91
94
  // What the codec is configured as, and the most that configuration allows
@@ -218,6 +218,7 @@ export function wireOutputs({
218
218
  readMetadataActivity = null,
219
219
  sourceInputsFor = null,
220
220
  readEncodeRanges = null,
221
+ readEncodeHeldRanges = null,
221
222
  encodeInputs = null,
222
223
  indexMemory = null,
223
224
  budgetPolicy = null,
@@ -964,6 +965,7 @@ export function wireOutputs({
964
965
  parts.encodeInputs = encodeInputs ?? (typeof resolveEncodeInput === "function" ? new EncodeInputs({
965
966
  resolve: resolveEncodeInput,
966
967
  readRanges: readEncodeRanges,
968
+ heldRanges: readEncodeHeldRanges,
967
969
  reviseBudget: () => parts.machineBudget.revise(),
968
970
  capacity: () => parts.machineBudget.capacityOf("memory"),
969
971
  urgent: (output, index) => parts.encodeOrchestrator.wantedSegmentsOn(output.outputKey).some(window =>