@torrent-tv/proxy 2.95.13 → 2.95.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.95.13",
3
+ "version": "2.95.14",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -19,9 +19,12 @@ export function handleEncodeInputGet(req, reply, { inputOf }) {
19
19
  }
20
20
  const range = parseRange(req.headers.range, length);
21
21
  if (!range || range.start >= length) return reply.code(416).header("Content-Range", `bytes */${length}`).send();
22
- // Older FFmpeg versions request through EOF. A finite partial response keeps
23
- // each read within the admitted lookahead, without version-specific options.
24
- range.end = Math.min(range.end, range.start + 1048575);
22
+ // The answer runs to the end of the admitted range the read starts in, which
23
+ // is what keeps FFmpeg inside the bytes the run holds. A shorter answer is
24
+ // not continued by every FFmpeg: 6.1 (ffmpeg-static) takes it for the end of
25
+ // the input and stops, where 8.1 sends a new request (http.c, EAGAIN at the
26
+ // end of a content range). A cap of 1 MiB here ended every original-source
27
+ // segment longer than that on 6.1 (torrent-tv/meta#151).
25
28
  const held = input.read(fileIndex, range.start, range.end, true);
26
29
  if (!held) return reply.code(503).send({ error: "Original input bytes are outside the admitted ranges." });
27
30
  return reply.code(206).header("Content-Length", String(held.bytes.length))
