@torrent-tv/proxy 2.17.0 → 2.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## 2.18.0
2
+
3
+ - **New**: Copying the picture is no longer priced at nothing. It demuxes, re-encodes the audio and writes segments, and it is what runs BESIDE every rung warmed for a quality change — the field measured it at 7.92-8.02x, about an eighth of a second of work per second of video. The figure is not a constant: a session that is copying reports its own speed, and the reciprocal of that IS the cost, learned per file as the decode cost already is (median of recent readings, only from a run past its own start, never from a suspended one).
4
+ - **New**: A rung is judged against the machine it will actually have. The cost of what the family is already committed to is added to the rung's own before the check, so the arithmetic of 2026-08-15 comes out as it did in the field: 0.125 for the copy plus about 1.05 for the 240p rung is more than the one second of work per second the machine has. Unmeasured means zero, so a host that has observed nothing is exactly as permissive as before.
5
+ - **Fix**: A copy reading taken while the torrent is short is discarded. A re-encode near realtime may be the host's limit; a copy near realtime is a copy waiting for data, since copying runs at eight times realtime — and filing that as the price of copying would refuse rungs on the download's account. An audio rendition is excluded from this learning too: it carries no picture, and its speed is the price of a soundtrack, not of a copy.
6
+
1
7
  ## 2.17.0
2
8
 
3
9
  - **New**: The encoder is benchmarked on real footage instead of a generated test pattern, and measured by ffmpeg's own progress rather than by the clock around the process. The pattern has flat areas and no grain and encodes **1.23x** cheaper than film on the same machine and preset — an error that always points at offering a rung the host cannot hold. Timing whole runs was the second error: process startup is ~0.4 s, which put `fast` and `ultrafast` within 1.24x of each other when they differ by three times. The clip is decoded once to raw frames in a temp file (feeding them through a pipe measured the pipe: the fastest presets want hundreds of megabytes a second), each preset is read from the slope between two progress reports, and the run is stopped as soon as a second of it has been covered.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.17.0",
3
+ "version": "2.18.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -1194,6 +1194,15 @@ export class HlsSessionManager {
1194
1194
  * @type {Map<string, { costSec: number, version: number }>}
1195
1195
  */
1196
1196
  #observedDecodeCost = new Map();
1197
+ /**
1198
+ * What copying costs, per source file, learned the same way: `key ->
1199
+ * { costSec, readings, version }`. A copy is what runs BESIDE a rung being
1200
+ * warmed, and pricing it at nothing is what let a host be told it had a whole
1201
+ * machine for the rung.
1202
+ *
1203
+ * @type {Map<string, { costSec: number, readings: number[], version: number }>}
1204
+ */
1205
+ #observedCopyCost = new Map();
1197
1206
  /** The previous reading of the machine, to compare the next one against. */
1198
1207
  #hostLoadSample = null;
1199
1208
 
