@torrent-tv/proxy 2.30.2 → 2.32.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 +9 -0
- package/assets/calibration/NOTICE.md +49 -16
- package/assets/calibration/cal-h264-1080-hi.mp4 +0 -0
- package/assets/calibration/cal-h264-1080-lo.mp4 +0 -0
- package/assets/calibration/cal-h264-480-hi.mp4 +0 -0
- package/assets/calibration/cal-h264-480-lo.mp4 +0 -0
- package/assets/calibration/cal-h264-720-hi.mp4 +0 -0
- package/assets/calibration/cal-h264-720-lo.mp4 +0 -0
- package/package.json +1 -1
- package/services/decode-cost-fit.js +331 -0
- package/services/hls-session-manager.js +43 -37
- package/services/hwaccel.js +35 -100
- package/test/decode-cost-fit.test.js +145 -0
- package/test/decode-cost.test.js +347 -347
- package/assets/calibration/cal-1080-hi.mp4 +0 -0
- package/assets/calibration/cal-1080-lo.mp4 +0 -0
- package/assets/calibration/cal-720.mp4 +0 -0
|
@@ -25,7 +25,8 @@ import {
|
|
|
25
25
|
ENCODE_RUN_STATE,
|
|
26
26
|
INITIAL_RUN_STATE,
|
|
27
27
|
nextState,
|
|
28
|
-
processCanBeSignalled
|
|
28
|
+
processCanBeSignalled,
|
|
29
|
+
wireState
|
|
29
30
|
} from "./encode-run-state.js";
|
|
30
31
|
import { ENCODE_EXIT, classifyEncodeExit } from "./encode-exit.js";
|
|
31
32
|
|
|
@@ -1854,17 +1855,23 @@ export class HlsSessionManager {
|
|
|
1854
1855
|
sourceMapKey,
|
|
1855
1856
|
fileName: logName,
|
|
1856
1857
|
dirPath: sessionDir,
|
|
1857
|
-
|
|
1858
|
+
// The SESSION's own lifetime, and nothing else: it exists, or it has been
|
|
1859
|
+
// disposed. It used to carry the encoder run's status as well, which is
|
|
1860
|
+
// why one line in the spawn path read `state === "disposed" ? "disposed"
|
|
1861
|
+
// : "starting"` — two lifetimes in one variable. The run's status lives
|
|
1862
|
+
// in `runState`.
|
|
1863
|
+
state: "live",
|
|
1858
1864
|
startedAt: Date.now(),
|
|
1859
1865
|
lastAccessedAt: Date.now(),
|
|
1860
1866
|
ffmpeg: null,
|
|
1861
1867
|
encodeRunGeneration: 0,
|
|
1862
1868
|
// What the ENCODER RUN is doing, as one control state from the table in
|
|
1863
|
-
// `encode-run-state.js`.
|
|
1864
|
-
//
|
|
1865
|
-
//
|
|
1866
|
-
// The
|
|
1867
|
-
//
|
|
1869
|
+
// `encode-run-state.js`. Every question about the run is now answered
|
|
1870
|
+
// from here: whether a process can be signalled, whether anything is
|
|
1871
|
+
// reading the input, what a missing segment is answered with, and what
|
|
1872
|
+
// the browser is told. The three representations it replaced — a status
|
|
1873
|
+
// string, a second status string on the wire, and a child-process handle
|
|
1874
|
+
// consulted at ten sites — could disagree with each other, and did.
|
|
1868
1875
|
runState: INITIAL_RUN_STATE,
|
|
1869
1876
|
lastError: "",
|
|
1870
1877
|
// Cold-start timing: entry timestamp + a once-guard so the first servable
|
|
@@ -2007,7 +2014,10 @@ export class HlsSessionManager {
|
|
|
2007
2014
|
seekFailureTarget: -1,
|
|
2008
2015
|
seekFailureCount: 0,
|
|
2009
2016
|
progress: {
|
|
2010
|
-
state
|
|
2017
|
+
// No `state` here. What the browser is told is `wireState(runState)`,
|
|
2018
|
+
// computed where it is sent — a Moore output rather than a field
|
|
2019
|
+
// maintained by hand at seven sites, which is how it came to be read
|
|
2020
|
+
// together with `session.state` under an `||`.
|
|
2011
2021
|
processedSeconds: 0,
|
|
2012
2022
|
startPositionSeconds: 0,
|
|
2013
2023
|
totalSeconds: hasDuration ? durationSeconds : null,
|
|
@@ -2099,7 +2109,7 @@ export class HlsSessionManager {
|
|
|
2099
2109
|
await this.waitUntilReady(session);
|
|
2100
2110
|
return session;
|
|
2101
2111
|
} catch (error) {
|
|
2102
|
-
if (session.
|
|
2112
|
+
if (session.runState === ENCODE_RUN_STATE.ENDED_FAILED) {
|
|
2103
2113
|
await this.disposeSession(session.id);
|
|
2104
2114
|
throw error;
|
|
2105
2115
|
}
|
|
@@ -3253,7 +3263,7 @@ export class HlsSessionManager {
|
|
|
3253
3263
|
if (
|
|
3254
3264
|
!session ||
|
|
3255
3265
|
session.state === "disposed" ||
|
|
3256
|
-
session.
|
|
3266
|
+
session.runState === ENCODE_RUN_STATE.ENDED_FAILED ||
|
|
3257
3267
|
!session.transcodeVideo ||
|
|
3258
3268
|
// Nothing is encoding, so there is no speed to judge. A variant the
|
|
3259
3269
|
// viewer has switched away from is left in exactly this state, and its
|
|
@@ -3812,8 +3822,6 @@ export class HlsSessionManager {
|
|
|
3812
3822
|
session.encodeStartIndex = safeIndex;
|
|
3813
3823
|
session.pendingRestartIndex = -1;
|
|
3814
3824
|
session.lastRestartAt = Date.now();
|
|
3815
|
-
session.state = session.state === "disposed" ? "disposed" : "starting";
|
|
3816
|
-
session.progress.state = "running";
|
|
3817
3825
|
session.progress.processedSeconds = startSeconds;
|
|
3818
3826
|
session.progress.startPositionSeconds = startSeconds;
|
|
3819
3827
|
session.progress.updatedAt = Date.now();
|
|
@@ -3912,7 +3920,6 @@ export class HlsSessionManager {
|
|
|
3912
3920
|
} else if (key === "speed") {
|
|
3913
3921
|
session.progress.speed = value;
|
|
3914
3922
|
} else if (key === "progress") {
|
|
3915
|
-
session.progress.state = value === "end" ? "ready" : "running";
|
|
3916
3923
|
}
|
|
3917
3924
|
const metrics = computeProgressMetrics(
|
|
3918
3925
|
session.progress.processedSeconds,
|
|
@@ -3948,9 +3955,7 @@ export class HlsSessionManager {
|
|
|
3948
3955
|
if (!this.#isCurrentRun(session, ffmpeg)) {
|
|
3949
3956
|
return;
|
|
3950
3957
|
}
|
|
3951
|
-
session.state = "failed";
|
|
3952
3958
|
session.lastError = error instanceof Error ? error.message : String(error);
|
|
3953
|
-
session.progress.state = "failed";
|
|
3954
3959
|
session.progress.updatedAt = Date.now();
|
|
3955
3960
|
this.#transitionRun(session, ENCODE_RUN_EVENT.EXITED_FAILED);
|
|
3956
3961
|
logger.error(`ffmpeg ${session.id} process error: ${session.lastError}`);
|
|
@@ -3989,9 +3994,7 @@ export class HlsSessionManager {
|
|
|
3989
3994
|
// segment nobody was making. So the claim is checked against the
|
|
3990
3995
|
// playlist we published, and a run that stopped short is a FAILURE that
|
|
3991
3996
|
// can be restarted, not a finished file.
|
|
3992
|
-
|
|
3993
|
-
session.progress.state = "failed";
|
|
3994
|
-
session.progress.updatedAt = Date.now();
|
|
3997
|
+
session.progress.updatedAt = Date.now();
|
|
3995
3998
|
session.lastError =
|
|
3996
3999
|
`input ended after segment #${producedThrough} of ${expectedLast} — ` +
|
|
3997
4000
|
"the source stopped delivering data";
|
|
@@ -4003,8 +4006,6 @@ export class HlsSessionManager {
|
|
|
4003
4006
|
return;
|
|
4004
4007
|
}
|
|
4005
4008
|
if (outcome === ENCODE_EXIT.COMPLETE) {
|
|
4006
|
-
session.state = "ready";
|
|
4007
|
-
session.progress.state = "ready";
|
|
4008
4009
|
session.progress.updatedAt = Date.now();
|
|
4009
4010
|
this.#transitionRun(session, ENCODE_RUN_EVENT.EXITED_COMPLETE);
|
|
4010
4011
|
logger.info(`transcode ${session.id} encode-run complete "${session.fileName}"`);
|
|
@@ -4066,11 +4067,9 @@ export class HlsSessionManager {
|
|
|
4066
4067
|
// stays for what it was built for, a target that genuinely cannot be
|
|
4067
4068
|
// encoded; it must not condemn a session whose data merely went away.
|
|
4068
4069
|
if (outcome === ENCODE_EXIT.INPUT_LOST) {
|
|
4069
|
-
session.state = "recovering";
|
|
4070
4070
|
// On the wire it is simply "not ready yet" — a state the browser has
|
|
4071
4071
|
// always known how to wait through. Only the proxy needs the
|
|
4072
4072
|
// distinction between waiting for data and having given up.
|
|
4073
|
-
session.progress.state = "starting";
|
|
4074
4073
|
session.progress.updatedAt = Date.now();
|
|
4075
4074
|
this.#transitionRun(session, ENCODE_RUN_EVENT.EXITED_INPUT_LOST);
|
|
4076
4075
|
session.inputRetryCount = (session.inputRetryCount ?? 0) + 1;
|
|
@@ -4085,7 +4084,7 @@ export class HlsSessionManager {
|
|
|
4085
4084
|
);
|
|
4086
4085
|
session.inputRetryTimer = setTimeout(() => {
|
|
4087
4086
|
session.inputRetryTimer = null;
|
|
4088
|
-
if (session.
|
|
4087
|
+
if (session.runState !== ENCODE_RUN_STATE.RETRY_WAIT) {
|
|
4089
4088
|
return;
|
|
4090
4089
|
}
|
|
4091
4090
|
this.#transitionRun(session, ENCODE_RUN_EVENT.RETRY_DUE);
|
|
@@ -4097,8 +4096,6 @@ export class HlsSessionManager {
|
|
|
4097
4096
|
session.inputRetryTimer.unref?.();
|
|
4098
4097
|
return;
|
|
4099
4098
|
}
|
|
4100
|
-
session.state = "failed";
|
|
4101
|
-
session.progress.state = "failed";
|
|
4102
4099
|
session.progress.updatedAt = Date.now();
|
|
4103
4100
|
this.#transitionRun(session, ENCODE_RUN_EVENT.EXITED_FAILED);
|
|
4104
4101
|
logger.error(
|
|
@@ -4579,10 +4576,9 @@ export class HlsSessionManager {
|
|
|
4579
4576
|
// Individual segments are long-polled by the segment route as ffmpeg
|
|
4580
4577
|
// produces them.
|
|
4581
4578
|
if (session.useSyntheticPlaylist) {
|
|
4582
|
-
if (session.
|
|
4579
|
+
if (session.runState === ENCODE_RUN_STATE.ENDED_FAILED) {
|
|
4583
4580
|
throw new Error(session.lastError || "ffmpeg failed to start HLS session.");
|
|
4584
4581
|
}
|
|
4585
|
-
session.state = "ready";
|
|
4586
4582
|
return;
|
|
4587
4583
|
}
|
|
4588
4584
|
|
|
@@ -4590,15 +4586,14 @@ export class HlsSessionManager {
|
|
|
4590
4586
|
const deadline = Date.now() + this.startupWaitMs;
|
|
4591
4587
|
|
|
4592
4588
|
while (Date.now() < deadline) {
|
|
4593
|
-
if (session.
|
|
4589
|
+
if (session.runState === ENCODE_RUN_STATE.ENDED_FAILED) {
|
|
4594
4590
|
throw new Error(session.lastError || "ffmpeg failed to start HLS session.");
|
|
4595
4591
|
}
|
|
4596
4592
|
try {
|
|
4597
4593
|
await access(playlistPath);
|
|
4598
4594
|
const text = await readFile(playlistPath, "utf8");
|
|
4599
4595
|
if (text.includes("#EXTM3U")) {
|
|
4600
|
-
|
|
4601
|
-
return;
|
|
4596
|
+
return;
|
|
4602
4597
|
}
|
|
4603
4598
|
} catch (_error) {
|
|
4604
4599
|
// Playlist is not ready yet.
|
|
@@ -5114,7 +5109,14 @@ export class HlsSessionManager {
|
|
|
5114
5109
|
const median = deviations.length > 0 ? deviations[Math.floor(deviations.length / 2)] : 0;
|
|
5115
5110
|
const landed = check.landedOnAnotherKeyframe ?? 0;
|
|
5116
5111
|
logger.info(
|
|
5117
|
-
|
|
5112
|
+
// The session id, because without it this line cannot be attributed. A
|
|
5113
|
+
// family produces one summary per member — the picture and each
|
|
5114
|
+
// soundtrack — and on 2026-08-17 the picture's was read as the sound's,
|
|
5115
|
+
// from a neighbouring log line, and a roadmap item was written against
|
|
5116
|
+
// the wrong half of the stream. The id is the only thing that says whose
|
|
5117
|
+
// reading this is.
|
|
5118
|
+
`keyframe-index ${session.id.slice(0, 8)} ${session.audioOnly === true ? "sound" : "picture"} ` +
|
|
5119
|
+
`${session.containerFormat || "unknown"} "${session.fileName}": ` +
|
|
5118
5120
|
`${check.disagreed} of ${check.checked} produced segments started away from the playlist, ` +
|
|
5119
5121
|
`median ${median.toFixed(3)}s worst ${check.maxDeviationSec.toFixed(3)}s` +
|
|
5120
5122
|
(check.firstDisagreementIndex >= 0 ? ` (first at #${check.firstDisagreementIndex})` : "") +
|
|
@@ -5374,7 +5376,7 @@ export class HlsSessionManager {
|
|
|
5374
5376
|
if (
|
|
5375
5377
|
!session ||
|
|
5376
5378
|
session.state === "disposed" ||
|
|
5377
|
-
session.
|
|
5379
|
+
session.runState === ENCODE_RUN_STATE.ENDED_FAILED ||
|
|
5378
5380
|
!session.ffmpeg ||
|
|
5379
5381
|
session.runState === ENCODE_RUN_STATE.SUSPENDED
|
|
5380
5382
|
) {
|
|
@@ -6923,13 +6925,13 @@ export class HlsSessionManager {
|
|
|
6923
6925
|
if (!session || !isSafeFileName(fileName, session.segmentFormat)) {
|
|
6924
6926
|
return { kind: "not-found" };
|
|
6925
6927
|
}
|
|
6926
|
-
if (session.
|
|
6928
|
+
if (session.runState === ENCODE_RUN_STATE.RETRY_WAIT) {
|
|
6927
6929
|
// The data went away and is being fetched again. Holding the request is
|
|
6928
6930
|
// the truthful answer: nothing is broken and there is nothing for the
|
|
6929
6931
|
// viewer to retry.
|
|
6930
6932
|
return { kind: "warming-up" };
|
|
6931
6933
|
}
|
|
6932
|
-
if (session.
|
|
6934
|
+
if (session.runState === ENCODE_RUN_STATE.ENDED_FAILED) {
|
|
6933
6935
|
return {
|
|
6934
6936
|
kind: "failed",
|
|
6935
6937
|
message: session.lastError || "ffmpeg failed for this transcode session."
|
|
@@ -7496,7 +7498,11 @@ export class HlsSessionManager {
|
|
|
7496
7498
|
session.lastAccessedAt = Date.now();
|
|
7497
7499
|
const warmupTotalSeconds = this.startupWaitMs / 1000;
|
|
7498
7500
|
const warmupElapsedSeconds = Math.max(0, (Date.now() - session.startedAt) / 1000);
|
|
7499
|
-
|
|
7501
|
+
// One question, one answer. This used to read BOTH strings with `||`
|
|
7502
|
+
// because neither could answer alone: `session.state` said "starting" from
|
|
7503
|
+
// the first spawn until something else overwrote it, and `progress.state`
|
|
7504
|
+
// said it again on its own schedule.
|
|
7505
|
+
const isWarmupPhase = wireState(session.runState) === "starting";
|
|
7500
7506
|
const warmupPercent = isWarmupPhase
|
|
7501
7507
|
? Math.max(0, Math.min(100, (warmupElapsedSeconds / warmupTotalSeconds) * 100))
|
|
7502
7508
|
: null;
|
|
@@ -7514,7 +7520,7 @@ export class HlsSessionManager {
|
|
|
7514
7520
|
// The id the caller asked about, not the variant it was answered from —
|
|
7515
7521
|
// the browser tracks its sessions by the id it was given.
|
|
7516
7522
|
sessionId: named.id,
|
|
7517
|
-
state: session.
|
|
7523
|
+
state: wireState(session.runState),
|
|
7518
7524
|
processedSeconds: session.progress.processedSeconds,
|
|
7519
7525
|
startPositionSeconds: session.progress.startPositionSeconds ?? 0,
|
|
7520
7526
|
totalSeconds: session.progress.totalSeconds,
|
|
@@ -7554,7 +7560,7 @@ export class HlsSessionManager {
|
|
|
7554
7560
|
expectedSessionCreateMs: this.expectedSessionCreateMs(),
|
|
7555
7561
|
expectedFirstSegmentMs: this.expectedFirstSegmentMs(),
|
|
7556
7562
|
updatedAt: session.progress.updatedAt,
|
|
7557
|
-
error: session.
|
|
7563
|
+
error: session.runState === ENCODE_RUN_STATE.ENDED_FAILED ? session.lastError : ""
|
|
7558
7564
|
};
|
|
7559
7565
|
}
|
|
7560
7566
|
|
package/services/hwaccel.js
CHANGED
|
@@ -24,6 +24,7 @@ import { spawn } from "node:child_process";
|
|
|
24
24
|
import { mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
25
25
|
import os from "node:os";
|
|
26
26
|
import path from "node:path";
|
|
27
|
+
import { fitDecodeCost } from "./decode-cost-fit.js";
|
|
27
28
|
import { fileURLToPath } from "node:url";
|
|
28
29
|
import {
|
|
29
30
|
parseFfmpegBitrateKbps,
|
|
@@ -670,45 +671,28 @@ export async function detectTonemapSupport({ ffmpegBin, logger }) {
|
|
|
670
671
|
return supported;
|
|
671
672
|
}
|
|
672
673
|
|
|
673
|
-
/**
|
|
674
|
-
* Solve a 3×3 linear system by Gaussian elimination with partial pivoting.
|
|
675
|
-
*
|
|
676
|
-
* @param {number[][]} rows - Three rows of [c0, c1, c2, rhs].
|
|
677
|
-
* @returns {number[] | null} The three unknowns, or null when singular.
|
|
678
|
-
*/
|
|
679
|
-
function solveLinear3(rows) {
|
|
680
|
-
const m = rows.map((row) => [...row]);
|
|
681
|
-
for (let col = 0; col < 3; col += 1) {
|
|
682
|
-
let pivot = col;
|
|
683
|
-
for (let row = col + 1; row < 3; row += 1) {
|
|
684
|
-
if (Math.abs(m[row][col]) > Math.abs(m[pivot][col])) {
|
|
685
|
-
pivot = row;
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
if (Math.abs(m[pivot][col]) < 1e-12) {
|
|
689
|
-
return null;
|
|
690
|
-
}
|
|
691
|
-
[m[col], m[pivot]] = [m[pivot], m[col]];
|
|
692
|
-
for (let row = 0; row < 3; row += 1) {
|
|
693
|
-
if (row === col) {
|
|
694
|
-
continue;
|
|
695
|
-
}
|
|
696
|
-
const factor = m[row][col] / m[col][col];
|
|
697
|
-
for (let k = col; k < 4; k += 1) {
|
|
698
|
-
m[row][k] -= factor * m[col][k];
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
return [m[0][3] / m[0][0], m[1][3] / m[1][1], m[2][3] / m[2][2]];
|
|
703
|
-
}
|
|
704
674
|
|
|
705
|
-
// The clips the decode cost is
|
|
675
|
+
// The clips the decode cost is fitted from. They ship with the package
|
|
706
676
|
// (`assets/calibration/`), cut from Netflix Open Content "Meridian" (CC-BY 4.0)
|
|
707
677
|
// — real, grainy live action, because a generated `testsrc2` clip decodes 158 %
|
|
708
|
-
// away from a real film where these are 11 % away (measured 2026-08-14).
|
|
709
|
-
//
|
|
710
|
-
//
|
|
711
|
-
|
|
678
|
+
// away from a real film where these are 11 % away (measured 2026-08-14).
|
|
679
|
+
//
|
|
680
|
+
// Three sizes at two bitrates each, with the axes varied INDEPENDENTLY. The set
|
|
681
|
+
// this replaced was three clips for three unknowns, two of them at the same
|
|
682
|
+
// size: an exact system, which cannot fail visibly. On 2026-08-17 it returned
|
|
683
|
+
// `0.007542 × Mpx/s + 0.000000 × Mbit/s + 0.0000 s/s` — the bitrate term and
|
|
684
|
+
// the constant exactly zero — and the prediction on top of it was 1.8-2.2x
|
|
685
|
+
// optimistic. Six points leave three spare, so the fit has a residual, and a
|
|
686
|
+
// term the data does not determine can be refused instead of published as a
|
|
687
|
+
// zero that looks measured. See `assets/calibration/NOTICE.md`.
|
|
688
|
+
const CALIBRATION_CLIPS = [
|
|
689
|
+
"cal-h264-1080-hi.mp4",
|
|
690
|
+
"cal-h264-1080-lo.mp4",
|
|
691
|
+
"cal-h264-720-hi.mp4",
|
|
692
|
+
"cal-h264-720-lo.mp4",
|
|
693
|
+
"cal-h264-480-hi.mp4",
|
|
694
|
+
"cal-h264-480-lo.mp4"
|
|
695
|
+
];
|
|
712
696
|
const CALIBRATION_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "assets", "calibration");
|
|
713
697
|
// How wide the measured window must be before the slope is trusted, and how
|
|
714
698
|
// long to wait for it at most. A second of decoding is thousands of frames on a
|
|
@@ -770,8 +754,8 @@ function parseClipCharacteristics(stderr) {
|
|
|
770
754
|
export async function benchmarkDecodeCost({ ffmpegBin, logger, clipsDir = CALIBRATION_DIR }) {
|
|
771
755
|
const log = logger ?? { info: () => {}, warn: () => {} };
|
|
772
756
|
const startedAllAt = Date.now();
|
|
773
|
-
/** @type {number
|
|
774
|
-
const
|
|
757
|
+
/** @type {Array<{ megapixelsPerSecond: number, megabitsPerSecond: number, costSecondsPerSecond: number }>} */
|
|
758
|
+
const samples = [];
|
|
775
759
|
for (const clip of CALIBRATION_CLIPS) {
|
|
776
760
|
const measured = await measureDecodeSlope(ffmpegBin, path.join(clipsDir, clip));
|
|
777
761
|
if (!measured) {
|
|
@@ -779,21 +763,31 @@ export async function benchmarkDecodeCost({ ffmpegBin, logger, clipsDir = CALIBR
|
|
|
779
763
|
return null;
|
|
780
764
|
}
|
|
781
765
|
const cost = 1 / measured.speed;
|
|
782
|
-
|
|
766
|
+
samples.push({
|
|
767
|
+
megapixelsPerSecond: measured.megapixelsPerSecond,
|
|
768
|
+
megabitsPerSecond: measured.megabitsPerSecond,
|
|
769
|
+
costSecondsPerSecond: cost
|
|
770
|
+
});
|
|
783
771
|
log.info(
|
|
784
772
|
`hwaccel: decode "${clip}" ${measured.megapixelsPerSecond.toFixed(1)} Mpx/s ` +
|
|
785
773
|
`${measured.megabitsPerSecond.toFixed(2)} Mbit/s -> ${measured.speed.toFixed(1)}x ` +
|
|
786
774
|
`(cost ${cost.toFixed(4)} s/s, over ${measured.windowSec.toFixed(1)}s of decoding)`
|
|
787
775
|
);
|
|
788
776
|
}
|
|
789
|
-
const fitted = fitDecodeCost(
|
|
777
|
+
const fitted = fitDecodeCost(samples);
|
|
790
778
|
if (!fitted) {
|
|
791
779
|
log.warn("hwaccel: decode cost could not be fitted to these measurements; decode cost unknown");
|
|
792
780
|
return null;
|
|
793
781
|
}
|
|
794
782
|
log.info(
|
|
795
783
|
`hwaccel: decode cost = ${fitted.pixelTerm.toFixed(6)} × Mpx/s + ${fitted.bitrateTerm.toFixed(6)} × Mbit/s ` +
|
|
796
|
-
`+ ${fitted.constantTerm.toFixed(4)} s/s (${fitted.shape}
|
|
784
|
+
`+ ${fitted.constantTerm.toFixed(4)} s/s (${fitted.shape} from ${fitted.samples} clips, ` +
|
|
785
|
+
`typical disagreement ${fitted.residualRms.toFixed(4)} s/s` +
|
|
786
|
+
// Named rather than implied: a zero in the line above means "not
|
|
787
|
+
// measured" for a dropped term and "measured to be nothing" otherwise,
|
|
788
|
+
// and those are different claims.
|
|
789
|
+
(fitted.dropped.length > 0 ? `, ${fitted.dropped.join(" and ")} not determined by these clips` : "") +
|
|
790
|
+
`, measured in ${((Date.now() - startedAllAt) / 1000).toFixed(1)}s)`
|
|
797
791
|
);
|
|
798
792
|
return { pixelTerm: fitted.pixelTerm, bitrateTerm: fitted.bitrateTerm, constantTerm: fitted.constantTerm };
|
|
799
793
|
}
|
|
@@ -911,65 +905,6 @@ function measureDecodeSlope(ffmpegBin, clipPath) {
|
|
|
911
905
|
});
|
|
912
906
|
}
|
|
913
907
|
|
|
914
|
-
/**
|
|
915
|
-
* Fit the three measurements, and say which shape the data supported.
|
|
916
|
-
*
|
|
917
|
-
* The three-term fit is exact — three points, three unknowns — and is used
|
|
918
|
-
* whenever every term comes out non-negative. A negative term is not a host
|
|
919
|
-
* being odd; it says the difference it was solved from is smaller than the
|
|
920
|
-
* noise between runs, which is what a fast machine produces: measured on a
|
|
921
|
-
* desktop, the 720p clip took LONGER per second than the low-bitrate 1080p one,
|
|
922
|
-
* because process startup is a large share of a decode that takes a second.
|
|
923
|
-
*
|
|
924
|
-
* When that happens the bitrate term — the weak one, and the one solved from a
|
|
925
|
-
* single difference — is dropped and the remaining two are fitted by least
|
|
926
|
-
* squares over all three points. If even the pixel slope comes out non-positive
|
|
927
|
-
* there is no measurable dependence on the source at all, and inventing one is
|
|
928
|
-
* worse than having none: the caller then prices the encoder alone and refuses
|
|
929
|
-
* nothing.
|
|
930
|
-
*
|
|
931
|
-
* @param {number[][]} equations - Rows of [Mpixel/s, Mbit/s, 1, cost].
|
|
932
|
-
* @returns {{ pixelTerm: number, bitrateTerm: number, constantTerm: number, shape: string } | null}
|
|
933
|
-
*/
|
|
934
|
-
function fitDecodeCost(equations) {
|
|
935
|
-
const exact = solveLinear3(equations);
|
|
936
|
-
if (exact && exact[0] > 0 && exact[1] >= 0 && exact[2] >= 0) {
|
|
937
|
-
return { pixelTerm: exact[0], bitrateTerm: exact[1], constantTerm: exact[2], shape: "pixels+bitrate+constant" };
|
|
938
|
-
}
|
|
939
|
-
const count = equations.length;
|
|
940
|
-
const meanPixels = equations.reduce((sum, row) => sum + row[0], 0) / count;
|
|
941
|
-
const meanCost = equations.reduce((sum, row) => sum + row[3], 0) / count;
|
|
942
|
-
let covariance = 0;
|
|
943
|
-
let variance = 0;
|
|
944
|
-
for (const row of equations) {
|
|
945
|
-
covariance += (row[0] - meanPixels) * (row[3] - meanCost);
|
|
946
|
-
variance += (row[0] - meanPixels) ** 2;
|
|
947
|
-
}
|
|
948
|
-
if (!(variance > 0)) {
|
|
949
|
-
return null;
|
|
950
|
-
}
|
|
951
|
-
const pixelTerm = covariance / variance;
|
|
952
|
-
const constantTerm = meanCost - pixelTerm * meanPixels;
|
|
953
|
-
if (pixelTerm > 0 && constantTerm >= 0) {
|
|
954
|
-
return { pixelTerm, bitrateTerm: 0, constantTerm, shape: "pixels+constant" };
|
|
955
|
-
}
|
|
956
|
-
// A negative constant is the line crossing below zero where no clip was
|
|
957
|
-
// measured — every clip is 22 Mpixel/s or more, and nothing here says what a
|
|
958
|
-
// tiny picture costs. Rather than carry a term that would price a small
|
|
959
|
-
// source as free work, fit through the origin: cost proportional to pixels,
|
|
960
|
-
// which is the relationship the measurements do support.
|
|
961
|
-
let weighted = 0;
|
|
962
|
-
let squares = 0;
|
|
963
|
-
for (const row of equations) {
|
|
964
|
-
weighted += row[0] * row[3];
|
|
965
|
-
squares += row[0] ** 2;
|
|
966
|
-
}
|
|
967
|
-
const throughOrigin = squares > 0 ? weighted / squares : 0;
|
|
968
|
-
if (!(throughOrigin > 0)) {
|
|
969
|
-
return null;
|
|
970
|
-
}
|
|
971
|
-
return { pixelTerm: throughOrigin, bitrateTerm: 0, constantTerm: 0, shape: "pixels only" };
|
|
972
|
-
}
|
|
973
908
|
|
|
974
909
|
/**
|
|
975
910
|
* How many times realtime this host can DECODE a source of these
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The decode-cost fit, and the case it exists to prevent.
|
|
3
|
+
*
|
|
4
|
+
* The failure being pinned is real and dated: on 2026-08-17 three clips for
|
|
5
|
+
* three unknowns returned `0.007542 × Mpx/s + 0.000000 × Mbit/s + 0.0000 s/s`,
|
|
6
|
+
* with the bitrate term and the constant exactly zero, and the prediction built
|
|
7
|
+
* on it was 1.8-2.2x optimistic. An exact system cannot notice that two of its
|
|
8
|
+
* points said the same thing; these tests hold the replacement to noticing.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import test from "node:test";
|
|
13
|
+
|
|
14
|
+
import { decodeCostOf, fitDecodeCost } from "../services/decode-cost-fit.js";
|
|
15
|
+
|
|
16
|
+
const FPS = 24;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A clip's measurement, priced by a known truth so a fit can be checked against
|
|
20
|
+
* the answer it should recover.
|
|
21
|
+
*
|
|
22
|
+
* @param {number} width
|
|
23
|
+
* @param {number} height
|
|
24
|
+
* @param {number} megabitsPerSecond
|
|
25
|
+
* @param {{ pixel: number, bitrate: number, constant: number, noise?: number }} truth
|
|
26
|
+
* @returns {{ megapixelsPerSecond: number, megabitsPerSecond: number, costSecondsPerSecond: number }}
|
|
27
|
+
*/
|
|
28
|
+
function clip(width, height, megabitsPerSecond, truth) {
|
|
29
|
+
const megapixelsPerSecond = (width * height * FPS) / 1e6;
|
|
30
|
+
const cost =
|
|
31
|
+
truth.pixel * megapixelsPerSecond + truth.bitrate * megabitsPerSecond + truth.constant + (truth.noise ?? 0);
|
|
32
|
+
return { megapixelsPerSecond, megabitsPerSecond, costSecondsPerSecond: cost };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The set the clips were cut to: three sizes, two bitrates, varied independently. */
|
|
36
|
+
const SIZES = [
|
|
37
|
+
[1920, 1080],
|
|
38
|
+
[1280, 720],
|
|
39
|
+
[854, 480]
|
|
40
|
+
];
|
|
41
|
+
const BITRATES = [9.5, 1.1];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {{ pixel: number, bitrate: number, constant: number }} truth
|
|
45
|
+
* @param {number[]} [noise] - Per-clip disturbance, in order.
|
|
46
|
+
* @returns {ReturnType<typeof clip>[]}
|
|
47
|
+
*/
|
|
48
|
+
function wellConditionedSet(truth, noise = []) {
|
|
49
|
+
const samples = [];
|
|
50
|
+
let index = 0;
|
|
51
|
+
for (const [width, height] of SIZES) {
|
|
52
|
+
for (const bitrate of BITRATES) {
|
|
53
|
+
samples.push(clip(width, height, bitrate, { ...truth, noise: noise[index] ?? 0 }));
|
|
54
|
+
index += 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return samples;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
test("a well-conditioned set recovers every term", () => {
|
|
61
|
+
const truth = { pixel: 0.0055, bitrate: 0.0099, constant: 0.057 };
|
|
62
|
+
const model = fitDecodeCost(wellConditionedSet(truth));
|
|
63
|
+
|
|
64
|
+
assert.ok(model, "six clips over three unknowns must produce a model");
|
|
65
|
+
assert.equal(model.shape, "pixels+bitrate+constant");
|
|
66
|
+
assert.deepEqual(model.dropped, []);
|
|
67
|
+
assert.ok(Math.abs(model.pixelTerm - truth.pixel) < 1e-6, `pixel term ${model.pixelTerm}`);
|
|
68
|
+
assert.ok(Math.abs(model.bitrateTerm - truth.bitrate) < 1e-6, `bitrate term ${model.bitrateTerm}`);
|
|
69
|
+
assert.ok(Math.abs(model.constantTerm - truth.constant) < 1e-6, `constant ${model.constantTerm}`);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("three clips are refused outright — an exact system cannot see its own degeneracy", () => {
|
|
73
|
+
// Exactly the shape that shipped: two clips at the same pixel rate differing
|
|
74
|
+
// only in bitrate, and a third at another size.
|
|
75
|
+
const truth = { pixel: 0.0055, bitrate: 0.0099, constant: 0.057 };
|
|
76
|
+
const three = [clip(1920, 1080, 11.4, truth), clip(1920, 1080, 0.97, truth), clip(1280, 720, 2.25, truth)];
|
|
77
|
+
|
|
78
|
+
assert.equal(
|
|
79
|
+
fitDecodeCost(three),
|
|
80
|
+
null,
|
|
81
|
+
"with no residual there is nothing to notice a degeneracy with, so no model may be published"
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("a term the measurements do not determine is dropped, and said so", () => {
|
|
86
|
+
// A host whose decoding does not depend on bitrate at all: the term is not
|
|
87
|
+
// small, it is absent. What must NOT happen is publishing a zero as though it
|
|
88
|
+
// had been measured.
|
|
89
|
+
const truth = { pixel: 0.0055, bitrate: 0, constant: 0.057 };
|
|
90
|
+
// Deliberately NOT alternating with the bitrate column: an earlier version
|
|
91
|
+
// of this test put positive noise on every high-bitrate clip and negative on
|
|
92
|
+
// every low one, which IS a bitrate signal — the fit found it, correctly, and
|
|
93
|
+
// the test was wrong.
|
|
94
|
+
const noise = [0.004, 0.003, -0.004, 0.002, -0.003, -0.002];
|
|
95
|
+
const model = fitDecodeCost(wellConditionedSet(truth, noise));
|
|
96
|
+
|
|
97
|
+
assert.ok(model);
|
|
98
|
+
assert.ok(model.dropped.includes("bitrate"), `dropped: ${model.dropped.join(",") || "nothing"}`);
|
|
99
|
+
assert.equal(model.bitrateTerm, 0);
|
|
100
|
+
assert.ok(model.shape.includes("pixels"));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("noise in the readings does not become a term", () => {
|
|
104
|
+
// Pure noise around a pixels-only truth, larger than any bitrate effect.
|
|
105
|
+
const truth = { pixel: 0.0055, bitrate: 0, constant: 0 };
|
|
106
|
+
const noise = [0.01, 0.009, -0.012, 0.011, -0.008, -0.01];
|
|
107
|
+
const model = fitDecodeCost(wellConditionedSet(truth, noise));
|
|
108
|
+
|
|
109
|
+
assert.ok(model);
|
|
110
|
+
assert.ok(model.pixelTerm > 0, "the one relationship every reading agrees on survives");
|
|
111
|
+
assert.ok(
|
|
112
|
+
model.dropped.length > 0,
|
|
113
|
+
"and the terms the noise could have supplied are reported as undetermined"
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("without a measurable dependence on the source there is no model", () => {
|
|
118
|
+
// Every clip costs the same regardless of size or bitrate: nothing here says
|
|
119
|
+
// what a bigger picture costs, and inventing it is worse than having none.
|
|
120
|
+
const flat = wellConditionedSet({ pixel: 0, bitrate: 0, constant: 0.3 });
|
|
121
|
+
assert.equal(fitDecodeCost(flat), null);
|
|
122
|
+
assert.equal(fitDecodeCost([]), null);
|
|
123
|
+
assert.equal(fitDecodeCost(null), null);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("readings that are not measurements are left out", () => {
|
|
127
|
+
const truth = { pixel: 0.0055, bitrate: 0.0099, constant: 0.057 };
|
|
128
|
+
const samples = [
|
|
129
|
+
...wellConditionedSet(truth),
|
|
130
|
+
{ megapixelsPerSecond: Number.NaN, megabitsPerSecond: 5, costSecondsPerSecond: 1 },
|
|
131
|
+
{ megapixelsPerSecond: 20, megabitsPerSecond: 5, costSecondsPerSecond: 0 }
|
|
132
|
+
];
|
|
133
|
+
const model = fitDecodeCost(samples);
|
|
134
|
+
assert.ok(model);
|
|
135
|
+
assert.equal(model.samples, 6);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("what a model prices a film at", () => {
|
|
139
|
+
const model = fitDecodeCost(wellConditionedSet({ pixel: 0.0055, bitrate: 0.0099, constant: 0.057 }));
|
|
140
|
+
// The field film: 1080p24 at about 8 Mbit/s.
|
|
141
|
+
const cost = decodeCostOf(model, { megapixelsPerSecond: 49.77, megabitsPerSecond: 8 });
|
|
142
|
+
assert.ok(cost > 0);
|
|
143
|
+
// Which is a decode speed of 1/cost — the figure the quality offer rests on.
|
|
144
|
+
assert.ok(1 / cost > 1 && 1 / cost < 10, `decodes at ${(1 / cost).toFixed(2)}x`);
|
|
145
|
+
});
|