@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 +1 -1
- package/routes/encode-input/get.js +6 -3
- package/server.js +14 -3
- package/services/encode/OutputOpening.js +10 -0
- package/services/media/KeyframeTables.js +1 -0
- package/services/media/container/AviContainer.js +110 -305
- package/services/media/container/KeyframeTable.js +18 -2
- package/services/media/container/avi-index.js +297 -0
- package/services/media/container/avi-open-dml.js +0 -47
package/package.json
CHANGED
|
@@ -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
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
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
|
-
|
|
130
|
-
|
|
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
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
if (
|
|
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.#
|
|
295
|
-
|
|
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
|
|
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
|
-
}
|