@torrent-tv/proxy 2.89.6 → 2.89.8

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 (92) hide show
  1. package/AGENTS.md +32 -12
  2. package/CHANGELOG.md +2340 -2330
  3. package/assets/calibration/audio/NOTICE.md +19 -19
  4. package/assets/calibration/audio/manifest.json +98 -98
  5. package/biome.json +412 -412
  6. package/docs/browser-proxy-contract.md +155 -155
  7. package/docs/container-architecture.md +273 -273
  8. package/docs/encode-run-state.md +1 -1
  9. package/docs/playback-start-forecast.md +113 -98
  10. package/package.json +51 -50
  11. package/routes/api/sources/files/get.js +106 -106
  12. package/scripts/build-audio-samples.mjs +31 -31
  13. package/services/encode/EncodeRun.js +67 -15
  14. package/services/encode/EncodeRuns.js +19 -7
  15. package/services/encode/OutputOpening.js +784 -784
  16. package/services/encode/audio-calibration.js +67 -67
  17. package/services/encode/audio-work-rate.js +15 -15
  18. package/services/encode/encode-exit.js +2 -0
  19. package/services/encode/hwaccel.js +1894 -1894
  20. package/services/encode/output/index.js +24 -24
  21. package/services/encode/piece-completeness.js +60 -2
  22. package/services/encode/run-command.js +757 -755
  23. package/services/encode/segment-formats/index.js +1 -0
  24. package/services/encode/segment-formats/mp4-boxes.js +8 -1
  25. package/services/media/ContainerOrchestrator.js +202 -202
  26. package/services/media/KeyframeTables.js +356 -356
  27. package/services/media/SubtitleCues.js +592 -592
  28. package/services/media/audio-inventory.js +299 -299
  29. package/services/media/container/AviContainer.js +268 -268
  30. package/services/media/container/Container.js +632 -632
  31. package/services/media/container/ContainerFactory.js +38 -38
  32. package/services/media/container/MatroskaContainer.js +1031 -1031
  33. package/services/media/container/Mp4Container.js +1354 -1354
  34. package/services/media/container/SubtitleFileContainer.js +339 -339
  35. package/services/media/container/ebml-stream.js +174 -174
  36. package/services/media/container/matroska-clusters.js +1165 -1165
  37. package/services/media/container/unavailable.js +77 -77
  38. package/services/media/tracks/ContainerTrack.js +84 -84
  39. package/services/media/tracks/TextSubtitleTrack.js +295 -295
  40. package/services/storage/piece-store/shared-piece-store.js +1937 -1937
  41. package/services/storage/segment-store/SegmentStore.js +1294 -1294
  42. package/services/torrent/Contents.js +382 -382
  43. package/services/torrent/episode-naming.js +182 -182
  44. package/services/torrent/torrent-pool.js +2688 -2688
  45. package/services/torrent/worker/client.js +836 -836
  46. package/services/torrent/worker/held-bytes.js +231 -231
  47. package/services/torrent/worker/worker.js +1137 -1137
  48. package/services/transport/data-channel-handler.js +1373 -1373
  49. package/services/viewer/PriorityMap.js +421 -421
  50. package/test/audio-calibration.test.js +23 -23
  51. package/test/auto-quality-step.test.js +762 -762
  52. package/test/coalesce.test.js +39 -39
  53. package/test/coverage-follows-the-disk.test.js +5 -0
  54. package/test/decode-cost.test.js +475 -475
  55. package/test/encode-run.test.js +58 -0
  56. package/test/episode-naming.test.js +131 -131
  57. package/test/failed-encoder-still-serves-what-exists.test.js +1 -1
  58. package/test/fixtures/audio-frame-boundary.json +1 -1
  59. package/test/fixtures/refused-audio-20261002.json +10 -0
  60. package/test/fmp4-presentation-clock.test.js +4 -2
  61. package/test/format-keyed-outputs.test.js +78 -78
  62. package/test/health-metrics.test.js +11 -13
  63. package/test/held-read-ownership.test.js +77 -77
  64. package/test/helpers/encode-run.js +14 -6
  65. package/test/helpers/held-file.js +77 -77
  66. package/test/helpers/manager.js +62 -62
  67. package/test/helpers/matroska-file.js +296 -296
  68. package/test/keyframe-table-budget.test.js +123 -123
  69. package/test/matroska-cluster-discovery.test.js +323 -323
  70. package/test/one-output-one-session.test.js +273 -273
  71. package/test/output-speed.test.js +6 -1
  72. package/test/piece-completeness.test.js +37 -1
  73. package/test/quality-variants.test.js +1274 -1270
  74. package/test/rate-control-in-the-output.test.js +4 -3
  75. package/test/reads-not-yet-arrived.test.js +218 -218
  76. package/test/seek-frees-the-viewers-requests.test.js +1 -1
  77. package/test/segment-closed-on-both-branches.test.js +114 -114
  78. package/test/segment-judged-by-its-own-header.test.js +222 -222
  79. package/test/segment-serve-wiring.test.js +481 -481
  80. package/test/serving-existing-output.test.js +111 -111
  81. package/test/source-files-route.test.js +113 -113
  82. package/test/subtitle-cue-walk.test.js +385 -385
  83. package/test/subtitle-metadata.test.js +15 -15
  84. package/test/subtitle-plan-waits.test.js +125 -125
  85. package/test/subtitle-track-numbering.test.js +391 -391
  86. package/test/two-viewers-one-picture.test.js +508 -508
  87. package/test/viewer-requests-admission.test.js +92 -92
  88. package/test/viewer-starts-on-map.test.js +27 -27
  89. package/test/viewer-states.test.js +5 -2
  90. package/test/viewing-generations.test.js +390 -390
  91. package/test/whole-file-held-read.test.js +30 -30
  92. package/utils/coalesce.js +41 -41
