@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.
- package/bin/cli.js +5 -2
- package/package.json +18 -2
- package/routes/api/playback-plan/post.js +21 -4
- package/routes/api/sources/files/get.js +20 -28
- package/routes/api/sources/post.js +4 -2
- package/routes/api/sources/stats/get.js +4 -11
- package/routes/api/sources/viewer/post.js +16 -0
- package/routes/api/sources/warm/post.js +6 -215
- package/routes/api/subtitles/get.js +22 -125
- package/routes/api/subtitles/post.js +26 -0
- package/routes/api/transcode-sessions/post.js +24 -2
- package/routes/media/get.js +80 -0
- package/routes/stream/get.js +26 -94
- package/routes/transcode/audio-warm/get.js +9 -21
- package/routes/transcode/variant-warm/get.js +8 -14
- package/server.js +410 -46
- package/services/encode/AdmittedInput.js +136 -0
- package/services/encode/CushionReport.js +1 -76
- package/services/encode/EncodeInputs.js +142 -0
- package/services/encode/EncodeOrchestrator.js +4 -1
- package/services/encode/EncodeRun.js +13 -4
- package/services/encode/EncodeRuns.js +132 -52
- package/services/encode/InputFailures.js +37 -0
- package/services/encode/MatroskaInput.js +158 -0
- package/services/encode/OutputOpening.js +16 -64
- package/services/encode/Renditions.js +1 -17
- package/services/encode/admitted-command.js +60 -0
- package/services/encode/annex-b-input.js +37 -0
- package/services/encode/bit-payload.js +29 -0
- package/services/encode/output/OutputCatalog.js +1 -1
- package/services/encode/output/Timeline.js +11 -0
- package/services/encode/pcm-normalization.js +30 -0
- package/services/encode/piece-completeness.js +54 -1
- package/services/encode/quality/HostLoad.js +19 -73
- package/services/encode/quality/QualityOffer.js +1 -1
- package/services/encode/run-command.js +1 -589
- package/services/encode/segment-formats/mp4-boxes.js +3 -1
- package/services/encode/segment-formats/presentation-segment.js +114 -0
- package/services/media/ContainerOrchestrator.js +118 -48
- package/services/media/KeyframeTables.js +16 -115
- package/services/media/MediaReadRequests.js +120 -0
- package/services/media/ProbeRequests.js +135 -0
- package/services/media/SegmentInputs.js +48 -0
- package/services/media/SourceFile.js +6 -0
- package/services/media/SubtitleOrchestrator.js +190 -4
- package/services/media/await-plan.js +47 -0
- package/services/media/container/AsfContainer.js +217 -0
- package/services/media/container/AviContainer.js +343 -34
- package/services/media/container/CadencedPictureOrder.js +45 -0
- package/services/media/container/Container.js +7 -3
- package/services/media/container/ContainerFactory.js +10 -3
- package/services/media/container/FfprobeContainer.js +222 -0
- package/services/media/container/MatroskaContainer.js +72 -46
- package/services/media/container/Mp4Container.js +472 -139
- package/services/media/container/MpegPsContainer.js +281 -0
- package/services/media/container/MpegTsContainer.js +277 -0
- package/services/media/container/PacketIndex.js +270 -0
- package/services/media/container/PacketRecords.js +237 -0
- package/services/media/container/RetainedBytes.js +39 -0
- package/services/media/container/RetainedReads.js +51 -0
- package/services/media/container/ac3-frame.js +46 -0
- package/services/media/container/asf-packets.js +153 -0
- package/services/media/container/avc-picture-order.js +50 -0
- package/services/media/container/avi-open-dml.js +47 -0
- package/services/media/container/dts-frame.js +51 -0
- package/services/media/container/ebml-stream.js +12 -1
- package/services/media/container/ffprobe-record.js +45 -0
- package/services/media/container/flv-packet-ranges.js +27 -0
- package/services/media/container/following-packet-time.js +37 -0
- package/services/media/container/h264-configuration.js +183 -0
- package/services/media/container/h264-elementary-index.js +245 -0
- package/services/media/container/hevc-configuration.js +165 -0
- package/services/media/container/hevc-elementary-index.js +132 -0
- package/services/media/container/hevc-picture-order.js +66 -0
- package/services/media/container/latm-frame.js +99 -0
- package/services/media/container/matroska-packets.js +207 -0
- package/services/media/container/memory-unavailable.js +8 -0
- package/services/media/container/mp3-packet-dependencies.js +41 -0
- package/services/media/container/mp4-fragments.js +148 -0
- package/services/media/container/mpeg-audio-frame.js +19 -0
- package/services/media/container/mpeg-audio-reservoir.js +27 -0
- package/services/media/container/mpeg-elementary-index.js +196 -0
- package/services/media/container/mpeg-sequence-reader.js +69 -0
- package/services/media/container/mpeg-ts-packets.js +93 -0
- package/services/media/container/mpeg-video-configuration.js +50 -0
- package/services/media/container/mpeg4-picture-timing.js +108 -0
- package/services/media/container/pes-header.js +34 -0
- package/services/media/playback-declarations.js +57 -0
- package/services/media/playback-planner.js +72 -230
- package/services/media/probe-packets.js +61 -0
- package/services/media/read-playback-declarations.js +18 -0
- package/services/media/tracks/AudioTrack.js +9 -0
- package/services/media/tracks/TextSubtitleTrack.js +4 -1
- package/services/server/OutputLifecycle.js +2 -0
- package/services/server/SegmentServing.js +40 -33
- package/services/server/ViewerRequests.js +22 -41
- package/services/server/controllers/PlaybackController.js +4 -0
- package/services/server/controllers/SubtitleController.js +8 -29
- package/services/server/transcode-session-files.js +58 -99
- package/services/server/wire-outputs.js +58 -15
- package/services/storage/IndexMemory.js +85 -0
- package/services/storage/MachineBudget.js +27 -3
- package/services/storage/allowance.js +8 -1
- package/services/storage/piece-store/piece-disk-store.js +18 -3
- package/services/storage/piece-store/piece-lru.js +8 -4
- package/services/storage/piece-store/shared-piece-store.js +26 -11
- package/services/storage/segment-store/SegmentStore.js +49 -18
- package/services/storage/wire.js +7 -0
- package/services/torrent/Contents.js +52 -20
- package/services/torrent/demand/DemandRegister.js +13 -4
- package/services/torrent/demand/Window.js +13 -2
- package/services/torrent/demand-progress.js +25 -0
- package/services/torrent/download/DeadlineScheduler.js +167 -0
- package/services/torrent/download/FutureDownload.js +166 -0
- package/services/torrent/download/SwarmSelection.js +30 -59
- package/services/torrent/download/registry.js +45 -5
- package/services/torrent/download/withdraw-claim.js +2 -0
- package/services/torrent/torrent-pool.js +39 -600
- package/services/torrent/worker/channel.js +8 -1
- package/services/torrent/worker/client.js +74 -72
- package/services/torrent/worker/files-in-use.js +9 -0
- package/services/torrent/worker/handle-state.js +12 -0
- package/services/torrent/worker/held-bytes.js +82 -99
- package/services/torrent/worker/held-file-bytes.js +17 -0
- package/services/torrent/worker/piece-reader.js +67 -683
- package/services/torrent/worker/pool-adapter.js +37 -123
- package/services/torrent/worker/protocol.js +6 -17
- package/services/torrent/worker/whole-file-stats.js +2 -0
- package/services/torrent/worker/worker.js +86 -156
- package/services/transport/data-channel-handler.js +56 -13
- package/services/transport/tunnel-client.js +111 -20
- package/services/viewer/DownloadMaps.js +168 -0
- package/services/viewer/NativeSourceMap.js +33 -0
- package/services/viewer/PriorityMap.js +49 -40
- package/services/viewer/PriorityOrchestrator.js +42 -13
- package/services/viewer/SourcePreparation.js +278 -0
- package/services/viewer/Viewer.js +11 -0
- package/services/viewer/Viewers.js +109 -2
- package/services/viewer/playback-readiness.js +50 -63
- package/services/viewer/segment-demands.js +37 -0
- package/services/viewer/urgent-output-ready.js +26 -0
- package/services/encode/run-inputs.js +0 -53
- package/services/media/keyframe-probe.js +0 -157
- package/services/media/media-info-probe.js +0 -93
- package/services/torrent/worker/background-fill.js +0 -213
- package/services/torrent/worker/resume-warm.js +0 -107
|
@@ -16,11 +16,17 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { Container } from "./Container.js";
|
|
19
|
+
import { RetainedBytes } from "./RetainedBytes.js";
|
|
19
20
|
import { VideoTrack } from "../tracks/VideoTrack.js";
|
|
20
21
|
import { AudioTrack } from "../tracks/AudioTrack.js";
|
|
21
22
|
import { TextSubtitleTrack, TEXT_FORMATS_MP4 } from "../tracks/TextSubtitleTrack.js";
|
|
22
23
|
import { ImageSubtitleTrack } from "../tracks/ImageSubtitleTrack.js";
|
|
23
24
|
import { isUnavailable } from "./unavailable.js";
|
|
25
|
+
import { PacketIndex } from "./PacketIndex.js";
|
|
26
|
+
import { h264Configuration } from "./h264-configuration.js";
|
|
27
|
+
import { hevcConfiguration } from "./hevc-configuration.js";
|
|
28
|
+
import { mpegAudioFrame } from "./mpeg-audio-frame.js";
|
|
29
|
+
import { readMp4Fragments } from "./mp4-fragments.js";
|
|
24
30
|
|
|
25
31
|
/** A box header is eight bytes, or sixteen when the size field says 1 (§4.2). */
|
|
26
32
|
const HEADER_BYTES = 8;
|
|
@@ -111,20 +117,84 @@ function readAudioSampleEntry(buf, entry) {
|
|
|
111
117
|
return null;
|
|
112
118
|
}
|
|
113
119
|
const version = buf.readUInt16BE(base + 8);
|
|
114
|
-
|
|
115
|
-
|
|
120
|
+
let channels = buf.readUInt16BE(base + 16);
|
|
121
|
+
let sampleRate = buf.readUInt32BE(base + 24) / 65536;
|
|
122
|
+
let bitDepth = buf.readUInt16BE(base + 18);
|
|
123
|
+
let codecId = entry.type;
|
|
124
|
+
if (entry.type === ".mp3") codecId = "mp3";
|
|
125
|
+
let codecDelaySeconds = 0;
|
|
126
|
+
let seekPrerollSeconds = 0;
|
|
116
127
|
const childrenStart = base + 28 + (version === 1 ? 16 : version === 2 ? 36 : 0);
|
|
128
|
+
if (version > 2 || childrenStart > entry.end) throw new Error("QuickTime audio sample description is incomplete or unsupported.");
|
|
129
|
+
if (version === 2) {
|
|
130
|
+
sampleRate = buf.readDoubleBE(base + 32);
|
|
131
|
+
channels = buf.readUInt32BE(base + 40);
|
|
132
|
+
bitDepth = buf.readUInt32BE(base + 48);
|
|
133
|
+
if (entry.type === "lpcm") {
|
|
134
|
+
const flags = buf.readUInt32BE(base + 52);
|
|
135
|
+
if ((flags & 32) || !(flags & 8)) throw new Error("QuickTime LPCM needs packed interleaved samples.");
|
|
136
|
+
codecId = `pcm_${flags & 1 ? "f" : flags & 4 ? "s" : "u"}${bitDepth}${bitDepth === 8 ? "" : flags & 2 ? "be" : "le"}`;
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
codecId = new Map([["sowt", `pcm_s${bitDepth}le`], ["twos", `pcm_s${bitDepth}be`],
|
|
140
|
+
["in24", "pcm_s24be"], ["in32", "pcm_s32be"], ["fl32", "pcm_f32be"], ["fl64", "pcm_f64be"], ["raw ", "pcm_u8"]]).get(entry.type) ?? entry.type;
|
|
141
|
+
const declaredBits = /^pcm_[suf](\d+)/.exec(codecId);
|
|
142
|
+
if (declaredBits) bitDepth = Number(declaredBits[1]);
|
|
143
|
+
const wave = childOf(buf, childrenStart, entry.end, "wave");
|
|
144
|
+
const endian = childOf(buf, childrenStart, entry.end, "enda") ??
|
|
145
|
+
(wave && childOf(buf, wave.dataOffset, wave.end, "enda"));
|
|
146
|
+
if (endian && /^pcm_[sf]\d+(?:be|le)$/.test(codecId)) {
|
|
147
|
+
if (endian.end - endian.dataOffset !== 2) throw new Error("QuickTime PCM byte-order declaration is invalid.");
|
|
148
|
+
const little = buf.readUInt16BE(endian.dataOffset);
|
|
149
|
+
if (little > 1) throw new Error("QuickTime PCM byte order is unsupported.");
|
|
150
|
+
codecId = codecId.replace(/(?:be|le)$/, little ? "le" : "be");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
117
153
|
let decoderConfig = null;
|
|
118
154
|
if (entry.type === "mp4a" && childrenStart < entry.end) {
|
|
119
|
-
const
|
|
155
|
+
const wave = childOf(buf, childrenStart, Math.min(entry.end, buf.length), "wave");
|
|
156
|
+
const esds = childOf(buf, childrenStart, Math.min(entry.end, buf.length), "esds") ??
|
|
157
|
+
(wave && childOf(buf, wave.dataOffset, wave.end, "esds"));
|
|
120
158
|
if (esds) {
|
|
121
|
-
|
|
159
|
+
const description = decoderSpecificInfoOf(buf, esds.dataOffset + 4, Math.min(esds.end, buf.length));
|
|
160
|
+
decoderConfig = description?.decoderConfig ?? null;
|
|
161
|
+
if ([0x69, 0x6b].includes(description?.objectType)) {
|
|
162
|
+
codecId = "mpeg_audio";
|
|
163
|
+
decoderConfig = null;
|
|
164
|
+
}
|
|
122
165
|
}
|
|
123
166
|
}
|
|
167
|
+
if (entry.type === "alac") {
|
|
168
|
+
const config = childOf(buf, childrenStart, entry.end, "alac");
|
|
169
|
+
if (!config || config.end - config.dataOffset < 28) throw new Error("ALAC decoder configuration is absent or truncated.");
|
|
170
|
+
decoderConfig = buf.subarray(config.dataOffset + 4, config.end);
|
|
171
|
+
}
|
|
172
|
+
if (entry.type === "Opus") {
|
|
173
|
+
const config = childOf(buf, childrenStart, entry.end, "dOps");
|
|
174
|
+
if (!config || config.end - config.dataOffset < 11) throw new Error("MP4 Opus decoder declaration is absent or truncated.");
|
|
175
|
+
const data = buf.subarray(config.dataOffset, config.end);
|
|
176
|
+
const family = data[10];
|
|
177
|
+
if (data[0] !== 0 || !data[1] || (!family && data[1] > 2) || (family && data.length < 13 + data[1])) {
|
|
178
|
+
throw new Error("MP4 Opus channel mapping is invalid.");
|
|
179
|
+
}
|
|
180
|
+
const head = Buffer.alloc(19);
|
|
181
|
+
head.write("OpusHead");
|
|
182
|
+
head[8] = 1;
|
|
183
|
+
head[9] = data[1];
|
|
184
|
+
head.writeUInt16LE(data.readUInt16BE(2), 10);
|
|
185
|
+
head.writeUInt32LE(data.readUInt32BE(4), 12);
|
|
186
|
+
head.writeInt16LE(data.readInt16BE(8), 16);
|
|
187
|
+
head[18] = family;
|
|
188
|
+
decoderConfig = family ? Buffer.concat([head, data.subarray(11, 13 + data[1])]) : head;
|
|
189
|
+
codecDelaySeconds = data.readUInt16BE(2) / 48000;
|
|
190
|
+
seekPrerollSeconds = 0.08;
|
|
191
|
+
sampleRate = 48000;
|
|
192
|
+
channels = data[1];
|
|
193
|
+
}
|
|
124
194
|
return {
|
|
125
195
|
channels: channels > 0 ? channels : null,
|
|
126
196
|
sampleRate: sampleRate > 0 ? sampleRate : null,
|
|
127
|
-
decoderConfig
|
|
197
|
+
decoderConfig, bitDepth: bitDepth > 0 ? bitDepth : null, codecId, codecDelaySeconds, seekPrerollSeconds
|
|
128
198
|
};
|
|
129
199
|
}
|
|
130
200
|
|
|
@@ -140,41 +210,49 @@ function readAudioSampleEntry(buf, entry) {
|
|
|
140
210
|
* @returns {Buffer | null}
|
|
141
211
|
*/
|
|
142
212
|
function decoderSpecificInfoOf(buf, start, end) {
|
|
143
|
-
const descriptorAt = (offset) => {
|
|
144
|
-
if (offset >=
|
|
213
|
+
const descriptorAt = (offset, limit = end) => {
|
|
214
|
+
if (offset >= limit) return null;
|
|
145
215
|
const tag = buf[offset];
|
|
146
216
|
let size = 0;
|
|
147
217
|
let cursor = offset + 1;
|
|
148
|
-
|
|
218
|
+
let complete = false;
|
|
219
|
+
for (let i = 0; i < 4 && cursor < limit; i += 1) {
|
|
149
220
|
const byte = buf[cursor];
|
|
150
221
|
cursor += 1;
|
|
151
222
|
size = (size * 128) + (byte & 0x7f);
|
|
152
|
-
if ((byte & 0x80) === 0) break;
|
|
223
|
+
if ((byte & 0x80) === 0) { complete = true; break; }
|
|
153
224
|
}
|
|
154
|
-
|
|
225
|
+
if (!complete || cursor + size > limit) throw new Error("MP4 audio descriptor exceeds its declared parent.");
|
|
226
|
+
return { tag, dataOffset: cursor, end: cursor + size };
|
|
155
227
|
};
|
|
156
228
|
const es = descriptorAt(start);
|
|
157
229
|
if (!es || es.tag !== 0x03 || es.dataOffset + 3 > es.end) return null;
|
|
158
230
|
const flags = buf[es.dataOffset + 2];
|
|
159
231
|
let cursor = es.dataOffset + 3;
|
|
160
232
|
if (flags & 0x80) cursor += 2;
|
|
161
|
-
if (flags & 0x40)
|
|
233
|
+
if (flags & 0x40) {
|
|
234
|
+
if (cursor >= es.end) throw new Error("MP4 audio descriptor URL length is absent.");
|
|
235
|
+
cursor += 1 + buf[cursor];
|
|
236
|
+
}
|
|
162
237
|
if (flags & 0x20) cursor += 2;
|
|
238
|
+
if (cursor > es.end) throw new Error("MP4 audio descriptor flags exceed its declared size.");
|
|
163
239
|
while (cursor < es.end) {
|
|
164
|
-
const descriptor = descriptorAt(cursor);
|
|
240
|
+
const descriptor = descriptorAt(cursor, es.end);
|
|
165
241
|
if (!descriptor) return null;
|
|
166
242
|
if (descriptor.tag === 0x04) {
|
|
167
243
|
// objectTypeIndication, streamType, bufferSizeDB, maxBitrate, avgBitrate.
|
|
244
|
+
if (descriptor.dataOffset + 13 > descriptor.end) throw new Error("MP4 audio decoder descriptor is truncated.");
|
|
245
|
+
const objectType = buf[descriptor.dataOffset];
|
|
168
246
|
let inner = descriptor.dataOffset + 13;
|
|
169
247
|
while (inner < descriptor.end) {
|
|
170
|
-
const child = descriptorAt(inner);
|
|
248
|
+
const child = descriptorAt(inner, descriptor.end);
|
|
171
249
|
if (!child) return null;
|
|
172
250
|
if (child.tag === 0x05) {
|
|
173
|
-
return child.end > child.dataOffset ? Buffer.from(buf.subarray(child.dataOffset, child.end)) : null;
|
|
251
|
+
return { objectType, decoderConfig: child.end > child.dataOffset ? Buffer.from(buf.subarray(child.dataOffset, child.end)) : null };
|
|
174
252
|
}
|
|
175
253
|
inner = child.end;
|
|
176
254
|
}
|
|
177
|
-
return null;
|
|
255
|
+
return { objectType, decoderConfig: null };
|
|
178
256
|
}
|
|
179
257
|
cursor = descriptor.end;
|
|
180
258
|
}
|
|
@@ -182,10 +260,13 @@ function decoderSpecificInfoOf(buf, start, end) {
|
|
|
182
260
|
}
|
|
183
261
|
|
|
184
262
|
export class Mp4Container extends Container {
|
|
263
|
+
#tracks = null;
|
|
185
264
|
get formatName() {
|
|
186
265
|
return "mp4";
|
|
187
266
|
}
|
|
188
267
|
|
|
268
|
+
packetIndexBytes() { return this.packetIndex?.allocatedBytes() ?? 0; }
|
|
269
|
+
|
|
189
270
|
static detect(head) {
|
|
190
271
|
return isMp4(head);
|
|
191
272
|
}
|
|
@@ -330,6 +411,7 @@ export class Mp4Container extends Container {
|
|
|
330
411
|
}
|
|
331
412
|
|
|
332
413
|
async readTracks() {
|
|
414
|
+
if (this.#tracks) return this.#tracks;
|
|
333
415
|
const head = await this.readRange(0, Math.min(64 - 1, this.fileSize - 1));
|
|
334
416
|
if (!isMp4(head)) return [];
|
|
335
417
|
|
|
@@ -379,6 +461,18 @@ export class Mp4Container extends Container {
|
|
|
379
461
|
// them above when they were stpp (non-text not in plan's tracks). The plan already excludes stpp from tracks
|
|
380
462
|
// but increments declaredIndex, so alignment holds: we don't need extra placeholders.
|
|
381
463
|
}
|
|
464
|
+
const pendingAudio = tracks.filter(track => track.codecId === "mpeg_audio");
|
|
465
|
+
if (pendingAudio.length) {
|
|
466
|
+
this.fragmentState ??= {};
|
|
467
|
+
await readMp4Fragments({ readRange: this.readRange, fileSize: this.fileSize,
|
|
468
|
+
tracks: fragmentTracks(held, tracks), memory: this.packetMemory, state: this.fragmentState,
|
|
469
|
+
firstTrackIds: pendingAudio.map(track => track.trackNumber) });
|
|
470
|
+
for (const track of pendingAudio) {
|
|
471
|
+
if (track.codecId !== "mpeg_audio") continue;
|
|
472
|
+
throw new Error("MP4 MPEG audio has no addressed sample declaring its codec.");
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
this.#tracks = tracks;
|
|
382
476
|
return tracks;
|
|
383
477
|
}
|
|
384
478
|
|
|
@@ -412,11 +506,34 @@ export class Mp4Container extends Container {
|
|
|
412
506
|
this.moovHeld = null;
|
|
413
507
|
return null;
|
|
414
508
|
}
|
|
415
|
-
|
|
416
|
-
|
|
509
|
+
this.moovMemory ??= new RetainedBytes(this.packetMemory);
|
|
510
|
+
const moov = await this.moovMemory.read(found.size,
|
|
511
|
+
() => this.readRange(found.offset, found.offset + found.size - 1));
|
|
512
|
+
this.moovHeld = { moov, header: found.headerBytes, offset: found.offset };
|
|
417
513
|
return this.moovHeld;
|
|
418
514
|
}
|
|
419
515
|
|
|
516
|
+
/** Exact decode-order samples from the same moov used for all metadata. */
|
|
517
|
+
async readPacketIndex() {
|
|
518
|
+
if (this.packetIndex) return this.packetIndex;
|
|
519
|
+
const held = await this.#moovBuffer();
|
|
520
|
+
if (!held) throw new Error(this.moovRefused ? "MP4 metadata exceeds the configured read limit." : "MP4 has no sample tables.");
|
|
521
|
+
const tracks = await this.readTracks();
|
|
522
|
+
const fragmented = childOf(held.moov, held.header, held.moov.length, "mvex");
|
|
523
|
+
let index;
|
|
524
|
+
if (fragmented) {
|
|
525
|
+
this.fragmentState ??= {};
|
|
526
|
+
index = await readMp4Fragments({ readRange: this.readRange, fileSize: this.fileSize,
|
|
527
|
+
tracks: fragmentTracks(held, tracks), memory: this.packetMemory, state: this.fragmentState });
|
|
528
|
+
const timeline = tracks.find(track => track.type === "video") ?? tracks.find(track => track.type === "audio");
|
|
529
|
+
const bounds = timeline && index.boundsOf(timeline.trackNumber);
|
|
530
|
+
if (bounds) this.mediaInfo = { ...(await this.readMediaInfo()), startTimeSeconds: bounds.start,
|
|
531
|
+
durationSeconds: bounds.end - bounds.start };
|
|
532
|
+
} else index = packetIndexFromMoov(held, this.fileSize, tracks, this.packetMemory);
|
|
533
|
+
this.packetIndex = index;
|
|
534
|
+
return index;
|
|
535
|
+
}
|
|
536
|
+
|
|
420
537
|
/**
|
|
421
538
|
* Duration from `mvhd` and the presentation offset from the first track's
|
|
422
539
|
* edit list, per ISO/IEC 14496-12 §8.2.2 and §8.6.6.
|
|
@@ -540,14 +657,15 @@ export class Mp4Container extends Container {
|
|
|
540
657
|
let height = null;
|
|
541
658
|
if (tkhd) {
|
|
542
659
|
const ver = moov[tkhd.dataOffset];
|
|
543
|
-
const flags = moov.
|
|
660
|
+
const flags = moov.readUIntBE(tkhd.dataOffset + 1, 3);
|
|
544
661
|
isEnabled = (flags & 0x000001) !== 0;
|
|
545
662
|
trackId = moov.readUInt32BE(ver === 1 ? tkhd.dataOffset + 20 : tkhd.dataOffset + 12);
|
|
546
|
-
|
|
663
|
+
const groupAt = tkhd.dataOffset + (ver === 1 ? 46 : 34);
|
|
664
|
+
if (groupAt + 2 <= tkhd.end) alternateGroup = moov.readUInt16BE(groupAt);
|
|
547
665
|
// width/height are 16.16 fixed point at end of tkhd
|
|
548
|
-
if (tkhd.end - tkhd.dataOffset >= 84) {
|
|
549
|
-
const w = moov.readUInt32BE(ver === 1 ? tkhd.dataOffset +
|
|
550
|
-
const h = moov.readUInt32BE(ver === 1 ? tkhd.dataOffset +
|
|
666
|
+
if (tkhd.end - tkhd.dataOffset >= (ver === 1 ? 96 : 84)) {
|
|
667
|
+
const w = moov.readUInt32BE(ver === 1 ? tkhd.dataOffset + 88 : tkhd.dataOffset + 76);
|
|
668
|
+
const h = moov.readUInt32BE(ver === 1 ? tkhd.dataOffset + 92 : tkhd.dataOffset + 80);
|
|
551
669
|
width = w / 65536;
|
|
552
670
|
height = h / 65536;
|
|
553
671
|
}
|
|
@@ -557,14 +675,31 @@ export class Mp4Container extends Container {
|
|
|
557
675
|
videoIdx += 1;
|
|
558
676
|
// stsd format for codecId
|
|
559
677
|
let codecId = "";
|
|
678
|
+
let codecPrivateB64 = "";
|
|
679
|
+
const displayWidth = width;
|
|
680
|
+
const displayHeight = height;
|
|
560
681
|
const minf = childOf(moov, mdia.dataOffset, mdia.end, "minf");
|
|
561
682
|
const stbl = minf && childOf(moov, minf.dataOffset, minf.end, "stbl");
|
|
562
683
|
const stsd = stbl && childOf(moov, stbl.dataOffset, stbl.end, "stsd");
|
|
563
684
|
if (stsd) {
|
|
564
685
|
const first = readBox(moov, stsd.dataOffset + 8);
|
|
565
|
-
if (first)
|
|
686
|
+
if (first) {
|
|
687
|
+
codecId = first.type;
|
|
688
|
+
if (first.end - first.dataOffset >= 78) {
|
|
689
|
+
width = moov.readUInt16BE(first.dataOffset + 24);
|
|
690
|
+
height = moov.readUInt16BE(first.dataOffset + 26);
|
|
691
|
+
}
|
|
692
|
+
for (const name of ["avcC", "hvcC", "av1C"]) {
|
|
693
|
+
const configuration = childOf(moov, first.dataOffset + 78, first.end, name);
|
|
694
|
+
if (configuration) codecPrivateB64 = moov.subarray(configuration.dataOffset, configuration.end).toString("base64");
|
|
695
|
+
}
|
|
696
|
+
}
|
|
566
697
|
}
|
|
567
|
-
|
|
698
|
+
const configuration = codecPrivateB64 && ["avc1", "avc3"].includes(codecId)
|
|
699
|
+
? h264Configuration(Buffer.from(codecPrivateB64, "base64"))
|
|
700
|
+
: codecPrivateB64 && ["hvc1", "hev1"].includes(codecId)
|
|
701
|
+
? hevcConfiguration(Buffer.from(codecPrivateB64, "base64")) : null;
|
|
702
|
+
const track = new VideoTrack({
|
|
568
703
|
trackNumber: trackId,
|
|
569
704
|
declaredIndex: videoIdx,
|
|
570
705
|
codecId,
|
|
@@ -574,11 +709,20 @@ export class Mp4Container extends Container {
|
|
|
574
709
|
isEnabled,
|
|
575
710
|
isDefault: true,
|
|
576
711
|
declaresDefault: false,
|
|
577
|
-
codecPrivateB64
|
|
712
|
+
codecPrivateB64,
|
|
578
713
|
alternateGroup,
|
|
579
|
-
width,
|
|
580
|
-
height
|
|
581
|
-
|
|
714
|
+
width: configuration?.width ?? width,
|
|
715
|
+
height: configuration?.height ?? height,
|
|
716
|
+
displayWidth,
|
|
717
|
+
displayHeight,
|
|
718
|
+
fps: configuration?.fps ?? videoSampleRate(moov, mdhd, stbl),
|
|
719
|
+
bitDepth: configuration?.bitDepth ?? null
|
|
720
|
+
});
|
|
721
|
+
if (configuration) {
|
|
722
|
+
track.codecConfiguration = configuration;
|
|
723
|
+
track.reorderDepth = configuration.reorderDepth;
|
|
724
|
+
}
|
|
725
|
+
result.push(track);
|
|
582
726
|
} else if (handler === "soun") {
|
|
583
727
|
audioIdx += 1;
|
|
584
728
|
let codecId = "";
|
|
@@ -589,8 +733,22 @@ export class Mp4Container extends Container {
|
|
|
589
733
|
if (stsd) {
|
|
590
734
|
const first = readBox(moov, stsd.dataOffset + 8);
|
|
591
735
|
if (first) {
|
|
592
|
-
codecId = first.type;
|
|
593
736
|
sampleEntry = readAudioSampleEntry(moov, first);
|
|
737
|
+
codecId = sampleEntry?.codecId ?? first.type;
|
|
738
|
+
if (codecId === "mpeg_audio" && !childOf(moov, held.header, moov.length, "mvex")) {
|
|
739
|
+
const chunks = childOf(moov, stbl.dataOffset, stbl.end, "stco") ?? childOf(moov, stbl.dataOffset, stbl.end, "co64");
|
|
740
|
+
const sizes = childOf(moov, stbl.dataOffset, stbl.end, "stsz");
|
|
741
|
+
if (!chunks || !sizes || !moov.readUInt32BE(chunks.dataOffset + 4) || !moov.readUInt32BE(sizes.dataOffset + 8)) {
|
|
742
|
+
throw new Error("MP4 MPEG audio has no addressed first sample.");
|
|
743
|
+
}
|
|
744
|
+
const offset = chunks.type === "co64" ? Number(moov.readBigUInt64BE(chunks.dataOffset + 8)) : moov.readUInt32BE(chunks.dataOffset + 8);
|
|
745
|
+
const size = moov.readUInt32BE(sizes.dataOffset + 4) || moov.readUInt32BE(sizes.dataOffset + 12);
|
|
746
|
+
if (!Number.isSafeInteger(offset) || size < 4 || offset + size > this.fileSize) throw new Error("MP4 MPEG audio sample address is invalid.");
|
|
747
|
+
const facts = mpegAudioFrame(await this.readRange(offset, offset + 3));
|
|
748
|
+
codecId = facts.codecId;
|
|
749
|
+
sampleEntry.channels = facts.channels;
|
|
750
|
+
sampleEntry.sampleRate = facts.sampleRate;
|
|
751
|
+
}
|
|
594
752
|
}
|
|
595
753
|
}
|
|
596
754
|
result.push(new AudioTrack({
|
|
@@ -603,15 +761,17 @@ export class Mp4Container extends Container {
|
|
|
603
761
|
isEnabled,
|
|
604
762
|
isDefault: true,
|
|
605
763
|
declaresDefault: false,
|
|
606
|
-
// The
|
|
607
|
-
// is what Matroska writes as CodecPrivate for the same codec.
|
|
764
|
+
// The sample entry's decoder configuration in its Matroska declaration form.
|
|
608
765
|
codecPrivateB64: sampleEntry?.decoderConfig ? sampleEntry.decoderConfig.toString("base64") : "",
|
|
609
766
|
alternateGroup,
|
|
610
767
|
isOriginal: false,
|
|
611
768
|
isCommentary: false,
|
|
612
769
|
isVisualImpaired: false,
|
|
613
770
|
channels: sampleEntry?.channels ?? null,
|
|
614
|
-
samplingFrequency: sampleEntry?.sampleRate ?? null
|
|
771
|
+
samplingFrequency: sampleEntry?.sampleRate ?? null,
|
|
772
|
+
bitDepth: sampleEntry?.bitDepth ?? null,
|
|
773
|
+
codecDelaySeconds: sampleEntry?.codecDelaySeconds ?? 0,
|
|
774
|
+
seekPrerollSeconds: sampleEntry?.seekPrerollSeconds ?? 0
|
|
615
775
|
}));
|
|
616
776
|
}
|
|
617
777
|
}
|
|
@@ -641,6 +801,10 @@ export class Mp4Container extends Container {
|
|
|
641
801
|
|
|
642
802
|
async parseKeyframeIndex() {
|
|
643
803
|
const held = await this.#moovBuffer();
|
|
804
|
+
if (held && childOf(held.moov, held.header, held.moov.length, "mvex")) {
|
|
805
|
+
const video = (await this.readTracks()).find(track => track.type === "video");
|
|
806
|
+
return video ? { times: (await this.readPacketIndex()).keyframesOf(video.trackNumber), tolerance: 0 } : null;
|
|
807
|
+
}
|
|
644
808
|
const r = held ? keyframeTimesFromMoov(held.moov, held.header) : null;
|
|
645
809
|
if (!r) return null;
|
|
646
810
|
if (Array.isArray(r)) return { times: r, tolerance: 0 };
|
|
@@ -648,6 +812,69 @@ export class Mp4Container extends Container {
|
|
|
648
812
|
}
|
|
649
813
|
}
|
|
650
814
|
|
|
815
|
+
/** Average decoded-frame cadence from the declared sample timing table. */
|
|
816
|
+
function videoSampleRate(bytes, mdhd, stbl) {
|
|
817
|
+
if (!mdhd || !stbl) return null;
|
|
818
|
+
const scaleAt = mdhd.dataOffset + (bytes[mdhd.dataOffset] === 1 ? 20 : 12);
|
|
819
|
+
if (scaleAt + 4 > mdhd.end) throw new Error("MP4 video timescale is truncated.");
|
|
820
|
+
const scale = bytes.readUInt32BE(scaleAt);
|
|
821
|
+
const stts = childOf(bytes, stbl.dataOffset, stbl.end, "stts");
|
|
822
|
+
if (!stts) return null;
|
|
823
|
+
if (stts.dataOffset + 8 > stts.end) throw new Error("MP4 video timing table is truncated.");
|
|
824
|
+
const count = bytes.readUInt32BE(stts.dataOffset + 4);
|
|
825
|
+
if (stts.dataOffset + 8 + count * 8 !== stts.end) throw new Error("MP4 video timing count differs from its table size.");
|
|
826
|
+
let samples = 0, ticks = 0;
|
|
827
|
+
for (let at = stts.dataOffset + 8; at < stts.end; at += 8) {
|
|
828
|
+
const n = bytes.readUInt32BE(at), duration = bytes.readUInt32BE(at + 4);
|
|
829
|
+
samples += n;
|
|
830
|
+
ticks += n * duration;
|
|
831
|
+
if (!Number.isSafeInteger(samples) || !Number.isSafeInteger(ticks)) throw new Error("MP4 video timing exceeds its integer range.");
|
|
832
|
+
}
|
|
833
|
+
return scale > 0 && samples > 0 && ticks > 0 ? samples * scale / ticks : null;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function fragmentTracks({ moov, header }, tracks) {
|
|
837
|
+
const movie = childOf(moov, header, moov.length, "mvhd");
|
|
838
|
+
const movieScale = movie && moov.readUInt32BE(movie.dataOffset + (moov[movie.dataOffset] === 1 ? 20 : 12));
|
|
839
|
+
const mvex = childOf(moov, header, moov.length, "mvex");
|
|
840
|
+
const defaults = new Map(childrenOf(moov, mvex.dataOffset, mvex.end, "trex").map(trex => {
|
|
841
|
+
if (trex.dataOffset + 24 !== trex.end) throw new Error("MP4 fragment defaults are incomplete.");
|
|
842
|
+
const at = trex.dataOffset;
|
|
843
|
+
return [moov.readUInt32BE(at + 4), { description: moov.readUInt32BE(at + 8),
|
|
844
|
+
duration: moov.readUInt32BE(at + 12), size: moov.readUInt32BE(at + 16), flags: moov.readUInt32BE(at + 20) }];
|
|
845
|
+
}));
|
|
846
|
+
return childrenOf(moov, header, moov.length, "trak").map(trak => {
|
|
847
|
+
const tkhd = childOf(moov, trak.dataOffset, trak.end, "tkhd");
|
|
848
|
+
const mdia = childOf(moov, trak.dataOffset, trak.end, "mdia");
|
|
849
|
+
if (!tkhd || !mdia) throw new Error("MP4 fragment track declaration is absent.");
|
|
850
|
+
const id = moov.readUInt32BE(tkhd.dataOffset + (moov[tkhd.dataOffset] === 1 ? 20 : 12));
|
|
851
|
+
const track = tracks.find(track => track.trackNumber === id);
|
|
852
|
+
const mdhd = childOf(moov, mdia.dataOffset, mdia.end, "mdhd");
|
|
853
|
+
const scale = mdhd && moov.readUInt32BE(mdhd.dataOffset + (moov[mdhd.dataOffset] === 1 ? 20 : 12));
|
|
854
|
+
if (!track || !scale || !defaults.has(id)) throw new Error("MP4 fragment track settings are incomplete.");
|
|
855
|
+
let shift = 0;
|
|
856
|
+
const edts = childOf(moov, trak.dataOffset, trak.end, "edts");
|
|
857
|
+
const elst = edts && childOf(moov, edts.dataOffset, edts.end, "elst");
|
|
858
|
+
if (elst) {
|
|
859
|
+
const wide = moov[elst.dataOffset] === 1, count = moov.readUInt32BE(elst.dataOffset + 4);
|
|
860
|
+
const width = wide ? 20 : 12;
|
|
861
|
+
if (moov[elst.dataOffset] > 1 || elst.dataOffset + 8 + count * width !== elst.end) throw new Error("MP4 fragment edits are incomplete.");
|
|
862
|
+
let mediaEdit = false;
|
|
863
|
+
for (let entry = 0, at = elst.dataOffset + 8; entry < count; entry++, at += width) {
|
|
864
|
+
const duration = wide ? Number(moov.readBigUInt64BE(at)) : moov.readUInt32BE(at);
|
|
865
|
+
const time = wide ? Number(moov.readBigInt64BE(at + 8)) : moov.readInt32BE(at + 4);
|
|
866
|
+
const rate = at + (wide ? 16 : 8);
|
|
867
|
+
if (!Number.isSafeInteger(duration) || !Number.isSafeInteger(time) ||
|
|
868
|
+
moov.readInt16BE(rate) !== 1 || moov.readInt16BE(rate + 2) !== 0 || mediaEdit) throw new Error("MP4 fragment edits require a different presentation mapping.");
|
|
869
|
+
if (time === -1 && movieScale) shift += duration / movieScale;
|
|
870
|
+
else if (time >= 0) { shift -= time / scale; mediaEdit = true; }
|
|
871
|
+
else throw new Error("MP4 fragment edit has no valid presentation time.");
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
return { id, track, scale, shift, ...defaults.get(id) };
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
|
|
651
878
|
// ---------------------------------------------------------------------------
|
|
652
879
|
// The MP4's own reading of its subtitle sample table. Here because every rule
|
|
653
880
|
// in it is ISO/IEC 14496-12 speaking about this container, and the class is
|
|
@@ -705,80 +932,170 @@ const TEXT_FORMATS = new Set(["tx3g", "text", "wvtt"]);
|
|
|
705
932
|
* @param {number} total - How many samples the size table declares.
|
|
706
933
|
* @returns {number[]} Ticks each sample lasts.
|
|
707
934
|
*/
|
|
708
|
-
function
|
|
709
|
-
const
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
const
|
|
716
|
-
|
|
717
|
-
|
|
935
|
+
function packetIndexFromMoov({ moov, header, offset: moovOffset }, fileSize, declaredTracks, packetMemory) {
|
|
936
|
+
const index = new PacketIndex({ packetMemory });
|
|
937
|
+
try {
|
|
938
|
+
const movie = childOf(moov, header, moov.length, "mvhd");
|
|
939
|
+
const movieScaleAt = movie ? movie.dataOffset + (moov[movie.dataOffset] === 1 ? 20 : 12) : 0;
|
|
940
|
+
const movieScale = movie ? moov.readUInt32BE(movieScaleAt) : 0;
|
|
941
|
+
for (const trak of childrenOf(moov, header, moov.length, "trak")) {
|
|
942
|
+
const tkhd = childOf(moov, trak.dataOffset, trak.end, "tkhd");
|
|
943
|
+
const mdia = childOf(moov, trak.dataOffset, trak.end, "mdia");
|
|
944
|
+
if (!tkhd || !mdia) throw new Error("MP4 track address is absent.");
|
|
945
|
+
const id = moov.readUInt32BE(tkhd.dataOffset + (moov[tkhd.dataOffset] === 1 ? 20 : 12));
|
|
946
|
+
const track = declaredTracks.find(one => one.trackNumber === id);
|
|
947
|
+
if (!track || (!["video", "audio"].includes(track.type) &&
|
|
948
|
+
!(track.type === "subtitle" && track.isTextBased()))) continue;
|
|
949
|
+
const mdhd = childOf(moov, mdia.dataOffset, mdia.end, "mdhd");
|
|
950
|
+
const scale = mdhd && moov.readUInt32BE(mdhd.dataOffset + (moov[mdhd.dataOffset] === 1 ? 20 : 12));
|
|
951
|
+
if (!(scale > 0)) throw new Error("MP4 media timescale is invalid.");
|
|
952
|
+
const minf = childOf(moov, mdia.dataOffset, mdia.end, "minf");
|
|
953
|
+
const stbl = minf && childOf(moov, minf.dataOffset, minf.end, "stbl");
|
|
954
|
+
if (!stbl) throw new Error("MP4 track sample table is absent.");
|
|
955
|
+
const table = name => childOf(moov, stbl.dataOffset, stbl.end, name);
|
|
956
|
+
const stsz = table("stsz"), stts = table("stts"), stsc = table("stsc"), stsd = table("stsd");
|
|
957
|
+
const chunks = table("stco") ?? table("co64");
|
|
958
|
+
if (!stsz || !stts || !stsc || !stsd || !chunks) throw new Error("MP4 track sample tables are incomplete.");
|
|
959
|
+
const count = moov.readUInt32BE(stsz.dataOffset + 8);
|
|
960
|
+
const uniform = moov.readUInt32BE(stsz.dataOffset + 4);
|
|
961
|
+
if ((!uniform && stsz.dataOffset + 12 + count * 4 > stsz.end) ||
|
|
962
|
+
(uniform && count * uniform > fileSize)) throw new Error("MP4 sample sizes exceed the file.");
|
|
963
|
+
const sizeAt = sample => uniform || moov.readUInt32BE(stsz.dataOffset + 12 + sample * 4);
|
|
964
|
+
const validateRuns = (box, width) => {
|
|
965
|
+
const entries = moov.readUInt32BE(box.dataOffset + 4);
|
|
966
|
+
if (box.dataOffset + 8 + entries * width > box.end) throw new Error("MP4 sample table is truncated.");
|
|
967
|
+
return entries;
|
|
968
|
+
};
|
|
969
|
+
const timeEntries = validateRuns(stts, 8);
|
|
970
|
+
let timedSamples = 0;
|
|
971
|
+
for (let entry = 0; entry < timeEntries; entry++) {
|
|
972
|
+
timedSamples += moov.readUInt32BE(stts.dataOffset + 8 + entry * 8);
|
|
973
|
+
}
|
|
974
|
+
if (timedSamples !== count) throw new Error("MP4 sample timing count differs from its size count.");
|
|
975
|
+
const nextDuration = sampleRunReader(moov, stts);
|
|
976
|
+
const ctts = table("ctts");
|
|
977
|
+
if (ctts) {
|
|
978
|
+
const entries = validateRuns(ctts, 8);
|
|
979
|
+
let sample = 0;
|
|
980
|
+
if (moov[ctts.dataOffset] > 1) throw new Error("MP4 composition table version is invalid.");
|
|
981
|
+
for (let entry = 0; entry < entries; entry++) {
|
|
982
|
+
const at = ctts.dataOffset + 8 + entry * 8;
|
|
983
|
+
const run = moov.readUInt32BE(at);
|
|
984
|
+
if (sample + run > count) throw new Error("MP4 composition count exceeds its size count.");
|
|
985
|
+
sample += run;
|
|
986
|
+
}
|
|
987
|
+
if (sample !== count) throw new Error("MP4 composition count differs from its size count.");
|
|
988
|
+
}
|
|
989
|
+
const nextComposition = ctts ? sampleRunReader(moov, ctts, moov[ctts.dataOffset] === 1) : () => 0;
|
|
990
|
+
const chunkWidth = chunks.type === "co64" ? 8 : 4;
|
|
991
|
+
const chunkCount = validateRuns(chunks, chunkWidth);
|
|
992
|
+
const chunkRuns = validateRuns(stsc, 12);
|
|
993
|
+
let declaredCount = 0;
|
|
994
|
+
let previousChunk = 0;
|
|
995
|
+
for (let run = 0; run < chunkRuns; run++) {
|
|
996
|
+
const at = stsc.dataOffset + 8 + run * 12;
|
|
997
|
+
const first = moov.readUInt32BE(at), perChunk = moov.readUInt32BE(at + 4);
|
|
998
|
+
const next = run + 1 < chunkRuns ? moov.readUInt32BE(at + 12) : chunkCount + 1;
|
|
999
|
+
if (first <= previousChunk || (run === 0 && first !== 1) || next <= first || next > chunkCount + 1 || !perChunk) {
|
|
1000
|
+
throw new Error("MP4 sample-to-chunk run is invalid.");
|
|
1001
|
+
}
|
|
1002
|
+
declaredCount += (next - first) * perChunk;
|
|
1003
|
+
previousChunk = first;
|
|
718
1004
|
}
|
|
1005
|
+
if (declaredCount !== count) throw new Error("MP4 chunk sample count differs from its size count.");
|
|
1006
|
+
const nextAddress = sampleAddressReader(moov, stsc, chunks, chunkWidth, chunkRuns);
|
|
1007
|
+
const stss = table("stss");
|
|
1008
|
+
let syncEntries = 0;
|
|
1009
|
+
if (stss) {
|
|
1010
|
+
syncEntries = validateRuns(stss, 4);
|
|
1011
|
+
let previous = 0;
|
|
1012
|
+
for (let entry = 0; entry < syncEntries; entry++) {
|
|
1013
|
+
const sample = moov.readUInt32BE(stss.dataOffset + 8 + entry * 4);
|
|
1014
|
+
if (sample <= previous || sample > count) throw new Error("MP4 sync sample is outside the sample table or unordered.");
|
|
1015
|
+
previous = sample;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
let emptyShiftSeconds = 0;
|
|
1019
|
+
let mediaShiftTicks = 0;
|
|
1020
|
+
const edts = childOf(moov, trak.dataOffset, trak.end, "edts");
|
|
1021
|
+
const edits = edts && childOf(moov, edts.dataOffset, edts.end, "elst");
|
|
1022
|
+
if (edits) {
|
|
1023
|
+
const version = moov[edits.dataOffset], wide = version === 1;
|
|
1024
|
+
if (version > 1) throw new Error("MP4 edit list version is invalid.");
|
|
1025
|
+
const entries = validateRuns(edits, wide ? 20 : 12);
|
|
1026
|
+
let emptySeconds = 0, mediaEdit = false;
|
|
1027
|
+
for (let entry = 0; entry < entries; entry++) {
|
|
1028
|
+
const at = edits.dataOffset + 8 + entry * (wide ? 20 : 12);
|
|
1029
|
+
const duration = wide ? Number(moov.readBigUInt64BE(at)) : moov.readUInt32BE(at);
|
|
1030
|
+
const mediaTime = wide ? Number(moov.readBigInt64BE(at + 8)) : moov.readInt32BE(at + 4);
|
|
1031
|
+
const rateAt = at + (wide ? 16 : 8);
|
|
1032
|
+
if (moov.readInt16BE(rateAt) !== 1 || moov.readInt16BE(rateAt + 2) !== 0) throw new Error("MP4 non-unit edit rate requires a presentation mapping.");
|
|
1033
|
+
if (mediaTime === -1 && !mediaEdit && movieScale > 0) emptySeconds += duration / movieScale;
|
|
1034
|
+
else if (mediaTime >= 0 && !mediaEdit) { emptyShiftSeconds = emptySeconds; mediaShiftTicks = mediaTime; mediaEdit = true; }
|
|
1035
|
+
else throw new Error("MP4 repeated edits require a presentation mapping.");
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
const audioPreroll = track.seekPrerollSeconds || (track.codecId === "Opus" ? 0.08 : 0);
|
|
1039
|
+
index.declareTrack(id, { type: track.type, codecId: track.codecId,
|
|
1040
|
+
codecRanges: track.type === "subtitle" ? [] : [[moovOffset + stsd.dataOffset, moovOffset + stsd.end - 1]],
|
|
1041
|
+
prerollSeconds: audioPreroll, reorderDepth: track.reorderDepth ?? 0 });
|
|
1042
|
+
index.reservePackets(id, count);
|
|
1043
|
+
let decodeTicks = 0, syncAt = 0;
|
|
1044
|
+
for (let sample = 0; sample < count; sample++) {
|
|
1045
|
+
const duration = nextDuration();
|
|
1046
|
+
const pts = (decodeTicks + nextComposition() - mediaShiftTicks) / scale + emptyShiftSeconds;
|
|
1047
|
+
const dts = (decodeTicks - mediaShiftTicks) / scale + emptyShiftSeconds;
|
|
1048
|
+
decodeTicks += duration;
|
|
1049
|
+
const size = sizeAt(sample), start = nextAddress(size);
|
|
1050
|
+
const keyframe = !stss || (syncAt < syncEntries && moov.readUInt32BE(stss.dataOffset + 8 + syncAt * 4) === sample + 1);
|
|
1051
|
+
if (stss && keyframe) syncAt++;
|
|
1052
|
+
if (size === 0) continue;
|
|
1053
|
+
if (!Number.isSafeInteger(start) || start < 0 || start + size > fileSize) throw new Error("MP4 sample address exceeds the file.");
|
|
1054
|
+
const discardPaddingSeconds = track.type === "audio" && !(track.codecDelaySeconds > 0)
|
|
1055
|
+
? -Math.min(duration / scale, Math.max(0, emptyShiftSeconds - pts)) : 0;
|
|
1056
|
+
index.append(id, { pts, dts, duration: duration / scale, keyframe, ranges: [[start, start + size - 1]],
|
|
1057
|
+
...(discardPaddingSeconds ? { discardPaddingSeconds } : {}) });
|
|
1058
|
+
}
|
|
1059
|
+
index.complete(id);
|
|
1060
|
+
}
|
|
1061
|
+
return index;
|
|
1062
|
+
} catch (error) {
|
|
1063
|
+
index.dispose();
|
|
1064
|
+
throw error;
|
|
719
1065
|
}
|
|
720
|
-
return durations;
|
|
721
1066
|
}
|
|
722
1067
|
|
|
723
|
-
/**
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
return
|
|
735
|
-
}
|
|
736
|
-
const sizes = new Array(count).fill(0);
|
|
737
|
-
let at = stsz.dataOffset + 12;
|
|
738
|
-
for (let index = 0; index < count && at + 4 <= stsz.end; index += 1, at += 4) {
|
|
739
|
-
sizes[index] = moov.readUInt32BE(at);
|
|
740
|
-
}
|
|
741
|
-
return sizes;
|
|
1068
|
+
/** Read compressed timing runs without expanding a whole-track array. */
|
|
1069
|
+
function sampleRunReader(bytes, table, signed = false) {
|
|
1070
|
+
let at = table.dataOffset + 8, remaining = 0, value = 0;
|
|
1071
|
+
return () => {
|
|
1072
|
+
while (!remaining) {
|
|
1073
|
+
if (at + 8 > table.end) throw new Error("MP4 sample timing run ended early.");
|
|
1074
|
+
remaining = bytes.readUInt32BE(at);
|
|
1075
|
+
value = signed ? bytes.readInt32BE(at + 4) : bytes.readUInt32BE(at + 4);
|
|
1076
|
+
at += 8;
|
|
1077
|
+
}
|
|
1078
|
+
remaining--;
|
|
1079
|
+
return value;
|
|
1080
|
+
};
|
|
742
1081
|
}
|
|
743
1082
|
|
|
744
|
-
/**
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
*
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
* @param {number[]} sizes
|
|
755
|
-
* @returns {number[]}
|
|
756
|
-
*/
|
|
757
|
-
function sampleOffsets(moov, stsc, chunkOffsets, sizes) {
|
|
758
|
-
const offsets = new Array(sizes.length).fill(0);
|
|
759
|
-
const entries = moov.readUInt32BE(stsc.dataOffset + 4);
|
|
760
|
-
/** @type {{ firstChunk: number, perChunk: number }[]} */
|
|
761
|
-
const runs = [];
|
|
762
|
-
let at = stsc.dataOffset + 8;
|
|
763
|
-
for (let entry = 0; entry < entries && at + 12 <= stsc.end; entry += 1, at += 12) {
|
|
764
|
-
runs.push({ firstChunk: moov.readUInt32BE(at), perChunk: moov.readUInt32BE(at + 4) });
|
|
765
|
-
}
|
|
766
|
-
let sample = 0;
|
|
767
|
-
for (let run = 0; run < runs.length && sample < sizes.length; run += 1) {
|
|
768
|
-
const from = runs[run].firstChunk;
|
|
769
|
-
const to = run + 1 < runs.length ? runs[run + 1].firstChunk - 1 : chunkOffsets.length;
|
|
770
|
-
for (let chunk = from; chunk <= to && sample < sizes.length; chunk += 1) {
|
|
771
|
-
let inChunk = chunkOffsets[chunk - 1];
|
|
772
|
-
if (inChunk === undefined) {
|
|
773
|
-
break;
|
|
774
|
-
}
|
|
775
|
-
for (let index = 0; index < runs[run].perChunk && sample < sizes.length; index += 1, sample += 1) {
|
|
776
|
-
offsets[sample] = inChunk;
|
|
777
|
-
inChunk += sizes[sample];
|
|
778
|
-
}
|
|
1083
|
+
/** Sample addresses follow chunk runs directly from the retained tables. */
|
|
1084
|
+
function sampleAddressReader(bytes, stsc, chunks, width, runs) {
|
|
1085
|
+
let chunk = 1, run = 0, remaining = 0, address = 0;
|
|
1086
|
+
return size => {
|
|
1087
|
+
if (!remaining) {
|
|
1088
|
+
while (run + 1 < runs && bytes.readUInt32BE(stsc.dataOffset + 8 + (run + 1) * 12) <= chunk) run++;
|
|
1089
|
+
remaining = bytes.readUInt32BE(stsc.dataOffset + 12 + run * 12);
|
|
1090
|
+
const at = chunks.dataOffset + 8 + (chunk - 1) * width;
|
|
1091
|
+
address = width === 8 ? Number(bytes.readBigUInt64BE(at)) : bytes.readUInt32BE(at);
|
|
1092
|
+
chunk++;
|
|
779
1093
|
}
|
|
780
|
-
|
|
781
|
-
|
|
1094
|
+
const start = address;
|
|
1095
|
+
address += size;
|
|
1096
|
+
remaining--;
|
|
1097
|
+
return start;
|
|
1098
|
+
};
|
|
782
1099
|
}
|
|
783
1100
|
|
|
784
1101
|
/**
|
|
@@ -812,6 +1129,57 @@ function sampleOffsets(moov, stsc, chunkOffsets, sizes) {
|
|
|
812
1129
|
* @param {Buffer} moov - The whole `moov` box, its header included.
|
|
813
1130
|
* @returns {{ tracks: Mp4SubtitleTrack[] } | null}
|
|
814
1131
|
*/
|
|
1132
|
+
function subtitleSampleView(bytes, { stsz, stts, stsc, chunks, timescale }) {
|
|
1133
|
+
const count = bytes.readUInt32BE(stsz.dataOffset + 8), uniform = bytes.readUInt32BE(stsz.dataOffset + 4);
|
|
1134
|
+
const sizeAt = sample => uniform || bytes.readUInt32BE(stsz.dataOffset + 12 + sample * 4);
|
|
1135
|
+
if (!uniform && stsz.dataOffset + 12 + count * 4 > stsz.end) throw new Error("MP4 subtitle sample sizes are truncated.");
|
|
1136
|
+
const entries = (table, width) => {
|
|
1137
|
+
const value = bytes.readUInt32BE(table.dataOffset + 4);
|
|
1138
|
+
if (table.dataOffset + 8 + value * width > table.end) throw new Error("MP4 subtitle sample table is truncated.");
|
|
1139
|
+
return value;
|
|
1140
|
+
};
|
|
1141
|
+
const timeEntries = entries(stts, 8), runs = entries(stsc, 12), width = chunks.type === "co64" ? 8 : 4;
|
|
1142
|
+
const chunkCount = entries(chunks, width);
|
|
1143
|
+
let timed = 0, addressed = 0, previous = 0;
|
|
1144
|
+
for (let entry = 0; entry < timeEntries; entry++) timed += bytes.readUInt32BE(stts.dataOffset + 8 + entry * 8);
|
|
1145
|
+
for (let run = 0; run < runs; run++) {
|
|
1146
|
+
const at = stsc.dataOffset + 8 + run * 12;
|
|
1147
|
+
const first = bytes.readUInt32BE(at), perChunk = bytes.readUInt32BE(at + 4);
|
|
1148
|
+
const next = run + 1 < runs ? bytes.readUInt32BE(at + 12) : chunkCount + 1;
|
|
1149
|
+
if (first <= previous || (run === 0 && first !== 1) || next <= first || next > chunkCount + 1 || !perChunk) throw new Error("MP4 subtitle chunk run is invalid.");
|
|
1150
|
+
addressed += (next - first) * perChunk;
|
|
1151
|
+
previous = first;
|
|
1152
|
+
}
|
|
1153
|
+
if (timed !== count || addressed !== count) throw new Error("MP4 subtitle sample tables disagree on their count.");
|
|
1154
|
+
let length = uniform ? uniform > 2 ? count : 0 : 0;
|
|
1155
|
+
if (!uniform) for (let sample = 0; sample < count; sample++) if (sizeAt(sample) > 2) length++;
|
|
1156
|
+
const view = {
|
|
1157
|
+
length,
|
|
1158
|
+
*[Symbol.iterator]() {
|
|
1159
|
+
if (!length) return;
|
|
1160
|
+
const nextDuration = sampleRunReader(bytes, stts);
|
|
1161
|
+
const nextAddress = sampleAddressReader(bytes, stsc, chunks, width, runs);
|
|
1162
|
+
let ticks = 0;
|
|
1163
|
+
for (let sample = 0; sample < count; sample++) {
|
|
1164
|
+
const startSeconds = ticks / timescale;
|
|
1165
|
+
ticks += nextDuration();
|
|
1166
|
+
const size = sizeAt(sample), offset = nextAddress(size);
|
|
1167
|
+
if (size > 2) yield { startSeconds, endSeconds: ticks / timescale, offset, size };
|
|
1168
|
+
}
|
|
1169
|
+
},
|
|
1170
|
+
map(callback) { return Array.from(this, callback); },
|
|
1171
|
+
at(index) {
|
|
1172
|
+
if (index < 0) index += length;
|
|
1173
|
+
if (!Number.isSafeInteger(index) || index < 0 || index >= length) return undefined;
|
|
1174
|
+
let current = 0;
|
|
1175
|
+
for (const sample of this) if (current++ === index) return sample;
|
|
1176
|
+
}
|
|
1177
|
+
};
|
|
1178
|
+
return new Proxy(view, { get(target, property, receiver) {
|
|
1179
|
+
return typeof property === "string" && /^(0|[1-9]\d*)$/.test(property) ? target.at(Number(property)) : Reflect.get(target, property, receiver);
|
|
1180
|
+
} });
|
|
1181
|
+
}
|
|
1182
|
+
|
|
815
1183
|
function subtitlePlanFromMoov(moov) {
|
|
816
1184
|
if (!moov || moov.length < HEADER_BYTES) {
|
|
817
1185
|
return null;
|
|
@@ -915,41 +1283,7 @@ function subtitlePlanFromMoov(moov) {
|
|
|
915
1283
|
continue;
|
|
916
1284
|
}
|
|
917
1285
|
|
|
918
|
-
const
|
|
919
|
-
const durations = sampleDurations(moov, stts, sizes.length);
|
|
920
|
-
const chunkOffsets = [];
|
|
921
|
-
if (stco) {
|
|
922
|
-
const count = moov.readUInt32BE(stco.dataOffset + 4);
|
|
923
|
-
let at = stco.dataOffset + 8;
|
|
924
|
-
for (let index = 0; index < count && at + 4 <= stco.end; index += 1, at += 4) {
|
|
925
|
-
chunkOffsets.push(moov.readUInt32BE(at));
|
|
926
|
-
}
|
|
927
|
-
} else {
|
|
928
|
-
const count = moov.readUInt32BE(co64.dataOffset + 4);
|
|
929
|
-
let at = co64.dataOffset + 8;
|
|
930
|
-
for (let index = 0; index < count && at + 8 <= co64.end; index += 1, at += 8) {
|
|
931
|
-
chunkOffsets.push(Number(moov.readBigUInt64BE(at)));
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
const offsets = sampleOffsets(moov, stsc, chunkOffsets, sizes);
|
|
935
|
-
|
|
936
|
-
/** @type {Mp4SubtitleSample[]} */
|
|
937
|
-
const samples = [];
|
|
938
|
-
let ticks = 0;
|
|
939
|
-
for (let index = 0; index < sizes.length; index += 1) {
|
|
940
|
-
const start = ticks / timescale;
|
|
941
|
-
ticks += durations[index];
|
|
942
|
-
// An empty sample is a gap between cues, which the format uses to say
|
|
943
|
-
// "nothing on screen"; it is not a cue and would show as a blank line.
|
|
944
|
-
if (sizes[index] > 2) {
|
|
945
|
-
samples.push({
|
|
946
|
-
startSeconds: start,
|
|
947
|
-
endSeconds: ticks / timescale,
|
|
948
|
-
offset: offsets[index],
|
|
949
|
-
size: sizes[index]
|
|
950
|
-
});
|
|
951
|
-
}
|
|
952
|
-
}
|
|
1286
|
+
const samples = subtitleSampleView(moov, { stsz, stts, stsc, chunks: stco ?? co64, timescale });
|
|
953
1287
|
tracks.push({
|
|
954
1288
|
trackId,
|
|
955
1289
|
declaredIndex,
|
|
@@ -1150,7 +1484,7 @@ function findAllBoxes(buffer, start, end, type) {
|
|
|
1150
1484
|
* @param {Set<number>} wanted - Sample numbers (1-based).
|
|
1151
1485
|
* @returns {number[]} Seconds, ascending.
|
|
1152
1486
|
*/
|
|
1153
|
-
function resolveSampleTimes(buffer, stts, timescale, wanted, offsets = null) {
|
|
1487
|
+
function resolveSampleTimes(buffer, stts, timescale, wanted, offsets = null, editShift = 0) {
|
|
1154
1488
|
const entryCount = buffer.readUInt32BE(stts.dataOffset + 4);
|
|
1155
1489
|
const times = [];
|
|
1156
1490
|
let sampleNumber = 1;
|
|
@@ -1164,7 +1498,7 @@ function resolveSampleTimes(buffer, stts, timescale, wanted, offsets = null) {
|
|
|
1164
1498
|
// `CT(n) = DT(n) + CTTS(n)` — ISO/IEC 14496-12 §8.6.1.3. The offset is
|
|
1165
1499
|
// what turns decode order into the order frames are shown in, and it is
|
|
1166
1500
|
// the timeline ffmpeg cuts on.
|
|
1167
|
-
times.push((ticks + (offsets?.get(sampleNumber) ?? 0)) / timescale);
|
|
1501
|
+
times.push((ticks + (offsets?.get(sampleNumber) ?? 0) - editShift) / timescale);
|
|
1168
1502
|
}
|
|
1169
1503
|
ticks += delta;
|
|
1170
1504
|
sampleNumber += 1;
|
|
@@ -1339,15 +1673,14 @@ function keyframeTimesFromMoov(moov, headerBytes) {
|
|
|
1339
1673
|
const elst = edts && findBox(moov, edts.dataOffset, edts.end, "elst");
|
|
1340
1674
|
const editShift = elst ? readEditShift(moov, elst) : 0;
|
|
1341
1675
|
|
|
1342
|
-
const times = resolveSampleTimes(moov, stts, timescale, wanted, offsets);
|
|
1676
|
+
const times = resolveSampleTimes(moov, stts, timescale, wanted, offsets, editShift);
|
|
1343
1677
|
if (times.length > 0) {
|
|
1344
1678
|
// A shift applied after the division would be in the wrong units: the
|
|
1345
1679
|
// edit's `media_time` is in MEDIA ticks, like everything else here.
|
|
1346
|
-
const shifted = editShift === 0 ? times : times.map((time) => time - editShift / timescale);
|
|
1347
1680
|
// A negative time is not a position in the file. It happens when an edit
|
|
1348
1681
|
// starts later than a keyframe the table lists, and those frames are not
|
|
1349
1682
|
// presented at all.
|
|
1350
|
-
return
|
|
1683
|
+
return times.filter((time) => time >= 0);
|
|
1351
1684
|
}
|
|
1352
1685
|
}
|
|
1353
1686
|
return null;
|