@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
|
@@ -28,222 +28,12 @@
|
|
|
28
28
|
* worse than a missing one, because `0:a:N` is what the encoder is given.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
-
/**
|
|
32
|
-
* Language codes that carry no information, so cannot confirm a pairing. Same
|
|
33
|
-
* rule and same reasoning as `subtitle-defaults.js`: ffmpeg prints `und` for a
|
|
34
|
-
* stream with no language, while Matroska's own default for `Language` is `eng`
|
|
35
|
-
* — which is also a real answer, so it is compared like any other.
|
|
36
|
-
*/
|
|
37
|
-
const EMPTY_LANGUAGES = new Set(["", "und", "unknown"]);
|
|
38
31
|
|
|
39
|
-
|
|
40
|
-
* @param {unknown} value
|
|
41
|
-
* @returns {string}
|
|
42
|
-
*/
|
|
43
|
-
function normalise(value) {
|
|
44
|
-
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
45
|
-
}
|
|
32
|
+
import { AudioTrack } from "./tracks/AudioTrack.js";
|
|
46
33
|
|
|
47
|
-
/**
|
|
48
|
-
* Whether one banner stream and one container track can be the same track.
|
|
49
|
-
*
|
|
50
|
-
* Agreement on either the language or the title is enough; both sides saying
|
|
51
|
-
* nothing is not agreement, but it is not disagreement either — a file may name
|
|
52
|
-
* neither, and then this pair simply adds no support to the alignment.
|
|
53
|
-
*
|
|
54
|
-
* @param {{ language?: string, title?: string }} banner
|
|
55
|
-
* @param {{ language?: string, name?: string }} container
|
|
56
|
-
* @returns {boolean}
|
|
57
|
-
*/
|
|
58
|
-
export function audioPairingHolds(banner, container) {
|
|
59
|
-
const bannerLanguage = normalise(banner?.language);
|
|
60
|
-
const containerLanguage = normalise(container?.language);
|
|
61
|
-
if (
|
|
62
|
-
!EMPTY_LANGUAGES.has(bannerLanguage) &&
|
|
63
|
-
!EMPTY_LANGUAGES.has(containerLanguage) &&
|
|
64
|
-
bannerLanguage === containerLanguage
|
|
65
|
-
) {
|
|
66
|
-
return true;
|
|
67
|
-
}
|
|
68
|
-
const bannerTitle = normalise(banner?.title);
|
|
69
|
-
const containerName = normalise(container?.name);
|
|
70
|
-
if (bannerTitle.length > 0 && bannerTitle === containerName) {
|
|
71
|
-
return true;
|
|
72
|
-
}
|
|
73
|
-
return (
|
|
74
|
-
(EMPTY_LANGUAGES.has(bannerLanguage) || EMPTY_LANGUAGES.has(containerLanguage)) &&
|
|
75
|
-
(bannerTitle.length === 0 || containerName.length === 0)
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
34
|
|
|
79
|
-
/**
|
|
80
|
-
* The banner's audio streams, with what the container declares about each.
|
|
81
|
-
*
|
|
82
|
-
* @param {Array<{ index?: number, language?: string, title?: string, isDefault?: boolean, codec?: string }>} bannerTracks
|
|
83
|
-
* @param {Array<object>} declared - `AudioTrack`s in container order.
|
|
84
|
-
* @returns {{ tracks: object[], aligned: boolean, reason: string }}
|
|
85
|
-
*/
|
|
86
|
-
export function mergeContainerAudioFlags(bannerTracks, declared) {
|
|
87
|
-
const banner = Array.isArray(bannerTracks) ? bannerTracks : [];
|
|
88
|
-
const container = Array.isArray(declared) ? declared : [];
|
|
89
|
-
const undecided = () => ({
|
|
90
|
-
// Nothing of the container reading is used, flags included — attributing
|
|
91
|
-
// them to the wrong track is the failure this guard exists to prevent.
|
|
92
|
-
tracks: banner.map((track) => ({
|
|
93
|
-
...track,
|
|
94
|
-
declaresDefault: false,
|
|
95
|
-
isOriginal: false,
|
|
96
|
-
isCommentary: false,
|
|
97
|
-
isVisualImpaired: false,
|
|
98
|
-
// Not "the container says this track is unusable": the container has not
|
|
99
|
-
// been heard from. A track is offered unless it was read to say otherwise.
|
|
100
|
-
isEnabled: true,
|
|
101
|
-
languageBcp47: "",
|
|
102
|
-
channels: null
|
|
103
|
-
}))
|
|
104
|
-
});
|
|
105
|
-
if (banner.length === 0) {
|
|
106
|
-
return { tracks: [], aligned: false, reason: "the probe found no audio stream" };
|
|
107
|
-
}
|
|
108
|
-
if (container.length === 0) {
|
|
109
|
-
return { ...undecided(), aligned: false, reason: "the container declares no audio track" };
|
|
110
|
-
}
|
|
111
|
-
if (container.length !== banner.length) {
|
|
112
|
-
return {
|
|
113
|
-
...undecided(),
|
|
114
|
-
aligned: false,
|
|
115
|
-
reason: `the container declares ${container.length} audio tracks and the probe found ${banner.length}`
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
for (const [order, track] of banner.entries()) {
|
|
119
|
-
if (!audioPairingHolds(track, container[order])) {
|
|
120
|
-
return {
|
|
121
|
-
...undecided(),
|
|
122
|
-
aligned: false,
|
|
123
|
-
reason:
|
|
124
|
-
`audio ${order} is "${normalise(track?.title) || "-"}"/${normalise(track?.language) || "-"} ` +
|
|
125
|
-
`in the probe and "${normalise(container[order]?.name) || "-"}"/` +
|
|
126
|
-
`${normalise(container[order]?.language) || "-"} in the container`
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
return {
|
|
131
|
-
tracks: banner.map((track, order) => ({
|
|
132
|
-
...track,
|
|
133
|
-
// Read from the file itself (RFC 9559 §5.1.4.1). None of these four
|
|
134
|
-
// reaches ffmpeg's banner, which is where every other field here is from.
|
|
135
|
-
isOriginal: container[order].isOriginal === true,
|
|
136
|
-
isCommentary: container[order].isCommentary === true,
|
|
137
|
-
isVisualImpaired: container[order].isVisualImpaired === true,
|
|
138
|
-
isEnabled: container[order].isEnabled !== false,
|
|
139
|
-
// FlagDefault, and whether the file actually WROTE it. Matroska defaults
|
|
140
|
-
// the flag to 1 and ffmpeg prints the applied default, so the banner
|
|
141
|
-
// cannot tell "every track marked" from "the file has no opinion".
|
|
142
|
-
isDefault: container[order].isDefault === true,
|
|
143
|
-
declaresDefault: container[order].declaresDefault === true,
|
|
144
|
-
languageBcp47:
|
|
145
|
-
typeof container[order].languageBcp47 === "string" ? container[order].languageBcp47 : "",
|
|
146
|
-
channels: Number.isFinite(container[order].channels) ? container[order].channels : null,
|
|
147
|
-
title:
|
|
148
|
-
typeof track?.title === "string" && track.title.length > 0
|
|
149
|
-
? track.title
|
|
150
|
-
: (typeof container[order].name === "string" ? container[order].name : "")
|
|
151
|
-
})),
|
|
152
|
-
aligned: true,
|
|
153
|
-
reason: ""
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
35
|
|
|
157
|
-
/**
|
|
158
|
-
* Codec identifiers as containers write them, against the name ffmpeg prints.
|
|
159
|
-
*
|
|
160
|
-
* Needed because the browser decides whether it can play a soundtrack from that
|
|
161
|
-
* name, and for a sidecar file there is no ffmpeg banner to read it from — the
|
|
162
|
-
* track came from the container's own table, where Matroska writes `A_AC3` and
|
|
163
|
-
* MP4 writes `ac-3` for the thing ffmpeg calls `ac3`. Only what a soundtrack can
|
|
164
|
-
* actually be is listed; an identifier not here is reported as it was written,
|
|
165
|
-
* which the browser treats as one it does not know and therefore transcodes.
|
|
166
|
-
*/
|
|
167
|
-
const CODEC_NAMES = new Map([
|
|
168
|
-
["A_AAC", "aac"],
|
|
169
|
-
["A_AC3", "ac3"],
|
|
170
|
-
["A_EAC3", "eac3"],
|
|
171
|
-
["A_DTS", "dts"],
|
|
172
|
-
["A_FLAC", "flac"],
|
|
173
|
-
["A_OPUS", "opus"],
|
|
174
|
-
["A_VORBIS", "vorbis"],
|
|
175
|
-
["A_TRUEHD", "truehd"],
|
|
176
|
-
["A_MPEG/L3", "mp3"],
|
|
177
|
-
["A_MPEG/L2", "mp2"],
|
|
178
|
-
["A_ALAC", "alac"],
|
|
179
|
-
["mp4a", "aac"],
|
|
180
|
-
["ac-3", "ac3"],
|
|
181
|
-
["ec-3", "eac3"],
|
|
182
|
-
["alac", "alac"],
|
|
183
|
-
["opus", "opus"],
|
|
184
|
-
["Opus", "opus"],
|
|
185
|
-
["fLaC", "flac"],
|
|
186
|
-
["flac", "flac"]
|
|
187
|
-
]);
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Extensions of raw elementary streams, against the codec they carry.
|
|
191
|
-
*
|
|
192
|
-
* A bare `.ac3` has no track table to read, and its extension is the only thing
|
|
193
|
-
* that states its codec — which for an elementary stream is exactly what the
|
|
194
|
-
* extension means.
|
|
195
|
-
*/
|
|
196
|
-
const CODEC_BY_EXTENSION = new Map([
|
|
197
|
-
[".aac", "aac"],
|
|
198
|
-
[".ac3", "ac3"],
|
|
199
|
-
[".eac3", "eac3"],
|
|
200
|
-
[".dts", "dts"],
|
|
201
|
-
[".dtshd", "dts"],
|
|
202
|
-
[".flac", "flac"],
|
|
203
|
-
[".mp3", "mp3"],
|
|
204
|
-
[".mp2", "mp2"],
|
|
205
|
-
[".opus", "opus"],
|
|
206
|
-
[".ogg", "vorbis"],
|
|
207
|
-
[".oga", "vorbis"],
|
|
208
|
-
[".wav", "pcm"],
|
|
209
|
-
[".thd", "truehd"],
|
|
210
|
-
[".mlp", "truehd"],
|
|
211
|
-
[".m4a", "aac"]
|
|
212
|
-
]);
|
|
213
36
|
|
|
214
|
-
/**
|
|
215
|
-
* The ffmpeg-side codec name for one track, from whatever the reading gave.
|
|
216
|
-
*
|
|
217
|
-
* @param {{ codec?: string, codecId?: string }} track
|
|
218
|
-
* @param {string} [extension] - The sidecar file's extension, when the track
|
|
219
|
-
* came from a file with no readable table.
|
|
220
|
-
* @returns {string}
|
|
221
|
-
*/
|
|
222
|
-
export function codecNameOf(track, extension = "") {
|
|
223
|
-
const fromBanner = typeof track?.codec === "string" ? track.codec.trim() : "";
|
|
224
|
-
if (fromBanner.length > 0) {
|
|
225
|
-
return fromBanner.toLowerCase();
|
|
226
|
-
}
|
|
227
|
-
const codecId = typeof track?.codecId === "string" ? track.codecId.trim() : "";
|
|
228
|
-
if (codecId.length > 0) {
|
|
229
|
-
const known = CODEC_NAMES.get(codecId);
|
|
230
|
-
if (known) {
|
|
231
|
-
return known;
|
|
232
|
-
}
|
|
233
|
-
// Matroska allows a suffix — `A_AAC/MPEG4/LC`, `A_PCM/INT/LIT` — so the
|
|
234
|
-
// family is what the first two segments say.
|
|
235
|
-
const family = codecId.split("/").slice(0, 2).join("/");
|
|
236
|
-
const byFamily = CODEC_NAMES.get(family) ?? CODEC_NAMES.get(codecId.split("/")[0]);
|
|
237
|
-
if (byFamily) {
|
|
238
|
-
return byFamily;
|
|
239
|
-
}
|
|
240
|
-
if (codecId.startsWith("A_PCM")) {
|
|
241
|
-
return "pcm";
|
|
242
|
-
}
|
|
243
|
-
return codecId.toLowerCase();
|
|
244
|
-
}
|
|
245
|
-
return CODEC_BY_EXTENSION.get(extension) ?? "";
|
|
246
|
-
}
|
|
247
37
|
|
|
248
38
|
/**
|
|
249
39
|
* @typedef {object} AudioInventoryEntry
|
|
@@ -283,7 +73,7 @@ export function codecNameOf(track, extension = "") {
|
|
|
283
73
|
* @param {object} params
|
|
284
74
|
* @param {object[]} params.embedded - Merged banner+container tracks of the video.
|
|
285
75
|
* @param {number} params.videoFileIndex
|
|
286
|
-
* @param {Array<{ file: import("./
|
|
76
|
+
* @param {Array<{ file: import("./torrent/files.js").SidecarFile, tracks: object[] }>} params.sidecars
|
|
287
77
|
* Each sidecar file with the audio tracks IT holds. A file whose container
|
|
288
78
|
* could not be read contributes one track, which is what a bare elementary
|
|
289
79
|
* stream is.
|
|
@@ -301,7 +91,7 @@ export function buildAudioInventory({ embedded, videoFileIndex, sidecars }) {
|
|
|
301
91
|
// The name the browser judges "can I play this?" by. For an embedded
|
|
302
92
|
// track it is ffmpeg's own; for a sidecar it is translated from what the
|
|
303
93
|
// container wrote, or from the extension when there was no table to read.
|
|
304
|
-
codec: codecNameOf(track, file?.extension ?? ""),
|
|
94
|
+
codec: AudioTrack.codecNameOf(track, file?.extension ?? ""),
|
|
305
95
|
language: typeof track?.language === "string" ? track.language : "",
|
|
306
96
|
languageBcp47: typeof track?.languageBcp47 === "string" ? track.languageBcp47 : "",
|
|
307
97
|
title:
|
|
@@ -316,7 +106,12 @@ export function buildAudioInventory({ embedded, videoFileIndex, sidecars }) {
|
|
|
316
106
|
isEnabled: track?.isEnabled !== false,
|
|
317
107
|
channels: Number.isFinite(track?.channels) ? track.channels : null,
|
|
318
108
|
fileName: kind === "sidecar" ? (file?.name ?? "") : "",
|
|
319
|
-
folders: kind === "sidecar" && Array.isArray(file?.folders) ? file.folders : []
|
|
109
|
+
folders: kind === "sidecar" && Array.isArray(file?.folders) ? file.folders : [],
|
|
110
|
+
// What the file's own path says, for a track that ships as its own file:
|
|
111
|
+
// read here, by the grammar that also decided this file belongs to this
|
|
112
|
+
// picture. The browser used to read the same path again to build the
|
|
113
|
+
// label, which is one name read twice by two rules.
|
|
114
|
+
naming: kind === "sidecar" ? (file?.naming ?? null) : null
|
|
320
115
|
});
|
|
321
116
|
};
|
|
322
117
|
|
|
@@ -1,81 +1,266 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file AVI container — RIFF.
|
|
3
|
-
*
|
|
4
|
-
* Minimal: only keyframe index via idx1 (AVIIF_KEYFRAME). Tracks are not
|
|
5
|
-
* used by current product beyond video — expose a single VideoTrack if needed.
|
|
6
|
-
* Spec: RIFF AVI, idx1 chunk at file end, OpenDML may lack idx1 → no index.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { Container } from "./Container.js";
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (
|
|
64
|
-
return
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @file AVI container — RIFF.
|
|
3
|
+
*
|
|
4
|
+
* Minimal: only keyframe index via idx1 (AVIIF_KEYFRAME). Tracks are not
|
|
5
|
+
* used by current product beyond video — expose a single VideoTrack if needed.
|
|
6
|
+
* Spec: RIFF AVI, idx1 chunk at file end, OpenDML may lack idx1 → no index.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Container } from "./Container.js";
|
|
10
|
+
import { VideoTrack } from "../tracks/VideoTrack.js";
|
|
11
|
+
|
|
12
|
+
export class AviContainer extends Container {
|
|
13
|
+
get formatName() {
|
|
14
|
+
return "avi";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
static detect(head) {
|
|
18
|
+
return isAvi(head);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The keyframe times this container's own index states, in ascending seconds.
|
|
23
|
+
*
|
|
24
|
+
* Static so a caller that has bytes and no container can ask; the instance
|
|
25
|
+
* form is {@link Container#readKeyframeIndex}.
|
|
26
|
+
*
|
|
27
|
+
* @param {(start:number,end:number)=>Promise<Buffer|null>} readRange
|
|
28
|
+
* @param {number} fileSize
|
|
29
|
+
* @returns {Promise<number[]|null>} Null where the container has no index.
|
|
30
|
+
*/
|
|
31
|
+
static readKeyframeTimes(readRange, fileSize) {
|
|
32
|
+
return readAviKeyframeTimes(readRange, fileSize);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async readTracks() {
|
|
36
|
+
const head = await this.readRange(0, Math.min(4095, this.fileSize - 1));
|
|
37
|
+
if (!head || !isAvi(head)) return [];
|
|
38
|
+
// AVI track table is minimal — expose one video track for uniformity.
|
|
39
|
+
return [new VideoTrack({
|
|
40
|
+
trackNumber: 1,
|
|
41
|
+
declaredIndex: 0,
|
|
42
|
+
codecId: "",
|
|
43
|
+
language: "",
|
|
44
|
+
languageBcp47: "",
|
|
45
|
+
name: "",
|
|
46
|
+
isEnabled: true,
|
|
47
|
+
isDefault: true,
|
|
48
|
+
declaresDefault: false
|
|
49
|
+
})];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Duration from the main AVI header, per the RIFF AVI specification: the
|
|
54
|
+
* header states microseconds per frame and the total number of frames, and
|
|
55
|
+
* their product is the length.
|
|
56
|
+
*
|
|
57
|
+
* An AVI has no edit list and no timeline offset of any kind, so its start is
|
|
58
|
+
* zero — a declaration of the format itself, not an absence.
|
|
59
|
+
*
|
|
60
|
+
* @returns {Promise<import("./Container.js").ContainerMediaInfo>}
|
|
61
|
+
*/
|
|
62
|
+
async readMediaInfo() {
|
|
63
|
+
if (this.mediaInfo) {
|
|
64
|
+
return this.mediaInfo;
|
|
65
|
+
}
|
|
66
|
+
/** @type {import("./Container.js").ContainerMediaInfo} */
|
|
67
|
+
const info = { format: this.formatName, durationSeconds: null, startTimeSeconds: 0 };
|
|
68
|
+
this.mediaInfo = info;
|
|
69
|
+
const head = await this.readRange(0, Math.min(4095, this.fileSize - 1));
|
|
70
|
+
if (!head || !isAvi(head)) {
|
|
71
|
+
return info;
|
|
72
|
+
}
|
|
73
|
+
// RIFF("AVI ") -> LIST("hdrl") -> avih. The avih chunk's payload begins with
|
|
74
|
+
// dwMicroSecPerFrame and its fifth field is dwTotalFrames.
|
|
75
|
+
const at = head.indexOf("avih", 0, "latin1");
|
|
76
|
+
if (at < 0 || at + 8 + 20 > head.length) {
|
|
77
|
+
return info;
|
|
78
|
+
}
|
|
79
|
+
const payload = at + 8;
|
|
80
|
+
const microsecondsPerFrame = head.readUInt32LE(payload);
|
|
81
|
+
const totalFrames = head.readUInt32LE(payload + 16);
|
|
82
|
+
if (microsecondsPerFrame > 0 && totalFrames > 0) {
|
|
83
|
+
info.durationSeconds = (microsecondsPerFrame * totalFrames) / 1e6;
|
|
84
|
+
}
|
|
85
|
+
return info;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async parseKeyframeIndex() {
|
|
89
|
+
const r = await readAviKeyframeTimes(this.readRange, this.fileSize);
|
|
90
|
+
if (!r) return null;
|
|
91
|
+
if (Array.isArray(r)) return { times: r, tolerance: 0 };
|
|
92
|
+
return r;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// RIFF speaking about AVI: the idx1 index and its keyframe flag.
|
|
98
|
+
// Here because the class is the only way in.
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
/**
|
|
101
|
+
* @file Keyframe index for AVI, read without downloading the file.
|
|
102
|
+
*
|
|
103
|
+
* AVI ends with an `idx1` chunk: one fixed-size entry per stream chunk, each
|
|
104
|
+
* carrying a flags word whose keyframe bit says whether that chunk starts a
|
|
105
|
+
* keyframe. Frame number times the video stream's frame duration gives the
|
|
106
|
+
* time, so the index alone is enough — no media has to be read.
|
|
107
|
+
*
|
|
108
|
+
* `idx1` lives at the end of the file and the top-level chunk headers state
|
|
109
|
+
* their sizes, so it is reached by stepping over headers (typically two hops:
|
|
110
|
+
* `LIST hdrl`, `LIST movi`), not by scanning.
|
|
111
|
+
*
|
|
112
|
+
* Still relevant despite the format's age: older releases are largely XviD in
|
|
113
|
+
* AVI, and those are exactly the files that get copied rather than re-encoded.
|
|
114
|
+
*/
|
|
115
|
+
|
|
116
|
+
const HEADER_BYTES = 8;
|
|
117
|
+
const PROBE_BYTES = 4096;
|
|
118
|
+
// Keyframe flag in an idx1 entry's flags word (AVIIF_KEYFRAME).
|
|
119
|
+
const KEYFRAME_FLAG = 0x10;
|
|
120
|
+
const IDX1_ENTRY_BYTES = 16;
|
|
121
|
+
// Cap on the idx1 read. One entry per chunk, 16 bytes each — a long film runs
|
|
122
|
+
// to a few MB; beyond this is not a normal index.
|
|
123
|
+
const MAX_IDX1_BYTES = 64 * 1024 * 1024;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Whether this looks like AVI: a RIFF container whose form type is `AVI `.
|
|
127
|
+
*
|
|
128
|
+
* @param {Buffer} head
|
|
129
|
+
* @returns {boolean}
|
|
130
|
+
*/
|
|
131
|
+
function isAvi(head) {
|
|
132
|
+
return (
|
|
133
|
+
head.length >= 12 &&
|
|
134
|
+
head.toString("latin1", 0, 4) === "RIFF" &&
|
|
135
|
+
head.toString("latin1", 8, 12) === "AVI "
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Microseconds per frame and the video stream's chunk id prefix, from the main
|
|
141
|
+
* header. Both live in the `hdrl` list near the file start.
|
|
142
|
+
*
|
|
143
|
+
* @param {Buffer} head
|
|
144
|
+
* @returns {{ microsecondsPerFrame: number } | null}
|
|
145
|
+
*/
|
|
146
|
+
function readMainHeader(head) {
|
|
147
|
+
// Top-level: "RIFF" size "AVI " then chunks. `avih` sits inside `LIST hdrl`.
|
|
148
|
+
let offset = 12;
|
|
149
|
+
while (offset + HEADER_BYTES <= head.length) {
|
|
150
|
+
const id = head.toString("latin1", offset, offset + 4);
|
|
151
|
+
const size = head.readUInt32LE(offset + 4);
|
|
152
|
+
if (size <= 0) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
if (id === "LIST") {
|
|
156
|
+
// Descend: list type follows the header, then its own chunks.
|
|
157
|
+
const listType = head.toString("latin1", offset + 8, offset + 12);
|
|
158
|
+
if (listType === "hdrl") {
|
|
159
|
+
let inner = offset + 12;
|
|
160
|
+
while (inner + HEADER_BYTES <= Math.min(head.length, offset + 8 + size)) {
|
|
161
|
+
const innerId = head.toString("latin1", inner, inner + 4);
|
|
162
|
+
const innerSize = head.readUInt32LE(inner + 4);
|
|
163
|
+
if (innerSize <= 0) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
if (innerId === "avih" && inner + 8 + 4 <= head.length) {
|
|
167
|
+
return { microsecondsPerFrame: head.readUInt32LE(inner + 8) };
|
|
168
|
+
}
|
|
169
|
+
inner += HEADER_BYTES + innerSize + (innerSize % 2);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
offset += HEADER_BYTES + 4 + (size - 4) + ((size - 4) % 2);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
offset += HEADER_BYTES + size + (size % 2);
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Step over top-level chunks to find `idx1`.
|
|
182
|
+
*
|
|
183
|
+
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
184
|
+
* @param {number} fileSize
|
|
185
|
+
* @returns {Promise<{ offset: number, size: number } | null>}
|
|
186
|
+
*/
|
|
187
|
+
async function findIdx1(readRange, fileSize) {
|
|
188
|
+
let offset = 12; // Past "RIFF" size "AVI ".
|
|
189
|
+
while (offset + HEADER_BYTES < fileSize) {
|
|
190
|
+
const probe = await readRange(offset, Math.min(fileSize - 1, offset + HEADER_BYTES - 1));
|
|
191
|
+
if (!probe || probe.length < HEADER_BYTES) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
const id = probe.toString("latin1", 0, 4);
|
|
195
|
+
const size = probe.readUInt32LE(4);
|
|
196
|
+
if (size <= 0) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
if (id === "idx1") {
|
|
200
|
+
return { offset: offset + HEADER_BYTES, size };
|
|
201
|
+
}
|
|
202
|
+
// Chunks are word-aligned; a LIST carries its type inside the payload, so
|
|
203
|
+
// the same size arithmetic covers both cases.
|
|
204
|
+
offset += HEADER_BYTES + size + (size % 2);
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Read the keyframe times of an AVI file.
|
|
211
|
+
*
|
|
212
|
+
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
213
|
+
* @param {number} fileSize
|
|
214
|
+
* @returns {Promise<number[] | null>} Ascending seconds, or null when the file
|
|
215
|
+
* has no `idx1` (OpenDML-only index, interrupted write, damaged upload).
|
|
216
|
+
*/
|
|
217
|
+
async function readAviKeyframeTimes(readRange, fileSize) {
|
|
218
|
+
const head = await readRange(0, Math.min(PROBE_BYTES - 1, fileSize - 1));
|
|
219
|
+
if (!head || !isAvi(head)) {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
const mainHeader = readMainHeader(head);
|
|
223
|
+
if (!mainHeader || !mainHeader.microsecondsPerFrame) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const idx1 = await findIdx1(readRange, fileSize);
|
|
228
|
+
if (!idx1 || idx1.size > MAX_IDX1_BYTES) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const table = await readRange(idx1.offset, Math.min(fileSize - 1, idx1.offset + idx1.size - 1));
|
|
233
|
+
if (!table || table.length < IDX1_ENTRY_BYTES) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const secondsPerFrame = mainHeader.microsecondsPerFrame / 1e6;
|
|
238
|
+
const times = [];
|
|
239
|
+
let videoFrame = 0;
|
|
240
|
+
for (let at = 0; at + IDX1_ENTRY_BYTES <= table.length; at += IDX1_ENTRY_BYTES) {
|
|
241
|
+
const chunkId = table.toString("latin1", at, at + 4);
|
|
242
|
+
// Video chunks are "##db" (uncompressed) or "##dc" (compressed); audio is
|
|
243
|
+
// "##wb" and must not advance the frame counter.
|
|
244
|
+
const isVideo = chunkId.endsWith("db") || chunkId.endsWith("dc");
|
|
245
|
+
if (!isVideo) {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const flags = table.readUInt32LE(at + 4);
|
|
249
|
+
if ((flags & KEYFRAME_FLAG) !== 0) {
|
|
250
|
+
times.push(videoFrame * secondsPerFrame);
|
|
251
|
+
}
|
|
252
|
+
videoFrame += 1;
|
|
253
|
+
}
|
|
254
|
+
if (times.length === 0) {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
// AVI names a keyframe by its FRAME NUMBER, and the time above is that number
|
|
258
|
+
// multiplied by the frame duration the header declares. The frames are the
|
|
259
|
+
// right ones — measured 2026-08-21 against the files themselves, 1196 index
|
|
260
|
+
// entries against 1196 real keyframes and 901 against 901, exactly — but the
|
|
261
|
+
// names are 10-44 ms away from the presentation times the demuxer computes,
|
|
262
|
+
// always under one frame. So the caller is told how far a time here may be
|
|
263
|
+
// from the instant it refers to, and can ask for a seek late enough that it
|
|
264
|
+
// still lands on the frame rather than on the one before it.
|
|
265
|
+
return { times, tolerance: secondsPerFrame };
|
|
266
|
+
}
|