@@ -1,268 +1,268 @@
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 (!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
- const head = await this.readRange(0, Math.min(4095, this.fileSize - 1));
69
- // Kept only once the head has been read: one that had not arrived used to
70
- // leave an empty answer here for the life of the container.
71
- this.mediaInfo = info;
72
- if (!isAvi(head)) {
73
- return info;
74
- }
75
- // RIFF("AVI ") -> LIST("hdrl") -> avih. The avih chunk's payload begins with
76
- // dwMicroSecPerFrame and its fifth field is dwTotalFrames.
77
- const at = head.indexOf("avih", 0, "latin1");
78
- if (at < 0 || at + 8 + 20 > head.length) {
79
- return info;
80
- }
81
- const payload = at + 8;
82
- const microsecondsPerFrame = head.readUInt32LE(payload);
83
- const totalFrames = head.readUInt32LE(payload + 16);
84
- if (microsecondsPerFrame > 0 && totalFrames > 0) {
85
- info.durationSeconds = (microsecondsPerFrame * totalFrames) / 1e6;
86
- }
87
- return info;
88
- }
89
-
90
- async parseKeyframeIndex() {
91
- const r = await readAviKeyframeTimes(this.readRange, this.fileSize);
92
- if (!r) return null;
93
- if (Array.isArray(r)) return { times: r, tolerance: 0 };
94
- return r;
95
- }
96
- }
97
-
98
- // ---------------------------------------------------------------------------
99
- // RIFF speaking about AVI: the idx1 index and its keyframe flag.
100
- // Here because the class is the only way in.
101
- // ---------------------------------------------------------------------------
102
- /**
103
- * @file Keyframe index for AVI, read without downloading the file.
104
- *
105
- * AVI ends with an `idx1` chunk: one fixed-size entry per stream chunk, each
106
- * carrying a flags word whose keyframe bit says whether that chunk starts a
107
- * keyframe. Frame number times the video stream's frame duration gives the
108
- * time, so the index alone is enough — no media has to be read.
109
- *
110
- * `idx1` lives at the end of the file and the top-level chunk headers state
111
- * their sizes, so it is reached by stepping over headers (typically two hops:
112
- * `LIST hdrl`, `LIST movi`), not by scanning.
113
- *
114
- * Still relevant despite the format's age: older releases are largely XviD in
115
- * AVI, and those are exactly the files that get copied rather than re-encoded.
116
- */
117
-
118
- const HEADER_BYTES = 8;
119
- const PROBE_BYTES = 4096;
120
- // Keyframe flag in an idx1 entry's flags word (AVIIF_KEYFRAME).
121
- const KEYFRAME_FLAG = 0x10;
122
- const IDX1_ENTRY_BYTES = 16;
123
- // Cap on the idx1 read. One entry per chunk, 16 bytes each — a long film runs
124
- // to a few MB; beyond this is not a normal index.
125
- const MAX_IDX1_BYTES = 64 * 1024 * 1024;
126
-
127
- /**
128
- * Whether this looks like AVI: a RIFF container whose form type is `AVI `.
129
- *
130
- * @param {Buffer} head
131
- * @returns {boolean}
132
- */
133
- function isAvi(head) {
134
- return (
135
- head.length >= 12 &&
136
- head.toString("latin1", 0, 4) === "RIFF" &&
137
- head.toString("latin1", 8, 12) === "AVI "
138
- );
139
- }
140
-
141
- /**
142
- * Microseconds per frame and the video stream's chunk id prefix, from the main
143
- * header. Both live in the `hdrl` list near the file start.
144
- *
145
- * @param {Buffer} head
146
- * @returns {{ microsecondsPerFrame: number } | null}
147
- */
148
- function readMainHeader(head) {
149
- // Top-level: "RIFF" size "AVI " then chunks. `avih` sits inside `LIST hdrl`.
150
- let offset = 12;
151
- while (offset + HEADER_BYTES <= head.length) {
152
- const id = head.toString("latin1", offset, offset + 4);
153
- const size = head.readUInt32LE(offset + 4);
154
- if (size <= 0) {
155
- return null;
156
- }
157
- if (id === "LIST") {
158
- // Descend: list type follows the header, then its own chunks.
159
- const listType = head.toString("latin1", offset + 8, offset + 12);
160
- if (listType === "hdrl") {
161
- let inner = offset + 12;
162
- while (inner + HEADER_BYTES <= Math.min(head.length, offset + 8 + size)) {
163
- const innerId = head.toString("latin1", inner, inner + 4);
164
- const innerSize = head.readUInt32LE(inner + 4);
165
- if (innerSize <= 0) {
166
- return null;
167
- }
168
- if (innerId === "avih" && inner + 8 + 4 <= head.length) {
169
- return { microsecondsPerFrame: head.readUInt32LE(inner + 8) };
170
- }
171
- inner += HEADER_BYTES + innerSize + (innerSize % 2);
172
- }
173
- }
174
- offset += HEADER_BYTES + 4 + (size - 4) + ((size - 4) % 2);
175
- continue;
176
- }
177
- offset += HEADER_BYTES + size + (size % 2);
178
- }
179
- return null;
180
- }
181
-
182
- /**
183
- * Step over top-level chunks to find `idx1`.
184
- *
185
- * @param {(start: number, end: number) => Promise<Buffer | null>} readRange
186
- * @param {number} fileSize
187
- * @returns {Promise<{ offset: number, size: number } | null>}
188
- */
189
- async function findIdx1(readRange, fileSize) {
190
- let offset = 12; // Past "RIFF" size "AVI ".
191
- while (offset + HEADER_BYTES < fileSize) {
192
- const probe = await readRange(offset, Math.min(fileSize - 1, offset + HEADER_BYTES - 1));
193
- if (!probe || probe.length < HEADER_BYTES) {
194
- return null;
195
- }
196
- const id = probe.toString("latin1", 0, 4);
197
- const size = probe.readUInt32LE(4);
198
- if (size <= 0) {
199
- return null;
200
- }
201
- if (id === "idx1") {
202
- return { offset: offset + HEADER_BYTES, size };
203
- }
204
- // Chunks are word-aligned; a LIST carries its type inside the payload, so
205
- // the same size arithmetic covers both cases.
206
- offset += HEADER_BYTES + size + (size % 2);
207
- }
208
- return null;
209
- }
210
-
211
- /**
212
- * Read the keyframe times of an AVI file.
213
- *
214
- * @param {(start: number, end: number) => Promise<Buffer | null>} readRange
215
- * @param {number} fileSize
216
- * @returns {Promise<number[] | null>} Ascending seconds, or null when the file
217
- * has no `idx1` (OpenDML-only index, interrupted write, damaged upload).
218
- */
219
- async function readAviKeyframeTimes(readRange, fileSize) {
220
- const head = await readRange(0, Math.min(PROBE_BYTES - 1, fileSize - 1));
221
- if (!head || !isAvi(head)) {
222
- return null;
223
- }
224
- const mainHeader = readMainHeader(head);
225
- if (!mainHeader || !mainHeader.microsecondsPerFrame) {
226
- return null;
227
- }
228
-
229
- const idx1 = await findIdx1(readRange, fileSize);
230
- if (!idx1 || idx1.size > MAX_IDX1_BYTES) {
231
- return null;
232
- }
233
-
234
- const table = await readRange(idx1.offset, Math.min(fileSize - 1, idx1.offset + idx1.size - 1));
235
- if (!table || table.length < IDX1_ENTRY_BYTES) {
236
- return null;
237
- }
238
-
239
- const secondsPerFrame = mainHeader.microsecondsPerFrame / 1e6;
240
- const times = [];
241
- let videoFrame = 0;
242
- for (let at = 0; at + IDX1_ENTRY_BYTES <= table.length; at += IDX1_ENTRY_BYTES) {
243
- const chunkId = table.toString("latin1", at, at + 4);
244
- // Video chunks are "##db" (uncompressed) or "##dc" (compressed); audio is
245
- // "##wb" and must not advance the frame counter.
246
- const isVideo = chunkId.endsWith("db") || chunkId.endsWith("dc");
247
- if (!isVideo) {
248
- continue;
249
- }
250
- const flags = table.readUInt32LE(at + 4);
251
- if ((flags & KEYFRAME_FLAG) !== 0) {
252
- times.push(videoFrame * secondsPerFrame);
253
- }
254
- videoFrame += 1;
255
- }
256
- if (times.length === 0) {
257
- return null;
258
- }
259
- // AVI names a keyframe by its FRAME NUMBER, and the time above is that number
260
- // multiplied by the frame duration the header declares. The frames are the
261
- // right ones — measured 2026-08-21 against the files themselves, 1196 index
262
- // entries against 1196 real keyframes and 901 against 901, exactly — but the
263
- // names are 10-44 ms away from the presentation times the demuxer computes,
264
- // always under one frame. So the caller is told how far a time here may be
265
- // from the instant it refers to, and can ask for a seek late enough that it
266
- // still lands on the frame rather than on the one before it.
267
- return { times, tolerance: secondsPerFrame };
268
- }
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 (!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
+ const head = await this.readRange(0, Math.min(4095, this.fileSize - 1));
69
+ // Kept only once the head has been read: one that had not arrived used to
70
+ // leave an empty answer here for the life of the container.
71
+ this.mediaInfo = info;
72
+ if (!isAvi(head)) {
73
+ return info;
74
+ }
75
+ // RIFF("AVI ") -> LIST("hdrl") -> avih. The avih chunk's payload begins with
76
+ // dwMicroSecPerFrame and its fifth field is dwTotalFrames.
77
+ const at = head.indexOf("avih", 0, "latin1");
78
+ if (at < 0 || at + 8 + 20 > head.length) {
79
+ return info;
80
+ }
81
+ const payload = at + 8;
82
+ const microsecondsPerFrame = head.readUInt32LE(payload);
83
+ const totalFrames = head.readUInt32LE(payload + 16);
84
+ if (microsecondsPerFrame > 0 && totalFrames > 0) {
85
+ info.durationSeconds = (microsecondsPerFrame * totalFrames) / 1e6;
86
+ }
87
+ return info;
88
+ }
89
+
90
+ async parseKeyframeIndex() {
91
+ const r = await readAviKeyframeTimes(this.readRange, this.fileSize);
92
+ if (!r) return null;
93
+ if (Array.isArray(r)) return { times: r, tolerance: 0 };
94
+ return r;
95
+ }
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // RIFF speaking about AVI: the idx1 index and its keyframe flag.
100
+ // Here because the class is the only way in.
101
+ // ---------------------------------------------------------------------------
102
+ /**
103
+ * @file Keyframe index for AVI, read without downloading the file.
104
+ *
105
+ * AVI ends with an `idx1` chunk: one fixed-size entry per stream chunk, each
106
+ * carrying a flags word whose keyframe bit says whether that chunk starts a
107
+ * keyframe. Frame number times the video stream's frame duration gives the
108
+ * time, so the index alone is enough — no media has to be read.
109
+ *
110
+ * `idx1` lives at the end of the file and the top-level chunk headers state
111
+ * their sizes, so it is reached by stepping over headers (typically two hops:
112
+ * `LIST hdrl`, `LIST movi`), not by scanning.
113
+ *
114
+ * Still relevant despite the format's age: older releases are largely XviD in
115
+ * AVI, and those are exactly the files that get copied rather than re-encoded.
116
+ */
117
+
118
+ const HEADER_BYTES = 8;
119
+ const PROBE_BYTES = 4096;
120
+ // Keyframe flag in an idx1 entry's flags word (AVIIF_KEYFRAME).
121
+ const KEYFRAME_FLAG = 0x10;
122
+ const IDX1_ENTRY_BYTES = 16;
123
+ // Cap on the idx1 read. One entry per chunk, 16 bytes each — a long film runs
124
+ // to a few MB; beyond this is not a normal index.
125
+ const MAX_IDX1_BYTES = 64 * 1024 * 1024;
126
+
127
+ /**
128
+ * Whether this looks like AVI: a RIFF container whose form type is `AVI `.
129
+ *
130
+ * @param {Buffer} head
131
+ * @returns {boolean}
132
+ */
133
+ function isAvi(head) {
134
+ return (
135
+ head.length >= 12 &&
136
+ head.toString("latin1", 0, 4) === "RIFF" &&
137
+ head.toString("latin1", 8, 12) === "AVI "
138
+ );
139
+ }
140
+
141
+ /**
142
+ * Microseconds per frame and the video stream's chunk id prefix, from the main
143
+ * header. Both live in the `hdrl` list near the file start.
144
+ *
145
+ * @param {Buffer} head
146
+ * @returns {{ microsecondsPerFrame: number } | null}
147
+ */
148
+ function readMainHeader(head) {
149
+ // Top-level: "RIFF" size "AVI " then chunks. `avih` sits inside `LIST hdrl`.
150
+ let offset = 12;
151
+ while (offset + HEADER_BYTES <= head.length) {
152
+ const id = head.toString("latin1", offset, offset + 4);
153
+ const size = head.readUInt32LE(offset + 4);
154
+ if (size <= 0) {
155
+ return null;
156
+ }
157
+ if (id === "LIST") {
158
+ // Descend: list type follows the header, then its own chunks.
159
+ const listType = head.toString("latin1", offset + 8, offset + 12);
160
+ if (listType === "hdrl") {
161
+ let inner = offset + 12;
162
+ while (inner + HEADER_BYTES <= Math.min(head.length, offset + 8 + size)) {
163
+ const innerId = head.toString("latin1", inner, inner + 4);
164
+ const innerSize = head.readUInt32LE(inner + 4);
165
+ if (innerSize <= 0) {
166
+ return null;
167
+ }
168
+ if (innerId === "avih" && inner + 8 + 4 <= head.length) {
169
+ return { microsecondsPerFrame: head.readUInt32LE(inner + 8) };
170
+ }
171
+ inner += HEADER_BYTES + innerSize + (innerSize % 2);
172
+ }
173
+ }
174
+ offset += HEADER_BYTES + 4 + (size - 4) + ((size - 4) % 2);
175
+ continue;
176
+ }
177
+ offset += HEADER_BYTES + size + (size % 2);
178
+ }
179
+ return null;
180
+ }
181
+
182
+ /**
183
+ * Step over top-level chunks to find `idx1`.
184
+ *
185
+ * @param {(start: number, end: number) => Promise<Buffer | null>} readRange
186
+ * @param {number} fileSize
187
+ * @returns {Promise<{ offset: number, size: number } | null>}
188
+ */
189
+ async function findIdx1(readRange, fileSize) {
190
+ let offset = 12; // Past "RIFF" size "AVI ".
191
+ while (offset + HEADER_BYTES < fileSize) {
192
+ const probe = await readRange(offset, Math.min(fileSize - 1, offset + HEADER_BYTES - 1));
193
+ if (!probe || probe.length < HEADER_BYTES) {
194
+ return null;
195
+ }
196
+ const id = probe.toString("latin1", 0, 4);
197
+ const size = probe.readUInt32LE(4);
198
+ if (size <= 0) {
199
+ return null;
200
+ }
201
+ if (id === "idx1") {
202
+ return { offset: offset + HEADER_BYTES, size };
203
+ }
204
+ // Chunks are word-aligned; a LIST carries its type inside the payload, so
205
+ // the same size arithmetic covers both cases.
206
+ offset += HEADER_BYTES + size + (size % 2);
207
+ }
208
+ return null;
209
+ }
210
+
211
+ /**
212
+ * Read the keyframe times of an AVI file.
213
+ *
214
+ * @param {(start: number, end: number) => Promise<Buffer | null>} readRange
215
+ * @param {number} fileSize
216
+ * @returns {Promise<number[] | null>} Ascending seconds, or null when the file
217
+ * has no `idx1` (OpenDML-only index, interrupted write, damaged upload).
218
+ */
219
+ async function readAviKeyframeTimes(readRange, fileSize) {
220
+ const head = await readRange(0, Math.min(PROBE_BYTES - 1, fileSize - 1));
221
+ if (!head || !isAvi(head)) {
222
+ return null;
223
+ }
224
+ const mainHeader = readMainHeader(head);
225
+ if (!mainHeader || !mainHeader.microsecondsPerFrame) {
226
+ return null;
227
+ }
228
+
229
+ const idx1 = await findIdx1(readRange, fileSize);
230
+ if (!idx1 || idx1.size > MAX_IDX1_BYTES) {
231
+ return null;
232
+ }
233
+
234
+ const table = await readRange(idx1.offset, Math.min(fileSize - 1, idx1.offset + idx1.size - 1));
235
+ if (!table || table.length < IDX1_ENTRY_BYTES) {
236
+ return null;
237
+ }
238
+
239
+ const secondsPerFrame = mainHeader.microsecondsPerFrame / 1e6;
240
+ const times = [];
241
+ let videoFrame = 0;
242
+ for (let at = 0; at + IDX1_ENTRY_BYTES <= table.length; at += IDX1_ENTRY_BYTES) {
243
+ const chunkId = table.toString("latin1", at, at + 4);
244
+ // Video chunks are "##db" (uncompressed) or "##dc" (compressed); audio is
245
+ // "##wb" and must not advance the frame counter.
246
+ const isVideo = chunkId.endsWith("db") || chunkId.endsWith("dc");
247
+ if (!isVideo) {
248
+ continue;
249
+ }
250
+ const flags = table.readUInt32LE(at + 4);
251
+ if ((flags & KEYFRAME_FLAG) !== 0) {
252
+ times.push(videoFrame * secondsPerFrame);
253
+ }
254
+ videoFrame += 1;
255
+ }
256
+ if (times.length === 0) {
257
+ return null;
258
+ }
259
+ // AVI names a keyframe by its FRAME NUMBER, and the time above is that number
260
+ // multiplied by the frame duration the header declares. The frames are the
261
+ // right ones — measured 2026-08-21 against the files themselves, 1196 index
262
+ // entries against 1196 real keyframes and 901 against 901, exactly — but the
263
+ // names are 10-44 ms away from the presentation times the demuxer computes,
264
+ // always under one frame. So the caller is told how far a time here may be
265
+ // from the instant it refers to, and can ask for a seek late enough that it
266
+ // still lands on the frame rather than on the one before it.
267
+ return { times, tolerance: secondsPerFrame };
268
+ }