@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 +6 -0
- package/package.json +1 -1
- package/services/hls-session-manager.js +108 -4
- package/services/hwaccel.js +18 -2
- package/test/concurrent-cost.test.js +82 -0
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
|
@@ -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 (
|
|
4653
|
-
return;
|
|
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);
|
package/services/hwaccel.js
CHANGED
|
@@ -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
|
|
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
|
+
});
|