@torrent-tv/proxy 2.97.5 → 2.97.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/server.js +1 -0
- package/services/encode/EncodeInputs.js +14 -0
- package/services/encode/EncodeRun.js +42 -4
- package/services/encode/EncodeRuns.js +129 -46
- package/services/encode/piece-completeness.js +54 -23
- package/services/encode/quality/HostLoad.js +5 -0
- package/services/encode/segment-formats/presentation-segment.js +139 -22
- package/services/server/wire-outputs.js +31 -2
- package/services/storage/processor-share.js +52 -0
- package/services/torrent/torrent-pool.js +29 -0
- package/services/torrent/worker/client.js +11 -0
- package/services/torrent/worker/pool-adapter.js +11 -0
- package/services/torrent/worker/protocol.js +6 -0
- package/services/torrent/worker/worker.js +5 -0
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -802,6 +802,7 @@ export async function startProxyServer({
|
|
|
802
802
|
// thread, so the map crosses the worker channel; what it does with it —
|
|
803
803
|
// seconds into bytes, what to ask the swarm for, what to keep in memory —
|
|
804
804
|
// is its own business.
|
|
805
|
+
setDownloadRate: (bytesPerSecond, because) => torrentPool.setDownloadRate?.(bytesPerSecond, because),
|
|
805
806
|
setPriorityMap: async ({ sourceKey, fileIndex, durationSeconds, zones }) => {
|
|
806
807
|
void sourcePreparation?.refresh();
|
|
807
808
|
const record = sourceRegistry.get(sourceKey);
|
|
@@ -25,6 +25,8 @@ function keep(readings, value) {
|
|
|
25
25
|
/** Event-driven preparation; a synchronous run request can take only ready input. */
|
|
26
26
|
export class EncodeInputs {
|
|
27
27
|
#requests = new Map();
|
|
28
|
+
/** Whether an output's last resolved input was its original file. @type {WeakMap<object, boolean>} */
|
|
29
|
+
#original = new WeakMap();
|
|
28
30
|
#revision = 0;
|
|
29
31
|
#memoryRevision = 0;
|
|
30
32
|
#held = 0;
|
|
@@ -130,6 +132,17 @@ export class EncodeInputs {
|
|
|
130
132
|
}
|
|
131
133
|
}
|
|
132
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Whether this output reads its original file, as its last resolved input
|
|
137
|
+
* did, or undefined before any was resolved.
|
|
138
|
+
*
|
|
139
|
+
* @param {object} output
|
|
140
|
+
* @returns {boolean | undefined}
|
|
141
|
+
*/
|
|
142
|
+
readsOriginal(output) {
|
|
143
|
+
return this.#original.get(output);
|
|
144
|
+
}
|
|
145
|
+
|
|
133
146
|
failureOf(output) {
|
|
134
147
|
for (const request of this.#requests.values()) {
|
|
135
148
|
if (request.output === output && request.result?.kind === "terminal") return request.result;
|
|
@@ -262,6 +275,7 @@ export class EncodeInputs {
|
|
|
262
275
|
request.timing.attempts += 1;
|
|
263
276
|
request.promise = (async () => {
|
|
264
277
|
const resolved = await this.#resolveTimed(request.timing, request.output, request.from, request.from);
|
|
278
|
+
if (resolved.kind === "result") this.#original.set(request.output, resolved.sources?.every(source => source.input.original === true) === true);
|
|
265
279
|
if (this.#requests.get(request.key) !== request) return;
|
|
266
280
|
let result;
|
|
267
281
|
if (resolved.kind !== "result") {
|
|
@@ -116,6 +116,12 @@ export class EncodeRun {
|
|
|
116
116
|
/** Half a name left over from the last chunk of the encoder's own channel. */
|
|
117
117
|
#closedTail = "";
|
|
118
118
|
#pendingClosed = null;
|
|
119
|
+
/**
|
|
120
|
+
* The last closed piece when this run was stopped, published once the file
|
|
121
|
+
* the muxer flushes after it is named — the file that says where the next
|
|
122
|
+
* keyframe's leading pictures end it — or at the exit when none is.
|
|
123
|
+
*/
|
|
124
|
+
#pendingAtStop = null;
|
|
119
125
|
#closedTimings = new Map();
|
|
120
126
|
#publicationError = null;
|
|
121
127
|
#publicationCode = null;
|
|
@@ -226,6 +232,9 @@ export class EncodeRun {
|
|
|
226
232
|
* encoder itself names it on its own channel, and answers with the name that
|
|
227
233
|
* piece is served under — because making it servable is a rename, and only
|
|
228
234
|
* whoever owns the disk can perform one.
|
|
235
|
+
* @param {(name: string) => void} [params.onTail] - Called with the working
|
|
236
|
+
* name of a file this run closed but will not publish: past its last cut, or
|
|
237
|
+
* flushed when it was stopped. It begins with the next keyframe.
|
|
229
238
|
* @param {(index: number) => void} [params.onProduced] - Called with every
|
|
230
239
|
* segment number this run has closed, as it closes it.
|
|
231
240
|
* @param {(progress: object) => void} [params.onProgress] - Called with this
|
|
@@ -262,6 +271,7 @@ export class EncodeRun {
|
|
|
262
271
|
onEnded,
|
|
263
272
|
onSpeedMeasured,
|
|
264
273
|
onClosed,
|
|
274
|
+
onTail,
|
|
265
275
|
onProgress,
|
|
266
276
|
onProduced,
|
|
267
277
|
lastSegmentIndex,
|
|
@@ -292,6 +302,7 @@ export class EncodeRun {
|
|
|
292
302
|
// Told the NAME of every piece the encoder has closed. The name is the
|
|
293
303
|
// proof it is whole; nothing else here can prove that.
|
|
294
304
|
this.onClosed = typeof onClosed === "function" ? onClosed : (name) => name;
|
|
305
|
+
this.onTail = typeof onTail === "function" ? onTail : () => {};
|
|
295
306
|
this.lastSegmentIndex = typeof lastSegmentIndex === "function" ? lastSegmentIndex : () => null;
|
|
296
307
|
this.inputUnavailable = typeof inputUnavailable === "function" ? inputUnavailable : () => false;
|
|
297
308
|
this.argsDescribed = argsDescribed;
|
|
@@ -605,7 +616,18 @@ export class EncodeRun {
|
|
|
605
616
|
if (name.length === 0) {
|
|
606
617
|
continue;
|
|
607
618
|
}
|
|
608
|
-
if (this.#
|
|
619
|
+
if (this.#publicationError) continue;
|
|
620
|
+
if (this.#stopping) {
|
|
621
|
+
// Flushed by our stop. Never published; it tells where the piece closed
|
|
622
|
+
// before the stop ends, and it begins with the next keyframe.
|
|
623
|
+
if (this.#pendingAtStop !== null) {
|
|
624
|
+
const pending = this.#pendingAtStop;
|
|
625
|
+
this.#pendingAtStop = null;
|
|
626
|
+
this.#publishClosed(pending, name, { quiet: true });
|
|
627
|
+
}
|
|
628
|
+
this.onTail(name);
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
609
631
|
if (csv) {
|
|
610
632
|
this.#closedTimings.set(name, {
|
|
611
633
|
endMicros: BigInt(Math.round(Number(csv[3]) * 1_000_000))
|
|
@@ -626,6 +648,7 @@ export class EncodeRun {
|
|
|
626
648
|
// A packet crossing the final cut can flush a following file. It may
|
|
627
649
|
// complete its predecessor, but no unadmitted interval can be published.
|
|
628
650
|
if (Number.isInteger(index) && Number.isInteger(endOfWork) && index > endOfWork) {
|
|
651
|
+
this.onTail(name);
|
|
629
652
|
this.#closedTimings.delete(name);
|
|
630
653
|
this.#pendingClosed = null;
|
|
631
654
|
continue;
|
|
@@ -634,7 +657,7 @@ export class EncodeRun {
|
|
|
634
657
|
}
|
|
635
658
|
}
|
|
636
659
|
|
|
637
|
-
#publishClosed(name, following = null) {
|
|
660
|
+
#publishClosed(name, following = null, { quiet = false } = {}) {
|
|
638
661
|
this.#measureSpeed();
|
|
639
662
|
// ITS SERVED NAME, which is what whoever owns the disk gives it in answer.
|
|
640
663
|
// ffmpeg writes a piece under a working name and reports that; the piece
|
|
@@ -644,6 +667,14 @@ export class EncodeRun {
|
|
|
644
667
|
try {
|
|
645
668
|
served = this.onClosed(name, following, this.#closedTimings.get(name) ?? null);
|
|
646
669
|
} catch (error) {
|
|
670
|
+
if (quiet) {
|
|
671
|
+
// A piece closed before our stop that cannot be proved whole is not
|
|
672
|
+
// published, and it is not a failure of the run: it is made again
|
|
673
|
+
// where it is wanted.
|
|
674
|
+
this.logger?.warn?.(`encode-run #${this.from}..#${this.to} on ${this.address}: ${name} closed before the stop is not published: ` +
|
|
675
|
+
`${error instanceof Error ? error.message : String(error)}`);
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
647
678
|
this.#publicationError = error instanceof Error ? error.message : String(error);
|
|
648
679
|
this.#publicationCode = error?.code ?? null;
|
|
649
680
|
this.lastError = this.#publicationError;
|
|
@@ -713,9 +744,11 @@ export class EncodeRun {
|
|
|
713
744
|
return;
|
|
714
745
|
}
|
|
715
746
|
// A cut closed before our stop stays complete; the file flushed by the
|
|
716
|
-
// subsequent signal must never be published as that same proof.
|
|
747
|
+
// subsequent signal must never be published as that same proof. It is
|
|
748
|
+
// waited for all the same: it says where the next keyframe's leading
|
|
749
|
+
// pictures end the piece before it (torrent-tv/meta#178).
|
|
717
750
|
if (this.#pendingClosed !== null && !this.#processExited && !this.#inputTruncated) {
|
|
718
|
-
this.#
|
|
751
|
+
this.#pendingAtStop = this.#pendingClosed;
|
|
719
752
|
this.#pendingClosed = null;
|
|
720
753
|
}
|
|
721
754
|
this.#stopping = true;
|
|
@@ -845,6 +878,11 @@ export class EncodeRun {
|
|
|
845
878
|
return;
|
|
846
879
|
}
|
|
847
880
|
if (this.#stopping) {
|
|
881
|
+
if (this.#pendingAtStop !== null) {
|
|
882
|
+
const pending = this.#pendingAtStop;
|
|
883
|
+
this.#pendingAtStop = null;
|
|
884
|
+
this.#publishClosed(pending, null, { quiet: true });
|
|
885
|
+
}
|
|
848
886
|
this.#finish(ENCODE_EXIT.STOPPED, this.#stopReason, code, signal);
|
|
849
887
|
return;
|
|
850
888
|
}
|
|
@@ -12,13 +12,13 @@ import { spawn } from "node:child_process";
|
|
|
12
12
|
import { contentionPenalty } from "./contention.js";
|
|
13
13
|
import { ENCODE_RUN_STATE, liveRunsOf, runStateOf, wireState } from "./encode-run-state.js";
|
|
14
14
|
import { ENCODE_EXIT } from "./encode-exit.js";
|
|
15
|
-
import { EncodeRun } from "./EncodeRun.js";
|
|
15
|
+
import { EncodeRun, endOfRun } from "./EncodeRun.js";
|
|
16
16
|
import { computeOutputDimensions } from "./args.js";
|
|
17
17
|
import { buildAdmittedCommand } from "./admitted-command.js";
|
|
18
18
|
import { buildOriginalCommand } from "./source-command.js";
|
|
19
19
|
import { writeAdmittedInput } from "./AdmittedInput.js";
|
|
20
|
-
import { cutOf, cutShiftOf, judgeNeighbors, judgePiece,
|
|
21
|
-
import { presentationSegment } from "./segment-formats/presentation-segment.js";
|
|
20
|
+
import { cutOf, cutShiftOf, judgeNeighbors, judgePiece, leadingSecondsOf, lacksOnlyItsLeadingPictures } from "./piece-completeness.js";
|
|
21
|
+
import { leadingPictures, presentationSegment } from "./segment-formats/presentation-segment.js";
|
|
22
22
|
import { InputFailures, failedAdmittedInput } from "./InputFailures.js";
|
|
23
23
|
import { readProcessCpuSeconds } from "./host-load.js";
|
|
24
24
|
|
|
@@ -174,6 +174,18 @@ export class EncodeRuns {
|
|
|
174
174
|
|
|
175
175
|
#lastInputToken = 0;
|
|
176
176
|
#inputFailures = new InputFailures();
|
|
177
|
+
/**
|
|
178
|
+
* The leading pictures of a keyframe as a run that had the group before it
|
|
179
|
+
* in hand made them, by output and the number of the piece that keyframe
|
|
180
|
+
* opens: taken from the file a run closes past its last cut, and given to
|
|
181
|
+
* the run that starts at that keyframe, which cannot make them
|
|
182
|
+
* (torrent-tv/meta#178). Null where that file said the keyframe has none,
|
|
183
|
+
* which is as much an answer; an entry goes when that piece is published or
|
|
184
|
+
* the output is gone.
|
|
185
|
+
*
|
|
186
|
+
* @type {Map<string, Map<number, Buffer | null>>}
|
|
187
|
+
*/
|
|
188
|
+
#leadingByOutput = new Map();
|
|
177
189
|
/** What this reads and asks of the rest of the proxy, and nothing else. @type {object} */
|
|
178
190
|
#host;
|
|
179
191
|
|
|
@@ -371,18 +383,16 @@ export class EncodeRuns {
|
|
|
371
383
|
* @param {Buffer | null} [bytes]
|
|
372
384
|
* @param {EncodeRun | null} [run] - The run that closed it, which keeps where its muxer counts cuts from.
|
|
373
385
|
* @param {{ endMicros: bigint } | null} [timing] - The piece's end in the muxer's clock, from its segment list.
|
|
386
|
+
* @param {string | null} [following] - The working name of the file the muxer closed after it.
|
|
374
387
|
* @returns {object | null | false}
|
|
375
388
|
*/
|
|
376
|
-
#wholeClosedPiece(session, name, index, admitted = false, bytes = null, run = null, timing = null) {
|
|
389
|
+
#wholeClosedPiece(session, name, index, admitted = false, bytes = null, run = null, timing = null, following = null) {
|
|
377
390
|
|
|
378
391
|
const format = session.segmentFormat;
|
|
379
392
|
if (!format?.readMediaRanges || !Number.isInteger(index) || index < 0) {
|
|
380
393
|
return null;
|
|
381
394
|
}
|
|
382
395
|
const address = session.outputKey ?? "";
|
|
383
|
-
// `bytes` is the piece the proxy cut again by presentation time at the
|
|
384
|
-
// published grid; without it the piece is as the muxer cut it.
|
|
385
|
-
const cutByProxy = bytes !== null;
|
|
386
396
|
try {
|
|
387
397
|
const mediaRanges = format.readMediaRanges(bytes ?? this.#host.segmentFiles.closedBytesOf(address, name));
|
|
388
398
|
|
|
@@ -394,32 +404,35 @@ export class EncodeRuns {
|
|
|
394
404
|
.map(input => [input.track.type === "video" ? "vide" : "soun", input.sourceEndSeconds])) } : {}),
|
|
395
405
|
requiredKinds: [session.spec.carries !== "audio-only" ? "vide" : null,
|
|
396
406
|
session.spec.audio && !this.#host.servesAudioSeparately(session) ? "soun" : null].filter(Boolean) } : undefined;
|
|
407
|
+
// THE NEXT KEYFRAME'S LEADING PICTURES end this piece before its cut. The
|
|
408
|
+
// muxer cut where that keyframe is, and the file it closed next begins
|
|
409
|
+
// with it: its pictures shown before it are the span (torrent-tv/meta#178).
|
|
410
|
+
if (mediaRanges && following && session.spec.carries !== "audio-only") {
|
|
411
|
+
mediaRanges.nextLeadingSeconds = leadingSecondsOf(format.readMediaRanges(this.#host.segmentFiles.closedBytesOf(address, following)));
|
|
412
|
+
}
|
|
397
413
|
if (run) {
|
|
398
414
|
// Where the muxer counts this run's cuts from is known once its first
|
|
399
415
|
// piece has closed, and holds for every piece after it. It is a fact
|
|
400
416
|
// of each piece the run makes, so it travels with the piece.
|
|
401
417
|
if (run.cutShiftSeconds === null && index === run.from) {
|
|
402
|
-
|
|
403
|
-
// the proxy starts where the grid says, whatever the muxer did.
|
|
404
|
-
const asMuxed = cutByProxy ? format.readMediaRanges(this.#host.segmentFiles.closedBytesOf(address, name)) : mediaRanges;
|
|
405
|
-
run.cutShiftSeconds = cutShiftOf(asMuxed, grid?.[run.from], session.spec.carries !== "audio-only" ? "vide" : "soun",
|
|
418
|
+
run.cutShiftSeconds = cutShiftOf(mediaRanges, grid?.[run.from], session.spec.carries !== "audio-only" ? "vide" : "soun",
|
|
406
419
|
timing?.endMicros ?? null);
|
|
407
420
|
// Said once per run: a piece judged on the wrong clock is refused by
|
|
408
421
|
// a millisecond, and nothing else in the log says which clock it was
|
|
409
422
|
// judged on (torrent-tv/meta#178).
|
|
410
423
|
this.#host.logger.info(`encode-run #${run.from}..#${run.to} on ${address}: its muxer counts cuts ` +
|
|
411
424
|
(run.cutShiftSeconds === null ? "from a first packet more than a frame away from its start (shift unknown)"
|
|
412
|
-
: `${run.cutShiftSeconds.toFixed(6)}s from its start at ${grid?.[run.from]}s`)
|
|
413
|
-
(cutByProxy ? "; its pieces are cut again by the proxy at the published grid, so the shift does not apply to them" : ""));
|
|
425
|
+
: `${run.cutShiftSeconds.toFixed(6)}s from its start at ${grid?.[run.from]}s`));
|
|
414
426
|
}
|
|
415
|
-
|
|
427
|
+
if (mediaRanges && run.cutShiftSeconds !== null) mediaRanges.cutShiftSeconds = run.cutShiftSeconds;
|
|
416
428
|
}
|
|
417
429
|
const { whole, throughSeconds, reason } = judgePiece(format, mediaRanges, cut, interval);
|
|
418
430
|
if (!whole) {
|
|
419
431
|
this.#host.logger.warn(
|
|
420
432
|
`encode: not publishing piece ${index} of ${address.slice(0, 60)}: ` +
|
|
421
433
|
(reason ?? (throughSeconds === null ? "it holds no playable media" : `produced through ${throughSeconds}s, cut ${cut}s`)) +
|
|
422
|
-
`
|
|
434
|
+
` shift=${Number.isFinite(mediaRanges?.cutShiftSeconds) ? mediaRanges.cutShiftSeconds : "none"}` +
|
|
435
|
+
` nextLeading=${mediaRanges?.nextLeadingSeconds ?? "none"}` +
|
|
423
436
|
(interval ? ` expected=${interval.from}..${interval.to} tracks=${JSON.stringify(mediaRanges.tracks.map(track => ({
|
|
424
437
|
kind: track.kind, timescale: track.timescale, frame: track.productionFrame,
|
|
425
438
|
first: track.ranges[0], last: track.ranges.at(-1), count: track.ranges.length })),
|
|
@@ -434,7 +447,18 @@ export class EncodeRuns {
|
|
|
434
447
|
const ranges = this.#host.segmentFiles.mediaRangesOf(address, neighbor,
|
|
435
448
|
{ startSeconds: grid[neighbor] });
|
|
436
449
|
if (!ranges) continue;
|
|
437
|
-
const
|
|
450
|
+
const [left, right] = neighbor < index ? [ranges, mediaRanges] : [mediaRanges, ranges];
|
|
451
|
+
const judged = judgeNeighbors(left, right);
|
|
452
|
+
// The one discontinuity that is known and not a fault of either
|
|
453
|
+
// piece: the right one's leading pictures, which a run starting at
|
|
454
|
+
// its keyframe cannot make and no run before it left. The gap is
|
|
455
|
+
// exactly the span the left one states it ends short by, and the
|
|
456
|
+
// right one begins at its keyframe.
|
|
457
|
+
if (!judged.whole && judged.reason?.startsWith("neighbor-discontinuity") && lacksOnlyItsLeadingPictures(left, right)) {
|
|
458
|
+
this.#host.logger.warn(`encode: publishing piece ${index} of ${address} beside a piece without its leading ` +
|
|
459
|
+
`pictures (${left.nextLeadingSeconds}s): the picture stands that long at #${Math.max(index, neighbor)}`);
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
438
462
|
if (!judged.whole) {
|
|
439
463
|
this.#host.logger.warn(`encode: not publishing piece ${index} of ${address}: ${judged.reason}`);
|
|
440
464
|
return false;
|
|
@@ -450,30 +474,25 @@ export class EncodeRuns {
|
|
|
450
474
|
}
|
|
451
475
|
|
|
452
476
|
/**
|
|
453
|
-
* The media intervals of a stored piece of this output
|
|
454
|
-
*
|
|
455
|
-
*
|
|
477
|
+
* The media intervals of a stored piece of this output, or undefined.
|
|
478
|
+
*
|
|
479
|
+
* A piece is stored under its served name only once it was judged whole,
|
|
480
|
+
* when it was published — by this process, or by the one before it, whose
|
|
481
|
+
* pieces are adopted on that ground (`SegmentStore.adoptWhatSurvived`). So
|
|
482
|
+
* this only reads. It used to judge every piece again on every progress
|
|
483
|
+
* report and delete what it found short: a second place for one verdict,
|
|
484
|
+
* with a read that removed files, and a rule that could not know a piece
|
|
485
|
+
* ends before its cut by the next keyframe's leading pictures
|
|
486
|
+
* (torrent-tv/meta#178).
|
|
456
487
|
*
|
|
457
488
|
* @param {HlsSession} output
|
|
458
489
|
* @param {number} index
|
|
459
490
|
* @returns {object | undefined}
|
|
460
491
|
*/
|
|
461
492
|
finishedMediaRangesOf(output, index) {
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
const ranges = this.#host.segmentFiles.mediaRangesOf(address, index, {
|
|
465
|
-
startSeconds: timeline.publishedStartOf(index)
|
|
493
|
+
return this.#host.segmentFiles.mediaRangesOf(output.outputKey ?? "", index, {
|
|
494
|
+
startSeconds: output.timeline.publishedStartOf(index)
|
|
466
495
|
});
|
|
467
|
-
if (!ranges || !output.segmentFormat?.producedThroughSeconds) {
|
|
468
|
-
return ranges;
|
|
469
|
-
}
|
|
470
|
-
const cut = cutOf(timeline, index);
|
|
471
|
-
const { whole, throughSeconds } = judgePiece(output.segmentFormat, ranges, cut);
|
|
472
|
-
if (whole || throughSeconds === null) {
|
|
473
|
-
return ranges;
|
|
474
|
-
}
|
|
475
|
-
this.#host.segmentFiles.remove(address, index, `produced through ${throughSeconds}s, short of its cut at ${cut}s`);
|
|
476
|
-
return undefined;
|
|
477
496
|
}
|
|
478
497
|
|
|
479
498
|
progressOf(output, index = null) {
|
|
@@ -598,10 +617,49 @@ export class EncodeRuns {
|
|
|
598
617
|
return byOutput;
|
|
599
618
|
}
|
|
600
619
|
|
|
620
|
+
/** Whether a run copies the picture from the original file it reads. */
|
|
621
|
+
#copiesOriginalPicture(session, admittedInput) {
|
|
622
|
+
return Boolean(admittedInput?.original) && !session.spec.transcodesVideo && session.spec.carries !== "audio-only";
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Whether a run placed at `startIndex` reads from the keyframe before it:
|
|
627
|
+
* a copied picture read from its original file, the piece before made or
|
|
628
|
+
* being made — so a viewer goes on from it into this one — and nothing
|
|
629
|
+
* known of the leading pictures of the keyframe at the start.
|
|
630
|
+
*/
|
|
631
|
+
#startsAtKeyframeBefore(session, startIndex) {
|
|
632
|
+
if (!(startIndex > 0) || session.spec.transcodesVideo || session.spec.carries === "audio-only" ||
|
|
633
|
+
session.segmentFormat?.id !== "fmp4" || this.#host.encodeInputs?.readsOriginal?.(session) !== true) return false;
|
|
634
|
+
if (this.#leadingByOutput.get(session.outputKey ?? "")?.has(startIndex)) return false;
|
|
635
|
+
const before = startIndex - 1;
|
|
636
|
+
return this.#host.producedNumbers(session).includes(before) ||
|
|
637
|
+
this.liveRunsOf(session).some(run => run.from <= before && endOfRun(run) >= before);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Keep what a run's unpublished file says of the keyframe it begins with:
|
|
642
|
+
* its leading pictures, or that it has none. Only for a piece not made yet.
|
|
643
|
+
*/
|
|
644
|
+
#keepLeadingPictures(session, admittedInput, name) {
|
|
645
|
+
if (!this.#copiesOriginalPicture(session, admittedInput) || session.segmentFormat?.id !== "fmp4") return;
|
|
646
|
+
const address = session.outputKey ?? "";
|
|
647
|
+
const index = session.segmentFormat.segmentIndexFromName(session.segmentFormat.servedNameOf?.(name) ?? name);
|
|
648
|
+
if (!Number.isInteger(index) || this.#host.producedNumbers(session).includes(index)) return;
|
|
649
|
+
try {
|
|
650
|
+
const byIndex = this.#leadingByOutput.get(address) ?? new Map();
|
|
651
|
+
byIndex.set(index, leadingPictures(this.#host.segmentFiles.closedBytesOf(address, name)));
|
|
652
|
+
this.#leadingByOutput.set(address, byIndex);
|
|
653
|
+
} catch (error) {
|
|
654
|
+
this.#host.logger.warn(`encode: could not read the leading pictures of ${name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
601
658
|
forgetEncodingOfGone(output) {
|
|
602
659
|
this.#host.encodeInputs?.forget(output);
|
|
603
660
|
if (this.#host.outputs.outputsOn(output.outputKey).length === 0) {
|
|
604
661
|
this.#inputFailures.forget(output.outputKey);
|
|
662
|
+
this.#leadingByOutput.delete(output.outputKey);
|
|
605
663
|
this.#host.encodeOrchestrator.forgetOpens?.(output.outputKey);
|
|
606
664
|
this.#host.encodeOrchestrator.forgetOutput(output.outputKey);
|
|
607
665
|
}
|
|
@@ -821,10 +879,17 @@ export class EncodeRuns {
|
|
|
821
879
|
if (!this.isLive(session)) {
|
|
822
880
|
return null;
|
|
823
881
|
}
|
|
882
|
+
// A COPIED PICTURE STARTED AT ITS KEYFRAME cannot make that keyframe's
|
|
883
|
+
// leading pictures: they reference the group before. Where the piece
|
|
884
|
+
// before is made and nothing is known of this keyframe's leading pictures,
|
|
885
|
+
// the run starts one keyframe earlier and publishes from its start, so the
|
|
886
|
+
// group before is in hand (torrent-tv/meta#178).
|
|
887
|
+
const fromKeyframeBefore = this.#startsAtKeyframeBefore(session, startIndex);
|
|
888
|
+
const inputStart = fromKeyframeBefore ? startIndex - 1 : startIndex;
|
|
824
889
|
// A copy held for a start that no live run ends before and that is not
|
|
825
890
|
// being started now goes back (torrent-tv/meta#184).
|
|
826
891
|
this.#host.encodeInputs?.retain(session, this.#host.encodeOrchestrator.wantedSegmentsOn(session.outputKey),
|
|
827
|
-
new Set([
|
|
892
|
+
new Set([inputStart, ...this.liveRunsOf(session).map((run) => run.to + 1)]));
|
|
828
893
|
// THE LAST NUMBER THIS RUN MAY REACH is the plan's; how far its input
|
|
829
894
|
// really reaches is the input's answer. An original input is one copied
|
|
830
895
|
// stretch, and the run is given exactly that stretch (torrent-tv/meta#158).
|
|
@@ -834,7 +899,7 @@ export class EncodeRuns {
|
|
|
834
899
|
const mayReach = this.#inputFailures.boundFor(session.outputKey, startIndex,
|
|
835
900
|
Number.isInteger(ordered?.to) && ordered.to >= startIndex ? ordered.to
|
|
836
901
|
: segmentCount > startIndex ? segmentCount - 1 : startIndex);
|
|
837
|
-
const admittedInput = this.#host.encodeInputs?.take(session,
|
|
902
|
+
const admittedInput = this.#host.encodeInputs?.take(session, inputStart, mayReach) ?? null;
|
|
838
903
|
if (!admittedInput) return null;
|
|
839
904
|
const inputEnd = admittedInput.original ? admittedInput.to : startIndex;
|
|
840
905
|
// The stretch is part of an original input's identity: two stretches from
|
|
@@ -929,7 +994,7 @@ export class EncodeRuns {
|
|
|
929
994
|
const inputToken = this.#lastInputToken;
|
|
930
995
|
if (admittedInput) admittedInput.runTag = `${startIndex}r${inputToken}`;
|
|
931
996
|
const buildCommand = admittedInput.original ? buildOriginalCommand : buildAdmittedCommand;
|
|
932
|
-
const
|
|
997
|
+
const built = buildCommand({
|
|
933
998
|
admittedInput,
|
|
934
999
|
inputToken,
|
|
935
1000
|
baseUrl: this.#host.localBaseUrl,
|
|
@@ -944,12 +1009,18 @@ export class EncodeRuns {
|
|
|
944
1009
|
audioSeparate: this.#host.servesAudioSeparately(session),
|
|
945
1010
|
audioSourceTrackIndex: session.spec.audioSourceTrackIndex,
|
|
946
1011
|
rateControl: session.spec.video?.encode?.rateControl ?? null,
|
|
947
|
-
startIndex,
|
|
1012
|
+
startIndex: inputStart,
|
|
948
1013
|
endIndex: admittedInput.original ? inputEnd
|
|
949
1014
|
: Number.isInteger(ordered?.to) ? ordered.to : runEnd >= startIndex ? runEnd : session.timeline.segmentCount - 1,
|
|
950
1015
|
videoEncoder: this.#host.videoEncoder,
|
|
951
1016
|
segmentDurationSec: this.#host.segmentDurationSec
|
|
952
1017
|
});
|
|
1018
|
+
const { args, cutTimes } = built;
|
|
1019
|
+
// The piece before the start, made only to have its group in hand, is
|
|
1020
|
+
// closed under a number below the run's and never published; the run
|
|
1021
|
+
// itself begins where it publishes.
|
|
1022
|
+
const safeIndex = fromKeyframeBefore ? startIndex : built.safeIndex;
|
|
1023
|
+
const startSeconds = fromKeyframeBefore ? session.timeline.publishedStartOf(startIndex) : built.startSeconds;
|
|
953
1024
|
|
|
954
1025
|
// The exact command, every run. An encode failure is otherwise reported
|
|
955
1026
|
// with ffmpeg's message and nothing about what it was asked to do, and the
|
|
@@ -1020,27 +1091,39 @@ export class EncodeRuns {
|
|
|
1020
1091
|
// complete input or overwrite a neighboring run's segment.
|
|
1021
1092
|
if (Number.isInteger(index) && (index < safeIndex || index > run.to)) return null;
|
|
1022
1093
|
let bytes = null;
|
|
1023
|
-
|
|
1024
|
-
|
|
1094
|
+
const address = session.outputKey ?? "";
|
|
1095
|
+
const fmp4 = admittedInput && session.segmentFormat.id === "fmp4" && session.spec.carries !== "audio-only";
|
|
1096
|
+
// The first piece of a run that read the picture from its keyframe:
|
|
1097
|
+
// its leading pictures are the ones the run before made, or none.
|
|
1098
|
+
const firstOfRun = fmp4 && this.#copiesOriginalPicture(session, admittedInput) &&
|
|
1099
|
+
index === run.from && index > 0 && !fromKeyframeBefore;
|
|
1100
|
+
const soundInPiece = fmp4 && session.spec.audio && !this.#host.servesAudioSeparately(session);
|
|
1101
|
+
if (firstOfRun || soundInPiece) {
|
|
1025
1102
|
try {
|
|
1026
|
-
const address = session.outputKey ?? "";
|
|
1027
1103
|
const grid = session.timeline.published ?? session.timeline.boundaries;
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1104
|
+
const raw = this.#host.segmentFiles.closedBytesOf(address, name);
|
|
1105
|
+
const leading = firstOfRun ? this.#leadingByOutput.get(address)?.get(index) ?? null : null;
|
|
1106
|
+
bytes = presentationSegment(raw, soundInPiece && following ? this.#host.segmentFiles.closedBytesOf(address, following) : null,
|
|
1107
|
+
{ from: grid[index], to: grid[index + 1] }, { firstOfRun, leading });
|
|
1031
1108
|
} catch (error) {
|
|
1032
|
-
this.#host.logger.warn(`encode: could not
|
|
1109
|
+
this.#host.logger.warn(`encode: could not prepare closed segment ${index}: ${error.message}`);
|
|
1033
1110
|
throw error;
|
|
1034
1111
|
}
|
|
1035
1112
|
}
|
|
1036
|
-
const mediaRanges = this.#wholeClosedPiece(session, name, index, admittedInput, bytes, run, timing);
|
|
1113
|
+
const mediaRanges = this.#wholeClosedPiece(session, name, index, admittedInput, bytes, run, timing, following);
|
|
1037
1114
|
if (mediaRanges === false) throw Object.assign(new Error(`Closed piece #${index} has incomplete media.`),
|
|
1038
1115
|
{ code: "ERR_INCOMPLETE_MEDIA" });
|
|
1039
|
-
const published = this.#host.segmentFiles.publish(
|
|
1116
|
+
const published = this.#host.segmentFiles.publish(address, name, session.segmentFormat, { mediaRanges, bytes });
|
|
1040
1117
|
if (!published) throw new Error(`Closed piece #${index} could not be stored.`);
|
|
1118
|
+
this.#leadingByOutput.get(address)?.delete(index);
|
|
1041
1119
|
return published;
|
|
1042
1120
|
|
|
1043
1121
|
},
|
|
1122
|
+
// A file closed past the run's last cut, or flushed when it was stopped:
|
|
1123
|
+
// never published, but it begins with the next keyframe, made with the
|
|
1124
|
+
// group before it in hand — the leading pictures the run that starts
|
|
1125
|
+
// there cannot make.
|
|
1126
|
+
onTail: (name) => this.#keepLeadingPictures(session, admittedInput, name),
|
|
1044
1127
|
onSpeedMeasured: () => this.#host.noteRunSpeedMeasured?.(session, run),
|
|
1045
1128
|
onEnded: (ended) => {
|
|
1046
1129
|
this.#runsByInputToken.delete(inputToken);
|
|
@@ -53,14 +53,15 @@ export function judgePiece(format, ranges, cutSeconds, interval) {
|
|
|
53
53
|
}
|
|
54
54
|
const shift = Number.isFinite(ranges?.cutShiftSeconds) ? ranges.cutShiftSeconds : 0;
|
|
55
55
|
if (interval) {
|
|
56
|
-
const reason = intervalFailure(ranges, { ...interval, shift });
|
|
56
|
+
const reason = intervalFailure(ranges, { ...interval, shift, nextLeadingSeconds: leadingOfNext(ranges) });
|
|
57
57
|
return reason ? { whole: false, throughSeconds, reason } : { whole: true, throughSeconds };
|
|
58
58
|
}
|
|
59
59
|
const cutMicros = Number.isFinite(cutSeconds) ? BigInt(Math.round((cutSeconds + shift) * 1_000_000)) : null;
|
|
60
60
|
if (cutMicros !== null && ranges?.tracks?.length) {
|
|
61
|
-
const
|
|
61
|
+
const leadMicros = BigInt(Math.round(leadingOfNext(ranges) * 1_000_000));
|
|
62
|
+
const whole = ranges.tracks.every(({ kind, timescale, ranges: held, positionErrorTicks = 0n }) => {
|
|
62
63
|
const end = held.at(-1)?.end;
|
|
63
|
-
return end !== undefined && (end + positionErrorTicks) * 1_000_000n >= cutMicros * timescale;
|
|
64
|
+
return end !== undefined && (end + positionErrorTicks) * 1_000_000n >= (cutMicros - (kind === "vide" ? leadMicros : 0n)) * timescale;
|
|
64
65
|
});
|
|
65
66
|
return { whole, throughSeconds };
|
|
66
67
|
}
|
|
@@ -71,26 +72,51 @@ export function judgePiece(format, ranges, cutSeconds, interval) {
|
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
/**
|
|
74
|
-
*
|
|
75
|
+
* How long a picture's leading pictures are shown before it: the span from the
|
|
76
|
+
* first presented picture of a piece to its first decoded one, which for a
|
|
77
|
+
* copied picture is its keyframe. Zero without a picture or leading pictures.
|
|
75
78
|
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* HEVC piece cut again at 2511.676..2521.686 holds the next keyframe, which its
|
|
82
|
-
* leading pictures need, and so ends one frame (672 of 16 000 ticks) past the
|
|
83
|
-
* cut, as it must; moved by the muxer's shift of −0.001 s the allowance ended
|
|
84
|
-
* one millisecond before that frame, the piece was refused and playback ended.
|
|
79
|
+
* In an open GOP the pictures decoded right after a keyframe can be shown
|
|
80
|
+
* before it. They belong to the piece of their keyframe, so the piece before
|
|
81
|
+
* ends that much before its cut and this piece begins that much before it
|
|
82
|
+
* (torrent-tv/meta#178). A piece states the next keyframe's span as
|
|
83
|
+
* `nextLeadingSeconds`, read from the piece the muxer closed after it.
|
|
85
84
|
*
|
|
86
|
-
* @param {object | null | undefined} ranges -
|
|
87
|
-
* @
|
|
88
|
-
* @param {boolean} cutByProxy - Whether the proxy cut the piece again.
|
|
89
|
-
* @returns {object | null | undefined} The same intervals.
|
|
85
|
+
* @param {object | null | undefined} ranges - A piece's media intervals.
|
|
86
|
+
* @returns {number}
|
|
90
87
|
*/
|
|
91
|
-
export function
|
|
92
|
-
|
|
93
|
-
|
|
88
|
+
export function leadingSecondsOf(ranges) {
|
|
89
|
+
const track = ranges?.tracks?.find(candidate => candidate.kind === "vide");
|
|
90
|
+
const first = track?.ranges?.[0];
|
|
91
|
+
if (!first || track.firstSampleStart === undefined || !(track.timescale > 0n)) return 0;
|
|
92
|
+
const lead = track.firstSampleStart - first.start;
|
|
93
|
+
return lead > 0n ? Number(lead) / Number(track.timescale) : 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Whether the only gap between two adjacent pieces is the right one's leading
|
|
98
|
+
* pictures, which it lacks: it begins at its keyframe, and the gap is the span
|
|
99
|
+
* the left one states it ends short by, within a frame. A run that starts at a
|
|
100
|
+
* keyframe cannot make those pictures and none was left by a run before it, so
|
|
101
|
+
* the picture stands that long — known, and no fault of either piece.
|
|
102
|
+
*
|
|
103
|
+
* @param {object} left
|
|
104
|
+
* @param {object} right
|
|
105
|
+
* @returns {boolean}
|
|
106
|
+
*/
|
|
107
|
+
export function lacksOnlyItsLeadingPictures(left, right) {
|
|
108
|
+
const before = left?.tracks?.find(track => track.kind === "vide");
|
|
109
|
+
const after = right?.tracks?.find(track => track.kind === "vide");
|
|
110
|
+
if (!before?.ranges?.length || !after?.ranges?.length || before.timescale !== after.timescale || leadingSecondsOf(right) !== 0) return false;
|
|
111
|
+
const lead = BigInt(Math.round(leadingOfNext(left) * Number(before.timescale)));
|
|
112
|
+
const gap = after.ranges[0].start - before.ranges.at(-1).end;
|
|
113
|
+
const frame = before.productionFrame ?? before.ranges.at(-1).frame;
|
|
114
|
+
return lead > 0n && gap > 0n && gap - lead <= frame && lead - gap <= frame;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The span a piece ends before its cut: the next keyframe's leading pictures. */
|
|
118
|
+
function leadingOfNext(ranges) {
|
|
119
|
+
return Number.isFinite(ranges?.nextLeadingSeconds) && ranges.nextLeadingSeconds > 0 ? ranges.nextLeadingSeconds : 0;
|
|
94
120
|
}
|
|
95
121
|
|
|
96
122
|
/**
|
|
@@ -166,13 +192,14 @@ export function judgeNeighbors(left, right) {
|
|
|
166
192
|
* soundtrack's at 1419.904 s against a final cut of 1419.993 s, and both last
|
|
167
193
|
* pieces were refused, so no viewer could reach the end of the episode.
|
|
168
194
|
*/
|
|
169
|
-
function intervalFailure(coverage, { from, to, requiredKinds, sourceEnds = {}, shift = 0, endsWithSource = false }) {
|
|
195
|
+
function intervalFailure(coverage, { from, to, requiredKinds, sourceEnds = {}, shift = 0, endsWithSource = false, nextLeadingSeconds = 0 }) {
|
|
170
196
|
if (!Number.isFinite(from) || !Number.isFinite(to) || !(to > from) || !requiredKinds?.length) return "segment-interval-is-not-declared";
|
|
171
197
|
for (const kind of requiredKinds) {
|
|
172
198
|
const track = coverage?.tracks?.find(track => track.kind === kind);
|
|
173
199
|
if (!track?.ranges?.length || !(track.timescale > 0n)) return `segment-missing-${kind}`;
|
|
174
200
|
const start = BigInt(Math.round((from + shift) * Number(track.timescale)));
|
|
175
|
-
const
|
|
201
|
+
const cutAt = to + shift - (kind === "vide" && !endsWithSource ? nextLeadingSeconds : 0);
|
|
202
|
+
const through = Number.isFinite(sourceEnds[kind]) ? Math.min(cutAt, sourceEnds[kind]) : cutAt;
|
|
176
203
|
if (!(through > from + shift)) return `segment-interval-is-empty-${kind}`;
|
|
177
204
|
const end = BigInt(Math.round(through * Number(track.timescale)));
|
|
178
205
|
const first = track.ranges[0];
|
|
@@ -181,7 +208,11 @@ function intervalFailure(coverage, { from, to, requiredKinds, sourceEnds = {}, s
|
|
|
181
208
|
// cadence. Account for that recorded position uncertainty at the boundary,
|
|
182
209
|
// without admitting another missing frame or widening internal gaps.
|
|
183
210
|
const positionError = track.positionErrorTicks ?? 0n;
|
|
184
|
-
|
|
211
|
+
// A piece starts at its first DECODED sample — a copied picture's keyframe;
|
|
212
|
+
// pictures shown before it are its leading pictures, and the walk below
|
|
213
|
+
// holds them to the same continuity as every other range.
|
|
214
|
+
const anchor = track.firstSampleStart ?? first.start;
|
|
215
|
+
if (anchor > start + frame + positionError || anchor < start - frame - positionError) return `segment-start-outside-interval-${kind}`;
|
|
185
216
|
// The first sample's cadence already bounds the interval start. A shorter
|
|
186
217
|
// final sample must not replace that bound when this range is traversed.
|
|
187
218
|
let reached = first.start;
|
|
@@ -89,6 +89,11 @@ export class HostLoad {
|
|
|
89
89
|
/**
|
|
90
90
|
* @param {object} host - `liveRunsOf`, `runStateOf`, the readings of `encode/host-load.js` (`readMachineState`, `readProcessCpuSeconds`, `readProxyCpuSeconds`, `readSystemCpu`, `shareOfMachine`), `getSourceStats`, `getTorrentTotals`, `outputs`
|
|
91
91
|
*/
|
|
92
|
+
/** The share of one core this process draws doing nothing priced; zero until measured. */
|
|
93
|
+
get baseDraw() {
|
|
94
|
+
return this.#observedBaseDraw ?? 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
92
97
|
constructor(host) {
|
|
93
98
|
this.#host = host;
|
|
94
99
|
}
|
|
@@ -1,48 +1,165 @@
|
|
|
1
1
|
import { readTrackEdits, walkBoxes } from "./mp4-boxes.js";
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* A closed piece as it is served.
|
|
5
|
+
*
|
|
6
|
+
* THE PICTURE STAYS CUT WHERE THE MUXER CUT IT: from its keyframe to the next,
|
|
7
|
+
* in decode order. In an open GOP the pictures decoded right after a keyframe
|
|
8
|
+
* can be shown before it — its leading pictures — and they reference the
|
|
9
|
+
* group before; they belong to the piece of their keyframe and are shown a
|
|
10
|
+
* little before its cut. Moving them into the previous piece with a copy of
|
|
11
|
+
* the keyframe (torrent-tv/meta#154) left a hole on every such cut: appending
|
|
12
|
+
* the next piece replaces that copy, and Media Source coded frame processing
|
|
13
|
+
* then removes every frame decoded after it up to the next random access point
|
|
14
|
+
* — the leading pictures. Measured in Chrome Canary 2026-10-11 on the pieces
|
|
15
|
+
* of a 1036p HEVC film: a hole of 0.292 s at 74.283 s; with the muxer's cut the
|
|
16
|
+
* buffer is continuous (torrent-tv/meta#178).
|
|
17
|
+
*
|
|
18
|
+
* A SOUNDTRACK carried in the same piece is cut by presentation time at the
|
|
19
|
+
* published grid, taking from the following piece what belongs before the cut.
|
|
20
|
+
*
|
|
21
|
+
* THE FIRST PIECE OF A RUN that started at its keyframe has leading pictures
|
|
22
|
+
* the run could not make: their references lie before the start, and FFmpeg,
|
|
23
|
+
* having no earlier packets, gives them presentation times at its own decode
|
|
24
|
+
* clock (74.2000 s for pictures shown at 73.991-74.241 s). They are removed,
|
|
25
|
+
* and where `leading` — the same keyframe's leading pictures from the run that
|
|
26
|
+
* made the piece before, which FFmpeg closes past that run's last cut — is
|
|
27
|
+
* given, those are put in their place on that run's decode clock.
|
|
28
|
+
*
|
|
29
|
+
* @param {Buffer} current - The closed piece, with its own init.
|
|
30
|
+
* @param {Buffer | null} following - The piece the muxer closed after it.
|
|
31
|
+
* @param {{ from: number, to: number }} interval - The piece's published stretch.
|
|
32
|
+
* @param {{ firstOfRun?: boolean, leading?: Buffer | null }} [options]
|
|
33
|
+
* @returns {Buffer}
|
|
34
|
+
*/
|
|
35
|
+
export function presentationSegment(current, following, { from, to }, { firstOfRun = false, leading = null } = {}) {
|
|
5
36
|
const own = packetsOf(current);
|
|
6
37
|
const next = following ? packetsOf(following) : null;
|
|
38
|
+
const before = leading ? packetsOf(leading) : null;
|
|
39
|
+
// A piece given the leading pictures of the run before is written on that
|
|
40
|
+
// run's clock, under its init: that run decoded the group before the
|
|
41
|
+
// keyframe, so its decode times lie earlier than this run's, and a decode
|
|
42
|
+
// time cannot lie before the clock's origin. Every track moves by the
|
|
43
|
+
// difference of the two inits' positions.
|
|
44
|
+
const spliced = firstOfRun && before ? splicedPicture(own, before) : null;
|
|
45
|
+
const clock = spliced ? before : own;
|
|
7
46
|
const tracks = [];
|
|
8
47
|
for (const [id, track] of own.tracks) {
|
|
9
48
|
const edit = own.edits.get(id);
|
|
10
49
|
if (!edit) throw new Error("A segment track has no declared clock.");
|
|
11
|
-
const
|
|
50
|
+
const move = spliced ? edit.offset - clock.edits.get(id).offset : 0n;
|
|
51
|
+
if (track.kind === "vide") {
|
|
52
|
+
const samples = spliced?.id === id ? spliced.samples : firstOfRun ? withoutLeading(track.samples, edit) : track.samples;
|
|
53
|
+
tracks.push({ id, samples: spliced?.id === id ? samples : samples.map(sample => ({ ...sample, dts: sample.dts + move })),
|
|
54
|
+
timedByDecode: firstOfRun });
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const samples = [...track.samples].map(sample => ({ ...sample, dts: sample.dts + move }));
|
|
12
58
|
if (next?.tracks.has(id)) {
|
|
13
59
|
const nextEdit = next.edits.get(id);
|
|
14
60
|
if (!nextEdit || nextEdit.timescale !== edit.timescale) throw new Error("Adjacent segment clocks differ.");
|
|
15
61
|
for (const sample of next.tracks.get(id).samples) {
|
|
16
|
-
samples.push({ ...sample, dts: sample.dts + nextEdit.offset - edit.offset });
|
|
62
|
+
samples.push({ ...sample, dts: sample.dts + nextEdit.offset - edit.offset + move });
|
|
17
63
|
}
|
|
18
64
|
}
|
|
19
65
|
const start = BigInt(Math.round(from * Number(edit.timescale)));
|
|
20
66
|
const end = BigInt(Math.round(to * Number(edit.timescale)));
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
67
|
+
const offset = clock.edits.get(id).offset;
|
|
68
|
+
const selected = samples.filter(sample => {
|
|
69
|
+
const pts = sample.dts + sample.composition + offset;
|
|
70
|
+
return pts < end && pts + sample.duration > start;
|
|
24
71
|
});
|
|
25
|
-
if (!
|
|
26
|
-
// An open-GOP picture before the cut can follow the next CRA in decode
|
|
27
|
-
// order. Retain the reference pictures through its last required DTS;
|
|
28
|
-
// dropping the CRA would make the recovered leading pictures undecodable.
|
|
29
|
-
const lastDecode = wanted.reduce((last, sample) => sample.dts > last ? sample.dts : last, -1n);
|
|
30
|
-
const firstDecode = wanted.reduce((first, sample) => first === null || sample.dts < first ? sample.dts : first, null);
|
|
31
|
-
const selected = track.kind === "vide" ? samples.filter(sample =>
|
|
32
|
-
sample.dts >= firstDecode && sample.dts <= lastDecode && sample.dts + sample.composition + edit.offset >= start) : wanted;
|
|
72
|
+
if (!selected.length) throw new Error("A presentation segment lacks required media.");
|
|
33
73
|
selected.sort((left, right) => left.dts < right.dts ? -1 : left.dts > right.dts ? 1 : 0);
|
|
34
74
|
tracks.push({ id, samples: selected });
|
|
35
75
|
}
|
|
76
|
+
return writePiece(clock.init, tracks);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The leading pictures of a piece's keyframe, as a piece of their own (the
|
|
81
|
+
* keyframe and the pictures decoded right after it that are shown before it),
|
|
82
|
+
* or null when the keyframe has none. Kept from a run's flushed file past its
|
|
83
|
+
* last cut for the run that starts at that keyframe.
|
|
84
|
+
*
|
|
85
|
+
* @param {Buffer} piece - A closed file, with its own init.
|
|
86
|
+
* @returns {Buffer | null}
|
|
87
|
+
*/
|
|
88
|
+
export function leadingPictures(piece) {
|
|
89
|
+
const own = packetsOf(piece);
|
|
90
|
+
const tracks = [];
|
|
91
|
+
for (const [id, track] of own.tracks) {
|
|
92
|
+
if (track.kind !== "vide") continue;
|
|
93
|
+
const edit = own.edits.get(id);
|
|
94
|
+
const count = leadingCount(track.samples, edit);
|
|
95
|
+
if (count > 0) tracks.push({ id, samples: track.samples.slice(0, count + 1) });
|
|
96
|
+
}
|
|
97
|
+
return tracks.length ? writePiece(own.init, tracks) : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** How many pictures decoded right after the first one are shown before it. */
|
|
101
|
+
function leadingCount(samples, edit) {
|
|
102
|
+
const first = samples[0];
|
|
103
|
+
if (!first || (first.flags & SAMPLE_IS_NON_SYNC) !== 0) return 0;
|
|
104
|
+
const shown = first.dts + first.composition + edit.offset;
|
|
105
|
+
let count = 0;
|
|
106
|
+
while (count + 1 < samples.length) {
|
|
107
|
+
const sample = samples[count + 1];
|
|
108
|
+
if (sample.dts + sample.composition + edit.offset >= shown) break;
|
|
109
|
+
count += 1;
|
|
110
|
+
}
|
|
111
|
+
return count;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A run's first picture samples without the leading pictures it could not make. */
|
|
115
|
+
function withoutLeading(samples, edit) {
|
|
116
|
+
return [samples[0], ...samples.slice(1 + leadingCount(samples, edit))];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The picture of a run's first piece with its own leading pictures replaced by
|
|
121
|
+
* the run before's, on the run before's clock: its keyframe and leading
|
|
122
|
+
* pictures as that run decoded them, then this run's pictures from the first
|
|
123
|
+
* one after them. Null — and the leading pictures are only removed — where the
|
|
124
|
+
* two do not begin with the same keyframe, the clocks differ, any track has no
|
|
125
|
+
* position in the other init, or the decode order would not move forward.
|
|
126
|
+
*/
|
|
127
|
+
function splicedPicture(own, before) {
|
|
128
|
+
for (const [id, track] of own.tracks) {
|
|
129
|
+
if (track.kind !== "vide") continue;
|
|
130
|
+
const edit = own.edits.get(id);
|
|
131
|
+
const theirs = before.tracks.get(id)?.samples;
|
|
132
|
+
const theirEdit = before.edits.get(id);
|
|
133
|
+
if (!edit || !theirs || theirs.length < 2 || !theirEdit || theirEdit.timescale !== edit.timescale) return null;
|
|
134
|
+
if ([...own.tracks.keys()].some(other => !before.edits.has(other))) return null;
|
|
135
|
+
const keyframe = track.samples[0];
|
|
136
|
+
if (keyframe.dts + keyframe.composition + edit.offset !== theirs[0].dts + theirs[0].composition + theirEdit.offset ||
|
|
137
|
+
!keyframe.bytes.equals(theirs[0].bytes)) return null;
|
|
138
|
+
const move = edit.offset - theirEdit.offset;
|
|
139
|
+
const rest = track.samples.slice(1 + leadingCount(track.samples, edit)).map(sample => ({ ...sample, dts: sample.dts + move }));
|
|
140
|
+
if (rest.length && theirs.at(-1).dts >= rest[0].dts) return null;
|
|
141
|
+
return { id, samples: [...theirs, ...rest] };
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** `sample_is_non_sync_sample` in the sample flags of ISO/IEC 14496-12. */
|
|
147
|
+
const SAMPLE_IS_NON_SYNC = 0x10000;
|
|
148
|
+
|
|
149
|
+
function writePiece(init, tracks) {
|
|
36
150
|
const makeMoof = offset => box("moof", full("mfhd", 0, 0, u32(1)), ...tracks.map(track => {
|
|
37
151
|
let decodeTime = track.samples[0].dts;
|
|
38
|
-
const entries = track.samples.map(sample => {
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
152
|
+
const entries = track.samples.map((sample, index) => {
|
|
153
|
+
// A picture taken from another run's decode clock keeps that clock, so
|
|
154
|
+
// its duration in decode order is the step to the next decode time. Any
|
|
155
|
+
// other sample keeps its own duration, and its presentation time is kept
|
|
156
|
+
// relative to the decode clock actually written here.
|
|
157
|
+
const following = track.samples[index + 1];
|
|
158
|
+
const duration = track.timedByDecode && following ? following.dts - sample.dts : sample.duration;
|
|
42
159
|
const composition = sample.dts + sample.composition - decodeTime;
|
|
43
|
-
decodeTime +=
|
|
160
|
+
decodeTime += duration;
|
|
44
161
|
return Buffer.concat([
|
|
45
|
-
u32(Number(
|
|
162
|
+
u32(Number(duration)), u32(sample.bytes.length), u32(sample.flags), i32(Number(composition))
|
|
46
163
|
]);
|
|
47
164
|
});
|
|
48
165
|
const payload = Buffer.concat([u32(track.samples.length), i32(offset), ...entries]);
|
|
@@ -52,7 +169,7 @@ export function presentationSegment(current, following, { from, to }) {
|
|
|
52
169
|
return box("traf", full("tfhd", 0, 0x20000, u32(track.id)), full("tfdt", 1, 0, clock), full("trun", 1, 0xf01, payload));
|
|
53
170
|
}));
|
|
54
171
|
const size = makeMoof(0).length;
|
|
55
|
-
return Buffer.concat([
|
|
172
|
+
return Buffer.concat([init, makeMoof(size + 8), box("mdat", ...tracks.flatMap(track => track.samples.map(sample => sample.bytes)))]);
|
|
56
173
|
}
|
|
57
174
|
|
|
58
175
|
function packetsOf(raw) {
|
|
@@ -97,6 +97,9 @@ import path from "node:path";
|
|
|
97
97
|
import { contentOf, LocalObservations, PROXY_ROOT } from "../encode/LocalObservations.js";
|
|
98
98
|
import { configurationKeyOf } from "../encode/fingerprint.js";
|
|
99
99
|
import { qualityStateOf } from "../encode/quality/OutputQualityState.js";
|
|
100
|
+
import os from "node:os";
|
|
101
|
+
import { downloadShare } from "../storage/processor-share.js";
|
|
102
|
+
import { ENCODE_RUN_STATE, processCanBeSignalled } from "../encode/encode-run-state.js";
|
|
100
103
|
|
|
101
104
|
// Where a variant and an audio rendition live under a session — `v/<height>/…`
|
|
102
105
|
// and `a/<track>/…` — is stated in `encode/output/playlists.js`, beside the lines that
|
|
@@ -193,6 +196,7 @@ export function wireOutputs({
|
|
|
193
196
|
decodeCostModel = null,
|
|
194
197
|
getSourceStats = null,
|
|
195
198
|
setPriorityMap = null,
|
|
199
|
+
setDownloadRate = null,
|
|
196
200
|
// What a second job costs on this host, measured at startup. Null when it
|
|
197
201
|
// could not be measured, and then nothing is corrected — the alternative
|
|
198
202
|
// is inventing a penalty, which is the same fault as inventing a fill rate.
|
|
@@ -342,7 +346,7 @@ export function wireOutputs({
|
|
|
342
346
|
get encoders() { return parts.encoders; },
|
|
343
347
|
invalidateWaits: (output) => parts.serving.invalidateWaits(output),
|
|
344
348
|
productionFailed: (output) => parts.renditions.noteProductionFailed(output),
|
|
345
|
-
encodersChanged: () => onEncodersChanged?.(),
|
|
349
|
+
encodersChanged: () => { parts.applyDownloadShare?.(); onEncodersChanged?.(); },
|
|
346
350
|
});
|
|
347
351
|
// How much film is ready in front of the viewers, said; and the spare soundtracks fetched once it is full.
|
|
348
352
|
parts.cushion = new CushionReport({
|
|
@@ -648,6 +652,31 @@ export function wireOutputs({
|
|
|
648
652
|
// (sourceKey, fileIndex) => Promise<{ downloadSpeed, fileLength, fileProgress } | null>.
|
|
649
653
|
parts.getSourceStats = typeof getSourceStats === "function" ? getSourceStats : null;
|
|
650
654
|
parts.setPriorityMap = typeof setPriorityMap === "function" ? setPriorityMap : null;
|
|
655
|
+
parts.setDownloadRate = typeof setDownloadRate === "function" ? setDownloadRate : null;
|
|
656
|
+
// HOW FAST THE TORRENTS MAY DOWNLOAD: the processor the encoders leave
|
|
657
|
+
// (`storage/processor-share.js`, torrent-tv/meta#178). Worked out again
|
|
658
|
+
// when the machine is read and when an encoder starts or ends, over every
|
|
659
|
+
// watched file of every torrent.
|
|
660
|
+
parts.applyDownloadShare = () => {
|
|
661
|
+
if (!parts.setDownloadRate || !parts.hostLoad || !parts.encodeCost) return;
|
|
662
|
+
const files = new Map();
|
|
663
|
+
for (const output of parts.outputs.values()) {
|
|
664
|
+
if (!(parts.viewers?.forOutput(output)?.size > 0)) continue;
|
|
665
|
+
const key = output.file.key;
|
|
666
|
+
const length = Number(output.file.lengthBytes ?? parts.hostLoad.fileLengthByKey.get(key));
|
|
667
|
+
const file = files.get(key) ?? { bytesPerFilmSecond: length / Number(output.file.durationSeconds), encodeCostSec: 0 };
|
|
668
|
+
const state = parts.encodeRuns.runStateOf(output);
|
|
669
|
+
if (processCanBeSignalled(state) && state !== ENCODE_RUN_STATE.SUSPENDED) {
|
|
670
|
+
file.encodeCostSec += parts.encodeCost.loadOfOutput(output.outputKey)?.costSec ?? 0;
|
|
671
|
+
}
|
|
672
|
+
files.set(key, file);
|
|
673
|
+
}
|
|
674
|
+
const watched = [...files.values()].filter((file) => Number.isFinite(file.bytesPerFilmSecond) && file.bytesPerFilmSecond > 0);
|
|
675
|
+
const share = downloadShare({ cores: os.cpus().length, availableShare: parts.hostLoad.hostAvailability?.share ?? 1,
|
|
676
|
+
baseDraw: parts.hostLoad.baseDraw, costPerMegabyte: parts.hostLoad.observedTorrentCostPerMegabyte, files: watched });
|
|
677
|
+
void Promise.resolve(parts.setDownloadRate(share.bytesPerSecond, `film speed ${share.filmSpeed.toFixed(2)}x ` +
|
|
678
|
+
`over ${watched.length} file(s), ${share.capacity.toFixed(2)} core(s) for the proxy`)).catch(() => {});
|
|
679
|
+
};
|
|
651
680
|
parts.contentionPenalties = contentionPenalties instanceof Map ? contentionPenalties : null;
|
|
652
681
|
// Seconds of film per second when the picture is COPIED, measured at
|
|
653
682
|
// startup. Nothing else prices that branch: the other benchmarks measure
|
|
@@ -1060,7 +1089,7 @@ export function wireOutputs({
|
|
|
1060
1089
|
liveConsumers: (...args) => parts.renditions.liveConsumers(...args),
|
|
1061
1090
|
liveRunsOf: (...args) => parts.encodeRuns.liveRunsOf(...args),
|
|
1062
1091
|
producedNumbers: (...args) => parts.serving.producedNumbers(...args),
|
|
1063
|
-
reportHostLoad: (...args) => parts.hostLoad.reportHostLoad(...args),
|
|
1092
|
+
reportHostLoad: async (...args) => { await parts.hostLoad.reportHostLoad(...args); parts.applyDownloadShare?.(); },
|
|
1064
1093
|
runStateOf: (...args) => parts.encodeRuns.runStateOf(...args),
|
|
1065
1094
|
sampleDownloadRates: (...args) => parts.hostLoad.sampleDownloadRates(...args),
|
|
1066
1095
|
get encodeCost() { return parts.encodeCost; },
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file How fast the torrents may download, so that downloading does not take
|
|
3
|
+
* the processor the encoders need (torrent-tv/meta#178).
|
|
4
|
+
*
|
|
5
|
+
* Field 2026-10-11, Home Assistant: an AVI re-encoded at 720x400 needs 2.3 s
|
|
6
|
+
* of processor per second of film. While the torrent downloaded 4.5-9.8 MB/s —
|
|
7
|
+
* mostly far ahead of the viewer — its thread took 56-63 % of a core, ffmpeg
|
|
8
|
+
* got 176-182 %, below real time, and the viewer saw the spinner; once the
|
|
9
|
+
* download stopped ffmpeg got 216-261 % and there were no stops.
|
|
10
|
+
*
|
|
11
|
+
* THE MODEL. The processor the proxy has, `C`, is what other processes leave
|
|
12
|
+
* of every core, less what the proxy draws doing nothing priced. Every watched
|
|
13
|
+
* file `f` of every torrent advances at a film speed `s`: its encoders take
|
|
14
|
+
* `s·k_f` (seconds of processor per second of film, as measured for the
|
|
15
|
+
* encoders running now) and its download `s·w_f·c` (`w_f` its bytes per second
|
|
16
|
+
* of film, `c` the measured processor per byte moved). One speed for all is
|
|
17
|
+
* the division under which the slowest file is as fast as it can be:
|
|
18
|
+
*
|
|
19
|
+
* s* = C / Σ_f (k_f + w_f·c), download rate = max(s*, 1) · Σ_f w_f
|
|
20
|
+
*
|
|
21
|
+
* Downloading faster than `s*·w` only piles bytes ahead while the encoders
|
|
22
|
+
* starve; what is fetched first within the rate is the priority map's order.
|
|
23
|
+
* The rate never falls below the films' own byte rate, `Σ w`, because below it
|
|
24
|
+
* the viewer stops whatever the encoders get. Where `s* < 1` or the swarm
|
|
25
|
+
* gives less than the rate, stops cannot be avoided, and the start forecast,
|
|
26
|
+
* reading the speeds measured under this rate, waits for them at the start
|
|
27
|
+
* and after a seek. With nothing encoding `k` is zero and the download has the
|
|
28
|
+
* processor. A term nobody has measured contributes nothing: an unmeasured
|
|
29
|
+
* price of a byte leaves the download unlimited.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {object} params
|
|
34
|
+
* @param {number} params.cores
|
|
35
|
+
* @param {number} params.availableShare - The share of the machine other processes leave, 0..1.
|
|
36
|
+
* @param {number} params.baseDraw - Processor the proxy draws doing nothing priced, in cores.
|
|
37
|
+
* @param {number | null} params.costPerMegabyte - Processor seconds per megabyte the torrent moves.
|
|
38
|
+
* @param {{ bytesPerFilmSecond: number, encodeCostSec: number }[]} params.files - Watched files.
|
|
39
|
+
* @returns {{ filmSpeed: number, bytesPerSecond: number, capacity: number }}
|
|
40
|
+
* `bytesPerSecond` is Infinity where the download is not limited.
|
|
41
|
+
*/
|
|
42
|
+
export function downloadShare({ cores, availableShare, baseDraw, costPerMegabyte, files }) {
|
|
43
|
+
const capacity = Math.max(0, cores * availableShare - baseDraw);
|
|
44
|
+
// A byte whose price is not measured, or costs nothing, takes nothing from
|
|
45
|
+
// the encoders: there is then no share to keep the download to.
|
|
46
|
+
const perByte = Number.isFinite(costPerMegabyte) && costPerMegabyte > 0 ? costPerMegabyte / 1e6 : 0;
|
|
47
|
+
const bytes = files.reduce((sum, file) => sum + file.bytesPerFilmSecond, 0);
|
|
48
|
+
const demand = files.reduce((sum, file) => sum + file.encodeCostSec + file.bytesPerFilmSecond * perByte, 0);
|
|
49
|
+
const filmSpeed = capacity / demand;
|
|
50
|
+
const bytesPerSecond = perByte > 0 ? Math.max(filmSpeed, 1) * bytes : Infinity;
|
|
51
|
+
return { filmSpeed, capacity, bytesPerSecond: Number.isFinite(bytesPerSecond) && bytes > 0 ? bytesPerSecond : Infinity };
|
|
52
|
+
}
|
|
@@ -1261,6 +1261,35 @@ export class TorrentPool {
|
|
|
1261
1261
|
* film against a number, highest first in urgency.
|
|
1262
1262
|
* @param {number} durationSeconds
|
|
1263
1263
|
*/
|
|
1264
|
+
/**
|
|
1265
|
+
* How fast every torrent may download in all: the share of the processor
|
|
1266
|
+
* the encoders leave (`storage/processor-share.js`, torrent-tv/meta#178).
|
|
1267
|
+
* Within it the swarm is asked for what the priority map puts first.
|
|
1268
|
+
*
|
|
1269
|
+
* @param {number | null} bytesPerSecond - Infinity or null for no limit.
|
|
1270
|
+
* @param {string} [because]
|
|
1271
|
+
*/
|
|
1272
|
+
setDownloadRate(bytesPerSecond, because = "") {
|
|
1273
|
+
if (!this.client || this.client.destroyed || typeof this.client.throttleDownload !== "function") return;
|
|
1274
|
+
const rate = Number.isFinite(bytesPerSecond) ? Math.max(1, Math.round(bytesPerSecond)) : -1;
|
|
1275
|
+
if (rate === this.#downloadLimit) return;
|
|
1276
|
+
this.client.throttleDownload(rate);
|
|
1277
|
+
// Said when the limit is set or lifted, and when it moves by a factor of
|
|
1278
|
+
// two either way — the scale on which a limit changes what the swarm gives.
|
|
1279
|
+
const scale = rate < 0 ? null : Math.round(Math.log2(rate));
|
|
1280
|
+
if (scale !== this.#downloadLimitScale) {
|
|
1281
|
+
this.#downloadLimitScale = scale;
|
|
1282
|
+
logger.info(`torrent-pool: download limit -> ${rate < 0 ? "none" : `${Math.round(rate / 1024)} KB/s`}${because ? ` (${because})` : ""}`);
|
|
1283
|
+
}
|
|
1284
|
+
this.#downloadLimit = rate;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/** The download limit in force, -1 for none. */
|
|
1288
|
+
#downloadLimit = -1;
|
|
1289
|
+
|
|
1290
|
+
/** The power of two the last limit said was nearest, null for none. */
|
|
1291
|
+
#downloadLimitScale = null;
|
|
1292
|
+
|
|
1264
1293
|
applyPriorityMap(torrent, fileIndex, zones, durationSeconds) {
|
|
1265
1294
|
// A MAP WITH NOTHING IN IT IS A STATEMENT, and it is answered before
|
|
1266
1295
|
// anything else is looked at. It says nobody wants any of this file, and
|
|
@@ -475,6 +475,17 @@ export class TorrentWorkerClient {
|
|
|
475
475
|
* @param {{ sourceKey: string, fileIndex: number, durationSeconds: number, zones: { from: number, to: number, priority: number }[] }} params
|
|
476
476
|
* @returns {Promise<void>}
|
|
477
477
|
*/
|
|
478
|
+
/**
|
|
479
|
+
* How fast the torrents may download in all (`Command.DOWNLOAD_RATE`).
|
|
480
|
+
*
|
|
481
|
+
* @param {number} bytesPerSecond - Infinity for no limit.
|
|
482
|
+
* @param {string} [because]
|
|
483
|
+
* @returns {Promise<void>}
|
|
484
|
+
*/
|
|
485
|
+
async setDownloadRate(bytesPerSecond, because = "") {
|
|
486
|
+
return this.#caller.call(Command.DOWNLOAD_RATE, { bytesPerSecond: Number.isFinite(bytesPerSecond) ? bytesPerSecond : null, because });
|
|
487
|
+
}
|
|
488
|
+
|
|
478
489
|
async setPriorityMap({ sourceKey, fileIndex, zones, durationSeconds, postedAt }) {
|
|
479
490
|
return this.#caller.call(Command.PRIORITY_MAP, {
|
|
480
491
|
sourceKey,
|
|
@@ -164,6 +164,17 @@ export class WorkerTorrentPool {
|
|
|
164
164
|
* @param {{ sourceKey: string, fileIndex: number, durationSeconds: number, zones: object[] }} params
|
|
165
165
|
* @returns {Promise<void>}
|
|
166
166
|
*/
|
|
167
|
+
/**
|
|
168
|
+
* How fast the torrents may download in all, Infinity for no limit.
|
|
169
|
+
*
|
|
170
|
+
* @param {number} bytesPerSecond
|
|
171
|
+
* @param {string} [because]
|
|
172
|
+
* @returns {Promise<void>}
|
|
173
|
+
*/
|
|
174
|
+
async setDownloadRate(bytesPerSecond, because = "") {
|
|
175
|
+
return this.#client.setDownloadRate(bytesPerSecond, because);
|
|
176
|
+
}
|
|
177
|
+
|
|
167
178
|
async setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones, postedAt }) {
|
|
168
179
|
if (!sourceKey) {
|
|
169
180
|
return;
|
|
@@ -66,6 +66,12 @@ export const Command = {
|
|
|
66
66
|
* sixteen pieces is what tore a film apart on 2026-09-05.
|
|
67
67
|
*/
|
|
68
68
|
PRIORITY_MAP: "priority-map",
|
|
69
|
+
/**
|
|
70
|
+
* How fast the torrents may download in all, in bytes per second, or
|
|
71
|
+
* Infinity for no limit: the share of the processor the encoders leave
|
|
72
|
+
* (`storage/processor-share.js`, torrent-tv/meta#178).
|
|
73
|
+
*/
|
|
74
|
+
DOWNLOAD_RATE: "download-rate",
|
|
69
75
|
/** Read a byte range; the body arrives as CHUNK messages. */
|
|
70
76
|
READ_RANGE: "read-range",
|
|
71
77
|
/** Abandon an in-flight READ_RANGE (viewer gone, seek superseded). */
|
|
@@ -476,6 +476,11 @@ async function runCommand(command, params, id) {
|
|
|
476
476
|
return { appliedMs: performance.now() - startedAt, receivedAt, repliedAt: performance.timeOrigin + performance.now() };
|
|
477
477
|
}
|
|
478
478
|
|
|
479
|
+
case Command.DOWNLOAD_RATE: {
|
|
480
|
+
pool.setDownloadRate(params.bytesPerSecond, params.because);
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
483
|
+
|
|
479
484
|
case Command.READ_RANGE: {
|
|
480
485
|
// Streams its own reply; the caller's promise resolves once the body has
|
|
481
486
|
// been fully sent, which is what lets the client await completion.
|