@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.
Files changed (124) hide show
  1. package/CHANGELOG.md +1504 -1453
  2. package/CLAUDE.md +19 -6
  3. package/biome.json +182 -1
  4. package/docs/container-architecture.md +27 -6
  5. package/docs/encode-run-state.md +1 -1
  6. package/knip.json +14 -0
  7. package/package.json +1 -1
  8. package/routes/api/sources/warm/post.js +1 -1
  9. package/routes/api/transcode-sessions/post.js +185 -185
  10. package/routes/api/transcode-sessions/progress/get.js +5 -1
  11. package/routes/transcode/audio-file/get.js +11 -1
  12. package/routes/transcode/audio-warm/get.js +11 -1
  13. package/routes/transcode/session-file/get.js +1 -1
  14. package/routes/transcode/variant-file/get.js +10 -1
  15. package/scripts/render-run-graph.js +2 -2
  16. package/server.js +25 -0
  17. package/services/audio-inventory.js +9 -214
  18. package/services/container/AviContainer.js +266 -81
  19. package/services/container/Container.js +281 -80
  20. package/services/container/ContainerFactory.js +67 -0
  21. package/services/container/MatroskaContainer.js +327 -8
  22. package/services/container/Mp4Container.js +373 -27
  23. package/services/container/SubtitleFileContainer.js +0 -1
  24. package/services/controllers/SubtitleController.js +128 -128
  25. package/services/demand/index.js +7 -10
  26. package/services/download/registry.js +0 -14
  27. package/services/encode/CoverageMap.js +281 -0
  28. package/services/encode/EncodePlan.js +255 -0
  29. package/services/encode/EncodeRun.js +587 -0
  30. package/services/encode/Encoder.js +84 -0
  31. package/services/encode/NvencEncoder.js +45 -0
  32. package/services/encode/QsvEncoder.js +47 -0
  33. package/services/encode/SegmentDemand.js +0 -0
  34. package/services/encode/SegmentStore.js +529 -0
  35. package/services/encode/SoftwareEncoder.js +111 -0
  36. package/services/encode/V4l2m2mEncoder.js +53 -0
  37. package/services/encode/VaapiEncoder.js +53 -0
  38. package/services/encode/args.js +200 -0
  39. package/services/{encode-exit.js → encode/encode-exit.js} +17 -0
  40. package/services/encode/index.js +9 -0
  41. package/services/encode/run-command.js +647 -0
  42. package/services/hls-session-manager.js +11073 -10711
  43. package/services/hwaccel.js +1688 -1992
  44. package/services/orchestrators/EncodeOrchestrator.js +359 -0
  45. package/services/output/LiveOutputs.js +213 -0
  46. package/services/output/Output.js +94 -0
  47. package/services/output/OutputSpec.js +195 -0
  48. package/services/output/Timeline.js +220 -0
  49. package/services/output/index.js +1 -0
  50. package/services/output/ladder.js +26 -0
  51. package/services/playback-planner.js +806 -747
  52. package/services/produced-index.js +222 -300
  53. package/services/source/SourceFile.js +346 -0
  54. package/services/{sidecar-files.js → torrent/files.js} +107 -11
  55. package/services/torrent/naming.js +619 -0
  56. package/services/torrent-worker/client.js +10 -0
  57. package/services/torrent-worker/container-tracks.js +71 -43
  58. package/services/torrent-worker/pool-adapter.js +370 -333
  59. package/services/torrent-worker/protocol.js +7 -0
  60. package/services/torrent-worker/subtitle-cues.js +549 -549
  61. package/services/torrent-worker/worker.js +18 -0
  62. package/services/tracks/AudioTrack.js +131 -40
  63. package/services/tracks/TextSubtitleTrack.js +287 -287
  64. package/services/tracks/index.js +15 -14
  65. package/services/viewer/Viewer.js +145 -0
  66. package/services/viewer/Viewers.js +124 -0
  67. package/test/audio-inventory.test.js +176 -177
  68. package/test/auto-quality-step.test.js +508 -506
  69. package/test/behind-head-repair.test.js +17 -7
  70. package/test/coverage-map.test.js +153 -0
  71. package/test/cut-times-timeline.test.js +6 -5
  72. package/test/cuts-follow-published-grid.test.js +4 -4
  73. package/test/decode-cost.test.js +31 -12
  74. package/test/encode-exit.test.js +1 -1
  75. package/test/encode-orchestrator.test.js +196 -0
  76. package/test/encode-plan.test.js +245 -0
  77. package/test/encode-run-state.test.js +2 -2
  78. package/test/encode-run.test.js +168 -0
  79. package/test/encoder-kinds.test.js +122 -0
  80. package/test/held-request-width.test.js +9 -3
  81. package/test/helpers/encode-run.js +128 -0
  82. package/test/keyframe-index-accuracy.test.js +19 -12
  83. package/test/keyframes-belong-to-the-file.test.js +132 -0
  84. package/test/matroska-cues-track.test.js +192 -192
  85. package/test/mp4-composition-times.test.js +0 -0
  86. package/test/orchestrator-wired.test.js +164 -0
  87. package/test/output-shape.test.js +68 -0
  88. package/test/output-spec.test.js +157 -0
  89. package/test/produced-copy-choice.test.js +58 -92
  90. package/test/produced-index.test.js +142 -188
  91. package/test/quality-variants.test.js +1079 -1075
  92. package/test/run-graph-drift.test.js +1 -1
  93. package/test/run-intervals.test.js +329 -0
  94. package/test/run-position-follows-published-grid.test.js +4 -4
  95. package/test/seek-landing.test.js +8 -8
  96. package/test/seek-target-not-superseded.test.js +21 -9
  97. package/test/segment-demand.test.js +82 -0
  98. package/test/segment-serve-wiring.test.js +47 -52
  99. package/test/segment-store.test.js +187 -0
  100. package/test/segments-are-shared.test.js +175 -0
  101. package/test/sidecar-naming.test.js +142 -0
  102. package/test/source-file.test.js +133 -0
  103. package/test/stale-request-after-seek.test.js +18 -12
  104. package/test/subtitle-language.test.js +252 -252
  105. package/test/timeline.test.js +95 -0
  106. package/test/{sidecar-files.test.js → torrent-files.test.js} +44 -1
  107. package/test/torrent-naming.test.js +255 -0
  108. package/test/tracks-begin-together.test.js +44 -32
  109. package/test/two-viewers-one-picture.test.js +347 -0
  110. package/test/video-facts.test.js +102 -0
  111. package/test/viewer-outputs.test.js +273 -0
  112. package/test/viewer.test.js +91 -0
  113. package/utils/perf.js +1 -63
  114. package/services/container/index.js +0 -6
  115. package/services/container-index/avi.js +0 -167
  116. package/services/container-index/index.js +0 -118
  117. package/services/container-index/matroska.js +0 -336
  118. package/services/container-index/mp4.js +0 -358
  119. package/services/controllers/index.js +0 -2
  120. package/services/download/index.js +0 -8
  121. package/services/orchestrators/index.js +0 -2
  122. /package/services/{container-index → container}/ebml-reader.js +0 -0
  123. /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
  124. /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("./sidecar-files.js").SidecarFile, tracks: object[] }>} params.sidecars
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 { isAvi, readAviKeyframeTimes } from "../container-index/avi.js";
11
- import { VideoTrack } from "../tracks/VideoTrack.js";
12
-
13
- export class AviContainer extends Container {
14
- get formatName() {
15
- return "avi";
16
- }
17
-
18
- static detect(head) {
19
- return isAvi(head);
20
- }
21
-
22
- async readTracks() {
23
- const head = await this.readRange(0, Math.min(4095, this.fileSize - 1));
24
- if (!head || !isAvi(head)) return [];
25
- // AVI track table is minimal — expose one video track for uniformity.
26
- return [new VideoTrack({
27
- trackNumber: 1,
28
- declaredIndex: 0,
29
- codecId: "",
30
- language: "",
31
- languageBcp47: "",
32
- name: "",
33
- isEnabled: true,
34
- isDefault: true,
35
- declaresDefault: false
36
- })];
37
- }
38
-
39
- /**
40
- * Duration from the main AVI header, per the RIFF AVI specification: the
41
- * header states microseconds per frame and the total number of frames, and
42
- * their product is the length.
43
- *
44
- * An AVI has no edit list and no timeline offset of any kind, so its start is
45
- * zero — a declaration of the format itself, not an absence.
46
- *
47
- * @returns {Promise<import("./Container.js").ContainerMediaInfo>}
48
- */
49
- async readMediaInfo() {
50
- if (this.mediaInfo) {
51
- return this.mediaInfo;
52
- }
53
- /** @type {import("./Container.js").ContainerMediaInfo} */
54
- const info = { format: this.formatName, durationSeconds: null, startTimeSeconds: 0 };
55
- this.mediaInfo = info;
56
- const head = await this.readRange(0, Math.min(4095, this.fileSize - 1));
57
- if (!head || !isAvi(head)) {
58
- return info;
59
- }
60
- // RIFF("AVI ") -> LIST("hdrl") -> avih. The avih chunk's payload begins with
61
- // dwMicroSecPerFrame and its fifth field is dwTotalFrames.
62
- const at = head.indexOf("avih", 0, "latin1");
63
- if (at < 0 || at + 8 + 20 > head.length) {
64
- return info;
65
- }
66
- const payload = at + 8;
67
- const microsecondsPerFrame = head.readUInt32LE(payload);
68
- const totalFrames = head.readUInt32LE(payload + 16);
69
- if (microsecondsPerFrame > 0 && totalFrames > 0) {
70
- info.durationSeconds = (microsecondsPerFrame * totalFrames) / 1e6;
71
- }
72
- return info;
73
- }
74
-
75
- async readKeyframeIndex() {
76
- const r = await readAviKeyframeTimes(this.readRange, this.fileSize);
77
- if (!r) return null;
78
- if (Array.isArray(r)) return { times: r, tolerance: 0 };
79
- return r;
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
+ }