@torrent-tv/proxy 2.76.3 → 2.76.5

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.
@@ -20,7 +20,7 @@ import { logger } from "../utils/logger.js";
20
20
  import { ContainerFactory } from "./container/ContainerFactory.js";
21
21
  import { readMachineState, readProcessCpuSeconds, readProxyCpuSeconds, readSystemCpu, shareOfMachine } from "./host-load.js";
22
22
  import { speedFromReadings } from "./encoder-readings.js";
23
- import { availableShareFrom, correctForAvailability } from "./available-share.js";
23
+ import { availableShareFrom } from "./available-share.js";
24
24
  import { contentionPenalty } from "./contention.js";
25
25
  import { minimumBufferFrom } from "./supply-margin.js";
26
26
  import { baseDrawFrom, costPerMegabyteFrom } from "./torrent-cost.js";
@@ -29,6 +29,8 @@ import {
29
29
  ENCODE_RUN_EVENT,
30
30
  ENCODE_RUN_STATE,
31
31
  INITIAL_RUN_STATE,
32
+ liveRunsOf,
33
+ runStateOf,
32
34
  processCanBeSignalled,
33
35
  wireState
34
36
  } from "./encode/encode-run-state.js";
@@ -42,7 +44,6 @@ import {
42
44
  chooseSoftwareEncodeSettings,
43
45
  pickSoftwarePreset,
44
46
  canSustainOutput,
45
- speedBar,
46
47
  maxrateKbpsFor,
47
48
  nominalKbpsForHeight,
48
49
  nominalKbpsForMaxrate,
@@ -68,6 +69,8 @@ import { masterPlaylistText, mediaPlaylistText, segmentIndexForTime } from "./ou
68
69
  import { SourceFiles, sourceDecodeCharacteristics } from "./source/SourceFile.js";
69
70
  import { ProducedIndex } from "./produced-index.js";
70
71
  import { SegmentStore } from "./encode/SegmentStore.js";
72
+ import { EncodeCost } from "./quality/EncodeCost.js";
73
+ import { endOfRun } from "./encode/EncodeRun.js";
71
74
  import {
72
75
  buildRunCommand,
73
76
  ffmpegSeconds,
@@ -1338,72 +1341,6 @@ export function describeGridDrift(published, live) {
1338
1341
 
1339
1342
 
1340
1343
 
1341
- /**
1342
- * Every run this session has going, earliest first.
1343
- *
1344
- * A session holds as many as the machine affords, because one output can be
1345
- * watched from more than one place: a viewer who jumps back gets a run of their
1346
- * own rather than dragging the picture away from a viewer watching ahead.
1347
- *
1348
- * @param {{ runs?: Set<object> }} session
1349
- * @returns {object[]}
1350
- */
1351
- function runsOf(session) {
1352
- const runs = session?.runs instanceof Set ? [...session.runs] : [];
1353
- return runs.sort((left, right) => left.from - right.from);
1354
- }
1355
-
1356
- /**
1357
- * The runs of this session that still have a process.
1358
- *
1359
- * @param {{ runs?: Set<object> }} session
1360
- * @returns {object[]}
1361
- */
1362
- function liveRunsOf(session) {
1363
- return runsOf(session).filter((run) => run.isAlive);
1364
- }
1365
-
1366
- /**
1367
- * What this session's encoding is doing, as one state.
1368
- *
1369
- * The most advanced state among its live runs: a session with anything
1370
- * producing IS producing, whatever else it has going. A session that has never
1371
- * started a run, or whose runs have all ended, is at the table's own initial
1372
- * state — which is what "nothing is encoding" means.
1373
- *
1374
- * @param {{ runs?: Set<object> }} session
1375
- * @returns {string}
1376
- */
1377
- function runStateOf(session) {
1378
- let answer = INITIAL_RUN_STATE;
1379
- let rank = -1;
1380
- for (const run of runsOf(session)) {
1381
- const at = RUN_STATE_ORDER.indexOf(run.state);
1382
- if (at > rank) {
1383
- rank = at;
1384
- answer = run.state;
1385
- }
1386
- }
1387
- return answer;
1388
- }
1389
-
1390
- /**
1391
- * How advanced a run state is, for reducing several runs to one answer.
1392
- *
1393
- * Producing outranks starting, and both outrank a run that has ended: what a
1394
- * caller asking "what is this session's encoding doing" wants to know is
1395
- * whether anything is being made, not what the quietest of them is up to.
1396
- */
1397
- const RUN_STATE_ORDER = [
1398
- ENCODE_RUN_STATE.ENDED_FAILED,
1399
- ENCODE_RUN_STATE.ENDED_COMPLETE,
1400
- ENCODE_RUN_STATE.STOPPED,
1401
- ENCODE_RUN_STATE.IDLE,
1402
- ENCODE_RUN_STATE.RETRY_WAIT,
1403
- ENCODE_RUN_STATE.SUSPENDED,
1404
- ENCODE_RUN_STATE.STARTING,
1405
- ENCODE_RUN_STATE.PRODUCING
1406
- ];
1407
1344
 
1408
1345
  /**
1409
1346
  * Whether this output is cut at times we hand the muxer, rather than at a
@@ -1569,51 +1506,12 @@ export class HlsSessionManager {
1569
1506
  */
1570
1507
  #sessionCreateLatencies = [];
1571
1508
 
1572
- /**
1573
- * What decoding costs for a source this proxy has actually run, keyed by
1574
- * `sourceKey:fileIndex` seconds of work per second of video, with a version
1575
- * that rises whenever a faster reading replaces the one held.
1576
- *
1577
- * The startup clips are H.264 and a source that has to be re-encoded usually
1578
- * is not, so their model is a first approximation. This is the file itself,
1579
- * measured by the encoder that is running on it, and it replaces the model
1580
- * for that file as soon as it exists. Held for the life of the process: it
1581
- * describes a source, and the same source is commonly opened again.
1582
- *
1583
- * @type {Map<string, { costSec: number, version: number }>}
1584
- */
1585
- #observedDecodeCost = new Map();
1586
- /**
1587
- * What copying costs, per source file, learned the same way: `key ->
1588
- * { costSec, readings, version }`. A copy is what runs BESIDE a rung being
1589
- * warmed, and pricing it at nothing is what let a host be told it had a whole
1590
- * machine for the rung.
1591
- *
1592
- * @type {Map<string, { costSec: number, readings: number[], version: number }>}
1593
- */
1594
- #observedCopyCost = new Map();
1595
-
1596
- /**
1597
- * What encoding one audio TRACK of one file costs this host, in seconds of
1598
- * work per second of video. Keyed by source, file and track, because two
1599
- * tracks of one film are not the same encode: a 5.1 AC-3 dub and a stereo
1600
- * AAC original decode and mix differently.
1601
- *
1602
- * Measured exactly as the copy's price is — from an audio-only session's own
1603
- * reported speed, past its start, alone on the machine, and never while the
1604
- * torrent is what is short.
1605
- *
1606
- * @type {Map<string, { costSec: number, readings: number[], version: number }>}
1607
- */
1608
- #observedAudioCost = new Map();
1609
-
1610
- /**
1611
- * The last "not offering" line written, so the same one is not written again.
1612
- * See the end of {@link HlsSessionManager##sustainableHeights}.
1613
- *
1614
- * @type {string}
1615
- */
1616
- #lastOfferLine = "";
1509
+ // What an encoder taught this host — the cost of decoding a file, of
1510
+ // copying its picture, of each of its soundtracks is held by the object
1511
+ // that reads it (`quality/EncodeCost.js`), together with the last refusal
1512
+ // it printed. The three methods that LEARN those costs are still here and
1513
+ // write into it; moving them is the next step, and until then there must
1514
+ // not be two copies of one reading.
1617
1515
  /** The previous reading of the machine, to compare the next one against. */
1618
1516
  #hostLoadSample = null;
1619
1517
  /** The previous reading taken while nothing was encoding, for the torrent's own cost. */
@@ -1802,11 +1700,30 @@ export class HlsSessionManager {
1802
1700
  // What encoders there should be on each output, and where. Given the two
1803
1701
  // things only this class can answer: how many this machine can afford, and
1804
1702
  // how to make one.
1703
+ // What encoding costs this machine, and which heights follow from it. Given
1704
+ // what it cannot work out for itself: which sessions belong to one file, the
1705
+ // host's own readings AT THE MOMENT OF THE QUESTION rather than copied, how
1706
+ // a soundtrack is keyed, how many encoders are running, and what a file
1707
+ // costs merely by being fetched.
1708
+ this.encodeCost = new EncodeCost({
1709
+ liveOutputs: this.liveOutputs,
1710
+ host: () => ({
1711
+ benchmark: this.softwarePresetBenchmark,
1712
+ decodeModel: this.decodeCostModel,
1713
+ contentionPenalties: this.contentionPenalties,
1714
+ availability: this.hostAvailability
1715
+ }),
1716
+ audioCostKey: (session) => this.#audioCostKey(session),
1717
+ runningEncoders: () => this.#runningEncoders(),
1718
+ encodersRunningNow: () => this.#encodersRunningNow(),
1719
+ torrentCostSecFor: (session) => this.#torrentCostSecFor(session)
1720
+ });
1805
1721
  this.encodeOrchestrator = new EncodeOrchestrator({
1806
1722
  maxRunsFor: (address) => this.maxRunsForOutput(address),
1807
1723
  makeRun: ({ address, from }) => this.#makeRunAt(address, from),
1808
1724
  segmentSeconds: this.segmentDurationSec,
1809
1725
  restartCostSec: RUN_RESTART_COST_SEC,
1726
+ lookaheadSegments: Math.ceil(this.lookaheadSeconds / this.segmentDurationSec),
1810
1727
  logger
1811
1728
  });
1812
1729
  // Where each file is cut, held once per file and grid rather than once per
@@ -2447,7 +2364,7 @@ export class HlsSessionManager {
2447
2364
  ? startAtLadderTop(chosenBudget, outputFps, this.softwarePresetBenchmark, {
2448
2365
  decodeModel: this.decodeCostModel,
2449
2366
  source: sourceDecode,
2450
- observedDecodeCostSec: this.#observedDecodeCost.get(SourceFiles.keyFor(sourceKey, fileIndex))?.costSec ?? null,
2367
+ observedDecodeCostSec: this.encodeCost.decodeCost.get(SourceFiles.keyFor(sourceKey, fileIndex))?.costSec ?? null,
2451
2368
  requiredSpeed: this.#requiredSpeedFor(sourceKey, fileIndex)
2452
2369
  })
2453
2370
  : chosenBudget;
@@ -2584,7 +2501,7 @@ export class HlsSessionManager {
2584
2501
  // field can say what the prediction was worth once the step runs. Null
2585
2502
  // when the step was never judged — a copied stream needs no encoder and
2586
2503
  // is never predicted.
2587
- predictedSpeedWhenOffered: this.lastPredictedByHeight?.get(output.encodeHeight) ?? null,
2504
+ predictedSpeedWhenOffered: this.encodeCost.lastPredictedByHeight?.get(output.encodeHeight) ?? null,
2588
2505
  lastPredictionRatio: null,
2589
2506
  // The NAME of this rung, fixed at the height that was asked for. It is
2590
2507
  // deliberately not the height being encoded: a viewer who picked 480p on
@@ -5495,7 +5412,7 @@ export class HlsSessionManager {
5495
5412
  // the honest extent of its claim, and it is a measured figure rather than
5496
5413
  // a chosen one — the same allowance the browser sizes its cushion from.
5497
5414
  const willReach = run.head + lookaheadSegments;
5498
- const allowed = Number.isInteger(run.to) && run.to >= run.from ? run.to : lastIndex;
5415
+ const allowed = Number.isFinite(endOfRun(run)) ? run.to : lastIndex;
5499
5416
  claims.push({ from: run.from, to: Math.min(allowed, willReach) });
5500
5417
  }
5501
5418
  const takenAt = (index) =>
@@ -7260,7 +7177,7 @@ export class HlsSessionManager {
7260
7177
  // outlived the rung: a rung the host cannot hold went on being offered, and
7261
7178
  // went on passing every route guard, after the viewer had left it.
7262
7179
  // Everything else is fixed for the session's life.
7263
- const observed = this.#observedDecodeCost.get(owner.file.key) ?? null;
7180
+ const observed = this.encodeCost.decodeCost.get(owner.file.key) ?? null;
7264
7181
  // Every rung a live viewer has on screen. One answer was enough while a
7265
7182
  // picture had one viewer; two of them can be on two rungs, and withdrawing
7266
7183
  // either is withdrawing a stream that is playing.
@@ -7276,14 +7193,14 @@ export class HlsSessionManager {
7276
7193
  // the menu would keep the answer computed before either was measured — on
7277
7194
  // a copied picture, which is the case they exist for, the decode version
7278
7195
  // never moves at all, so the cache would never be recomputed.
7279
- const copyVersion = this.#observedCopyCost.get(owner.file.key)?.version ?? 0;
7196
+ const copyVersion = this.encodeCost.copyCost.get(owner.file.key)?.version ?? 0;
7280
7197
  const torrentCost = this.#observedTorrentCostPerMegabyte ?? 0;
7281
7198
  // The soundtrack's price is an input too, and so is how many encoders of
7282
7199
  // this family are running: both move the answer, and an answer cached
7283
7200
  // across them is the stale menu this key exists to prevent.
7284
7201
  const audioVersion = [...this.liveOutputs.familyOf(owner)]
7285
7202
  .filter((member) => member.audioOnly === true)
7286
- .map((member) => this.#observedAudioCost.get(this.#audioCostKey(member))?.version ?? 0)
7203
+ .map((member) => this.encodeCost.audioCost.get(this.#audioCostKey(member))?.version ?? 0)
7287
7204
  .reduce((total, one) => total + one, 0);
7288
7205
  const running = [...this.liveOutputs.familyOf(owner)]
7289
7206
  .filter((member) => processCanBeSignalled(runStateOf(member))).length;
@@ -7345,13 +7262,13 @@ export class HlsSessionManager {
7345
7262
  // the rung that taught the lesson would be the first to be dropped, and
7346
7263
  // every route guard reads this list: its next segment would 404 on a stream
7347
7264
  // that is playing, with its own encoder still running.
7348
- const answer = this.#sustainableHeights({
7265
+ const answer = this.encodeCost.sustainableHeights({
7349
7266
  heights: ordered,
7350
7267
  ownHeight: own,
7351
7268
  playingHeights,
7352
7269
  // What each rung was actually seen doing in this session, which is the
7353
7270
  // only thing a live reading may speak for.
7354
- measuredHeights: this.#measuredRungSpeeds(owner),
7271
+ measuredHeights: this.encodeCost.measuredRungSpeeds(owner),
7355
7272
  // The speed this file's supply demands, measured by its own reader on
7356
7273
  // this swarm. A well-seeded film and a thin one ask different speeds of
7357
7274
  // the same machine, so the bar belongs to the pair, not to the host.
@@ -7359,10 +7276,10 @@ export class HlsSessionManager {
7359
7276
  // What the family is already spending while a rung is considered. The
7360
7277
  // picture being COPIED is the common case and used to be priced at
7361
7278
  // nothing; measured, it is about an eighth of the machine.
7362
- concurrentCostSec: this.#committedCostOf(owner),
7279
+ concurrentCostSec: this.encodeCost.committedCostOf(owner),
7363
7280
  // So a height already being produced is not charged for itself when it is
7364
- // judged. See the subtraction in #sustainableHeights.
7365
- runningCostByHeight: this.#runningCostByHeight(owner),
7281
+ // judged. See the subtraction in EncodeCost#sustainableHeights.
7282
+ runningCostByHeight: this.encodeCost.runningCostByHeight(owner),
7366
7283
  sourceWidth: Number(owner.file.width) || 0,
7367
7284
  sourceHeight: Math.round(Number(owner.file.height) || 0),
7368
7285
  fps: Number(owner.output.outputFps) || TRANSCODE_FPS,
@@ -7402,7 +7319,7 @@ export class HlsSessionManager {
7402
7319
  * @returns {number | null}
7403
7320
  */
7404
7321
  #observedDecodeCostFor(session) {
7405
- const entry = this.#observedDecodeCost.get(session.file.key);
7322
+ const entry = this.encodeCost.decodeCost.get(session.file.key);
7406
7323
  return entry ? entry.costSec : null;
7407
7324
  }
7408
7325
 
@@ -7550,7 +7467,7 @@ export class HlsSessionManager {
7550
7467
  if (kind !== "audio") {
7551
7468
  return;
7552
7469
  }
7553
- const others = this.#pricedConcurrentCost(session);
7470
+ const others = this.encodeCost.pricedConcurrentCost(session);
7554
7471
  if (others === null) {
7555
7472
  return; // something running has no price; nothing can be attributed
7556
7473
  }
@@ -7639,14 +7556,14 @@ export class HlsSessionManager {
7639
7556
  return;
7640
7557
  }
7641
7558
  const key = session.file.key;
7642
- const known = this.#observedCopyCost.get(key);
7559
+ const known = this.encodeCost.copyCost.get(key);
7643
7560
  const readings = [...(known?.readings ?? []), costSec].slice(-DECODE_LEARNING_READINGS);
7644
7561
  const median = medianOf(readings);
7645
7562
  if (!movedBeyondScatter(known?.costSec ?? null, median, readings)) {
7646
- this.#observedCopyCost.set(key, { ...known, readings });
7563
+ this.encodeCost.copyCost.set(key, { ...known, readings });
7647
7564
  return;
7648
7565
  }
7649
- this.#observedCopyCost.set(key, { costSec: median, readings, version: (known?.version ?? 0) + 1 });
7566
+ this.encodeCost.copyCost.set(key, { costSec: median, readings, version: (known?.version ?? 0) + 1 });
7650
7567
  logger.info(
7651
7568
  `transcode: ${session.file.name} copies at ${(1 / median).toFixed(2)}x on this host ` +
7652
7569
  `(median of ${readings.length}, latest ${speed.toFixed(2)}x)`
@@ -7680,20 +7597,46 @@ export class HlsSessionManager {
7680
7597
  return;
7681
7598
  }
7682
7599
  const key = this.#audioCostKey(session);
7683
- const known = this.#observedAudioCost.get(key);
7600
+ const known = this.encodeCost.audioCost.get(key);
7684
7601
  const readings = [...(known?.readings ?? []), costSec].slice(-DECODE_LEARNING_READINGS);
7685
7602
  const median = medianOf(readings);
7686
7603
  if (!movedBeyondScatter(known?.costSec ?? null, median, readings)) {
7687
- this.#observedAudioCost.set(key, { ...known, readings });
7604
+ this.encodeCost.audioCost.set(key, { ...known, readings });
7688
7605
  return;
7689
7606
  }
7690
- this.#observedAudioCost.set(key, { costSec: median, readings, version: (known?.version ?? 0) + 1 });
7607
+ this.encodeCost.audioCost.set(key, { costSec: median, readings, version: (known?.version ?? 0) + 1 });
7691
7608
  logger.info(
7692
7609
  `transcode: ${session.file.name} encodes audio track ${session.audioTrackIndex ?? 0} at ` +
7693
7610
  `${(1 / median).toFixed(2)}x on this host (median of ${readings.length}, latest ${speed.toFixed(2)}x)`
7694
7611
  );
7695
7612
  }
7696
7613
 
7614
+ /**
7615
+ * What this file costs the machine merely by being fetched and delivered
7616
+ * while it is watched, in seconds of work per second of video.
7617
+ *
7618
+ * A viewer consumes the file at its own byte rate, and every one of those
7619
+ * bytes is downloaded, verified and pushed by this process. Priced per
7620
+ * megabyte from readings taken while nothing was encoding, so the two
7621
+ * measurements do not contain each other. Zero while either term is unmeasured
7622
+ * — a guess here would refuse rungs on arithmetic nobody performed.
7623
+ *
7624
+ * @param {HlsSession} session
7625
+ * @returns {number}
7626
+ */
7627
+ #torrentCostSecFor(session) {
7628
+ const perMegabyte = this.#observedTorrentCostPerMegabyte;
7629
+ const megabytesPerSecond = this.#torrentMegabytesPerSecond(
7630
+ session.file.sourceKey,
7631
+ session.file.fileIndex,
7632
+ this.#fileLengthByKey.get(session.file.key) ?? null,
7633
+ session.file.durationSeconds
7634
+ );
7635
+ return perMegabyte !== null && megabytesPerSecond !== null
7636
+ ? perMegabyte * megabytesPerSecond
7637
+ : 0;
7638
+ }
7639
+
7697
7640
  /**
7698
7641
  * @param {HlsSession} session
7699
7642
  * @returns {string}
@@ -7740,7 +7683,7 @@ export class HlsSessionManager {
7740
7683
  return;
7741
7684
  }
7742
7685
  const key = session.file.key;
7743
- const known = this.#observedDecodeCost.get(key);
7686
+ const known = this.encodeCost.decodeCost.get(key);
7744
7687
  const readings = [...(known?.readings ?? []), decodeCostSec].slice(-DECODE_LEARNING_READINGS);
7745
7688
  const costSec = medianOf(readings);
7746
7689
  if (!movedBeyondScatter(known?.costSec ?? null, costSec, readings)) {
@@ -7748,10 +7691,10 @@ export class HlsSessionManager {
7748
7691
  // would bump the version and make every session recompute its offer,
7749
7692
  // which is asked for on the path that serves every playlist, init and
7750
7693
  // segment.
7751
- this.#observedDecodeCost.set(key, { ...known, readings });
7694
+ this.encodeCost.decodeCost.set(key, { ...known, readings });
7752
7695
  return;
7753
7696
  }
7754
- this.#observedDecodeCost.set(key, { costSec, readings, version: (known?.version ?? 0) + 1 });
7697
+ this.encodeCost.decodeCost.set(key, { costSec, readings, version: (known?.version ?? 0) + 1 });
7755
7698
  logger.info(
7756
7699
  `transcode: ${session.file.name} decodes at ${(1 / costSec).toFixed(2)}x on this host ` +
7757
7700
  `(median of ${readings.length}, latest ${(1 / decodeCostSec).toFixed(2)}x from ${height}p ` +
@@ -7811,7 +7754,7 @@ export class HlsSessionManager {
7811
7754
  // has run before. Without it a second open of a file answers from the
7812
7755
  // startup clips again, undoing the correction the first playback earned.
7813
7756
  const observedDecodeCostSec = mediaInfo?.sourceKey !== undefined
7814
- ? (this.#observedDecodeCost.get(`${mediaInfo.sourceKey}:${mediaInfo.fileIndex}`)?.costSec ?? null)
7757
+ ? (this.encodeCost.decodeCost.get(`${mediaInfo.sourceKey}:${mediaInfo.fileIndex}`)?.costSec ?? null)
7815
7758
  : null;
7816
7759
  // What this file costs the machine merely by being fetched and delivered is
7817
7760
  // known before any session exists, so the FIRST offer — the one the viewer
@@ -7829,7 +7772,7 @@ export class HlsSessionManager {
7829
7772
  ? this.#observedTorrentCostPerMegabyte * movingMegabytesPerSec
7830
7773
  : 0;
7831
7774
  const forBranch = (transcodeVideo) =>
7832
- this.#sustainableHeights({
7775
+ this.encodeCost.sustainableHeights({
7833
7776
  heights,
7834
7777
  concurrentCostSec: torrentCostSec,
7835
7778
  // What this file's swarm demanded the last time it was read. Absent on
@@ -7850,409 +7793,6 @@ export class HlsSessionManager {
7850
7793
  return { copy: forBranch(false), transcode: forBranch(true) };
7851
7794
  }
7852
7795
 
7853
- /**
7854
- * Drop the rungs this host cannot hold at realtime.
7855
- *
7856
- * Every rung below the source height is a full re-encode — decode the whole
7857
- * source, encode a smaller picture — and on a weak host that is dearer than
7858
- * the copy it replaces. Measured 2026-08-14: 1080p was copied at 7.8-8.9x
7859
- * while the offered 240p rung ran at 0.388-0.947x, its first segment took
7860
- * 30 s and later ones were held 22 s, so choosing a LOWER quality is what
7861
- * broke playback. A rung that cannot be produced faster than it is watched
7862
- * must not be offered at all.
7863
- *
7864
- * The session's OWN height always stays: an encoder is already producing it,
7865
- * and removing it would point the player at a rung nobody is encoding.
7866
- *
7867
- * @param {{ heights: number[], ownHeight: number, sourceWidth: number, sourceHeight: number, fps: number, source: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, transcodeVideo: boolean }} params
7868
- * @returns {number[]}
7869
- */
7870
- /**
7871
- * What a running re-encode of the picture costs, in seconds of work per
7872
- * second of video.
7873
- *
7874
- * Measured first: `lastAloneSpeed` is what this very rung did with the
7875
- * machine to itself. Failing that, the encode model that decides every rung —
7876
- * the same benchmark, the same decode term — applied to this rung's own pixel
7877
- * rate. There is no third answer: a rung whose cost cannot be derived at all
7878
- * contributes nothing rather than a number somebody invented.
7879
- *
7880
- * @param {HlsSession} session
7881
- * @returns {number}
7882
- */
7883
- #pictureCostOf(session) {
7884
- if (Number.isFinite(session.lastAloneSpeed) && session.lastAloneSpeed > 0) {
7885
- return 1 / session.lastAloneSpeed;
7886
- }
7887
- const benchmark = this.softwarePresetBenchmark;
7888
- const width = Number(session.output.encodeWidth) || 0;
7889
- const height = Number(session.output.encodeHeight) || 0;
7890
- const fps = Number(session.output.outputFps) || TRANSCODE_FPS;
7891
- if (!Array.isArray(benchmark) || benchmark.length === 0 || width <= 0 || height <= 0) {
7892
- return 0;
7893
- }
7894
- const { speed } = canSustainOutput({
7895
- benchmark,
7896
- decodeModel: this.decodeCostModel,
7897
- source: session.file.decode ?? null,
7898
- outputPixelsPerSec: width * height * fps,
7899
- observedDecodeCostSec: null,
7900
- concurrentCostSec: 0
7901
- });
7902
- return Number.isFinite(speed) && speed > 0 ? 1 / speed : 0;
7903
- }
7904
-
7905
- /**
7906
- * What everything OTHER than this session is costing right now, or null when
7907
- * any of it is unpriced.
7908
- *
7909
- * Used to recover a soundtrack's own share from a reading taken beside the
7910
- * picture — the only kind of reading a rendition ever gives, since it runs
7911
- * exactly as long as the picture does. Refusing to answer when something
7912
- * running has no price is the point: unpriced work would otherwise be
7913
- * attributed to the soundtrack, and an overpriced soundtrack refuses quality
7914
- * steps the host could actually hold.
7915
- *
7916
- * @param {HlsSession} session
7917
- * @returns {number | null}
7918
- */
7919
- #pricedConcurrentCost(session) {
7920
- let cost = 0;
7921
- for (const member of this.liveOutputs.familyOf(session)) {
7922
- if (member === session || !processCanBeSignalled(runStateOf(member))) {
7923
- continue;
7924
- }
7925
- if (member.audioOnly === true) {
7926
- const audio = this.#observedAudioCost.get(this.#audioCostKey(member));
7927
- if (!audio || !(audio.costSec > 0)) {
7928
- return null;
7929
- }
7930
- cost += audio.costSec;
7931
- continue;
7932
- }
7933
- if (member.transcodeVideo !== true) {
7934
- const copy = this.#observedCopyCost.get(member.file.key);
7935
- if (!copy || !(copy.costSec > 0)) {
7936
- return null;
7937
- }
7938
- cost += copy.costSec;
7939
- continue;
7940
- }
7941
- const picture = this.#pictureCostOf(member);
7942
- if (!(picture > 0)) {
7943
- return null;
7944
- }
7945
- cost += picture;
7946
- }
7947
- // Encoders outside this family are counted by number only — there is no
7948
- // price to look up for another film's session — so a reading taken while
7949
- // one is running cannot be attributed either.
7950
- return this.#runningEncoders() > this.liveOutputs.familyOf(session).filter(
7951
- (member) => processCanBeSignalled(runStateOf(member))
7952
- ).length
7953
- ? null
7954
- : cost;
7955
- }
7956
-
7957
- /**
7958
- * What each height of this family is costing RIGHT NOW, for the heights an
7959
- * encoder is actually running at.
7960
- *
7961
- * Exists so a height can be judged against what the machine spends on
7962
- * everything else — a step being warmed is running while it is judged, and
7963
- * charged its own cost it refuses itself.
7964
- *
7965
- * @param {HlsSession} session
7966
- * @returns {Map<number, number>}
7967
- */
7968
- #runningCostByHeight(session) {
7969
- /** @type {Map<number, number>} */
7970
- const byHeight = new Map();
7971
- for (const member of this.liveOutputs.familyOf(session)) {
7972
- if (member.audioOnly === true || member.transcodeVideo !== true) {
7973
- continue;
7974
- }
7975
- if (!processCanBeSignalled(runStateOf(member))) {
7976
- continue;
7977
- }
7978
- const height = this.liveOutputs.variantHeightOf(member);
7979
- if (height > 0) {
7980
- byHeight.set(height, (byHeight.get(height) ?? 0) + this.#pictureCostOf(member));
7981
- }
7982
- }
7983
- return byHeight;
7984
- }
7985
-
7986
- /**
7987
- * Seconds of work per second of video this family is ALREADY committed to,
7988
- * beside any rung being considered.
7989
- *
7990
- * Every encoder of the family that is actually running: the picture, whether
7991
- * it is copied or re-encoded, and each audio rendition. The rung the viewer
7992
- * is watching and the source's own copied height are never withdrawn by the
7993
- * caller, so charging for the encoder that serves them cannot strand anyone —
7994
- * what it does is stop the NEXT rung being offered as though the machine were
7995
- * idle, which is what the field disproved on 2026-08-15.
7996
- *
7997
- * Anything whose cost is neither measured nor derivable contributes nothing.
7998
- * A guess here would refuse rungs on arithmetic nobody performed.
7999
- *
8000
- * @param {HlsSession} session
8001
- * @returns {number}
8002
- */
8003
- #committedCostOf(session) {
8004
- let cost = 0;
8005
- for (const member of this.liveOutputs.familyOf(session)) {
8006
- // Only what still HAS an encoder. A quality step the viewer left keeps
8007
- // its session and its segments but not a process, and it produces nothing
8008
- // for anybody — charging the machine for it would refuse steps on work
8009
- // nobody is doing.
8010
- //
8011
- // A SUSPENDED encoder is charged, deliberately, and this is not the same
8012
- // question. The unit here is seconds of work per second of VIDEO, not per
8013
- // second of wall clock: a copy running at 8x costs 0.125 s/s whether it
8014
- // is producing right now or parked by the look-ahead cap, because over an
8015
- // hour of watching it still produces an hour of video. Suspension is how
8016
- // that cost is spread, not a discount on it — and pricing a parked
8017
- // encoder at zero would offer a step on the strength of a pause that ends
8018
- // the moment the viewer catches up.
8019
- if (!processCanBeSignalled(runStateOf(member))) {
8020
- continue;
8021
- }
8022
- if (member.audioOnly === true) {
8023
- // A soundtrack encoder, priced from its own measured speed. Nothing is
8024
- // charged for a track nobody has measured: a guess here refuses rungs
8025
- // on arithmetic no one performed.
8026
- const audio = this.#observedAudioCost.get(this.#audioCostKey(member));
8027
- cost += audio && audio.costSec > 0 ? audio.costSec : 0;
8028
- continue;
8029
- }
8030
- if (member.transcodeVideo !== true) {
8031
- const observed = this.#observedCopyCost.get(member.file.key);
8032
- cost += observed && observed.costSec > 0 ? observed.costSec : 0;
8033
- continue;
8034
- }
8035
- // A picture being RE-ENCODED beside the rung being judged — the warm-up
8036
- // that makes a quality switch seamless is two encoders by design, and
8037
- // that overlap is exactly where the field measured 0.504x on a rung
8038
- // predicted at 1.58x (2026-08-15). Priced by what it has been SEEN doing
8039
- // when it had the machine to itself, and otherwise by the same model that
8040
- // judges every rung — which is a prediction, not a guess.
8041
- cost += this.#pictureCostOf(member);
8042
- }
8043
- // And what the FILE costs simply by being fetched and delivered while it is
8044
- // watched: a viewer consumes it at its own byte rate, and every one of
8045
- // those bytes is downloaded, verified and pushed by this process. Priced
8046
- // per megabyte from readings taken while nothing was encoding, so the two
8047
- // measurements do not contain each other.
8048
- const perMegabyte = this.#observedTorrentCostPerMegabyte;
8049
- const megabytesPerSecond = this.#torrentMegabytesPerSecond(
8050
- session.file.sourceKey,
8051
- session.file.fileIndex,
8052
- this.#fileLengthByKey.get(session.file.key) ?? null,
8053
- session.file.durationSeconds
8054
- );
8055
- if (perMegabyte !== null && megabytesPerSecond !== null) {
8056
- cost += perMegabyte * megabytesPerSecond;
8057
- }
8058
- return cost;
8059
- }
8060
-
8061
- /**
8062
- * The speed each rung of this family was last seen running at, when it was
8063
- * running alone.
8064
- *
8065
- * A rung that has been watched failing is refused on that evidence; a rung
8066
- * nobody has run says nothing about itself and is judged by the startup
8067
- * measurement like any other.
8068
- *
8069
- * @param {HlsSession} base
8070
- * @returns {Map<number, number>}
8071
- */
8072
- #measuredRungSpeeds(base) {
8073
- /** @type {Map<number, number>} */
8074
- const speeds = new Map();
8075
- for (const session of this.liveOutputs.familyOf(base)) {
8076
- if (session.transcodeVideo !== true || !Number.isFinite(session.lastAloneSpeed)) {
8077
- continue;
8078
- }
8079
- const height = this.liveOutputs.variantHeightOf(session);
8080
- if (height > 0) {
8081
- speeds.set(height, session.lastAloneSpeed);
8082
- }
8083
- }
8084
- return speeds;
8085
- }
8086
-
8087
- #sustainableHeights({
8088
- heights,
8089
- ownHeight,
8090
- // Every height a viewer has on screen, not one: two viewers of one picture
8091
- // can be on two rungs, and a rung is never withdrawn while somebody is
8092
- // watching it — their next segment would 404 on a stream that is playing.
8093
- playingHeights = new Set(),
8094
- sourceWidth,
8095
- sourceHeight,
8096
- fps,
8097
- source,
8098
- transcodeVideo,
8099
- observedDecodeCostSec = null,
8100
- concurrentCostSec = 0,
8101
- runningCostByHeight = null,
8102
- measuredHeights = null,
8103
- requiredSpeed = null
8104
- }) {
8105
- // What this file's own supply demands, measured by its reader — and
8106
- // realtime while it has not been measured. Read once here so the line that
8107
- // reports a refusal names the figure it refused against.
8108
- const bar = speedBar(requiredSpeed);
8109
- const benchmark = this.softwarePresetBenchmark;
8110
- if (!Array.isArray(benchmark) || benchmark.length === 0 || sourceHeight <= 0 || sourceWidth <= 0) {
8111
- return heights;
8112
- }
8113
- /** @type {number[]} */
8114
- const kept = [];
8115
- /** @type {string[]} */
8116
- const dropped = [];
8117
- // What each height was predicted to do on THIS machine, kept so a session
8118
- // started at that height can be compared against it once it runs. The
8119
- // manager holds the last answer, because the offer is computed on the path
8120
- // that serves every request while a session is created elsewhere.
8121
- /** @type {Map<number, number | null>} */
8122
- const predictedByHeight = new Map();
8123
- for (const height of heights) {
8124
- // A rung this session has actually been seen running below realtime is
8125
- // withdrawn on that evidence, whatever the prediction says. This is the
8126
- // one thing a live reading is authority on: itself. It is asked before
8127
- // any exemption so a rung measured failing while on screen does not stay
8128
- // offered because it was on screen when measured — otherwise a step
8129
- // would ask for the one rung this machine has been measured failing at,
8130
- // then fail again, then step down, for ever. A copied source height
8131
- // cannot reach this: `#measuredRungSpeeds` records only sessions that
8132
- // re-encode, so a copy has no reading to be withdrawn on, which is right
8133
- // — it costs no encoder.
8134
- const measured = measuredHeights?.get(height) ?? null;
8135
- if (measured !== null && measured < 1) {
8136
- // Even the rung on screen is withdrawn on measured failure: keeping it
8137
- // would 404 the next segment, but keeping a rung measured at 0.007x
8138
- // (field 2026-08-31, 4K HEVC on CM4) stalls the viewer for minutes with
8139
- // 0.04s buffered and no way to downgrade because every other rung is
8140
- // also dropped. Withdrawing it lets the offer become empty, which the
8141
- // caller turns into an error the viewer can act on (try another proxy
8142
- // or a lower source) instead of an endless spinner.
8143
- dropped.push(`${height}p=${measured.toFixed(2)}x measured`);
8144
- continue;
8145
- }
8146
- // The rung ON SCREEN is kept only when it has not been measured failing
8147
- // above. Keeping a rung measured at 0.007x would stall the viewer with
8148
- // no path to a faster rung, which is what the field showed.
8149
- if (playingHeights.has(height)) {
8150
- kept.push(height);
8151
- continue;
8152
- }
8153
- // The height an encoder is ALREADY producing, and the source's own height
8154
- // when the FAMILY serves it by copy — neither has to be predicted,
8155
- // because it is happening. A copied rung costs no encoder at all, so no
8156
- // measurement of this host can ever be a reason to withdraw it, and the
8157
- // whole point of it is that it is where a viewer on a rung the machine
8158
- // cannot hold goes back to. `transcodeVideo` here is the base's, not the
8159
- // asking session's: a 240p rung re-encodes, and reading its own flag is
8160
- // what withdrew a copied 1080p in the field on 2026-08-15.
8161
- //
8162
- // A source height that would have to be RE-ENCODED is a prediction like
8163
- // any other: on a session whose budget stepped down to 480p, the source's
8164
- // 1080p is neither copied nor being produced, and keeping it unpriced
8165
- // would offer exactly the kind of rung this refuses. Likewise, a rung
8166
- // this session is already producing at 0.007x (field 2026-08-31, 4K HEVC
8167
- // on CM4, 0.1x at 23:45 and 0.007x at 06:57) is not sustainable just
8168
- // because it is running — keeping it offered no path to a faster rung
8169
- // and left the viewer at 0.04s buffered with no downgrade.
8170
- if (
8171
- (height === ownHeight && !transcodeVideo) ||
8172
- (height === sourceHeight && !transcodeVideo)
8173
- ) {
8174
- kept.push(height);
8175
- continue;
8176
- }
8177
- const width = Math.round(((sourceWidth / sourceHeight) * height) / 2) * 2;
8178
- // What the machine is spending on everything EXCEPT this height. A step
8179
- // being warmed for a switch is already running while it is judged, so its
8180
- // own cost is inside the committed total — and charged against itself it
8181
- // is counted twice. Measured against the field figures of 2026-08-15
8182
- // that is 1.83x against 1.03x: below the margin, so the step the viewer
8183
- // had just asked for was dropped from the offer by the act of warming it,
8184
- // and its next segment answered 404 on a stream that was playing.
8185
- const concurrentBesideThis = Math.max(
8186
- 0,
8187
- concurrentCostSec - (runningCostByHeight?.get(height) ?? 0)
8188
- );
8189
- const { speed } = canSustainOutput({
8190
- benchmark,
8191
- decodeModel: this.decodeCostModel,
8192
- source,
8193
- outputPixelsPerSec: width * height * fps,
8194
- observedDecodeCostSec,
8195
- concurrentCostSec: concurrentBesideThis
8196
- });
8197
- // The benchmark behind that figure was taken on a QUIET host — one
8198
- // ffmpeg and nothing else. The machine a step will actually run on is
8199
- // also running the kernel, the container and whatever else its owner
8200
- // does, and on the addon host that was measured at 99 % busy with a
8201
- // quarter of it unattributed. Only the unattributed part is charged
8202
- // here: our own encoders are already in `concurrentBesideThis` and the
8203
- // proxy's own work is already priced per megabyte moved.
8204
- // Two corrections, and they are different facts about the machine. The
8205
- // availability share removes work nobody has been charged for; the
8206
- // contention penalty says what OUR OWN second job costs, because the
8207
- // budget adds independent prices and this host does not behave that way
8208
- // — the same work measured 2.6× dearer beside one encoder and 3.7×
8209
- // beside two (2026-08-18). `concurrentBesideThis` already counts what is
8210
- // committed; this multiplies by how badly running at all together goes.
8211
- const othersRunning = concurrentBesideThis > 0 ? this.#encodersRunningNow() : 0;
8212
- const { penalty } = contentionPenalty(othersRunning, this.contentionPenalties);
8213
- const onThisMachine = correctForAvailability(
8214
- speed === null ? null : speed / penalty,
8215
- this.hostAvailability
8216
- );
8217
- // Kept against the step's own session, so that when it runs the field
8218
- // says what the prediction was worth. Without this the only comparison
8219
- // available is between two figures written minutes apart in different
8220
- // lines of the log.
8221
- predictedByHeight.set(height, onThisMachine);
8222
- if (onThisMachine !== null && onThisMachine >= bar) {
8223
- kept.push(height);
8224
- continue;
8225
- }
8226
- dropped.push(`${height}p=${onThisMachine === null ? "n/a" : `${onThisMachine.toFixed(2)}x`}`);
8227
- }
8228
- // Written when the ANSWER changes, not when the answer is recomputed. This
8229
- // is asked on the path that serves every playlist, init and segment, and
8230
- // the figures behind it move every five seconds — so an unconditional line
8231
- // here is roughly seven hundred identical lines an hour into a forwarder
8232
- // that holds five hundred, which buries whatever is worth reading.
8233
- if (dropped.length > 0) {
8234
- const line =
8235
- `transcode: not offering ${dropped.join(" ")} — below ${bar.toFixed(2)}x ` +
8236
- (Number.isFinite(requiredSpeed) && requiredSpeed > 1
8237
- ? "(the speed this file's own interruptions demand) "
8238
- : "(realtime, this file's supply not measured yet) ") +
8239
- // Said with the figures, because a step refused on a busy machine and
8240
- // one refused on an idle machine are different facts about the host.
8241
- (this.hostAvailability?.known
8242
- ? `on a machine with ${Math.round(this.hostAvailability.share * 100)}% to spare `
8243
- : "") +
8244
- `(offering ${kept.map((height) => `${height}p`).join(" ")})`;
8245
- if (line !== this.#lastOfferLine) {
8246
- this.#lastOfferLine = line;
8247
- logger.info(line);
8248
- }
8249
- this.lastPredictedByHeight = predictedByHeight;
8250
- } else {
8251
- this.lastPredictedByHeight = predictedByHeight;
8252
- this.#lastOfferLine = "";
8253
- }
8254
- return kept;
8255
- }
8256
7796
 
8257
7797
  /**
8258
7798
  * The height this proxy is asking the player to move to, or 0.