@torrent-tv/proxy 2.9.65 → 2.9.66
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/CHANGELOG.md +4 -0
- package/package.json +1 -1
- package/services/container-index/avi.js +156 -0
- package/services/container-index/index.js +46 -20
- package/services/container-index/mp4.js +246 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
## 2.9.66
|
|
2
|
+
|
|
3
|
+
- **New**: The container keyframe index now covers **MP4/MOV and AVI** as well as Matroska. MP4 reads the sync-sample and time-to-sample tables from `moov` — found by stepping over top-level box headers, so it works whether `moov` sits at the file start or the end, without scanning the gigabytes of `mdat` between them (verified on a 2 GB field file: **1145 keyframes in 625 ms**). AVI reads the trailing `idx1` table, still worth having because older releases are largely XviD-in-AVI and are exactly the files served by copying rather than re-encoding. Formats left out are documented in the module with the reason: MPEG-TS/M2TS carry no index anywhere by design, fragmented MP4 spreads timing across fragments instead of one table, and FLV/ASF have tables but effectively never appear in these releases.
|
|
4
|
+
|
|
1
5
|
## 2.9.65
|
|
2
6
|
|
|
3
7
|
- **Fix**: Segment boundaries on the video-COPY path are now the source's **real** keyframe positions, read from the container's own index (`services/container-index/`), instead of an invented 4 s grid. ffmpeg can only cut a copied stream at existing keyframes, so the declared grid was simply false — measured on a field file, the true keyframe spacing is **10.43 s**, meaning roughly two of every three declared boundaries could not exist. Players punish this in two ways, both seen in the field 2026-08-02: on a long file the player stops trusting the playlist and walks it from segment #1 to locate a seek (a 1:30 seek produced requests #1, #2, #45, #86 … #1187 and never arrived), and on a short one it presents **audio with no picture**, because a segment beginning without a keyframe has nothing to decode from.
|
package/package.json
CHANGED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Keyframe index for AVI, read without downloading the file.
|
|
3
|
+
*
|
|
4
|
+
* AVI ends with an `idx1` chunk: one fixed-size entry per stream chunk, each
|
|
5
|
+
* carrying a flags word whose keyframe bit says whether that chunk starts a
|
|
6
|
+
* keyframe. Frame number times the video stream's frame duration gives the
|
|
7
|
+
* time, so the index alone is enough — no media has to be read.
|
|
8
|
+
*
|
|
9
|
+
* `idx1` lives at the end of the file and the top-level chunk headers state
|
|
10
|
+
* their sizes, so it is reached by stepping over headers (typically two hops:
|
|
11
|
+
* `LIST hdrl`, `LIST movi`), not by scanning.
|
|
12
|
+
*
|
|
13
|
+
* Still relevant despite the format's age: older releases are largely XviD in
|
|
14
|
+
* AVI, and those are exactly the files that get copied rather than re-encoded.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const HEADER_BYTES = 8;
|
|
18
|
+
const PROBE_BYTES = 4096;
|
|
19
|
+
// Keyframe flag in an idx1 entry's flags word (AVIIF_KEYFRAME).
|
|
20
|
+
const KEYFRAME_FLAG = 0x10;
|
|
21
|
+
const IDX1_ENTRY_BYTES = 16;
|
|
22
|
+
// Cap on the idx1 read. One entry per chunk, 16 bytes each — a long film runs
|
|
23
|
+
// to a few MB; beyond this is not a normal index.
|
|
24
|
+
const MAX_IDX1_BYTES = 64 * 1024 * 1024;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Whether this looks like AVI: a RIFF container whose form type is `AVI `.
|
|
28
|
+
*
|
|
29
|
+
* @param {Buffer} head
|
|
30
|
+
* @returns {boolean}
|
|
31
|
+
*/
|
|
32
|
+
export function isAvi(head) {
|
|
33
|
+
return (
|
|
34
|
+
head.length >= 12 &&
|
|
35
|
+
head.toString("latin1", 0, 4) === "RIFF" &&
|
|
36
|
+
head.toString("latin1", 8, 12) === "AVI "
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Microseconds per frame and the video stream's chunk id prefix, from the main
|
|
42
|
+
* header. Both live in the `hdrl` list near the file start.
|
|
43
|
+
*
|
|
44
|
+
* @param {Buffer} head
|
|
45
|
+
* @returns {{ microsecondsPerFrame: number } | null}
|
|
46
|
+
*/
|
|
47
|
+
function readMainHeader(head) {
|
|
48
|
+
// Top-level: "RIFF" size "AVI " then chunks. `avih` sits inside `LIST hdrl`.
|
|
49
|
+
let offset = 12;
|
|
50
|
+
while (offset + HEADER_BYTES <= head.length) {
|
|
51
|
+
const id = head.toString("latin1", offset, offset + 4);
|
|
52
|
+
const size = head.readUInt32LE(offset + 4);
|
|
53
|
+
if (size <= 0) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
if (id === "LIST") {
|
|
57
|
+
// Descend: list type follows the header, then its own chunks.
|
|
58
|
+
const listType = head.toString("latin1", offset + 8, offset + 12);
|
|
59
|
+
if (listType === "hdrl") {
|
|
60
|
+
let inner = offset + 12;
|
|
61
|
+
while (inner + HEADER_BYTES <= Math.min(head.length, offset + 8 + size)) {
|
|
62
|
+
const innerId = head.toString("latin1", inner, inner + 4);
|
|
63
|
+
const innerSize = head.readUInt32LE(inner + 4);
|
|
64
|
+
if (innerSize <= 0) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
if (innerId === "avih" && inner + 8 + 4 <= head.length) {
|
|
68
|
+
return { microsecondsPerFrame: head.readUInt32LE(inner + 8) };
|
|
69
|
+
}
|
|
70
|
+
inner += HEADER_BYTES + innerSize + (innerSize % 2);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
offset += HEADER_BYTES + 4 + (size - 4) + ((size - 4) % 2);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
offset += HEADER_BYTES + size + (size % 2);
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Step over top-level chunks to find `idx1`.
|
|
83
|
+
*
|
|
84
|
+
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
85
|
+
* @param {number} fileSize
|
|
86
|
+
* @returns {Promise<{ offset: number, size: number } | null>}
|
|
87
|
+
*/
|
|
88
|
+
async function findIdx1(readRange, fileSize) {
|
|
89
|
+
let offset = 12; // Past "RIFF" size "AVI ".
|
|
90
|
+
while (offset + HEADER_BYTES < fileSize) {
|
|
91
|
+
const probe = await readRange(offset, Math.min(fileSize - 1, offset + HEADER_BYTES - 1));
|
|
92
|
+
if (!probe || probe.length < HEADER_BYTES) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const id = probe.toString("latin1", 0, 4);
|
|
96
|
+
const size = probe.readUInt32LE(4);
|
|
97
|
+
if (size <= 0) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
if (id === "idx1") {
|
|
101
|
+
return { offset: offset + HEADER_BYTES, size };
|
|
102
|
+
}
|
|
103
|
+
// Chunks are word-aligned; a LIST carries its type inside the payload, so
|
|
104
|
+
// the same size arithmetic covers both cases.
|
|
105
|
+
offset += HEADER_BYTES + size + (size % 2);
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Read the keyframe times of an AVI file.
|
|
112
|
+
*
|
|
113
|
+
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
114
|
+
* @param {number} fileSize
|
|
115
|
+
* @returns {Promise<number[] | null>} Ascending seconds, or null when the file
|
|
116
|
+
* has no `idx1` (OpenDML-only index, interrupted write, damaged upload).
|
|
117
|
+
*/
|
|
118
|
+
export async function readAviKeyframeTimes(readRange, fileSize) {
|
|
119
|
+
const head = await readRange(0, Math.min(PROBE_BYTES - 1, fileSize - 1));
|
|
120
|
+
if (!head || !isAvi(head)) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
const mainHeader = readMainHeader(head);
|
|
124
|
+
if (!mainHeader || !mainHeader.microsecondsPerFrame) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const idx1 = await findIdx1(readRange, fileSize);
|
|
129
|
+
if (!idx1 || idx1.size > MAX_IDX1_BYTES) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const table = await readRange(idx1.offset, Math.min(fileSize - 1, idx1.offset + idx1.size - 1));
|
|
134
|
+
if (!table || table.length < IDX1_ENTRY_BYTES) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const secondsPerFrame = mainHeader.microsecondsPerFrame / 1e6;
|
|
139
|
+
const times = [];
|
|
140
|
+
let videoFrame = 0;
|
|
141
|
+
for (let at = 0; at + IDX1_ENTRY_BYTES <= table.length; at += IDX1_ENTRY_BYTES) {
|
|
142
|
+
const chunkId = table.toString("latin1", at, at + 4);
|
|
143
|
+
// Video chunks are "##db" (uncompressed) or "##dc" (compressed); audio is
|
|
144
|
+
// "##wb" and must not advance the frame counter.
|
|
145
|
+
const isVideo = chunkId.endsWith("db") || chunkId.endsWith("dc");
|
|
146
|
+
if (!isVideo) {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const flags = table.readUInt32LE(at + 4);
|
|
150
|
+
if ((flags & KEYFRAME_FLAG) !== 0) {
|
|
151
|
+
times.push(videoFrame * secondsPerFrame);
|
|
152
|
+
}
|
|
153
|
+
videoFrame += 1;
|
|
154
|
+
}
|
|
155
|
+
return times.length > 0 ? times : null;
|
|
156
|
+
}
|
|
@@ -3,24 +3,26 @@
|
|
|
3
3
|
* from the container's own tables rather than by scanning the media.
|
|
4
4
|
*
|
|
5
5
|
* The problem it solves: on the video-COPY path ffmpeg can only cut segments at
|
|
6
|
-
* the source's existing keyframes.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* full packet scan of 5.5 GB found 77 keyframes in 45 s without finishing.
|
|
6
|
+
* the source's existing keyframes. A playlist declaring an even grid instead is
|
|
7
|
+
* then false, and players punish it — either walking the whole file to rebuild
|
|
8
|
+
* the timeline, or presenting audio with no picture because a segment begins
|
|
9
|
+
* with nothing decodable (both seen in the field 2026-08-02; the file measured
|
|
10
|
+
* had 10.43 s keyframe spacing against our declared 4 s).
|
|
12
11
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* Scanning for the answer is not an option here: the file is served from a
|
|
13
|
+
* torrent, and a full packet scan of 5.5 GB found 77 keyframes in 45 s without
|
|
14
|
+
* finishing. Containers already store the table — this reads it with a couple
|
|
15
|
+
* of point reads (16 KB, 0.8 s for 570 keyframes on that same file).
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* Transport-agnostic by construction: it takes a byte-range function and knows
|
|
18
|
+
* nothing about torrents, HTTP or our session model, which is what let it be
|
|
19
|
+
* verified standalone before being wired in.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { logger } from "../../utils/logger.js";
|
|
23
23
|
import { isMatroska, readMatroskaKeyframeTimes } from "./matroska.js";
|
|
24
|
+
import { isMp4, readMp4KeyframeTimes } from "./mp4.js";
|
|
25
|
+
import { isAvi, readAviKeyframeTimes } from "./avi.js";
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
28
|
* @callback ReadRange
|
|
@@ -29,9 +31,30 @@ import { isMatroska, readMatroskaKeyframeTimes } from "./matroska.js";
|
|
|
29
31
|
* @returns {Promise<Buffer | null>} The bytes, or null when unavailable.
|
|
30
32
|
*/
|
|
31
33
|
|
|
32
|
-
// Enough to identify
|
|
34
|
+
// Enough to identify every supported container from its opening bytes.
|
|
33
35
|
const SNIFF_BYTES = 16;
|
|
34
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Container readers, in detection order. Each pairs a cheap magic-byte test
|
|
39
|
+
* with the reader for that format.
|
|
40
|
+
*
|
|
41
|
+
* Formats deliberately absent, and why:
|
|
42
|
+
* - **MPEG-TS / M2TS** carry no index at all — the format is a continuous
|
|
43
|
+
* broadcast stream with no table of contents anywhere. Nothing to read.
|
|
44
|
+
* - **FLV / ASF-WMV** do have keyframe tables, but effectively never appear in
|
|
45
|
+
* the releases this serves; adding them is mechanical if that changes.
|
|
46
|
+
* - **Fragmented MP4** spreads its timing across fragments instead of a single
|
|
47
|
+
* `moov` table; `readMp4KeyframeTimes` returns null for it rather than
|
|
48
|
+
* guessing.
|
|
49
|
+
*
|
|
50
|
+
* @type {{ name: string, matches: (head: Buffer) => boolean, read: ReadRange extends never ? never : (readRange: ReadRange, fileSize: number) => Promise<number[] | null> }[]}
|
|
51
|
+
*/
|
|
52
|
+
const READERS = [
|
|
53
|
+
{ name: "matroska", matches: isMatroska, read: readMatroskaKeyframeTimes },
|
|
54
|
+
{ name: "mp4", matches: isMp4, read: readMp4KeyframeTimes },
|
|
55
|
+
{ name: "avi", matches: isAvi, read: readAviKeyframeTimes }
|
|
56
|
+
];
|
|
57
|
+
|
|
35
58
|
/**
|
|
36
59
|
* Read a file's keyframe times from its container index.
|
|
37
60
|
*
|
|
@@ -49,28 +72,31 @@ export async function readKeyframeIndex({ readRange, fileSize, label = "" }) {
|
|
|
49
72
|
|
|
50
73
|
const startedAt = Date.now();
|
|
51
74
|
let times = null;
|
|
75
|
+
let format = "unrecognised";
|
|
52
76
|
try {
|
|
53
77
|
const sniff = await readRange(0, Math.min(SNIFF_BYTES - 1, fileSize - 1));
|
|
54
78
|
if (!sniff) {
|
|
55
79
|
return null;
|
|
56
80
|
}
|
|
57
|
-
|
|
58
|
-
|
|
81
|
+
const reader = READERS.find((candidate) => candidate.matches(sniff));
|
|
82
|
+
if (reader) {
|
|
83
|
+
format = reader.name;
|
|
84
|
+
times = await reader.read(readRange, fileSize);
|
|
59
85
|
}
|
|
60
|
-
// Other containers fall through as null until their readers land; MP4's
|
|
61
|
-
// sync-sample table and AVI's index are the next candidates.
|
|
62
86
|
} catch (error) {
|
|
63
87
|
// A malformed or partially-downloaded index must never take playback down —
|
|
64
|
-
// it only means
|
|
88
|
+
// it only means the grid is unknown, which the caller already handles.
|
|
65
89
|
logger.warn(`container-index: failed to read index for "${label}": ${error?.message ?? error}`);
|
|
66
90
|
return null;
|
|
67
91
|
}
|
|
68
92
|
|
|
69
93
|
const elapsedMs = Date.now() - startedAt;
|
|
70
94
|
if (times) {
|
|
71
|
-
logger.info(
|
|
95
|
+
logger.info(
|
|
96
|
+
`container-index: ${times.length} keyframes from the ${format} index in ${elapsedMs}ms for "${label}"`
|
|
97
|
+
);
|
|
72
98
|
} else {
|
|
73
|
-
logger.info(`container-index: no usable index for "${label}" (${elapsedMs}ms)`);
|
|
99
|
+
logger.info(`container-index: no usable index for "${label}" (${format}, ${elapsedMs}ms)`);
|
|
74
100
|
}
|
|
75
101
|
return times;
|
|
76
102
|
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Keyframe index for MP4/MOV, read without downloading the file.
|
|
3
|
+
*
|
|
4
|
+
* MP4 keeps its tables in a `moov` box: `stss` lists which samples are sync
|
|
5
|
+
* samples (keyframes) by number, and `stts` gives each sample's duration, so
|
|
6
|
+
* the two together turn "sample #N" into "second T". `moov` sits either at the
|
|
7
|
+
* start (files written for streaming) or at the end (the common case for a
|
|
8
|
+
* plain mux); box headers state their own size, so it is found by stepping over
|
|
9
|
+
* top-level boxes rather than scanning bytes — a couple of 64-byte reads even
|
|
10
|
+
* when `mdat` is gigabytes.
|
|
11
|
+
*
|
|
12
|
+
* Same purpose as the Matroska reader: on the video-COPY path the segment
|
|
13
|
+
* boundaries ARE the source's keyframes, and inventing an even grid instead
|
|
14
|
+
* makes players walk the whole file or present audio with no picture.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// A 64-bit box size is signalled by a 32-bit size of 1, the real size following
|
|
18
|
+
// in the next 8 bytes.
|
|
19
|
+
const LARGE_SIZE_MARKER = 1;
|
|
20
|
+
const HEADER_BYTES = 8;
|
|
21
|
+
const LARGE_HEADER_BYTES = 16;
|
|
22
|
+
// Enough to read any box header while walking the top level.
|
|
23
|
+
const PROBE_BYTES = 64;
|
|
24
|
+
// Cap on the moov read. A feature-length file indexes to a few hundred KB;
|
|
25
|
+
// beyond this is not a normal index and not worth pulling over a torrent.
|
|
26
|
+
const MAX_MOOV_BYTES = 32 * 1024 * 1024;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether this looks like MP4/MOV — every real file opens with an `ftyp` box.
|
|
30
|
+
*
|
|
31
|
+
* @param {Buffer} head
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
export function isMp4(head) {
|
|
35
|
+
return head.length >= 12 && head.toString("latin1", 4, 8) === "ftyp";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read a box header at `offset`.
|
|
40
|
+
*
|
|
41
|
+
* @param {Buffer} buffer
|
|
42
|
+
* @param {number} offset
|
|
43
|
+
* @returns {{ type: string, size: number, headerBytes: number } | null}
|
|
44
|
+
*/
|
|
45
|
+
function readBoxHeader(buffer, offset) {
|
|
46
|
+
if (offset + HEADER_BYTES > buffer.length) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const size32 = buffer.readUInt32BE(offset);
|
|
50
|
+
const type = buffer.toString("latin1", offset + 4, offset + 8);
|
|
51
|
+
if (size32 === LARGE_SIZE_MARKER) {
|
|
52
|
+
if (offset + LARGE_HEADER_BYTES > buffer.length) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
// High word is zero for any file we can practically handle.
|
|
56
|
+
const high = buffer.readUInt32BE(offset + 8);
|
|
57
|
+
const low = buffer.readUInt32BE(offset + 12);
|
|
58
|
+
return { type, size: high * 4294967296 + low, headerBytes: LARGE_HEADER_BYTES };
|
|
59
|
+
}
|
|
60
|
+
return { type, size: size32, headerBytes: HEADER_BYTES };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Walk the top-level boxes to find `moov`, reading only each box header.
|
|
65
|
+
*
|
|
66
|
+
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
67
|
+
* @param {number} fileSize
|
|
68
|
+
* @returns {Promise<{ offset: number, size: number, headerBytes: number } | null>}
|
|
69
|
+
*/
|
|
70
|
+
async function findMoov(readRange, fileSize) {
|
|
71
|
+
let offset = 0;
|
|
72
|
+
while (offset < fileSize) {
|
|
73
|
+
const probe = await readRange(offset, Math.min(fileSize - 1, offset + PROBE_BYTES - 1));
|
|
74
|
+
if (!probe || probe.length < HEADER_BYTES) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const header = readBoxHeader(probe, 0);
|
|
78
|
+
// Size 0 means "extends to end of file" — legal only for the last box, and
|
|
79
|
+
// never for one we would step over.
|
|
80
|
+
if (!header || header.size <= 0) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
if (header.type === "moov") {
|
|
84
|
+
return { offset, size: header.size, headerBytes: header.headerBytes };
|
|
85
|
+
}
|
|
86
|
+
offset += header.size;
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Find the first box of `type` directly inside a range of an already-read buffer.
|
|
93
|
+
*
|
|
94
|
+
* @param {Buffer} buffer
|
|
95
|
+
* @param {number} start
|
|
96
|
+
* @param {number} end
|
|
97
|
+
* @param {string} type
|
|
98
|
+
* @returns {{ dataOffset: number, end: number } | null}
|
|
99
|
+
*/
|
|
100
|
+
function findBox(buffer, start, end, type) {
|
|
101
|
+
let offset = start;
|
|
102
|
+
while (offset + HEADER_BYTES <= end) {
|
|
103
|
+
const header = readBoxHeader(buffer, offset);
|
|
104
|
+
if (!header || header.size <= 0) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
if (header.type === type) {
|
|
108
|
+
return { dataOffset: offset + header.headerBytes, end: Math.min(end, offset + header.size) };
|
|
109
|
+
}
|
|
110
|
+
offset += header.size;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* All boxes of `type` directly inside a range.
|
|
117
|
+
*
|
|
118
|
+
* @param {Buffer} buffer
|
|
119
|
+
* @param {number} start
|
|
120
|
+
* @param {number} end
|
|
121
|
+
* @param {string} type
|
|
122
|
+
* @returns {{ dataOffset: number, end: number }[]}
|
|
123
|
+
*/
|
|
124
|
+
function findAllBoxes(buffer, start, end, type) {
|
|
125
|
+
const found = [];
|
|
126
|
+
let offset = start;
|
|
127
|
+
while (offset + HEADER_BYTES <= end) {
|
|
128
|
+
const header = readBoxHeader(buffer, offset);
|
|
129
|
+
if (!header || header.size <= 0) {
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
if (header.type === type) {
|
|
133
|
+
found.push({ dataOffset: offset + header.headerBytes, end: Math.min(end, offset + header.size) });
|
|
134
|
+
}
|
|
135
|
+
offset += header.size;
|
|
136
|
+
}
|
|
137
|
+
return found;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Turn sample numbers into seconds using the time-to-sample table.
|
|
142
|
+
*
|
|
143
|
+
* `stts` is run-length encoded — pairs of (sample count, per-sample duration) —
|
|
144
|
+
* so one walk yields every sample's start time without expanding the table.
|
|
145
|
+
*
|
|
146
|
+
* @param {Buffer} buffer
|
|
147
|
+
* @param {{ dataOffset: number, end: number }} stts
|
|
148
|
+
* @param {number} timescale - Ticks per second.
|
|
149
|
+
* @param {Set<number>} wanted - Sample numbers (1-based).
|
|
150
|
+
* @returns {number[]} Seconds, ascending.
|
|
151
|
+
*/
|
|
152
|
+
function resolveSampleTimes(buffer, stts, timescale, wanted) {
|
|
153
|
+
const entryCount = buffer.readUInt32BE(stts.dataOffset + 4);
|
|
154
|
+
const times = [];
|
|
155
|
+
let sampleNumber = 1;
|
|
156
|
+
let ticks = 0;
|
|
157
|
+
let cursor = stts.dataOffset + 8;
|
|
158
|
+
for (let entry = 0; entry < entryCount && cursor + 8 <= stts.end; entry += 1) {
|
|
159
|
+
const count = buffer.readUInt32BE(cursor);
|
|
160
|
+
const delta = buffer.readUInt32BE(cursor + 4);
|
|
161
|
+
for (let index = 0; index < count; index += 1) {
|
|
162
|
+
if (wanted.has(sampleNumber)) {
|
|
163
|
+
times.push(ticks / timescale);
|
|
164
|
+
}
|
|
165
|
+
ticks += delta;
|
|
166
|
+
sampleNumber += 1;
|
|
167
|
+
}
|
|
168
|
+
cursor += 8;
|
|
169
|
+
}
|
|
170
|
+
return times;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Read the keyframe times of an MP4/MOV file.
|
|
175
|
+
*
|
|
176
|
+
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
177
|
+
* @param {number} fileSize
|
|
178
|
+
* @returns {Promise<number[] | null>} Ascending seconds, or null when the file
|
|
179
|
+
* carries no usable index (fragmented MP4, truncated or damaged `moov`).
|
|
180
|
+
*/
|
|
181
|
+
export async function readMp4KeyframeTimes(readRange, fileSize) {
|
|
182
|
+
const moovBox = await findMoov(readRange, fileSize);
|
|
183
|
+
if (!moovBox || moovBox.size > MAX_MOOV_BYTES) {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const moov = await readRange(moovBox.offset, Math.min(fileSize - 1, moovBox.offset + moovBox.size - 1));
|
|
188
|
+
if (!moov || moov.length < moovBox.headerBytes) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Examine every track; the video one is whichever carries sync samples. A
|
|
193
|
+
// track with no `stss` has every sample a keyframe, so it constrains nothing
|
|
194
|
+
// and is skipped.
|
|
195
|
+
for (const trak of findAllBoxes(moov, moovBox.headerBytes, moov.length, "trak")) {
|
|
196
|
+
const mdia = findBox(moov, trak.dataOffset, trak.end, "mdia");
|
|
197
|
+
if (!mdia) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const mdhd = findBox(moov, mdia.dataOffset, mdia.end, "mdhd");
|
|
201
|
+
if (!mdhd) {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
// mdhd layout: version(1) + flags(3), then creation/modification times —
|
|
205
|
+
// 32-bit each in version 0, 64-bit in version 1 — then the timescale.
|
|
206
|
+
const version = moov[mdhd.dataOffset];
|
|
207
|
+
const timescaleOffset = version === 1 ? mdhd.dataOffset + 20 : mdhd.dataOffset + 12;
|
|
208
|
+
if (timescaleOffset + 4 > mdhd.end) {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const timescale = moov.readUInt32BE(timescaleOffset);
|
|
212
|
+
if (!timescale) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const minf = findBox(moov, mdia.dataOffset, mdia.end, "minf");
|
|
217
|
+
const stbl = minf && findBox(moov, minf.dataOffset, minf.end, "stbl");
|
|
218
|
+
if (!stbl) {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const stss = findBox(moov, stbl.dataOffset, stbl.end, "stss");
|
|
222
|
+
const stts = findBox(moov, stbl.dataOffset, stbl.end, "stts");
|
|
223
|
+
if (!stss || !stts) {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const syncCount = moov.readUInt32BE(stss.dataOffset + 4);
|
|
228
|
+
const wanted = new Set();
|
|
229
|
+
for (let index = 0; index < syncCount; index += 1) {
|
|
230
|
+
const at = stss.dataOffset + 8 + index * 4;
|
|
231
|
+
if (at + 4 > stss.end) {
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
wanted.add(moov.readUInt32BE(at));
|
|
235
|
+
}
|
|
236
|
+
if (wanted.size === 0) {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const times = resolveSampleTimes(moov, stts, timescale, wanted);
|
|
241
|
+
if (times.length > 0) {
|
|
242
|
+
return times;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return null;
|
|
246
|
+
}
|