@@ -4549,6 +4558,10 @@ export class HlsSessionManager {
4549
4558
  heights: ordered,
4550
4559
  ownHeight: own,
4551
4560
  playingHeight: playing,
4561
+ // What the family is already spending while a rung is considered. The
4562
+ // picture being COPIED is the common case and used to be priced at
4563
+ // nothing; measured, it is about an eighth of the machine.
4564
+ concurrentCostSec: this.#committedCostOf(owner),
4552
4565
  sourceWidth: Number(owner.sourceWidth) || 0,
4553
4566
  sourceHeight: Math.round(Number(owner.sourceHeight) || 0),
4554
4567
  fps: Number(owner.outputFps) || TRANSCODE_FPS,
@@ -4648,9 +4661,76 @@ export class HlsSessionManager {
4648
4661
  this.#learnDecodeCost(session, speed);
4649
4662
  }
4650
4663
 
4664
+ /**
4665
+ * What COPYING this file costs on this host, learned from a session that is
4666
+ * doing it: seconds of work per second of video.
4667
+ *
4668
+ * A copy is not free. It demuxes, it re-encodes the audio, it writes
4669
+ * segments, and it runs BESIDE every rung warmed for a quality change — so a
4670
+ * budget that prices it at nothing predicts a machine that does not exist.
4671
+ * The figure is the reciprocal of the speed the session reports, which is the
4672
+ * measurement itself rather than a model of it.
4673
+ *
4674
+ * Median of recent readings, taken only from a run past its own start, for
4675
+ * the same reasons as the decode cost beside it.
4676
+ *
4677
+ * @param {HlsSession} session
4678
+ * @param {number} speed
4679
+ */
4680
+ async #learnCopyCost(session, speed) {
4681
+ const runStartedAt = Number(session.encodeRunStartedAt);
4682
+ if (!Number.isFinite(runStartedAt) || Date.now() - runStartedAt < DECODE_LEARNING_SETTLE_MS) {
4683
+ return;
4684
+ }
4685
+ if (session.encoderPaused === true) {
4686
+ return; // a suspended run reports a cumulative figure that is decaying
4687
+ }
4688
+ // Always asked, not only below realtime. A re-encode near 1x may be the
4689
+ // host; a COPY near 1x is a copy waiting for the torrent, because copying
4690
+ // is what a machine does at eight times realtime — and a starved reading
4691
+ // filed as the price of copying would refuse rungs on the download's
4692
+ // account.
4693
+ if (await this.#classifyTranscodeBound(session) === "download") {
4694
+ return;
4695
+ }
4696
+ const costSec = 1 / speed;
4697
+ if (!(costSec > 0) || !Number.isFinite(costSec)) {
4698
+ return;
4699
+ }
4700
+ const key = `${session.sourceKey}:${session.fileIndex}`;
4701
+ const known = this.#observedCopyCost.get(key);
4702
+ const readings = [...(known?.readings ?? []), costSec].slice(-DECODE_LEARNING_READINGS);
4703
+ const sorted = [...readings].sort((left, right) => left - right);
4704
+ const median = sorted[Math.floor(sorted.length / 2)];
4705
+ if (known && Math.abs(median - known.costSec) / known.costSec < DECODE_LEARNING_CHANGE) {
4706
+ this.#observedCopyCost.set(key, { ...known, readings });
4707
+ return;
4708
+ }
4709
+ this.#observedCopyCost.set(key, { costSec: median, readings, version: (known?.version ?? 0) + 1 });
4710
+ logger.info(
4711
+ `transcode: ${session.fileName} copies at ${(1 / median).toFixed(2)}x on this host ` +
4712
+ `(median of ${readings.length}, latest ${speed.toFixed(2)}x)`
4713
+ );
4714
+ }
4715
+
4651
4716
  #learnDecodeCost(session, speed) {
