@torrent-tv/proxy 2.74.1 → 2.76.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 +1504 -1453
- package/CLAUDE.md +19 -6
- package/biome.json +182 -1
- package/docs/container-architecture.md +27 -6
- package/docs/encode-run-state.md +1 -1
- package/knip.json +14 -0
- package/package.json +1 -1
- package/routes/api/sources/warm/post.js +1 -1
- package/routes/api/transcode-sessions/post.js +185 -185
- package/routes/api/transcode-sessions/progress/get.js +5 -1
- package/routes/transcode/audio-file/get.js +11 -1
- package/routes/transcode/audio-warm/get.js +11 -1
- package/routes/transcode/session-file/get.js +1 -1
- package/routes/transcode/variant-file/get.js +10 -1
- package/scripts/render-run-graph.js +2 -2
- package/server.js +25 -0
- package/services/audio-inventory.js +9 -214
- package/services/container/AviContainer.js +266 -81
- package/services/container/Container.js +281 -80
- package/services/container/ContainerFactory.js +67 -0
- package/services/container/MatroskaContainer.js +327 -8
- package/services/container/Mp4Container.js +373 -27
- package/services/container/SubtitleFileContainer.js +0 -1
- package/services/controllers/SubtitleController.js +128 -128
- package/services/demand/index.js +7 -10
- package/services/download/registry.js +0 -14
- package/services/encode/CoverageMap.js +281 -0
- package/services/encode/EncodePlan.js +255 -0
- package/services/encode/EncodeRun.js +587 -0
- package/services/encode/Encoder.js +84 -0
- package/services/encode/NvencEncoder.js +45 -0
- package/services/encode/QsvEncoder.js +47 -0
- package/services/encode/SegmentDemand.js +0 -0
- package/services/encode/SegmentStore.js +529 -0
- package/services/encode/SoftwareEncoder.js +111 -0
- package/services/encode/V4l2m2mEncoder.js +53 -0
- package/services/encode/VaapiEncoder.js +53 -0
- package/services/encode/args.js +200 -0
- package/services/{encode-exit.js → encode/encode-exit.js} +17 -0
- package/services/encode/index.js +9 -0
- package/services/encode/run-command.js +647 -0
- package/services/hls-session-manager.js +11073 -10711
- package/services/hwaccel.js +1688 -1992
- package/services/orchestrators/EncodeOrchestrator.js +359 -0
- package/services/output/LiveOutputs.js +213 -0
- package/services/output/Output.js +94 -0
- package/services/output/OutputSpec.js +195 -0
- package/services/output/Timeline.js +220 -0
- package/services/output/index.js +1 -0
- package/services/output/ladder.js +26 -0
- package/services/playback-planner.js +806 -747
- package/services/produced-index.js +222 -300
- package/services/source/SourceFile.js +346 -0
- package/services/{sidecar-files.js → torrent/files.js} +107 -11
- package/services/torrent/naming.js +619 -0
- package/services/torrent-worker/client.js +10 -0
- package/services/torrent-worker/container-tracks.js +71 -43
- package/services/torrent-worker/pool-adapter.js +370 -333
- package/services/torrent-worker/protocol.js +7 -0
- package/services/torrent-worker/subtitle-cues.js +549 -549
- package/services/torrent-worker/worker.js +18 -0
- package/services/tracks/AudioTrack.js +131 -40
- package/services/tracks/TextSubtitleTrack.js +287 -287
- package/services/tracks/index.js +15 -14
- package/services/viewer/Viewer.js +145 -0
- package/services/viewer/Viewers.js +124 -0
- package/test/audio-inventory.test.js +176 -177
- package/test/auto-quality-step.test.js +508 -506
- package/test/behind-head-repair.test.js +17 -7
- package/test/coverage-map.test.js +153 -0
- package/test/cut-times-timeline.test.js +6 -5
- package/test/cuts-follow-published-grid.test.js +4 -4
- package/test/decode-cost.test.js +31 -12
- package/test/encode-exit.test.js +1 -1
- package/test/encode-orchestrator.test.js +196 -0
- package/test/encode-plan.test.js +245 -0
- package/test/encode-run-state.test.js +2 -2
- package/test/encode-run.test.js +168 -0
- package/test/encoder-kinds.test.js +122 -0
- package/test/held-request-width.test.js +9 -3
- package/test/helpers/encode-run.js +128 -0
- package/test/keyframe-index-accuracy.test.js +19 -12
- package/test/keyframes-belong-to-the-file.test.js +132 -0
- package/test/matroska-cues-track.test.js +192 -192
- package/test/mp4-composition-times.test.js +0 -0
- package/test/orchestrator-wired.test.js +164 -0
- package/test/output-shape.test.js +68 -0
- package/test/output-spec.test.js +157 -0
- package/test/produced-copy-choice.test.js +58 -92
- package/test/produced-index.test.js +142 -188
- package/test/quality-variants.test.js +1079 -1075
- package/test/run-graph-drift.test.js +1 -1
- package/test/run-intervals.test.js +329 -0
- package/test/run-position-follows-published-grid.test.js +4 -4
- package/test/seek-landing.test.js +8 -8
- package/test/seek-target-not-superseded.test.js +21 -9
- package/test/segment-demand.test.js +82 -0
- package/test/segment-serve-wiring.test.js +47 -52
- package/test/segment-store.test.js +187 -0
- package/test/segments-are-shared.test.js +175 -0
- package/test/sidecar-naming.test.js +142 -0
- package/test/source-file.test.js +133 -0
- package/test/stale-request-after-seek.test.js +18 -12
- package/test/subtitle-language.test.js +252 -252
- package/test/timeline.test.js +95 -0
- package/test/{sidecar-files.test.js → torrent-files.test.js} +44 -1
- package/test/torrent-naming.test.js +255 -0
- package/test/tracks-begin-together.test.js +44 -32
- package/test/two-viewers-one-picture.test.js +347 -0
- package/test/video-facts.test.js +102 -0
- package/test/viewer-outputs.test.js +273 -0
- package/test/viewer.test.js +91 -0
- package/utils/perf.js +1 -63
- package/services/container/index.js +0 -6
- package/services/container-index/avi.js +0 -167
- package/services/container-index/index.js +0 -118
- package/services/container-index/matroska.js +0 -336
- package/services/container-index/mp4.js +0 -358
- package/services/controllers/index.js +0 -2
- package/services/download/index.js +0 -8
- package/services/orchestrators/index.js +0 -2
- /package/services/{container-index → container}/ebml-reader.js +0 -0
- /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
- /package/services/{language-detect.js → tracks/language-detect.js} +0 -0
|
@@ -91,10 +91,257 @@ export class Container {
|
|
|
91
91
|
* @param {object[]} declared - This container's own subtitle tracks, in its order.
|
|
92
92
|
* @returns {object[]}
|
|
93
93
|
*/
|
|
94
|
+
/**
|
|
95
|
+
* The picture's facts, from the two readings that state them.
|
|
96
|
+
*
|
|
97
|
+
* Audio and subtitles have had this since the flags were first read from the
|
|
98
|
+
* file; video never did. Every figure the encode is planned from — the size,
|
|
99
|
+
* the frame rate, whether it is HDR, how many bits a sample carries — was
|
|
100
|
+
* taken from ffmpeg's `-i` banner alone, and the `VideoTrack` the container
|
|
101
|
+
* declares was read and then used for nothing but a line in the log.
|
|
102
|
+
*
|
|
103
|
+
* Which reading wins is decided per field by what each one IS, and the rule
|
|
104
|
+
* is the one `readMediaInfo` already states: a fact the container DECLARES is
|
|
105
|
+
* read from the container; a fact only the media has is measured from the
|
|
106
|
+
* media.
|
|
107
|
+
*
|
|
108
|
+
* - the coded size and the frame rate are the BANNER's. Both are declared by
|
|
109
|
+
* the container as well, but what the encoder receives is what the decoder
|
|
110
|
+
* produced, and the ladder and the scale filter have to be sized to that. A
|
|
111
|
+
* container that declares something else is mis-declaring, and using its
|
|
112
|
+
* numbers would size the encode to a picture that never arrives;
|
|
113
|
+
* - the bit depth and the HDR signalling are the CONTAINER's where it states
|
|
114
|
+
* them. They are not properties of the decoded frames at all — they are the
|
|
115
|
+
* file saying how its samples are to be read — and ffmpeg prints them only
|
|
116
|
+
* as a side effect of naming a pixel format. HDR is not compared for
|
|
117
|
+
* disagreement: both sides give a boolean, and a boolean cannot say "I did
|
|
118
|
+
* not look";
|
|
119
|
+
* - the display size is the container's alone; the banner has no such field.
|
|
120
|
+
*
|
|
121
|
+
* Where only one side states a field, that side answers whatever the rule
|
|
122
|
+
* would have preferred. Where both state it and they DISAGREE, the
|
|
123
|
+
* disagreement is reported: it is a fact about the file, and until now
|
|
124
|
+
* nothing could see it.
|
|
125
|
+
*
|
|
126
|
+
* @param {{ width?: number|null, height?: number|null, fps?: number|null, isHdr?: boolean, bitDepth?: number|null }} banner
|
|
127
|
+
* @param {object | null} declared - The container's own `VideoTrack`.
|
|
128
|
+
* @returns {{ width: number|null, height: number|null, fps: number|null, isHdr: boolean, bitDepth: number|null, displayWidth: number|null, displayHeight: number|null, disagreements: string[] }}
|
|
129
|
+
*/
|
|
130
|
+
static mergeVideoFacts(banner, declared) {
|
|
131
|
+
const number = (value) => (Number.isFinite(value) && value > 0 ? Number(value) : null);
|
|
132
|
+
const fromBanner = {
|
|
133
|
+
width: number(banner?.width),
|
|
134
|
+
height: number(banner?.height),
|
|
135
|
+
fps: number(banner?.fps),
|
|
136
|
+
isHdr: banner?.isHdr === true,
|
|
137
|
+
bitDepth: number(banner?.bitDepth)
|
|
138
|
+
};
|
|
139
|
+
if (!declared) {
|
|
140
|
+
return { ...fromBanner, displayWidth: null, displayHeight: null, disagreements: [] };
|
|
141
|
+
}
|
|
142
|
+
const fromContainer = {
|
|
143
|
+
width: number(declared.width),
|
|
144
|
+
height: number(declared.height),
|
|
145
|
+
fps: number(declared.fps),
|
|
146
|
+
isHdr: declared.isHdr === true,
|
|
147
|
+
bitDepth: number(declared.bitDepth)
|
|
148
|
+
};
|
|
149
|
+
const disagreements = [];
|
|
150
|
+
const note = (field, mine, theirs) => {
|
|
151
|
+
if (mine !== null && theirs !== null && mine !== theirs) {
|
|
152
|
+
disagreements.push(`${field} ${theirs} in the container against ${mine} in the probe`);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
note("width", fromBanner.width, fromContainer.width);
|
|
156
|
+
note("height", fromBanner.height, fromContainer.height);
|
|
157
|
+
if (fromBanner.bitDepth !== null && fromContainer.bitDepth !== null && fromBanner.bitDepth !== fromContainer.bitDepth) {
|
|
158
|
+
disagreements.push(
|
|
159
|
+
`bit depth ${fromContainer.bitDepth} in the container against ${fromBanner.bitDepth} in the probe`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
// HDR is deliberately NOT compared. Both sides give it as a boolean, and a
|
|
163
|
+
// boolean cannot say "I did not look": a container with no Colour element
|
|
164
|
+
// and one that states SDR are the same `false`, as are a probe that printed
|
|
165
|
+
// no colour metadata and one that printed BT.709. Reporting that as a
|
|
166
|
+
// disagreement would report it on almost every file. Either side saying yes
|
|
167
|
+
// is taken as yes, which is the safe direction — the cost of tone mapping a
|
|
168
|
+
// picture that did not need it is smaller than showing a washed-out one.
|
|
169
|
+
return {
|
|
170
|
+
width: fromBanner.width ?? fromContainer.width,
|
|
171
|
+
height: fromBanner.height ?? fromContainer.height,
|
|
172
|
+
fps: fromBanner.fps ?? fromContainer.fps,
|
|
173
|
+
// The container declares these; the probe only reflects them.
|
|
174
|
+
bitDepth: fromContainer.bitDepth ?? fromBanner.bitDepth,
|
|
175
|
+
isHdr: fromContainer.isHdr || fromBanner.isHdr,
|
|
176
|
+
displayWidth: number(declared.displayWidth),
|
|
177
|
+
displayHeight: number(declared.displayHeight),
|
|
178
|
+
disagreements
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Line ffmpeg's banner up with what the container declares, and say whether the
|
|
184
|
+
* two are describing the same thing in the same order.
|
|
185
|
+
*
|
|
186
|
+
* ffmpeg numbers a file's streams `0:a:N` / `0:s:N` over every stream of that
|
|
187
|
+
* kind, in the order the container declares them; the container reading is a
|
|
188
|
+
* list in that same order. So position is the correspondence — but a position
|
|
189
|
+
* match that is merely assumed is worth nothing, and it is CHECKED: each pair
|
|
190
|
+
* has to agree on language or on title. One pair that agrees on neither, or a
|
|
191
|
+
* length that differs, means the two readings are not about the same thing, and
|
|
192
|
+
* then the container reading is not used AT ALL. A wrong flag is worse than a
|
|
193
|
+
* missing one, because `0:a:N` is what the encoder is given.
|
|
194
|
+
*
|
|
195
|
+
* One alignment for every media kind, because it is one rule. It was written
|
|
196
|
+
* twice — once for subtitles, once for audio — down to a `pairingHolds` that
|
|
197
|
+
* was byte-for-byte the same function under two names.
|
|
198
|
+
*
|
|
199
|
+
* @param {object[]} bannerTracks
|
|
200
|
+
* @param {object[]} declared
|
|
201
|
+
* @param {string} noun - "subtitle" or "audio"; only for the reason text.
|
|
202
|
+
* @returns {{ aligned: boolean, reason: string, banner: object[], container: object[] }}
|
|
203
|
+
*/
|
|
204
|
+
static alignWithBanner(bannerTracks, declared, noun) {
|
|
205
|
+
const banner = Array.isArray(bannerTracks) ? bannerTracks : [];
|
|
206
|
+
const container = Array.isArray(declared) ? declared : [];
|
|
207
|
+
if (banner.length === 0) {
|
|
208
|
+
return { aligned: false, reason: `the probe found no ${noun} stream`, banner, container };
|
|
209
|
+
}
|
|
210
|
+
if (container.length === 0) {
|
|
211
|
+
return { aligned: false, reason: `the container declares no ${noun} track`, banner, container };
|
|
212
|
+
}
|
|
213
|
+
if (container.length !== banner.length) {
|
|
214
|
+
return {
|
|
215
|
+
aligned: false,
|
|
216
|
+
reason: `the container declares ${container.length} ${noun} tracks and the probe found ${banner.length}`,
|
|
217
|
+
banner,
|
|
218
|
+
container
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
for (const [order, track] of banner.entries()) {
|
|
222
|
+
if (!Container.pairingHolds(track, container[order])) {
|
|
223
|
+
return {
|
|
224
|
+
aligned: false,
|
|
225
|
+
reason:
|
|
226
|
+
`${noun} ${order} is "${normalise(track?.title) || "-"}"/${normalise(track?.language) || "-"} ` +
|
|
227
|
+
`in the probe and "${normalise(container[order]?.name) || "-"}"/` +
|
|
228
|
+
`${normalise(container[order]?.language) || "-"} in the container`,
|
|
229
|
+
banner,
|
|
230
|
+
container
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return { aligned: true, reason: "", banner, container };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* What the banner says, with the flags only the container knows added — or the
|
|
239
|
+
* banner alone where the two could not be lined up.
|
|
240
|
+
*
|
|
241
|
+
* `take` says which of the container's fields this kind of track wants, and is
|
|
242
|
+
* the only part that differs between them.
|
|
243
|
+
*
|
|
244
|
+
* @param {object[]} bannerTracks
|
|
245
|
+
* @param {object[]} declared
|
|
246
|
+
* @param {string} noun
|
|
247
|
+
* @param {(containerTrack: object, bannerTrack: object) => object} take
|
|
248
|
+
* @param {object} absent - The same fields as `take` returns, for a track whose
|
|
249
|
+
* container reading could not be used. Not "the container says no" — the
|
|
250
|
+
* container has not been heard from.
|
|
251
|
+
* @returns {{ tracks: object[], aligned: boolean, reason: string }}
|
|
252
|
+
*/
|
|
253
|
+
static mergeDeclaredFlags(bannerTracks, declared, noun, take, absent) {
|
|
254
|
+
const { aligned, reason, banner, container } = Container.alignWithBanner(bannerTracks, declared, noun);
|
|
255
|
+
if (!aligned) {
|
|
256
|
+
return {
|
|
257
|
+
tracks: banner.map((track) => ({ ...track, ...absent })),
|
|
258
|
+
aligned,
|
|
259
|
+
reason
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
tracks: banner.map((track, order) => ({ ...track, ...take(container[order], track) })),
|
|
264
|
+
aligned: true,
|
|
265
|
+
reason: ""
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
94
269
|
static mergeSubtitleFlags(bannerTracks, declared) {
|
|
95
|
-
return
|
|
270
|
+
return Container.mergeDeclaredFlags(
|
|
271
|
+
bannerTracks,
|
|
272
|
+
declared,
|
|
273
|
+
"subtitle",
|
|
274
|
+
(track) => ({
|
|
275
|
+
isDefault: track.isDefault === true,
|
|
276
|
+
declaresDefault: track.declaresDefault === true,
|
|
277
|
+
// Read from the file rather than guessed from the track's name. Both
|
|
278
|
+
// are stated by the container itself (RFC 9559 §5.1.4.1) and neither
|
|
279
|
+
// reaches ffmpeg's `-i` banner, which is where every other field here
|
|
280
|
+
// comes from.
|
|
281
|
+
isForced: track.isForced === true,
|
|
282
|
+
isHearingImpaired: track.isHearingImpaired === true,
|
|
283
|
+
// FlagEnabled, so the browser can leave an unusable track out of the
|
|
284
|
+
// menu. It stays in the list and keeps its number: ffmpeg creates a
|
|
285
|
+
// stream for it either way.
|
|
286
|
+
isEnabled: track.isEnabled !== false,
|
|
287
|
+
// The RFC 5646 tag where the file writes one, kept BESIDE the code
|
|
288
|
+
// rather than replacing it: this list is aligned against ffmpeg's
|
|
289
|
+
// banner, which prints the three-letter form.
|
|
290
|
+
languageBcp47: typeof track.languageBcp47 === "string" ? track.languageBcp47 : ""
|
|
291
|
+
}),
|
|
292
|
+
{
|
|
293
|
+
declaresDefault: false,
|
|
294
|
+
isForced: false,
|
|
295
|
+
isHearingImpaired: false,
|
|
296
|
+
isEnabled: true,
|
|
297
|
+
languageBcp47: ""
|
|
298
|
+
}
|
|
299
|
+
);
|
|
96
300
|
}
|
|
97
301
|
|
|
302
|
+
/**
|
|
303
|
+
* The same for audio, with the flags RFC 9559 §5.1.4.1 defines for it.
|
|
304
|
+
*
|
|
305
|
+
* `FlagOriginal`, `FlagCommentary` and `FlagVisualImpaired` do not appear in
|
|
306
|
+
* the banner at all, so without this the audio menu cannot tell a director's
|
|
307
|
+
* commentary from the film.
|
|
308
|
+
*
|
|
309
|
+
* @param {object[]} bannerTracks
|
|
310
|
+
* @param {object[]} declared
|
|
311
|
+
* @returns {{ tracks: object[], aligned: boolean, reason: string }}
|
|
312
|
+
*/
|
|
313
|
+
static mergeAudioFlags(bannerTracks, declared) {
|
|
314
|
+
return Container.mergeDeclaredFlags(
|
|
315
|
+
bannerTracks,
|
|
316
|
+
declared,
|
|
317
|
+
"audio",
|
|
318
|
+
(track, banner) => ({
|
|
319
|
+
isOriginal: track.isOriginal === true,
|
|
320
|
+
isCommentary: track.isCommentary === true,
|
|
321
|
+
isVisualImpaired: track.isVisualImpaired === true,
|
|
322
|
+
isEnabled: track.isEnabled !== false,
|
|
323
|
+
isDefault: track.isDefault === true,
|
|
324
|
+
declaresDefault: track.declaresDefault === true,
|
|
325
|
+
languageBcp47: typeof track.languageBcp47 === "string" ? track.languageBcp47 : "",
|
|
326
|
+
channels: Number.isFinite(track.channels) ? track.channels : null,
|
|
327
|
+
title:
|
|
328
|
+
typeof banner?.title === "string" && banner.title.length > 0
|
|
329
|
+
? banner.title
|
|
330
|
+
: (typeof track.name === "string" ? track.name : "")
|
|
331
|
+
}),
|
|
332
|
+
{
|
|
333
|
+
declaresDefault: false,
|
|
334
|
+
isOriginal: false,
|
|
335
|
+
isCommentary: false,
|
|
336
|
+
isVisualImpaired: false,
|
|
337
|
+
isEnabled: true,
|
|
338
|
+
languageBcp47: "",
|
|
339
|
+
channels: null
|
|
340
|
+
}
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
|
|
98
345
|
/** @returns {string} Human name: "matroska" | "mp4" | "avi" | "unknown" */
|
|
99
346
|
get formatName() {
|
|
100
347
|
return "unknown";
|
|
@@ -161,10 +408,42 @@ export class Container {
|
|
|
161
408
|
}
|
|
162
409
|
|
|
163
410
|
/**
|
|
164
|
-
* Keyframe times for the video track, ascending seconds. Null when index
|
|
411
|
+
* Keyframe times for the video track, ascending seconds. Null when index
|
|
412
|
+
* absent (MPEG-TS, fragmented MP4, truncated).
|
|
413
|
+
*
|
|
414
|
+
* Read ONCE per file, like the track table and the media info beside it: this
|
|
415
|
+
* is a property of immutable bytes, so a second reading could only agree —
|
|
416
|
+
* and the reading is not cheap, since the table lives at the end of the file
|
|
417
|
+
* and comes off a torrent. The wait belongs here too: two sessions created in
|
|
418
|
+
* the same moment join one read instead of making two, which is exactly what
|
|
419
|
+
* two viewers opening one film do.
|
|
420
|
+
*
|
|
421
|
+
* The subclass says how its format states it; this says how often it is
|
|
422
|
+
* asked.
|
|
423
|
+
*
|
|
165
424
|
* @returns {Promise<{times:number[],tolerance:number}|null>}
|
|
166
425
|
*/
|
|
167
426
|
async readKeyframeIndex() {
|
|
427
|
+
if (this.keyframeIndexRead) {
|
|
428
|
+
return this.keyframeIndexRead;
|
|
429
|
+
}
|
|
430
|
+
this.keyframeIndexRead = Promise.resolve(this.parseKeyframeIndex()).catch((error) => {
|
|
431
|
+
// A failed read is not remembered as an answer: the bytes it needed may
|
|
432
|
+
// simply not have arrived yet.
|
|
433
|
+
this.keyframeIndexRead = null;
|
|
434
|
+
throw error;
|
|
435
|
+
});
|
|
436
|
+
return this.keyframeIndexRead;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* How THIS format states where its keyframes are. Overridden by every
|
|
441
|
+
* container that has such a table; the default is the honest answer for one
|
|
442
|
+
* that does not.
|
|
443
|
+
*
|
|
444
|
+
* @returns {Promise<{times:number[],tolerance:number}|null>}
|
|
445
|
+
*/
|
|
446
|
+
async parseKeyframeIndex() {
|
|
168
447
|
return null;
|
|
169
448
|
}
|
|
170
449
|
|
|
@@ -320,81 +599,3 @@ function pairingHolds(banner, container) {
|
|
|
320
599
|
);
|
|
321
600
|
}
|
|
322
601
|
|
|
323
|
-
/**
|
|
324
|
-
* The banner's subtitle tracks, with what the container says about each.
|
|
325
|
-
*
|
|
326
|
-
* Every returned track gains `declaresDefault`: whether the FILE wrote the flag
|
|
327
|
-
* for it. When the container reading cannot be trusted — no declarations, a
|
|
328
|
-
* different number of them, or a pair that agrees on neither language nor name
|
|
329
|
-
* — every track gets `declaresDefault: false` and its `isDefault` is left as
|
|
330
|
-
* the banner had it. That is the honest answer for a file we cannot read this
|
|
331
|
-
* way: the container has not been heard from, so nothing is shown unasked.
|
|
332
|
-
*
|
|
333
|
-
* @param {Array<{ index?: number, language?: string, title?: string, isDefault?: boolean }>} bannerTracks
|
|
334
|
-
* @param {Array<{ language?: string, name?: string, isDefault?: boolean, declaresDefault?: boolean }>} declared
|
|
335
|
-
* @returns {{ tracks: object[], aligned: boolean, reason: string }}
|
|
336
|
-
*/
|
|
337
|
-
function mergeContainerSubtitleFlags(bannerTracks, declared) {
|
|
338
|
-
const banner = Array.isArray(bannerTracks) ? bannerTracks : [];
|
|
339
|
-
const container = Array.isArray(declared) ? declared : [];
|
|
340
|
-
const undecided = () => ({
|
|
341
|
-
// The container reading could not be lined up, so nothing of it is used —
|
|
342
|
-
// including the flags, which would otherwise be attributed to the wrong
|
|
343
|
-
// track.
|
|
344
|
-
tracks: banner.map((track) => ({
|
|
345
|
-
...track,
|
|
346
|
-
declaresDefault: false,
|
|
347
|
-
isForced: false,
|
|
348
|
-
isHearingImpaired: false,
|
|
349
|
-
// Not "the container says this track is unusable" — nothing of the
|
|
350
|
-
// container is being used here. A track is offered unless it was read to
|
|
351
|
-
// say otherwise.
|
|
352
|
-
isEnabled: true,
|
|
353
|
-
languageBcp47: ""
|
|
354
|
-
}))
|
|
355
|
-
});
|
|
356
|
-
if (container.length === 0) {
|
|
357
|
-
return { ...undecided(), aligned: false, reason: "the container declares no subtitle track" };
|
|
358
|
-
}
|
|
359
|
-
if (container.length !== banner.length) {
|
|
360
|
-
return {
|
|
361
|
-
...undecided(),
|
|
362
|
-
aligned: false,
|
|
363
|
-
reason: `the container declares ${container.length} subtitle tracks and the probe found ${banner.length}`
|
|
364
|
-
};
|
|
365
|
-
}
|
|
366
|
-
for (const [order, track] of banner.entries()) {
|
|
367
|
-
if (!pairingHolds(track, container[order])) {
|
|
368
|
-
return {
|
|
369
|
-
...undecided(),
|
|
370
|
-
aligned: false,
|
|
371
|
-
reason:
|
|
372
|
-
`subtitle ${order} is "${normalise(track?.title) || "-"}"/${normalise(track?.language) || "-"} ` +
|
|
373
|
-
`in the probe and "${normalise(container[order]?.name) || "-"}"/` +
|
|
374
|
-
`${normalise(container[order]?.language) || "-"} in the container`
|
|
375
|
-
};
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
return {
|
|
379
|
-
tracks: banner.map((track, order) => ({
|
|
380
|
-
...track,
|
|
381
|
-
isDefault: container[order].isDefault === true,
|
|
382
|
-
declaresDefault: container[order].declaresDefault === true,
|
|
383
|
-
// Read from the file rather than guessed from the track's name. Both are
|
|
384
|
-
// stated by the container itself (RFC 9559 §5.1.4.1) and neither reaches
|
|
385
|
-
// ffmpeg's `-i` banner, which is where every other field here comes from.
|
|
386
|
-
isForced: container[order].isForced === true,
|
|
387
|
-
isHearingImpaired: container[order].isHearingImpaired === true,
|
|
388
|
-
// FlagEnabled, so the browser can leave an unusable track out of the
|
|
389
|
-
// menu. It stays in this list and keeps its number: ffmpeg creates a
|
|
390
|
-
// stream for it either way.
|
|
391
|
-
isEnabled: container[order].isEnabled !== false,
|
|
392
|
-
// The RFC 5646 tag, where the file writes one. Kept beside the code
|
|
393
|
-
// rather than replacing it: what this list is aligned against is ffmpeg's
|
|
394
|
-
// banner, which prints the three-letter form.
|
|
395
|
-
languageBcp47: typeof container[order].languageBcp47 === "string" ? container[order].languageBcp47 : ""
|
|
396
|
-
})),
|
|
397
|
-
aligned: true,
|
|
398
|
-
reason: ""
|
|
399
|
-
};
|
|
400
|
-
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { MatroskaContainer } from "./MatroskaContainer.js";
|
|
10
10
|
import { Mp4Container } from "./Mp4Container.js";
|
|
11
11
|
import { AviContainer } from "./AviContainer.js";
|
|
12
|
+
import { logger } from "../../utils/logger.js";
|
|
12
13
|
|
|
13
14
|
const SNIFF_BYTES = 16;
|
|
14
15
|
|
|
@@ -46,6 +47,72 @@ export class ContainerFactory {
|
|
|
46
47
|
* @param {string} name
|
|
47
48
|
* @returns {typeof MatroskaContainer | typeof Mp4Container | null}
|
|
48
49
|
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Where a file's real keyframes are, read from the container's own tables
|
|
53
|
+
* rather than by scanning the media.
|
|
54
|
+
*
|
|
55
|
+
* The problem it solves: on the video-COPY path ffmpeg can only cut segments
|
|
56
|
+
* at the source's existing keyframes. A playlist declaring an even grid
|
|
57
|
+
* instead is false, and players punish it — either walking the whole file to
|
|
58
|
+
* rebuild the timeline, or presenting audio with no picture because a segment
|
|
59
|
+
* begins with nothing decodable (both seen in the field 2026-08-02; the file
|
|
60
|
+
* measured had 10.43 s keyframe spacing against our declared 4 s).
|
|
61
|
+
*
|
|
62
|
+
* Scanning is not an option: the file is served from a torrent, and a full
|
|
63
|
+
* packet scan of 5.5 GB found 77 keyframes in 45 s without finishing.
|
|
64
|
+
* Containers already store the table, and a couple of point reads get it —
|
|
65
|
+
* 16 KB and 0.8 s for 570 keyframes on that same file.
|
|
66
|
+
*
|
|
67
|
+
* This is the sniff plus the container's own reading, which is why it lives
|
|
68
|
+
* on the factory: doing it anywhere else meant a third place that decided
|
|
69
|
+
* what a file is.
|
|
70
|
+
*
|
|
71
|
+
* @param {{ readRange: (start:number,end:number)=>Promise<Buffer|null>, fileSize: number, label?: string }} params
|
|
72
|
+
* @returns {Promise<{ times: number[] | null, format: string, tolerance: number }>}
|
|
73
|
+
* Ascending seconds, or null times where this file has no readable index —
|
|
74
|
+
* the caller must then not claim to know the grid. The format is which
|
|
75
|
+
* container answered, reported whether or not it produced anything: how
|
|
76
|
+
* often an index disagrees with its own file is a question about the
|
|
77
|
+
* CONTAINER, and a measurement that does not say which one cannot answer it.
|
|
78
|
+
*/
|
|
79
|
+
static async readKeyframeIndex({ readRange, fileSize, label = "" }) {
|
|
80
|
+
if (typeof readRange !== "function" || !Number.isFinite(fileSize) || fileSize <= 0) {
|
|
81
|
+
return { times: null, format: "unknown", tolerance: 0 };
|
|
82
|
+
}
|
|
83
|
+
const startedAt = Date.now();
|
|
84
|
+
let container = null;
|
|
85
|
+
try {
|
|
86
|
+
container = await ContainerFactory.create({ readRange, fileSize, label });
|
|
87
|
+
} catch (error) {
|
|
88
|
+
logger.warn(`container-index: failed to read "${label}": ${error?.message ?? error}`);
|
|
89
|
+
return { times: null, format: "unrecognised", tolerance: 0 };
|
|
90
|
+
}
|
|
91
|
+
if (!container) {
|
|
92
|
+
return { times: null, format: "unrecognised", tolerance: 0 };
|
|
93
|
+
}
|
|
94
|
+
const format = container.formatName;
|
|
95
|
+
let index = null;
|
|
96
|
+
try {
|
|
97
|
+
index = await container.readKeyframeIndex();
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// A malformed or partly-downloaded index must never take playback down —
|
|
100
|
+
// it only means the grid is unknown, which the caller already handles.
|
|
101
|
+
logger.warn(`container-index: failed to read index for "${label}": ${error?.message ?? error}`);
|
|
102
|
+
return { times: null, format, tolerance: 0 };
|
|
103
|
+
}
|
|
104
|
+
const elapsedMs = Date.now() - startedAt;
|
|
105
|
+
const times = index && Array.isArray(index.times) ? index.times : null;
|
|
106
|
+
if (times) {
|
|
107
|
+
logger.info(
|
|
108
|
+
`container-index: ${times.length} keyframes from the ${format} index in ${elapsedMs}ms for "${label}"`
|
|
109
|
+
);
|
|
110
|
+
} else {
|
|
111
|
+
logger.info(`container-index: no usable index for "${label}" (${format}, ${elapsedMs}ms)`);
|
|
112
|
+
}
|
|
113
|
+
return { times, format, tolerance: Number.isFinite(index?.tolerance) ? index.tolerance : 0 };
|
|
114
|
+
}
|
|
115
|
+
|
|
49
116
|
static byName(name) {
|
|
50
117
|
const text = String(name ?? "");
|
|
51
118
|
if (/\.(mp4|m4v|m4a)$/i.test(text)) return Mp4Container;
|