@torrent-tv/proxy 2.90.0 → 2.92.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/bin/cli.js +5 -2
  2. package/package.json +18 -2
  3. package/routes/api/playback-plan/post.js +21 -4
  4. package/routes/api/sources/files/get.js +20 -28
  5. package/routes/api/sources/post.js +4 -2
  6. package/routes/api/sources/stats/get.js +4 -11
  7. package/routes/api/sources/viewer/post.js +16 -0
  8. package/routes/api/sources/warm/post.js +6 -215
  9. package/routes/api/subtitles/get.js +22 -125
  10. package/routes/api/subtitles/post.js +26 -0
  11. package/routes/api/transcode-sessions/post.js +24 -2
  12. package/routes/media/get.js +80 -0
  13. package/routes/stream/get.js +26 -94
  14. package/routes/transcode/audio-warm/get.js +9 -21
  15. package/routes/transcode/variant-warm/get.js +8 -14
  16. package/server.js +410 -46
  17. package/services/encode/AdmittedInput.js +136 -0
  18. package/services/encode/CushionReport.js +1 -76
  19. package/services/encode/EncodeInputs.js +142 -0
  20. package/services/encode/EncodeOrchestrator.js +4 -1
  21. package/services/encode/EncodeRun.js +13 -4
  22. package/services/encode/EncodeRuns.js +132 -52
  23. package/services/encode/InputFailures.js +37 -0
  24. package/services/encode/MatroskaInput.js +158 -0
  25. package/services/encode/OutputOpening.js +16 -64
  26. package/services/encode/Renditions.js +1 -17
  27. package/services/encode/admitted-command.js +60 -0
  28. package/services/encode/annex-b-input.js +37 -0
  29. package/services/encode/bit-payload.js +29 -0
  30. package/services/encode/output/OutputCatalog.js +1 -1
  31. package/services/encode/output/Timeline.js +11 -0
  32. package/services/encode/pcm-normalization.js +30 -0
  33. package/services/encode/piece-completeness.js +54 -1
  34. package/services/encode/quality/HostLoad.js +19 -73
  35. package/services/encode/quality/QualityOffer.js +1 -1
  36. package/services/encode/run-command.js +1 -589
  37. package/services/encode/segment-formats/mp4-boxes.js +3 -1
  38. package/services/encode/segment-formats/presentation-segment.js +114 -0
  39. package/services/media/ContainerOrchestrator.js +118 -48
  40. package/services/media/KeyframeTables.js +16 -115
  41. package/services/media/MediaReadRequests.js +120 -0
  42. package/services/media/ProbeRequests.js +135 -0
  43. package/services/media/SegmentInputs.js +48 -0
  44. package/services/media/SourceFile.js +6 -0
  45. package/services/media/SubtitleOrchestrator.js +190 -4
  46. package/services/media/await-plan.js +47 -0
  47. package/services/media/container/AsfContainer.js +217 -0
  48. package/services/media/container/AviContainer.js +343 -34
  49. package/services/media/container/CadencedPictureOrder.js +45 -0
  50. package/services/media/container/Container.js +7 -3
  51. package/services/media/container/ContainerFactory.js +10 -3
  52. package/services/media/container/FfprobeContainer.js +222 -0
  53. package/services/media/container/MatroskaContainer.js +72 -46
  54. package/services/media/container/Mp4Container.js +472 -139
  55. package/services/media/container/MpegPsContainer.js +281 -0
  56. package/services/media/container/MpegTsContainer.js +277 -0
  57. package/services/media/container/PacketIndex.js +270 -0
  58. package/services/media/container/PacketRecords.js +237 -0
  59. package/services/media/container/RetainedBytes.js +39 -0
  60. package/services/media/container/RetainedReads.js +51 -0
  61. package/services/media/container/ac3-frame.js +46 -0
  62. package/services/media/container/asf-packets.js +153 -0
  63. package/services/media/container/avc-picture-order.js +50 -0
  64. package/services/media/container/avi-open-dml.js +47 -0
  65. package/services/media/container/dts-frame.js +51 -0
  66. package/services/media/container/ebml-stream.js +12 -1
  67. package/services/media/container/ffprobe-record.js +45 -0
  68. package/services/media/container/flv-packet-ranges.js +27 -0
  69. package/services/media/container/following-packet-time.js +37 -0
  70. package/services/media/container/h264-configuration.js +183 -0
  71. package/services/media/container/h264-elementary-index.js +245 -0
  72. package/services/media/container/hevc-configuration.js +165 -0
  73. package/services/media/container/hevc-elementary-index.js +132 -0
  74. package/services/media/container/hevc-picture-order.js +66 -0
  75. package/services/media/container/latm-frame.js +99 -0
  76. package/services/media/container/matroska-packets.js +207 -0
  77. package/services/media/container/memory-unavailable.js +8 -0
  78. package/services/media/container/mp3-packet-dependencies.js +41 -0
  79. package/services/media/container/mp4-fragments.js +148 -0
  80. package/services/media/container/mpeg-audio-frame.js +19 -0
  81. package/services/media/container/mpeg-audio-reservoir.js +27 -0
  82. package/services/media/container/mpeg-elementary-index.js +196 -0
  83. package/services/media/container/mpeg-sequence-reader.js +69 -0
  84. package/services/media/container/mpeg-ts-packets.js +93 -0
  85. package/services/media/container/mpeg-video-configuration.js +50 -0
  86. package/services/media/container/mpeg4-picture-timing.js +108 -0
  87. package/services/media/container/pes-header.js +34 -0
  88. package/services/media/playback-declarations.js +57 -0
  89. package/services/media/playback-planner.js +72 -230
  90. package/services/media/probe-packets.js +61 -0
  91. package/services/media/read-playback-declarations.js +18 -0
  92. package/services/media/tracks/AudioTrack.js +9 -0
  93. package/services/media/tracks/TextSubtitleTrack.js +4 -1
  94. package/services/server/OutputLifecycle.js +2 -0
  95. package/services/server/SegmentServing.js +40 -33
  96. package/services/server/ViewerRequests.js +22 -41
  97. package/services/server/controllers/PlaybackController.js +4 -0
  98. package/services/server/controllers/SubtitleController.js +8 -29
  99. package/services/server/transcode-session-files.js +58 -99
  100. package/services/server/wire-outputs.js +58 -15
  101. package/services/storage/IndexMemory.js +85 -0
  102. package/services/storage/MachineBudget.js +27 -3
  103. package/services/storage/allowance.js +8 -1
  104. package/services/storage/piece-store/piece-disk-store.js +18 -3
  105. package/services/storage/piece-store/piece-lru.js +8 -4
  106. package/services/storage/piece-store/shared-piece-store.js +26 -11
  107. package/services/storage/segment-store/SegmentStore.js +49 -18
  108. package/services/storage/wire.js +7 -0
  109. package/services/torrent/Contents.js +52 -20
  110. package/services/torrent/demand/DemandRegister.js +13 -4
  111. package/services/torrent/demand/Window.js +13 -2
  112. package/services/torrent/demand-progress.js +25 -0
  113. package/services/torrent/download/DeadlineScheduler.js +167 -0
  114. package/services/torrent/download/FutureDownload.js +166 -0
  115. package/services/torrent/download/SwarmSelection.js +30 -59
  116. package/services/torrent/download/registry.js +45 -5
  117. package/services/torrent/download/withdraw-claim.js +2 -0
  118. package/services/torrent/torrent-pool.js +39 -600
  119. package/services/torrent/worker/channel.js +8 -1
  120. package/services/torrent/worker/client.js +74 -72
  121. package/services/torrent/worker/files-in-use.js +9 -0
  122. package/services/torrent/worker/handle-state.js +12 -0
  123. package/services/torrent/worker/held-bytes.js +82 -99
  124. package/services/torrent/worker/held-file-bytes.js +17 -0
  125. package/services/torrent/worker/piece-reader.js +67 -683
  126. package/services/torrent/worker/pool-adapter.js +37 -123
  127. package/services/torrent/worker/protocol.js +6 -17
  128. package/services/torrent/worker/whole-file-stats.js +2 -0
  129. package/services/torrent/worker/worker.js +86 -156
  130. package/services/transport/data-channel-handler.js +56 -13
  131. package/services/transport/tunnel-client.js +111 -20
  132. package/services/viewer/DownloadMaps.js +168 -0
  133. package/services/viewer/NativeSourceMap.js +33 -0
  134. package/services/viewer/PriorityMap.js +49 -40
  135. package/services/viewer/PriorityOrchestrator.js +42 -13
  136. package/services/viewer/SourcePreparation.js +278 -0
  137. package/services/viewer/Viewer.js +11 -0
  138. package/services/viewer/Viewers.js +109 -2
  139. package/services/viewer/playback-readiness.js +50 -63
  140. package/services/viewer/segment-demands.js +37 -0
  141. package/services/viewer/urgent-output-ready.js +26 -0
  142. package/services/encode/run-inputs.js +0 -53
  143. package/services/media/keyframe-probe.js +0 -157
  144. package/services/media/media-info-probe.js +0 -93
  145. package/services/torrent/worker/background-fill.js +0 -213
  146. package/services/torrent/worker/resume-warm.js +0 -107
