@torrent-tv/proxy 2.35.0 → 2.36.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- REALTIME_SPEED_MARGIN,
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 long an encode run must have been going before a reading of its speed is
517
- // taken as evidence about decoding. `speed=` is cumulative over the run, so a
518
- // restart after a seek, a resume after a suspension and the wait for the first
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({ 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
+ }) {
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
- // Enough movement to divide by: a tick with almost nothing downloaded
3125
- // measures the idle loop, not the torrent.
3126
- 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) {
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 sorted = [...readings].sort((left, right) => left - right);
3133
- const median = sorted[Math.floor(sorted.length / 2)];
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
- if (this.videoEncoder?.kind !== "software") {
3279
- return;
3280
- }
3281
- // One tick at a time. It awaits torrent statistics per session now, so a
3282
- // slow or stuck answer would otherwise let the next tick in behind it —
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: `speed=` is cumulative over
5379
- * a run, its maximum falls in the burst where the encoder races to the
5380
- * look-ahead cap with the pieces already on disk and nothing competing, and
5381
- * one such moment would re-admit — permanently — the very rung the field
5382
- * measured at 0.388-0.947x. The median moves in both directions and describes
5383
- * the machine as it usually is, which is what a viewer will meet.
5384
- *
5385
- * A reading is only taken from a run that has been going long enough to have
5386
- * left its own start behind: ffmpeg's `speed=` is cumulative, so a restart
5387
- * after a seek, a resume after a suspension, and the wait for the first
5388
- * 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.
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
- 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 };
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, taken only from a run past its own start, for
5551
- * 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.
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 sorted = [...readings].sort((left, right) => left - right);
5580
- const median = sorted[Math.floor(sorted.length / 2)];
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: past the run's
5602
- * 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.
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 sorted = [...readings].sort((left, right) => left - right);
5626
- const median = sorted[Math.floor(sorted.length / 2)];
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 sorted = [...readings].sort((left, right) => left - right);
5691
- const costSec = sorted[Math.floor(sorted.length / 2)];
5692
- if (known && Math.abs(costSec - known.costSec) / known.costSec < DECODE_LEARNING_CHANGE) {
5693
- // The same answer as before. Storing it would bump the version and make
5694
- // every session recompute its offer, which is asked for on the path that
5695
- // 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.
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 torrentCostSec = this.#observedTorrentCostPerMegabyte !== null && mediaInfo?.fileLength > 0 &&
5766
- mediaInfo?.durationSeconds > 0
5767
- ? 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
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 = sourceMegabytesPerSecond(
5983
- session,
5984
- 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
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 >= REALTIME_SPEED_MARGIN) {
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 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) ") +
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