@torrent-tv/proxy 2.97.16 → 2.97.18
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/routes/api/transcode-sessions/net-report/post.js +35 -2
- package/server.js +16 -2
- package/services/encode/EncodeOrchestrator.js +33 -1
- package/services/media/container/AviContainer.js +42 -5
- package/services/server/ViewerRequests.js +20 -4
- package/services/server/wire-outputs.js +30 -6
- package/services/viewer/DownloadMaps.js +34 -1
- package/services/viewer/playback-readiness.js +65 -12
package/package.json
CHANGED
|
@@ -19,14 +19,15 @@ import { recordViewerReport } from "../../../../services/viewer/report-intake.js
|
|
|
19
19
|
*
|
|
20
20
|
* @param {import("fastify").FastifyRequest} req
|
|
21
21
|
* @param {import("fastify").FastifyReply} reply
|
|
22
|
-
* @param {{ outputs: { get: (id: string) => object | undefined }, viewers: object, renditions: { viewerPlays: Function, noteViewerReported: Function }, quality: { noteViewerReported: Function } }} deps -
|
|
22
|
+
* @param {{ outputs: { get: (id: string) => object | undefined }, viewers: object, renditions: { viewerPlays: Function, noteViewerReported: Function }, quality: { noteViewerReported: Function }, planSoon?: () => void, segmentAt?: (output: object, seconds: number) => number | null }} deps -
|
|
23
23
|
* The live outputs, the registry of viewers, and the rungs of a picture. The
|
|
24
24
|
* VIEWER component takes the statement; which rung a stated height is, is the
|
|
25
25
|
* encoding component's. This route's whole job is turning a request into
|
|
26
26
|
* those calls and their answer into a status code.
|
|
27
27
|
* @returns {Promise<void>}
|
|
28
28
|
*/
|
|
29
|
-
export async function handleApiTranscodeSessionNetReportPost(req, reply,
|
|
29
|
+
export async function handleApiTranscodeSessionNetReportPost(req, reply,
|
|
30
|
+
{ outputs, viewers, renditions, quality, planSoon = () => {}, segmentAt = () => null }) {
|
|
30
31
|
const sessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
|
|
31
32
|
const body = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
|
|
32
33
|
// WHAT THIS REPORT IS. A statement by one viewer about itself: where the
|
|
@@ -100,6 +101,10 @@ export async function handleApiTranscodeSessionNetReportPost(req, reply, { outpu
|
|
|
100
101
|
Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : undefined
|
|
101
102
|
);
|
|
102
103
|
}
|
|
104
|
+
// Where they stood and whether they were playing or waiting, before this
|
|
105
|
+
// report: what the encoders are planned from (`movedForThePlan`).
|
|
106
|
+
const before = viewers.get?.(consumerId);
|
|
107
|
+
const was = before ? { seconds: before.position?.seconds ?? null, playing: before.playing, waiting: before.waiting } : null;
|
|
103
108
|
const recorded = recordViewerReport({
|
|
104
109
|
outputs,
|
|
105
110
|
viewers,
|
|
@@ -127,6 +132,14 @@ export async function handleApiTranscodeSessionNetReportPost(req, reply, { outpu
|
|
|
127
132
|
if (!recorded) {
|
|
128
133
|
return reply.code(404).send({ error: "Transcode session was not found." });
|
|
129
134
|
}
|
|
135
|
+
// A VIEWER WHO MOVED INTO ANOTHER PIECE, or started or stopped playing or
|
|
136
|
+
// waiting, changed the maps the encoders are placed from; the plan is taken
|
|
137
|
+
// again now. Recorded and not re-planned, a soundtrack the viewer was
|
|
138
|
+
// stopped for waited 59 s behind a picture that had long left the urgent
|
|
139
|
+
// band, because only the end of that picture's run planned again (Home
|
|
140
|
+
// Assistant 2026-10-12, torrent-tv/meta#178).
|
|
141
|
+
const after = viewers.get?.(consumerId);
|
|
142
|
+
if (after && movedForThePlan(was, after, outputs.get(sessionId), segmentAt)) planSoon();
|
|
130
143
|
// After the statement is recorded, so what is judged is what they just said:
|
|
131
144
|
// a move of theirs to another limit may no longer be wanted.
|
|
132
145
|
renditions.noteViewerReported(sessionId, consumerId);
|
|
@@ -136,3 +149,23 @@ export async function handleApiTranscodeSessionNetReportPost(req, reply, { outpu
|
|
|
136
149
|
void quality.noteViewerReported(sessionId, consumerId);
|
|
137
150
|
return reply.code(204).send();
|
|
138
151
|
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Whether a report changed what the encoders are planned from: the piece the
|
|
155
|
+
* viewer stands in, or whether they play or wait.
|
|
156
|
+
*
|
|
157
|
+
* @param {{ seconds: number | null, playing: boolean, waiting: boolean } | null} was
|
|
158
|
+
* @param {{ position?: { seconds: number } | null, playing: boolean, waiting: boolean }} now
|
|
159
|
+
* @param {object | undefined} output
|
|
160
|
+
* @param {(output: object, seconds: number) => number | null} segmentAt
|
|
161
|
+
* @returns {boolean}
|
|
162
|
+
*/
|
|
163
|
+
export function movedForThePlan(was, now, output, segmentAt) {
|
|
164
|
+
if (!was) return true;
|
|
165
|
+
if (was.playing !== now.playing || was.waiting !== now.waiting) return true;
|
|
166
|
+
const seconds = now.position?.seconds;
|
|
167
|
+
if (!Number.isFinite(seconds)) return false;
|
|
168
|
+
if (!Number.isFinite(was.seconds)) return true;
|
|
169
|
+
if (!output) return false;
|
|
170
|
+
return segmentAt(output, was.seconds) !== segmentAt(output, seconds);
|
|
171
|
+
}
|
package/server.js
CHANGED
|
@@ -556,6 +556,9 @@ export async function startProxyServer({
|
|
|
556
556
|
const known = segmentSourceRanges.get(rangesKey);
|
|
557
557
|
if (known) { convert(known); continue; }
|
|
558
558
|
if (waiting.has(zone.outputKey)) continue;
|
|
559
|
+
// What is resolved so far is stated before a lower zone is worked out
|
|
560
|
+
// (DownloadMaps, torrent-tv/meta#178).
|
|
561
|
+
map.workingOut?.(zone.priority, converted);
|
|
559
562
|
if (direct) {
|
|
560
563
|
// Asked per zone too: a soundtrack of an AVI is read from its own
|
|
561
564
|
// packets, which the same index states (torrent-tv/meta#166).
|
|
@@ -568,9 +571,15 @@ export async function startProxyServer({
|
|
|
568
571
|
value = new SegmentInputs({ index, tracks: wanted }).forInterval({ ...interval, mode: track => modes.get(track) });
|
|
569
572
|
}
|
|
570
573
|
} catch { value = null; }
|
|
574
|
+
// A zone worked out gives the thread back before the next: worked out
|
|
575
|
+
// in one stretch, the first pass over a film's 4000 zones held the main
|
|
576
|
+
// thread 5-7 s while FFmpeg's first open waited for its answers. What
|
|
577
|
+
// was worked out is kept, so a pass a newer map ends is not lost
|
|
578
|
+
// (torrent-tv/meta#178).
|
|
579
|
+
if (value?.kind === "result") segmentSourceRanges.set(rangesKey, value.ranges);
|
|
580
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
571
581
|
if (!map.isCurrent()) return [];
|
|
572
582
|
if (value?.kind === "result") {
|
|
573
|
-
segmentSourceRanges.set(rangesKey, value.ranges);
|
|
574
583
|
convert(value.ranges);
|
|
575
584
|
continue;
|
|
576
585
|
}
|
|
@@ -1301,7 +1310,12 @@ export async function startProxyServer({
|
|
|
1301
1310
|
outputs: outputParts.outputs,
|
|
1302
1311
|
viewers: outputParts.viewers,
|
|
1303
1312
|
renditions: outputParts.renditions,
|
|
1304
|
-
quality: outputParts.quality
|
|
1313
|
+
quality: outputParts.quality,
|
|
1314
|
+
planSoon: () => outputParts.encodeRuns.planEncodersSoon(),
|
|
1315
|
+
segmentAt: (output, seconds) => {
|
|
1316
|
+
// A cut table not built yet has no pieces to move between.
|
|
1317
|
+
try { return outputParts.outputTimes.segmentIndexForTime(output, seconds); } catch { return null; }
|
|
1318
|
+
}
|
|
1305
1319
|
})
|
|
1306
1320
|
);
|
|
1307
1321
|
app.post("/api/transcode-sessions/:sessionId/fragment-far", async (req, reply) =>
|
|
@@ -130,6 +130,17 @@ export class EncodeOrchestrator {
|
|
|
130
130
|
* @type {{ urgent: boolean, priority: number, address: string, index: number } | null}
|
|
131
131
|
*/
|
|
132
132
|
#leading = null;
|
|
133
|
+
/**
|
|
134
|
+
* Outputs whose start the last plan held back behind a more urgent encoder,
|
|
135
|
+
* and the band of the number it would have started at. Looked at again
|
|
136
|
+
* whenever that encoder moves on (`shareTheMachine`), because a closed piece
|
|
137
|
+
* is what lowers its band: placed only by a full plan, a soundtrack the
|
|
138
|
+
* viewer was stopped for waited 59 s behind a picture that had long left
|
|
139
|
+
* the urgent band (Home Assistant 2026-10-12, torrent-tv/meta#178).
|
|
140
|
+
*
|
|
141
|
+
* @type {Map<string, { urgent: boolean, priority: number }>}
|
|
142
|
+
*/
|
|
143
|
+
#heldBack = new Map();
|
|
133
144
|
|
|
134
145
|
/** What a stop and a start have cost on this host. */
|
|
135
146
|
#costs = new RunCosts();
|
|
@@ -689,7 +700,10 @@ export class EncodeOrchestrator {
|
|
|
689
700
|
shareTheMachine() {
|
|
690
701
|
const leading = this.#leadingBand();
|
|
691
702
|
this.#leading = leading;
|
|
692
|
-
if (!leading)
|
|
703
|
+
if (!leading) {
|
|
704
|
+
this.#placeHeldBack(null);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
693
707
|
for (const [address, runs] of this.#runs) {
|
|
694
708
|
for (const run of runs) {
|
|
695
709
|
if (!run.isAlive || run.isStopping || typeof run.pause !== "function") continue;
|
|
@@ -702,6 +716,22 @@ export class EncodeOrchestrator {
|
|
|
702
716
|
}
|
|
703
717
|
}
|
|
704
718
|
}
|
|
719
|
+
this.#placeHeldBack(leading);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Plan again every output whose start was held back behind what is no
|
|
724
|
+
* longer more urgent than it: the start is placed now, by the same plan,
|
|
725
|
+
* rather than at whatever event next re-plans everything.
|
|
726
|
+
*
|
|
727
|
+
* @param {{ urgent: boolean, priority: number } | null} leading
|
|
728
|
+
*/
|
|
729
|
+
#placeHeldBack(leading) {
|
|
730
|
+
for (const [address, band] of [...this.#heldBack]) {
|
|
731
|
+
if (leading && compareUrgency(band, leading) > 0) continue;
|
|
732
|
+
this.#heldBack.delete(address);
|
|
733
|
+
this.#reconcileOne(address);
|
|
734
|
+
}
|
|
705
735
|
}
|
|
706
736
|
|
|
707
737
|
/**
|
|
@@ -916,6 +946,8 @@ export class EncodeOrchestrator {
|
|
|
916
946
|
// a 159 090-line file, 31 % of the log, all of one wedge.
|
|
917
947
|
const wanting = firstUnmetWant(coverage, windows);
|
|
918
948
|
const stillRunning = this.runsOn(address).filter((run) => run.isAlive);
|
|
949
|
+
if (heldBack) this.#heldBack.set(address, this.#bandAt(address, heldBack.from));
|
|
950
|
+
else this.#heldBack.delete(address);
|
|
919
951
|
if (wanting === null || stillRunning.length > 0) {
|
|
920
952
|
this.#lastUnmet.delete(address);
|
|
921
953
|
} else if (heldBack) {
|
|
@@ -43,6 +43,8 @@ export class AviContainer extends Container {
|
|
|
43
43
|
/** The index alone: undefined until read, null when the file states none. */
|
|
44
44
|
#index = undefined;
|
|
45
45
|
#layout = null;
|
|
46
|
+
/** The first RIFF's `idx1`: undefined until looked for, null when there is none. */
|
|
47
|
+
#idx1Chunk = undefined;
|
|
46
48
|
|
|
47
49
|
async #readHeaders() {
|
|
48
50
|
if (this.#headers) return this.#headers;
|
|
@@ -124,17 +126,40 @@ export class AviContainer extends Container {
|
|
|
124
126
|
}
|
|
125
127
|
|
|
126
128
|
|
|
127
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* The first RIFF's `movi` list, found without reading past it: the chunk
|
|
131
|
+
* after `movi` lies where the first RIFF ends, which on a cold start is a
|
|
132
|
+
* piece of its own off the swarm.
|
|
133
|
+
*/
|
|
128
134
|
async #topLevel() {
|
|
129
135
|
if (this.#layout) return this.#layout;
|
|
130
136
|
const header = await this.readRange(0, 11);
|
|
131
|
-
const
|
|
132
|
-
const
|
|
137
|
+
const end = 8 + header.readUInt32LE(4);
|
|
138
|
+
const before = await riffChunks(this.readRange, 12, end, "movi");
|
|
139
|
+
const movi = before.find(chunk => chunk.id === "LIST" && chunk.type === "movi");
|
|
133
140
|
if (!movi) throw new Error("AVI media list is absent.");
|
|
134
|
-
this.#layout = { movi,
|
|
141
|
+
this.#layout = { movi, end, before };
|
|
135
142
|
return this.#layout;
|
|
136
143
|
}
|
|
137
144
|
|
|
145
|
+
/**
|
|
146
|
+
* The first RIFF's `idx1` chunk, which follows `movi`. Read only for a file
|
|
147
|
+
* with no OpenDML index, as FFmpeg reads it: a loaded OpenDML index marks
|
|
148
|
+
* the index loaded and `avi_load_index` is not called (avidec.c n6.1 and
|
|
149
|
+
* n8.1, `read_odml_index`). Looked for on every file, it cost a cold
|
|
150
|
+
* two-hour OpenDML AVI a round trip to the swarm: its index's own piece was
|
|
151
|
+
* asked for 4.4 s later than it could have been (Home Assistant 2026-10-12,
|
|
152
|
+
* torrent-tv/meta#178).
|
|
153
|
+
*/
|
|
154
|
+
async #idx1() {
|
|
155
|
+
if (this.#idx1Chunk !== undefined) return this.#idx1Chunk;
|
|
156
|
+
const { movi, end, before } = await this.#topLevel();
|
|
157
|
+
const after = movi.end + ((movi.end - movi.start) & 1);
|
|
158
|
+
const chunks = [...before, ...(after < end ? await riffChunks(this.readRange, after, end) : [])];
|
|
159
|
+
this.#idx1Chunk = chunks.find(chunk => chunk.id === "idx1") ?? null;
|
|
160
|
+
return this.#idx1Chunk;
|
|
161
|
+
}
|
|
162
|
+
|
|
138
163
|
/**
|
|
139
164
|
* What the index states, read once; null when the file states no index.
|
|
140
165
|
* A shortage of bytes or memory is thrown and nothing is kept, so the next
|
|
@@ -144,7 +169,8 @@ export class AviContainer extends Container {
|
|
|
144
169
|
if (this.#index !== undefined) return this.#index;
|
|
145
170
|
const tracks = await this.readTracks();
|
|
146
171
|
const { streams } = await this.#readHeaders();
|
|
147
|
-
const { movi
|
|
172
|
+
const { movi } = await this.#topLevel();
|
|
173
|
+
const idx1 = streams.some(stream => stream.indexChunks.length) ? null : await this.#idx1();
|
|
148
174
|
const record = { dispose: () => this.#index?.dispose?.() };
|
|
149
175
|
const allocation = this.packetMemory?.forRecord?.(record, "index") ?? this.packetMemory;
|
|
150
176
|
const index = await readAviIndex({ read: this.readRange, fileSize: this.fileSize, movi, idx1, allocation,
|
|
@@ -404,8 +430,19 @@ export class AviContainer extends Container {
|
|
|
404
430
|
packets.append(track.trackNumber, { pts: stream.times[previous], duration: Math.max(0, end - stream.times[previous]),
|
|
405
431
|
keyframe: true, ranges: [[stream.starts[previous], stream.starts[previous] + stream.lengths[previous] - 1]] });
|
|
406
432
|
};
|
|
433
|
+
// THE THREAD IS GIVEN BACK at each portion of the file the table
|
|
434
|
+
// reaches: a two-hour soundtrack is some 300 000 packets, and built in
|
|
435
|
+
// one stretch it held the main thread with the map's first pass for
|
|
436
|
+
// 5-7 s while FFmpeg's first open waited for its answers
|
|
437
|
+
// (torrent-tv/meta#178).
|
|
438
|
+
let portion = -1;
|
|
407
439
|
for (let at = 0; at < stream.count; at++) {
|
|
408
440
|
if (!stream.lengths[at]) continue;
|
|
441
|
+
const reached = Math.floor(stream.starts[at] / this.portionBytes);
|
|
442
|
+
if (Number.isFinite(reached) && reached !== portion) {
|
|
443
|
+
if (portion >= 0) await new Promise(resolve => setImmediate(resolve));
|
|
444
|
+
portion = reached;
|
|
445
|
+
}
|
|
409
446
|
if (previous >= 0) append(stream.times[at]);
|
|
410
447
|
previous = at;
|
|
411
448
|
}
|
|
@@ -646,6 +646,14 @@ export class ViewerRequests {
|
|
|
646
646
|
processingRanges: liveRuns.map(({ progress }) => ({
|
|
647
647
|
start: progress.startPositionSeconds, end: progress.processedSeconds
|
|
648
648
|
})),
|
|
649
|
+
// What a run's start costs this output, and the stretches its live
|
|
650
|
+
// runs were given: the forecast starts a run wherever production meets
|
|
651
|
+
// bytes that were not held when the run before was admitted.
|
|
652
|
+
openSeconds: this.#host.openSecondsOf?.(output) ?? null,
|
|
653
|
+
runSpans: liveRuns.map(run => ({
|
|
654
|
+
start: output.timeline?.publishedStartOf?.(run.from),
|
|
655
|
+
end: Number.isInteger(run.to) && run.to >= run.from ? output.timeline?.publishedStartOf?.(run.to + 1) : Number.POSITIVE_INFINITY
|
|
656
|
+
})),
|
|
649
657
|
bitsPerMediaSecond: Number.isFinite(observedMbps) && observedMbps > 0 ? observedMbps * 1_000_000 :
|
|
650
658
|
this.#host.initialOutputBitsPerMediaSecondOf?.(output) ?? 0,
|
|
651
659
|
readings: trackRates,
|
|
@@ -705,7 +713,8 @@ export class ViewerRequests {
|
|
|
705
713
|
const forecast = withSubtitleReadiness(this.#host.playbackReadiness.predict(input), subtitle,
|
|
706
714
|
subtitle ? this.#host.subtitleReadyFor?.(viewer) !== false : true);
|
|
707
715
|
forecast.operations = outputs.flatMap((output, index) => {
|
|
708
|
-
const common = { speed: this.#host.playbackReadiness.forecastRate(tracks[index].readings
|
|
716
|
+
const common = { speed: this.#host.playbackReadiness.forecastRate(tracks[index].readings, now, 0, "piece"),
|
|
717
|
+
processedSeconds: tracks[index].processedSeconds };
|
|
709
718
|
return [
|
|
710
719
|
...(output.spec.video ? [{ ...common, track: "video",
|
|
711
720
|
operation: output.spec.transcodesVideo ? "encode" : "copy",
|
|
@@ -855,10 +864,17 @@ export class ViewerRequests {
|
|
|
855
864
|
const reading = this.#host.encodeSpeedReadingOf?.(output, now) ?? null;
|
|
856
865
|
if (Number.isFinite(reading?.speed) && reading.speed > 0 && Number.isFinite(reading.at)) {
|
|
857
866
|
trend.add(reading.at, reading.speed);
|
|
858
|
-
} else {
|
|
859
|
-
|
|
867
|
+
} else if (!trend.snapshot()) {
|
|
868
|
+
// ONLY UNTIL A PIECE IS MEASURED does the prediction stand for the
|
|
869
|
+
// encoder, and it is not kept among the readings. Added on every poll
|
|
870
|
+
// between runs, it put the machine's share of that moment — 1 % at a
|
|
871
|
+
// cold start — on the whole film, and the forecast read 0.03x
|
|
872
|
+
// (torrent-tv/meta#178).
|
|
873
|
+
const projected = Number(this.#host.predictedEncodeSpeedOf?.(output) ?? this.#host.projectedEncodeSpeedOf?.(output));
|
|
860
874
|
if (Number.isFinite(projected) && projected > 0) {
|
|
861
|
-
|
|
875
|
+
const seed = new this.#host.playbackReadiness.RateTrend();
|
|
876
|
+
seed.add(now, projected);
|
|
877
|
+
return seed.snapshot();
|
|
862
878
|
}
|
|
863
879
|
}
|
|
864
880
|
return trend.snapshot();
|
|
@@ -579,13 +579,16 @@ export function wireOutputs({
|
|
|
579
579
|
sourceInputsFor: (output, index) => parts.sourceInputsFor?.(output, index),
|
|
580
580
|
encodeSpeedReadingOf: (output) => parts.encodeCost.latestSpeedReadingOf(output),
|
|
581
581
|
projectedEncodeSpeedOf: (output) => parts.encodeCost.projectedSpeedOf(output),
|
|
582
|
+
// The speed the offer predicted for this output when it chose its mode: a
|
|
583
|
+
// forecast with no measured piece yet stands on it rather than on the
|
|
584
|
+
// machine's share of the moment (torrent-tv/meta#178).
|
|
585
|
+
predictedEncodeSpeedOf: (output) => {
|
|
586
|
+
const predicted = Number(qualityStateOf(output).predictedSpeedWhenOffered);
|
|
587
|
+
return Number.isFinite(predicted) && predicted > 0 ? predicted : null;
|
|
588
|
+
},
|
|
582
589
|
audioDescriptionOf,
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
codec: audioDescriptionOf(output)?.codec ?? output.file.media?.audioCodec,
|
|
586
|
-
transcode: output.spec.audio?.transcode })?.startupSeconds ?? 0 :
|
|
587
|
-
Math.max(0, (startStopCost?.firstByteWaitSec ?? 0) - parts.segmentDurationSec /
|
|
588
|
-
parts.encodeCost.projectedSpeedOf(output)),
|
|
590
|
+
openSecondsOf: (output) => runStartSecondsOf(output),
|
|
591
|
+
processingStartupSecondsOf: (output) => runStartSecondsOf(output),
|
|
589
592
|
initialOutputBitsPerMediaSecondOf: (output) => {
|
|
590
593
|
const description = audioDescriptionOf(output);
|
|
591
594
|
const reference = audioReadingFor(audioCalibration, { codec: description?.codec, transcode: false });
|
|
@@ -1045,6 +1048,27 @@ export function wireOutputs({
|
|
|
1045
1048
|
readFree: freeBytesFor,
|
|
1046
1049
|
logger
|
|
1047
1050
|
});
|
|
1051
|
+
/**
|
|
1052
|
+
* What starting a run costs this output before its first piece is encoded:
|
|
1053
|
+
* its own measured open where it has one (`RunCosts.openOf`), else the
|
|
1054
|
+
* host's startup reading of spawn to first piece less that piece's encoding;
|
|
1055
|
+
* for sound, its packaged startup reading.
|
|
1056
|
+
*
|
|
1057
|
+
* @param {object} output
|
|
1058
|
+
* @returns {number}
|
|
1059
|
+
*/
|
|
1060
|
+
function runStartSecondsOf(output) {
|
|
1061
|
+
if (output.spec.carries === "audio-only") {
|
|
1062
|
+
return audioReadingFor(audioCalibration, { ...audioDescriptionOf(output),
|
|
1063
|
+
codec: audioDescriptionOf(output)?.codec ?? output.file.media?.audioCodec,
|
|
1064
|
+
transcode: output.spec.audio?.transcode })?.startupSeconds ?? 0;
|
|
1065
|
+
}
|
|
1066
|
+
const open = parts.encodeOrchestrator?.openOf?.(output.outputKey ?? "");
|
|
1067
|
+
if (open?.measured) return open.seconds;
|
|
1068
|
+
return Math.max(0, (startStopCost?.firstByteWaitSec ?? 0) - parts.segmentDurationSec /
|
|
1069
|
+
parts.encodeCost.projectedSpeedOf(output));
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1048
1072
|
// Realtime-budget monitor: only meaningful for the software encoder with a
|
|
1049
1073
|
// benchmark (the only path that can pick/step resolution). Cheap no-op scan
|
|
1050
1074
|
// otherwise.
|
|
@@ -74,8 +74,28 @@ export class DownloadMaps {
|
|
|
74
74
|
}
|
|
75
75
|
if (changed) await this.#emit(file);
|
|
76
76
|
if (revision !== file.revision) return;
|
|
77
|
+
const isCurrent = () => this.#files.get(`${sourceKey}:${fileIndex}`) === file && revision === file.revision;
|
|
78
|
+
// WHAT IS RESOLVED IS STATED WHILE THE REST IS STILL BEING RESOLVED. A pass
|
|
79
|
+
// works the zones out in the order the swarm is to fetch them, and
|
|
80
|
+
// published only at its end it kept the first bytes from the swarm for as
|
|
81
|
+
// long as the far end of the film took: 14.6 s on a cold two-hour AVI, the
|
|
82
|
+
// swarm fetching nothing for 17.5 s while the encoder waited for its next
|
|
83
|
+
// piece (Home Assistant 2026-10-12, torrent-tv/meta#178). The swarm
|
|
84
|
+
// fetches the highest group still missing, so before the pass works out a
|
|
85
|
+
// zone of a lower priority than any before it, what it has resolved is
|
|
86
|
+
// published, with the zones still retained for what it has not reached. A
|
|
87
|
+
// pass that works nothing out — every pass over a film already worked out
|
|
88
|
+
// — states nothing early.
|
|
89
|
+
let stated = Number.POSITIVE_INFINITY;
|
|
90
|
+
const workingOut = (priority, zones) => {
|
|
91
|
+
if (!(priority < stated)) return;
|
|
92
|
+
stated = priority;
|
|
93
|
+
if (zones.length === 0 || !isCurrent()) return;
|
|
94
|
+
this.#setZones(file, withRetained(zones, retained));
|
|
95
|
+
this.#emit(file).catch(() => undefined);
|
|
96
|
+
};
|
|
77
97
|
const resolved = await this.#resolvePlayback({ sourceKey, fileIndex, durationSeconds, zones: file.sourceZones,
|
|
78
|
-
isCurrent
|
|
98
|
+
isCurrent, workingOut });
|
|
79
99
|
if (revision !== file.revision) return;
|
|
80
100
|
this.#setZones(file, resolved);
|
|
81
101
|
return this.#emit(file);
|
|
@@ -333,6 +353,19 @@ export class DownloadMaps {
|
|
|
333
353
|
}
|
|
334
354
|
}
|
|
335
355
|
|
|
356
|
+
/**
|
|
357
|
+
* Zones resolved so far, with the retained zones of every segment they do not
|
|
358
|
+
* yet resolve again: a segment's bytes are stated once, by its newest reading.
|
|
359
|
+
*/
|
|
360
|
+
function withRetained(resolved, retained) {
|
|
361
|
+
const keyOf = zone => typeof zone.outputKey === "string" && Number.isSafeInteger(zone.index) ? `${zone.outputKey}:${zone.index}` : null;
|
|
362
|
+
const resolvedKeys = new Set(resolved.map(keyOf).filter(key => key !== null));
|
|
363
|
+
return [...resolved, ...retained.filter(zone => {
|
|
364
|
+
const key = keyOf(zone);
|
|
365
|
+
return key === null || !resolvedKeys.has(key);
|
|
366
|
+
})];
|
|
367
|
+
}
|
|
368
|
+
|
|
336
369
|
/** @param {number} since */
|
|
337
370
|
function emptyStats(since) {
|
|
338
371
|
return { since, sent: 0, unchanged: 0, zones: [], deliveredMs: [], appliedMs: [],
|
|
@@ -49,8 +49,8 @@ const CLIENT_RANGE_START_TOLERANCE_SECONDS = 0.25;
|
|
|
49
49
|
* @param {number} horizonSeconds
|
|
50
50
|
* @returns {number | null}
|
|
51
51
|
*/
|
|
52
|
-
export function forecastRate(readings, now = Date.now(), horizonSeconds = 0) {
|
|
53
|
-
const curve = rateCurve(readings, now);
|
|
52
|
+
export function forecastRate(readings, now = Date.now(), horizonSeconds = 0, weighting = "last") {
|
|
53
|
+
const curve = rateCurve(readings, now, weighting);
|
|
54
54
|
if (!curve) {
|
|
55
55
|
return null;
|
|
56
56
|
}
|
|
@@ -192,7 +192,12 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
192
192
|
[{ start: at, end: add(at, measured ? nearest(fromSeconds(buffered), unit) :
|
|
193
193
|
rescale(fromSeconds(buffered), unit)), frame: zero }],
|
|
194
194
|
ready: new Set(Array.isArray(track.readySegmentIndices) ? track.readySegmentIndices : []),
|
|
195
|
-
|
|
195
|
+
// Film made over the encoder's working time, over the readings the trend
|
|
196
|
+
// keeps: one reading is one closed piece, so the harmonic mean is that
|
|
197
|
+
// ratio. The last reading alone is one piece's content and machine — a
|
|
198
|
+
// first piece of credits read 6.49x and started a cold AVI on 2.4 s of
|
|
199
|
+
// buffer that the next piece, at 0.57x, did not hold (torrent-tv/meta#178).
|
|
200
|
+
curve: rateCurve(track.readings, now, "piece"),
|
|
196
201
|
bitsPerMediaSecond: averageBitsPerMediaSecond(track, track.segments,
|
|
197
202
|
new Set(Array.isArray(track.readySegmentIndices) ? track.readySegmentIndices : [])),
|
|
198
203
|
segments: segmentsOf[trackIndex]
|
|
@@ -211,7 +216,10 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
211
216
|
const serviceKey = typeof source.serviceKey === "string" ? source.serviceKey : id;
|
|
212
217
|
let service = sourceServices.get(serviceKey);
|
|
213
218
|
if (!service) {
|
|
214
|
-
|
|
219
|
+
// Bytes over time across the readings the trend keeps: the last 5 s of a
|
|
220
|
+
// swarm moved between 0.97 and 5.8 MB/s and was carried to the end of the
|
|
221
|
+
// film each time (torrent-tv/meta#178).
|
|
222
|
+
service = { rate: rateCurve(source.downloadRateReadings, now, "time"), finish: 0 };
|
|
215
223
|
sourceServices.set(serviceKey, service);
|
|
216
224
|
}
|
|
217
225
|
sourceState.set(id, { source, service });
|
|
@@ -330,6 +338,22 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
330
338
|
}
|
|
331
339
|
}
|
|
332
340
|
const trackFinish = trackState.map(({ track }) => finiteNonNegative(track.startupRemainingSeconds));
|
|
341
|
+
// WHERE A RUN STARTS, AND WHAT IT COSTS. A run is given the stretch whose
|
|
342
|
+
// bytes are held when its input is admitted, and the next one starts after
|
|
343
|
+
// it, when more bytes are held, and opens its input first — 6-10 s on an
|
|
344
|
+
// OpenDML AVI. Charged once at the first start only, a cold AVI made of runs
|
|
345
|
+
// of one to five pieces was forecast as one run (torrent-tv/meta#178). So a
|
|
346
|
+
// piece outside the live runs' stretches whose bytes arrive after the run
|
|
347
|
+
// the schedule last started was admitted begins a run of its own, admitted
|
|
348
|
+
// when the piece before it is made and its bytes are held, then opened.
|
|
349
|
+
// The first run's start is `startupRemainingSeconds`, charged once above; a
|
|
350
|
+
// stretch's bound by memory and by its copy time is not modelled.
|
|
351
|
+
const runStarts = trackState.map(({ track }) => ({
|
|
352
|
+
openSeconds: finiteNonNegative(track.openSeconds),
|
|
353
|
+
spans: (Array.isArray(track.runSpans) ? track.runSpans : [])
|
|
354
|
+
.filter(span => Number.isFinite(span?.start) && span.end > span.start),
|
|
355
|
+
admittedAt: finiteNonNegative(track.startupRemainingSeconds) > 0 ? 0 : null
|
|
356
|
+
}));
|
|
333
357
|
const sourceByteCoverage = new Map([...sourceByteService].map(([id, ranges]) => [id, numericCoverage(ranges)]));
|
|
334
358
|
const sourceArrivals = new Map([...sourceByteService].map(([id, ranges]) => [id, indexedArrivals(ranges)]));
|
|
335
359
|
let transferFinish = 0;
|
|
@@ -386,7 +410,15 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
386
410
|
// Production accepts a complete held segment input. It cannot overlap its
|
|
387
411
|
// own source download; reused input retains its original arrival time.
|
|
388
412
|
const processing = trackState[trackIndex].curve;
|
|
389
|
-
|
|
413
|
+
let admittedAt = Math.max(trackFinish[trackIndex], sourceArrivalAt);
|
|
414
|
+
if (!job.produced && encodeWork > 0) {
|
|
415
|
+
const run = runStarts[trackIndex];
|
|
416
|
+
const live = run.spans.some(span => segment.startSeconds >= span.start && segment.startSeconds < span.end);
|
|
417
|
+
if (!live && (run.admittedAt === null || sourceArrivalAt > run.admittedAt)) {
|
|
418
|
+
run.admittedAt = admittedAt;
|
|
419
|
+
admittedAt += run.openSeconds;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
390
422
|
const producedAt = job.produced || encodeWork === 0 ? 0 : processing.finish(encodeWork, admittedAt);
|
|
391
423
|
trackFinish[trackIndex] = Math.max(trackFinish[trackIndex], producedAt);
|
|
392
424
|
productions.push({ at: producedAt, trackIndex, segment, requiresSource: sourceArrivalAt > 0 });
|
|
@@ -787,11 +819,19 @@ function roundedHalfUp(numerator, denominator) {
|
|
|
787
819
|
return 2n * (numerator - floor * denominator) >= denominator ? floor + 1n : floor;
|
|
788
820
|
}
|
|
789
821
|
|
|
790
|
-
|
|
822
|
+
/**
|
|
823
|
+
* @param {*} readings - A summary (`RateTrend.snapshot`) or the readings themselves.
|
|
824
|
+
* @param {number} now
|
|
825
|
+
* @param {"last" | "piece" | "time"} [weighting] - Which rate the readings
|
|
826
|
+
* stand for: the last one; work per working time where each reading is one
|
|
827
|
+
* piece of film; or quantity per wall time where each reading holds until
|
|
828
|
+
* the next.
|
|
829
|
+
*/
|
|
830
|
+
function rateCurve(readings, now, weighting = "last") {
|
|
791
831
|
if (readings && Number.isFinite(readings.count) && readings.count > 0 &&
|
|
792
832
|
Number.isFinite(readings.lastAt) && Number.isFinite(readings.lastValue) &&
|
|
793
833
|
Number.isFinite(readings.meanValue) && Number.isFinite(readings.spanSeconds)) {
|
|
794
|
-
return rateCurveFrom(readings,
|
|
834
|
+
return rateCurveFrom(readings, weighting);
|
|
795
835
|
}
|
|
796
836
|
const ordered = (Array.isArray(readings) ? readings : [])
|
|
797
837
|
.filter((reading) => Number.isFinite(reading?.at) && Number.isFinite(reading?.value) && reading.value >= 0)
|
|
@@ -802,25 +842,38 @@ function rateCurve(readings, now) {
|
|
|
802
842
|
return null;
|
|
803
843
|
}
|
|
804
844
|
|
|
805
|
-
return rateCurveFrom(summarizeRate(ordered),
|
|
845
|
+
return rateCurveFrom(summarizeRate(ordered), weighting);
|
|
806
846
|
}
|
|
807
847
|
|
|
808
848
|
function summarizeRate(readings) {
|
|
809
849
|
const latest = readings.at(-1);
|
|
850
|
+
let weighted = 0;
|
|
851
|
+
for (let index = 0; index + 1 < readings.length; index += 1) {
|
|
852
|
+
weighted += readings[index].value * (readings[index + 1].at - readings[index].at);
|
|
853
|
+
}
|
|
854
|
+
const spanMs = latest.at - readings[0].at;
|
|
810
855
|
return {
|
|
811
856
|
count: readings.length,
|
|
812
857
|
lastAt: latest.at,
|
|
813
858
|
lastValue: latest.value,
|
|
814
859
|
meanValue: readings.reduce((sum, reading) => sum + reading.value, 0) / readings.length,
|
|
815
|
-
|
|
860
|
+
// Each reading one piece of work: work over the time it took.
|
|
861
|
+
harmonicValue: readings.some(reading => !(reading.value > 0)) ? 0
|
|
862
|
+
: readings.length / readings.reduce((sum, reading) => sum + 1 / reading.value, 0),
|
|
863
|
+
// Each reading holding until the next: quantity over the span.
|
|
864
|
+
timeWeightedValue: spanMs > 0 ? weighted / spanMs : latest.value,
|
|
865
|
+
spanSeconds: spanMs / 1000
|
|
816
866
|
};
|
|
817
867
|
}
|
|
818
868
|
|
|
819
|
-
function rateCurveFrom(
|
|
820
|
-
// Conditional projection of the
|
|
869
|
+
function rateCurveFrom(summary, weighting = "last") {
|
|
870
|
+
// Conditional projection of the measured service. No exponential
|
|
821
871
|
// relaxation, smoothing time, slope extrapolation or guessed recovery.
|
|
822
872
|
// A new measurement changes the integral; polling does not change its rate.
|
|
823
|
-
const
|
|
873
|
+
const value = weighting === "piece" && Number.isFinite(summary.harmonicValue) ? summary.harmonicValue
|
|
874
|
+
: weighting === "time" && Number.isFinite(summary.timeWeightedValue) ? summary.timeWeightedValue
|
|
875
|
+
: summary.lastValue;
|
|
876
|
+
const rate = Math.max(0, value);
|
|
824
877
|
const workBy = (seconds) => rate * Math.max(0, seconds);
|
|
825
878
|
return {
|
|
826
879
|
rateAt: () => rate,
|