@torrent-tv/proxy 2.97.16 → 2.97.17
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 +13 -2
- package/services/encode/EncodeOrchestrator.js +33 -1
- package/services/media/container/AviContainer.js +11 -0
- package/services/server/ViewerRequests.js +20 -4
- package/services/server/wire-outputs.js +30 -6
- 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
|
@@ -568,9 +568,15 @@ export async function startProxyServer({
|
|
|
568
568
|
value = new SegmentInputs({ index, tracks: wanted }).forInterval({ ...interval, mode: track => modes.get(track) });
|
|
569
569
|
}
|
|
570
570
|
} catch { value = null; }
|
|
571
|
+
// A zone worked out gives the thread back before the next: worked out
|
|
572
|
+
// in one stretch, the first pass over a film's 4000 zones held the main
|
|
573
|
+
// thread 5-7 s while FFmpeg's first open waited for its answers. What
|
|
574
|
+
// was worked out is kept, so a pass a newer map ends is not lost
|
|
575
|
+
// (torrent-tv/meta#178).
|
|
576
|
+
if (value?.kind === "result") segmentSourceRanges.set(rangesKey, value.ranges);
|
|
577
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
571
578
|
if (!map.isCurrent()) return [];
|
|
572
579
|
if (value?.kind === "result") {
|
|
573
|
-
segmentSourceRanges.set(rangesKey, value.ranges);
|
|
574
580
|
convert(value.ranges);
|
|
575
581
|
continue;
|
|
576
582
|
}
|
|
@@ -1301,7 +1307,12 @@ export async function startProxyServer({
|
|
|
1301
1307
|
outputs: outputParts.outputs,
|
|
1302
1308
|
viewers: outputParts.viewers,
|
|
1303
1309
|
renditions: outputParts.renditions,
|
|
1304
|
-
quality: outputParts.quality
|
|
1310
|
+
quality: outputParts.quality,
|
|
1311
|
+
planSoon: () => outputParts.encodeRuns.planEncodersSoon(),
|
|
1312
|
+
segmentAt: (output, seconds) => {
|
|
1313
|
+
// A cut table not built yet has no pieces to move between.
|
|
1314
|
+
try { return outputParts.outputTimes.segmentIndexForTime(output, seconds); } catch { return null; }
|
|
1315
|
+
}
|
|
1305
1316
|
})
|
|
1306
1317
|
);
|
|
1307
1318
|
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) {
|
|
@@ -404,8 +404,19 @@ export class AviContainer extends Container {
|
|
|
404
404
|
packets.append(track.trackNumber, { pts: stream.times[previous], duration: Math.max(0, end - stream.times[previous]),
|
|
405
405
|
keyframe: true, ranges: [[stream.starts[previous], stream.starts[previous] + stream.lengths[previous] - 1]] });
|
|
406
406
|
};
|
|
407
|
+
// THE THREAD IS GIVEN BACK at each portion of the file the table
|
|
408
|
+
// reaches: a two-hour soundtrack is some 300 000 packets, and built in
|
|
409
|
+
// one stretch it held the main thread with the map's first pass for
|
|
410
|
+
// 5-7 s while FFmpeg's first open waited for its answers
|
|
411
|
+
// (torrent-tv/meta#178).
|
|
412
|
+
let portion = -1;
|
|
407
413
|
for (let at = 0; at < stream.count; at++) {
|
|
408
414
|
if (!stream.lengths[at]) continue;
|
|
415
|
+
const reached = Math.floor(stream.starts[at] / this.portionBytes);
|
|
416
|
+
if (Number.isFinite(reached) && reached !== portion) {
|
|
417
|
+
if (portion >= 0) await new Promise(resolve => setImmediate(resolve));
|
|
418
|
+
portion = reached;
|
|
419
|
+
}
|
|
409
420
|
if (previous >= 0) append(stream.times[at]);
|
|
410
421
|
previous = at;
|
|
411
422
|
}
|
|
@@ -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.
|
|
@@ -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,
|