@torrent-tv/proxy 2.31.0 → 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 +5 -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/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
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
## 2.32.0
|
|
2
|
+
|
|
3
|
+
- **New**: The decode cost is fitted from a clip set that can be checked, and a term the measurements do not determine is refused instead of published as a zero. The set that shipped until now was three clips for three unknowns — an EXACT system, with two of the clips at the same pixel rate — and such a system cannot fail visibly: it returns whatever satisfies its equations. On 2026-08-17 it returned `0.007542 × Mpx/s + 0.000000 × Mbit/s + 0.0000 s/s`, so a film's own bitrate never entered its price, and the prediction built on it was 1.8-2.2x optimistic against the same file measured while playing. The new set is six clips — three sizes × two bitrates, the axes varied INDEPENDENTLY — cut from the same Netflix Open Content "Meridian" footage (CC BY 4.0, `assets/calibration/NOTICE.md`), 7.7 MB against 8.8 MB before. Three spare measurements give the fit a residual, and with it two questions it could not ask before: whether a term's whole effect across the measured range exceeds the scatter, and whether the coefficient exceeds its own standard error. A term that fails either is dropped, the rest are fitted again, and the log names it — a zero now means "not measured" only when it says so. A NEGATIVE coefficient is dropped too rather than clamped to zero: more pixels cannot cost less work, so a negative fit is noise beating an effect, not a discovery about the host. Measured on the developer's machine, the new set determines all three terms (`0.000520 × Mpx/s + 0.002086 × Mbit/s + 0.0033 s/s`, typical disagreement 0.0012 s/s), and the bitrate term it recovers matches the difference between the two 1080p clips to 15 %. The arithmetic is a pure module with the degenerate case as a test (`services/decode-cost-fit.js`).
|
|
4
|
+
- **Chore**: What the calibration costs at startup, measured rather than assumed: six clips take 14.8 s on the developer's Windows box, of which the decoding is about 50 ms per clip — an empty ffmpeg spawn there costs 774 ms, and opening each file most of the rest. Shortening the clips would therefore save nothing; the cost is spawning ffmpeg once per clip, and it is paid before any viewer exists.
|
|
5
|
+
|
|
1
6
|
## 2.31.0
|
|
2
7
|
|
|
3
8
|
- **Chore**: The encoder run's two status strings are gone; both are now outputs of the state table shipped in 2.23.0. `session.progress.state` was maintained by hand at seven sites and `session.state` at nine, and neither could answer on its own — the warm-up test had to read both under an `||`, because one said "starting" from the first spawn until something overwrote it while the other said it again on its own schedule. What the browser is told is computed where it is sent (`wireState(runState)`), and `session.state` is reduced to the session's own lifetime: it exists, or it has been disposed. That deletes the line in the spawn path that read `state === "disposed" ? "disposed" : "starting"` — two lifetimes in one variable, which is what it was there to paper over. Verified before the change that nothing in the browser reads the wire string, so the value set is unchanged and unobserved either way; the four values it can take are the same four as before.
|
|
@@ -1,30 +1,63 @@
|
|
|
1
1
|
# Calibration clips — attribution
|
|
2
2
|
|
|
3
|
-
The
|
|
3
|
+
The clips in this folder are excerpts from **"Meridian"**, part of
|
|
4
4
|
[Netflix Open Content](https://opencontent.netflix.com/), licensed under the
|
|
5
5
|
**Creative Commons Attribution 4.0 International (CC BY 4.0)** licence:
|
|
6
6
|
<https://creativecommons.org/licenses/by/4.0/>.
|
|
7
7
|
|
|
8
8
|
They were cut from `Meridian/Meridian_UHD4k5994_HDR_P3PQ.mp4` and re-encoded to
|
|
9
|
-
H.264 High, 24 fps,
|
|
9
|
+
H.264 High, 24 fps, 2 s each, no audio:
|
|
10
10
|
|
|
11
11
|
| file | resolution | bitrate |
|
|
12
12
|
|---|---|---|
|
|
13
|
-
| `cal-1080-hi.mp4` | 1920×1080 |
|
|
14
|
-
| `cal-1080-lo.mp4` | 1920×1080 |
|
|
15
|
-
| `cal-720.mp4` | 1280×720 |
|
|
13
|
+
| `cal-h264-1080-hi.mp4` | 1920×1080 | 9.36 Mbit/s |
|
|
14
|
+
| `cal-h264-1080-lo.mp4` | 1920×1080 | 1.00 Mbit/s |
|
|
15
|
+
| `cal-h264-720-hi.mp4` | 1280×720 | 9.95 Mbit/s |
|
|
16
|
+
| `cal-h264-720-lo.mp4` | 1280×720 | 1.04 Mbit/s |
|
|
17
|
+
| `cal-h264-480-hi.mp4` | 854×480 | 9.48 Mbit/s |
|
|
18
|
+
| `cal-h264-480-lo.mp4` | 854×480 | 1.25 Mbit/s |
|
|
16
19
|
|
|
17
|
-
## Why
|
|
20
|
+
## Why six, and why this grid
|
|
18
21
|
|
|
19
|
-
`services/hwaccel.js` decodes them at startup and
|
|
20
|
-
`a × Mpixel/s + b × Mbit/s + c`, from the
|
|
21
|
-
|
|
22
|
-
pixel count. Three points, three unknowns.
|
|
22
|
+
`services/hwaccel.js` decodes them at startup and fits the host's decode cost,
|
|
23
|
+
`a × Mpixel/s + b × Mbit/s + c`, from the measurements
|
|
24
|
+
(`services/decode-cost-fit.js`).
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
The set before this one was three clips for three unknowns — an EXACT system,
|
|
27
|
+
with two of the clips sharing a pixel count. Such a system cannot fail visibly:
|
|
28
|
+
it returns whatever satisfies its three equations, and on 2026-08-17 it returned
|
|
29
|
+
`0.007542 × Mpx/s + 0.000000 × Mbit/s + 0.0000 s/s` — the bitrate term and the
|
|
30
|
+
constant exactly zero, so a film's own bitrate never entered its price, and the
|
|
31
|
+
prediction built on it was 1.8-2.2x optimistic against the same file measured
|
|
32
|
+
while playing.
|
|
27
33
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
34
|
+
Six clips are three sizes × two bitrates, with the two axes varied
|
|
35
|
+
**independently**. That gives three spare measurements, so the fit has a
|
|
36
|
+
residual — something to notice a degeneracy WITH — and lets a term be refused
|
|
37
|
+
when the data does not determine it, instead of being published as a zero that
|
|
38
|
+
looks measured.
|
|
39
|
+
|
|
40
|
+
A 480p clip at 9.5 Mbit/s is not content anyone ships; that is the point. The
|
|
41
|
+
bitrate has to vary at every size, or it cannot be told apart from the size.
|
|
42
|
+
|
|
43
|
+
## Why real footage
|
|
44
|
+
|
|
45
|
+
Grainy live action rather than a generated pattern: measured 2026-08-14 on the
|
|
46
|
+
addon host, this material decodes 11 % away from the film being served, where a
|
|
47
|
+
generated `testsrc2` clip is 158 % away. It also matches most of what the
|
|
48
|
+
product actually plays — a survey of 133 releases (2026-07-10) found ~67 %
|
|
49
|
+
H.264 live action, the rest HEVC, XviD and animation.
|
|
50
|
+
|
|
51
|
+
## Replacing or extending the set
|
|
52
|
+
|
|
53
|
+
Allowed: the benchmark reads each clip's dimensions, frame rate and bitrate from
|
|
54
|
+
ffmpeg's own output rather than from this table. Two rules hold, though:
|
|
55
|
+
|
|
56
|
+
- **more measurements than terms**, or there is no residual and no way to see a
|
|
57
|
+
degenerate fit;
|
|
58
|
+
- **the axes stay independent** — every size at both bitrates. Dropping one cell
|
|
59
|
+
reintroduces the collinearity that produced the zeros above.
|
|
60
|
+
|
|
61
|
+
Codecs other than H.264 (HEVC, AV1, 10-bit) decode dearer per pixel, and the
|
|
62
|
+
model fitted here does not describe them. Covering them means a set per codec
|
|
63
|
+
family and constants chosen by the source's own codec — roadmap item 3(b).
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Fit the cost of decoding from measured clips — and say which terms the
|
|
3
|
+
* measurements actually determine.
|
|
4
|
+
*
|
|
5
|
+
* The model is `cost = pixelTerm × Mpixel/s + bitrateTerm × Mbit/s +
|
|
6
|
+
* constantTerm`, in seconds of work per second of content.
|
|
7
|
+
*
|
|
8
|
+
* What went wrong with the fit this replaces, measured 2026-08-17: three clips
|
|
9
|
+
* for three unknowns is an EXACT system, and two of those clips shared a pixel
|
|
10
|
+
* rate and differed only in bitrate. A system that near-singular does not fail
|
|
11
|
+
* loudly — it returns whatever satisfies the three equations, and on that boot
|
|
12
|
+
* it returned `0.007542 × Mpx/s + 0.000000 × Mbit/s + 0.0000 s/s`. The bitrate
|
|
13
|
+
* term and the constant were zero, so a film's own bitrate never entered its
|
|
14
|
+
* price, and the whole prediction was 1.8-2.2x optimistic against what the same
|
|
15
|
+
* file measured while playing.
|
|
16
|
+
*
|
|
17
|
+
* Two things are different here, and both are about honesty rather than
|
|
18
|
+
* accuracy:
|
|
19
|
+
*
|
|
20
|
+
* - the system is OVER-determined (six clips, three unknowns) and solved by
|
|
21
|
+
* least squares, so no single noisy reading decides a term;
|
|
22
|
+
* - a term is published only if the measurements determine it. The test is
|
|
23
|
+
* the coefficient against its OWN standard error: a term smaller than the
|
|
24
|
+
* uncertainty in it has not been measured, and stating it as a number —
|
|
25
|
+
* zero or otherwise — claims knowledge that is not there. It is dropped,
|
|
26
|
+
* the remaining terms are fitted again, and the caller is told which shape
|
|
27
|
+
* survived.
|
|
28
|
+
*
|
|
29
|
+
* There is no threshold to tune in that rule and no coefficient anywhere in
|
|
30
|
+
* this file. "Smaller than its own uncertainty" is a statement about the data,
|
|
31
|
+
* not a setting.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One measured clip.
|
|
36
|
+
*
|
|
37
|
+
* @typedef {object} DecodeSample
|
|
38
|
+
* @property {number} megapixelsPerSecond - Width × height × frame rate ÷ 1e6.
|
|
39
|
+
* @property {number} megabitsPerSecond - The clip's own measured bitrate.
|
|
40
|
+
* @property {number} costSecondsPerSecond - Seconds of work per second of
|
|
41
|
+
* content, from ffmpeg's own progress.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The fitted model, with the shape the data supported.
|
|
46
|
+
*
|
|
47
|
+
* @typedef {object} DecodeCostModel
|
|
48
|
+
* @property {number} pixelTerm
|
|
49
|
+
* @property {number} bitrateTerm
|
|
50
|
+
* @property {number} constantTerm
|
|
51
|
+
* @property {string} shape - Which terms were determined.
|
|
52
|
+
* @property {string[]} dropped - Terms the measurements could not determine.
|
|
53
|
+
* @property {number} samples
|
|
54
|
+
* @property {number} residualRms - Typical disagreement between the fit and a
|
|
55
|
+
* measurement, in the same units as the cost.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/** Every term the model can carry, in column order. */
|
|
59
|
+
const TERMS = ["pixels", "bitrate", "constant"];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Fit the model, dropping any term the measurements do not determine.
|
|
63
|
+
*
|
|
64
|
+
* @param {DecodeSample[]} samples
|
|
65
|
+
* @returns {DecodeCostModel | null} Null when nothing at all can be said: fewer
|
|
66
|
+
* measurements than terms, or no measurable dependence on the source.
|
|
67
|
+
*/
|
|
68
|
+
export function fitDecodeCost(samples) {
|
|
69
|
+
const usable = (Array.isArray(samples) ? samples : []).filter(
|
|
70
|
+
(sample) =>
|
|
71
|
+
Number.isFinite(sample?.megapixelsPerSecond) &&
|
|
72
|
+
sample.megapixelsPerSecond > 0 &&
|
|
73
|
+
Number.isFinite(sample?.megabitsPerSecond) &&
|
|
74
|
+
sample.megabitsPerSecond >= 0 &&
|
|
75
|
+
Number.isFinite(sample?.costSecondsPerSecond) &&
|
|
76
|
+
sample.costSecondsPerSecond > 0
|
|
77
|
+
);
|
|
78
|
+
// Fitting three terms to three points is what produced the degenerate answer
|
|
79
|
+
// this file exists to prevent: it has no residual and therefore no way to
|
|
80
|
+
// notice that two of the points said the same thing.
|
|
81
|
+
if (usable.length < TERMS.length + 1) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let columns = [...TERMS];
|
|
86
|
+
let dropped = [];
|
|
87
|
+
for (let attempt = 0; attempt < TERMS.length; attempt += 1) {
|
|
88
|
+
const fit = leastSquares(usable, columns);
|
|
89
|
+
if (!fit) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
// Which terms the data did not determine: a coefficient no larger than the
|
|
93
|
+
// uncertainty in it. Only one is dropped per pass — removing a column
|
|
94
|
+
// changes the uncertainty in the others, so the question has to be asked
|
|
95
|
+
// again of the smaller model.
|
|
96
|
+
const undetermined = columns
|
|
97
|
+
.map((name, index) => ({
|
|
98
|
+
name,
|
|
99
|
+
coefficient: fit.coefficients[index],
|
|
100
|
+
ratio: determination(usable, columns, name, fit.coefficients[index], fit.standardErrors[index], fit.residualRms)
|
|
101
|
+
}))
|
|
102
|
+
// The pixel term is the one relationship every measurement agrees on, and
|
|
103
|
+
// a model without it says nothing at all. If IT is undetermined the
|
|
104
|
+
// answer is no model, which the caller handles by pricing the encoder
|
|
105
|
+
// alone and refusing nothing.
|
|
106
|
+
// A NEGATIVE coefficient is undetermined whatever its ratio says. Every
|
|
107
|
+
// term here is physically non-negative — more pixels cannot cost less
|
|
108
|
+
// work, and neither can more bits — so a negative fit is noise winning
|
|
109
|
+
// over an effect, not a discovery about the host. Clamping it to zero
|
|
110
|
+
// instead, which the first version of this file did, publishes a zero
|
|
111
|
+
// that looks measured: exactly the dishonesty the module exists to
|
|
112
|
+
// remove.
|
|
113
|
+
.filter((term) => term.ratio < 1 || term.coefficient < 0)
|
|
114
|
+
.sort((left, right) => {
|
|
115
|
+
const negative = (term) => (term.coefficient < 0 ? 0 : 1);
|
|
116
|
+
return negative(left) - negative(right) || left.ratio - right.ratio;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
if (undetermined.length === 0) {
|
|
120
|
+
return published(columns, fit, dropped, usable.length);
|
|
121
|
+
}
|
|
122
|
+
const weakest = undetermined[0].name;
|
|
123
|
+
if (weakest === "pixels") {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
columns = columns.filter((name) => name !== weakest);
|
|
127
|
+
dropped = [...dropped, weakest];
|
|
128
|
+
if (columns.length === 0) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* How well one term is determined, as a ratio against 1.
|
|
138
|
+
*
|
|
139
|
+
* Two ways a term can fail to be measured, and both have to be asked, because
|
|
140
|
+
* each is blind where the other sees:
|
|
141
|
+
*
|
|
142
|
+
* - **against the noise.** The term's effect across the range the clips
|
|
143
|
+
* actually cover — its coefficient times the spread of its own column — has
|
|
144
|
+
* to be larger than the typical disagreement between the fit and the
|
|
145
|
+
* measurements. A term whose whole contribution is smaller than the scatter
|
|
146
|
+
* was not measured; it was fitted to the scatter.
|
|
147
|
+
* - **against the arithmetic.** When a fit happens to be exact the scatter is
|
|
148
|
+
* zero, every standard error vanishes, and a coefficient of 7e-18 — which
|
|
149
|
+
* is how double-precision writes "nothing" — passes any test framed as a
|
|
150
|
+
* ratio. So the effect must also be larger than what the arithmetic itself
|
|
151
|
+
* can represent at this scale. That floor is a property of the numbers, not
|
|
152
|
+
* a setting: it moves with the size of the measurements.
|
|
153
|
+
*
|
|
154
|
+
* @param {DecodeSample[]} samples
|
|
155
|
+
* @param {string[]} columns
|
|
156
|
+
* @param {string} name
|
|
157
|
+
* @param {number} coefficient
|
|
158
|
+
* @param {number} standardError
|
|
159
|
+
* @param {number} residualRms
|
|
160
|
+
* @returns {number} Below 1 means the measurements do not determine this term.
|
|
161
|
+
*/
|
|
162
|
+
function determination(samples, columns, name, coefficient, standardError, residualRms) {
|
|
163
|
+
const values = samples.map((sample) => {
|
|
164
|
+
if (name === "pixels") {
|
|
165
|
+
return sample.megapixelsPerSecond;
|
|
166
|
+
}
|
|
167
|
+
if (name === "bitrate") {
|
|
168
|
+
return sample.megabitsPerSecond;
|
|
169
|
+
}
|
|
170
|
+
return 1;
|
|
171
|
+
});
|
|
172
|
+
const spread = name === "constant" ? 1 : Math.max(...values) - Math.min(...values);
|
|
173
|
+
const effect = Math.abs(coefficient) * spread;
|
|
174
|
+
const meanCost = samples.reduce((sum, sample) => sum + sample.costSecondsPerSecond, 0) / samples.length;
|
|
175
|
+
const arithmeticFloor = Math.abs(meanCost) * Number.EPSILON * samples.length * columns.length;
|
|
176
|
+
const noiseFloor = Math.max(residualRms, arithmeticFloor);
|
|
177
|
+
const againstNoise = noiseFloor > 0 ? effect / noiseFloor : Number.POSITIVE_INFINITY;
|
|
178
|
+
const againstError = standardError > 0 ? Math.abs(coefficient) / standardError : Number.POSITIVE_INFINITY;
|
|
179
|
+
return Math.min(againstNoise, againstError);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Assemble the answer, with absent terms as zero — and `dropped` saying that
|
|
184
|
+
* the zero means "not measured" rather than "measured to be nothing".
|
|
185
|
+
*
|
|
186
|
+
* @param {string[]} columns
|
|
187
|
+
* @param {{ coefficients: number[], residualRms: number }} fit
|
|
188
|
+
* @param {string[]} dropped
|
|
189
|
+
* @param {number} samples
|
|
190
|
+
* @returns {DecodeCostModel | null}
|
|
191
|
+
*/
|
|
192
|
+
function published(columns, fit, dropped, samples) {
|
|
193
|
+
const value = (name) => {
|
|
194
|
+
const index = columns.indexOf(name);
|
|
195
|
+
return index < 0 ? 0 : fit.coefficients[index];
|
|
196
|
+
};
|
|
197
|
+
const pixelTerm = value("pixels");
|
|
198
|
+
// A negative price for pixels is not a host being unusual — it is noise
|
|
199
|
+
// larger than the effect, and carrying it would price a bigger picture as
|
|
200
|
+
// cheaper than a smaller one.
|
|
201
|
+
if (!(pixelTerm > 0)) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
const constantTerm = value("constant");
|
|
205
|
+
return {
|
|
206
|
+
pixelTerm,
|
|
207
|
+
bitrateTerm: Math.max(0, value("bitrate")),
|
|
208
|
+
// A negative constant would make a small enough source free, which no
|
|
209
|
+
// measurement here supports: it is the fitted line crossing below zero
|
|
210
|
+
// outside the range that was measured.
|
|
211
|
+
constantTerm: Math.max(0, constantTerm),
|
|
212
|
+
shape: columns.join("+"),
|
|
213
|
+
dropped,
|
|
214
|
+
samples,
|
|
215
|
+
residualRms: fit.residualRms
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Least squares over the named columns, with the standard error of each
|
|
221
|
+
* coefficient.
|
|
222
|
+
*
|
|
223
|
+
* Solved through the normal equations. The matrix is at most 3×3 and the
|
|
224
|
+
* columns are of similar magnitude once a clip set varies them deliberately,
|
|
225
|
+
* which is exactly what the set is designed for.
|
|
226
|
+
*
|
|
227
|
+
* @param {DecodeSample[]} samples
|
|
228
|
+
* @param {string[]} columns
|
|
229
|
+
* @returns {{ coefficients: number[], standardErrors: number[], residualRms: number } | null}
|
|
230
|
+
*/
|
|
231
|
+
function leastSquares(samples, columns) {
|
|
232
|
+
const rowOf = (sample) =>
|
|
233
|
+
columns.map((name) => {
|
|
234
|
+
if (name === "pixels") {
|
|
235
|
+
return sample.megapixelsPerSecond;
|
|
236
|
+
}
|
|
237
|
+
if (name === "bitrate") {
|
|
238
|
+
return sample.megabitsPerSecond;
|
|
239
|
+
}
|
|
240
|
+
return 1;
|
|
241
|
+
});
|
|
242
|
+
const width = columns.length;
|
|
243
|
+
const normal = Array.from({ length: width }, () => new Array(width).fill(0));
|
|
244
|
+
const right = new Array(width).fill(0);
|
|
245
|
+
for (const sample of samples) {
|
|
246
|
+
const row = rowOf(sample);
|
|
247
|
+
for (let i = 0; i < width; i += 1) {
|
|
248
|
+
right[i] += row[i] * sample.costSecondsPerSecond;
|
|
249
|
+
for (let j = 0; j < width; j += 1) {
|
|
250
|
+
normal[i][j] += row[i] * row[j];
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const inverse = invert(normal);
|
|
255
|
+
if (!inverse) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
const coefficients = inverse.map((row) => row.reduce((sum, value, index) => sum + value * right[index], 0));
|
|
259
|
+
|
|
260
|
+
let residualSquares = 0;
|
|
261
|
+
for (const sample of samples) {
|
|
262
|
+
const row = rowOf(sample);
|
|
263
|
+
const predicted = row.reduce((sum, value, index) => sum + value * coefficients[index], 0);
|
|
264
|
+
residualSquares += (sample.costSecondsPerSecond - predicted) ** 2;
|
|
265
|
+
}
|
|
266
|
+
const degreesOfFreedom = samples.length - width;
|
|
267
|
+
const variance = degreesOfFreedom > 0 ? residualSquares / degreesOfFreedom : 0;
|
|
268
|
+
const standardErrors = inverse.map((row, index) => Math.sqrt(Math.max(0, variance * row[index])));
|
|
269
|
+
return {
|
|
270
|
+
coefficients,
|
|
271
|
+
standardErrors,
|
|
272
|
+
residualRms: Math.sqrt(residualSquares / samples.length)
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Invert a small symmetric matrix by Gauss-Jordan, or null when it is singular.
|
|
278
|
+
*
|
|
279
|
+
* @param {number[][]} matrix
|
|
280
|
+
* @returns {number[][] | null}
|
|
281
|
+
*/
|
|
282
|
+
function invert(matrix) {
|
|
283
|
+
const size = matrix.length;
|
|
284
|
+
const work = matrix.map((row, index) => [
|
|
285
|
+
...row,
|
|
286
|
+
...Array.from({ length: size }, (_unused, column) => (column === index ? 1 : 0))
|
|
287
|
+
]);
|
|
288
|
+
for (let column = 0; column < size; column += 1) {
|
|
289
|
+
let pivot = column;
|
|
290
|
+
for (let row = column + 1; row < size; row += 1) {
|
|
291
|
+
if (Math.abs(work[row][column]) > Math.abs(work[pivot][column])) {
|
|
292
|
+
pivot = row;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// Singular to the precision of the arithmetic: the columns are not
|
|
296
|
+
// independent, which is a statement about the clip set, not about the host.
|
|
297
|
+
if (Math.abs(work[pivot][column]) < 1e-12) {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
[work[column], work[pivot]] = [work[pivot], work[column]];
|
|
301
|
+
const scale = work[column][column];
|
|
302
|
+
for (let k = 0; k < 2 * size; k += 1) {
|
|
303
|
+
work[column][k] /= scale;
|
|
304
|
+
}
|
|
305
|
+
for (let row = 0; row < size; row += 1) {
|
|
306
|
+
if (row === column) {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const factor = work[row][column];
|
|
310
|
+
for (let k = 0; k < 2 * size; k += 1) {
|
|
311
|
+
work[row][k] -= factor * work[column][k];
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return work.map((row) => row.slice(size));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* What a model prices one source at.
|
|
320
|
+
*
|
|
321
|
+
* @param {DecodeCostModel} model
|
|
322
|
+
* @param {{ megapixelsPerSecond: number, megabitsPerSecond: number }} source
|
|
323
|
+
* @returns {number} Seconds of work per second of content.
|
|
324
|
+
*/
|
|
325
|
+
export function decodeCostOf(model, source) {
|
|
326
|
+
return (
|
|
327
|
+
model.pixelTerm * source.megapixelsPerSecond +
|
|
328
|
+
model.bitrateTerm * source.megabitsPerSecond +
|
|
329
|
+
model.constantTerm
|
|
330
|
+
);
|
|
331
|
+
}
|
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
|