@torrent-tv/proxy 2.35.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 +7 -0
- package/package.json +1 -1
- package/services/contention.js +128 -128
- package/services/hls-session-manager.js +326 -116
- package/services/hwaccel.js +59 -28
- package/services/learned-median.js +79 -0
- package/services/torrent-cost.js +88 -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
|
@@ -23,6 +23,8 @@ import { speedFromReadings } from "./encoder-readings.js";
|
|
|
23
23
|
import { availableShareFrom, correctForAvailability } from "./available-share.js";
|
|
24
24
|
import { contentionPenalty } from "./contention.js";
|
|
25
25
|
import { minimumBufferFrom } from "./supply-margin.js";
|
|
26
|
+
import { baseDrawFrom, costPerMegabyteFrom } from "./torrent-cost.js";
|
|
27
|
+
import { medianOf, movedBeyondScatter, scatterOf } from "./learned-median.js";
|
|
26
28
|
import {
|
|
27
29
|
ENCODE_RUN_EVENT,
|
|
28
30
|
ENCODE_RUN_STATE,
|
|
@@ -40,7 +42,7 @@ import {
|
|
|
40
42
|
chooseSoftwareEncodeSettings,
|
|
41
43
|
pickSoftwarePreset,
|
|
42
44
|
canSustainOutput,
|
|
43
|
-
|
|
45
|
+
speedBar,
|
|
44
46
|
TRANSCODE_FPS,
|
|
45
47
|
chooseOutputFps
|
|
46
48
|
} from "./hwaccel.js";
|
|
@@ -266,7 +268,7 @@ export function noteIndexDeviation(check, index, deviationSec, landedOnKeyframe
|
|
|
266
268
|
* @param {{ ladder: { width: number, height: number }[], rungIndex: number } | null} budget
|
|
267
269
|
* @param {number} outputFps
|
|
268
270
|
* @param {unknown} benchmark
|
|
269
|
-
* @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]
|
|
270
272
|
* @returns {object | null}
|
|
271
273
|
*/
|
|
272
274
|
function startAtLadderTop(budget, outputFps, benchmark, cost = {}) {
|
|
@@ -293,7 +295,8 @@ function startAtLadderTop(budget, outputFps, benchmark, cost = {}) {
|
|
|
293
295
|
decodeModel: cost.decodeModel ?? null,
|
|
294
296
|
source: cost.source ?? null,
|
|
295
297
|
outputPixelsPerSec: ladder[index].width * ladder[index].height * fps,
|
|
296
|
-
observedDecodeCostSec: cost.observedDecodeCostSec ?? null
|
|
298
|
+
observedDecodeCostSec: cost.observedDecodeCostSec ?? null,
|
|
299
|
+
requiredSpeed: cost.requiredSpeed ?? null
|
|
297
300
|
});
|
|
298
301
|
if (sustainable) {
|
|
299
302
|
startIndex = index;
|
|
@@ -496,8 +499,6 @@ const DEFAULT_STARTUP_WAIT_MS = 5_000;
|
|
|
496
499
|
// and restart at the current segment. Conservative so it never thrashes: a long
|
|
497
500
|
// sustained window, a post-action cooldown, a step cap, and no upswitch (v1).
|
|
498
501
|
const BUDGET_CHECK_INTERVAL_MS = 5_000;
|
|
499
|
-
/** Below this, a tick has not moved enough of the torrent to price it. */
|
|
500
|
-
const TORRENT_COST_MIN_MEGABYTES = 2;
|
|
501
502
|
/** The narrowest stretch of uninterrupted encoding a speed may be read from. */
|
|
502
503
|
const LEARN_WINDOW_MIN_SEC = 3;
|
|
503
504
|
// Speed below this (cumulative ffmpeg average) counts as "slow"; recovery to
|
|
@@ -513,18 +514,10 @@ const BUDGET_SUSTAINED_MS = 15_000;
|
|
|
513
514
|
const BUDGET_ACTION_COOLDOWN_MS = 30_000;
|
|
514
515
|
// Never step down more than this many rungs below the startup choice.
|
|
515
516
|
const BUDGET_MAX_DOWNSHIFTS = 3;
|
|
516
|
-
// How
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
// pieces all sit in the denominator of an early reading.
|
|
520
|
-
const DECODE_LEARNING_SETTLE_MS = 20_000;
|
|
521
|
-
// How many readings the median is taken over. Long enough to outvote a single
|
|
522
|
-
// 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.
|
|
523
520
|
const DECODE_LEARNING_READINGS = 7;
|
|
524
|
-
// A new median has to differ by this much to be adopted. Below it the answer is
|
|
525
|
-
// the same one, and re-publishing it would make every session recompute its
|
|
526
|
-
// offer on the path that serves every playlist, init and segment.
|
|
527
|
-
const DECODE_LEARNING_CHANGE = 0.05;
|
|
528
521
|
// The input counts as "keeping up" when the torrent downloads at least this
|
|
529
522
|
// multiple of the source's average byte rate. Below it (and not yet fully
|
|
530
523
|
// downloaded), a low speed is download-bound, not CPU-bound → do NOT downscale.
|
|
@@ -1229,31 +1222,6 @@ function normalizeLogFileName(fileName, fileIndex) {
|
|
|
1229
1222
|
* combination. Sessions are reused across consumers and are automatically
|
|
1230
1223
|
* expired after {@link HlsSessionManagerOptions.sessionTtlMs} of idle time.
|
|
1231
1224
|
*/
|
|
1232
|
-
/**
|
|
1233
|
-
* How many megabytes a second of this source is, from what the probe read.
|
|
1234
|
-
*
|
|
1235
|
-
* A viewer consumes the file at its own rate, so this is also the rate at which
|
|
1236
|
-
* the machine must fetch, verify and deliver it while they watch.
|
|
1237
|
-
*
|
|
1238
|
-
* @param {HlsSession} session
|
|
1239
|
-
* @param {number | null} fileLengthBytes
|
|
1240
|
-
* @returns {number | null}
|
|
1241
|
-
*/
|
|
1242
|
-
|
|
1243
|
-
function sourceMegabytesPerSecond(session, fileLengthBytes) {
|
|
1244
|
-
// The FILE's rate, not the video stream's. What the torrent moves is the
|
|
1245
|
-
// container: on the releases this serves, two or three AC-3 tracks add 10-25 %
|
|
1246
|
-
// to what the picture alone would suggest, and `sourceDecode` deliberately
|
|
1247
|
-
// carries the video stream's own bitrate because the decode model was fitted
|
|
1248
|
-
// on video-only clips.
|
|
1249
|
-
const fileLength = Number(fileLengthBytes);
|
|
1250
|
-
const durationSeconds = Number(session.durationSeconds);
|
|
1251
|
-
if (Number.isFinite(fileLength) && fileLength > 0 && Number.isFinite(durationSeconds) && durationSeconds > 0) {
|
|
1252
|
-
return fileLength / durationSeconds / 1e6;
|
|
1253
|
-
}
|
|
1254
|
-
return null;
|
|
1255
|
-
}
|
|
1256
|
-
|
|
1257
1225
|
/**
|
|
1258
1226
|
* Which cost a speed reading from this session is a measurement OF.
|
|
1259
1227
|
*
|
|
@@ -1357,6 +1325,32 @@ export class HlsSessionManager {
|
|
|
1357
1325
|
#observedTorrentCostPerMegabyte = null;
|
|
1358
1326
|
/** @type {number[]} Recent readings behind that median. */
|
|
1359
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();
|
|
1360
1354
|
|
|
1361
1355
|
/**
|
|
1362
1356
|
* @param {HlsSessionManagerOptions} options
|
|
@@ -1827,7 +1821,8 @@ export class HlsSessionManager {
|
|
|
1827
1821
|
sourceWidth,
|
|
1828
1822
|
sourceHeight,
|
|
1829
1823
|
outputFps,
|
|
1830
|
-
source: sourceDecode
|
|
1824
|
+
source: sourceDecode,
|
|
1825
|
+
requiredSpeed: this.#requiredSpeedFor(sourceKey, fileIndex)
|
|
1831
1826
|
});
|
|
1832
1827
|
// A forced resolution starts at exactly that size — the viewer asked for it
|
|
1833
1828
|
// — but KEEPS the ladder beneath it. Discarding the ladder is what left a
|
|
@@ -1841,7 +1836,8 @@ export class HlsSessionManager {
|
|
|
1841
1836
|
? startAtLadderTop(chosenBudget, outputFps, this.softwarePresetBenchmark, {
|
|
1842
1837
|
decodeModel: this.decodeCostModel,
|
|
1843
1838
|
source: sourceDecode,
|
|
1844
|
-
observedDecodeCostSec: this.#observedDecodeCost.get(`${sourceKey}:${fileIndex}`)?.costSec ?? null
|
|
1839
|
+
observedDecodeCostSec: this.#observedDecodeCost.get(`${sourceKey}:${fileIndex}`)?.costSec ?? null,
|
|
1840
|
+
requiredSpeed: this.#requiredSpeedFor(sourceKey, fileIndex)
|
|
1845
1841
|
})
|
|
1846
1842
|
: chosenBudget;
|
|
1847
1843
|
const softwarePreset = encodeBudget?.preset ?? null;
|
|
@@ -2524,7 +2520,16 @@ export class HlsSessionManager {
|
|
|
2524
2520
|
* @param {{ transcodeVideo: boolean, targetWidth: number, targetHeight: number, sourceWidth: number | null, sourceHeight: number | null, outputFps: number, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null }} params
|
|
2525
2521
|
* @returns {{ width: number, height: number, preset: string } | null}
|
|
2526
2522
|
*/
|
|
2527
|
-
#chooseEncodeBudget({
|
|
2523
|
+
#chooseEncodeBudget({
|
|
2524
|
+
transcodeVideo,
|
|
2525
|
+
targetWidth,
|
|
2526
|
+
targetHeight,
|
|
2527
|
+
sourceWidth,
|
|
2528
|
+
sourceHeight,
|
|
2529
|
+
outputFps,
|
|
2530
|
+
source = null,
|
|
2531
|
+
requiredSpeed = null
|
|
2532
|
+
}) {
|
|
2528
2533
|
if (!transcodeVideo || this.videoEncoder?.kind !== "software" || !this.softwarePresetBenchmark) {
|
|
2529
2534
|
return null;
|
|
2530
2535
|
}
|
|
@@ -2536,7 +2541,7 @@ export class HlsSessionManager {
|
|
|
2536
2541
|
this.softwarePresetBenchmark,
|
|
2537
2542
|
{ width: ceiling.w, height: ceiling.h },
|
|
2538
2543
|
outputFps,
|
|
2539
|
-
{ decodeModel: this.decodeCostModel, source }
|
|
2544
|
+
{ decodeModel: this.decodeCostModel, source, requiredSpeed }
|
|
2540
2545
|
);
|
|
2541
2546
|
}
|
|
2542
2547
|
|
|
@@ -3121,24 +3126,184 @@ export class HlsSessionManager {
|
|
|
3121
3126
|
// was the difference between offering it and refusing it.
|
|
3122
3127
|
const cores = Math.max(1, os.cpus().length);
|
|
3123
3128
|
const cpuSeconds = (now.cpuSeconds - previous.cpuSeconds) / cores;
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
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) {
|
|
3127
3147
|
return;
|
|
3128
3148
|
}
|
|
3129
|
-
const costPerMegabyte = cpuSeconds / megabytes;
|
|
3130
3149
|
const readings = [...this.#torrentCostReadings, costPerMegabyte].slice(-DECODE_LEARNING_READINGS);
|
|
3131
3150
|
this.#torrentCostReadings = readings;
|
|
3132
|
-
const
|
|
3133
|
-
|
|
3134
|
-
if (this.#observedTorrentCostPerMegabyte !== null &&
|
|
3135
|
-
Math.abs(median - this.#observedTorrentCostPerMegabyte) / this.#observedTorrentCostPerMegabyte < DECODE_LEARNING_CHANGE) {
|
|
3151
|
+
const median = medianOf(readings);
|
|
3152
|
+
if (!movedBeyondScatter(this.#observedTorrentCostPerMegabyte, median, readings)) {
|
|
3136
3153
|
return;
|
|
3137
3154
|
}
|
|
3138
3155
|
this.#observedTorrentCostPerMegabyte = median;
|
|
3139
3156
|
logger.info(
|
|
3140
3157
|
`host-load: the torrent costs ${(median * 1000).toFixed(1)}ms of CPU per MB on this host ` +
|
|
3141
|
-
`(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)}%)`
|
|
3142
3307
|
);
|
|
3143
3308
|
}
|
|
3144
3309
|
|
|
@@ -3275,18 +3440,22 @@ export class HlsSessionManager {
|
|
|
3275
3440
|
|
|
3276
3441
|
async #enforceRealtimeBudget() {
|
|
3277
3442
|
void this.#reportHostLoad();
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
//
|
|
3282
|
-
//
|
|
3283
|
-
// two passes over the same sessions, taking the same reading twice and
|
|
3284
|
-
// 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.
|
|
3285
3448
|
if (this.budgetTickRunning === true) {
|
|
3286
3449
|
return;
|
|
3287
3450
|
}
|
|
3288
3451
|
this.budgetTickRunning = true;
|
|
3289
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
|
+
}
|
|
3290
3459
|
await this.#realtimeBudgetPass();
|
|
3291
3460
|
} finally {
|
|
3292
3461
|
this.budgetTickRunning = false;
|
|
@@ -3458,7 +3627,9 @@ export class HlsSessionManager {
|
|
|
3458
3627
|
{
|
|
3459
3628
|
decodeModel: this.decodeCostModel,
|
|
3460
3629
|
source: session.sourceDecode ?? null,
|
|
3461
|
-
observedDecodeCostSec: this.#observedDecodeCostFor(session)
|
|
3630
|
+
observedDecodeCostSec: this.#observedDecodeCostFor(session),
|
|
3631
|
+
requiredSpeed: session.supplyFigures?.requiredSpeed
|
|
3632
|
+
?? this.#requiredSpeedFor(session.sourceKey, session.fileIndex)
|
|
3462
3633
|
}
|
|
3463
3634
|
);
|
|
3464
3635
|
// Restart at the current live-edge segment so the lighter profile takes over
|
|
@@ -3523,10 +3694,6 @@ export class HlsSessionManager {
|
|
|
3523
3694
|
// produce a file that is neither, which is the only reason a restart ever
|
|
3524
3695
|
// had to wait for its predecessor to die.
|
|
3525
3696
|
session.runSerial = (session.runSerial ?? 0) + 1;
|
|
3526
|
-
// When THIS run began. ffmpeg's `speed=` is cumulative over a run, so a
|
|
3527
|
-
// reading of it says something about the machine only once the run has left
|
|
3528
|
-
// its own start behind — see #learnDecodeCost.
|
|
3529
|
-
session.encodeRunStartedAt = Date.now();
|
|
3530
3697
|
session.runDirPath = path.join(session.dirPath, `run-${session.runSerial}`);
|
|
3531
3698
|
await mkdir(session.runDirPath, { recursive: true });
|
|
3532
3699
|
// The restart backs off a segment or two from what was asked for, so the
|
|
@@ -5288,9 +5455,24 @@ export class HlsSessionManager {
|
|
|
5288
5455
|
return `${speed < 1 ? "slow" : "ok"}${(1 / speed).toFixed(2)}`;
|
|
5289
5456
|
})
|
|
5290
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
|
+
);
|
|
5291
5472
|
const version =
|
|
5292
5473
|
`${observed?.version ?? 0}:${playing}:${copyVersion}:${torrentCost.toFixed(6)}:` +
|
|
5293
|
-
`${audioVersion}:${running}:${measured}
|
|
5474
|
+
`${audioVersion}:${running}:${measured}:${(demanded ?? 0).toFixed(2)}:` +
|
|
5475
|
+
`${(movingMegabytes ?? 0).toFixed(2)}`;
|
|
5294
5476
|
if (Array.isArray(owner.offeredHeightsCache) && owner.offeredHeightsVersion === version) {
|
|
5295
5477
|
return owner.offeredHeightsCache;
|
|
5296
5478
|
}
|
|
@@ -5313,6 +5495,10 @@ export class HlsSessionManager {
|
|
|
5313
5495
|
// What each rung was actually seen doing in this session, which is the
|
|
5314
5496
|
// only thing a live reading may speak for.
|
|
5315
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,
|
|
5316
5502
|
// What the family is already spending while a rung is considered. The
|
|
5317
5503
|
// picture being COPIED is the common case and used to be priced at
|
|
5318
5504
|
// nothing; measured, it is about an eighth of the machine.
|
|
@@ -5375,17 +5561,20 @@ export class HlsSessionManager {
|
|
|
5375
5561
|
* whatever else it is doing.
|
|
5376
5562
|
*
|
|
5377
5563
|
* The MEDIAN of the recent readings is used, over a bounded window. Keeping
|
|
5378
|
-
* the fastest instead makes the figure a ratchet:
|
|
5379
|
-
*
|
|
5380
|
-
*
|
|
5381
|
-
*
|
|
5382
|
-
*
|
|
5383
|
-
*
|
|
5384
|
-
*
|
|
5385
|
-
*
|
|
5386
|
-
*
|
|
5387
|
-
*
|
|
5388
|
-
*
|
|
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.
|
|
5389
5578
|
*
|
|
5390
5579
|
* @param {HlsSession} session
|
|
5391
5580
|
* @param {number} speed - The `speed=` ffmpeg reports, as a multiple of realtime.
|
|
@@ -5450,10 +5639,23 @@ export class HlsSessionManager {
|
|
|
5450
5639
|
const processedSeconds = Number(session.progress?.processedSeconds);
|
|
5451
5640
|
const takenAt = Date.now();
|
|
5452
5641
|
const previous = session.learnSample ?? null;
|
|
5453
|
-
|
|
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 };
|
|
5454
5653
|
if (previous === null || !Number.isFinite(processedSeconds) || !Number.isFinite(previous.processedSeconds)) {
|
|
5455
5654
|
return;
|
|
5456
5655
|
}
|
|
5656
|
+
if (previous.runSerial !== runSerial) {
|
|
5657
|
+
return; // the pair straddles a restart and measures the seek, not the host
|
|
5658
|
+
}
|
|
5457
5659
|
const speed = speedFromReadings(previous, { takenAt, processedSeconds }, LEARN_WINDOW_MIN_SEC);
|
|
5458
5660
|
if (speed === null) {
|
|
5459
5661
|
return;
|
|
@@ -5547,17 +5749,13 @@ export class HlsSessionManager {
|
|
|
5547
5749
|
* The figure is the reciprocal of the speed the session reports, which is the
|
|
5548
5750
|
* measurement itself rather than a model of it.
|
|
5549
5751
|
*
|
|
5550
|
-
* Median of recent readings,
|
|
5551
|
-
*
|
|
5752
|
+
* Median of recent readings, for the same reasons as the decode cost beside
|
|
5753
|
+
* it.
|
|
5552
5754
|
*
|
|
5553
5755
|
* @param {HlsSession} session
|
|
5554
5756
|
* @param {number} speed
|
|
5555
5757
|
*/
|
|
5556
5758
|
async #learnCopyCost(session, speed) {
|
|
5557
|
-
const runStartedAt = Number(session.encodeRunStartedAt);
|
|
5558
|
-
if (!Number.isFinite(runStartedAt) || Date.now() - runStartedAt < DECODE_LEARNING_SETTLE_MS) {
|
|
5559
|
-
return;
|
|
5560
|
-
}
|
|
5561
5759
|
if (session.runState === ENCODE_RUN_STATE.SUSPENDED) {
|
|
5562
5760
|
return; // a suspended run reports a cumulative figure that is decaying
|
|
5563
5761
|
}
|
|
@@ -5576,9 +5774,8 @@ export class HlsSessionManager {
|
|
|
5576
5774
|
const key = `${session.sourceKey}:${session.fileIndex}`;
|
|
5577
5775
|
const known = this.#observedCopyCost.get(key);
|
|
5578
5776
|
const readings = [...(known?.readings ?? []), costSec].slice(-DECODE_LEARNING_READINGS);
|
|
5579
|
-
const
|
|
5580
|
-
|
|
5581
|
-
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)) {
|
|
5582
5779
|
this.#observedCopyCost.set(key, { ...known, readings });
|
|
5583
5780
|
return;
|
|
5584
5781
|
}
|
|
@@ -5598,17 +5795,13 @@ export class HlsSessionManager {
|
|
|
5598
5795
|
* host where a rung needs almost the whole machine, a soundtrack is the
|
|
5599
5796
|
* difference between offering it and refusing it.
|
|
5600
5797
|
*
|
|
5601
|
-
* Same rules as {@link #learnCopyCost}, for the same reasons:
|
|
5602
|
-
*
|
|
5798
|
+
* Same rules as {@link #learnCopyCost}, for the same reasons: never
|
|
5799
|
+
* suspended, never while the torrent is what is short.
|
|
5603
5800
|
*
|
|
5604
5801
|
* @param {HlsSession} session
|
|
5605
5802
|
* @param {number} speed
|
|
5606
5803
|
*/
|
|
5607
5804
|
async #learnAudioCost(session, speed) {
|
|
5608
|
-
const runStartedAt = Number(session.encodeRunStartedAt);
|
|
5609
|
-
if (!Number.isFinite(runStartedAt) || Date.now() - runStartedAt < DECODE_LEARNING_SETTLE_MS) {
|
|
5610
|
-
return;
|
|
5611
|
-
}
|
|
5612
5805
|
if (session.runState === ENCODE_RUN_STATE.SUSPENDED) {
|
|
5613
5806
|
return;
|
|
5614
5807
|
}
|
|
@@ -5622,9 +5815,8 @@ export class HlsSessionManager {
|
|
|
5622
5815
|
const key = this.#audioCostKey(session);
|
|
5623
5816
|
const known = this.#observedAudioCost.get(key);
|
|
5624
5817
|
const readings = [...(known?.readings ?? []), costSec].slice(-DECODE_LEARNING_READINGS);
|
|
5625
|
-
const
|
|
5626
|
-
|
|
5627
|
-
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)) {
|
|
5628
5820
|
this.#observedAudioCost.set(key, { ...known, readings });
|
|
5629
5821
|
return;
|
|
5630
5822
|
}
|
|
@@ -5655,10 +5847,6 @@ export class HlsSessionManager {
|
|
|
5655
5847
|
// (`transcodeVideo === true`), so neither could run.
|
|
5656
5848
|
return;
|
|
5657
5849
|
}
|
|
5658
|
-
const runStartedAt = Number(session.encodeRunStartedAt);
|
|
5659
|
-
if (!Number.isFinite(runStartedAt) || Date.now() - runStartedAt < DECODE_LEARNING_SETTLE_MS) {
|
|
5660
|
-
return; // no run, or one still carrying its own start in the average
|
|
5661
|
-
}
|
|
5662
5850
|
if (this.videoEncoder?.kind !== "software") {
|
|
5663
5851
|
return; // the benchmark that prices the encode half is libx264 only
|
|
5664
5852
|
}
|
|
@@ -5687,12 +5875,12 @@ export class HlsSessionManager {
|
|
|
5687
5875
|
const key = `${session.sourceKey}:${session.fileIndex}`;
|
|
5688
5876
|
const known = this.#observedDecodeCost.get(key);
|
|
5689
5877
|
const readings = [...(known?.readings ?? []), decodeCostSec].slice(-DECODE_LEARNING_READINGS);
|
|
5690
|
-
const
|
|
5691
|
-
|
|
5692
|
-
|
|
5693
|
-
//
|
|
5694
|
-
//
|
|
5695
|
-
//
|
|
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.
|
|
5696
5884
|
this.#observedDecodeCost.set(key, { ...known, readings });
|
|
5697
5885
|
return;
|
|
5698
5886
|
}
|
|
@@ -5762,14 +5950,26 @@ export class HlsSessionManager {
|
|
|
5762
5950
|
// known before any session exists, so the FIRST offer — the one the viewer
|
|
5763
5951
|
// actually sees when they open a file — is priced with it too. Without this
|
|
5764
5952
|
// the plan and a live session answer differently about the same file.
|
|
5765
|
-
const
|
|
5766
|
-
|
|
5767
|
-
|
|
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
|
|
5768
5963
|
: 0;
|
|
5769
5964
|
const forBranch = (transcodeVideo) =>
|
|
5770
5965
|
this.#sustainableHeights({
|
|
5771
5966
|
heights,
|
|
5772
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,
|
|
5773
5973
|
observedDecodeCostSec,
|
|
5774
5974
|
// Nothing is running yet, so nothing is exempt from being predicted —
|
|
5775
5975
|
// except the copy itself, which the branch flag already covers.
|
|
@@ -5979,9 +6179,11 @@ export class HlsSessionManager {
|
|
|
5979
6179
|
// per megabyte from readings taken while nothing was encoding, so the two
|
|
5980
6180
|
// measurements do not contain each other.
|
|
5981
6181
|
const perMegabyte = this.#observedTorrentCostPerMegabyte;
|
|
5982
|
-
const megabytesPerSecond =
|
|
5983
|
-
session,
|
|
5984
|
-
|
|
6182
|
+
const megabytesPerSecond = this.#torrentMegabytesPerSecond(
|
|
6183
|
+
session.sourceKey,
|
|
6184
|
+
session.fileIndex,
|
|
6185
|
+
this.#fileLengthByKey.get(`${session.sourceKey}:${session.fileIndex}`) ?? null,
|
|
6186
|
+
session.durationSeconds
|
|
5985
6187
|
);
|
|
5986
6188
|
if (perMegabyte !== null && megabytesPerSecond !== null) {
|
|
5987
6189
|
cost += perMegabyte * megabytesPerSecond;
|
|
@@ -6027,8 +6229,13 @@ export class HlsSessionManager {
|
|
|
6027
6229
|
observedDecodeCostSec = null,
|
|
6028
6230
|
concurrentCostSec = 0,
|
|
6029
6231
|
runningCostByHeight = null,
|
|
6030
|
-
measuredHeights = null
|
|
6232
|
+
measuredHeights = null,
|
|
6233
|
+
requiredSpeed = null
|
|
6031
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);
|
|
6032
6239
|
const benchmark = this.softwarePresetBenchmark;
|
|
6033
6240
|
if (!Array.isArray(benchmark) || benchmark.length === 0 || sourceHeight <= 0 || sourceWidth <= 0) {
|
|
6034
6241
|
return heights;
|
|
@@ -6118,7 +6325,7 @@ export class HlsSessionManager {
|
|
|
6118
6325
|
// available is between two figures written minutes apart in different
|
|
6119
6326
|
// lines of the log.
|
|
6120
6327
|
predictedByHeight.set(height, onThisMachine);
|
|
6121
|
-
if (onThisMachine !== null && onThisMachine >=
|
|
6328
|
+
if (onThisMachine !== null && onThisMachine >= bar) {
|
|
6122
6329
|
kept.push(height);
|
|
6123
6330
|
continue;
|
|
6124
6331
|
}
|
|
@@ -6131,7 +6338,10 @@ export class HlsSessionManager {
|
|
|
6131
6338
|
// that holds five hundred, which buries whatever is worth reading.
|
|
6132
6339
|
if (dropped.length > 0) {
|
|
6133
6340
|
const line =
|
|
6134
|
-
`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) ") +
|
|
6135
6345
|
// Said with the figures, because a step refused on a busy machine and
|
|
6136
6346
|
// one refused on an idle machine are different facts about the host.
|
|
6137
6347
|
(this.hostAvailability?.known
|