@torrent-tv/proxy 2.94.3 → 2.95.1
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/assets/calibration/NOTICE.md +25 -0
- package/assets/calibration/cal-av1-1080-hi.mp4 +0 -0
- package/assets/calibration/cal-av1-1080-lo.mp4 +0 -0
- package/assets/calibration/cal-av1-480-hi.mp4 +0 -0
- package/assets/calibration/cal-av1-480-lo.mp4 +0 -0
- package/assets/calibration/cal-mpeg2-1080-hi.mp4 +0 -0
- package/assets/calibration/cal-mpeg2-1080-lo.mp4 +0 -0
- package/assets/calibration/cal-mpeg2-480-hi.mp4 +0 -0
- package/assets/calibration/cal-mpeg2-480-lo.mp4 +0 -0
- package/assets/calibration/cal-mpeg4-1080-hi.mp4 +0 -0
- package/assets/calibration/cal-mpeg4-1080-lo.mp4 +0 -0
- package/assets/calibration/cal-mpeg4-480-hi.mp4 +0 -0
- package/assets/calibration/cal-mpeg4-480-lo.mp4 +0 -0
- package/package.json +1 -1
- package/server.js +0 -2
- package/services/encode/EncodeOrchestrator.js +10 -0
- package/services/encode/LocalObservations.js +11 -2
- package/services/encode/OutputOpening.js +3 -6
- package/services/encode/SoftwareEncoder.js +2 -2
- package/services/encode/available-share.js +5 -1
- package/services/encode/calibration.js +15 -2
- package/services/encode/decode-cost-fit.js +12 -0
- package/services/encode/host-load.js +34 -0
- package/services/encode/hwaccel.js +139 -125
- package/services/encode/quality/ColdStarts.js +78 -0
- package/services/encode/quality/EncodeCost.js +119 -47
- package/services/encode/quality/OutputQualityState.js +2 -0
- package/services/encode/quality/QualityController.js +9 -5
- package/services/media/playback-declarations.js +6 -1
- package/services/media/playback-planner.js +10 -20
- package/services/server/SegmentServing.js +3 -3
- package/services/server/ViewerRequests.js +1 -10
- package/services/server/wire-outputs.js +14 -19
- package/services/encode/quality/HostTimings.js +0 -249
|
@@ -79,15 +79,97 @@ export class EncodeCost {
|
|
|
79
79
|
#decodeCost = new Map();
|
|
80
80
|
|
|
81
81
|
/**
|
|
82
|
-
* What
|
|
83
|
-
*
|
|
82
|
+
* What this output is predicted to do on this machine, in the mode it is
|
|
83
|
+
* actually encoded in, kept on the output so that once it runs the field says
|
|
84
|
+
* what the prediction was worth (torrent-tv/meta#3).
|
|
84
85
|
*
|
|
85
|
-
*
|
|
86
|
+
* Computed here, for this output, and not taken from the last offer. Two
|
|
87
|
+
* faults came from taking it from there, both read from the field log of
|
|
88
|
+
* 2026-10-07. The offer prices a height at the CHEAPEST mode, because a
|
|
89
|
+
* height may be offered when any mode can hold it, while the budget then
|
|
90
|
+
* encodes at the best mode that clears the bar: an XviD 360p step was
|
|
91
|
+
* predicted 7.27x at `ultrafast` and measured 1.2-3.4x at `fast`, a ratio of
|
|
92
|
+
* 0.16-0.47 that said nothing about the arithmetic. And the last offer is one
|
|
93
|
+
* answer for the whole process, so an output could be given the figure of
|
|
94
|
+
* another film's offer.
|
|
95
|
+
*
|
|
96
|
+
* The offer's own figure for the height is kept beside it, so the line says
|
|
97
|
+
* both what the offer decided on and what this run was expected to do.
|
|
98
|
+
*
|
|
99
|
+
* @param {object} session
|
|
100
|
+
* @returns {void}
|
|
86
101
|
*/
|
|
87
|
-
|
|
102
|
+
notePredictionFor(session) {
|
|
103
|
+
const state = qualityStateOf(session);
|
|
104
|
+
state.predictedSpeedWhenOffered = null;
|
|
105
|
+
state.predictedMode = null;
|
|
106
|
+
state.offeredSpeedAtCheapestMode = null;
|
|
107
|
+
const encode = session?.spec?.video?.encode ?? null;
|
|
108
|
+
const benchmark = this.#host().benchmark;
|
|
109
|
+
if (!session?.spec?.transcodesVideo || !encode || !Array.isArray(benchmark) || benchmark.length === 0) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const entry = modeEntryFor(benchmark, encode.preset);
|
|
113
|
+
const priced = {
|
|
114
|
+
source: session.file?.decode ?? null,
|
|
115
|
+
width: encode.width,
|
|
116
|
+
height: encode.height,
|
|
117
|
+
fps: Number(encode.fps) || TRANSCODE_FPS,
|
|
118
|
+
observedDecodeCostSec: null,
|
|
119
|
+
// Everything else this output's family has running. The output itself has
|
|
120
|
+
// no process yet, so it is not in the total.
|
|
121
|
+
concurrentCostSec: this.committedCostOf(session)
|
|
122
|
+
};
|
|
123
|
+
state.offeredSpeedAtCheapestMode = this.#speedOnThisMachine({ ...priced, benchmark });
|
|
124
|
+
if (entry !== null) {
|
|
125
|
+
state.predictedMode = entry.preset;
|
|
126
|
+
state.predictedSpeedWhenOffered = this.#speedOnThisMachine({ ...priced, benchmark: [entry] });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
88
129
|
|
|
89
|
-
|
|
90
|
-
|
|
130
|
+
/**
|
|
131
|
+
* The speed an encode is predicted to run at on this machine: the startup
|
|
132
|
+
* measurement of the cheapest mode in `benchmark` at this size, the decode of
|
|
133
|
+
* this source, the work already running beside it, how badly running together
|
|
134
|
+
* goes here, and the share of the machine nobody has been charged for.
|
|
135
|
+
*
|
|
136
|
+
* The one calculation both the offer and the per-output prediction use, so
|
|
137
|
+
* that a ratio between a run and its prediction measures the arithmetic and
|
|
138
|
+
* not a difference between two copies of it.
|
|
139
|
+
*
|
|
140
|
+
* @param {{ benchmark: object[], source: object | null, width: number, height: number, fps: number, observedDecodeCostSec: number | null, concurrentCostSec: number }} params
|
|
141
|
+
* @returns {number | null}
|
|
142
|
+
*/
|
|
143
|
+
#speedOnThisMachine({ benchmark, source, width, height, fps, observedDecodeCostSec, concurrentCostSec }) {
|
|
144
|
+
const { speed } = canSustainOutput({
|
|
145
|
+
benchmark,
|
|
146
|
+
decodeModel: this.#host().decodeModel,
|
|
147
|
+
source,
|
|
148
|
+
outputPixelsPerSec: width * height * fps,
|
|
149
|
+
observedDecodeCostSec,
|
|
150
|
+
concurrentCostSec,
|
|
151
|
+
frame: { width, height }
|
|
152
|
+
});
|
|
153
|
+
// The benchmark behind that figure was taken on a QUIET host — one
|
|
154
|
+
// ffmpeg and nothing else. The machine a step will actually run on is
|
|
155
|
+
// also running the kernel, the container and whatever else its owner
|
|
156
|
+
// does, and on the addon host that was measured at 99 % busy with a
|
|
157
|
+
// quarter of it unattributed. Only the unattributed part is charged
|
|
158
|
+
// here: our own encoders are already in `concurrentCostSec` and the
|
|
159
|
+
// proxy's own work is already priced per megabyte moved.
|
|
160
|
+
// Two corrections, and they are different facts about the machine. The
|
|
161
|
+
// availability share removes work nobody has been charged for; the
|
|
162
|
+
// contention penalty says what OUR OWN second job costs, because the
|
|
163
|
+
// budget adds independent prices and this host does not behave that way
|
|
164
|
+
// — the same work measured 2.6× dearer beside one encoder and 3.7×
|
|
165
|
+
// beside two (2026-08-18). `concurrentCostSec` already counts what is
|
|
166
|
+
// committed; this multiplies by how badly running at all together goes.
|
|
167
|
+
const othersRunning = concurrentCostSec > 0 ? this.#encodersRunningNow() : 0;
|
|
168
|
+
const { penalty } = contentionPenalty(othersRunning, this.#host().contentionPenalties);
|
|
169
|
+
return correctForAvailability(
|
|
170
|
+
speed === null ? null : speed / penalty,
|
|
171
|
+
this.#host().availability
|
|
172
|
+
);
|
|
91
173
|
}
|
|
92
174
|
|
|
93
175
|
// The last refusal printed. The offer is recomputed on the path that serves
|
|
@@ -635,12 +717,6 @@ export class EncodeCost {
|
|
|
635
717
|
const kept = [];
|
|
636
718
|
/** @type {string[]} */
|
|
637
719
|
const dropped = [];
|
|
638
|
-
// What each height was predicted to do on THIS machine, kept so a session
|
|
639
|
-
// started at that height can be compared against it once it runs. The
|
|
640
|
-
// manager holds the last answer, because the offer is computed on the path
|
|
641
|
-
// that serves every request while a session is created elsewhere.
|
|
642
|
-
/** @type {Map<number, number | null>} */
|
|
643
|
-
const predictedByHeight = new Map();
|
|
644
720
|
for (const height of heights) {
|
|
645
721
|
// A rung this session has actually been seen running below realtime is
|
|
646
722
|
// withdrawn on that evidence, whatever the prediction says. This is the
|
|
@@ -707,40 +783,15 @@ export class EncodeCost {
|
|
|
707
783
|
0,
|
|
708
784
|
concurrentCostSec - (runningCostByHeight?.get(height) ?? 0)
|
|
709
785
|
);
|
|
710
|
-
const
|
|
786
|
+
const onThisMachine = this.#speedOnThisMachine({
|
|
711
787
|
benchmark,
|
|
712
|
-
decodeModel: this.#host().decodeModel,
|
|
713
788
|
source,
|
|
714
|
-
|
|
789
|
+
width,
|
|
790
|
+
height,
|
|
791
|
+
fps,
|
|
715
792
|
observedDecodeCostSec,
|
|
716
|
-
concurrentCostSec: concurrentBesideThis
|
|
717
|
-
frame: { width, height }
|
|
793
|
+
concurrentCostSec: concurrentBesideThis
|
|
718
794
|
});
|
|
719
|
-
// The benchmark behind that figure was taken on a QUIET host — one
|
|
720
|
-
// ffmpeg and nothing else. The machine a step will actually run on is
|
|
721
|
-
// also running the kernel, the container and whatever else its owner
|
|
722
|
-
// does, and on the addon host that was measured at 99 % busy with a
|
|
723
|
-
// quarter of it unattributed. Only the unattributed part is charged
|
|
724
|
-
// here: our own encoders are already in `concurrentBesideThis` and the
|
|
725
|
-
// proxy's own work is already priced per megabyte moved.
|
|
726
|
-
// Two corrections, and they are different facts about the machine. The
|
|
727
|
-
// availability share removes work nobody has been charged for; the
|
|
728
|
-
// contention penalty says what OUR OWN second job costs, because the
|
|
729
|
-
// budget adds independent prices and this host does not behave that way
|
|
730
|
-
// — the same work measured 2.6× dearer beside one encoder and 3.7×
|
|
731
|
-
// beside two (2026-08-18). `concurrentBesideThis` already counts what is
|
|
732
|
-
// committed; this multiplies by how badly running at all together goes.
|
|
733
|
-
const othersRunning = concurrentBesideThis > 0 ? this.#encodersRunningNow() : 0;
|
|
734
|
-
const { penalty } = contentionPenalty(othersRunning, this.#host().contentionPenalties);
|
|
735
|
-
const onThisMachine = correctForAvailability(
|
|
736
|
-
speed === null ? null : speed / penalty,
|
|
737
|
-
this.#host().availability
|
|
738
|
-
);
|
|
739
|
-
// Kept against the step's own session, so that when it runs the field
|
|
740
|
-
// says what the prediction was worth. Without this the only comparison
|
|
741
|
-
// available is between two figures written minutes apart in different
|
|
742
|
-
// lines of the log.
|
|
743
|
-
predictedByHeight.set(height, onThisMachine);
|
|
744
795
|
if (onThisMachine !== null && onThisMachine >= bar) {
|
|
745
796
|
kept.push(height);
|
|
746
797
|
continue;
|
|
@@ -768,9 +819,7 @@ export class EncodeCost {
|
|
|
768
819
|
this.#lastOfferLine = line;
|
|
769
820
|
logger.info(line);
|
|
770
821
|
}
|
|
771
|
-
this.lastPredictedByHeight = predictedByHeight;
|
|
772
822
|
} else {
|
|
773
|
-
this.lastPredictedByHeight = predictedByHeight;
|
|
774
823
|
this.#lastOfferLine = "";
|
|
775
824
|
}
|
|
776
825
|
return kept;
|
|
@@ -859,9 +908,12 @@ export class EncodeCost {
|
|
|
859
908
|
if (!Number.isFinite(lastSaid) || Math.abs(ratio - lastSaid) > 0.1) {
|
|
860
909
|
state.lastPredictionRatio = ratio;
|
|
861
910
|
logger.info(
|
|
862
|
-
`prediction ${session.id.slice(0, 8)} ${session.output.encodeHeight || "source"}p
|
|
863
|
-
`predicted ${state.predictedSpeedWhenOffered.toFixed(2)}x, measured ${speed.toFixed(2)}x ` +
|
|
864
|
-
`(ratio ${ratio.toFixed(2)}; 1.00 would mean the arithmetic describes this machine)`
|
|
911
|
+
`prediction ${session.id.slice(0, 8)} ${session.output.encodeHeight || "source"}p ` +
|
|
912
|
+
`"${state.predictedMode}": predicted ${state.predictedSpeedWhenOffered.toFixed(2)}x, measured ${speed.toFixed(2)}x ` +
|
|
913
|
+
`(ratio ${ratio.toFixed(2)}; 1.00 would mean the arithmetic describes this machine)` +
|
|
914
|
+
(Number.isFinite(state.offeredSpeedAtCheapestMode)
|
|
915
|
+
? `; offered on ${state.offeredSpeedAtCheapestMode.toFixed(2)}x at the cheapest mode`
|
|
916
|
+
: "")
|
|
865
917
|
);
|
|
866
918
|
}
|
|
867
919
|
}
|
|
@@ -1041,3 +1093,23 @@ export class EncodeCost {
|
|
|
1041
1093
|
);
|
|
1042
1094
|
}
|
|
1043
1095
|
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* The startup measurement of the mode an output is encoded in.
|
|
1099
|
+
*
|
|
1100
|
+
* A mode is filed under its setting, or under the encoder's own name where the
|
|
1101
|
+
* encoder has one setting (`calibration.js`), so an output that names no setting
|
|
1102
|
+
* is matched to the one mode there is. Null when the output's setting was not
|
|
1103
|
+
* measured: its prediction is then unknown rather than borrowed from another
|
|
1104
|
+
* mode.
|
|
1105
|
+
*
|
|
1106
|
+
* @param {Array<{ preset: string }>} benchmark
|
|
1107
|
+
* @param {string | null | undefined} preset
|
|
1108
|
+
* @returns {{ preset: string } | null}
|
|
1109
|
+
*/
|
|
1110
|
+
export function modeEntryFor(benchmark, preset) {
|
|
1111
|
+
if (typeof preset === "string" && preset.length > 0) {
|
|
1112
|
+
return benchmark.find((entry) => entry.preset === preset) ?? null;
|
|
1113
|
+
}
|
|
1114
|
+
return benchmark.length === 1 ? benchmark[0] : null;
|
|
1115
|
+
}
|
|
@@ -51,7 +51,7 @@ export class QualityController {
|
|
|
51
51
|
#affirmed = new Set();
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
|
-
* @param {object} host - `isLive`, `liveConsumers`, `liveRunsOf`, `producedNumbers`, `reportHostLoad`, `runStateOf`, `sampleDownloadRates`, `encodeCost`, `outputs`, `qualityOffer`, `segmentDurationSec`, `segmentPaths`, `videoEncoder`, `prepareSameHeightSwitch`, `sameHeightSwitchPending`, `sameHeightSwitchDirection`, `cancelSameHeightSwitch`, `heightReadyFor`, `bufferOf`, `visiblePictureOf`, `
|
|
54
|
+
* @param {object} host - `isLive`, `liveConsumers`, `liveRunsOf`, `producedNumbers`, `reportHostLoad`, `runStateOf`, `sampleDownloadRates`, `encodeCost`, `outputs`, `qualityOffer`, `segmentDurationSec`, `segmentPaths`, `videoEncoder`, `prepareSameHeightSwitch`, `sameHeightSwitchPending`, `sameHeightSwitchDirection`, `cancelSameHeightSwitch`, `heightReadyFor`, `bufferOf`, `visiblePictureOf`, `computedPreparationSec`
|
|
55
55
|
* @param {SegmentPathLookup} host.segmentPaths - Lookup for completed
|
|
56
56
|
* segment paths needed by quality decisions.
|
|
57
57
|
*/
|
|
@@ -103,16 +103,20 @@ export class QualityController {
|
|
|
103
103
|
/**
|
|
104
104
|
* How long another output of the mode on this viewer's screen takes to be
|
|
105
105
|
* ready here, in seconds: as observed on this host (roadmap item 97, step
|
|
106
|
-
* 14), and otherwise this host's
|
|
107
|
-
*
|
|
106
|
+
* 14), and otherwise computed from this host's measured wait for a first
|
|
107
|
+
* output and this output's own measured speed (torrent-tv/meta#3). Null
|
|
108
|
+
* when neither can be said.
|
|
108
109
|
*
|
|
109
110
|
* @param {HlsSession} session
|
|
110
111
|
* @returns {number | null}
|
|
111
112
|
*/
|
|
112
113
|
#secondsToReady(session) {
|
|
113
114
|
const observedMs = this.#host.observedPreparationMs?.(session) ?? null;
|
|
114
|
-
|
|
115
|
-
|
|
115
|
+
if (Number.isFinite(observedMs) && observedMs >= 0) {
|
|
116
|
+
return observedMs / 1000;
|
|
117
|
+
}
|
|
118
|
+
const computed = this.#host.computedPreparationSec?.(session) ?? null;
|
|
119
|
+
return Number.isFinite(computed) && computed >= 0 ? computed : null;
|
|
116
120
|
}
|
|
117
121
|
|
|
118
122
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AudioTrack } from "./tracks/AudioTrack.js";
|
|
2
|
+
import { TextSubtitleTrack } from "./tracks/TextSubtitleTrack.js";
|
|
2
3
|
|
|
3
4
|
const VIDEO_CODECS = new Map([
|
|
4
5
|
["V_MPEG4/ISO/AVC", "h264"], ["avc1", "h264"], ["avc3", "h264"],
|
|
@@ -37,7 +38,11 @@ export function playbackDeclarations({ tracks, media, fileBytes = null }) {
|
|
|
37
38
|
...track, index, streamIndex: track.declaredIndex,
|
|
38
39
|
codec: type === "audio" ? AudioTrack.codecNameOf(track)
|
|
39
40
|
: SUBTITLE_CODECS.get(track.codecId) ?? track.codecId.toLowerCase(),
|
|
40
|
-
title: track.name ?? ""
|
|
41
|
+
title: track.name ?? "",
|
|
42
|
+
// The browser offers only tracks marked text-based: a picture subtitle
|
|
43
|
+
// cannot become WebVTT. Dropped when the plan moved to these declarations
|
|
44
|
+
// (torrent-tv/meta#95), which emptied every subtitle menu (#8).
|
|
45
|
+
...(type === "subtitle" ? { textBased: TextSubtitleTrack.isTextCodec(track.codecId) } : {})
|
|
41
46
|
}));
|
|
42
47
|
const audioTracks = inventory("audio"), subtitleTracks = inventory("subtitle");
|
|
43
48
|
return { tracks, video, audioTracks, subtitleTracks,
|
|
@@ -200,12 +200,6 @@ export function createPlaybackPlanner({
|
|
|
200
200
|
localBaseUrl,
|
|
201
201
|
sourceRegistry,
|
|
202
202
|
torrentPool,
|
|
203
|
-
// Optional. Reports what this host typically takes to produce a session's
|
|
204
|
-
// first segment. The browser needs it for the gap between "the file is
|
|
205
|
-
// downloaded" and "a segment exists": until now it assumed the pipeline
|
|
206
|
-
// merely keeps up with realtime, and showed 15 s where 3.8 s were left.
|
|
207
|
-
expectedFirstSegmentMs,
|
|
208
|
-
expectedSessionCreateMs,
|
|
209
203
|
// What the torrent holds beside this picture — its dubs, its subtitle files,
|
|
210
204
|
// its contact sheets — grouped by whoever knows what a torrent contains. This
|
|
211
205
|
// layer answers about ONE file and must not read a torrent's file list.
|
|
@@ -480,13 +474,11 @@ export function createPlaybackPlanner({
|
|
|
480
474
|
return { audioTracks: plan.audioTracks, pending: pendingIndexes.size > 0 };
|
|
481
475
|
}
|
|
482
476
|
|
|
483
|
-
function
|
|
477
|
+
function withLiveFigures(plan) {
|
|
484
478
|
const withOffer = {
|
|
485
479
|
...plan,
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
// Answered here for the same reason as the two above: a plan is cached for
|
|
489
|
-
// the life of the process, and what this host will serve a file at is not.
|
|
480
|
+
// Answered here, not when the plan is built: a plan is cached for the
|
|
481
|
+
// life of the process, and what this host will serve a file at is not.
|
|
490
482
|
// It starts as a prediction from the startup benchmarks and is replaced by
|
|
491
483
|
// what an encoder running on this very source turns out to cost — frozen
|
|
492
484
|
// into the cache, every later open of the file would hand the browser the
|
|
@@ -590,7 +582,7 @@ export function createPlaybackPlanner({
|
|
|
590
582
|
};
|
|
591
583
|
const cached = cache.get(cacheKey);
|
|
592
584
|
if (cached) {
|
|
593
|
-
return
|
|
585
|
+
return withLiveFigures(cached);
|
|
594
586
|
}
|
|
595
587
|
// Where the time before playback goes. `cold-start` already breaks down
|
|
596
588
|
// everything from the transcode-session request onwards, but the plan
|
|
@@ -634,7 +626,7 @@ export function createPlaybackPlanner({
|
|
|
634
626
|
subtitleTracks: []
|
|
635
627
|
};
|
|
636
628
|
cache.set(cacheKey, plan);
|
|
637
|
-
return
|
|
629
|
+
return withLiveFigures(plan);
|
|
638
630
|
}
|
|
639
631
|
|
|
640
632
|
// Structural reads state their exact missing bytes through
|
|
@@ -710,11 +702,9 @@ export function createPlaybackPlanner({
|
|
|
710
702
|
// all.
|
|
711
703
|
sidecarSubtitles: sidecars.subtitles,
|
|
712
704
|
sidecarImages: sidecars.images,
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
expectedFirstSegmentMs: null,
|
|
717
|
-
expectedSessionCreateMs: null,
|
|
705
|
+
// Filled in by `withLiveFigures` on the way out, never here: read at
|
|
706
|
+
// build time it would be frozen into the cached plan, which is the bug
|
|
707
|
+
// fixed in 2.9.106.
|
|
718
708
|
offeredHeights: null,
|
|
719
709
|
// What the offer is computed FROM, kept on the cached plan so the offer
|
|
720
710
|
// itself can be recomputed on every response. The figures are the
|
|
@@ -771,9 +761,9 @@ export function createPlaybackPlanner({
|
|
|
771
761
|
// Retain every declared stream count for failed-run diagnostics.
|
|
772
762
|
streamCounts: probe.streamCounts
|
|
773
763
|
});
|
|
774
|
-
return
|
|
764
|
+
return withLiveFigures(plan);
|
|
775
765
|
}
|
|
776
|
-
return
|
|
766
|
+
return withLiveFigures({ ...plan, pending: true });
|
|
777
767
|
}
|
|
778
768
|
};
|
|
779
769
|
}
|
|
@@ -52,7 +52,7 @@ export class SegmentServing {
|
|
|
52
52
|
#host;
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
|
-
* @param {object} host - `buildMasterPlaylist`, `declaredTracks`, `publishedGridFor`, `runStartTimeFor`, `cushion`, `encodeOrchestrator`, `encodeRuns`, `
|
|
55
|
+
* @param {object} host - `buildMasterPlaylist`, `declaredTracks`, `publishedGridFor`, `runStartTimeFor`, `cushion`, `encodeOrchestrator`, `encodeRuns`, `coldStarts`, `lookaheadSeconds`, `outputTimes`, `outputs`, `segmentStore`, `startupWaitMs`, `viewers`
|
|
56
56
|
*/
|
|
57
57
|
constructor(host) {
|
|
58
58
|
this.#host = host;
|
|
@@ -288,8 +288,8 @@ export class SegmentServing {
|
|
|
288
288
|
}
|
|
289
289
|
|
|
290
290
|
// Cold-start: the time from the create request to a playable first
|
|
291
|
-
// segment, measured once per output
|
|
292
|
-
const coldStartMs = isPlaylist ? null : this.#host.
|
|
291
|
+
// segment, measured once per output.
|
|
292
|
+
const coldStartMs = isPlaylist ? null : this.#host.coldStarts.noteSegmentServed(session);
|
|
293
293
|
if (coldStartMs !== null) {
|
|
294
294
|
// Data is flowing again, so the next loss starts its backoff afresh
|
|
295
295
|
// rather than inheriting the delay of the last one.
|
|
@@ -47,7 +47,7 @@ export class ViewerRequests {
|
|
|
47
47
|
#rateStateBySession = new WeakMap();
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
* @param {object} host - `opening`, `activeOutputFor`, `viewerSecondsOn`, `minimumBufferSecondsFor`, `getSourceStats`, `disposeSession`, `
|
|
50
|
+
* @param {object} host - `opening`, `activeOutputFor`, `viewerSecondsOn`, `minimumBufferSecondsFor`, `getSourceStats`, `disposeSession`, `planEncodersSoon`, `waitUntilReady`, `encodeRuns`, `encodeSpeedReadingOf`, `outputTimes`, `outputs`, `lookaheadSeconds`, `quality`, `qualityOffer`, `renditions`, `segmentDurationSec`, `segmentStore`, `sourceFiles`, `startupWaitMs`, `viewers`, `acceptsGeneration`, `generationOfRequest`, `noteGivenOutput`, `holdForResponse`, `noteServingVerdict`, `servingVerdictOf`
|
|
51
51
|
*/
|
|
52
52
|
constructor(host) {
|
|
53
53
|
this.#host = host;
|
|
@@ -531,15 +531,6 @@ export class ViewerRequests {
|
|
|
531
531
|
// confirmed), or `no measurement`. The page says which, so an estimate
|
|
532
532
|
// is never presented as a guarantee.
|
|
533
533
|
servingVerdict: this.#host.servingVerdictOf(consumerId),
|
|
534
|
-
// What this host takes to create a session and to make a first segment.
|
|
535
|
-
// Also on the playback plan, but the browser reads that once per file:
|
|
536
|
-
// measured 2026-08-06 across four seeks, a proxy that had just restarted
|
|
537
|
-
// answered null for both, and every later seek then computed its estimate
|
|
538
|
-
// with one term of four — the figure hit zero after 3.5 s of an 11.8 s
|
|
539
|
-
// wait and read "starting now" for the remaining 8.4 s. This response is
|
|
540
|
-
// polled about every 1.5 s, so carrying them here keeps them current.
|
|
541
|
-
expectedSessionCreateMs: this.#host.expectedSessionCreateMs(),
|
|
542
|
-
expectedFirstSegmentMs: this.#host.expectedFirstSegmentMs(),
|
|
543
534
|
updatedAt: progress.updatedAt,
|
|
544
535
|
error: this.#host.encodeRuns.failureOf(failed ?? session)
|
|
545
536
|
};
|
|
@@ -78,7 +78,7 @@ import { EncodeRuns } from "../encode/EncodeRuns.js";
|
|
|
78
78
|
import { EncodeInputs } from "../encode/EncodeInputs.js";
|
|
79
79
|
import { OutputTimes } from "../encode/OutputTimes.js";
|
|
80
80
|
import { HostLoad } from "../encode/quality/HostLoad.js";
|
|
81
|
-
import {
|
|
81
|
+
import { ColdStarts, secondsToFirstPiece } from "../encode/quality/ColdStarts.js";
|
|
82
82
|
import { BUDGET_CHECK_INTERVAL_MS, QualityController } from "../encode/quality/QualityController.js";
|
|
83
83
|
import { EncodeOrchestrator } from "../encode/EncodeOrchestrator.js";
|
|
84
84
|
import { EncodeAdmission } from "../encode/EncodeAdmission.js";
|
|
@@ -93,9 +93,8 @@ import { wireMachineBudget } from "../storage/wire.js";
|
|
|
93
93
|
import { Returns } from "../storage/returns.js";
|
|
94
94
|
import { freeBytesFor } from "../storage/free.js";
|
|
95
95
|
import path from "node:path";
|
|
96
|
-
import { contentOf, LocalObservations } from "../encode/LocalObservations.js";
|
|
96
|
+
import { contentOf, LocalObservations, PROXY_ROOT } from "../encode/LocalObservations.js";
|
|
97
97
|
import { configurationKeyOf } from "../encode/fingerprint.js";
|
|
98
|
-
import { PROXY_ROOT } from "../encode/quality/HostTimings.js";
|
|
99
98
|
import { qualityStateOf } from "../encode/quality/OutputQualityState.js";
|
|
100
99
|
|
|
101
100
|
// Where a variant and an audio rendition live under a session — `v/<height>/…`
|
|
@@ -488,7 +487,7 @@ export function wireOutputs({
|
|
|
488
487
|
get cushion() { return parts.cushion; },
|
|
489
488
|
get encodeOrchestrator() { return parts.encodeOrchestrator; },
|
|
490
489
|
get encodeRuns() { return parts.encodeRuns; },
|
|
491
|
-
get
|
|
490
|
+
get coldStarts() { return parts.coldStarts; },
|
|
492
491
|
get lookaheadSeconds() { return parts.lookaheadSeconds; },
|
|
493
492
|
get outputTimes() { return parts.outputTimes; },
|
|
494
493
|
get outputs() { return parts.outputs; },
|
|
@@ -539,7 +538,7 @@ export function wireOutputs({
|
|
|
539
538
|
get admission() { return parts.admission; },
|
|
540
539
|
get getCachedMediaInfo() { return parts.getCachedMediaInfo; },
|
|
541
540
|
get hostLoad() { return parts.hostLoad; },
|
|
542
|
-
get
|
|
541
|
+
get coldStarts() { return parts.coldStarts; },
|
|
543
542
|
get keyframeTables() { return parts.keyframeTables; },
|
|
544
543
|
get localBaseUrl() { return parts.localBaseUrl; },
|
|
545
544
|
get outputs() { return parts.outputs; },
|
|
@@ -564,8 +563,6 @@ export function wireOutputs({
|
|
|
564
563
|
minimumBufferSecondsFor: (output) => minimumBufferSecondsOf(output),
|
|
565
564
|
getSourceStats: (...args) => parts.getSourceStats?.(...args) ?? null,
|
|
566
565
|
disposeSession: (...args) => parts.lifecycle.disposeSession(...args),
|
|
567
|
-
expectedFirstSegmentMs: (...args) => parts.hostTimings.expectedFirstSegmentMs(...args),
|
|
568
|
-
expectedSessionCreateMs: (...args) => parts.hostTimings.expectedSessionCreateMs(...args),
|
|
569
566
|
planEncodersSoon: (...args) => parts.encodeRuns.planEncodersSoon(...args),
|
|
570
567
|
waitUntilReady: (...args) => parts.serving.waitUntilReady(...args),
|
|
571
568
|
get encodeRuns() { return parts.encodeRuns; },
|
|
@@ -618,12 +615,8 @@ export function wireOutputs({
|
|
|
618
615
|
noteServingVerdict: (consumerId, verdict) => noteServingVerdict(parts.viewers, consumerId, verdict),
|
|
619
616
|
servingVerdictOf: (consumerId) => servingVerdictOf(parts.viewers, consumerId),
|
|
620
617
|
});
|
|
621
|
-
//
|
|
622
|
-
parts.
|
|
623
|
-
get segmentDurationSec() { return parts.segmentDurationSec; },
|
|
624
|
-
get softwarePresetBenchmark() { return parts.softwarePresetBenchmark; },
|
|
625
|
-
get stateDir() { return parts.stateDir; },
|
|
626
|
-
});
|
|
618
|
+
// How long each output took to its first served segment, said once in the log.
|
|
619
|
+
parts.coldStarts = new ColdStarts();
|
|
627
620
|
parts.enabled = Boolean(enabled);
|
|
628
621
|
parts.ffmpegBin = ffmpegBin;
|
|
629
622
|
parts.sourceInputsFor = typeof sourceInputsFor === "function" ? sourceInputsFor : null;
|
|
@@ -776,9 +769,6 @@ export function wireOutputs({
|
|
|
776
769
|
fileLengthOf: (session) => parts.hostLoad.fileLengthByKey.get(session.file.key) ?? 0,
|
|
777
770
|
largestPieceOf: (address) => parts.segmentStore.largestPiece(address)
|
|
778
771
|
});
|
|
779
|
-
// What this host learned last time it ran. Without it every restart shows
|
|
780
|
-
// the first viewer a figure with no measurement behind it.
|
|
781
|
-
parts.hostTimings.loadHostTimings();
|
|
782
772
|
// Where produced segments live, addressed by WHAT they are rather than by
|
|
783
773
|
// which session's encoder wrote them. Two sessions of one output — two
|
|
784
774
|
// viewers who opened the same film at different places — write into one
|
|
@@ -1033,9 +1023,14 @@ export function wireOutputs({
|
|
|
1033
1023
|
// (roadmap item 98), as values.
|
|
1034
1024
|
bufferOf: (output, consumerId, spanSec) => bufferOf(parts.viewers, output, consumerId, spanSec),
|
|
1035
1025
|
visiblePictureOf: (output, consumerId) => visiblePictureOf(parts.viewers, output, consumerId),
|
|
1036
|
-
// How
|
|
1037
|
-
//
|
|
1038
|
-
|
|
1026
|
+
// How soon another output of this mode could have the piece a viewer needs,
|
|
1027
|
+
// computed from this host's measured wait for a first output and the
|
|
1028
|
+
// output's own measured speed.
|
|
1029
|
+
computedPreparationSec: (output) => secondsToFirstPiece({
|
|
1030
|
+
firstByteWaitSec: parts.encodeOrchestrator?.runCostSeconds().firstByteWaitSec ?? 0,
|
|
1031
|
+
segmentDurationSec: parts.segmentDurationSec,
|
|
1032
|
+
speed: parts.encodeCost.speedForOutput(output?.outputKey ?? "")
|
|
1033
|
+
}),
|
|
1039
1034
|
dropAskOf: (output, consumerId) => dropAskOf(parts.viewers, output, consumerId),
|
|
1040
1035
|
// EACH viewer's own link and who is present: a thin link decides for the
|
|
1041
1036
|
// person on it and for nobody else (roadmap item 97, step 11), so the
|