@torrent-tv/proxy 2.16.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 +14 -0
- package/package.json +1 -1
- package/server.js +6 -12
- package/services/hls-session-manager.js +158 -12
- package/services/hwaccel.js +1518 -1248
- package/test/concurrent-cost.test.js +82 -0
- package/test/encode-slope.test.js +46 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
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
|
+
|
|
7
|
+
## 2.17.0
|
|
8
|
+
|
|
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.
|
|
10
|
+
- **Fix**: A preset that ends before its window is covered is still measured, but never over a window of no width — two reports a millisecond apart would have called a host twenty times faster than it is, and one such reading is what every ladder decision is then taken from. A position ffmpeg reports as the smallest signed 64-bit integer (some builds print that instead of `N/A` before the first packet) is discarded, and a slope above a thousand times realtime is treated as a fault rather than as a fast machine.
|
|
11
|
+
- **Fix**: Which rungs may be offered is decided from the CHEAPEST preset's throughput, not from the largest reading in the array. Measurements scatter on a busy machine — `faster` read below `fast` twice on 2026-08-15 — and taking the maximum let one noisy reading of an expensive preset raise the bar that decides what is offered. Choosing a preset still scans every entry rather than stopping at the first miss, because there the direction of that error costs picture quality, not playback.
|
|
12
|
+
- **Fix**: A host with nothing measured says so in those words — `the quality ladder is UNFILTERED on this host` — because that is what an empty benchmark means, and the previous wording said only that presets were unmeasured. The benchmark also can no longer stop the proxy from starting: a missing or read-only temp directory, or a locked file after a kill, is a host left unmeasured, not a process that fails to listen.
|
|
13
|
+
- **Fix**: The host-load line counts CPU per PROCESS across readings, and only for processes present in both. A seek kills ffmpeg and starts another whose counter begins at zero, so subtracting one total from another printed shares like `-598%`; and on a host without `/proc` the sum of no readings was reported as a confident `0%` beside honest `n/a`s.
|
|
14
|
+
|
|
1
15
|
## 2.16.0
|
|
2
16
|
|
|
3
17
|
- **New**: While an encoder runs, one line every five seconds says what the MACHINE is doing: the share of it ffmpeg is getting, the share everything else is taking, the share spent waiting on a disk, the CPU's current clock and its temperature. The budget predicts a rung from benchmarks taken at startup on an idle box, and on 2026-08-15 it predicted 1.83x for a rung that then ran at 0.90-0.999x with nothing else encoding — and no log anywhere could say which of the candidate reasons it was. Now the reading exists: an encoder starved of cores, a machine that has dropped its clock or grown hot, and work around the encode that nobody counted all look different in this line. Linux-only and best effort — a host without `/proc` writes nothing and nothing else changes.
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -124,21 +124,15 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
|
|
|
124
124
|
// For software libx264, benchmark preset throughput once at startup so the
|
|
125
125
|
// session manager can pick the highest-quality preset that still encodes each
|
|
126
126
|
// stream faster than realtime. Hardware encoders use their own fixed preset.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
// A re-encode pays for decoding as well, and the preset benchmark measures
|
|
131
|
-
// only the encoder — which is how a rung this host runs at 0.39x came to be
|
|
132
|
-
// offered as if it cleared realtime 2.5× over (measured 2026-08-14). Solve
|
|
133
|
-
// the decode cost from the bundled calibration clips once at startup; every
|
|
134
|
-
// source is then priced from figures the probe already has.
|
|
135
|
-
// Only the software path can read it: the budget and the ladder both bail
|
|
136
|
-
// on a missing preset benchmark, and that is only produced for libx264. A
|
|
137
|
-
// host with a hardware encoder would pay three decodes at every start for a
|
|
138
|
-
// figure nothing would ever ask for.
|
|
127
|
+
// The decode model first, and the preset benchmark second — they are
|
|
128
|
+
// independent now (the presets are timed on raw frames), but the order costs
|
|
129
|
+
// nothing and keeps the two figures side by side in the log.
|
|
139
130
|
const decodeCostModel = videoEncoder?.kind === "software"
|
|
140
131
|
? await benchmarkDecodeCost({ ffmpegBin, logger })
|
|
141
132
|
: null;
|
|
133
|
+
const softwarePresetBenchmark = videoEncoder?.kind === "software"
|
|
134
|
+
? await benchmarkSoftwarePresets({ ffmpegBin, logger })
|
|
135
|
+
: null;
|
|
142
136
|
// Whether this ffmpeg build can tone-map HDR→SDR (zscale + tonemap filters).
|
|
143
137
|
// Detected once; the session manager applies the tonemap chain only for HDR
|
|
144
138
|
// sources on the software path when available.
|
|
@@ -18,7 +18,7 @@ import { spawn } from "node:child_process";
|
|
|
18
18
|
import { createRequire } from "node:module";
|
|
19
19
|
import { logger } from "../utils/logger.js";
|
|
20
20
|
import { readKeyframeIndex } from "./container-index/index.js";
|
|
21
|
-
import { readMachineState,
|
|
21
|
+
import { readMachineState, readProcessCpuSeconds, readSystemCpu, shareOfMachine } from "./host-load.js";
|
|
22
22
|
|
|
23
23
|
/** Own package version, stamped onto session-start log lines. */
|
|
24
24
|
const PROXY_VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
@@ -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
|
|
|
@@ -2665,27 +2674,69 @@ export class HlsSessionManager {
|
|
|
2665
2674
|
this.#hostLoadSample = null;
|
|
2666
2675
|
return;
|
|
2667
2676
|
}
|
|
2668
|
-
//
|
|
2669
|
-
//
|
|
2670
|
-
|
|
2671
|
-
|
|
2677
|
+
// EVERY encoder, added up. One of them is meaningless on a host that runs a
|
|
2678
|
+
// picture and an audio track at once, and picking the first would have
|
|
2679
|
+
// reported whichever the map happened to hold.
|
|
2680
|
+
// Kept per PROCESS, not as one total. The set changes between readings —
|
|
2681
|
+
// a seek kills ffmpeg and starts another with a new pid whose counter
|
|
2682
|
+
// begins at zero, a session ends, a rendition begins — and subtracting one
|
|
2683
|
+
// total from another across a changed set produces nonsense: a restart
|
|
2684
|
+
// alone would print something like `ffmpeg=-598%`. Only pids present in
|
|
2685
|
+
// BOTH readings are counted, so a process that came or went contributes
|
|
2686
|
+
// nothing rather than a lie.
|
|
2687
|
+
const pids = encoding.map((session) => session.ffmpeg?.pid ?? null).filter((pid) => pid !== null);
|
|
2688
|
+
const [system, ...cpuReadings] = await Promise.all([
|
|
2689
|
+
readSystemCpu(),
|
|
2690
|
+
...pids.map((pid) => readProcessCpuSeconds(pid))
|
|
2691
|
+
]);
|
|
2692
|
+
/** @type {Map<number, number>} */
|
|
2693
|
+
const byPid = new Map();
|
|
2694
|
+
pids.forEach((pid, index) => {
|
|
2695
|
+
const seconds = cpuReadings[index];
|
|
2696
|
+
if (seconds !== null) {
|
|
2697
|
+
byPid.set(pid, seconds);
|
|
2698
|
+
}
|
|
2699
|
+
});
|
|
2700
|
+
const sample = { takenAt: Date.now(), byPid, system };
|
|
2672
2701
|
const previous = this.#hostLoadSample;
|
|
2673
2702
|
this.#hostLoadSample = sample;
|
|
2674
2703
|
if (previous === null) {
|
|
2675
2704
|
return; // the first reading is only something to compare against
|
|
2676
2705
|
}
|
|
2677
|
-
|
|
2706
|
+
// Summed over the pids both readings hold, so nothing is measured against a
|
|
2707
|
+
// process that was not there before. Unknown stays unknown: on a host with
|
|
2708
|
+
// no `/proc` there are no readings at all, and the share is null rather
|
|
2709
|
+
// than a confident zero.
|
|
2710
|
+
let encoderDelta = null;
|
|
2711
|
+
for (const [pid, seconds] of sample.byPid) {
|
|
2712
|
+
const before = previous.byPid?.get(pid);
|
|
2713
|
+
if (before !== undefined && seconds >= before) {
|
|
2714
|
+
encoderDelta = (encoderDelta ?? 0) + (seconds - before);
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
const share = shareOfMachine(
|
|
2718
|
+
{ takenAt: previous.takenAt, processCpuSeconds: encoderDelta === null ? null : 0, system: previous.system },
|
|
2719
|
+
{ takenAt: sample.takenAt, processCpuSeconds: encoderDelta, system: sample.system }
|
|
2720
|
+
);
|
|
2678
2721
|
if (share === null) {
|
|
2679
2722
|
return;
|
|
2680
2723
|
}
|
|
2724
|
+
// How many of them are stopped by the look-ahead cap. Without this a zero
|
|
2725
|
+
// share reads as an encoder being starved of the machine, when it is an
|
|
2726
|
+
// encoder deliberately not running — which is what the first readings on
|
|
2727
|
+
// the addon host actually were (2026-08-15: `ffmpeg=0% system=24%`, both
|
|
2728
|
+
// encoders suspended, and the speed beside it a stale figure from before
|
|
2729
|
+
// they stopped).
|
|
2730
|
+
const suspended = encoding.filter((session) => session.encoderPaused === true).length;
|
|
2731
|
+
const running = encoding.length - suspended;
|
|
2681
2732
|
const machine = await readMachineState();
|
|
2682
2733
|
const asPercent = (value) => (value === null ? "n/a" : `${Math.round(value * 100)}%`);
|
|
2683
2734
|
logger.info(
|
|
2684
2735
|
`host-load: ffmpeg=${asPercent(share.processShare)} system=${asPercent(share.systemShare)} ` +
|
|
2685
2736
|
`iowait=${asPercent(share.iowaitShare)} cpu=${machine.megahertz === null ? "n/a" : `${machine.megahertz}MHz`} ` +
|
|
2686
2737
|
`temp=${machine.celsius === null ? "n/a" : `${machine.celsius}C`} ` +
|
|
2687
|
-
`encoders=${
|
|
2688
|
-
`over=${share.elapsedSec.toFixed(1)}s`
|
|
2738
|
+
`encoders=${running} running` + (suspended > 0 ? ` +${suspended} suspended` : "") +
|
|
2739
|
+
` over=${share.elapsedSec.toFixed(1)}s`
|
|
2689
2740
|
);
|
|
2690
2741
|
}
|
|
2691
2742
|
|
|
@@ -4507,6 +4558,10 @@ export class HlsSessionManager {
|
|
|
4507
4558
|
heights: ordered,
|
|
4508
4559
|
ownHeight: own,
|
|
4509
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),
|
|
4510
4565
|
sourceWidth: Number(owner.sourceWidth) || 0,
|
|
4511
4566
|
sourceHeight: Math.round(Number(owner.sourceHeight) || 0),
|
|
4512
4567
|
fps: Number(owner.outputFps) || TRANSCODE_FPS,
|
|
@@ -4606,9 +4661,76 @@ export class HlsSessionManager {
|
|
|
4606
4661
|
this.#learnDecodeCost(session, speed);
|
|
4607
4662
|
}
|
|
4608
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
|
+
|
|
4609
4716
|
#learnDecodeCost(session, speed) {
|
|
4610
|
-
if (
|
|
4611
|
-
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;
|
|
4612
4734
|
}
|
|
4613
4735
|
const runStartedAt = Number(session.encodeRunStartedAt);
|
|
4614
4736
|
if (!Number.isFinite(runStartedAt) || Date.now() - runStartedAt < DECODE_LEARNING_SETTLE_MS) {
|
|
@@ -4746,6 +4868,28 @@ export class HlsSessionManager {
|
|
|
4746
4868
|
* @param {{ heights: number[], ownHeight: number, sourceWidth: number, sourceHeight: number, fps: number, source: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, transcodeVideo: boolean }} params
|
|
4747
4869
|
* @returns {number[]}
|
|
4748
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
|
+
|
|
4749
4893
|
#sustainableHeights({
|
|
4750
4894
|
heights,
|
|
4751
4895
|
ownHeight,
|
|
@@ -4755,7 +4899,8 @@ export class HlsSessionManager {
|
|
|
4755
4899
|
fps,
|
|
4756
4900
|
source,
|
|
4757
4901
|
transcodeVideo,
|
|
4758
|
-
observedDecodeCostSec = null
|
|
4902
|
+
observedDecodeCostSec = null,
|
|
4903
|
+
concurrentCostSec = 0
|
|
4759
4904
|
}) {
|
|
4760
4905
|
const benchmark = this.softwarePresetBenchmark;
|
|
4761
4906
|
if (!Array.isArray(benchmark) || benchmark.length === 0 || sourceHeight <= 0 || sourceWidth <= 0) {
|
|
@@ -4793,7 +4938,8 @@ export class HlsSessionManager {
|
|
|
4793
4938
|
decodeModel: this.decodeCostModel,
|
|
4794
4939
|
source,
|
|
4795
4940
|
outputPixelsPerSec: width * height * fps,
|
|
4796
|
-
observedDecodeCostSec
|
|
4941
|
+
observedDecodeCostSec,
|
|
4942
|
+
concurrentCostSec
|
|
4797
4943
|
});
|
|
4798
4944
|
if (sustainable) {
|
|
4799
4945
|
kept.push(height);
|