@torrent-tv/proxy 2.74.1 → 2.75.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/CHANGELOG.md +8 -0
- package/CLAUDE.md +8 -5
- package/docs/container-architecture.md +25 -5
- package/package.json +1 -1
- package/services/audio-inventory.js +201 -411
- package/services/container/AviContainer.js +266 -81
- package/services/container/Container.js +248 -79
- package/services/container/ContainerFactory.js +67 -0
- package/services/container/MatroskaContainer.js +326 -7
- package/services/container/Mp4Container.js +372 -26
- package/services/hls-session-manager.js +2 -2
- package/services/playback-planner.js +43 -15
- package/services/torrent-worker/pool-adapter.js +352 -333
- package/services/tracks/AudioTrack.js +131 -40
- package/test/audio-inventory.test.js +176 -177
- package/test/matroska-cues-track.test.js +192 -192
- package/test/mp4-composition-times.test.js +0 -0
- package/test/video-facts.test.js +102 -0
- package/services/container-index/avi.js +0 -167
- package/services/container-index/index.js +0 -118
- package/services/container-index/matroska.js +0 -336
- package/services/container-index/mp4.js +0 -358
- /package/services/{container-index → container}/ebml-reader.js +0 -0
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Container keyframe index — where a file's real keyframes are, read
|
|
3
|
-
* from the container's own tables rather than by scanning the media.
|
|
4
|
-
*
|
|
5
|
-
* The problem it solves: on the video-COPY path ffmpeg can only cut segments at
|
|
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).
|
|
11
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*/
|
|
21
|
-
|
|
22
|
-
import { logger } from "../../utils/logger.js";
|
|
23
|
-
import { isMatroska, readMatroskaKeyframeTimes } from "./matroska.js";
|
|
24
|
-
import { isMp4, readMp4KeyframeTimes } from "./mp4.js";
|
|
25
|
-
import { isAvi, readAviKeyframeTimes } from "./avi.js";
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* @callback ReadRange
|
|
29
|
-
* @param {number} start - First byte, inclusive.
|
|
30
|
-
* @param {number} end - Last byte, inclusive.
|
|
31
|
-
* @returns {Promise<Buffer | null>} The bytes, or null when unavailable.
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
// Enough to identify every supported container from its opening bytes.
|
|
35
|
-
const SNIFF_BYTES = 16;
|
|
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
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Read a file's keyframe times from its container index.
|
|
60
|
-
*
|
|
61
|
-
* @param {object} params
|
|
62
|
-
* @param {ReadRange} params.readRange
|
|
63
|
-
* @param {number} params.fileSize
|
|
64
|
-
* @param {string} [params.label] - For logging only.
|
|
65
|
-
* @returns {Promise<{ times: number[] | null, format: string, tolerance: number }>} Ascending
|
|
66
|
-
* seconds, or null times when this file has no readable index — the caller
|
|
67
|
-
* must then not claim to know the grid. The format is which reader matched,
|
|
68
|
-
* reported whether or not it produced anything: how often an index disagrees
|
|
69
|
-
* with its own file is a question about the CONTAINER, and it cannot be
|
|
70
|
-
* answered by a measurement that does not say which one it came from.
|
|
71
|
-
*/
|
|
72
|
-
export async function readKeyframeIndex({ readRange, fileSize, label = "" }) {
|
|
73
|
-
if (typeof readRange !== "function" || !Number.isFinite(fileSize) || fileSize <= 0) {
|
|
74
|
-
return { times: null, format: "unknown", tolerance: 0 };
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const startedAt = Date.now();
|
|
78
|
-
let times = null;
|
|
79
|
-
let format = "unrecognised";
|
|
80
|
-
// How far a time in `times` may be from the instant it names. Zero wherever
|
|
81
|
-
// the container states instants outright, which Matroska and MP4 both do.
|
|
82
|
-
let tolerance = 0;
|
|
83
|
-
try {
|
|
84
|
-
const sniff = await readRange(0, Math.min(SNIFF_BYTES - 1, fileSize - 1));
|
|
85
|
-
if (!sniff) {
|
|
86
|
-
return { times: null, format: "unread", tolerance: 0 };
|
|
87
|
-
}
|
|
88
|
-
const reader = READERS.find((candidate) => candidate.matches(sniff));
|
|
89
|
-
if (reader) {
|
|
90
|
-
format = reader.name;
|
|
91
|
-
const read = await reader.read(readRange, fileSize);
|
|
92
|
-
// A reader may answer with the times alone, or with how far those times
|
|
93
|
-
// may sit from the instants they name — which only AVI has to say,
|
|
94
|
-
// because only AVI computes them from frame numbers.
|
|
95
|
-
if (Array.isArray(read)) {
|
|
96
|
-
times = read;
|
|
97
|
-
} else if (read && Array.isArray(read.times)) {
|
|
98
|
-
times = read.times;
|
|
99
|
-
tolerance = Number.isFinite(read.tolerance) ? read.tolerance : 0;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
} catch (error) {
|
|
103
|
-
// A malformed or partially-downloaded index must never take playback down —
|
|
104
|
-
// it only means the grid is unknown, which the caller already handles.
|
|
105
|
-
logger.warn(`container-index: failed to read index for "${label}": ${error?.message ?? error}`);
|
|
106
|
-
return { times: null, format, tolerance: 0 };
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const elapsedMs = Date.now() - startedAt;
|
|
110
|
-
if (times) {
|
|
111
|
-
logger.info(
|
|
112
|
-
`container-index: ${times.length} keyframes from the ${format} index in ${elapsedMs}ms for "${label}"`
|
|
113
|
-
);
|
|
114
|
-
} else {
|
|
115
|
-
logger.info(`container-index: no usable index for "${label}" (${format}, ${elapsedMs}ms)`);
|
|
116
|
-
}
|
|
117
|
-
return { times, format, tolerance };
|
|
118
|
-
}
|
|
@@ -1,336 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Keyframe index for Matroska (MKV/WebM), read without downloading the file.
|
|
3
|
-
*
|
|
4
|
-
* Matroska stores a Cues element — a table of "at time T, a keyframe starts at
|
|
5
|
-
* byte P" — and a SeekHead near the start listing where each top-level element
|
|
6
|
-
* lives. So two point reads suffice: the head, to learn where Cues is, then
|
|
7
|
-
* Cues itself. Measured on a 5.5 GB torrent-backed file: 4 KB + 12 KB, both
|
|
8
|
-
* effectively instant, versus a full packet scan that found 77 keyframes in
|
|
9
|
-
* 45 s and never finished.
|
|
10
|
-
*
|
|
11
|
-
* This matters because on the video-COPY path the encoder can only cut on the
|
|
12
|
-
* source's own keyframes. A playlist declaring an even grid is then a lie, and
|
|
13
|
-
* players react badly to it — either walking the whole file to rebuild the
|
|
14
|
-
* timeline, or presenting audio with no picture because a segment begins with
|
|
15
|
-
* no keyframe to decode from (both observed in the field 2026-08-02).
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { findElement, iterateElements, readUint } from "./ebml-reader.js";
|
|
19
|
-
|
|
20
|
-
// Element ids, from the Matroska specification.
|
|
21
|
-
const ID_SEGMENT = 0x18538067;
|
|
22
|
-
const ID_SEEK_HEAD = 0x114d9b74;
|
|
23
|
-
const ID_SEEK = 0x4dbb;
|
|
24
|
-
const ID_SEEK_ID = 0x53ab;
|
|
25
|
-
const ID_SEEK_POSITION = 0x53ac;
|
|
26
|
-
const ID_INFO = 0x1549a966;
|
|
27
|
-
const ID_TIMESTAMP_SCALE = 0x2ad7b1;
|
|
28
|
-
const ID_CUES = 0x1c53bb6b;
|
|
29
|
-
const ID_CUE_POINT = 0xbb;
|
|
30
|
-
const ID_CUE_TIME = 0xb3;
|
|
31
|
-
const ID_CUE_TRACK_POSITIONS = 0xb7;
|
|
32
|
-
const ID_CUE_TRACK = 0xf7;
|
|
33
|
-
const ID_TRACKS = 0x1654ae6b;
|
|
34
|
-
const ID_TRACK_ENTRY = 0xae;
|
|
35
|
-
const ID_TRACK_NUMBER = 0xd7;
|
|
36
|
-
const ID_TRACK_TYPE = 0x83;
|
|
37
|
-
// TrackType 1 is video; 2 is audio, 17 subtitles, and the rest are rarer still.
|
|
38
|
-
const TRACK_TYPE_VIDEO = 1;
|
|
39
|
-
|
|
40
|
-
// How much of the file start to read. Must cover the EBML header, the SeekHead
|
|
41
|
-
// and Info; 64 KB is generous for every real muxer (the file measured needed
|
|
42
|
-
// under 4 KB) while still trivial to fetch.
|
|
43
|
-
const HEAD_BYTES = 64 * 1024;
|
|
44
|
-
// Cap on the Cues read. A two-hour film indexes to tens of KB; anything beyond
|
|
45
|
-
// this is not a normal index and not worth pulling over a torrent.
|
|
46
|
-
const MAX_CUES_BYTES = 8 * 1024 * 1024;
|
|
47
|
-
// Cap on a Tracks read, for the rare file whose Tracks element sits outside the
|
|
48
|
-
// head window. Track entries are small, so a file with dozens of them still
|
|
49
|
-
// fits well inside this.
|
|
50
|
-
const MAX_TRACKS_BYTES = 1024 * 1024;
|
|
51
|
-
// Matroska's default timestamp scale (nanoseconds per tick) when Info omits it.
|
|
52
|
-
const DEFAULT_TIMESTAMP_SCALE = 1_000_000;
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Whether this looks like a Matroska file (the EBML magic `0x1A45DFA3`).
|
|
56
|
-
*
|
|
57
|
-
* @param {Buffer} head
|
|
58
|
-
* @returns {boolean}
|
|
59
|
-
*/
|
|
60
|
-
export function isMatroska(head) {
|
|
61
|
-
return head.length >= 4 && head.readUInt32BE(0) === 0x1a45dfa3;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Locate the Segment element and the SeekHead entries inside it.
|
|
66
|
-
*
|
|
67
|
-
* Seek positions are relative to the start of Segment's payload, not to the
|
|
68
|
-
* file, so that base has to come back with them.
|
|
69
|
-
*
|
|
70
|
-
* @param {Buffer} head
|
|
71
|
-
* @returns {{ segmentDataOffset: number, entries: Map<number, number> } | null}
|
|
72
|
-
*/
|
|
73
|
-
function readSeekHead(head) {
|
|
74
|
-
let segmentDataOffset = -1;
|
|
75
|
-
for (const element of iterateElements(head)) {
|
|
76
|
-
if (element.id === ID_SEGMENT) {
|
|
77
|
-
segmentDataOffset = element.dataOffset;
|
|
78
|
-
break;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
if (segmentDataOffset < 0) {
|
|
82
|
-
return null;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const seekHead = findElement(head, ID_SEEK_HEAD, [], segmentDataOffset);
|
|
86
|
-
if (!seekHead) {
|
|
87
|
-
return null;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const entries = new Map();
|
|
91
|
-
const seekHeadEnd = Math.min(head.length, seekHead.dataOffset + seekHead.size);
|
|
92
|
-
for (const seek of iterateElements(head, seekHead.dataOffset, seekHeadEnd)) {
|
|
93
|
-
if (seek.id !== ID_SEEK) {
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
const seekEnd = Math.min(seekHeadEnd, seek.dataOffset + seek.size);
|
|
97
|
-
let targetId = null;
|
|
98
|
-
let position = null;
|
|
99
|
-
for (const field of iterateElements(head, seek.dataOffset, seekEnd)) {
|
|
100
|
-
if (field.id === ID_SEEK_ID) {
|
|
101
|
-
targetId = readUint(head, field.dataOffset, field.size);
|
|
102
|
-
} else if (field.id === ID_SEEK_POSITION) {
|
|
103
|
-
position = readUint(head, field.dataOffset, field.size);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
if (targetId !== null && position !== null) {
|
|
107
|
-
entries.set(targetId, position);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
return { segmentDataOffset, entries };
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Timestamp scale (nanoseconds per tick) declared in Info, or the default.
|
|
115
|
-
*
|
|
116
|
-
* @param {Buffer} head
|
|
117
|
-
* @param {number} segmentDataOffset
|
|
118
|
-
* @returns {number}
|
|
119
|
-
*/
|
|
120
|
-
function readTimestampScale(head, segmentDataOffset) {
|
|
121
|
-
const info = findElement(head, ID_INFO, [], segmentDataOffset);
|
|
122
|
-
if (!info) {
|
|
123
|
-
return DEFAULT_TIMESTAMP_SCALE;
|
|
124
|
-
}
|
|
125
|
-
const infoEnd = Math.min(head.length, info.dataOffset + info.size);
|
|
126
|
-
for (const field of iterateElements(head, info.dataOffset, infoEnd)) {
|
|
127
|
-
if (field.id === ID_TIMESTAMP_SCALE) {
|
|
128
|
-
const scale = readUint(head, field.dataOffset, field.size);
|
|
129
|
-
return scale > 0 ? scale : DEFAULT_TIMESTAMP_SCALE;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
return DEFAULT_TIMESTAMP_SCALE;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* The number of the first video track, from a Tracks payload.
|
|
137
|
-
*
|
|
138
|
-
* The FIRST one, because that is the track ffmpeg is told to copy (`0:v:0`).
|
|
139
|
-
*
|
|
140
|
-
* @param {Buffer} buffer
|
|
141
|
-
* @param {{ dataOffset: number, size: number }} tracks
|
|
142
|
-
* @returns {number | null}
|
|
143
|
-
*/
|
|
144
|
-
function readVideoTrackNumber(buffer, tracks) {
|
|
145
|
-
const tracksEnd = Math.min(buffer.length, tracks.dataOffset + tracks.size);
|
|
146
|
-
for (const entry of iterateElements(buffer, tracks.dataOffset, tracksEnd)) {
|
|
147
|
-
if (entry.id !== ID_TRACK_ENTRY) {
|
|
148
|
-
continue;
|
|
149
|
-
}
|
|
150
|
-
const entryEnd = Math.min(tracksEnd, entry.dataOffset + entry.size);
|
|
151
|
-
let number = null;
|
|
152
|
-
let type = null;
|
|
153
|
-
for (const field of iterateElements(buffer, entry.dataOffset, entryEnd)) {
|
|
154
|
-
if (field.id === ID_TRACK_NUMBER) {
|
|
155
|
-
number = readUint(buffer, field.dataOffset, field.size);
|
|
156
|
-
} else if (field.id === ID_TRACK_TYPE) {
|
|
157
|
-
type = readUint(buffer, field.dataOffset, field.size);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
if (number !== null && type === TRACK_TYPE_VIDEO) {
|
|
161
|
-
return number;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
return null;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Cue times (seconds, ascending) of ONE track, from a Cues payload.
|
|
169
|
-
*
|
|
170
|
-
* The track is the whole point, and leaving it out is what this reader got
|
|
171
|
-
* wrong until 2026-08-18. A CuePoint belongs to the track named inside its
|
|
172
|
-
* CueTrackPositions, and a muxer indexes whatever tracks it likes: RFC 9559
|
|
173
|
-
* says each keyframe of a video track SHOULD be referenced, and that the Cues
|
|
174
|
-
* Element "can be used to index every single timestamp of every Block or they
|
|
175
|
-
* can be indexed selectively". Both field files index their SUBTITLE tracks as
|
|
176
|
-
* well — `Minions.and.Monsters.1080p.mkv` has 2778 video entries every 2.002 s
|
|
177
|
-
* plus 4669 across four subtitle tracks; `Moana.2 … MegaPeer.mkv` has 1055
|
|
178
|
-
* video entries plus 5007 across five.
|
|
179
|
-
*
|
|
180
|
-
* Read without the track, those extra times enter the cut list as though they
|
|
181
|
-
* were keyframes. ffmpeg can only cut a COPIED picture at a real keyframe at or
|
|
182
|
-
* after the time it is given, so every cut asked for at one of them lands late
|
|
183
|
-
* — which is exactly what the field measured: on the first file every deviation
|
|
184
|
-
* was 2.002 s, that file's own keyframe spacing, and on the second the median
|
|
185
|
-
* was 6.3 s with a worst case of 21 s. Never once negative.
|
|
186
|
-
*
|
|
187
|
-
* @param {Buffer} cues
|
|
188
|
-
* @param {number} timestampScale - Nanoseconds per tick.
|
|
189
|
-
* @param {number | null} trackNumber - Null keeps every entry, which is right
|
|
190
|
-
* only for a file that indexes one track.
|
|
191
|
-
* @returns {number[]}
|
|
192
|
-
*/
|
|
193
|
-
function readCueTimes(cues, timestampScale, trackNumber) {
|
|
194
|
-
const times = [];
|
|
195
|
-
const secondsPerTick = timestampScale / 1e9;
|
|
196
|
-
for (const point of iterateElements(cues)) {
|
|
197
|
-
if (point.id !== ID_CUE_POINT) {
|
|
198
|
-
continue;
|
|
199
|
-
}
|
|
200
|
-
const pointEnd = Math.min(cues.length, point.dataOffset + point.size);
|
|
201
|
-
let time = null;
|
|
202
|
-
let belongsToTrack = trackNumber === null;
|
|
203
|
-
for (const field of iterateElements(cues, point.dataOffset, pointEnd)) {
|
|
204
|
-
if (field.id === ID_CUE_TIME) {
|
|
205
|
-
time = readUint(cues, field.dataOffset, field.size) * secondsPerTick;
|
|
206
|
-
continue;
|
|
207
|
-
}
|
|
208
|
-
if (field.id !== ID_CUE_TRACK_POSITIONS || belongsToTrack) {
|
|
209
|
-
continue;
|
|
210
|
-
}
|
|
211
|
-
const positionsEnd = Math.min(pointEnd, field.dataOffset + field.size);
|
|
212
|
-
for (const inner of iterateElements(cues, field.dataOffset, positionsEnd)) {
|
|
213
|
-
if (inner.id === ID_CUE_TRACK && readUint(cues, inner.dataOffset, inner.size) === trackNumber) {
|
|
214
|
-
belongsToTrack = true;
|
|
215
|
-
break;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
if (time !== null && belongsToTrack) {
|
|
220
|
-
times.push(time);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
times.sort((left, right) => left - right);
|
|
224
|
-
return times;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* Read the keyframe times of a Matroska file using only two point reads.
|
|
229
|
-
*
|
|
230
|
-
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
231
|
-
* Inclusive byte range reader; returns null when the range is unavailable.
|
|
232
|
-
* @param {number} fileSize
|
|
233
|
-
* @returns {Promise<number[] | null>} Ascending seconds, or null when the file
|
|
234
|
-
* carries no usable index (see the module doc for when that happens).
|
|
235
|
-
*/
|
|
236
|
-
export async function readMatroskaKeyframeTimes(readRange, fileSize) {
|
|
237
|
-
const head = await readRange(0, Math.min(HEAD_BYTES, Math.max(0, fileSize - 1)));
|
|
238
|
-
if (!head || !isMatroska(head)) {
|
|
239
|
-
return null;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
const seekHead = readSeekHead(head);
|
|
243
|
-
if (!seekHead) {
|
|
244
|
-
return null; // No SeekHead — a streamed or truncated mux.
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
const cuesRelative = seekHead.entries.get(ID_CUES);
|
|
248
|
-
if (cuesRelative === undefined) {
|
|
249
|
-
return null; // Indexless file: live capture, interrupted write, damaged upload.
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// SeekHead positions are relative to Segment's payload.
|
|
253
|
-
const cuesOffset = seekHead.segmentDataOffset + cuesRelative;
|
|
254
|
-
if (!Number.isFinite(cuesOffset) || cuesOffset <= 0 || cuesOffset >= fileSize) {
|
|
255
|
-
return null;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// The element header states the payload size, but reading it costs a round
|
|
259
|
-
// trip; fetch a bounded window instead and let the parser stop at the end of
|
|
260
|
-
// what it got. Cues sits near the file end, so the window is clamped there.
|
|
261
|
-
const cuesEnd = Math.min(fileSize - 1, cuesOffset + MAX_CUES_BYTES);
|
|
262
|
-
const cuesChunk = await readRange(cuesOffset, cuesEnd);
|
|
263
|
-
if (!cuesChunk || cuesChunk.length === 0) {
|
|
264
|
-
return null;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// The window starts exactly at the Cues element, so its own header comes
|
|
268
|
-
// first; step over it to reach the CuePoints.
|
|
269
|
-
const cuesElement = [...iterateElements(cuesChunk, 0, cuesChunk.length)][0];
|
|
270
|
-
if (!cuesElement || cuesElement.id !== ID_CUES) {
|
|
271
|
-
return null;
|
|
272
|
-
}
|
|
273
|
-
const payloadEnd = Math.min(cuesChunk.length, cuesElement.dataOffset + cuesElement.size);
|
|
274
|
-
const payload = cuesChunk.subarray(cuesElement.dataOffset, payloadEnd);
|
|
275
|
-
|
|
276
|
-
// Whose entries to keep. Tracks sits near the head and is normally inside the
|
|
277
|
-
// bytes already fetched; when it is not, SeekHead says where it is and one
|
|
278
|
-
// more short read gets it. Nothing is fetched twice and nothing is scanned.
|
|
279
|
-
const videoTrack = await readVideoTrack(readRange, head, seekHead, fileSize);
|
|
280
|
-
const timestampScale = readTimestampScale(head, seekHead.segmentDataOffset);
|
|
281
|
-
const times = readCueTimes(payload, timestampScale, videoTrack);
|
|
282
|
-
if (times.length > 0) {
|
|
283
|
-
return times;
|
|
284
|
-
}
|
|
285
|
-
if (videoTrack === null) {
|
|
286
|
-
return null;
|
|
287
|
-
}
|
|
288
|
-
// The filter left nothing, and that is not an answer about the file: a table
|
|
289
|
-
// exists, and this reader simply failed to recognise which of its entries
|
|
290
|
-
// belong to the picture — a track numbered one way in Tracks and another in
|
|
291
|
-
// the cue points, or an entry with no CueTrack at all. Returning null here
|
|
292
|
-
// would put an EVEN grid on a copied picture, which is the failure this
|
|
293
|
-
// module exists to prevent, so the unfiltered table is used instead: less
|
|
294
|
-
// exact than the picture's own keyframes, better than a grid that has nothing
|
|
295
|
-
// to do with the file.
|
|
296
|
-
const unfiltered = readCueTimes(payload, timestampScale, null);
|
|
297
|
-
return unfiltered.length > 0 ? unfiltered : null;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* The video track's number — from the head when it is there, and from one extra
|
|
302
|
-
* short read when it is not.
|
|
303
|
-
*
|
|
304
|
-
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
305
|
-
* @param {Buffer} head
|
|
306
|
-
* @param {{ segmentDataOffset: number, entries: Map<number, number> }} seekHead
|
|
307
|
-
* @param {number} fileSize
|
|
308
|
-
* @returns {Promise<number | null>} Null when Tracks cannot be read at all, and
|
|
309
|
-
* then every cue entry is kept — right for a file that indexes only its
|
|
310
|
-
* picture, wrong for one that does not, and nothing here can tell them apart.
|
|
311
|
-
* Refusing the index instead would put an even grid on a copied picture,
|
|
312
|
-
* which is the failure this reader exists to prevent.
|
|
313
|
-
*/
|
|
314
|
-
async function readVideoTrack(readRange, head, seekHead, fileSize) {
|
|
315
|
-
const inHead = findElement(head, ID_TRACKS, [], seekHead.segmentDataOffset);
|
|
316
|
-
if (inHead && inHead.dataOffset + inHead.size <= head.length) {
|
|
317
|
-
return readVideoTrackNumber(head, inHead);
|
|
318
|
-
}
|
|
319
|
-
const relative = seekHead.entries.get(ID_TRACKS);
|
|
320
|
-
if (relative === undefined) {
|
|
321
|
-
return null;
|
|
322
|
-
}
|
|
323
|
-
const offset = seekHead.segmentDataOffset + relative;
|
|
324
|
-
if (!Number.isFinite(offset) || offset <= 0 || offset >= fileSize) {
|
|
325
|
-
return null;
|
|
326
|
-
}
|
|
327
|
-
const chunk = await readRange(offset, Math.min(fileSize - 1, offset + MAX_TRACKS_BYTES));
|
|
328
|
-
if (!chunk || chunk.length === 0) {
|
|
329
|
-
return null;
|
|
330
|
-
}
|
|
331
|
-
const element = [...iterateElements(chunk, 0, chunk.length)][0];
|
|
332
|
-
if (!element || element.id !== ID_TRACKS) {
|
|
333
|
-
return null;
|
|
334
|
-
}
|
|
335
|
-
return readVideoTrackNumber(chunk, { dataOffset: element.dataOffset, size: element.size });
|
|
336
|
-
}
|