@torrent-tv/proxy 2.34.0 → 2.36.0
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/CHANGELOG.md +11 -0
- package/package.json +1 -1
- package/server.js +8 -1
- package/services/contention.js +128 -0
- package/services/hls-session-manager.js +365 -117
- package/services/hwaccel.js +142 -28
- package/services/learned-median.js +79 -0
- package/services/torrent-cost.js +88 -0
- package/test/contention.test.js +76 -0
- package/test/decode-cost.test.js +44 -12
- package/test/learned-median.test.js +37 -0
- package/test/torrent-cost.test.js +71 -0
|
@@ -21,7 +21,10 @@ import { readKeyframeIndex } from "./container-index/index.js";
|
|
|
21
21
|
import { readMachineState, readProcessCpuSeconds, readProxyCpuSeconds, readSystemCpu, shareOfMachine } from "./host-load.js";
|
|
22
22
|
import { speedFromReadings } from "./encoder-readings.js";
|
|
23
23
|
import { availableShareFrom, correctForAvailability } from "./available-share.js";
|
|
24
|
+
import { contentionPenalty } from "./contention.js";
|
|
24
25
|
import { minimumBufferFrom } from "./supply-margin.js";
|
|
26
|
+
import { baseDrawFrom, costPerMegabyteFrom } from "./torrent-cost.js";
|
|
27
|
+
import { medianOf, movedBeyondScatter, scatterOf } from "./learned-median.js";
|
|
25
28
|
import {
|
|
26
29
|
ENCODE_RUN_EVENT,
|
|
27
30
|
ENCODE_RUN_STATE,
|
|
@@ -39,7 +42,7 @@ import {
|
|
|
39
42
|
chooseSoftwareEncodeSettings,
|
|
40
43
|
pickSoftwarePreset,
|
|
41
44
|
canSustainOutput,
|
|
42
|
-
|
|
45
|
+
speedBar,
|
|
43
46
|
TRANSCODE_FPS,
|
|
44
47
|
chooseOutputFps
|
|
45
48
|
} from "./hwaccel.js";
|
|
@@ -265,7 +268,7 @@ export function noteIndexDeviation(check, index, deviationSec, landedOnKeyframe
|
|
|
265
268
|
* @param {{ ladder: { width: number, height: number }[], rungIndex: number } | null} budget
|
|
266
269
|
* @param {number} outputFps
|
|
267
270
|
* @param {unknown} benchmark
|
|
268
|
-
* @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null }} [cost]
|
|
271
|
+
* @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, requiredSpeed?: number | null }} [cost]
|
|
269
272
|
* @returns {object | null}
|
|
270
273
|
*/
|
|
271
274
|
function startAtLadderTop(budget, outputFps, benchmark, cost = {}) {
|
|
@@ -292,7 +295,8 @@ function startAtLadderTop(budget, outputFps, benchmark, cost = {}) {
|
|
|
292
295
|
decodeModel: cost.decodeModel ?? null,
|
|
293
296
|
source: cost.source ?? null,
|
|
294
297
|
outputPixelsPerSec: ladder[index].width * ladder[index].height * fps,
|
|
295
|
-
observedDecodeCostSec: cost.observedDecodeCostSec ?? null
|
|
298
|
+
observedDecodeCostSec: cost.observedDecodeCostSec ?? null,
|
|
299
|
+
requiredSpeed: cost.requiredSpeed ?? null
|
|
296
300
|
});
|
|
297
301
|
if (sustainable) {
|
|
298
302
|
startIndex = index;
|
|
@@ -495,8 +499,6 @@ const DEFAULT_STARTUP_WAIT_MS = 5_000;
|
|
|
495
499
|
// and restart at the current segment. Conservative so it never thrashes: a long
|
|
496
500
|
// sustained window, a post-action cooldown, a step cap, and no upswitch (v1).
|
|
497
501
|
const BUDGET_CHECK_INTERVAL_MS = 5_000;
|
|
498
|
-
/** Below this, a tick has not moved enough of the torrent to price it. */
|
|
499
|
-
const TORRENT_COST_MIN_MEGABYTES = 2;
|
|
500
502
|
/** The narrowest stretch of uninterrupted encoding a speed may be read from. */
|
|
501
503
|
const LEARN_WINDOW_MIN_SEC = 3;
|
|
502
504
|
// Speed below this (cumulative ffmpeg average) counts as "slow"; recovery to
|
|
@@ -512,18 +514,10 @@ const BUDGET_SUSTAINED_MS = 15_000;
|
|
|
512
514
|
const BUDGET_ACTION_COOLDOWN_MS = 30_000;
|
|
513
515
|
// Never step down more than this many rungs below the startup choice.
|
|
514
516
|
const BUDGET_MAX_DOWNSHIFTS = 3;
|
|
515
|
-
// How
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
// pieces all sit in the denominator of an early reading.
|
|
519
|
-
const DECODE_LEARNING_SETTLE_MS = 20_000;
|
|
520
|
-
// How many readings the median is taken over. Long enough to outvote a single
|
|
521
|
-
// disturbed moment, short enough to follow a host whose load has changed.
|
|
517
|
+
// How many readings the median is taken over. This one is a statement about
|
|
518
|
+
// how much of the past still describes the host, not a measured quantity, and
|
|
519
|
+
// it is written here rather than dressed up as one.
|
|
522
520
|
const DECODE_LEARNING_READINGS = 7;
|
|
523
|
-
// A new median has to differ by this much to be adopted. Below it the answer is
|
|
524
|
-
// the same one, and re-publishing it would make every session recompute its
|
|
525
|
-
// offer on the path that serves every playlist, init and segment.
|
|
526
|
-
const DECODE_LEARNING_CHANGE = 0.05;
|
|
527
521
|
// The input counts as "keeping up" when the torrent downloads at least this
|
|
528
522
|
// multiple of the source's average byte rate. Below it (and not yet fully
|
|
529
523
|
// downloaded), a low speed is download-bound, not CPU-bound → do NOT downscale.
|
|
@@ -1228,31 +1222,6 @@ function normalizeLogFileName(fileName, fileIndex) {
|
|
|
1228
1222
|
* combination. Sessions are reused across consumers and are automatically
|
|
1229
1223
|
* expired after {@link HlsSessionManagerOptions.sessionTtlMs} of idle time.
|
|
1230
1224
|
*/
|
|
1231
|
-
/**
|
|
1232
|
-
* How many megabytes a second of this source is, from what the probe read.
|
|
1233
|
-
*
|
|
1234
|
-
* A viewer consumes the file at its own rate, so this is also the rate at which
|
|
1235
|
-
* the machine must fetch, verify and deliver it while they watch.
|
|
1236
|
-
*
|
|
1237
|
-
* @param {HlsSession} session
|
|
1238
|
-
* @param {number | null} fileLengthBytes
|
|
1239
|
-
* @returns {number | null}
|
|
1240
|
-
*/
|
|
1241
|
-
|
|
1242
|
-
function sourceMegabytesPerSecond(session, fileLengthBytes) {
|
|
1243
|
-
// The FILE's rate, not the video stream's. What the torrent moves is the
|
|
1244
|
-
// container: on the releases this serves, two or three AC-3 tracks add 10-25 %
|
|
1245
|
-
// to what the picture alone would suggest, and `sourceDecode` deliberately
|
|
1246
|
-
// carries the video stream's own bitrate because the decode model was fitted
|
|
1247
|
-
// on video-only clips.
|
|
1248
|
-
const fileLength = Number(fileLengthBytes);
|
|
1249
|
-
const durationSeconds = Number(session.durationSeconds);
|
|
1250
|
-
if (Number.isFinite(fileLength) && fileLength > 0 && Number.isFinite(durationSeconds) && durationSeconds > 0) {
|
|
1251
|
-
return fileLength / durationSeconds / 1e6;
|
|
1252
|
-
}
|
|
1253
|
-
return null;
|
|
1254
|
-
}
|
|
1255
|
-
|
|
1256
1225
|
/**
|
|
1257
1226
|
* Which cost a speed reading from this session is a measurement OF.
|
|
1258
1227
|
*
|
|
@@ -1356,6 +1325,32 @@ export class HlsSessionManager {
|
|
|
1356
1325
|
#observedTorrentCostPerMegabyte = null;
|
|
1357
1326
|
/** @type {number[]} Recent readings behind that median. */
|
|
1358
1327
|
#torrentCostReadings = [];
|
|
1328
|
+
/**
|
|
1329
|
+
* The share of one core this process draws with nothing encoding and the
|
|
1330
|
+
* torrents moving nothing — the spending that would have happened anyway, and
|
|
1331
|
+
* which must come off a reading before the rest is called the torrent's.
|
|
1332
|
+
*/
|
|
1333
|
+
#observedBaseDraw = null;
|
|
1334
|
+
/** @type {number[]} Recent readings behind that median. */
|
|
1335
|
+
#baseDrawReadings = [];
|
|
1336
|
+
/**
|
|
1337
|
+
* What each watched TORRENT is measured to be moving right now, in bytes per
|
|
1338
|
+
* second, keyed by source. Rebuilt every budget tick from the live sessions,
|
|
1339
|
+
* so an entry that is present was taken this tick.
|
|
1340
|
+
*
|
|
1341
|
+
* @type {Map<string, number>}
|
|
1342
|
+
*/
|
|
1343
|
+
#downloadRateByKey = new Map();
|
|
1344
|
+
/**
|
|
1345
|
+
* The speed each file's own interruptions were last measured to demand,
|
|
1346
|
+
* keyed `sourceKey:fileIndex`. Kept per source rather than per session
|
|
1347
|
+
* because the first offer for a file is made before any session of it exists,
|
|
1348
|
+
* and a file that has been watched before has already told the reader what
|
|
1349
|
+
* its swarm does.
|
|
1350
|
+
*
|
|
1351
|
+
* @type {Map<string, number>}
|
|
1352
|
+
*/
|
|
1353
|
+
#requiredSpeedByKey = new Map();
|
|
1359
1354
|
|
|
1360
1355
|
/**
|
|
1361
1356
|
* @param {HlsSessionManagerOptions} options
|
|
@@ -1372,6 +1367,10 @@ export class HlsSessionManager {
|
|
|
1372
1367
|
softwarePresetBenchmark = null,
|
|
1373
1368
|
decodeCostModel = null,
|
|
1374
1369
|
getSourceStats = null,
|
|
1370
|
+
// What a second job costs on this host, measured at startup. Null when it
|
|
1371
|
+
// could not be measured, and then nothing is corrected — the alternative
|
|
1372
|
+
// is inventing a penalty, which is the same fault as inventing a fill rate.
|
|
1373
|
+
contentionPenalties = null,
|
|
1375
1374
|
tonemapSupported = false,
|
|
1376
1375
|
getCachedMediaInfo = null,
|
|
1377
1376
|
getCachedAudioTracks = null,
|
|
@@ -1398,6 +1397,7 @@ export class HlsSessionManager {
|
|
|
1398
1397
|
// realtime budget to tell a CPU limit from a download-starved input:
|
|
1399
1398
|
// (sourceKey, fileIndex) => Promise<{ downloadSpeed, fileLength, fileProgress } | null>.
|
|
1400
1399
|
this.getSourceStats = typeof getSourceStats === "function" ? getSourceStats : null;
|
|
1400
|
+
this.contentionPenalties = contentionPenalties instanceof Map ? contentionPenalties : null;
|
|
1401
1401
|
// Totals across every torrent this proxy holds, used to price what the
|
|
1402
1402
|
// torrent itself costs the machine (item 7). Optional: a proxy wired
|
|
1403
1403
|
// without it simply never learns that figure.
|
|
@@ -1821,7 +1821,8 @@ export class HlsSessionManager {
|
|
|
1821
1821
|
sourceWidth,
|
|
1822
1822
|
sourceHeight,
|
|
1823
1823
|
outputFps,
|
|
1824
|
-
source: sourceDecode
|
|
1824
|
+
source: sourceDecode,
|
|
1825
|
+
requiredSpeed: this.#requiredSpeedFor(sourceKey, fileIndex)
|
|
1825
1826
|
});
|
|
1826
1827
|
// A forced resolution starts at exactly that size — the viewer asked for it
|
|
1827
1828
|
// — but KEEPS the ladder beneath it. Discarding the ladder is what left a
|
|
@@ -1835,7 +1836,8 @@ export class HlsSessionManager {
|
|
|
1835
1836
|
? startAtLadderTop(chosenBudget, outputFps, this.softwarePresetBenchmark, {
|
|
1836
1837
|
decodeModel: this.decodeCostModel,
|
|
1837
1838
|
source: sourceDecode,
|
|
1838
|
-
observedDecodeCostSec: this.#observedDecodeCost.get(`${sourceKey}:${fileIndex}`)?.costSec ?? null
|
|
1839
|
+
observedDecodeCostSec: this.#observedDecodeCost.get(`${sourceKey}:${fileIndex}`)?.costSec ?? null,
|
|
1840
|
+
requiredSpeed: this.#requiredSpeedFor(sourceKey, fileIndex)
|
|
1839
1841
|
})
|
|
1840
1842
|
: chosenBudget;
|
|
1841
1843
|
const softwarePreset = encodeBudget?.preset ?? null;
|
|
@@ -2518,7 +2520,16 @@ export class HlsSessionManager {
|
|
|
2518
2520
|
* @param {{ transcodeVideo: boolean, targetWidth: number, targetHeight: number, sourceWidth: number | null, sourceHeight: number | null, outputFps: number, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null }} params
|
|
2519
2521
|
* @returns {{ width: number, height: number, preset: string } | null}
|
|
2520
2522
|
*/
|
|
2521
|
-
#chooseEncodeBudget({
|
|
2523
|
+
#chooseEncodeBudget({
|
|
2524
|
+
transcodeVideo,
|
|
2525
|
+
targetWidth,
|
|
2526
|
+
targetHeight,
|
|
2527
|
+
sourceWidth,
|
|
2528
|
+
sourceHeight,
|
|
2529
|
+
outputFps,
|
|
2530
|
+
source = null,
|
|
2531
|
+
requiredSpeed = null
|
|
2532
|
+
}) {
|
|
2522
2533
|
if (!transcodeVideo || this.videoEncoder?.kind !== "software" || !this.softwarePresetBenchmark) {
|
|
2523
2534
|
return null;
|
|
2524
2535
|
}
|
|
@@ -2530,7 +2541,7 @@ export class HlsSessionManager {
|
|
|
2530
2541
|
this.softwarePresetBenchmark,
|
|
2531
2542
|
{ width: ceiling.w, height: ceiling.h },
|
|
2532
2543
|
outputFps,
|
|
2533
|
-
{ decodeModel: this.decodeCostModel, source }
|
|
2544
|
+
{ decodeModel: this.decodeCostModel, source, requiredSpeed }
|
|
2534
2545
|
);
|
|
2535
2546
|
}
|
|
2536
2547
|
|
|
@@ -2916,6 +2927,26 @@ export class HlsSessionManager {
|
|
|
2916
2927
|
* @param {string} event - One of {@link ENCODE_RUN_EVENT}.
|
|
2917
2928
|
* @returns {string} The state now in force.
|
|
2918
2929
|
*/
|
|
2930
|
+
/**
|
|
2931
|
+
* How many of this proxy's encoders are running right now.
|
|
2932
|
+
*
|
|
2933
|
+
* Suspended runs are not counted: a process stopped by the look-ahead cap
|
|
2934
|
+
* competes for nothing, and counting it would price a machine as busier than
|
|
2935
|
+
* it is — the same distinction the host-load line had to learn (2026-08-15,
|
|
2936
|
+
* `ffmpeg=0% system=24%` with both encoders parked).
|
|
2937
|
+
*
|
|
2938
|
+
* @returns {number}
|
|
2939
|
+
*/
|
|
2940
|
+
#encodersRunningNow() {
|
|
2941
|
+
let running = 0;
|
|
2942
|
+
for (const session of this.sessionsById.values()) {
|
|
2943
|
+
if (session.runState === ENCODE_RUN_STATE.STARTING || session.runState === ENCODE_RUN_STATE.PRODUCING) {
|
|
2944
|
+
running += 1;
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
return running;
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2919
2950
|
#transitionRun(session, event) {
|
|
2920
2951
|
const from = session.runState ?? INITIAL_RUN_STATE;
|
|
2921
2952
|
const to = nextState(from, event);
|
|
@@ -3095,24 +3126,184 @@ export class HlsSessionManager {
|
|
|
3095
3126
|
// was the difference between offering it and refusing it.
|
|
3096
3127
|
const cores = Math.max(1, os.cpus().length);
|
|
3097
3128
|
const cpuSeconds = (now.cpuSeconds - previous.cpuSeconds) / cores;
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3129
|
+
if (megabytes === 0) {
|
|
3130
|
+
// Nothing encoding and not one byte moved: whatever this process spent in
|
|
3131
|
+
// that interval, it spends whether or not there is a torrent. Measuring
|
|
3132
|
+
// it is what lets the next interval be attributed instead of divided
|
|
3133
|
+
// whole — see `torrent-cost.js` for the readings that forced this.
|
|
3134
|
+
this.#learnBaseDraw(baseDrawFrom({ cpuSeconds, elapsedSeconds: elapsedSec }));
|
|
3135
|
+
return;
|
|
3136
|
+
}
|
|
3137
|
+
const costPerMegabyte = costPerMegabyteFrom({
|
|
3138
|
+
cpuSeconds,
|
|
3139
|
+
elapsedSeconds: elapsedSec,
|
|
3140
|
+
megabytes,
|
|
3141
|
+
baseDraw: this.#observedBaseDraw,
|
|
3142
|
+
// How much the draw's own readings disagree, which is how much of this
|
|
3143
|
+
// interval's remainder means nothing.
|
|
3144
|
+
drawScatter: scatterOf(this.#baseDrawReadings)
|
|
3145
|
+
});
|
|
3146
|
+
if (costPerMegabyte === null) {
|
|
3101
3147
|
return;
|
|
3102
3148
|
}
|
|
3103
|
-
const costPerMegabyte = cpuSeconds / megabytes;
|
|
3104
3149
|
const readings = [...this.#torrentCostReadings, costPerMegabyte].slice(-DECODE_LEARNING_READINGS);
|
|
3105
3150
|
this.#torrentCostReadings = readings;
|
|
3106
|
-
const
|
|
3107
|
-
|
|
3108
|
-
if (this.#observedTorrentCostPerMegabyte !== null &&
|
|
3109
|
-
Math.abs(median - this.#observedTorrentCostPerMegabyte) / this.#observedTorrentCostPerMegabyte < DECODE_LEARNING_CHANGE) {
|
|
3151
|
+
const median = medianOf(readings);
|
|
3152
|
+
if (!movedBeyondScatter(this.#observedTorrentCostPerMegabyte, median, readings)) {
|
|
3110
3153
|
return;
|
|
3111
3154
|
}
|
|
3112
3155
|
this.#observedTorrentCostPerMegabyte = median;
|
|
3113
3156
|
logger.info(
|
|
3114
3157
|
`host-load: the torrent costs ${(median * 1000).toFixed(1)}ms of CPU per MB on this host ` +
|
|
3115
|
-
`(median of ${readings.length}, latest ${(costPerMegabyte * 1000).toFixed(1)}ms over ${megabytes.toFixed(1)}MB
|
|
3158
|
+
`(median of ${readings.length}, latest ${(costPerMegabyte * 1000).toFixed(1)}ms over ${megabytes.toFixed(1)}MB, ` +
|
|
3159
|
+
`base draw ${((this.#observedBaseDraw ?? 0) * 100).toFixed(1)}% of a core already taken off)`
|
|
3160
|
+
);
|
|
3161
|
+
}
|
|
3162
|
+
|
|
3163
|
+
/**
|
|
3164
|
+
* What each watched file's torrent is moving right now.
|
|
3165
|
+
*
|
|
3166
|
+
* The torrent is priced per megabyte it moves, so the price has to be charged
|
|
3167
|
+
* against the megabytes it IS moving. Charged against the file's own byte
|
|
3168
|
+
* rate — what the viewer consumes — it asks for payment on a fully downloaded
|
|
3169
|
+
* file that is moving nothing, and it under-charges a file being fetched
|
|
3170
|
+
* ahead of the viewer, which is the state every session starts in.
|
|
3171
|
+
*
|
|
3172
|
+
* Rebuilt whole each tick from the live sessions, so an entry that is here
|
|
3173
|
+
* was taken this tick and a source nobody is watching leaves by itself.
|
|
3174
|
+
*
|
|
3175
|
+
* @returns {Promise<void>}
|
|
3176
|
+
*/
|
|
3177
|
+
async #sampleDownloadRates() {
|
|
3178
|
+
if (!this.getSourceStats) {
|
|
3179
|
+
return;
|
|
3180
|
+
}
|
|
3181
|
+
/** @type {Map<string, { sourceKey: string, fileIndex: number }>} */
|
|
3182
|
+
const wanted = new Map();
|
|
3183
|
+
for (const session of this.sessionsById.values()) {
|
|
3184
|
+
if (!session || session.state === "disposed") {
|
|
3185
|
+
continue;
|
|
3186
|
+
}
|
|
3187
|
+
wanted.set(`${session.sourceKey}:${session.fileIndex}`, {
|
|
3188
|
+
sourceKey: session.sourceKey,
|
|
3189
|
+
fileIndex: session.fileIndex
|
|
3190
|
+
});
|
|
3191
|
+
}
|
|
3192
|
+
/** @type {Map<string, number>} */
|
|
3193
|
+
const measured = new Map();
|
|
3194
|
+
for (const [key, source] of wanted) {
|
|
3195
|
+
try {
|
|
3196
|
+
const stats = await this.getSourceStats(source.sourceKey, source.fileIndex);
|
|
3197
|
+
// The TORRENT's rate, which is what it is: one swarm feeding one
|
|
3198
|
+
// client, whichever of its files are being read. Kept per source and
|
|
3199
|
+
// divided among the files being watched, so two episodes of one pack
|
|
3200
|
+
// do not each charge the machine for the whole download.
|
|
3201
|
+
const rate = Number(stats?.downloadSpeed);
|
|
3202
|
+
if (Number.isFinite(rate) && rate >= 0) {
|
|
3203
|
+
measured.set(source.sourceKey, rate);
|
|
3204
|
+
}
|
|
3205
|
+
// Kept, not rebuilt: the demand a swarm made on this file does not stop
|
|
3206
|
+
// being true when a tick fails to fetch it, and it is what the FIRST
|
|
3207
|
+
// offer of the next session will be judged against.
|
|
3208
|
+
const demanded = Number(stats?.supply?.requiredSpeed);
|
|
3209
|
+
if (Number.isFinite(demanded) && demanded > 0) {
|
|
3210
|
+
this.#requiredSpeedByKey.set(key, demanded);
|
|
3211
|
+
}
|
|
3212
|
+
// And onto the sessions themselves, which is where the browser's
|
|
3213
|
+
// minimum buffer is read from. Set only by the downshift check until
|
|
3214
|
+
// now, it stood still on every session that never fell below realtime,
|
|
3215
|
+
// so the figures the viewer waits on were minutes old or absent.
|
|
3216
|
+
if (stats?.supply) {
|
|
3217
|
+
for (const session of this.sessionsById.values()) {
|
|
3218
|
+
if (session?.sourceKey === source.sourceKey && session.fileIndex === source.fileIndex) {
|
|
3219
|
+
session.supplyFigures = stats.supply;
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
3222
|
+
}
|
|
3223
|
+
} catch {
|
|
3224
|
+
// The pool is busy or gone. A reading missed is not a fault, and the
|
|
3225
|
+
// key simply does not appear this tick.
|
|
3226
|
+
}
|
|
3227
|
+
}
|
|
3228
|
+
this.#downloadRateByKey = measured;
|
|
3229
|
+
}
|
|
3230
|
+
|
|
3231
|
+
/**
|
|
3232
|
+
* How many files of one torrent have a live session reading them.
|
|
3233
|
+
*
|
|
3234
|
+
* @param {string} sourceKey
|
|
3235
|
+
* @returns {number} At least one, so the rate is never divided by nothing.
|
|
3236
|
+
*/
|
|
3237
|
+
#filesWatchedOn(sourceKey) {
|
|
3238
|
+
const files = new Set();
|
|
3239
|
+
for (const session of this.sessionsById.values()) {
|
|
3240
|
+
if (session && session.state !== "disposed" && session.sourceKey === sourceKey) {
|
|
3241
|
+
files.add(session.fileIndex);
|
|
3242
|
+
}
|
|
3243
|
+
}
|
|
3244
|
+
return Math.max(1, files.size);
|
|
3245
|
+
}
|
|
3246
|
+
|
|
3247
|
+
/**
|
|
3248
|
+
* The speed this file's supply demands, as last measured on this swarm.
|
|
3249
|
+
*
|
|
3250
|
+
* @param {string} sourceKey
|
|
3251
|
+
* @param {number} fileIndex
|
|
3252
|
+
* @returns {number | null}
|
|
3253
|
+
*/
|
|
3254
|
+
#requiredSpeedFor(sourceKey, fileIndex) {
|
|
3255
|
+
return this.#requiredSpeedByKey.get(`${sourceKey}:${fileIndex}`) ?? null;
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3258
|
+
/**
|
|
3259
|
+
* How many megabytes a second the torrent is moving for this file — measured
|
|
3260
|
+
* where a reading exists, and otherwise the rate the file has to be moved at
|
|
3261
|
+
* to be watched at all (its length over its duration), which is what the
|
|
3262
|
+
* measured rate averages to over a viewing.
|
|
3263
|
+
*
|
|
3264
|
+
* @param {string} sourceKey
|
|
3265
|
+
* @param {number} fileIndex
|
|
3266
|
+
* @param {number | null} fileLengthBytes
|
|
3267
|
+
* @param {number | null} durationSeconds
|
|
3268
|
+
* @returns {number | null}
|
|
3269
|
+
*/
|
|
3270
|
+
#torrentMegabytesPerSecond(sourceKey, fileIndex, fileLengthBytes, durationSeconds) {
|
|
3271
|
+
const measured = this.#downloadRateByKey.get(sourceKey);
|
|
3272
|
+
if (Number.isFinite(measured)) {
|
|
3273
|
+
return measured / 1e6 / this.#filesWatchedOn(sourceKey);
|
|
3274
|
+
}
|
|
3275
|
+
// The FILE's rate, not the video stream's. What the torrent moves is the
|
|
3276
|
+
// container: on the releases this serves, two or three AC-3 tracks add
|
|
3277
|
+
// 10-25 % to what the picture alone would suggest.
|
|
3278
|
+
const fileLength = Number(fileLengthBytes);
|
|
3279
|
+
const duration = Number(durationSeconds);
|
|
3280
|
+
if (Number.isFinite(fileLength) && fileLength > 0 && Number.isFinite(duration) && duration > 0) {
|
|
3281
|
+
return fileLength / duration / 1e6;
|
|
3282
|
+
}
|
|
3283
|
+
return null;
|
|
3284
|
+
}
|
|
3285
|
+
|
|
3286
|
+
/**
|
|
3287
|
+
* Record what this process draws when it is doing none of the work that gets
|
|
3288
|
+
* priced.
|
|
3289
|
+
*
|
|
3290
|
+
* @param {number | null} share - Of one core, over the interval just read.
|
|
3291
|
+
* @returns {void}
|
|
3292
|
+
*/
|
|
3293
|
+
#learnBaseDraw(share) {
|
|
3294
|
+
if (share === null) {
|
|
3295
|
+
return;
|
|
3296
|
+
}
|
|
3297
|
+
const readings = [...this.#baseDrawReadings, share].slice(-DECODE_LEARNING_READINGS);
|
|
3298
|
+
this.#baseDrawReadings = readings;
|
|
3299
|
+
const median = medianOf(readings);
|
|
3300
|
+
if (!movedBeyondScatter(this.#observedBaseDraw, median, readings)) {
|
|
3301
|
+
return;
|
|
3302
|
+
}
|
|
3303
|
+
this.#observedBaseDraw = median;
|
|
3304
|
+
logger.info(
|
|
3305
|
+
`host-load: this process draws ${(median * 100).toFixed(1)}% of a core with nothing encoding and ` +
|
|
3306
|
+
`nothing downloading (median of ${readings.length}, latest ${(share * 100).toFixed(1)}%)`
|
|
3116
3307
|
);
|
|
3117
3308
|
}
|
|
3118
3309
|
|
|
@@ -3249,18 +3440,22 @@ export class HlsSessionManager {
|
|
|
3249
3440
|
|
|
3250
3441
|
async #enforceRealtimeBudget() {
|
|
3251
3442
|
void this.#reportHostLoad();
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
//
|
|
3256
|
-
//
|
|
3257
|
-
// two passes over the same sessions, taking the same reading twice and
|
|
3258
|
-
// acting on the same speed twice.
|
|
3443
|
+
// One tick at a time. Both halves await torrent statistics per source, so a
|
|
3444
|
+
// slow or stuck answer would otherwise let the next tick in behind it — two
|
|
3445
|
+
// passes over the same sessions, taking the same reading twice and acting
|
|
3446
|
+
// on the same speed twice, and an earlier tick's rates landing on top of a
|
|
3447
|
+
// later tick's.
|
|
3259
3448
|
if (this.budgetTickRunning === true) {
|
|
3260
3449
|
return;
|
|
3261
3450
|
}
|
|
3262
3451
|
this.budgetTickRunning = true;
|
|
3263
3452
|
try {
|
|
3453
|
+
// Taken whatever the encoder is: the torrent's price is charged against
|
|
3454
|
+
// this rate on every host, not only on the ones that re-encode.
|
|
3455
|
+
await this.#sampleDownloadRates();
|
|
3456
|
+
if (this.videoEncoder?.kind !== "software") {
|
|
3457
|
+
return;
|
|
3458
|
+
}
|
|
3264
3459
|
await this.#realtimeBudgetPass();
|
|
3265
3460
|
} finally {
|
|
3266
3461
|
this.budgetTickRunning = false;
|
|
@@ -3432,7 +3627,9 @@ export class HlsSessionManager {
|
|
|
3432
3627
|
{
|
|
3433
3628
|
decodeModel: this.decodeCostModel,
|
|
3434
3629
|
source: session.sourceDecode ?? null,
|
|
3435
|
-
observedDecodeCostSec: this.#observedDecodeCostFor(session)
|
|
3630
|
+
observedDecodeCostSec: this.#observedDecodeCostFor(session),
|
|
3631
|
+
requiredSpeed: session.supplyFigures?.requiredSpeed
|
|
3632
|
+
?? this.#requiredSpeedFor(session.sourceKey, session.fileIndex)
|
|
3436
3633
|
}
|
|
3437
3634
|
);
|
|
3438
3635
|
// Restart at the current live-edge segment so the lighter profile takes over
|
|
@@ -3497,10 +3694,6 @@ export class HlsSessionManager {
|
|
|
3497
3694
|
// produce a file that is neither, which is the only reason a restart ever
|
|
3498
3695
|
// had to wait for its predecessor to die.
|
|
3499
3696
|
session.runSerial = (session.runSerial ?? 0) + 1;
|
|
3500
|
-
// When THIS run began. ffmpeg's `speed=` is cumulative over a run, so a
|
|
3501
|
-
// reading of it says something about the machine only once the run has left
|
|
3502
|
-
// its own start behind — see #learnDecodeCost.
|
|
3503
|
-
session.encodeRunStartedAt = Date.now();
|
|
3504
3697
|
session.runDirPath = path.join(session.dirPath, `run-${session.runSerial}`);
|
|
3505
3698
|
await mkdir(session.runDirPath, { recursive: true });
|
|
3506
3699
|
// The restart backs off a segment or two from what was asked for, so the
|
|
@@ -5262,9 +5455,24 @@ export class HlsSessionManager {
|
|
|
5262
5455
|
return `${speed < 1 ? "slow" : "ok"}${(1 / speed).toFixed(2)}`;
|
|
5263
5456
|
})
|
|
5264
5457
|
.join(",");
|
|
5458
|
+
// The bar the answer is judged against, and the rate the torrent's price is
|
|
5459
|
+
// charged at. Both are inputs now — the bar rises when the reader meets
|
|
5460
|
+
// interruptions, the rate moves every five seconds — and neither moves any
|
|
5461
|
+
// other term of this key. Left out, a menu computed while nothing was known
|
|
5462
|
+
// about the swarm would stand for the whole film, offering steps that
|
|
5463
|
+
// supply cannot support and passing every route guard on the way.
|
|
5464
|
+
const demanded = owner.supplyFigures?.requiredSpeed
|
|
5465
|
+
?? this.#requiredSpeedFor(owner.sourceKey, owner.fileIndex);
|
|
5466
|
+
const movingMegabytes = this.#torrentMegabytesPerSecond(
|
|
5467
|
+
owner.sourceKey,
|
|
5468
|
+
owner.fileIndex,
|
|
5469
|
+
this.#fileLengthByKey.get(`${owner.sourceKey}:${owner.fileIndex}`) ?? null,
|
|
5470
|
+
owner.durationSeconds
|
|
5471
|
+
);
|
|
5265
5472
|
const version =
|
|
5266
5473
|
`${observed?.version ?? 0}:${playing}:${copyVersion}:${torrentCost.toFixed(6)}:` +
|
|
5267
|
-
`${audioVersion}:${running}:${measured}
|
|
5474
|
+
`${audioVersion}:${running}:${measured}:${(demanded ?? 0).toFixed(2)}:` +
|
|
5475
|
+
`${(movingMegabytes ?? 0).toFixed(2)}`;
|
|
5268
5476
|
if (Array.isArray(owner.offeredHeightsCache) && owner.offeredHeightsVersion === version) {
|
|
5269
5477
|
return owner.offeredHeightsCache;
|
|
5270
5478
|
}
|
|
@@ -5287,6 +5495,10 @@ export class HlsSessionManager {
|
|
|
5287
5495
|
// What each rung was actually seen doing in this session, which is the
|
|
5288
5496
|
// only thing a live reading may speak for.
|
|
5289
5497
|
measuredHeights: this.#measuredRungSpeeds(owner),
|
|
5498
|
+
// The speed this file's supply demands, measured by its own reader on
|
|
5499
|
+
// this swarm. A well-seeded film and a thin one ask different speeds of
|
|
5500
|
+
// the same machine, so the bar belongs to the pair, not to the host.
|
|
5501
|
+
requiredSpeed: demanded,
|
|
5290
5502
|
// What the family is already spending while a rung is considered. The
|
|
5291
5503
|
// picture being COPIED is the common case and used to be priced at
|
|
5292
5504
|
// nothing; measured, it is about an eighth of the machine.
|
|
@@ -5349,17 +5561,20 @@ export class HlsSessionManager {
|
|
|
5349
5561
|
* whatever else it is doing.
|
|
5350
5562
|
*
|
|
5351
5563
|
* The MEDIAN of the recent readings is used, over a bounded window. Keeping
|
|
5352
|
-
* the fastest instead makes the figure a ratchet:
|
|
5353
|
-
*
|
|
5354
|
-
*
|
|
5355
|
-
*
|
|
5356
|
-
*
|
|
5357
|
-
*
|
|
5358
|
-
*
|
|
5359
|
-
*
|
|
5360
|
-
*
|
|
5361
|
-
*
|
|
5362
|
-
*
|
|
5564
|
+
* the fastest instead makes the figure a ratchet: its maximum falls in the
|
|
5565
|
+
* burst where the encoder races to the look-ahead cap with the pieces already
|
|
5566
|
+
* on disk and nothing competing, and one such moment would re-admit —
|
|
5567
|
+
* permanently — the very rung the field measured at 0.388-0.947x. The median
|
|
5568
|
+
* moves in both directions and describes the machine as it usually is, which
|
|
5569
|
+
* is what a viewer will meet.
|
|
5570
|
+
*
|
|
5571
|
+
* The reading is the difference between two samples of one run: `speed=`
|
|
5572
|
+
* itself is cumulative and would carry the restart, the resume and the wait
|
|
5573
|
+
* for the first pieces in its denominator, but a difference cannot — and a
|
|
5574
|
+
* new run clears the previous sample (`#startEncodeRun`), so no pair can
|
|
5575
|
+
* straddle two runs. That is why nothing here waits a fixed twenty seconds
|
|
5576
|
+
* before believing a run: waiting was a chosen number standing in for this,
|
|
5577
|
+
* and it cost every reading a short run could have given.
|
|
5363
5578
|
*
|
|
5364
5579
|
* @param {HlsSession} session
|
|
5365
5580
|
* @param {number} speed - The `speed=` ffmpeg reports, as a multiple of realtime.
|
|
@@ -5424,10 +5639,23 @@ export class HlsSessionManager {
|
|
|
5424
5639
|
const processedSeconds = Number(session.progress?.processedSeconds);
|
|
5425
5640
|
const takenAt = Date.now();
|
|
5426
5641
|
const previous = session.learnSample ?? null;
|
|
5427
|
-
|
|
5642
|
+
// Stamped with the run it was taken from. A restart clears this sample, but
|
|
5643
|
+
// it then spends up to a second and a half making its directory and burying
|
|
5644
|
+
// its predecessor, and through that window the session still carries the
|
|
5645
|
+
// OLD process and the OLD position — so a sample taken there, paired with
|
|
5646
|
+
// the new run's first position, reads a twenty-minute seek as twenty
|
|
5647
|
+
// minutes of video produced in five seconds. Filed as this file's price it
|
|
5648
|
+
// admits every quality step there is. Comparing the serials is what the
|
|
5649
|
+
// twenty-second wait used to stand in for, and unlike the wait it costs no
|
|
5650
|
+
// readings on a short run.
|
|
5651
|
+
const runSerial = session.runSerial ?? 0;
|
|
5652
|
+
session.learnSample = { takenAt, processedSeconds, runSerial };
|
|
5428
5653
|
if (previous === null || !Number.isFinite(processedSeconds) || !Number.isFinite(previous.processedSeconds)) {
|
|
5429
5654
|
return;
|
|
5430
5655
|
}
|
|
5656
|
+
if (previous.runSerial !== runSerial) {
|
|
5657
|
+
return; // the pair straddles a restart and measures the seek, not the host
|
|
5658
|
+
}
|
|
5431
5659
|
const speed = speedFromReadings(previous, { takenAt, processedSeconds }, LEARN_WINDOW_MIN_SEC);
|
|
5432
5660
|
if (speed === null) {
|
|
5433
5661
|
return;
|
|
@@ -5521,17 +5749,13 @@ export class HlsSessionManager {
|
|
|
5521
5749
|
* The figure is the reciprocal of the speed the session reports, which is the
|
|
5522
5750
|
* measurement itself rather than a model of it.
|
|
5523
5751
|
*
|
|
5524
|
-
* Median of recent readings,
|
|
5525
|
-
*
|
|
5752
|
+
* Median of recent readings, for the same reasons as the decode cost beside
|
|
5753
|
+
* it.
|
|
5526
5754
|
*
|
|
5527
5755
|
* @param {HlsSession} session
|
|
5528
5756
|
* @param {number} speed
|
|
5529
5757
|
*/
|
|
5530
5758
|
async #learnCopyCost(session, speed) {
|
|
5531
|
-
const runStartedAt = Number(session.encodeRunStartedAt);
|
|
5532
|
-
if (!Number.isFinite(runStartedAt) || Date.now() - runStartedAt < DECODE_LEARNING_SETTLE_MS) {
|
|
5533
|
-
return;
|
|
5534
|
-
}
|
|
5535
5759
|
if (session.runState === ENCODE_RUN_STATE.SUSPENDED) {
|
|
5536
5760
|
return; // a suspended run reports a cumulative figure that is decaying
|
|
5537
5761
|
}
|
|
@@ -5550,9 +5774,8 @@ export class HlsSessionManager {
|
|
|
5550
5774
|
const key = `${session.sourceKey}:${session.fileIndex}`;
|
|
5551
5775
|
const known = this.#observedCopyCost.get(key);
|
|
5552
5776
|
const readings = [...(known?.readings ?? []), costSec].slice(-DECODE_LEARNING_READINGS);
|
|
5553
|
-
const
|
|
5554
|
-
|
|
5555
|
-
if (known && Math.abs(median - known.costSec) / known.costSec < DECODE_LEARNING_CHANGE) {
|
|
5777
|
+
const median = medianOf(readings);
|
|
5778
|
+
if (!movedBeyondScatter(known?.costSec ?? null, median, readings)) {
|
|
5556
5779
|
this.#observedCopyCost.set(key, { ...known, readings });
|
|
5557
5780
|
return;
|
|
5558
5781
|
}
|
|
@@ -5572,17 +5795,13 @@ export class HlsSessionManager {
|
|
|
5572
5795
|
* host where a rung needs almost the whole machine, a soundtrack is the
|
|
5573
5796
|
* difference between offering it and refusing it.
|
|
5574
5797
|
*
|
|
5575
|
-
* Same rules as {@link #learnCopyCost}, for the same reasons:
|
|
5576
|
-
*
|
|
5798
|
+
* Same rules as {@link #learnCopyCost}, for the same reasons: never
|
|
5799
|
+
* suspended, never while the torrent is what is short.
|
|
5577
5800
|
*
|
|
5578
5801
|
* @param {HlsSession} session
|
|
5579
5802
|
* @param {number} speed
|
|
5580
5803
|
*/
|
|
5581
5804
|
async #learnAudioCost(session, speed) {
|
|
5582
|
-
const runStartedAt = Number(session.encodeRunStartedAt);
|
|
5583
|
-
if (!Number.isFinite(runStartedAt) || Date.now() - runStartedAt < DECODE_LEARNING_SETTLE_MS) {
|
|
5584
|
-
return;
|
|
5585
|
-
}
|
|
5586
5805
|
if (session.runState === ENCODE_RUN_STATE.SUSPENDED) {
|
|
5587
5806
|
return;
|
|
5588
5807
|
}
|
|
@@ -5596,9 +5815,8 @@ export class HlsSessionManager {
|
|
|
5596
5815
|
const key = this.#audioCostKey(session);
|
|
5597
5816
|
const known = this.#observedAudioCost.get(key);
|
|
5598
5817
|
const readings = [...(known?.readings ?? []), costSec].slice(-DECODE_LEARNING_READINGS);
|
|
5599
|
-
const
|
|
5600
|
-
|
|
5601
|
-
if (known && Math.abs(median - known.costSec) / known.costSec < DECODE_LEARNING_CHANGE) {
|
|
5818
|
+
const median = medianOf(readings);
|
|
5819
|
+
if (!movedBeyondScatter(known?.costSec ?? null, median, readings)) {
|
|
5602
5820
|
this.#observedAudioCost.set(key, { ...known, readings });
|
|
5603
5821
|
return;
|
|
5604
5822
|
}
|
|
@@ -5629,10 +5847,6 @@ export class HlsSessionManager {
|
|
|
5629
5847
|
// (`transcodeVideo === true`), so neither could run.
|
|
5630
5848
|
return;
|
|
5631
5849
|
}
|
|
5632
|
-
const runStartedAt = Number(session.encodeRunStartedAt);
|
|
5633
|
-
if (!Number.isFinite(runStartedAt) || Date.now() - runStartedAt < DECODE_LEARNING_SETTLE_MS) {
|
|
5634
|
-
return; // no run, or one still carrying its own start in the average
|
|
5635
|
-
}
|
|
5636
5850
|
if (this.videoEncoder?.kind !== "software") {
|
|
5637
5851
|
return; // the benchmark that prices the encode half is libx264 only
|
|
5638
5852
|
}
|
|
@@ -5661,12 +5875,12 @@ export class HlsSessionManager {
|
|
|
5661
5875
|
const key = `${session.sourceKey}:${session.fileIndex}`;
|
|
5662
5876
|
const known = this.#observedDecodeCost.get(key);
|
|
5663
5877
|
const readings = [...(known?.readings ?? []), decodeCostSec].slice(-DECODE_LEARNING_READINGS);
|
|
5664
|
-
const
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
//
|
|
5668
|
-
//
|
|
5669
|
-
//
|
|
5878
|
+
const costSec = medianOf(readings);
|
|
5879
|
+
if (!movedBeyondScatter(known?.costSec ?? null, costSec, readings)) {
|
|
5880
|
+
// The same answer as before, by the readings' own scatter. Storing it
|
|
5881
|
+
// would bump the version and make every session recompute its offer,
|
|
5882
|
+
// which is asked for on the path that serves every playlist, init and
|
|
5883
|
+
// segment.
|
|
5670
5884
|
this.#observedDecodeCost.set(key, { ...known, readings });
|
|
5671
5885
|
return;
|
|
5672
5886
|
}
|
|
@@ -5736,14 +5950,26 @@ export class HlsSessionManager {
|
|
|
5736
5950
|
// known before any session exists, so the FIRST offer — the one the viewer
|
|
5737
5951
|
// actually sees when they open a file — is priced with it too. Without this
|
|
5738
5952
|
// the plan and a live session answer differently about the same file.
|
|
5739
|
-
const
|
|
5740
|
-
|
|
5741
|
-
|
|
5953
|
+
const movingMegabytesPerSec = mediaInfo?.sourceKey !== undefined
|
|
5954
|
+
? this.#torrentMegabytesPerSecond(
|
|
5955
|
+
mediaInfo.sourceKey,
|
|
5956
|
+
mediaInfo.fileIndex,
|
|
5957
|
+
mediaInfo.fileLength ?? null,
|
|
5958
|
+
mediaInfo.durationSeconds ?? null
|
|
5959
|
+
)
|
|
5960
|
+
: null;
|
|
5961
|
+
const torrentCostSec = this.#observedTorrentCostPerMegabyte !== null && movingMegabytesPerSec !== null
|
|
5962
|
+
? this.#observedTorrentCostPerMegabyte * movingMegabytesPerSec
|
|
5742
5963
|
: 0;
|
|
5743
5964
|
const forBranch = (transcodeVideo) =>
|
|
5744
5965
|
this.#sustainableHeights({
|
|
5745
5966
|
heights,
|
|
5746
5967
|
concurrentCostSec: torrentCostSec,
|
|
5968
|
+
// What this file's swarm demanded the last time it was read. Absent on
|
|
5969
|
+
// a first open, and then the bar is realtime.
|
|
5970
|
+
requiredSpeed: mediaInfo?.sourceKey !== undefined
|
|
5971
|
+
? this.#requiredSpeedFor(mediaInfo.sourceKey, mediaInfo.fileIndex)
|
|
5972
|
+
: null,
|
|
5747
5973
|
observedDecodeCostSec,
|
|
5748
5974
|
// Nothing is running yet, so nothing is exempt from being predicted —
|
|
5749
5975
|
// except the copy itself, which the branch flag already covers.
|
|
@@ -5953,9 +6179,11 @@ export class HlsSessionManager {
|
|
|
5953
6179
|
// per megabyte from readings taken while nothing was encoding, so the two
|
|
5954
6180
|
// measurements do not contain each other.
|
|
5955
6181
|
const perMegabyte = this.#observedTorrentCostPerMegabyte;
|
|
5956
|
-
const megabytesPerSecond =
|
|
5957
|
-
session,
|
|
5958
|
-
|
|
6182
|
+
const megabytesPerSecond = this.#torrentMegabytesPerSecond(
|
|
6183
|
+
session.sourceKey,
|
|
6184
|
+
session.fileIndex,
|
|
6185
|
+
this.#fileLengthByKey.get(`${session.sourceKey}:${session.fileIndex}`) ?? null,
|
|
6186
|
+
session.durationSeconds
|
|
5959
6187
|
);
|
|
5960
6188
|
if (perMegabyte !== null && megabytesPerSecond !== null) {
|
|
5961
6189
|
cost += perMegabyte * megabytesPerSecond;
|
|
@@ -6001,8 +6229,13 @@ export class HlsSessionManager {
|
|
|
6001
6229
|
observedDecodeCostSec = null,
|
|
6002
6230
|
concurrentCostSec = 0,
|
|
6003
6231
|
runningCostByHeight = null,
|
|
6004
|
-
measuredHeights = null
|
|
6232
|
+
measuredHeights = null,
|
|
6233
|
+
requiredSpeed = null
|
|
6005
6234
|
}) {
|
|
6235
|
+
// What this file's own supply demands, measured by its reader — and
|
|
6236
|
+
// realtime while it has not been measured. Read once here so the line that
|
|
6237
|
+
// reports a refusal names the figure it refused against.
|
|
6238
|
+
const bar = speedBar(requiredSpeed);
|
|
6006
6239
|
const benchmark = this.softwarePresetBenchmark;
|
|
6007
6240
|
if (!Array.isArray(benchmark) || benchmark.length === 0 || sourceHeight <= 0 || sourceWidth <= 0) {
|
|
6008
6241
|
return heights;
|
|
@@ -6074,13 +6307,25 @@ export class HlsSessionManager {
|
|
|
6074
6307
|
// quarter of it unattributed. Only the unattributed part is charged
|
|
6075
6308
|
// here: our own encoders are already in `concurrentBesideThis` and the
|
|
6076
6309
|
// proxy's own work is already priced per megabyte moved.
|
|
6077
|
-
|
|
6310
|
+
// Two corrections, and they are different facts about the machine. The
|
|
6311
|
+
// availability share removes work nobody has been charged for; the
|
|
6312
|
+
// contention penalty says what OUR OWN second job costs, because the
|
|
6313
|
+
// budget adds independent prices and this host does not behave that way
|
|
6314
|
+
// — the same work measured 2.6× dearer beside one encoder and 3.7×
|
|
6315
|
+
// beside two (2026-08-18). `concurrentBesideThis` already counts what is
|
|
6316
|
+
// committed; this multiplies by how badly running at all together goes.
|
|
6317
|
+
const othersRunning = concurrentBesideThis > 0 ? this.#encodersRunningNow() : 0;
|
|
6318
|
+
const { penalty } = contentionPenalty(othersRunning, this.contentionPenalties);
|
|
6319
|
+
const onThisMachine = correctForAvailability(
|
|
6320
|
+
speed === null ? null : speed / penalty,
|
|
6321
|
+
this.hostAvailability
|
|
6322
|
+
);
|
|
6078
6323
|
// Kept against the step's own session, so that when it runs the field
|
|
6079
6324
|
// says what the prediction was worth. Without this the only comparison
|
|
6080
6325
|
// available is between two figures written minutes apart in different
|
|
6081
6326
|
// lines of the log.
|
|
6082
6327
|
predictedByHeight.set(height, onThisMachine);
|
|
6083
|
-
if (onThisMachine !== null && onThisMachine >=
|
|
6328
|
+
if (onThisMachine !== null && onThisMachine >= bar) {
|
|
6084
6329
|
kept.push(height);
|
|
6085
6330
|
continue;
|
|
6086
6331
|
}
|
|
@@ -6093,7 +6338,10 @@ export class HlsSessionManager {
|
|
|
6093
6338
|
// that holds five hundred, which buries whatever is worth reading.
|
|
6094
6339
|
if (dropped.length > 0) {
|
|
6095
6340
|
const line =
|
|
6096
|
-
`transcode: not offering ${dropped.join(" ")} — below
|
|
6341
|
+
`transcode: not offering ${dropped.join(" ")} — below ${bar.toFixed(2)}x ` +
|
|
6342
|
+
(Number.isFinite(requiredSpeed) && requiredSpeed > 1
|
|
6343
|
+
? "(the speed this file's own interruptions demand) "
|
|
6344
|
+
: "(realtime, this file's supply not measured yet) ") +
|
|
6097
6345
|
// Said with the figures, because a step refused on a busy machine and
|
|
6098
6346
|
// one refused on an idle machine are different facts about the host.
|
|
6099
6347
|
(this.hostAvailability?.known
|