package/server.js CHANGED
@@ -561,6 +561,7 @@ export async function startProxyServer({
561
561
  return {
562
562
  times: index?.times ?? null,
563
563
  tolerance: index?.tolerance ?? 0,
564
+ copyable: index?.copyable,
564
565
  // Which container answered, whether or not it produced a table: the
565
566
  // refusal that follows names it, and a measurement of how often an
566
567
  // index disagrees with its own file cannot be read without it.
@@ -596,14 +597,19 @@ export async function startProxyServer({
596
597
  mode: type === "video" ? spec.encode ? "transcode" : "copy" : spec.transcode ? "transcode" : "copy" });
597
598
  selected.set(spec.fileIndex, list);
598
599
  }
599
- const sources = [];
600
+ // Every selected file is read the same way in one run: from the original
601
+ // file when each container can name the bytes the interval needs, from
602
+ // reassembled packets otherwise.
603
+ const files = [];
600
604
  for (const [fileIndex, choices] of selected) {
601
605
  const params = await containerOver({ sourceKey: output.file.sourceKey, fileIndex });
602
606
  if (!params) return { kind: "needs-source" };
603
607
  const tracks = await containerOrchestrator.inspect(params, "tracks");
604
608
  if (tracks.kind !== "result") return tracks;
605
609
  const container = containerOrchestrator.known(params.sourceKey, fileIndex);
606
- if (typeof container?.readPacketIndex !== "function") return { kind: "needs-index", reason: "packet-index-not-yet-available" };
610
+ if (typeof container?.readPacketIndex !== "function" && typeof container?.readSourceRanges !== "function") {
611
+ return { kind: "needs-index", reason: "packet-index-not-yet-available" };
612
+ }
607
613
  const modes = new Map(), wanted = [];
608
614
  for (const choice of choices) {
609
615
  // The picture is the first usable video track (torrent-tv/meta#49);
@@ -622,7 +628,12 @@ export async function startProxyServer({
622
628
  modes: Object.fromEntries(wanted.map(track => [track.trackNumber, modes.get(track)])) };
623
629
  const navigation = await containerOrchestrator.inspect(params, "source-navigation");
624
630
  if (navigation.kind !== "result") return navigation;
625
- if (navigation.value && selected.size === 1 && output.segmentFormat.supportsOriginalInput === true) {
631
+ files.push({ fileIndex, choices, params, wanted, modes, timeShiftSeconds, sourceInterval, navigable: navigation.value === true });
632
+ }
633
+ const original = output.segmentFormat.supportsOriginalInput === true && files.every(file => file.navigable);
634
+ const sources = [];
635
+ for (const { fileIndex, choices, params, wanted, modes, timeShiftSeconds, sourceInterval } of files) {
636
+ if (original) {
626
637
  const ranges = await containerOrchestrator.inspect(params, "source-ranges");
627
638
  if (ranges.kind !== "result") return ranges;
628
639
  sources.push({ sourceKey: params.sourceKey, fileIndex, timeShiftSeconds,
@@ -468,6 +468,16 @@ export class OutputOpening {
468
468
  "re-encoded instead and its keyframes are placed on our own cuts"
469
469
  );
470
470
  }
471
+ } else if (!keyframes.copyable) {
472
+ // The container states when each picture is decoded and not when it is
473
+ // shown, and this picture may show them in another order: a copy would
474
+ // carry the decoding times and play its pictures out of order. A
475
+ // re-encode decodes them in their own order and stamps them itself.
476
+ transcodeVideo = true;
477
+ this.#host.logger.warn(
478
+ `transcode: the ${keyframes.format} container states no presentation times for "${logName}" and its ` +
479
+ "picture may reorder, so a copy would play out of order — the video is re-encoded instead"
480
+ );
471
481
  }
472
482
  }
473
483
  this.#host.logger.info(
@@ -107,6 +107,7 @@ export class KeyframeTables {
107
107
  table.learn({
108
108
  times: Array.isArray(reading?.times) ? reading.times : null,
109
109
  tolerance: reading?.tolerance,
110
+ copyable: reading?.copyable,
110
111
  // Which container answered, whether or not it produced a table: the
111
112
  // refusal that follows names it, and "unknown" would make that line
112
113
  // say nothing about the file it is refusing.
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * @file AVI container — RIFF.
3
3
  *
4
- * Stream declarations and exact packet addresses from idx1 or OpenDML indexes.
4
+ * Stream declarations, and from the index alone (`idx1` or OpenDML) the
5
+ * keyframes and the original bytes a run needs: FFmpeg reads an indexed AVI
6
+ * from the file itself (`avi-index.js`, torrent-tv/meta#151). Packets are
7
+ * reassembled only for an AVI that states no index.
5
8
  */
6
9
 
7
10
  import { Container } from "./Container.js";
@@ -9,15 +12,16 @@ import { VideoTrack } from "../tracks/VideoTrack.js";
9
12
  import { AudioTrack } from "../tracks/AudioTrack.js";
10
13
  import { ContainerTrack } from "../tracks/ContainerTrack.js";
11
14
  import { PacketIndex } from "./PacketIndex.js";
12
- import { openDmlPackets } from "./avi-open-dml.js";
15
+ import { readAviIndex } from "./avi-index.js";
13
16
  import { isUnavailable } from "./unavailable.js";
14
- import { IndexMemoryUnavailable } from "./memory-unavailable.js";
15
17
  import { Mpeg4PictureTiming } from "./mpeg4-picture-timing.js";
16
18
  import { MpegElementaryIndex } from "./mpeg-elementary-index.js";
17
19
  import { RetainedReads } from "./RetainedReads.js";
18
20
  import { OutsideReadableEdges, edgeReader, emptyWorkTags, text, textList, yearOf } from "./work-tags.js";
19
21
 
20
22
  const MPEG4_CODECS = new Set(["FMP4", "XVID", "DIVX", "DX50", "MP4V", "M4S2", "MP4S"]);
23
+ /** Picture codecs whose every picture stands alone, so decoding order is showing order. */
24
+ const INTRA_PICTURE_CODECS = new Set(["MJPG", "JPEG", "DIB ", "\0\0\0\0", "I420", "YV12", "YUY2", "UYVY"]);
21
25
  const MPEG_AUDIO_CODECS = new Set(["mp1", "mp2", "mp3"]);
22
26
  const NAL_CODECS = new Map([["H264", "h264"], ["X264", "h264"], ["AVC1", "h264"], ["HEVC", "hevc"], ["H265", "hevc"]]);
23
27
 
@@ -26,9 +30,11 @@ export class AviContainer extends Container {
26
30
  #tracks = null;
27
31
  #packets = null;
28
32
  #scan = null;
29
- #indexed = null;
30
33
  #declarations = null;
31
34
  #workTags = null;
35
+ /** The index alone: undefined until read, null when the file states none. */
36
+ #index = undefined;
37
+ #layout = null;
32
38
 
33
39
  async #readHeaders() {
34
40
  if (this.#headers) return this.#headers;
@@ -110,24 +116,83 @@ export class AviContainer extends Container {
110
116
  }
111
117
 
112
118
 
113
- packetIndexBytes() { return (this.#packets ?? this.#scan?.index ?? this.#indexed?.index)?.allocatedBytes() ?? 0; }
119
+ /** The first RIFF's `movi` list and `idx1` chunk. */
120
+ async #topLevel() {
121
+ if (this.#layout) return this.#layout;
122
+ const header = await this.readRange(0, 11);
123
+ const chunks = await riffChunks(this.readRange, 12, 8 + header.readUInt32LE(4));
124
+ const movi = chunks.find(chunk => chunk.id === "LIST" && chunk.type === "movi");
125
+ if (!movi) throw new Error("AVI media list is absent.");
126
+ this.#layout = { movi, idx1: chunks.find(chunk => chunk.id === "idx1") ?? null };
127
+ return this.#layout;
128
+ }
114
129
 
115
- static detect(head) {
116
- return isAvi(head);
130
+ /**
131
+ * What the index states, read once; null when the file states no index.
132
+ * A shortage of bytes or memory is thrown and nothing is kept, so the next
133
+ * read starts again from the index (the index is small and read in requests).
134
+ */
135
+ async #readIndex() {
136
+ if (this.#index !== undefined) return this.#index;
137
+ const tracks = await this.readTracks();
138
+ const { streams } = await this.#readHeaders();
139
+ const { movi, idx1 } = await this.#topLevel();
140
+ const record = { dispose: () => this.#index?.dispose?.() };
141
+ const allocation = this.packetMemory?.forRecord?.(record, "index") ?? this.packetMemory;
142
+ const index = await readAviIndex({ read: this.readRange, fileSize: this.fileSize, movi, idx1, allocation,
143
+ streams: tracks.map(track => ({ type: track.type, timeBase: track.timeBase, sampleSize: track.sampleSize,
144
+ startTimeSeconds: track.startTimeSeconds, indexChunks: streams[track.trackNumber].indexChunks })) });
145
+ this.#index = index;
146
+ return index;
117
147
  }
118
148
 
149
+ async supportsOriginalSourceRanges() {
150
+ const index = await this.#readIndex();
151
+ if (!index) return false;
152
+ const picture = ContainerTrack.firstUsable(await this.readTracks(), "video");
153
+ return !picture || index.keyframeTimes(picture.trackNumber).length > 0;
154
+ }
155
+
156
+ async readMappedSourceRanges(interval) { return this.readSourceRanges(interval); }
157
+
119
158
  /**
120
- * The keyframe times this container's own index states, in ascending seconds.
121
- *
122
- * Static so a caller that has bytes and no container can ask; the instance
123
- * form is {@link Container#readKeyframeIndex}.
124
- *
125
- * @param {(start:number,end:number)=>Promise<Buffer|null>} readRange
126
- * @param {number} fileSize
127
- * @returns {Promise<number[]|null>} Null where the container has no index.
159
+ * The bytes FFmpeg reads to produce `[from, to)` from the original file:
160
+ * the declarations and the start of `movi` it probes, the index it loads at
161
+ * open (`idx1` to the end of the file, or every OpenDML standard index), the
162
+ * first packet of each stream it inspects, and the selected streams' packets
163
+ * around the interval. Each range is held one MiB past its last named byte,
164
+ * because FFmpeg's input buffer reads ahead of the packet it is parsing.
128
165
  */
129
- static readKeyframeTimes(readRange, fileSize) {
130
- return readAviKeyframeTimes(readRange, fileSize);
166
+ async readSourceRanges(interval) {
167
+ if (!Number.isFinite(interval?.from) || !Number.isFinite(interval?.to) || interval.to <= interval.from) {
168
+ throw new TypeError("Source ranges require a finite increasing interval.");
169
+ }
170
+ const index = await this.#readIndex();
171
+ if (!index) return { kind: "needs-index", reason: "source-has-no-avi-index" };
172
+ const tracks = await this.readTracks();
173
+ const { movi } = await this.#topLevel();
174
+ const requested = tracks.filter(track => ["video", "audio"].includes(track.type) &&
175
+ (!interval.trackIds?.length || interval.trackIds.includes(track.trackNumber)));
176
+ const picture = ContainerTrack.firstUsable(tracks, "video");
177
+ const requestBytes = 1024 * 1024;
178
+ const padded = ([start, end]) => [Math.max(0, start), Math.min(this.fileSize - 1, end + requestBytes)];
179
+ const ranges = [[0, movi.start + 3], ...index.indexRanges, ...index.firstPackets(),
180
+ ...index.mediaRanges({ from: interval.from, to: interval.to, picture: picture?.trackNumber ?? null,
181
+ streams: requested.map(track => track.trackNumber) })].map(padded);
182
+ ranges.sort((left, right) => left[0] - right[0]);
183
+ const union = [];
184
+ for (const range of ranges) {
185
+ const previous = union.at(-1);
186
+ if (previous && range[0] <= previous[1] + 1) previous[1] = Math.max(previous[1], range[1]);
187
+ else union.push([...range]);
188
+ }
189
+ return { kind: "result", from: interval.from, to: interval.to, ranges: union, fileLength: this.fileSize };
190
+ }
191
+
192
+ packetIndexBytes() { return (this.#packets ?? this.#scan?.index)?.allocatedBytes() ?? 0; }
193
+
194
+ static detect(head) {
195
+ return isAvi(head);
131
196
  }
132
197
 
133
198
  async readTracks() {
@@ -206,152 +271,46 @@ export class AviContainer extends Container {
206
271
  }
207
272
 
208
273
  async parseKeyframeIndex() {
209
- const { streams } = await this.#readHeaders();
210
- const picture = (await this.readTracks()).find(track => track.type === "video");
211
- if (streams.some(stream => stream.indexChunks.length) || ["h264", "hevc"].includes(picture?.codecId) || MPEG4_CODECS.has(picture?.codecId.toUpperCase())) {
212
- const index = await this.readPacketIndex();
213
- return picture ? { times: index.keyframesOf(picture.trackNumber), tolerance: 0 } : null;
214
- }
215
- const r = await readAviKeyframeTimes(this.readRange, this.fileSize);
216
- if (!r) {
217
- return picture ? { times: (await this.readPacketIndex()).keyframesOf(picture.trackNumber), tolerance: 0 } : null;
218
- }
219
- if (Array.isArray(r)) return { times: r, tolerance: 0 };
220
- return r;
274
+ const picture = ContainerTrack.firstUsable(await this.readTracks(), "video");
275
+ if (!picture) return null;
276
+ // The index states every keyframe on the clock FFmpeg reads the file by,
277
+ // so no packet is read to learn them (torrent-tv/meta#151). A file with no
278
+ // index has its packets scanned instead.
279
+ // AVI states no presentation time, only each packet's place in decoding
280
+ // order. A picture whose codec may show its pictures in another order
281
+ // (B-pictures) is therefore not copied: FFmpeg stamps a copy with the
282
+ // decoding times and the pictures lose their order — measured on an H.264
283
+ // AVI with B-pictures, where a whole-file copy to MP4 decodes to other
284
+ // pictures than the source. Reading the order from the bitstream is the
285
+ // parsing the original-file path exists to avoid (torrent-tv/meta#151).
286
+ const copyable = INTRA_PICTURE_CODECS.has(picture.codecId.toUpperCase());
287
+ const index = await this.#readIndex();
288
+ if (index) return { times: index.keyframeTimes(picture.trackNumber), tolerance: 0, copyable };
289
+ return { times: (await this.readPacketIndex()).keyframesOf(picture.trackNumber), tolerance: 0, copyable };
221
290
  }
222
291
 
223
292
  async readPacketIndex(interval) {
224
293
  if (this.#packets) return this.#packets;
225
294
  if (this.#scan) return this.#readUnindexed(interval);
226
- if (this.#indexed) return this.#readIndexed();
295
+ // A file with an index is read by FFmpeg from the original bytes; packets
296
+ // are reassembled only where no index states them (torrent-tv/meta#151).
297
+ if (await this.#readIndex()) throw new Error("AVI with an index is read from the original file, not from reassembled packets.");
227
298
  const tracks = await this.readTracks();
228
299
  const { streams } = await this.#readHeaders();
229
- if (streams.some(stream => stream.indexChunks.length)) {
230
- const index = new PacketIndex({ packetMemory: this.packetMemory, deferMemory: true });
231
- const timings = new Map();
232
- try {
233
- for (const track of tracks.filter(track => ["video", "audio"].includes(track.type))) {
234
- if (!(track.timeBase > 0) || !streams[track.trackNumber].indexChunks.length) throw new Error("AVI OpenDML stream index is incomplete.");
235
- index.declareTrack(track.trackNumber, { type: track.type, codecId: track.codecId, codecRanges: streams[track.trackNumber].codecRanges });
236
- let pts = track.startTimeSeconds;
237
- for await (const packet of openDmlPackets({ readRange: this.readRange, fileSize: this.fileSize,
238
- indexes: streams[track.trackNumber].indexChunks, streamId: track.trackNumber })) {
239
- if (!packet.length) {
240
- if (track.type === "video") { index.extendLastPresentation(track.trackNumber, track.timeBase); pts += track.timeBase; }
241
- continue;
242
- }
243
- if (track.sampleSize > 0 && packet.length % track.sampleSize) throw new Error("AVI packet contains an incomplete fixed-size sample.");
244
- const duration = track.timeBase * (track.sampleSize > 0 ? packet.length / track.sampleSize : 1);
245
- await this.#appendPacket(index, timings, track, { pts, duration, keyframe: track.type !== "video" || packet.keyframe,
246
- ranges: [[packet.start, packet.start + packet.length - 1]] });
247
- pts += duration;
248
- index.flushPending();
249
- }
250
- timings.get(track.trackNumber)?.elementary?.complete();
251
- index.flushPending();
252
- index.complete(track.trackNumber);
253
- }
254
- this.#packets = index;
255
- return index;
256
- } catch (error) {
257
- index.dispose();
258
- throw error;
259
- }
260
- }
261
- const header = await this.readRange(0, 11);
262
- const chunks = await riffChunks(this.readRange, 12, 8 + header.readUInt32LE(4));
263
- const movi = chunks.find(chunk => chunk.id === "LIST" && chunk.type === "movi");
264
- const table = chunks.find(chunk => chunk.id === "idx1");
265
- if (!movi) throw new Error("AVI media list is absent.");
266
- if (!table) {
267
- const index = new PacketIndex({ packetMemory: this.packetMemory, deferMemory: true });
268
- const clocks = new Map();
269
- for (const track of tracks.filter(track => ["video", "audio"].includes(track.type))) {
270
- if (!(track.timeBase > 0)) throw new Error("AVI stream time base is invalid.");
271
- if (track.type === "video" && !["h264", "hevc"].includes(track.codecId) && !MPEG4_CODECS.has(track.codecId.toUpperCase()) && !["MJPG", "JPEG", "DIB ", "\0\0\0\0"].includes(track.codecId.toUpperCase())) {
272
- throw new Error("AVI video without an index requires elementary picture timing.");
273
- }
274
- if (track.type === "audio" && !(track.sampleSize > 0) && !MPEG_AUDIO_CODECS.has(track.codecId)) throw new Error("AVI variable-size audio requires elementary frame timing.");
275
- index.declareTrack(track.trackNumber, { type: track.type, codecId: track.codecId, codecRanges: streams[track.trackNumber].codecRanges });
276
- clocks.set(track.trackNumber, track.startTimeSeconds);
277
- }
278
- this.#scan = { index, tracks, clocks, timings: new Map(), at: 0,
279
- stack: [{ end: this.fileSize, next: this.fileSize }] };
280
- return this.#readUnindexed(interval);
281
- }
282
- if ((table.end - table.start) % 16 !== 0) throw new Error("AVI packet index entry is truncated.");
283
300
  const index = new PacketIndex({ packetMemory: this.packetMemory, deferMemory: true });
284
301
  const clocks = new Map();
285
- const timings = new Map();
286
- if (tracks.some(track => ["video", "audio", "subtitle"].includes(track.type) && !(track.timeBase > 0))) {
287
- throw new Error("AVI stream time base is invalid.");
288
- }
289
- for (const track of tracks) {
290
- if (!["video", "audio", "subtitle"].includes(track.type)) continue;
302
+ for (const track of tracks.filter(track => ["video", "audio"].includes(track.type))) {
303
+ if (!(track.timeBase > 0)) throw new Error("AVI stream time base is invalid.");
304
+ if (track.type === "video" && !["h264", "hevc"].includes(track.codecId) && !MPEG4_CODECS.has(track.codecId.toUpperCase()) && !INTRA_PICTURE_CODECS.has(track.codecId.toUpperCase())) {
305
+ throw new Error("AVI video without an index requires elementary picture timing.");
306
+ }
307
+ if (track.type === "audio" && !(track.sampleSize > 0) && !MPEG_AUDIO_CODECS.has(track.codecId)) throw new Error("AVI variable-size audio requires elementary frame timing.");
291
308
  index.declareTrack(track.trackNumber, { type: track.type, codecId: track.codecId, codecRanges: streams[track.trackNumber].codecRanges });
292
309
  clocks.set(track.trackNumber, track.startTimeSeconds);
293
310
  }
294
- this.#indexed = { index, clocks, timings, tracks, movi, table, offsetBase: null, at: table.start };
295
- return this.#readIndexed();
296
- }
297
-
298
- async #readIndexed() {
299
- const state = this.#indexed;
300
- const { index, clocks, timings, tracks, movi, table } = state;
301
- try {
302
- index.flushPending();
303
- for (; state.at < table.end;) {
304
- const at = state.at;
305
- const entry = await this.readRange(at, at + 15);
306
- const chunkId = entry.toString("ascii", 0, 4);
307
- if (!/^[0-9]{2}(db|dc|wb)$/.test(chunkId)) { state.at += 16; continue; }
308
- const id = Number(chunkId.slice(0, 2));
309
- const track = tracks.find(track => track.trackNumber === id);
310
- if (!track || !clocks.has(id)) throw new Error("AVI packet refers to an undeclared stream.");
311
- const flags = entry.readUInt32LE(4);
312
- const offset = entry.readUInt32LE(8);
313
- const length = entry.readUInt32LE(12);
314
- if (length === 0) {
315
- if (track.type === "video") { index.extendLastPresentation(id, track.timeBase); clocks.set(id, clocks.get(id) + track.timeBase); }
316
- state.at += 16;
317
- continue;
318
- }
319
- if (state.offsetBase === null) {
320
- for (const base of [movi.start, 0, movi.start + 4]) {
321
- const address = base + offset;
322
- if (address < movi.start + 4 || address + 8 + length > movi.end) continue;
323
- const probe = await this.readRange(address, address + 7);
324
- if (probe.toString("ascii", 0, 4) === chunkId && probe.readUInt32LE(4) === length) { state.offsetBase = base; break; }
325
- }
326
- if (state.offsetBase === null) throw new Error("AVI packet index has no valid offset base.");
327
- }
328
- const address = state.offsetBase + offset;
329
- if (address < movi.start + 4 || address + 8 + length > movi.end) throw new Error("AVI indexed packet exceeds its media list.");
330
- const probe = await this.readRange(address, address + 7);
331
- if (probe.toString("ascii", 0, 4) !== chunkId || probe.readUInt32LE(4) !== length) throw new Error("AVI packet index disagrees with its chunk header.");
332
- if (track.sampleSize > 0 && length % track.sampleSize !== 0) throw new Error("AVI packet contains an incomplete fixed-size sample.");
333
- const duration = track.timeBase * (track.sampleSize > 0 ? length / track.sampleSize : 1);
334
- const pts = clocks.get(id);
335
- await this.#appendPacket(index, timings, track, { pts, duration, keyframe: track.type !== "video" || chunkId.endsWith("db") || !!(flags & 0x10),
336
- ranges: [[address + 8, address + 8 + length - 1]] });
337
- clocks.set(id, pts + duration);
338
- // The pending facts own this entry even if their allocation must wait.
339
- state.at += 16;
340
- index.flushPending();
341
- }
342
- for (const timing of timings.values()) timing.elementary?.complete();
343
- index.flushPending();
344
- for (const id of clocks.keys()) index.complete(id);
345
- this.#packets = index;
346
- this.#indexed = null;
347
- return index;
348
- } catch (error) {
349
- if (!isUnavailable(error) && !(error instanceof IndexMemoryUnavailable)) {
350
- index.dispose();
351
- this.#indexed = null;
352
- }
353
- throw error;
354
- }
311
+ this.#scan = { index, tracks, clocks, timings: new Map(), at: 0,
312
+ stack: [{ end: this.fileSize, next: this.fileSize }] };
313
+ return this.#readUnindexed(interval);
355
314
  }
356
315
 
357
316
  async #readUnindexed(interval) {
@@ -470,34 +429,6 @@ function waveCodec(tag) {
470
429
  [0xff, "aac"], [0x160, "wmav1"], [0x161, "wmav2"], [0x2000, "ac3"], [0x2001, "dts"]]).get(tag) ?? `wave:${tag}`;
471
430
  }
472
431
 
473
- // ---------------------------------------------------------------------------
474
- // RIFF speaking about AVI: the idx1 index and its keyframe flag.
475
- // Here because the class is the only way in.
476
- // ---------------------------------------------------------------------------
477
- /**
478
- * @file Keyframe index for AVI, read without downloading the file.
479
- *
480
- * AVI ends with an `idx1` chunk: one fixed-size entry per stream chunk, each
481
- * carrying a flags word whose keyframe bit says whether that chunk starts a
482
- * keyframe. Frame number times the video stream's frame duration gives the
483
- * time, so the index alone is enough — no media has to be read.
484
- *
485
- * `idx1` lives at the end of the file and the top-level chunk headers state
486
- * their sizes, so it is reached by stepping over headers (typically two hops:
487
- * `LIST hdrl`, `LIST movi`), not by scanning.
488
- *
489
- * Still relevant despite the format's age: older releases are largely XviD in
490
- * AVI, and those are exactly the files that get copied rather than re-encoded.
491
- */
492
-
493
- const HEADER_BYTES = 8;
494
- const PROBE_BYTES = 4096;
495
- // Keyframe flag in an idx1 entry's flags word (AVIIF_KEYFRAME).
496
- const KEYFRAME_FLAG = 0x10;
497
- const IDX1_ENTRY_BYTES = 16;
498
- // Cap on the idx1 read. One entry per chunk, 16 bytes each — a long film runs
499
- // to a few MB; beyond this is not a normal index.
500
- const MAX_IDX1_BYTES = 64 * 1024 * 1024;
501
432
 
502
433
  /**
503
434
  * Whether this looks like AVI: a RIFF container whose form type is `AVI `.
@@ -513,131 +444,5 @@ function isAvi(head) {
513
444
  );
514
445
  }
515
446
 
516
- /**
517
- * Microseconds per frame and the video stream's chunk id prefix, from the main
518
- * header. Both live in the `hdrl` list near the file start.
519
- *
520
- * @param {Buffer} head
521
- * @returns {{ microsecondsPerFrame: number } | null}
522
- */
523
- function readMainHeader(head) {
524
- // Top-level: "RIFF" size "AVI " then chunks. `avih` sits inside `LIST hdrl`.
525
- let offset = 12;
526
- while (offset + HEADER_BYTES <= head.length) {
527
- const id = head.toString("latin1", offset, offset + 4);
528
- const size = head.readUInt32LE(offset + 4);
529
- if (size <= 0) {
530
- return null;
531
- }
532
- if (id === "LIST") {
533
- // Descend: list type follows the header, then its own chunks.
534
- const listType = head.toString("latin1", offset + 8, offset + 12);
535
- if (listType === "hdrl") {
536
- let inner = offset + 12;
537
- while (inner + HEADER_BYTES <= Math.min(head.length, offset + 8 + size)) {
538
- const innerId = head.toString("latin1", inner, inner + 4);
539
- const innerSize = head.readUInt32LE(inner + 4);
540
- if (innerSize <= 0) {
541
- return null;
542
- }
543
- if (innerId === "avih" && inner + 8 + 4 <= head.length) {
544
- return { microsecondsPerFrame: head.readUInt32LE(inner + 8) };
545
- }
546
- inner += HEADER_BYTES + innerSize + (innerSize % 2);
547
- }
548
- }
549
- offset += HEADER_BYTES + 4 + (size - 4) + ((size - 4) % 2);
550
- continue;
551
- }
552
- offset += HEADER_BYTES + size + (size % 2);
553
- }
554
- return null;
555
- }
556
447
 
557
- /**
558
- * Step over top-level chunks to find `idx1`.
559
- *
560
- * @param {(start: number, end: number) => Promise<Buffer | null>} readRange
561
- * @param {number} fileSize
562
- * @returns {Promise<{ offset: number, size: number } | null>}
563
- */
564
- async function findIdx1(readRange, fileSize) {
565
- let offset = 12; // Past "RIFF" size "AVI ".
566
- while (offset + HEADER_BYTES < fileSize) {
567
- const probe = await readRange(offset, Math.min(fileSize - 1, offset + HEADER_BYTES - 1));
568
- if (!probe || probe.length < HEADER_BYTES) {
569
- return null;
570
- }
571
- const id = probe.toString("latin1", 0, 4);
572
- const size = probe.readUInt32LE(4);
573
- if (size <= 0) {
574
- return null;
575
- }
576
- if (id === "idx1") {
577
- return { offset: offset + HEADER_BYTES, size };
578
- }
579
- // Chunks are word-aligned; a LIST carries its type inside the payload, so
580
- // the same size arithmetic covers both cases.
581
- offset += HEADER_BYTES + size + (size % 2);
582
- }
583
- return null;
584
- }
585
448
 
586
- /**
587
- * Read the keyframe times of an AVI file.
588
- *
589
- * @param {(start: number, end: number) => Promise<Buffer | null>} readRange
590
- * @param {number} fileSize
591
- * @returns {Promise<number[] | null>} Ascending seconds, or null when the file
592
- * has no `idx1` (OpenDML-only index, interrupted write, damaged upload).
593
- */
594
- async function readAviKeyframeTimes(readRange, fileSize) {
595
- const head = await readRange(0, Math.min(PROBE_BYTES - 1, fileSize - 1));
596
- if (!head || !isAvi(head)) {
597
- return null;
598
- }
599
- const mainHeader = readMainHeader(head);
600
- if (!mainHeader || !mainHeader.microsecondsPerFrame) {
601
- return null;
602
- }
603
-
604
- const idx1 = await findIdx1(readRange, fileSize);
605
- if (!idx1 || idx1.size > MAX_IDX1_BYTES) {
606
- return null;
607
- }
608
-
609
- const table = await readRange(idx1.offset, Math.min(fileSize - 1, idx1.offset + idx1.size - 1));
610
- if (!table || table.length < IDX1_ENTRY_BYTES) {
611
- return null;
612
- }
613
-
614
- const secondsPerFrame = mainHeader.microsecondsPerFrame / 1e6;
615
- const times = [];
616
- let videoFrame = 0;
617
- for (let at = 0; at + IDX1_ENTRY_BYTES <= table.length; at += IDX1_ENTRY_BYTES) {
618
- const chunkId = table.toString("latin1", at, at + 4);
619
- // Video chunks are "##db" (uncompressed) or "##dc" (compressed); audio is
620
- // "##wb" and must not advance the frame counter.
621
- const isVideo = chunkId.endsWith("db") || chunkId.endsWith("dc");
622
- if (!isVideo) {
623
- continue;
624
- }
625
- const flags = table.readUInt32LE(at + 4);
626
- if ((flags & KEYFRAME_FLAG) !== 0) {
627
- times.push(videoFrame * secondsPerFrame);
628
- }
629
- videoFrame += 1;
630
- }
631
- if (times.length === 0) {
632
- return null;
633
- }
634
- // AVI names a keyframe by its FRAME NUMBER, and the time above is that number
635
- // multiplied by the frame duration the header declares. The frames are the
636
- // right ones — measured 2026-08-21 against the files themselves, 1196 index
637
- // entries against 1196 real keyframes and 901 against 901, exactly — but the
638
- // names are 10-44 ms away from the presentation times the demuxer computes,
639
- // always under one frame. So the caller is told how far a time here may be
640
- // from the instant it refers to, and can ask for a seek late enough that it
641
- // still lands on the frame rather than on the one before it.
642
- return { times, tolerance: secondsPerFrame };
643
- }
@@ -65,6 +65,9 @@ export class KeyframeTable {
65
65
  /** @type {boolean} */
66
66
  #answered = false;
67
67
 
68
+ /** @type {boolean} */
69
+ #copyable = true;
70
+
68
71
  /**
69
72
  * What copied pieces of this file have shown about the table above.
70
73
  *
@@ -141,7 +144,7 @@ export class KeyframeTable {
141
144
  }
142
145
 
143
146
  /**
144
- * Whether a picture of this file can be COPIED.
147
+ * Whether this file has a table of keyframe times.
145
148
  *
146
149
  * A copy can only be cut where a keyframe already is, so without a table
147
150
  * there is no honest grid: declaring an even one instead is a falsehood the
@@ -155,6 +158,18 @@ export class KeyframeTable {
155
158
  return this.#times !== null;
156
159
  }
157
160
 
161
+ /**
162
+ * Whether a copy of this file's picture keeps the order its pictures are
163
+ * shown in. False where the container states only decoding order and the
164
+ * picture's codec may reorder — an AVI holding H.264 (torrent-tv/meta#151).
165
+ * A container that says so is never overruled by a later reading.
166
+ *
167
+ * @returns {boolean}
168
+ */
169
+ get copyable() {
170
+ return this.#copyable;
171
+ }
172
+
158
173
  /**
159
174
  * Take in what a reader found. Called only by `KeyframeTables`.
160
175
  *
@@ -166,12 +181,13 @@ export class KeyframeTable {
166
181
  * all 570 in 0.8 s from the index. Whichever of them answers second, the
167
182
  * fuller answer is the one that stands.
168
183
  *
169
- * @param {{ times?: number[] | null, tolerance?: number, format?: string } | null} reading
184
+ * @param {{ times?: number[] | null, tolerance?: number, format?: string, copyable?: boolean } | null} reading
170
185
  * @returns {this}
171
186
  */
172
187
  learn(reading) {
173
188
  const wasAnswered = this.#answered;
174
189
  this.#answered = true;
190
+ if (reading?.copyable === false) this.#copyable = false;
175
191
  const times = Array.isArray(reading?.times) && reading.times.length > 0 ? reading.times : null;
176
192
  const format = typeof reading?.format === "string" && reading.format.length > 0 ? reading.format : "";
177
193
  const fuller = times !== null && (this.#times === null || times.length > this.#times.length);
@@ -0,0 +1,297 @@
1
+ /**
2
+ * @file What an AVI index states, read without reading the media it indexes.
3
+ *
4
+ * `idx1` and the OpenDML standard indexes (`ix##`, reached through each
5
+ * stream's `indx` super index) give, for every packet, its stream, the file
6
+ * position of its payload, its size and whether it is a keyframe. A packet's
7
+ * time follows from its place in its stream: FFmpeg's AVI demuxer advances a
8
+ * stream's clock by one unit per packet, or by the payload's byte count over
9
+ * the sample size where the stream declares one (`get_duration`, avidec.c).
10
+ * FFmpeg copies and decodes on exactly that clock, so these times are the
11
+ * times a run reading the original file will see.
12
+ *
13
+ * Nothing here reads a packet. The packet reassembly this replaces confirmed
14
+ * every index entry against the chunk header in `movi`, which made the
15
+ * keyframe table wait for nearly the whole film (torrent-tv/meta#151).
16
+ */
17
+
18
+ import { IndexMemoryUnavailable } from "./memory-unavailable.js";
19
+
20
+ const IDX1_ENTRY_BYTES = 16;
21
+ /** Index bytes read per request; one request's worth of the proxy's input route. */
22
+ const READ_BYTES = 1024 * 1024;
23
+ /** Bytes held per packet: position (8), size (4), time (8), keyframe flag (1). */
24
+ const ENTRY_BYTES = 21;
25
+ const AVIIF_KEYFRAME = 0x10;
26
+
27
+ /** One stream's packets in file order, in typed arrays. */
28
+ class StreamPackets {
29
+ constructor(count) {
30
+ this.count = 0;
31
+ this.starts = new Float64Array(count);
32
+ this.lengths = new Uint32Array(count);
33
+ this.times = new Float64Array(count);
34
+ this.keyframes = new Uint8Array(count);
35
+ }
36
+
37
+ push(start, length, time, keyframe) {
38
+ const at = this.count++;
39
+ this.starts[at] = start;
40
+ this.lengths[at] = length;
41
+ this.times[at] = time;
42
+ this.keyframes[at] = keyframe ? 1 : 0;
43
+ }
44
+
45
+ /** The last packet whose time is at or before `seconds`, or 0. */
46
+ atOrBefore(seconds) {
47
+ let low = 0, high = this.count;
48
+ while (low < high) {
49
+ const middle = (low + high) >> 1;
50
+ if (this.times[middle] <= seconds) low = middle + 1;
51
+ else high = middle;
52
+ }
53
+ return Math.max(0, low - 1);
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Read the index of one AVI.
59
+ *
60
+ * @param {object} params
61
+ * @param {(start: number, end: number) => Promise<Buffer>} params.read - Strict byte read.
62
+ * @param {number} params.fileSize
63
+ * @param {{ start: number, end: number }} params.movi - The first `movi` list (`start` at its type).
64
+ * @param {{ start: number, end: number } | null} params.idx1 - The `idx1` chunk's payload, if any.
65
+ * @param {Array<{ type: string, timeBase: number | null, sampleSize: number, startTimeSeconds: number | null,
66
+ * indexChunks: Array<{ start: number, end: number }> }>} params.streams - In stream-number order.
67
+ * @param {{ reserve?: (bytes: number) => boolean, release?: (bytes: number) => void }} params.allocation
68
+ * @returns {Promise<AviIndex | null>} Null when the file states no index.
69
+ */
70
+ export async function readAviIndex({ read, fileSize, movi, idx1, streams, allocation }) {
71
+ const useOpenDml = streams.some(stream => stream.indexChunks.length);
72
+ if (!useOpenDml && !idx1) return null;
73
+ if (streams.some(stream => ["video", "audio"].includes(stream.type) && !(stream.timeBase > 0))) {
74
+ throw new Error("AVI stream time base is invalid.");
75
+ }
76
+ return useOpenDml
77
+ ? readOpenDml({ read, fileSize, streams, allocation })
78
+ : readIdx1({ read, fileSize, movi, idx1, streams, allocation });
79
+ }
80
+
81
+ /** Whole entries of `unit` bytes, about one request at a time. */
82
+ async function* blocks(read, start, end, unit) {
83
+ const step = Math.max(unit, Math.floor(READ_BYTES / unit) * unit);
84
+ for (let at = start; at < end; at += step) {
85
+ yield { at, bytes: await read(at, Math.min(end, at + step) - 1) };
86
+ }
87
+ }
88
+
89
+ function reserve(allocation, count) {
90
+ const bytes = count * ENTRY_BYTES;
91
+ if (allocation?.reserve?.(bytes) === false) throw new IndexMemoryUnavailable(bytes);
92
+ return bytes;
93
+ }
94
+
95
+ function durationOf(stream, length) {
96
+ return stream.timeBase * (stream.sampleSize > 0 ? length / stream.sampleSize : 1);
97
+ }
98
+
99
+ async function readIdx1({ read, fileSize, movi, idx1, streams, allocation }) {
100
+ if ((idx1.end - idx1.start) % IDX1_ENTRY_BYTES !== 0) throw new Error("AVI packet index entry is truncated.");
101
+ const counts = new Array(streams.length).fill(0);
102
+ let first = null;
103
+ // Entries are 16 bytes and blocks are a multiple of 16, so none straddles.
104
+ let anyKeyframe = false;
105
+ for await (const { at, bytes } of blocks(read, idx1.start, idx1.end, IDX1_ENTRY_BYTES)) {
106
+ for (let offset = 0; offset < bytes.length; offset += IDX1_ENTRY_BYTES) {
107
+ const id = streamOf(bytes.toString("ascii", offset, offset + 4), streams.length);
108
+ if (id === null) continue;
109
+ counts[id]++;
110
+ if (bytes.readUInt32LE(offset + 4) & AVIIF_KEYFRAME) anyKeyframe = true;
111
+ if (first === null && bytes.readUInt32LE(offset + 12) > 0) {
112
+ first = { chunkId: bytes.toString("ascii", offset, offset + 4), offset: bytes.readUInt32LE(offset + 8), length: bytes.readUInt32LE(offset + 12), at: at + offset };
113
+ }
114
+ }
115
+ }
116
+ if (first === null) return null;
117
+ // Offsets count from the `movi` type or from the start of the file; the one
118
+ // that lands on the first entry's own chunk header is the file's convention.
119
+ let base = null;
120
+ for (const candidate of [movi.start, 0, movi.start + 4]) {
121
+ const address = candidate + first.offset;
122
+ if (address < movi.start + 4 || address + 8 + first.length > movi.end) continue;
123
+ const head = await read(address, address + 7);
124
+ if (head.toString("ascii", 0, 4) === first.chunkId && head.readUInt32LE(4) === first.length) { base = candidate; break; }
125
+ }
126
+ if (base === null) throw new Error("AVI packet index has no valid offset base.");
127
+ const held = reserve(allocation, counts.reduce((sum, count) => sum + count, 0));
128
+ try {
129
+ const packets = counts.map(count => new StreamPackets(count));
130
+ const clocks = streams.map(stream => stream.startTimeSeconds ?? 0);
131
+ for await (const { bytes } of blocks(read, idx1.start, idx1.end, IDX1_ENTRY_BYTES)) {
132
+ for (let offset = 0; offset < bytes.length; offset += IDX1_ENTRY_BYTES) {
133
+ const chunkId = bytes.toString("ascii", offset, offset + 4);
134
+ const id = streamOf(chunkId, streams.length);
135
+ if (id === null) continue;
136
+ const length = bytes.readUInt32LE(offset + 12);
137
+ const start = base + bytes.readUInt32LE(offset + 8) + 8;
138
+ if (length && (start < movi.start + 12 || start + length > movi.end)) throw new Error("AVI indexed packet exceeds its media list.");
139
+ // FFmpeg takes the index flag alone, and every entry as a keyframe
140
+ // when no entry carries it (avi_read_idx1).
141
+ const keyframe = !anyKeyframe || !!(bytes.readUInt32LE(offset + 4) & AVIIF_KEYFRAME);
142
+ packets[id].push(start, length, clocks[id], keyframe);
143
+ clocks[id] += durationOf(streams[id], length);
144
+ }
145
+ }
146
+ return new AviIndex({ packets, indexRanges: [[idx1.start - 8, fileSize - 1]], held, allocation });
147
+ } catch (error) {
148
+ allocation?.release?.(held);
149
+ throw error;
150
+ }
151
+ }
152
+
153
+ async function readOpenDml({ read, fileSize, streams, allocation }) {
154
+ const standards = [];
155
+ for (const [id, stream] of streams.entries()) {
156
+ for (const chunk of stream.indexChunks) {
157
+ const header = await read(chunk.start, chunk.start + 23);
158
+ if (header[3] !== 0 || header.readUInt16LE(0) !== 4) {
159
+ throw new Error("AVI OpenDML super index structure is invalid.");
160
+ }
161
+ const count = header.readUInt32LE(4);
162
+ if (count > Math.floor((chunk.end - chunk.start - 24) / 16)) throw new Error("AVI OpenDML index entry count exceeds its chunk.");
163
+ const table = count ? await read(chunk.start + 24, chunk.start + 23 + count * 16) : Buffer.alloc(0);
164
+ for (let at = 0; at < count; at++) {
165
+ const offset = Number(table.readBigUInt64LE(at * 16)), size = table.readUInt32LE(at * 16 + 8);
166
+ if (!offset && !size) continue;
167
+ if (!Number.isSafeInteger(offset) || size < 32 || offset + size > fileSize) throw new Error("AVI OpenDML child index exceeds the file.");
168
+ standards.push({ id, start: offset, end: offset + size });
169
+ }
170
+ }
171
+ }
172
+ // Headers first: the entry counts decide the allocation before any table is held.
173
+ const parsed = [];
174
+ for (const standard of standards) {
175
+ const header = await read(standard.start, standard.start + 31);
176
+ if (!/^ix[0-9]{2}$/.test(header.toString("ascii", 0, 4)) || header.readUInt32LE(4) + 8 !== standard.end - standard.start) {
177
+ throw new Error("AVI OpenDML child index size disagrees with its header.");
178
+ }
179
+ const words = header.readUInt16LE(8), subtype = header[10], type = header[11];
180
+ const count = header.readUInt32LE(12), chunkId = header.toString("ascii", 16, 20);
181
+ if (type !== 1 || !((subtype === 0 && words === 2) || (subtype === 1 && words === 3))) throw new Error("AVI OpenDML standard index structure is invalid.");
182
+ if (streamOf(chunkId, streams.length) !== standard.id) throw new Error("AVI OpenDML index refers to another stream.");
183
+ if (count > Math.floor((standard.end - standard.start - 32) / (words * 4))) throw new Error("AVI OpenDML index entry count exceeds its chunk.");
184
+ const base = Number(header.readBigUInt64LE(20));
185
+ if (!Number.isSafeInteger(base)) throw new Error("AVI OpenDML address exceeds exact integer precision.");
186
+ parsed.push({ ...standard, entryBytes: words * 4, count, base });
187
+ }
188
+ const counts = new Array(streams.length).fill(0);
189
+ for (const standard of parsed) counts[standard.id] += standard.count;
190
+ const held = reserve(allocation, counts.reduce((sum, count) => sum + count, 0));
191
+ try {
192
+ const packets = counts.map(count => new StreamPackets(count));
193
+ const clocks = streams.map(stream => stream.startTimeSeconds ?? 0);
194
+ for (const standard of parsed) {
195
+ const stream = streams[standard.id];
196
+ const tableStart = standard.start + 32;
197
+ const tableEnd = tableStart + standard.count * standard.entryBytes;
198
+ for await (const { bytes } of blocks(read, tableStart, tableEnd, standard.entryBytes)) {
199
+ for (let offset = 0; offset + standard.entryBytes <= bytes.length; offset += standard.entryBytes) {
200
+ const start = standard.base + bytes.readUInt32LE(offset);
201
+ const encoded = bytes.readUInt32LE(offset + 4), length = encoded & 0x7fffffff;
202
+ if (length && (start < 8 || start + length > fileSize)) throw new Error("AVI OpenDML packet exceeds the file.");
203
+ packets[standard.id].push(start, length, clocks[standard.id], !(encoded & 0x80000000));
204
+ clocks[standard.id] += durationOf(stream, length);
205
+ }
206
+ }
207
+ }
208
+ return new AviIndex({ packets, indexRanges: parsed.map(standard => [standard.start, standard.end - 1]), held, allocation });
209
+ } catch (error) {
210
+ allocation?.release?.(held);
211
+ throw error;
212
+ }
213
+ }
214
+
215
+ /** The stream an entry belongs to; entries of no declared stream are skipped, as FFmpeg does. */
216
+ function streamOf(chunkId, streamCount) {
217
+ if (!/^[0-9]{2}(db|dc|wb)$/.test(chunkId)) return null;
218
+ const id = Number(chunkId.slice(0, 2));
219
+ return id < streamCount ? id : null;
220
+ }
221
+
222
+ /** The packets an AVI index states, and the index bytes FFmpeg reads to learn them. */
223
+ export class AviIndex {
224
+ #held;
225
+ #allocation;
226
+
227
+ constructor({ packets, indexRanges, held, allocation }) {
228
+ this.packets = packets;
229
+ this.indexRanges = indexRanges;
230
+ this.#held = held;
231
+ this.#allocation = allocation;
232
+ }
233
+
234
+ /** Keyframe times of a stream, ascending. */
235
+ keyframeTimes(stream) {
236
+ const packets = this.packets[stream];
237
+ const times = [];
238
+ for (let at = 0; at < packets.count; at++) if (packets.keyframes[at] && packets.lengths[at]) times.push(packets.times[at]);
239
+ return times;
240
+ }
241
+
242
+ /**
243
+ * The payload bytes a reader of `[from, to)` needs from each stream.
244
+ *
245
+ * The picture is taken from the keyframe two keyframes before `from` — the
246
+ * same margin the Matroska reading keeps in Cue points — to the keyframe that
247
+ * follows the one after `to`. Every other selected stream is taken over the
248
+ * same span of time, so an interleave that places sound ahead of or behind
249
+ * its picture is covered in either direction.
250
+ *
251
+ * @param {{ from: number, to: number, picture: number | null, streams: number[] }} params
252
+ * @returns {Array<[number, number]>}
253
+ */
254
+ mediaRanges({ from, to, picture, streams }) {
255
+ let first = from, last = to;
256
+ if (picture !== null) {
257
+ const video = this.packets[picture];
258
+ const keys = [];
259
+ for (let at = 0; at < video.count; at++) if (video.keyframes[at] && video.lengths[at]) keys.push(at);
260
+ if (!keys.length) throw new Error("AVI picture index states no keyframe.");
261
+ const before = keys.findLastIndex(at => video.times[at] <= from);
262
+ const after = keys.findIndex(at => video.times[at] >= to);
263
+ first = video.times[keys[Math.max(0, before - 2)]];
264
+ last = after < 0 || after + 1 >= keys.length ? Infinity : video.times[keys[after + 1]];
265
+ }
266
+ const ranges = [];
267
+ for (const stream of streams) {
268
+ const packets = this.packets[stream];
269
+ if (!packets?.count) continue;
270
+ const start = packets.atOrBefore(first);
271
+ let end = packets.count - 1;
272
+ if (Number.isFinite(last)) end = Math.min(end, packets.atOrBefore(last));
273
+ let low = Infinity, high = -1;
274
+ for (let at = start; at <= end; at++) {
275
+ if (!packets.lengths[at]) continue;
276
+ low = Math.min(low, packets.starts[at] - 8);
277
+ high = Math.max(high, packets.starts[at] + packets.lengths[at] - 1);
278
+ }
279
+ if (high >= 0) ranges.push([low, high]);
280
+ }
281
+ return ranges;
282
+ }
283
+
284
+ /** Where each stream's first packet begins, header included. */
285
+ firstPackets() {
286
+ return this.packets.flatMap(packets => {
287
+ for (let at = 0; at < packets.count; at++) if (packets.lengths[at]) return [[packets.starts[at] - 8, packets.starts[at] + 7]];
288
+ return [];
289
+ });
290
+ }
291
+
292
+ dispose() {
293
+ if (!this.#held) return;
294
+ this.#allocation?.release?.(this.#held);
295
+ this.#held = 0;
296
+ }
297
+ }
@@ -1,47 +0,0 @@
1
- /** OpenDML indexes address stream payloads directly, including later RIFF AVIX forms. */
2
- export async function* openDmlPackets({ readRange, fileSize, indexes, streamId }) {
3
- const seen = new Set();
4
- for (const chunk of indexes) yield* entries(chunk, false);
5
-
6
- async function* entries(chunk, child) {
7
- if (seen.has(chunk.start)) throw new Error("AVI OpenDML repeats an index address.");
8
- seen.add(chunk.start);
9
- if (chunk.start < 0 || chunk.end > fileSize || chunk.end - chunk.start < 24) throw new Error("AVI OpenDML index header is truncated.");
10
- const header = await readRange(chunk.start, chunk.start + 23);
11
- const words = header.readUInt16LE(), subtype = header[2], type = header[3];
12
- const count = header.readUInt32LE(4), chunkId = header.toString("ascii", 8, 12);
13
- if (!/^[0-9]{2}(db|dc|wb)$/.test(chunkId) || Number(chunkId.slice(0, 2)) !== streamId) throw new Error("AVI OpenDML index refers to another stream.");
14
- const entryBytes = words * 4;
15
- if (!entryBytes || count > Math.floor((chunk.end - chunk.start - 24) / entryBytes)) throw new Error("AVI OpenDML index entry count exceeds its chunk.");
16
- if (type === 0) {
17
- if (child || words !== 4 || subtype !== 0) throw new Error("AVI OpenDML super index structure is invalid.");
18
- for (let at = 0; at < count; at++) {
19
- const entry = await readRange(chunk.start + 24 + at * 16, chunk.start + 39 + at * 16);
20
- const offset = safeOffset(entry.readBigUInt64LE()), size = entry.readUInt32LE(8);
21
- if (!offset && !size) continue;
22
- if (size < 32 || offset + size > fileSize) throw new Error("AVI OpenDML child index exceeds the file.");
23
- const prefix = await readRange(offset, offset + 7);
24
- if (!/^ix[0-9]{2}$/.test(prefix.toString("ascii", 0, 4)) || prefix.readUInt32LE(4) + 8 !== size) throw new Error("AVI OpenDML child index size disagrees with its header.");
25
- yield* entries({ start: offset + 8, end: offset + size }, true);
26
- }
27
- return;
28
- }
29
- if (type !== 1 || !((subtype === 0 && words === 2) || (subtype === 1 && words === 3))) throw new Error("AVI OpenDML standard index structure is invalid.");
30
- const base = safeOffset(header.readBigUInt64LE(12));
31
- for (let at = 0; at < count; at++) {
32
- const entry = await readRange(chunk.start + 24 + at * entryBytes, chunk.start + 23 + (at + 1) * entryBytes);
33
- const start = base + entry.readUInt32LE(), encodedSize = entry.readUInt32LE(4), length = encodedSize & 0x7fffffff;
34
- if (!Number.isSafeInteger(start) || start < 8 || start + length > fileSize) throw new Error("AVI OpenDML packet exceeds the file.");
35
- if (subtype === 1 && entry.readUInt32LE(8) >= length) throw new Error("AVI OpenDML second field exceeds its frame.");
36
- if (!length) { yield { chunkId, start, length, keyframe: false }; continue; }
37
- const prefix = await readRange(start - 8, start - 1);
38
- if (prefix.toString("ascii", 0, 4) !== chunkId || prefix.readUInt32LE(4) !== length) throw new Error("AVI OpenDML packet disagrees with its chunk header.");
39
- yield { chunkId, start, length, keyframe: !(encodedSize & 0x80000000) };
40
- }
41
- }
42
- }
43
-
44
- function safeOffset(value) {
45
- if (value > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error("AVI OpenDML address exceeds exact integer precision.");
46
- return Number(value);
47
- }