@@ -0,0 +1,217 @@
1
+ import { Container } from "./Container.js";
2
+ import { VideoTrack } from "../tracks/VideoTrack.js";
3
+ import { AudioTrack } from "../tracks/AudioTrack.js";
4
+ import { readAsfPackets } from "./asf-packets.js";
5
+ import { RetainedReads } from "./RetainedReads.js";
6
+
7
+ const HEADER = "3026b2758e66cf11a6d900aa0062ce6c";
8
+ const FILE = "a1dcab8c47a9cf118ee400c00c205365";
9
+ const STREAM = "9107dcb7b7a9cf118ee600c00c205365";
10
+ const EXTENSION = "b503bf5f2ea9cf118ee300c00c205365";
11
+ const EXTENDED_STREAM = "cba5e61472c632438399a96952065b5a";
12
+ const LANGUAGES = "7c4346a9efe0fc4bb229393ede415c85";
13
+ const AUDIO = "409e69f84d5bcf11a8fd00805f5c442b";
14
+ const VIDEO = "c0ef19bc4d5bcf11a8fd00805f5c442b";
15
+ const ENCRYPTION = new Set(["fbb3112223bdd211b4b700a0c955fc6e", "14e68a292226174cb935dae07e9e28c9"]);
16
+ const AUDIO_CODECS = new Map([[1, "pcm_s16le"], [0x55, "mp3"], [0xff, "aac"],
17
+ [0x160, "wmav1"], [0x161, "wmav2"], [0x162, "wmapro"], [0x163, "wmalossless"], [0x2000, "ac3"]]);
18
+
19
+ /** ASF declarations are bounded objects, read without fetching source packets. */
20
+ export class AsfContainer extends Container {
21
+ #tracks = null;
22
+ #info = null;
23
+ #header = null;
24
+ #objects = [];
25
+ #offset = 30;
26
+ #packets = null;
27
+ #packetState = {};
28
+ #startRead = false;
29
+ #duration = null;
30
+ #declarations = null;
31
+
32
+ get formatName() { return "asf"; }
33
+ packetIndexBytes() {
34
+ return (this.#packets?.allocatedBytes() ?? 0) +
35
+ [...(this.#packetState.frames?.values() ?? [])].reduce((sum, records) => sum + records.allocatedBytes, 0);
36
+ }
37
+ static detect(head) { return head.length >= 16 && head.subarray(0, 16).toString("hex") === HEADER; }
38
+
39
+ async #readHeader() {
40
+ if (this.#info) return;
41
+ this.#declarations ??= new RetainedReads(this.packetMemory);
42
+ if (!this.#header) {
43
+ const bytes = await this.readRange(0, 29);
44
+ if (!AsfContainer.detect(bytes) || bytes[28] !== 1 || bytes[29] !== 2) throw new Error("ASF header is invalid.");
45
+ const size = Number(bytes.readBigUInt64LE(16));
46
+ const count = bytes.readUInt32LE(24);
47
+ if (!Number.isSafeInteger(size) || size < 30 || size > this.fileSize || count * 24 > size - 30) {
48
+ throw new Error("ASF header object sizes exceed the file.");
49
+ }
50
+ this.#header = { size, count };
51
+ }
52
+ while (this.#objects.length < this.#header.count) {
53
+ const head = await this.readRange(this.#offset, this.#offset + 23);
54
+ const size = Number(head.readBigUInt64LE(16));
55
+ if (!Number.isSafeInteger(size) || size < 24 || this.#offset + size > this.#header.size) {
56
+ throw new Error("ASF child object exceeds its header.");
57
+ }
58
+ const guid = head.subarray(0, 16).toString("hex");
59
+ if (ENCRYPTION.has(guid)) throw new Error("ASF media is encrypted.");
60
+ const bytes = [FILE, STREAM, EXTENSION, EXTENDED_STREAM, LANGUAGES].includes(guid)
61
+ ? await this.#declarations.read(this.#offset + 24, this.#offset + size - 1, this.readRange) : null;
62
+ this.#objects.push({ guid, bytes });
63
+ this.#offset += size;
64
+ }
65
+ const objects = [...this.#objects];
66
+ for (const object of this.#objects) {
67
+ if (object.guid !== EXTENSION) continue;
68
+ const bytes = object.bytes;
69
+ if (bytes.length < 22) throw new Error("ASF header extension is truncated.");
70
+ const end = 22 + bytes.readUInt32LE(18);
71
+ if (end > bytes.length) throw new Error("ASF header extension length is invalid.");
72
+ for (let at = 22; at < end;) {
73
+ if (at + 24 > end) throw new Error("ASF extension object is truncated.");
74
+ const size = Number(bytes.readBigUInt64LE(at + 16));
75
+ if (!Number.isSafeInteger(size) || size < 24 || at + size > end) throw new Error("ASF extension object size is invalid.");
76
+ const guid = bytes.subarray(at, at + 16).toString("hex");
77
+ if (ENCRYPTION.has(guid)) throw new Error("ASF media is encrypted.");
78
+ objects.push({ guid, bytes: bytes.subarray(at + 24, at + size) });
79
+ at += size;
80
+ }
81
+ }
82
+ const properties = objects.find(object => object.guid === FILE)?.bytes;
83
+ if (!properties || properties.length < 80) throw new Error("ASF file properties are absent or truncated.");
84
+ const duration = Number(properties.readBigUInt64LE(40)) / 1e7;
85
+ const preroll = Number(properties.readBigUInt64LE(56)) / 1000;
86
+ const streaming = (properties.readUInt32LE(64) & 1) !== 0;
87
+ const tracks = [];
88
+ const counts = { video: 0, audio: 0 };
89
+ for (const object of objects) {
90
+ if (object.guid !== STREAM) continue;
91
+ const bytes = object.bytes;
92
+ if (bytes.length < 54) throw new Error("ASF stream properties are truncated.");
93
+ const type = bytes.subarray(0, 16).toString("hex");
94
+ const flags = bytes.readUInt16LE(48);
95
+ if (flags & 0x8000) throw new Error("ASF stream is encrypted.");
96
+ const number = flags & 0x7f;
97
+ if (!number || tracks.some(track => track.trackNumber === number)) throw new Error("ASF stream number is invalid.");
98
+ const length = bytes.readUInt32LE(40);
99
+ if (54 + length > bytes.length) throw new Error("ASF codec declaration is truncated.");
100
+ const data = bytes.subarray(54, 54 + length);
101
+ const base = { trackNumber: number, isEnabled: true, isDefault: false, declaresDefault: false };
102
+ if (type === VIDEO) {
103
+ if (data.length < 51) throw new Error("ASF video format is truncated.");
104
+ const bitmapSize = data.readUInt32LE(11);
105
+ if (bitmapSize < 40 || 11 + bitmapSize > data.length) throw new Error("ASF bitmap format size is invalid.");
106
+ const codecId = data.toString("latin1", 27, 31);
107
+ const track = new VideoTrack({ ...base, declaredIndex: counts.video++, codecId,
108
+ width: Math.abs(data.readInt32LE(15)), height: Math.abs(data.readInt32LE(19)),
109
+ codecPrivateB64: data.subarray(51, 11 + bitmapSize).toString("base64") });
110
+ track.matroskaCodecId = "V_MS/VFW/FOURCC";
111
+ track.matroskaCodecPrivateB64 = data.subarray(11).toString("base64");
112
+ tracks.push(track);
113
+ } else if (type === AUDIO) {
114
+ if (data.length < 16) throw new Error("ASF audio format is truncated.");
115
+ const tag = data.readUInt16LE(0);
116
+ const extraLength = data.length >= 18 ? data.readUInt16LE(16) : 0;
117
+ if (extraLength + 18 > data.length && extraLength > 0) throw new Error("ASF audio codec settings are truncated.");
118
+ const track = new AudioTrack({ ...base, declaredIndex: counts.audio++, codecId: AUDIO_CODECS.get(tag) ?? `wave:${tag}`,
119
+ channels: data.readUInt16LE(2), samplingFrequency: data.readUInt32LE(4),
120
+ codecPrivateB64: data.subarray(18, 18 + extraLength).toString("base64") });
121
+ track.matroskaCodecId = "A_MS/ACM";
122
+ track.matroskaCodecPrivateB64 = (data.length === 16
123
+ ? Buffer.concat([data, Buffer.alloc(2)]) : data).toString("base64");
124
+ tracks.push(track);
125
+ }
126
+ }
127
+ const languages = [];
128
+ for (const object of objects.filter(object => object.guid === LANGUAGES)) {
129
+ const bytes = object.bytes;
130
+ if (bytes.length < 2) throw new Error("ASF language list is truncated.");
131
+ let at = 2;
132
+ for (let index = 0; index < bytes.readUInt16LE(0); index++) {
133
+ if (at >= bytes.length) throw new Error("ASF language entry is truncated.");
134
+ const length = bytes[at++];
135
+ if (length % 2 || at + length > bytes.length) throw new Error("ASF language entry length is invalid.");
136
+ languages.push(bytes.toString("utf16le", at, at + length).replace(/\0+$/, ""));
137
+ at += length;
138
+ }
139
+ }
140
+ for (const object of objects.filter(object => object.guid === EXTENDED_STREAM)) {
141
+ const bytes = object.bytes;
142
+ if (bytes.length < 64) throw new Error("ASF extended stream properties are truncated.");
143
+ const track = tracks.find(track => track.trackNumber === bytes.readUInt16LE(48));
144
+ if (!track) throw new Error("ASF extended properties name an undeclared stream.");
145
+ const durationTicks = Number(bytes.readBigUInt64LE(52));
146
+ if (!Number.isSafeInteger(durationTicks)) throw new Error("ASF average frame duration exceeds its integer range.");
147
+ if (durationTicks > 0) {
148
+ track.averageFrameDurationSeconds = durationTicks / 1e7;
149
+ if (track.type === "video") track.fps = 1e7 / durationTicks;
150
+ }
151
+ const language = languages[bytes.readUInt16LE(50)];
152
+ if (language) {
153
+ track.language = language;
154
+ track.languageBcp47 = language;
155
+ track.declaresLanguage = true;
156
+ track.languageSource = "bcp47";
157
+ }
158
+ let at = 64;
159
+ for (let index = 0; index < bytes.readUInt16LE(60); index++) {
160
+ if (at + 4 > bytes.length) throw new Error("ASF stream name is truncated.");
161
+ const length = bytes.readUInt16LE(at + 2);
162
+ if (length % 2 || at + 4 + length > bytes.length) throw new Error("ASF stream name length is invalid.");
163
+ if (!track.name) track.name = bytes.toString("utf16le", at + 4, at + 4 + length).replace(/\0+$/, "");
164
+ at += 4 + length;
165
+ }
166
+ const extensions = [];
167
+ for (let index = 0; index < bytes.readUInt16LE(62); index++) {
168
+ if (at + 22 > bytes.length) throw new Error("ASF payload extension declaration is truncated.");
169
+ const length = bytes.readUInt32LE(at + 18);
170
+ if (at + 22 + length > bytes.length) throw new Error("ASF payload extension information exceeds its object.");
171
+ extensions.push({ id: bytes.subarray(at, at + 16).toString("hex"), size: bytes.readUInt16LE(at + 16) });
172
+ at += 22 + length;
173
+ }
174
+ track.payloadExtensions = extensions;
175
+ }
176
+ this.#tracks = tracks;
177
+ this.#duration = streaming ? null : Math.max(0, duration - preroll);
178
+ this.#info = { format: this.formatName, durationSeconds: streaming ? null : Math.max(0, duration - preroll),
179
+ startTimeSeconds: null, bitrateKbps: properties.readUInt32LE(76) / 1000,
180
+ prerollSeconds: preroll, minimumPacketLength: properties.readUInt32LE(68), packetLength: properties.readUInt32LE(72), dataOffset: this.#header.size };
181
+ }
182
+
183
+ async readTracks() { await this.#readHeader(); return this.#tracks; }
184
+ async readMediaInfo() {
185
+ await this.#readHeader();
186
+ if (!this.#startRead && this.#info.dataOffset < this.fileSize && this.#tracks.some(track => track.type === "video")) {
187
+ const first = await readAsfPackets({ readRange: this.readRange, fileSize: this.fileSize,
188
+ tracks: this.#tracks, info: this.#info, firstPicture: true, state: this.#packetState, packetMemory: this.packetMemory });
189
+ this.#info = { ...this.#info, startTimeSeconds: first.startTimeSeconds,
190
+ durationSeconds: this.#duration === null ? null : Math.max(0, this.#duration - (first.startTimeSeconds ?? 0)) };
191
+ this.#startRead = true;
192
+ }
193
+ return this.#info;
194
+ }
195
+ async parseKeyframeIndex() {
196
+ const index = await this.readPacketIndex();
197
+ const track = this.#tracks.find(track => track.type === "video");
198
+ return track ? { times: index.keyframesOf(track.trackNumber), tolerance: 0 } : null;
199
+ }
200
+ async readPacketIndex() {
201
+ await this.#readHeader();
202
+ if (!this.#packets) {
203
+ const index = await readAsfPackets({ readRange: this.readRange, fileSize: this.fileSize, tracks: this.#tracks,
204
+ info: { ...this.#info, durationSeconds: this.#duration }, state: this.#packetState, packetMemory: this.packetMemory });
205
+ const timeline = this.#tracks.find(track => track.type === "video") ?? this.#tracks.find(track => track.type === "audio");
206
+ const bounds = timeline ? index.boundsOf(timeline.trackNumber) : null;
207
+ if (bounds) {
208
+ this.#info = { ...this.#info, startTimeSeconds: bounds.start,
209
+ durationSeconds: this.#duration === null ? bounds.end - bounds.start : Math.max(0, this.#duration - bounds.start) };
210
+ this.#startRead = true;
211
+ }
212
+ this.#packets = index;
213
+ this.#packetState = {};
214
+ }
215
+ return this.#packets;
216
+ }
217
+ }
@@ -1,19 +1,69 @@
1
1
  /**
2
2
  * @file AVI container — RIFF.
3
3
  *
4
- * Minimal: only keyframe index via idx1 (AVIIF_KEYFRAME). Tracks are not
5
- * used by current product beyond video — expose a single VideoTrack if needed.
6
- * Spec: RIFF AVI, idx1 chunk at file end, OpenDML may lack idx1 → no index.
4
+ * Stream declarations and exact packet addresses from idx1 or OpenDML indexes.
7
5
  */
8
6
 
9
7
  import { Container } from "./Container.js";
10
8
  import { VideoTrack } from "../tracks/VideoTrack.js";
9
+ import { AudioTrack } from "../tracks/AudioTrack.js";
10
+ import { ContainerTrack } from "../tracks/ContainerTrack.js";
11
+ import { PacketIndex } from "./PacketIndex.js";
12
+ import { openDmlPackets } from "./avi-open-dml.js";
13
+ import { isUnavailable } from "./unavailable.js";
14
+ import { Mpeg4PictureTiming } from "./mpeg4-picture-timing.js";
15
+ import { MpegElementaryIndex } from "./mpeg-elementary-index.js";
16
+ import { RetainedReads } from "./RetainedReads.js";
17
+
18
+ const MPEG4_CODECS = new Set(["FMP4", "XVID", "DIVX", "DX50", "MP4V", "M4S2", "MP4S"]);
19
+ const MPEG_AUDIO_CODECS = new Set(["mp1", "mp2", "mp3"]);
20
+ const NAL_CODECS = new Map([["H264", "h264"], ["X264", "h264"], ["AVC1", "h264"], ["HEVC", "hevc"], ["H265", "hevc"]]);
11
21
 
12
22
  export class AviContainer extends Container {
23
+ #headers = null;
24
+ #tracks = null;
25
+ #packets = null;
26
+ #scan = null;
27
+ #declarations = null;
28
+
29
+ async #readHeaders() {
30
+ if (this.#headers) return this.#headers;
31
+ this.#declarations ??= new RetainedReads(this.packetMemory);
32
+ const head = await this.readRange(0, Math.min(11, this.fileSize - 1));
33
+ if (!isAvi(head)) throw new Error("AVI RIFF header is absent.");
34
+ const end = 8 + head.readUInt32LE(4);
35
+ if (end > this.fileSize || end < 12) throw new Error("AVI RIFF size is invalid.");
36
+ const lists = await riffChunks(this.readRange, 12, end, "hdrl");
37
+ const hdrl = lists.find(chunk => chunk.id === "LIST" && chunk.type === "hdrl");
38
+ if (!hdrl) throw new Error("AVI stream header list is absent.");
39
+ const chunks = await riffChunks(this.readRange, hdrl.start + 4, hdrl.end);
40
+ const main = chunks.find(chunk => chunk.id === "avih");
41
+ const avih = main ? await this.#declarations.read(main.start, main.end - 1, this.readRange) : null;
42
+ if (avih && avih.length < 20) throw new Error("AVI main header is truncated.");
43
+ const streams = [];
44
+ for (const list of chunks.filter(chunk => chunk.id === "LIST" && chunk.type === "strl")) {
45
+ const children = await riffChunks(this.readRange, list.start + 4, list.end);
46
+ const fields = {};
47
+ fields.codecRanges = [];
48
+ fields.indexChunks = children.filter(chunk => chunk.id === "indx");
49
+ for (const chunk of children) {
50
+ if (!["strh", "strf", "strn", "strd"].includes(chunk.id)) continue;
51
+ fields[chunk.id] = chunk.end > chunk.start
52
+ ? await this.#declarations.read(chunk.start, chunk.end - 1, this.readRange) : Buffer.alloc(0);
53
+ if (["strh", "strf", "strd"].includes(chunk.id) && chunk.end > chunk.start) fields.codecRanges.push([chunk.start, chunk.end - 1]);
54
+ }
55
+ if (!fields.strh || fields.strh.length < 48 || !fields.strf) throw new Error("AVI stream declaration is incomplete.");
56
+ streams.push(fields);
57
+ }
58
+ this.#headers = { avih, streams };
59
+ return this.#headers;
60
+ }
13
61
  get formatName() {
14
62
  return "avi";
15
63
  }
16
64
 
65
+ packetIndexBytes() { return (this.#packets ?? this.#scan?.index)?.allocatedBytes() ?? 0; }
66
+
17
67
  static detect(head) {
18
68
  return isAvi(head);
19
69
  }
@@ -33,20 +83,52 @@ export class AviContainer extends Container {
33
83
  }
34
84
 
35
85
  async readTracks() {
36
- const head = await this.readRange(0, Math.min(4095, this.fileSize - 1));
37
- if (!isAvi(head)) return [];
38
- // AVI track table is minimal — expose one video track for uniformity.
39
- return [new VideoTrack({
40
- trackNumber: 1,
41
- declaredIndex: 0,
42
- codecId: "",
43
- language: "",
44
- languageBcp47: "",
45
- name: "",
46
- isEnabled: true,
47
- isDefault: true,
48
- declaresDefault: false
49
- })];
86
+ if (this.#tracks) return this.#tracks;
87
+ const { streams } = await this.#readHeaders();
88
+ const counts = { video: 0, audio: 0, other: 0 };
89
+ const tracks = streams.map(({ strh, strf, strn, strd }, trackNumber) => {
90
+ const kind = strh.toString("ascii", 0, 4);
91
+ const type = kind === "vids" ? "video" : kind === "auds" ? "audio" : "other";
92
+ const scale = strh.readUInt32LE(20);
93
+ const rate = strh.readUInt32LE(24);
94
+ const params = { trackNumber, declaredIndex: counts[type]++, type,
95
+ isEnabled: !(strh.readUInt32LE(8) & 1), isDefault: false, declaresDefault: false,
96
+ language: "", name: strn?.toString("utf8").replace(/\0+$/, "") ?? "",
97
+ codecPrivateB64: (strd ?? Buffer.alloc(0)).toString("base64") };
98
+ let track;
99
+ if (type === "video") {
100
+ if (strf.length < 40 || strf.readUInt32LE(0) < 40) throw new Error("AVI bitmap format is truncated.");
101
+ track = new VideoTrack({ ...params, codecId: strf.toString("ascii", 16, 20),
102
+ width: Math.abs(strf.readInt32LE(4)), height: Math.abs(strf.readInt32LE(8)),
103
+ bitDepth: strf.readUInt16LE(14), fps: scale > 0 && rate > 0 ? rate / scale : null });
104
+ // Preserve the complete decoder declaration, including palette and private bytes.
105
+ track.matroskaCodecId = "V_MS/VFW/FOURCC";
106
+ track.matroskaCodecPrivateB64 = strf.toString("base64");
107
+ const nalCodec = NAL_CODECS.get(track.codecId.toUpperCase());
108
+ if (nalCodec) {
109
+ track.codecId = nalCodec;
110
+ delete track.matroskaCodecId;
111
+ delete track.matroskaCodecPrivateB64;
112
+ }
113
+ } else if (type === "audio") {
114
+ if (strf.length < 16) throw new Error("AVI wave format is truncated.");
115
+ const tag = strf.readUInt16LE(0);
116
+ track = new AudioTrack({ ...params, codecId: waveCodec(tag),
117
+ channels: strf.readUInt16LE(2), samplingFrequency: strf.readUInt32LE(4),
118
+ codecPrivateB64: strf.subarray(18).toString("base64") });
119
+ track.matroskaCodecId = "A_MS/ACM";
120
+ track.matroskaCodecPrivateB64 = (strf.length === 16
121
+ ? Buffer.concat([strf, Buffer.alloc(2)]) : strf).toString("base64");
122
+ } else track = new ContainerTrack({ ...params, codecId: strh.toString("ascii", 4, 8) });
123
+ track.timeBase = scale > 0 && rate > 0 ? scale / rate : null;
124
+ track.startTimeSeconds = track.timeBase === null ? null : strh.readUInt32LE(28) * track.timeBase;
125
+ track.durationSeconds = track.timeBase === null ? null : strh.readUInt32LE(32) * track.timeBase;
126
+ track.sampleSize = strh.readUInt32LE(44);
127
+ if (["h264", "hevc"].includes(track.codecId)) track.presentationCadenceSeconds = track.timeBase;
128
+ return track;
129
+ });
130
+ this.#tracks = tracks;
131
+ return tracks;
50
132
  }
51
133
 
52
134
  /**
@@ -65,34 +147,261 @@ export class AviContainer extends Container {
65
147
  }
66
148
  /** @type {import("./Container.js").ContainerMediaInfo} */
67
149
  const info = { format: this.formatName, durationSeconds: null, startTimeSeconds: 0 };
68
- const head = await this.readRange(0, Math.min(4095, this.fileSize - 1));
69
- // Kept only once the head has been read: one that had not arrived used to
70
- // leave an empty answer here for the life of the container.
71
- this.mediaInfo = info;
72
- if (!isAvi(head)) {
73
- return info;
74
- }
75
- // RIFF("AVI ") -> LIST("hdrl") -> avih. The avih chunk's payload begins with
76
- // dwMicroSecPerFrame and its fifth field is dwTotalFrames.
77
- const at = head.indexOf("avih", 0, "latin1");
78
- if (at < 0 || at + 8 + 20 > head.length) {
79
- return info;
80
- }
81
- const payload = at + 8;
82
- const microsecondsPerFrame = head.readUInt32LE(payload);
83
- const totalFrames = head.readUInt32LE(payload + 16);
150
+ const { avih } = await this.#readHeaders();
151
+ const microsecondsPerFrame = avih?.readUInt32LE(0) ?? 0;
152
+ const totalFrames = avih?.readUInt32LE(16) ?? 0;
84
153
  if (microsecondsPerFrame > 0 && totalFrames > 0) {
85
154
  info.durationSeconds = (microsecondsPerFrame * totalFrames) / 1e6;
86
155
  }
156
+ this.mediaInfo = info;
87
157
  return info;
88
158
  }
89
159
 
90
160
  async parseKeyframeIndex() {
161
+ const { streams } = await this.#readHeaders();
162
+ const picture = (await this.readTracks()).find(track => track.type === "video");
163
+ if (streams.some(stream => stream.indexChunks.length) || ["h264", "hevc"].includes(picture?.codecId) || MPEG4_CODECS.has(picture?.codecId.toUpperCase())) {
164
+ const index = await this.readPacketIndex();
165
+ return picture ? { times: index.keyframesOf(picture.trackNumber), tolerance: 0 } : null;
166
+ }
91
167
  const r = await readAviKeyframeTimes(this.readRange, this.fileSize);
92
- if (!r) return null;
168
+ if (!r) {
169
+ return picture ? { times: (await this.readPacketIndex()).keyframesOf(picture.trackNumber), tolerance: 0 } : null;
170
+ }
93
171
  if (Array.isArray(r)) return { times: r, tolerance: 0 };
94
172
  return r;
95
173
  }
174
+
175
+ async readPacketIndex(interval) {
176
+ if (this.#packets) return this.#packets;
177
+ if (this.#scan) return this.#readUnindexed(interval);
178
+ const tracks = await this.readTracks();
179
+ const { streams } = await this.#readHeaders();
180
+ if (streams.some(stream => stream.indexChunks.length)) {
181
+ const index = new PacketIndex({ packetMemory: this.packetMemory, deferMemory: true });
182
+ const timings = new Map();
183
+ try {
184
+ for (const track of tracks.filter(track => ["video", "audio"].includes(track.type))) {
185
+ if (!(track.timeBase > 0) || !streams[track.trackNumber].indexChunks.length) throw new Error("AVI OpenDML stream index is incomplete.");
186
+ index.declareTrack(track.trackNumber, { type: track.type, codecId: track.codecId, codecRanges: streams[track.trackNumber].codecRanges });
187
+ let pts = track.startTimeSeconds;
188
+ for await (const packet of openDmlPackets({ readRange: this.readRange, fileSize: this.fileSize,
189
+ indexes: streams[track.trackNumber].indexChunks, streamId: track.trackNumber })) {
190
+ if (!packet.length) {
191
+ if (track.type === "video") { index.extendLastPresentation(track.trackNumber, track.timeBase); pts += track.timeBase; }
192
+ continue;
193
+ }
194
+ if (track.sampleSize > 0 && packet.length % track.sampleSize) throw new Error("AVI packet contains an incomplete fixed-size sample.");
195
+ const duration = track.timeBase * (track.sampleSize > 0 ? packet.length / track.sampleSize : 1);
196
+ await this.#appendPacket(index, timings, track, { pts, duration, keyframe: track.type !== "video" || packet.keyframe,
197
+ ranges: [[packet.start, packet.start + packet.length - 1]] });
198
+ pts += duration;
199
+ index.flushPending();
200
+ }
201
+ timings.get(track.trackNumber)?.elementary?.complete();
202
+ index.flushPending();
203
+ index.complete(track.trackNumber);
204
+ }
205
+ this.#packets = index;
206
+ return index;
207
+ } catch (error) {
208
+ index.dispose();
209
+ throw error;
210
+ }
211
+ }
212
+ const header = await this.readRange(0, 11);
213
+ const chunks = await riffChunks(this.readRange, 12, 8 + header.readUInt32LE(4));
214
+ const movi = chunks.find(chunk => chunk.id === "LIST" && chunk.type === "movi");
215
+ const table = chunks.find(chunk => chunk.id === "idx1");
216
+ if (!movi) throw new Error("AVI media list is absent.");
217
+ if (!table) {
218
+ const index = new PacketIndex({ packetMemory: this.packetMemory, deferMemory: true });
219
+ const clocks = new Map();
220
+ for (const track of tracks.filter(track => ["video", "audio"].includes(track.type))) {
221
+ if (!(track.timeBase > 0)) throw new Error("AVI stream time base is invalid.");
222
+ 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())) {
223
+ throw new Error("AVI video without an index requires elementary picture timing.");
224
+ }
225
+ if (track.type === "audio" && !(track.sampleSize > 0) && !MPEG_AUDIO_CODECS.has(track.codecId)) throw new Error("AVI variable-size audio requires elementary frame timing.");
226
+ index.declareTrack(track.trackNumber, { type: track.type, codecId: track.codecId, codecRanges: streams[track.trackNumber].codecRanges });
227
+ clocks.set(track.trackNumber, track.startTimeSeconds);
228
+ }
229
+ this.#scan = { index, tracks, clocks, timings: new Map(), at: 0,
230
+ stack: [{ end: this.fileSize, next: this.fileSize }] };
231
+ return this.#readUnindexed(interval);
232
+ }
233
+ if ((table.end - table.start) % 16 !== 0) throw new Error("AVI packet index entry is truncated.");
234
+ const index = new PacketIndex({ packetMemory: this.packetMemory, deferMemory: true });
235
+ try {
236
+ const clocks = new Map();
237
+ const timings = new Map();
238
+ for (const track of tracks) {
239
+ if (!["video", "audio", "subtitle"].includes(track.type)) continue;
240
+ if (!(track.timeBase > 0)) throw new Error("AVI stream time base is invalid.");
241
+ index.declareTrack(track.trackNumber, { type: track.type, codecId: track.codecId, codecRanges: streams[track.trackNumber].codecRanges });
242
+ clocks.set(track.trackNumber, track.startTimeSeconds);
243
+ }
244
+ let offsetBase = null;
245
+ for (let at = table.start; at < table.end; at += 16) {
246
+ const entry = await this.readRange(at, at + 15);
247
+ const chunkId = entry.toString("ascii", 0, 4);
248
+ if (!/^[0-9]{2}(db|dc|wb)$/.test(chunkId)) continue;
249
+ const id = Number(chunkId.slice(0, 2));
250
+ const track = tracks.find(track => track.trackNumber === id);
251
+ if (!track || !clocks.has(id)) throw new Error("AVI packet refers to an undeclared stream.");
252
+ const flags = entry.readUInt32LE(4);
253
+ const offset = entry.readUInt32LE(8);
254
+ const length = entry.readUInt32LE(12);
255
+ if (length === 0) {
256
+ if (track.type === "video") { index.extendLastPresentation(id, track.timeBase); clocks.set(id, clocks.get(id) + track.timeBase); }
257
+ continue;
258
+ }
259
+ if (offsetBase === null) {
260
+ for (const base of [movi.start, 0, movi.start + 4]) {
261
+ const address = base + offset;
262
+ if (address < movi.start + 4 || address + 8 + length > movi.end) continue;
263
+ const probe = await this.readRange(address, address + 7);
264
+ if (probe.toString("ascii", 0, 4) === chunkId && probe.readUInt32LE(4) === length) { offsetBase = base; break; }
265
+ }
266
+ if (offsetBase === null) throw new Error("AVI packet index has no valid offset base.");
267
+ }
268
+ const address = offsetBase + offset;
269
+ if (address < movi.start + 4 || address + 8 + length > movi.end) throw new Error("AVI indexed packet exceeds its media list.");
270
+ const probe = await this.readRange(address, address + 7);
271
+ if (probe.toString("ascii", 0, 4) !== chunkId || probe.readUInt32LE(4) !== length) throw new Error("AVI packet index disagrees with its chunk header.");
272
+ if (track.sampleSize > 0 && length % track.sampleSize !== 0) throw new Error("AVI packet contains an incomplete fixed-size sample.");
273
+ const duration = track.timeBase * (track.sampleSize > 0 ? length / track.sampleSize : 1);
274
+ const pts = clocks.get(id);
275
+ await this.#appendPacket(index, timings, track, { pts, duration, keyframe: track.type !== "video" || chunkId.endsWith("db") || !!(flags & 0x10),
276
+ ranges: [[address + 8, address + 8 + length - 1]] });
277
+ clocks.set(id, pts + duration);
278
+ index.flushPending();
279
+ }
280
+ for (const timing of timings.values()) timing.elementary?.complete();
281
+ index.flushPending();
282
+ for (const id of clocks.keys()) index.complete(id);
283
+ this.#packets = index;
284
+ return index;
285
+ } catch (error) {
286
+ index.dispose();
287
+ throw error;
288
+ }
289
+ }
290
+
291
+ async #readUnindexed(interval) {
292
+ const state = this.#scan;
293
+ state.index.flushPending();
294
+ const ready = () => interval && state.tracks.filter(track => state.clocks.has(track.trackNumber) &&
295
+ (!interval.trackIds || interval.trackIds.includes(track.trackNumber))).every(track =>
296
+ state.index.inputFor({ trackId: track.trackNumber, from: interval.from, to: interval.to }).kind === "result");
297
+ try {
298
+ while (state.stack.length) {
299
+ if (ready()) return state.index;
300
+ const parent = state.stack.at(-1);
301
+ if (state.at === parent.end) { state.at = parent.next; state.stack.pop(); continue; }
302
+ if (state.at + 8 > parent.end) throw new Error("AVI media chunk header is truncated.");
303
+ const head = await this.readRange(state.at, state.at + 7);
304
+ const id = head.toString("ascii", 0, 4), length = head.readUInt32LE(4);
305
+ const start = state.at + 8, end = start + length, next = end + (length & 1);
306
+ if (end > parent.end || next > parent.end) throw new Error("AVI media chunk exceeds its containing list.");
307
+ if (id === "LIST" || id === "RIFF") {
308
+ if (length < 4) throw new Error("AVI media list has no type.");
309
+ const type = (await this.readRange(start, start + 3)).toString("ascii");
310
+ if (id === "RIFF" && type !== "AVI " && type !== "AVIX") throw new Error("AVI continuation form type is invalid.");
311
+ if (id === "LIST" && type !== "rec " && type !== "movi") { state.at = next; continue; }
312
+ state.stack.push({ end, next });
313
+ state.at = start + 4;
314
+ continue;
315
+ }
316
+ if (/^[0-9]{2}(db|dc|wb)$/.test(id)) {
317
+ const trackId = Number(id.slice(0, 2));
318
+ const track = state.tracks.find(track => track.trackNumber === trackId);
319
+ if (!track || !state.clocks.has(trackId)) throw new Error("AVI packet refers to an undeclared stream.");
320
+ if (track.sampleSize > 0 && length % track.sampleSize) throw new Error("AVI packet contains an incomplete fixed-size sample.");
321
+ const duration = track.timeBase * (track.sampleSize > 0 ? length / track.sampleSize : 1);
322
+ const pts = state.clocks.get(trackId);
323
+ if (length) await this.#appendPacket(state.index, state.timings, track, { pts, duration, keyframe: true, ranges: [[start, end - 1]] });
324
+ else if (track.type === "video") state.index.extendLastPresentation(trackId, track.timeBase);
325
+ state.clocks.set(trackId, pts + duration);
326
+ if (!MPEG4_CODECS.has(track.codecId.toUpperCase()) && !["h264", "hevc"].includes(track.codecId) && !MPEG_AUDIO_CODECS.has(track.codecId) && (track.type === "audio" || length)) state.index.coverThrough(trackId, Math.max(0, pts + duration));
327
+ }
328
+ state.at = next;
329
+ state.index.flushPending();
330
+ }
331
+ } catch (error) {
332
+ if (isUnavailable(error) && ready()) return state.index;
333
+ throw error;
334
+ }
335
+ for (const timing of state.timings.values()) timing.elementary?.complete();
336
+ state.index.flushPending();
337
+ for (const trackId of state.clocks.keys()) state.index.complete(trackId);
338
+ this.#packets = state.index;
339
+ return this.#packets;
340
+ }
341
+
342
+ async #appendPacket(index, timings, track, packet) {
343
+ if ((track.type === "audio" && MPEG_AUDIO_CODECS.has(track.codecId)) ||
344
+ (track.type === "video" && ["h264", "hevc"].includes(track.codecId))) {
345
+ let timing = timings.get(track.trackNumber);
346
+ if (!timing) timings.set(track.trackNumber, timing = {
347
+ elementary: new MpegElementaryIndex([track], { index }), stamped: false, current: null
348
+ });
349
+ const [start, end] = packet.ranges[0];
350
+ if (timing.current?.start !== start) timing.current = { start, at: start };
351
+ index.flushPending();
352
+ while (timing.current.at <= end) {
353
+ const at = timing.current.at, last = Math.min(end, at + this.portionBytes - 1);
354
+ const bytes = await this.readRange(at, last);
355
+ timing.elementary.push(track.trackNumber, bytes, at, timing.stamped ? {} : { pts: track.startTimeSeconds, dts: track.startTimeSeconds });
356
+ timing.stamped = true;
357
+ timing.current.at = last + 1;
358
+ index.flushPending();
359
+ }
360
+ return;
361
+ }
362
+ if (track.type !== "video" || !MPEG4_CODECS.has(track.codecId.toUpperCase())) {
363
+ index.append(track.trackNumber, packet);
364
+ return;
365
+ }
366
+ let timing = timings.get(track.trackNumber);
367
+ if (!timing) timings.set(track.trackNumber, timing = new Mpeg4PictureTiming(this.packetMemory));
368
+ const [start, end] = packet.ranges[0];
369
+ const picture = await timing.read(this.readRange, start, end, track.startTimeSeconds);
370
+ for (const one of picture.pictures) {
371
+ if (one.coded) index.append(track.trackNumber, { ...packet, pts: one.pts, keyframe: one.keyframe, ranges: [[one.start, one.end]] });
372
+ else if (!picture.packed) index.extendLastPresentation(track.trackNumber, packet.duration);
373
+ if (one.coveredThrough !== null) index.coverThrough(track.trackNumber, Math.max(0, one.coveredThrough));
374
+ }
375
+ picture.commit();
376
+ }
377
+ }
378
+
379
+ /** Walk declared chunks without reading media or alignment padding. */
380
+ async function riffChunks(read, start, end, stopAfterListType = null) {
381
+ const chunks = [];
382
+ for (let at = start; at < end;) {
383
+ if (at + 8 > end) throw new Error("AVI chunk header is truncated.");
384
+ const head = await read(at, at + 7);
385
+ const id = head.toString("ascii", 0, 4);
386
+ const size = head.readUInt32LE(4);
387
+ const dataStart = at + 8;
388
+ const dataEnd = dataStart + size;
389
+ if (dataEnd > end) throw new Error("AVI chunk exceeds its containing list.");
390
+ let type = null;
391
+ if (id === "LIST") {
392
+ if (size < 4) throw new Error("AVI list type is absent.");
393
+ type = (await read(dataStart, dataStart + 3)).toString("ascii");
394
+ }
395
+ chunks.push({ id, type, start: dataStart, end: dataEnd });
396
+ if (type !== null && type === stopAfterListType) break;
397
+ at = dataEnd + (size & 1);
398
+ }
399
+ return chunks;
400
+ }
401
+
402
+ function waveCodec(tag) {
403
+ return new Map([[1, "pcm"], [3, "pcm_float"], [0x50, "mp2"], [0x55, "mp3"],
404
+ [0xff, "aac"], [0x160, "wmav1"], [0x161, "wmav2"], [0x2000, "ac3"], [0x2001, "dts"]]).get(tag) ?? `wave:${tag}`;
96
405
  }
97
406
 
98
407
  // ---------------------------------------------------------------------------