@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.
@@ -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
- REALTIME_SPEED_MARGIN,
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 long an encode run must have been going before a reading of its speed is
516
- // taken as evidence about decoding. `speed=` is cumulative over the run, so a
517
- // restart after a seek, a resume after a suspension and the wait for the first
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({ transcodeVideo, targetWidth, targetHeight, sourceWidth, sourceHeight, outputFps, source = null }) {
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
- // Enough movement to divide by: a tick with almost nothing downloaded
3099
- // measures the idle loop, not the torrent.
3100
- if (!(elapsedSec > 0) || !(megabytes >= TORRENT_COST_MIN_MEGABYTES) || !(cpuSeconds > 0)) {
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 sorted = [...readings].sort((left, right) => left - right);
3107
- const median = sorted[Math.floor(sorted.length / 2)];
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
- if (this.videoEncoder?.kind !== "software") {
3253
- return;
3254
- }
3255
- // One tick at a time. It awaits torrent statistics per session now, so a
3256
- // slow or stuck answer would otherwise let the next tick in behind it —
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: `speed=` is cumulative over
5353
- * a run, its maximum falls in the burst where the encoder races to the
5354
- * look-ahead cap with the pieces already on disk and nothing competing, and
5355
- * one such moment would re-admit — permanently — the very rung the field
5356
- * measured at 0.388-0.947x. The median moves in both directions and describes
5357
- * the machine as it usually is, which is what a viewer will meet.
5358
- *
5359
- * A reading is only taken from a run that has been going long enough to have
5360
- * left its own start behind: ffmpeg's `speed=` is cumulative, so a restart
5361
- * after a seek, a resume after a suspension, and the wait for the first
5362
- * pieces are all in the denominator of an early reading.
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
- session.learnSample = { takenAt, processedSeconds };
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, taken only from a run past its own start, for
5525
- * the same reasons as the decode cost beside it.
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 sorted = [...readings].sort((left, right) => left - right);
5554
- const median = sorted[Math.floor(sorted.length / 2)];
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: past the run's
5576
- * own start, never suspended, never while the torrent is what is short.
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 sorted = [...readings].sort((left, right) => left - right);
5600
- const median = sorted[Math.floor(sorted.length / 2)];
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 sorted = [...readings].sort((left, right) => left - right);
5665
- const costSec = sorted[Math.floor(sorted.length / 2)];
5666
- if (known && Math.abs(costSec - known.costSec) / known.costSec < DECODE_LEARNING_CHANGE) {
5667
- // The same answer as before. Storing it would bump the version and make
5668
- // every session recompute its offer, which is asked for on the path that
5669
- // serves every playlist, init and segment.
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 torrentCostSec = this.#observedTorrentCostPerMegabyte !== null && mediaInfo?.fileLength > 0 &&
5740
- mediaInfo?.durationSeconds > 0
5741
- ? this.#observedTorrentCostPerMegabyte * (mediaInfo.fileLength / mediaInfo.durationSeconds / 1e6)
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 = sourceMegabytesPerSecond(
5957
- session,
5958
- this.#fileLengthByKey.get(`${session.sourceKey}:${session.fileIndex}`) ?? null
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
- const onThisMachine = correctForAvailability(speed, this.hostAvailability);
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 >= REALTIME_SPEED_MARGIN) {
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 realtime × ${REALTIME_SPEED_MARGIN} ` +
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