@torrent-tv/proxy 2.74.1 → 2.76.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 +1504 -1453
- package/CLAUDE.md +19 -6
- package/biome.json +182 -1
- package/docs/container-architecture.md +27 -6
- package/docs/encode-run-state.md +1 -1
- package/knip.json +14 -0
- package/package.json +1 -1
- package/routes/api/sources/warm/post.js +1 -1
- package/routes/api/transcode-sessions/post.js +185 -185
- package/routes/api/transcode-sessions/progress/get.js +5 -1
- package/routes/transcode/audio-file/get.js +11 -1
- package/routes/transcode/audio-warm/get.js +11 -1
- package/routes/transcode/session-file/get.js +1 -1
- package/routes/transcode/variant-file/get.js +10 -1
- package/scripts/render-run-graph.js +2 -2
- package/server.js +25 -0
- package/services/audio-inventory.js +9 -214
- package/services/container/AviContainer.js +266 -81
- package/services/container/Container.js +281 -80
- package/services/container/ContainerFactory.js +67 -0
- package/services/container/MatroskaContainer.js +327 -8
- package/services/container/Mp4Container.js +373 -27
- package/services/container/SubtitleFileContainer.js +0 -1
- package/services/controllers/SubtitleController.js +128 -128
- package/services/demand/index.js +7 -10
- package/services/download/registry.js +0 -14
- package/services/encode/CoverageMap.js +281 -0
- package/services/encode/EncodePlan.js +255 -0
- package/services/encode/EncodeRun.js +587 -0
- package/services/encode/Encoder.js +84 -0
- package/services/encode/NvencEncoder.js +45 -0
- package/services/encode/QsvEncoder.js +47 -0
- package/services/encode/SegmentDemand.js +0 -0
- package/services/encode/SegmentStore.js +529 -0
- package/services/encode/SoftwareEncoder.js +111 -0
- package/services/encode/V4l2m2mEncoder.js +53 -0
- package/services/encode/VaapiEncoder.js +53 -0
- package/services/encode/args.js +200 -0
- package/services/{encode-exit.js → encode/encode-exit.js} +17 -0
- package/services/encode/index.js +9 -0
- package/services/encode/run-command.js +647 -0
- package/services/hls-session-manager.js +11073 -10711
- package/services/hwaccel.js +1688 -1992
- package/services/orchestrators/EncodeOrchestrator.js +359 -0
- package/services/output/LiveOutputs.js +213 -0
- package/services/output/Output.js +94 -0
- package/services/output/OutputSpec.js +195 -0
- package/services/output/Timeline.js +220 -0
- package/services/output/index.js +1 -0
- package/services/output/ladder.js +26 -0
- package/services/playback-planner.js +806 -747
- package/services/produced-index.js +222 -300
- package/services/source/SourceFile.js +346 -0
- package/services/{sidecar-files.js → torrent/files.js} +107 -11
- package/services/torrent/naming.js +619 -0
- package/services/torrent-worker/client.js +10 -0
- package/services/torrent-worker/container-tracks.js +71 -43
- package/services/torrent-worker/pool-adapter.js +370 -333
- package/services/torrent-worker/protocol.js +7 -0
- package/services/torrent-worker/subtitle-cues.js +549 -549
- package/services/torrent-worker/worker.js +18 -0
- package/services/tracks/AudioTrack.js +131 -40
- package/services/tracks/TextSubtitleTrack.js +287 -287
- package/services/tracks/index.js +15 -14
- package/services/viewer/Viewer.js +145 -0
- package/services/viewer/Viewers.js +124 -0
- package/test/audio-inventory.test.js +176 -177
- package/test/auto-quality-step.test.js +508 -506
- package/test/behind-head-repair.test.js +17 -7
- package/test/coverage-map.test.js +153 -0
- package/test/cut-times-timeline.test.js +6 -5
- package/test/cuts-follow-published-grid.test.js +4 -4
- package/test/decode-cost.test.js +31 -12
- package/test/encode-exit.test.js +1 -1
- package/test/encode-orchestrator.test.js +196 -0
- package/test/encode-plan.test.js +245 -0
- package/test/encode-run-state.test.js +2 -2
- package/test/encode-run.test.js +168 -0
- package/test/encoder-kinds.test.js +122 -0
- package/test/held-request-width.test.js +9 -3
- package/test/helpers/encode-run.js +128 -0
- package/test/keyframe-index-accuracy.test.js +19 -12
- package/test/keyframes-belong-to-the-file.test.js +132 -0
- package/test/matroska-cues-track.test.js +192 -192
- package/test/mp4-composition-times.test.js +0 -0
- package/test/orchestrator-wired.test.js +164 -0
- package/test/output-shape.test.js +68 -0
- package/test/output-spec.test.js +157 -0
- package/test/produced-copy-choice.test.js +58 -92
- package/test/produced-index.test.js +142 -188
- package/test/quality-variants.test.js +1079 -1075
- package/test/run-graph-drift.test.js +1 -1
- package/test/run-intervals.test.js +329 -0
- package/test/run-position-follows-published-grid.test.js +4 -4
- package/test/seek-landing.test.js +8 -8
- package/test/seek-target-not-superseded.test.js +21 -9
- package/test/segment-demand.test.js +82 -0
- package/test/segment-serve-wiring.test.js +47 -52
- package/test/segment-store.test.js +187 -0
- package/test/segments-are-shared.test.js +175 -0
- package/test/sidecar-naming.test.js +142 -0
- package/test/source-file.test.js +133 -0
- package/test/stale-request-after-seek.test.js +18 -12
- package/test/subtitle-language.test.js +252 -252
- package/test/timeline.test.js +95 -0
- package/test/{sidecar-files.test.js → torrent-files.test.js} +44 -1
- package/test/torrent-naming.test.js +255 -0
- package/test/tracks-begin-together.test.js +44 -32
- package/test/two-viewers-one-picture.test.js +347 -0
- package/test/video-facts.test.js +102 -0
- package/test/viewer-outputs.test.js +273 -0
- package/test/viewer.test.js +91 -0
- package/utils/perf.js +1 -63
- package/services/container/index.js +0 -6
- 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/controllers/index.js +0 -2
- package/services/download/index.js +0 -8
- package/services/orchestrators/index.js +0 -2
- /package/services/{container-index → container}/ebml-reader.js +0 -0
- /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
- /package/services/{language-detect.js → tracks/language-detect.js} +0 -0
|
@@ -10,21 +10,21 @@
|
|
|
10
10
|
* - Language 0x22B59C default "eng", LanguageBCP47 0x22B59D MUST — when present, Language ignored
|
|
11
11
|
* - CodecID 0x86, CodecPrivate 0x63A2, Name 0x536E, TrackType 0x83 (1 video, 2 audio, 17 subtitle)
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* the Tracks walk, the Cues
|
|
16
|
-
* blocks inside a cluster —
|
|
17
|
-
*
|
|
13
|
+
* Byte-level EBML element walking is `ebml-reader.js`, which is the format's
|
|
14
|
+
* grammar and not this container's own statements. Everything Matroska DOES
|
|
15
|
+
* state about itself — the Tracks walk, the Cues, the keyframe times they name,
|
|
16
|
+
* the cluster positions, the blocks inside a cluster —
|
|
17
|
+
* is read in this module: each of those is RFC 9559 speaking about Matroska,
|
|
18
|
+
* and the class is the only way in.
|
|
18
19
|
*/
|
|
19
20
|
|
|
20
21
|
import { Container } from "./Container.js";
|
|
21
|
-
import { isMatroska, readMatroskaKeyframeTimes } from "../container-index/matroska.js";
|
|
22
22
|
import { VideoTrack } from "../tracks/VideoTrack.js";
|
|
23
23
|
import { AudioTrack } from "../tracks/AudioTrack.js";
|
|
24
24
|
import { TextSubtitleTrack, TEXT_CODECS_MATROSKA } from "../tracks/TextSubtitleTrack.js";
|
|
25
25
|
import { ImageSubtitleTrack } from "../tracks/ImageSubtitleTrack.js";
|
|
26
26
|
import { ContainerTrack } from "../tracks/ContainerTrack.js";
|
|
27
|
-
import { findElement, iterateElements, readFloat, readUint, readVint } from "
|
|
27
|
+
import { findElement, iterateElements, readFloat, readUint, readVint } from "./ebml-reader.js";
|
|
28
28
|
|
|
29
29
|
const HEAD_BYTES = 64 * 1024;
|
|
30
30
|
/** Enough to read any cluster's own element header. */
|
|
@@ -101,6 +101,20 @@ export class MatroskaContainer extends Container {
|
|
|
101
101
|
return isMatroska(head);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* The keyframe times this container's own index states, in ascending seconds.
|
|
106
|
+
*
|
|
107
|
+
* Static so a caller that has bytes and no container can ask; the instance
|
|
108
|
+
* form is {@link Container#readKeyframeIndex}.
|
|
109
|
+
*
|
|
110
|
+
* @param {(start:number,end:number)=>Promise<Buffer|null>} readRange
|
|
111
|
+
* @param {number} fileSize
|
|
112
|
+
* @returns {Promise<number[]|null>} Null where the container has no index.
|
|
113
|
+
*/
|
|
114
|
+
static readKeyframeTimes(readRange, fileSize) {
|
|
115
|
+
return readMatroskaKeyframeTimes(readRange, fileSize);
|
|
116
|
+
}
|
|
117
|
+
|
|
104
118
|
/**
|
|
105
119
|
* This container's subtitle tracks, its Cues table and the cluster positions
|
|
106
120
|
* they name — RFC 9559 §5.1.4 and §5.1.3.
|
|
@@ -667,7 +681,7 @@ export class MatroskaContainer extends Container {
|
|
|
667
681
|
return fields.length > ASS_FIELDS_BEFORE_TEXT ? fields.slice(ASS_FIELDS_BEFORE_TEXT).join(",") : "";
|
|
668
682
|
}
|
|
669
683
|
|
|
670
|
-
async
|
|
684
|
+
async parseKeyframeIndex() {
|
|
671
685
|
const times = await readMatroskaKeyframeTimes(this.readRange, this.fileSize);
|
|
672
686
|
if (!times) return null;
|
|
673
687
|
if (Array.isArray(times)) return { times, tolerance: 0 };
|
|
@@ -1164,3 +1178,308 @@ function harvestCluster(bytes, trackNumber, secondsPerTick) {
|
|
|
1164
1178
|
payload: block.payload
|
|
1165
1179
|
}));
|
|
1166
1180
|
}
|
|
1181
|
+
|
|
1182
|
+
// ---------------------------------------------------------------------------
|
|
1183
|
+
// RFC 9559 speaking about Matroska: the Cues table that says where a keyframe is.
|
|
1184
|
+
// Here because the class is the only way in.
|
|
1185
|
+
// ---------------------------------------------------------------------------
|
|
1186
|
+
|
|
1187
|
+
// Element ids, from the Matroska specification.
|
|
1188
|
+
const ID_CUE_TIME = 0xb3;
|
|
1189
|
+
// TrackType 1 is video; 2 is audio, 17 subtitles, and the rest are rarer still.
|
|
1190
|
+
const TRACK_TYPE_VIDEO = 1;
|
|
1191
|
+
|
|
1192
|
+
// How much of the file start to read. Must cover the EBML header, the SeekHead
|
|
1193
|
+
// and Info; 64 KB is generous for every real muxer (the file measured needed
|
|
1194
|
+
// under 4 KB) while still trivial to fetch.
|
|
1195
|
+
// Cap on the Cues read. A two-hour film indexes to tens of KB; anything beyond
|
|
1196
|
+
// this is not a normal index and not worth pulling over a torrent.
|
|
1197
|
+
// Cap on a Tracks read, for the rare file whose Tracks element sits outside the
|
|
1198
|
+
// head window. Track entries are small, so a file with dozens of them still
|
|
1199
|
+
// fits well inside this.
|
|
1200
|
+
const MAX_TRACKS_BYTES = 1024 * 1024;
|
|
1201
|
+
// Matroska's default timestamp scale (nanoseconds per tick) when Info omits it.
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* Whether this looks like a Matroska file (the EBML magic `0x1A45DFA3`).
|
|
1205
|
+
*
|
|
1206
|
+
* @param {Buffer} head
|
|
1207
|
+
* @returns {boolean}
|
|
1208
|
+
*/
|
|
1209
|
+
function isMatroska(head) {
|
|
1210
|
+
return head.length >= 4 && head.readUInt32BE(0) === 0x1a45dfa3;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* Locate the Segment element and the SeekHead entries inside it.
|
|
1215
|
+
*
|
|
1216
|
+
* Seek positions are relative to the start of Segment's payload, not to the
|
|
1217
|
+
* file, so that base has to come back with them.
|
|
1218
|
+
*
|
|
1219
|
+
* @param {Buffer} head
|
|
1220
|
+
* @returns {{ segmentDataOffset: number, entries: Map<number, number> } | null}
|
|
1221
|
+
*/
|
|
1222
|
+
function readSeekHead(head) {
|
|
1223
|
+
let segmentDataOffset = -1;
|
|
1224
|
+
for (const element of iterateElements(head)) {
|
|
1225
|
+
if (element.id === ID_SEGMENT) {
|
|
1226
|
+
segmentDataOffset = element.dataOffset;
|
|
1227
|
+
break;
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
if (segmentDataOffset < 0) {
|
|
1231
|
+
return null;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
const seekHead = findElement(head, ID_SEEK_HEAD, [], segmentDataOffset);
|
|
1235
|
+
if (!seekHead) {
|
|
1236
|
+
return null;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
const entries = new Map();
|
|
1240
|
+
const seekHeadEnd = Math.min(head.length, seekHead.dataOffset + seekHead.size);
|
|
1241
|
+
for (const seek of iterateElements(head, seekHead.dataOffset, seekHeadEnd)) {
|
|
1242
|
+
if (seek.id !== ID_SEEK) {
|
|
1243
|
+
continue;
|
|
1244
|
+
}
|
|
1245
|
+
const seekEnd = Math.min(seekHeadEnd, seek.dataOffset + seek.size);
|
|
1246
|
+
let targetId = null;
|
|
1247
|
+
let position = null;
|
|
1248
|
+
for (const field of iterateElements(head, seek.dataOffset, seekEnd)) {
|
|
1249
|
+
if (field.id === ID_SEEK_ID) {
|
|
1250
|
+
targetId = readUint(head, field.dataOffset, field.size);
|
|
1251
|
+
} else if (field.id === ID_SEEK_POSITION) {
|
|
1252
|
+
position = readUint(head, field.dataOffset, field.size);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
if (targetId !== null && position !== null) {
|
|
1256
|
+
entries.set(targetId, position);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
return { segmentDataOffset, entries };
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/**
|
|
1263
|
+
* Timestamp scale (nanoseconds per tick) declared in Info, or the default.
|
|
1264
|
+
*
|
|
1265
|
+
* @param {Buffer} head
|
|
1266
|
+
* @param {number} segmentDataOffset
|
|
1267
|
+
* @returns {number}
|
|
1268
|
+
*/
|
|
1269
|
+
function readTimestampScale(head, segmentDataOffset) {
|
|
1270
|
+
const info = findElement(head, ID_INFO, [], segmentDataOffset);
|
|
1271
|
+
if (!info) {
|
|
1272
|
+
return DEFAULT_TIMESTAMP_SCALE;
|
|
1273
|
+
}
|
|
1274
|
+
const infoEnd = Math.min(head.length, info.dataOffset + info.size);
|
|
1275
|
+
for (const field of iterateElements(head, info.dataOffset, infoEnd)) {
|
|
1276
|
+
if (field.id === ID_TIMESTAMP_SCALE) {
|
|
1277
|
+
const scale = readUint(head, field.dataOffset, field.size);
|
|
1278
|
+
return scale > 0 ? scale : DEFAULT_TIMESTAMP_SCALE;
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
return DEFAULT_TIMESTAMP_SCALE;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/**
|
|
1285
|
+
* The number of the first video track, from a Tracks payload.
|
|
1286
|
+
*
|
|
1287
|
+
* The FIRST one, because that is the track ffmpeg is told to copy (`0:v:0`).
|
|
1288
|
+
*
|
|
1289
|
+
* @param {Buffer} buffer
|
|
1290
|
+
* @param {{ dataOffset: number, size: number }} tracks
|
|
1291
|
+
* @returns {number | null}
|
|
1292
|
+
*/
|
|
1293
|
+
function readVideoTrackNumber(buffer, tracks) {
|
|
1294
|
+
const tracksEnd = Math.min(buffer.length, tracks.dataOffset + tracks.size);
|
|
1295
|
+
for (const entry of iterateElements(buffer, tracks.dataOffset, tracksEnd)) {
|
|
1296
|
+
if (entry.id !== ID_TRACK_ENTRY) {
|
|
1297
|
+
continue;
|
|
1298
|
+
}
|
|
1299
|
+
const entryEnd = Math.min(tracksEnd, entry.dataOffset + entry.size);
|
|
1300
|
+
let number = null;
|
|
1301
|
+
let type = null;
|
|
1302
|
+
for (const field of iterateElements(buffer, entry.dataOffset, entryEnd)) {
|
|
1303
|
+
if (field.id === ID_TRACK_NUMBER) {
|
|
1304
|
+
number = readUint(buffer, field.dataOffset, field.size);
|
|
1305
|
+
} else if (field.id === ID_TRACK_TYPE) {
|
|
1306
|
+
type = readUint(buffer, field.dataOffset, field.size);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
if (number !== null && type === TRACK_TYPE_VIDEO) {
|
|
1310
|
+
return number;
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
return null;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
/**
|
|
1317
|
+
* Cue times (seconds, ascending) of ONE track, from a Cues payload.
|
|
1318
|
+
*
|
|
1319
|
+
* The track is the whole point, and leaving it out is what this reader got
|
|
1320
|
+
* wrong until 2026-08-18. A CuePoint belongs to the track named inside its
|
|
1321
|
+
* CueTrackPositions, and a muxer indexes whatever tracks it likes: RFC 9559
|
|
1322
|
+
* says each keyframe of a video track SHOULD be referenced, and that the Cues
|
|
1323
|
+
* Element "can be used to index every single timestamp of every Block or they
|
|
1324
|
+
* can be indexed selectively". Both field files index their SUBTITLE tracks as
|
|
1325
|
+
* well — `Minions.and.Monsters.1080p.mkv` has 2778 video entries every 2.002 s
|
|
1326
|
+
* plus 4669 across four subtitle tracks; `Moana.2 … MegaPeer.mkv` has 1055
|
|
1327
|
+
* video entries plus 5007 across five.
|
|
1328
|
+
*
|
|
1329
|
+
* Read without the track, those extra times enter the cut list as though they
|
|
1330
|
+
* were keyframes. ffmpeg can only cut a COPIED picture at a real keyframe at or
|
|
1331
|
+
* after the time it is given, so every cut asked for at one of them lands late
|
|
1332
|
+
* — which is exactly what the field measured: on the first file every deviation
|
|
1333
|
+
* was 2.002 s, that file's own keyframe spacing, and on the second the median
|
|
1334
|
+
* was 6.3 s with a worst case of 21 s. Never once negative.
|
|
1335
|
+
*
|
|
1336
|
+
* @param {Buffer} cues
|
|
1337
|
+
* @param {number} timestampScale - Nanoseconds per tick.
|
|
1338
|
+
* @param {number | null} trackNumber - Null keeps every entry, which is right
|
|
1339
|
+
* only for a file that indexes one track.
|
|
1340
|
+
* @returns {number[]}
|
|
1341
|
+
*/
|
|
1342
|
+
function readCueTimes(cues, timestampScale, trackNumber) {
|
|
1343
|
+
const times = [];
|
|
1344
|
+
const secondsPerTick = timestampScale / 1e9;
|
|
1345
|
+
for (const point of iterateElements(cues)) {
|
|
1346
|
+
if (point.id !== ID_CUE_POINT) {
|
|
1347
|
+
continue;
|
|
1348
|
+
}
|
|
1349
|
+
const pointEnd = Math.min(cues.length, point.dataOffset + point.size);
|
|
1350
|
+
let time = null;
|
|
1351
|
+
let belongsToTrack = trackNumber === null;
|
|
1352
|
+
for (const field of iterateElements(cues, point.dataOffset, pointEnd)) {
|
|
1353
|
+
if (field.id === ID_CUE_TIME) {
|
|
1354
|
+
time = readUint(cues, field.dataOffset, field.size) * secondsPerTick;
|
|
1355
|
+
continue;
|
|
1356
|
+
}
|
|
1357
|
+
if (field.id !== ID_CUE_TRACK_POSITIONS || belongsToTrack) {
|
|
1358
|
+
continue;
|
|
1359
|
+
}
|
|
1360
|
+
const positionsEnd = Math.min(pointEnd, field.dataOffset + field.size);
|
|
1361
|
+
for (const inner of iterateElements(cues, field.dataOffset, positionsEnd)) {
|
|
1362
|
+
if (inner.id === ID_CUE_TRACK && readUint(cues, inner.dataOffset, inner.size) === trackNumber) {
|
|
1363
|
+
belongsToTrack = true;
|
|
1364
|
+
break;
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
if (time !== null && belongsToTrack) {
|
|
1369
|
+
times.push(time);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
times.sort((left, right) => left - right);
|
|
1373
|
+
return times;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/**
|
|
1377
|
+
* Read the keyframe times of a Matroska file using only two point reads.
|
|
1378
|
+
*
|
|
1379
|
+
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
1380
|
+
* Inclusive byte range reader; returns null when the range is unavailable.
|
|
1381
|
+
* @param {number} fileSize
|
|
1382
|
+
* @returns {Promise<number[] | null>} Ascending seconds, or null when the file
|
|
1383
|
+
* carries no usable index (see the module doc for when that happens).
|
|
1384
|
+
*/
|
|
1385
|
+
async function readMatroskaKeyframeTimes(readRange, fileSize) {
|
|
1386
|
+
const head = await readRange(0, Math.min(HEAD_BYTES, Math.max(0, fileSize - 1)));
|
|
1387
|
+
if (!head || !isMatroska(head)) {
|
|
1388
|
+
return null;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
const seekHead = readSeekHead(head);
|
|
1392
|
+
if (!seekHead) {
|
|
1393
|
+
return null; // No SeekHead — a streamed or truncated mux.
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
const cuesRelative = seekHead.entries.get(ID_CUES);
|
|
1397
|
+
if (cuesRelative === undefined) {
|
|
1398
|
+
return null; // Indexless file: live capture, interrupted write, damaged upload.
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// SeekHead positions are relative to Segment's payload.
|
|
1402
|
+
const cuesOffset = seekHead.segmentDataOffset + cuesRelative;
|
|
1403
|
+
if (!Number.isFinite(cuesOffset) || cuesOffset <= 0 || cuesOffset >= fileSize) {
|
|
1404
|
+
return null;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
// The element header states the payload size, but reading it costs a round
|
|
1408
|
+
// trip; fetch a bounded window instead and let the parser stop at the end of
|
|
1409
|
+
// what it got. Cues sits near the file end, so the window is clamped there.
|
|
1410
|
+
const cuesEnd = Math.min(fileSize - 1, cuesOffset + MAX_CUES_BYTES);
|
|
1411
|
+
const cuesChunk = await readRange(cuesOffset, cuesEnd);
|
|
1412
|
+
if (!cuesChunk || cuesChunk.length === 0) {
|
|
1413
|
+
return null;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
// The window starts exactly at the Cues element, so its own header comes
|
|
1417
|
+
// first; step over it to reach the CuePoints.
|
|
1418
|
+
const cuesElement = [...iterateElements(cuesChunk, 0, cuesChunk.length)][0];
|
|
1419
|
+
if (!cuesElement || cuesElement.id !== ID_CUES) {
|
|
1420
|
+
return null;
|
|
1421
|
+
}
|
|
1422
|
+
const payloadEnd = Math.min(cuesChunk.length, cuesElement.dataOffset + cuesElement.size);
|
|
1423
|
+
const payload = cuesChunk.subarray(cuesElement.dataOffset, payloadEnd);
|
|
1424
|
+
|
|
1425
|
+
// Whose entries to keep. Tracks sits near the head and is normally inside the
|
|
1426
|
+
// bytes already fetched; when it is not, SeekHead says where it is and one
|
|
1427
|
+
// more short read gets it. Nothing is fetched twice and nothing is scanned.
|
|
1428
|
+
const videoTrack = await readVideoTrack(readRange, head, seekHead, fileSize);
|
|
1429
|
+
const timestampScale = readTimestampScale(head, seekHead.segmentDataOffset);
|
|
1430
|
+
const times = readCueTimes(payload, timestampScale, videoTrack);
|
|
1431
|
+
if (times.length > 0) {
|
|
1432
|
+
return times;
|
|
1433
|
+
}
|
|
1434
|
+
if (videoTrack === null) {
|
|
1435
|
+
return null;
|
|
1436
|
+
}
|
|
1437
|
+
// The filter left nothing, and that is not an answer about the file: a table
|
|
1438
|
+
// exists, and this reader simply failed to recognise which of its entries
|
|
1439
|
+
// belong to the picture — a track numbered one way in Tracks and another in
|
|
1440
|
+
// the cue points, or an entry with no CueTrack at all. Returning null here
|
|
1441
|
+
// would put an EVEN grid on a copied picture, which is the failure this
|
|
1442
|
+
// module exists to prevent, so the unfiltered table is used instead: less
|
|
1443
|
+
// exact than the picture's own keyframes, better than a grid that has nothing
|
|
1444
|
+
// to do with the file.
|
|
1445
|
+
const unfiltered = readCueTimes(payload, timestampScale, null);
|
|
1446
|
+
return unfiltered.length > 0 ? unfiltered : null;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* The video track's number — from the head when it is there, and from one extra
|
|
1451
|
+
* short read when it is not.
|
|
1452
|
+
*
|
|
1453
|
+
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
1454
|
+
* @param {Buffer} head
|
|
1455
|
+
* @param {{ segmentDataOffset: number, entries: Map<number, number> }} seekHead
|
|
1456
|
+
* @param {number} fileSize
|
|
1457
|
+
* @returns {Promise<number | null>} Null when Tracks cannot be read at all, and
|
|
1458
|
+
* then every cue entry is kept — right for a file that indexes only its
|
|
1459
|
+
* picture, wrong for one that does not, and nothing here can tell them apart.
|
|
1460
|
+
* Refusing the index instead would put an even grid on a copied picture,
|
|
1461
|
+
* which is the failure this reader exists to prevent.
|
|
1462
|
+
*/
|
|
1463
|
+
async function readVideoTrack(readRange, head, seekHead, fileSize) {
|
|
1464
|
+
const inHead = findElement(head, ID_TRACKS, [], seekHead.segmentDataOffset);
|
|
1465
|
+
if (inHead && inHead.dataOffset + inHead.size <= head.length) {
|
|
1466
|
+
return readVideoTrackNumber(head, inHead);
|
|
1467
|
+
}
|
|
1468
|
+
const relative = seekHead.entries.get(ID_TRACKS);
|
|
1469
|
+
if (relative === undefined) {
|
|
1470
|
+
return null;
|
|
1471
|
+
}
|
|
1472
|
+
const offset = seekHead.segmentDataOffset + relative;
|
|
1473
|
+
if (!Number.isFinite(offset) || offset <= 0 || offset >= fileSize) {
|
|
1474
|
+
return null;
|
|
1475
|
+
}
|
|
1476
|
+
const chunk = await readRange(offset, Math.min(fileSize - 1, offset + MAX_TRACKS_BYTES));
|
|
1477
|
+
if (!chunk || chunk.length === 0) {
|
|
1478
|
+
return null;
|
|
1479
|
+
}
|
|
1480
|
+
const element = [...iterateElements(chunk, 0, chunk.length)][0];
|
|
1481
|
+
if (!element || element.id !== ID_TRACKS) {
|
|
1482
|
+
return null;
|
|
1483
|
+
}
|
|
1484
|
+
return readVideoTrackNumber(chunk, { dataOffset: element.dataOffset, size: element.size });
|
|
1485
|
+
}
|