@torrent-tv/proxy 2.44.0 → 2.46.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 +12 -0
- package/assets/calibration/NOTICE.md +40 -3
- package/assets/calibration/cal-hevc-1080-hi.mp4 +0 -0
- package/assets/calibration/cal-hevc-1080-lo.mp4 +0 -0
- package/assets/calibration/cal-hevc-480-hi.mp4 +0 -0
- package/assets/calibration/cal-hevc-480-lo.mp4 +0 -0
- package/assets/calibration/cal-hevc10-1080-hi.mp4 +0 -0
- package/assets/calibration/cal-hevc10-1080-lo.mp4 +0 -0
- package/assets/calibration/cal-hevc10-480-hi.mp4 +0 -0
- package/assets/calibration/cal-hevc10-480-lo.mp4 +0 -0
- package/package.json +1 -1
- package/services/container-index/matroska-subtitles.js +24 -4
- package/services/decode-cost-fit.js +37 -5
- package/services/ffmpeg-banner.js +45 -0
- package/services/hls-session-manager.js +46 -6
- package/services/hwaccel.js +80 -12
- package/services/playback-planner.js +581 -538
- package/services/subtitle-defaults.js +131 -0
- package/services/torrent-worker/pool-adapter.js +18 -0
- package/services/torrent-worker/subtitle-cues.js +25 -1
- package/services/torrent-worker/worker.js +5 -2
- package/test/cuts-follow-published-grid.test.js +73 -0
- package/test/decode-cost.test.js +65 -1
- package/test/subtitle-defaults.test.js +97 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## 2.46.0
|
|
2
|
+
|
|
3
|
+
- **New**: The cost of DECODING is measured per codec family, not once on H.264. A video that has to be re-encoded is by definition one the browser could not play — HEVC, 10-bit — so the one model the host had was fitted on the codec it is least often asked about, and those decode dearer per pixel on the same box. There are now sets for HEVC 8-bit and HEVC Main 10 beside the H.264 one (`assets/calibration/`, four clips each: two sizes at two bitrates, the smallest grid that keeps the axes independent and still leaves a spare), the source's own codec and bit depth choose the constants, and a family with no set of its own is priced as H.264 — said in the log rather than left to be inferred. Measured on a desktop 2026-08-20, the same 1080p picture at ~5.8 Mbit/s: 7.7x as 8-bit HEVC against 6.3x as 10-bit, which is why ten bits is its own family and not a multiplier. AV1 has no set yet; the release survey of 2026-07-10 found it rare where HEVC was 18 %.
|
|
4
|
+
- **New**: The video's bit depth is read from the probe (`parseFfmpegBitDepth`), and it travels with the source's pixel and bit rates because it decides which measurement of this host applies.
|
|
5
|
+
- **Chore**: When the H.264 clips do not fit, the line now says which families did. Measured on a fast desktop the same day, the H.264 readings stopped being ordered — 1080p at 9.35 Mbit/s costing 0.0307 s/s against 720p at 9.94 costing 0.0472, which is not a thing a decoder does — so a failure there is a measurement problem and not a missing file, and the two have to be tellable apart.
|
|
6
|
+
|
|
7
|
+
## 2.45.0
|
|
8
|
+
|
|
9
|
+
- **Fix**: A run is cut where the PLAYER was told the cuts are. There are two boundary tables — the live one, corrected as produced segments reveal where the file's cuts truly are, and the one the playlist text was written from — and a player places every fragment by the text it holds, which never changes. The cut list handed to ffmpeg came from the live table, so every correction moved the run away from the timeline the player is reading: measured 2026-08-20 on `Minions.and.Monsters.1080p.mkv`, 119 of 125 produced segments arrived a uniform 2.002 s before the times their playlist named, against the 0.5 s hls.js bridges, and a fragment that does not land is fetched again — on 2026-08-17 two of them 1908 times each. A seek is resolved on the same table for the same reason: the time being resolved came out of that playlist. The corrections keep their purpose, which is to describe the FILE — a variant created later inherits the corrected table and publishes it, so its own playlist and its own cuts agree from the start — but they may no longer move the cuts of a session already being read.
|
|
10
|
+
- **New**: `FlagDefault` is read from the container itself rather than from ffmpeg's description of it. Matroska's flag DEFAULTS TO 1 and ffmpeg has applied that default by the time it prints `(default)`, so a file whose muxer marked no subtitle track is indistinguishable in the banner from one that marked every track — and the difference is the whole question, since one means "show this" and the other means "the file has no opinion". The EBML reader already walks the Tracks element for subtitle extraction and now also records whether the element was WRITTEN. Lining the two readings up is the awkward part and it is checked rather than assumed: ffmpeg numbers `0:s:N` over every subtitle stream in container order, so position is the correspondence, but each pair must agree on language or on title — one that agrees on neither, or a differing count, and the container reading is not used at all, leaving the probe's own flags with nothing claimed for them. `mergeContainerSubtitleFlags` is pure and tested.
|
|
11
|
+
- **Chore**: The line reporting a segment that began away from its grid said "the playlist says", while the figure it prints comes from the live table. Reading that log on 2026-08-20 cost a wrong diagnosis; it now says "the grid says".
|
|
12
|
+
|
|
1
13
|
## 2.44.0
|
|
2
14
|
|
|
3
15
|
- **Fix**: The cues a browser is missing are found by the order they were READ, not by where they sit in the film. Cues come out of whichever clusters are downloaded, and a torrent does not arrive in film order, so the set of known cues grows in the MIDDLE as well as at the end — and the cursor shipped in 2.43.1 was a time. Measured 2026-08-20: a viewer at 272 s was answered with cues out to 1176 s, and from that moment every cue between the two was filtered away for the rest of the session, with 59 of 276 clusters read. The subtitles the viewer was about to need had become unreachable, while cues fifteen minutes ahead kept arriving. Each cue now carries the order it was found in, `?since=<n>` selects by that, and the answer states the next cursor in `X-Subtitle-Cursor`. `?after=<seconds>` still works, for a browser that has not been reloaded.
|
|
@@ -58,6 +58,43 @@ ffmpeg's own output rather than from this table. Two rules hold, though:
|
|
|
58
58
|
- **the axes stay independent** — every size at both bitrates. Dropping one cell
|
|
59
59
|
reintroduces the collinearity that produced the zeros above.
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
## One set per codec family
|
|
62
|
+
|
|
63
|
+
A video that has to be RE-ENCODED is by definition one the browser could not
|
|
64
|
+
play, which is to say HEVC, 10-bit or AV1 — so the model was fitted on the one
|
|
65
|
+
codec it is least often asked about. There is now a set per family, and the
|
|
66
|
+
source's own codec chooses the constants.
|
|
67
|
+
|
|
68
|
+
| file | codec | resolution | bitrate |
|
|
69
|
+
|---|---|---|---|
|
|
70
|
+
| `cal-hevc-1080-hi.mp4` | HEVC Main, 8-bit | 1920×1080 | 5.78 Mbit/s |
|
|
71
|
+
| `cal-hevc-1080-lo.mp4` | HEVC Main, 8-bit | 1920×1080 | 0.52 Mbit/s |
|
|
72
|
+
| `cal-hevc-480-hi.mp4` | HEVC Main, 8-bit | 854×480 | 7.76 Mbit/s |
|
|
73
|
+
| `cal-hevc-480-lo.mp4` | HEVC Main, 8-bit | 854×480 | 0.74 Mbit/s |
|
|
74
|
+
| `cal-hevc10-1080-hi.mp4` | HEVC Main 10 | 1920×1080 | 5.82 Mbit/s |
|
|
75
|
+
| `cal-hevc10-1080-lo.mp4` | HEVC Main 10 | 1920×1080 | 0.52 Mbit/s |
|
|
76
|
+
| `cal-hevc10-480-hi.mp4` | HEVC Main 10 | 854×480 | 6.95 Mbit/s |
|
|
77
|
+
| `cal-hevc10-480-lo.mp4` | HEVC Main 10 | 854×480 | 0.68 Mbit/s |
|
|
78
|
+
|
|
79
|
+
Four per family, not six: two sizes at two bitrates is the smallest grid that
|
|
80
|
+
keeps the axes independent and still leaves one spare measurement, and every
|
|
81
|
+
clip costs the startup about two seconds. They are one second long rather than
|
|
82
|
+
two — the benchmark loops the clip and measures a slope over about a second of
|
|
83
|
+
decoding, so length beyond that buys nothing and only makes the package larger.
|
|
84
|
+
|
|
85
|
+
They were cut from `cal-h264-1080-hi.mp4` rather than from the 4K master, which
|
|
86
|
+
is not in this repository. Re-encoding an already-compressed picture softens its
|
|
87
|
+
grain slightly; what is being measured is the cost of decoding the new
|
|
88
|
+
bitstream, and the grain that survives is the same material in every set, which
|
|
89
|
+
is what makes the families comparable to each other.
|
|
90
|
+
|
|
91
|
+
10-bit is its own family rather than a multiplier on the 8-bit one. Wider
|
|
92
|
+
samples mean wider arithmetic, and how much that costs is a property of the
|
|
93
|
+
machine — which is the thing being measured. Measured on a desktop 2026-08-20,
|
|
94
|
+
1080p at ~5.8 Mbit/s: 7.7x as 8-bit HEVC and 6.3x as 10-bit, so it is a real
|
|
95
|
+
difference and not a rounding.
|
|
96
|
+
|
|
97
|
+
AV1 has no set yet. The release survey of 2026-07-10 found it rare where HEVC
|
|
98
|
+
was 18 %, so it is priced as H.264 until it is worth the startup seconds — and
|
|
99
|
+
the log says which families were measured and which are falling back, rather
|
|
100
|
+
than leaving it to be inferred.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -92,7 +92,7 @@ function readString(buffer, element) {
|
|
|
92
92
|
*
|
|
93
93
|
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
94
94
|
* @param {number} fileSize
|
|
95
|
-
* @returns {Promise<{ tracks: SubtitleTrackPlan[], secondsPerTick: number, segmentDataOffset: number } | null>}
|
|
95
|
+
* @returns {Promise<{ tracks: SubtitleTrackPlan[], declared: object[], secondsPerTick: number, segmentDataOffset: number } | null>}
|
|
96
96
|
*/
|
|
97
97
|
export async function readSubtitlePlan(readRange, fileSize) {
|
|
98
98
|
const head = await readRange(0, Math.min(HEAD_BYTES, Math.max(0, fileSize - 1)));
|
|
@@ -124,6 +124,15 @@ export async function readSubtitlePlan(readRange, fileSize) {
|
|
|
124
124
|
const tracksEnd = Math.min(head.length, tracksElement.dataOffset + tracksElement.size);
|
|
125
125
|
/** @type {SubtitleTrackPlan[]} */
|
|
126
126
|
const tracks = [];
|
|
127
|
+
/**
|
|
128
|
+
* Every subtitle track the file declares, in the order the Tracks element
|
|
129
|
+
* names them, text or picture. This is not for extraction — `tracks` is —
|
|
130
|
+
* but for lining ffmpeg's `0:s:N` numbering up against the container, which
|
|
131
|
+
* only holds while nothing is missing from the middle of the list.
|
|
132
|
+
*
|
|
133
|
+
* @type {Array<{ trackNumber: number, codecId: string, language: string, name: string, isDefault: boolean, declaresDefault: boolean }>}
|
|
134
|
+
*/
|
|
135
|
+
const declared = [];
|
|
127
136
|
for (const entry of iterateElements(head, tracksElement.dataOffset, tracksEnd)) {
|
|
128
137
|
if (entry.id !== ID_TRACK_ENTRY) {
|
|
129
138
|
continue;
|
|
@@ -135,7 +144,13 @@ export async function readSubtitlePlan(readRange, fileSize) {
|
|
|
135
144
|
let language = "";
|
|
136
145
|
let name = "";
|
|
137
146
|
let codecPrivate = "";
|
|
147
|
+
// Matroska's `FlagDefault` DEFAULTS TO 1, so a file whose muxer wrote it on
|
|
148
|
+
// no track is indistinguishable, once the default has been applied, from
|
|
149
|
+
// one that wrote it on every track — which is how ffmpeg's banner prints it
|
|
150
|
+
// and why the banner cannot answer this. Both are kept: what the flag
|
|
151
|
+
// amounts to, and whether the file said anything at all.
|
|
138
152
|
let isDefault = true;
|
|
153
|
+
let declaresDefault = false;
|
|
139
154
|
for (const field of iterateElements(head, entry.dataOffset, entryEnd)) {
|
|
140
155
|
if (field.id === ID_TRACK_NUMBER) {
|
|
141
156
|
trackNumber = readUint(head, field.dataOffset, field.size);
|
|
@@ -149,11 +164,16 @@ export async function readSubtitlePlan(readRange, fileSize) {
|
|
|
149
164
|
name = readString(head, field);
|
|
150
165
|
} else if (field.id === ID_FLAG_DEFAULT) {
|
|
151
166
|
isDefault = readUint(head, field.dataOffset, field.size) === 1;
|
|
167
|
+
declaresDefault = true;
|
|
152
168
|
} else if (field.id === ID_CODEC_PRIVATE) {
|
|
153
169
|
codecPrivate = head.toString("base64", field.dataOffset, field.dataOffset + field.size);
|
|
154
170
|
}
|
|
155
171
|
}
|
|
156
|
-
if (type !== TRACK_TYPE_SUBTITLE || trackNumber === null
|
|
172
|
+
if (type !== TRACK_TYPE_SUBTITLE || trackNumber === null) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
declared.push({ trackNumber, codecId, language, name, isDefault, declaresDefault });
|
|
176
|
+
if (!TEXT_CODECS.has(codecId)) {
|
|
157
177
|
continue;
|
|
158
178
|
}
|
|
159
179
|
tracks.push({
|
|
@@ -167,7 +187,7 @@ export async function readSubtitlePlan(readRange, fileSize) {
|
|
|
167
187
|
});
|
|
168
188
|
}
|
|
169
189
|
if (tracks.length === 0) {
|
|
170
|
-
return { tracks, secondsPerTick: scale / 1e9, segmentDataOffset: base };
|
|
190
|
+
return { tracks, declared, secondsPerTick: scale / 1e9, segmentDataOffset: base };
|
|
171
191
|
}
|
|
172
192
|
|
|
173
193
|
// Where the clusters holding those tracks are. A file that indexes only its
|
|
@@ -232,7 +252,7 @@ export async function readSubtitlePlan(readRange, fileSize) {
|
|
|
232
252
|
}
|
|
233
253
|
}
|
|
234
254
|
}
|
|
235
|
-
return { tracks, secondsPerTick: scale / 1e9, segmentDataOffset: base };
|
|
255
|
+
return { tracks, declared, secondsPerTick: scale / 1e9, segmentDataOffset: base };
|
|
236
256
|
}
|
|
237
257
|
|
|
238
258
|
/**
|
|
@@ -315,17 +315,49 @@ function invert(matrix) {
|
|
|
315
315
|
return work.map((row) => row.slice(size));
|
|
316
316
|
}
|
|
317
317
|
|
|
318
|
+
/**
|
|
319
|
+
* Which measured family prices a source of this codec and depth.
|
|
320
|
+
*
|
|
321
|
+
* The families are the ones there are clips for. Anything else is priced as
|
|
322
|
+
* H.264 — not because it decodes like H.264, but because that is the only
|
|
323
|
+
* measurement of this host there is, and saying so in one place beats each
|
|
324
|
+
* caller inventing its own fallback.
|
|
325
|
+
*
|
|
326
|
+
* 10-bit is its own family rather than a multiplier on the 8-bit one: the
|
|
327
|
+
* samples are wider, the arithmetic is wider, and how much that costs is a
|
|
328
|
+
* property of the machine, which is what is being measured.
|
|
329
|
+
*
|
|
330
|
+
* @param {{ codec?: string | null, bitDepth?: number | null }} source
|
|
331
|
+
* @returns {string}
|
|
332
|
+
*/
|
|
333
|
+
export function decodeFamilyOf(source) {
|
|
334
|
+
const codec = typeof source?.codec === "string" ? source.codec.trim().toLowerCase() : "";
|
|
335
|
+
const depth = Number(source?.bitDepth);
|
|
336
|
+
const tenBit = Number.isFinite(depth) && depth >= 10;
|
|
337
|
+
if (codec === "hevc" || codec === "h265" || codec === "x265") {
|
|
338
|
+
return tenBit ? "hevc10" : "hevc";
|
|
339
|
+
}
|
|
340
|
+
return "h264";
|
|
341
|
+
}
|
|
342
|
+
|
|
318
343
|
/**
|
|
319
344
|
* What a model prices one source at.
|
|
320
345
|
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
346
|
+
* The model may carry a set of per-family fits (`families`) alongside the H.264
|
|
347
|
+
* terms it also spreads at the top level, which is what a caller that knows
|
|
348
|
+
* nothing about codecs still reads. When the source names a codec and that
|
|
349
|
+
* family was measured, its own constants are used.
|
|
350
|
+
*
|
|
351
|
+
* @param {DecodeCostModel & { families?: Record<string, DecodeCostModel> }} model
|
|
352
|
+
* @param {{ megapixelsPerSecond: number, megabitsPerSecond: number, codec?: string | null, bitDepth?: number | null }} source
|
|
323
353
|
* @returns {number} Seconds of work per second of content.
|
|
324
354
|
*/
|
|
325
355
|
export function decodeCostOf(model, source) {
|
|
356
|
+
const family = decodeFamilyOf(source);
|
|
357
|
+
const terms = model?.families?.[family] ?? model;
|
|
326
358
|
return (
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
359
|
+
terms.pixelTerm * source.megapixelsPerSecond +
|
|
360
|
+
terms.bitrateTerm * source.megabitsPerSecond +
|
|
361
|
+
terms.constantTerm
|
|
330
362
|
);
|
|
331
363
|
}
|
|
@@ -165,3 +165,48 @@ export function parseFfmpegHdr(stderrText) {
|
|
|
165
165
|
// the reliable HDR signal.
|
|
166
166
|
return /\b(smpte2084|arib-std-b67|arib_std_b67)\b/i.test(videoLine[0]);
|
|
167
167
|
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* How many bits per sample the video carries, from the pixel format ffmpeg
|
|
171
|
+
* prints on its `Video:` line.
|
|
172
|
+
*
|
|
173
|
+
* This is a decode-cost input, not a colour one: a 10-bit stream holds wider
|
|
174
|
+
* samples and is decoded with wider arithmetic, and how much that costs is a
|
|
175
|
+
* property of the machine. It is therefore its own calibration family rather
|
|
176
|
+
* than a multiplier on the 8-bit one.
|
|
177
|
+
*
|
|
178
|
+
* ffmpeg names the depth in the format itself — `yuv420p10le`, `yuv420p12le`,
|
|
179
|
+
* `p010le` — and omits it at 8 bits (`yuv420p`, `nv12`). An unreadable line
|
|
180
|
+
* gives null, and the caller then prices the source as 8-bit, which is what
|
|
181
|
+
* every source was priced as before this existed.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} stderrText
|
|
184
|
+
* @returns {number | null}
|
|
185
|
+
*/
|
|
186
|
+
export function parseFfmpegBitDepth(stderrText) {
|
|
187
|
+
if (typeof stderrText !== "string" || stderrText.length === 0) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
const videoLine = stderrText.match(/Video:[^\n]*/i);
|
|
191
|
+
if (!videoLine) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
// The pixel format is the token after the codec and its tag, and the depth
|
|
195
|
+
// rides on its name. `p010`/`p016` are the two that state it without a `p`
|
|
196
|
+
// separator, and they are 10 and 16 bits.
|
|
197
|
+
const named = videoLine[0].match(/\byuv[a-z0-9]*?p(\d{1,2})(?:le|be)\b/i);
|
|
198
|
+
if (named) {
|
|
199
|
+
const depth = Number(named[1]);
|
|
200
|
+
return Number.isFinite(depth) && depth >= 8 ? depth : null;
|
|
201
|
+
}
|
|
202
|
+
if (/\bp010(?:le|be)?\b/i.test(videoLine[0])) {
|
|
203
|
+
return 10;
|
|
204
|
+
}
|
|
205
|
+
if (/\bp016(?:le|be)?\b/i.test(videoLine[0])) {
|
|
206
|
+
return 16;
|
|
207
|
+
}
|
|
208
|
+
if (/\byuv[a-z0-9]*p\b|\bnv12\b|\bnv21\b|\bgbrp\b/i.test(videoLine[0])) {
|
|
209
|
+
return 8;
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
@@ -180,8 +180,13 @@ export function variantHeightsFor(sourceHeight) {
|
|
|
180
180
|
* Returns null when the probe did not report enough — the budget then prices
|
|
181
181
|
* the encoder alone rather than inventing a figure.
|
|
182
182
|
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
183
|
+
* The codec and bit depth travel with the rates because they decide WHICH
|
|
184
|
+
* measurement of this host applies: the model is fitted per codec family, and a
|
|
185
|
+
* video that has to be re-encoded is by definition one the browser could not
|
|
186
|
+
* play — HEVC, 10-bit — which is exactly where H.264 constants are wrong.
|
|
187
|
+
*
|
|
188
|
+
* @param {{ width: number | null, height: number | null, fps: number | null, bitrateKbps: number | null, codec?: string | null, bitDepth?: number | null }} mediaInfo
|
|
189
|
+
* @returns {{ megapixelsPerSecond: number, megabitsPerSecond: number, codec: string, bitDepth: number | null } | null}
|
|
185
190
|
*/
|
|
186
191
|
export function sourceDecodeCharacteristics(mediaInfo) {
|
|
187
192
|
const width = Number(mediaInfo?.width);
|
|
@@ -191,9 +196,12 @@ export function sourceDecodeCharacteristics(mediaInfo) {
|
|
|
191
196
|
if (!(width > 0) || !(height > 0) || !(fps > 0) || !(kbps > 0)) {
|
|
192
197
|
return null;
|
|
193
198
|
}
|
|
199
|
+
const depth = Number(mediaInfo?.bitDepth);
|
|
194
200
|
return {
|
|
195
201
|
megapixelsPerSecond: (width * height * fps) / 1e6,
|
|
196
|
-
megabitsPerSecond: kbps / 1000
|
|
202
|
+
megabitsPerSecond: kbps / 1000,
|
|
203
|
+
codec: typeof mediaInfo?.codec === "string" ? mediaInfo.codec : "",
|
|
204
|
+
bitDepth: Number.isFinite(depth) && depth > 0 ? depth : null
|
|
197
205
|
};
|
|
198
206
|
}
|
|
199
207
|
|
|
@@ -2434,6 +2442,21 @@ export class HlsSessionManager {
|
|
|
2434
2442
|
* @param {number} index
|
|
2435
2443
|
* @returns {number}
|
|
2436
2444
|
*/
|
|
2445
|
+
/**
|
|
2446
|
+
* The boundary table the player is working from: the one its playlist was
|
|
2447
|
+
* written from, falling back to the live table when no playlist was built
|
|
2448
|
+
* from a table at all (no duration, so no synthetic playlist — and then
|
|
2449
|
+
* nothing the player holds contradicts it).
|
|
2450
|
+
*
|
|
2451
|
+
* @param {HlsSession} session
|
|
2452
|
+
* @returns {number[]}
|
|
2453
|
+
*/
|
|
2454
|
+
publishedGridFor(session) {
|
|
2455
|
+
return Array.isArray(session.publishedBoundaries) && session.publishedBoundaries.length > 0
|
|
2456
|
+
? session.publishedBoundaries
|
|
2457
|
+
: (session.segmentBoundaries ?? []);
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2437
2460
|
#publishedStartTime(session, index) {
|
|
2438
2461
|
const boundaries = Array.isArray(session.publishedBoundaries) && session.publishedBoundaries.length > 0
|
|
2439
2462
|
? session.publishedBoundaries
|
|
@@ -2486,7 +2509,10 @@ export class HlsSessionManager {
|
|
|
2486
2509
|
* @returns {number}
|
|
2487
2510
|
*/
|
|
2488
2511
|
#segmentIndexForTime(session, t) {
|
|
2489
|
-
|
|
2512
|
+
// The player's grid, for the same reason the cut list uses it: the time
|
|
2513
|
+
// being resolved came from the playlist the player holds, so the index it
|
|
2514
|
+
// means is the index that playlist gives it.
|
|
2515
|
+
const boundaries = this.publishedGridFor(session);
|
|
2490
2516
|
if (boundaries.length < 2) {
|
|
2491
2517
|
return Math.max(0, Math.floor(t / this.segmentDurationSec));
|
|
2492
2518
|
}
|
|
@@ -3797,8 +3823,22 @@ export class HlsSessionManager {
|
|
|
3797
3823
|
// writes no self-contained pieces, so nothing could read a true start and
|
|
3798
3824
|
// segments were stamped with times the file does not have — the 4.17 s
|
|
3799
3825
|
// speech-against-subtitles drift, back again.
|
|
3826
|
+
//
|
|
3827
|
+
// Cut on the grid the PLAYER WAS GIVEN, not on the corrected one. A player
|
|
3828
|
+
// places a fragment by the playlist it holds, and that text was written
|
|
3829
|
+
// once and never changes; the live table keeps moving as produced segments
|
|
3830
|
+
// reveal where the file's cuts really are. Cutting on the moved table makes
|
|
3831
|
+
// every run faithful to a timeline nobody sent the player — measured
|
|
3832
|
+
// 2026-08-20, the picture's segments arriving a uniform 2.002 s before the
|
|
3833
|
+
// times the playlist named for them, which is four times what hls.js will
|
|
3834
|
+
// bridge, so the fragment does not land and is asked for again.
|
|
3835
|
+
//
|
|
3836
|
+
// The corrections keep their purpose: they describe the file, and a variant
|
|
3837
|
+
// created later inherits the corrected table and PUBLISHES it, so its own
|
|
3838
|
+
// playlist and its own cuts agree from the start. What they may not do is
|
|
3839
|
+
// move the cuts of a session whose playlist is already being read.
|
|
3800
3840
|
const gridCutTimes = explicitTimes && (!session.transcodeVideo || session.cutGrid === "keyframe")
|
|
3801
|
-
? segmentCutTimesFrom(session
|
|
3841
|
+
? segmentCutTimesFrom(this.publishedGridFor(session), safeIndex)
|
|
3802
3842
|
: null;
|
|
3803
3843
|
// Cut times are stated on the grid, for both branches.
|
|
3804
3844
|
//
|
|
@@ -5117,7 +5157,7 @@ export class HlsSessionManager {
|
|
|
5117
5157
|
logger.warn(
|
|
5118
5158
|
`transcode ${session.id} segment #${index} really starts at ` +
|
|
5119
5159
|
`${trueStart.toFixed(3)}s (boundary ${at === null ? "none" : `#${at}`}), ` +
|
|
5120
|
-
`the
|
|
5160
|
+
`the grid says ${declaredStart.toFixed(3)}s — ` +
|
|
5121
5161
|
(session.audioOnly === true
|
|
5122
5162
|
// A soundtrack is cut exactly where it was asked to be, so a
|
|
5123
5163
|
// disagreement here is not a reading about the file at all: it is the
|
package/services/hwaccel.js
CHANGED
|
@@ -683,14 +683,41 @@ export async function detectTonemapSupport({ ffmpegBin, logger }) {
|
|
|
683
683
|
// optimistic. Six points leave three spare, so the fit has a residual, and a
|
|
684
684
|
// term the data does not determine can be refused instead of published as a
|
|
685
685
|
// zero that looks measured. See `assets/calibration/NOTICE.md`.
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
686
|
+
//
|
|
687
|
+
// One set PER CODEC FAMILY, because a family is what the model describes. The
|
|
688
|
+
// fit used to be H.264 only, while a video that has to be RE-ENCODED is by
|
|
689
|
+
// definition one the browser could not play — which is to say HEVC, 10-bit or
|
|
690
|
+
// AV1 — and those decode dearer per pixel on the same box. Pricing them with
|
|
691
|
+
// H.264 constants is the one case the model is always asked about and was never
|
|
692
|
+
// measured on.
|
|
693
|
+
//
|
|
694
|
+
// A family that has no set of its own is priced with H.264's, which is what
|
|
695
|
+
// happened to every family before this; the line says so rather than implying
|
|
696
|
+
// it. AV1 has no set yet: the survey of 2026-07-10 found it rare where HEVC was
|
|
697
|
+
// 18 % of releases, so it waits for the same treatment.
|
|
698
|
+
const CALIBRATION_SETS = {
|
|
699
|
+
h264: [
|
|
700
|
+
"cal-h264-1080-hi.mp4",
|
|
701
|
+
"cal-h264-1080-lo.mp4",
|
|
702
|
+
"cal-h264-720-hi.mp4",
|
|
703
|
+
"cal-h264-720-lo.mp4",
|
|
704
|
+
"cal-h264-480-hi.mp4",
|
|
705
|
+
"cal-h264-480-lo.mp4"
|
|
706
|
+
],
|
|
707
|
+
hevc: [
|
|
708
|
+
"cal-hevc-1080-hi.mp4",
|
|
709
|
+
"cal-hevc-1080-lo.mp4",
|
|
710
|
+
"cal-hevc-480-hi.mp4",
|
|
711
|
+
"cal-hevc-480-lo.mp4"
|
|
712
|
+
],
|
|
713
|
+
hevc10: [
|
|
714
|
+
"cal-hevc10-1080-hi.mp4",
|
|
715
|
+
"cal-hevc10-1080-lo.mp4",
|
|
716
|
+
"cal-hevc10-480-hi.mp4",
|
|
717
|
+
"cal-hevc10-480-lo.mp4"
|
|
718
|
+
]
|
|
719
|
+
};
|
|
720
|
+
const CALIBRATION_CLIPS = CALIBRATION_SETS.h264;
|
|
694
721
|
const CALIBRATION_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "assets", "calibration");
|
|
695
722
|
// How wide the measured window must be before the slope is trusted, and how
|
|
696
723
|
// long to wait for it at most. A second of decoding is thousands of frames on a
|
|
@@ -833,13 +860,54 @@ export async function benchmarkContention({ ffmpegBin, logger, clipsDir = CALIBR
|
|
|
833
860
|
|
|
834
861
|
export async function benchmarkDecodeCost({ ffmpegBin, logger, clipsDir = CALIBRATION_DIR }) {
|
|
835
862
|
const log = logger ?? { info: () => {}, warn: () => {} };
|
|
863
|
+
const startedAllAt = Date.now();
|
|
864
|
+
/** @type {Record<string, { pixelTerm: number, bitrateTerm: number, constantTerm: number }>} */
|
|
865
|
+
const families = {};
|
|
866
|
+
for (const [family, clips] of Object.entries(CALIBRATION_SETS)) {
|
|
867
|
+
const fitted = await fitOneFamily({ ffmpegBin, log, clipsDir, family, clips });
|
|
868
|
+
if (fitted) {
|
|
869
|
+
families[family] = fitted;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
if (!families.h264) {
|
|
873
|
+
// H.264 is the family every other one falls back to, so without it there is
|
|
874
|
+
// no model at all rather than a partial one. Which families DID fit is said
|
|
875
|
+
// anyway: on a fast host the H.264 clips decode at 20-80x and the readings
|
|
876
|
+
// stop being ordered — measured 2026-08-20 on a desktop, 1080p at 9.35
|
|
877
|
+
// Mbit/s costing 0.0307 s/s against 720p at 9.94 costing 0.0472, which is
|
|
878
|
+
// not a thing a decoder does — so a failure here is a measurement problem
|
|
879
|
+
// and not a missing file, and the line has to let those be told apart.
|
|
880
|
+
log.warn(
|
|
881
|
+
"hwaccel: decode cost unknown — the H.264 clips did not fit" +
|
|
882
|
+
(Object.keys(families).length > 0
|
|
883
|
+
? `, though ${Object.keys(families).join(" and ")} did`
|
|
884
|
+
: "")
|
|
885
|
+
);
|
|
886
|
+
return null;
|
|
887
|
+
}
|
|
888
|
+
const missing = Object.keys(CALIBRATION_SETS).filter((family) => !families[family]);
|
|
889
|
+
log.info(
|
|
890
|
+
`hwaccel: decode cost measured for ${Object.keys(families).join(", ")}` +
|
|
891
|
+
(missing.length > 0 ? `; ${missing.join(" and ")} priced as H.264` : "") +
|
|
892
|
+
` (in ${((Date.now() - startedAllAt) / 1000).toFixed(1)}s)`
|
|
893
|
+
);
|
|
894
|
+
return { families, ...families.h264 };
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Fit one codec family's decode cost from its own clips.
|
|
899
|
+
*
|
|
900
|
+
* @param {{ ffmpegBin: string, log: { info: Function, warn: Function }, clipsDir: string, family: string, clips: string[] }} params
|
|
901
|
+
* @returns {Promise<{ pixelTerm: number, bitrateTerm: number, constantTerm: number } | null>}
|
|
902
|
+
*/
|
|
903
|
+
async function fitOneFamily({ ffmpegBin, log, clipsDir, family, clips }) {
|
|
836
904
|
const startedAllAt = Date.now();
|
|
837
905
|
/** @type {Array<{ megapixelsPerSecond: number, megabitsPerSecond: number, costSecondsPerSecond: number }>} */
|
|
838
906
|
const samples = [];
|
|
839
|
-
for (const clip of
|
|
907
|
+
for (const clip of clips) {
|
|
840
908
|
const measured = await measureDecodeSlope(ffmpegBin, path.join(clipsDir, clip));
|
|
841
909
|
if (!measured) {
|
|
842
|
-
log.warn(`hwaccel: decode benchmark "${clip}" failed or said nothing;
|
|
910
|
+
log.warn(`hwaccel: decode benchmark "${clip}" failed or said nothing; ${family} not measured`);
|
|
843
911
|
return null;
|
|
844
912
|
}
|
|
845
913
|
const cost = 1 / measured.speed;
|
|
@@ -856,11 +924,11 @@ export async function benchmarkDecodeCost({ ffmpegBin, logger, clipsDir = CALIBR
|
|
|
856
924
|
}
|
|
857
925
|
const fitted = fitDecodeCost(samples);
|
|
858
926
|
if (!fitted) {
|
|
859
|
-
log.warn(
|
|
927
|
+
log.warn(`hwaccel: ${family} decode cost could not be fitted to these measurements`);
|
|
860
928
|
return null;
|
|
861
929
|
}
|
|
862
930
|
log.info(
|
|
863
|
-
`hwaccel: decode cost = ${fitted.pixelTerm.toFixed(6)} × Mpx/s + ${fitted.bitrateTerm.toFixed(6)} × Mbit/s ` +
|
|
931
|
+
`hwaccel: ${family} decode cost = ${fitted.pixelTerm.toFixed(6)} × Mpx/s + ${fitted.bitrateTerm.toFixed(6)} × Mbit/s ` +
|
|
864
932
|
`+ ${fitted.constantTerm.toFixed(4)} s/s (${fitted.shape} from ${fitted.samples} clips, ` +
|
|
865
933
|
`typical disagreement ${fitted.residualRms.toFixed(4)} s/s` +
|
|
866
934
|
// Named rather than implied: a zero in the line above means "not
|