4652
- if (session.transcodeVideo !== true || !(speed > 0)) {
4653
- return; // a copied video decodes nothing, so it says nothing about decoding
4717
+ if (!(speed > 0)) {
4718
+ return;
4719
+ }
4720
+ if (session.transcodeVideo !== true) {
4721
+ // A copied video decodes nothing, so it says nothing about DECODING —
4722
+ // but it says exactly what COPYING costs, which the budget has been
4723
+ // treating as free. Field 2026-08-15: a copy ran at 7.92-8.02x, i.e.
4724
+ // about an eighth of a second of work per second of video, while a rung
4725
+ // warmed beside it needed the rest of the machine.
4726
+ //
4727
+ // An audio rendition also carries no video, and its speed is the cost of
4728
+ // encoding a soundtrack — a different quantity that must not be filed
4729
+ // under what copying the picture costs.
4730
+ if (session.audioOnly !== true) {
4731
+ void this.#learnCopyCost(session, speed);
4732
+ }
4733
+ return;
4654
4734
  }
4655
4735
  const runStartedAt = Number(session.encodeRunStartedAt);
4656
4736
  if (!Number.isFinite(runStartedAt) || Date.now() - runStartedAt < DECODE_LEARNING_SETTLE_MS) {
@@ -4788,6 +4868,28 @@ export class HlsSessionManager {
4788
4868
  * @param {{ heights: number[], ownHeight: number, sourceWidth: number, sourceHeight: number, fps: number, source: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, transcodeVideo: boolean }} params
4789
4869
  * @returns {number[]}
4790
4870
  */
4871
+ /**
4872
+ * Seconds of work per second of video this family is ALREADY committed to,
4873
+ * beside any rung being considered.
4874
+ *
4875
+ * Today that is the copy of the picture, where the video is copied and its
4876
+ * cost has been observed. An encoded picture is not added: a viewer changing
4877
+ * quality leaves the rung they are on, so the two do not overlap for long,
4878
+ * and the warm-up that does overlap is bounded by the switch. An audio
4879
+ * rendition is not added either, until its cost is measured the same way —
4880
+ * counting it at a guess would refuse rungs on arithmetic nobody took.
4881
+ *
4882
+ * @param {HlsSession} session
4883
+ * @returns {number}
4884
+ */
4885
+ #committedCostOf(session) {
4886
+ if (session.transcodeVideo === true) {
4887
+ return 0;
4888
+ }
4889
+ const observed = this.#observedCopyCost.get(`${session.sourceKey}:${session.fileIndex}`);
4890
+ return observed && observed.costSec > 0 ? observed.costSec : 0;
4891
+ }
4892
+
4791
4893
  #sustainableHeights({
4792
4894
  heights,
4793
4895
  ownHeight,
@@ -4797,7 +4899,8 @@ export class HlsSessionManager {
4797
4899
  fps,
4798
4900
  source,
4799
4901
  transcodeVideo,
4800
- observedDecodeCostSec = null
4902
+ observedDecodeCostSec = null,
4903
+ concurrentCostSec = 0
4801
4904
  }) {
4802
4905
  const benchmark = this.softwarePresetBenchmark;
4803
4906
  if (!Array.isArray(benchmark) || benchmark.length === 0 || sourceHeight <= 0 || sourceWidth <= 0) {
@@ -4835,7 +4938,8 @@ export class HlsSessionManager {
4835
4938
  decodeModel: this.decodeCostModel,
4836
4939
  source,
4837
4940
  outputPixelsPerSec: width * height * fps,
4838
- observedDecodeCostSec
4941
+ observedDecodeCostSec,
4942
+ concurrentCostSec
4839
4943
  });
4840
4944
  if (sustainable) {
4841
4945
  kept.push(height);
@@ -1054,7 +1054,8 @@ export function canSustainOutput({
1054
1054
  decodeModel = null,
1055
1055
  source = null,
1056
1056
  outputPixelsPerSec,
1057
- observedDecodeCostSec = null
1057
+ observedDecodeCostSec = null,
1058
+ concurrentCostSec = 0
1058
1059
  }) {
1059
1060
  if (!Array.isArray(benchmark) || benchmark.length === 0) {
1060
1061
  // Nothing measured on this host: the budget cannot refuse what it cannot
@@ -1070,13 +1071,28 @@ export function canSustainOutput({
1070
1071
  // term the ladder is offered whole, exactly as it was before.
1071
1072
  return { speed: null, sustainable: true };
1072
1073
  }
1073
- const speed = predictedRealtimeSpeed({
1074
+ const alone = predictedRealtimeSpeed({
1074
1075
  decodeModel,
1075
1076
  encodePixelsPerSec: cheapestPresetPixelsPerSec(benchmark),
1076
1077
  outputPixelsPerSec,
1077
1078
  source,
1078
1079
  observedDecodeCostSec: observed
1079
1080
  });
1081
+ // What ELSE will be running while this rung is. A rung is never the only
1082
+ // thing on the machine: the picture it accompanies is being copied or
1083
+ // encoded, an audio track may have its own encoder, and a warm-up is two
1084
+ // encoders by design. Measured on the addon host, a copy alone takes about an
1085
+ // eighth of the machine per second of video, and the field case of
1086
+ // 2026-08-15 adds up exactly: 0.125 for the copy plus ~1.05 for the rung is
1087
+ // more than the one second per second the machine has, which is what was
1088
+ // observed.
1089
+ //
1090
+ // Zero when nothing else is known to be running, or when nothing has been
1091
+ // measured yet — then this is a LOWER bound on the cost and the check is as
1092
+ // permissive as it was before.
1093
+ const speed = alone === null || !(concurrentCostSec > 0)
1094
+ ? alone
1095
+ : 1 / (1 / alone + concurrentCostSec);
1080
1096
  if (speed === null) {
1081
1097
  return { speed: null, sustainable: true };
1082
1098
  }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @file A rung is judged against the machine it will actually have.
3
+ *
4
+ * The field case of 2026-08-15, in arithmetic: a copy of the picture took about
5
+ * 0.125 s of work per second of video (7.9-8.0x measured), a 240p rung needed
6
+ * about 1.05, and the two together are more than the one second per second the
7
+ * machine has. The budget priced the copy at nothing, offered the rung, and the
8
+ * viewer watched it fail.
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import test from "node:test";
13
+
14
+ import { canSustainOutput } from "../services/hwaccel.js";
15
+
16
+ // A host that encodes 640x360 at 24 fps about eleven times over, and decodes
17
+ // 1080p24 at 2.6x — the addon host's own figures.
18
+ const BENCHMARK = [
19
+ { preset: "fast", pixelsPerSec: 17.0e6 },
20
+ { preset: "ultrafast", pixelsPerSec: 67.5e6 }
21
+ ];
22
+ const DECODE_MODEL = { pixelTerm: 0.007742, bitrateTerm: 0, constantTerm: 0 };
23
+ const SOURCE = { megapixelsPerSecond: (1920 * 1080 * 24) / 1e6, megabitsPerSecond: 8 };
24
+ const RUNG_240P = 426 * 240 * 24;
25
+
26
+ test("a rung is judged on the machine it will have, not on an empty one", () => {
27
+ const alone = canSustainOutput({
28
+ benchmark: BENCHMARK,
29
+ decodeModel: DECODE_MODEL,
30
+ source: SOURCE,
31
+ outputPixelsPerSec: RUNG_240P
32
+ });
33
+ const beside = canSustainOutput({
34
+ benchmark: BENCHMARK,
35
+ decodeModel: DECODE_MODEL,
36
+ source: SOURCE,
37
+ outputPixelsPerSec: RUNG_240P,
38
+ // The picture is being copied beside it, at the cost the field measured.
39
+ concurrentCostSec: 0.125
40
+ });
41
+ assert.ok(alone.speed !== null && beside.speed !== null);
42
+ assert.ok(beside.speed < alone.speed, "sharing the machine cannot make a rung faster");
43
+ });
44
+
45
+ test("nothing else running leaves the answer exactly as it was", () => {
46
+ const withZero = canSustainOutput({
47
+ benchmark: BENCHMARK,
48
+ decodeModel: DECODE_MODEL,
49
+ source: SOURCE,
50
+ outputPixelsPerSec: RUNG_240P,
51
+ concurrentCostSec: 0
52
+ });
53
+ const without = canSustainOutput({
54
+ benchmark: BENCHMARK,
55
+ decodeModel: DECODE_MODEL,
56
+ source: SOURCE,
57
+ outputPixelsPerSec: RUNG_240P
58
+ });
59
+ assert.equal(withZero.speed, without.speed);
60
+ });
61
+
62
+ test("a host with nothing measured still refuses nothing", () => {
63
+ const answer = canSustainOutput({
64
+ benchmark: [],
65
+ outputPixelsPerSec: RUNG_240P,
66
+ concurrentCostSec: 0.125
67
+ });
68
+ assert.equal(answer.sustainable, true);
69
+ assert.equal(answer.speed, null);
70
+ });
71
+
72
+ test("a cost heavy enough to fill the machine puts a rung below realtime", () => {
73
+ const answer = canSustainOutput({
74
+ benchmark: BENCHMARK,
75
+ decodeModel: DECODE_MODEL,
76
+ source: SOURCE,
77
+ outputPixelsPerSec: RUNG_240P,
78
+ concurrentCostSec: 0.9
79
+ });
80
+ assert.ok(answer.speed !== null && answer.speed < 1, "0.9 of the machine spent elsewhere leaves less than realtime");
81
+ assert.equal(answer.sustainable, false);
82
+ });