@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
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { Container } from "./Container.js";
|
|
19
|
-
import { isMp4, readMp4KeyframeTimes } from "../container-index/mp4.js";
|
|
20
19
|
import { VideoTrack } from "../tracks/VideoTrack.js";
|
|
21
20
|
import { AudioTrack } from "../tracks/AudioTrack.js";
|
|
22
21
|
import { TextSubtitleTrack, TEXT_FORMATS_MP4 } from "../tracks/TextSubtitleTrack.js";
|
|
@@ -98,6 +97,20 @@ export class Mp4Container extends Container {
|
|
|
98
97
|
return isMp4(head);
|
|
99
98
|
}
|
|
100
99
|
|
|
100
|
+
/**
|
|
101
|
+
* The keyframe times this container's own index states, in ascending seconds.
|
|
102
|
+
*
|
|
103
|
+
* Static so a caller that has bytes and no container can ask; the instance
|
|
104
|
+
* form is {@link Container#readKeyframeIndex}.
|
|
105
|
+
*
|
|
106
|
+
* @param {(start:number,end:number)=>Promise<Buffer|null>} readRange
|
|
107
|
+
* @param {number} fileSize
|
|
108
|
+
* @returns {Promise<number[]|null>} Null where the container has no index.
|
|
109
|
+
*/
|
|
110
|
+
static readKeyframeTimes(readRange, fileSize) {
|
|
111
|
+
return readMp4KeyframeTimes(readRange, fileSize);
|
|
112
|
+
}
|
|
113
|
+
|
|
101
114
|
/**
|
|
102
115
|
* This container's text subtitle tracks, with every cue's time and byte
|
|
103
116
|
* range — ISO/IEC 14496-12 §8.5 and §8.7.
|
|
@@ -562,31 +575,6 @@ const TEXT_FORMATS = new Set(["tx3g", "text", "wvtt"]);
|
|
|
562
575
|
|
|
563
576
|
|
|
564
577
|
|
|
565
|
-
/**
|
|
566
|
-
* Walk the top level of the file to find `moov`, reading only box headers.
|
|
567
|
-
*
|
|
568
|
-
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
569
|
-
* @param {number} fileSize
|
|
570
|
-
* @returns {Promise<{ offset: number, size: number } | null>}
|
|
571
|
-
*/
|
|
572
|
-
async function findMoov(readRange, fileSize) {
|
|
573
|
-
let at = 0;
|
|
574
|
-
while (at < fileSize) {
|
|
575
|
-
const probe = await readRange(at, Math.min(fileSize - 1, at + PROBE_BYTES - 1));
|
|
576
|
-
if (!probe || probe.length < HEADER_BYTES) {
|
|
577
|
-
return null;
|
|
578
|
-
}
|
|
579
|
-
const box = readBox(probe, 0);
|
|
580
|
-
if (!box) {
|
|
581
|
-
return null;
|
|
582
|
-
}
|
|
583
|
-
if (box.type === "moov") {
|
|
584
|
-
return { offset: at, size: box.size };
|
|
585
|
-
}
|
|
586
|
-
at += box.size;
|
|
587
|
-
}
|
|
588
|
-
return null;
|
|
589
|
-
}
|
|
590
578
|
|
|
591
579
|
/**
|
|
592
580
|
* Sample durations, expanded from the run-length table.
|
|
@@ -896,3 +884,361 @@ function decodeSubtitleSample(bytes, format) {
|
|
|
896
884
|
const length = bytes.readUInt16BE(0);
|
|
897
885
|
return bytes.toString("utf8", 2, Math.min(bytes.length, 2 + length)).trim();
|
|
898
886
|
}
|
|
887
|
+
|
|
888
|
+
// ---------------------------------------------------------------------------
|
|
889
|
+
// ISO/IEC 14496-12 speaking about MP4: stss, stts, ctts and the edit list.
|
|
890
|
+
// Here because the class is the only way in.
|
|
891
|
+
// ---------------------------------------------------------------------------
|
|
892
|
+
/**
|
|
893
|
+
* @file Keyframe index for MP4/MOV, read without downloading the file.
|
|
894
|
+
*
|
|
895
|
+
* MP4 keeps its tables in a `moov` box: `stss` lists which samples are sync
|
|
896
|
+
* samples (keyframes) by number, and `stts` gives each sample's duration, so
|
|
897
|
+
* the two together turn "sample #N" into "second T". `moov` sits either at the
|
|
898
|
+
* start (files written for streaming) or at the end (the common case for a
|
|
899
|
+
* plain mux); box headers state their own size, so it is found by stepping over
|
|
900
|
+
* top-level boxes rather than scanning bytes — a couple of 64-byte reads even
|
|
901
|
+
* when `mdat` is gigabytes.
|
|
902
|
+
*
|
|
903
|
+
* Same purpose as the Matroska reader: on the video-COPY path the segment
|
|
904
|
+
* boundaries ARE the source's keyframes, and inventing an even grid instead
|
|
905
|
+
* makes players walk the whole file or present audio with no picture.
|
|
906
|
+
*/
|
|
907
|
+
|
|
908
|
+
// A 64-bit box size is signalled by a 32-bit size of 1, the real size following
|
|
909
|
+
// in the next 8 bytes.
|
|
910
|
+
// Enough to read any box header while walking the top level.
|
|
911
|
+
// Cap on the moov read. A feature-length file indexes to a few hundred KB;
|
|
912
|
+
// beyond this is not a normal index and not worth pulling over a torrent.
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* Whether this looks like MP4/MOV — every real file opens with an `ftyp` box.
|
|
916
|
+
*
|
|
917
|
+
* @param {Buffer} head
|
|
918
|
+
* @returns {boolean}
|
|
919
|
+
*/
|
|
920
|
+
function isMp4(head) {
|
|
921
|
+
return head.length >= 12 && head.toString("latin1", 4, 8) === "ftyp";
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Read a box header at `offset`.
|
|
926
|
+
*
|
|
927
|
+
* @param {Buffer} buffer
|
|
928
|
+
* @param {number} offset
|
|
929
|
+
* @returns {{ type: string, size: number, headerBytes: number } | null}
|
|
930
|
+
*/
|
|
931
|
+
function readBoxHeader(buffer, offset) {
|
|
932
|
+
if (offset + HEADER_BYTES > buffer.length) {
|
|
933
|
+
return null;
|
|
934
|
+
}
|
|
935
|
+
const size32 = buffer.readUInt32BE(offset);
|
|
936
|
+
const type = buffer.toString("latin1", offset + 4, offset + 8);
|
|
937
|
+
if (size32 === LARGE_SIZE_MARKER) {
|
|
938
|
+
if (offset + LARGE_HEADER_BYTES > buffer.length) {
|
|
939
|
+
return null;
|
|
940
|
+
}
|
|
941
|
+
// High word is zero for any file we can practically handle.
|
|
942
|
+
const high = buffer.readUInt32BE(offset + 8);
|
|
943
|
+
const low = buffer.readUInt32BE(offset + 12);
|
|
944
|
+
return { type, size: high * 4294967296 + low, headerBytes: LARGE_HEADER_BYTES };
|
|
945
|
+
}
|
|
946
|
+
return { type, size: size32, headerBytes: HEADER_BYTES };
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Walk the top-level boxes to find `moov`, reading only each box header.
|
|
951
|
+
*
|
|
952
|
+
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
953
|
+
* @param {number} fileSize
|
|
954
|
+
* @returns {Promise<{ offset: number, size: number, headerBytes: number } | null>}
|
|
955
|
+
*/
|
|
956
|
+
async function findMoov(readRange, fileSize) {
|
|
957
|
+
let offset = 0;
|
|
958
|
+
while (offset < fileSize) {
|
|
959
|
+
const probe = await readRange(offset, Math.min(fileSize - 1, offset + PROBE_BYTES - 1));
|
|
960
|
+
if (!probe || probe.length < HEADER_BYTES) {
|
|
961
|
+
return null;
|
|
962
|
+
}
|
|
963
|
+
const header = readBoxHeader(probe, 0);
|
|
964
|
+
// Size 0 means "extends to end of file" — legal only for the last box, and
|
|
965
|
+
// never for one we would step over.
|
|
966
|
+
if (!header || header.size <= 0) {
|
|
967
|
+
return null;
|
|
968
|
+
}
|
|
969
|
+
if (header.type === "moov") {
|
|
970
|
+
return { offset, size: header.size, headerBytes: header.headerBytes };
|
|
971
|
+
}
|
|
972
|
+
offset += header.size;
|
|
973
|
+
}
|
|
974
|
+
return null;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Find the first box of `type` directly inside a range of an already-read buffer.
|
|
979
|
+
*
|
|
980
|
+
* @param {Buffer} buffer
|
|
981
|
+
* @param {number} start
|
|
982
|
+
* @param {number} end
|
|
983
|
+
* @param {string} type
|
|
984
|
+
* @returns {{ dataOffset: number, end: number } | null}
|
|
985
|
+
*/
|
|
986
|
+
function findBox(buffer, start, end, type) {
|
|
987
|
+
let offset = start;
|
|
988
|
+
while (offset + HEADER_BYTES <= end) {
|
|
989
|
+
const header = readBoxHeader(buffer, offset);
|
|
990
|
+
if (!header || header.size <= 0) {
|
|
991
|
+
return null;
|
|
992
|
+
}
|
|
993
|
+
if (header.type === type) {
|
|
994
|
+
return { dataOffset: offset + header.headerBytes, end: Math.min(end, offset + header.size) };
|
|
995
|
+
}
|
|
996
|
+
offset += header.size;
|
|
997
|
+
}
|
|
998
|
+
return null;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* All boxes of `type` directly inside a range.
|
|
1003
|
+
*
|
|
1004
|
+
* @param {Buffer} buffer
|
|
1005
|
+
* @param {number} start
|
|
1006
|
+
* @param {number} end
|
|
1007
|
+
* @param {string} type
|
|
1008
|
+
* @returns {{ dataOffset: number, end: number }[]}
|
|
1009
|
+
*/
|
|
1010
|
+
function findAllBoxes(buffer, start, end, type) {
|
|
1011
|
+
const found = [];
|
|
1012
|
+
let offset = start;
|
|
1013
|
+
while (offset + HEADER_BYTES <= end) {
|
|
1014
|
+
const header = readBoxHeader(buffer, offset);
|
|
1015
|
+
if (!header || header.size <= 0) {
|
|
1016
|
+
break;
|
|
1017
|
+
}
|
|
1018
|
+
if (header.type === type) {
|
|
1019
|
+
found.push({ dataOffset: offset + header.headerBytes, end: Math.min(end, offset + header.size) });
|
|
1020
|
+
}
|
|
1021
|
+
offset += header.size;
|
|
1022
|
+
}
|
|
1023
|
+
return found;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* Turn sample numbers into seconds using the time-to-sample table.
|
|
1028
|
+
*
|
|
1029
|
+
* `stts` is run-length encoded — pairs of (sample count, per-sample duration) —
|
|
1030
|
+
* so one walk yields every sample's start time without expanding the table.
|
|
1031
|
+
*
|
|
1032
|
+
* @param {Buffer} buffer
|
|
1033
|
+
* @param {{ dataOffset: number, end: number }} stts
|
|
1034
|
+
* @param {number} timescale - Ticks per second.
|
|
1035
|
+
* @param {Set<number>} wanted - Sample numbers (1-based).
|
|
1036
|
+
* @returns {number[]} Seconds, ascending.
|
|
1037
|
+
*/
|
|
1038
|
+
function resolveSampleTimes(buffer, stts, timescale, wanted, offsets = null) {
|
|
1039
|
+
const entryCount = buffer.readUInt32BE(stts.dataOffset + 4);
|
|
1040
|
+
const times = [];
|
|
1041
|
+
let sampleNumber = 1;
|
|
1042
|
+
let ticks = 0;
|
|
1043
|
+
let cursor = stts.dataOffset + 8;
|
|
1044
|
+
for (let entry = 0; entry < entryCount && cursor + 8 <= stts.end; entry += 1) {
|
|
1045
|
+
const count = buffer.readUInt32BE(cursor);
|
|
1046
|
+
const delta = buffer.readUInt32BE(cursor + 4);
|
|
1047
|
+
for (let index = 0; index < count; index += 1) {
|
|
1048
|
+
if (wanted.has(sampleNumber)) {
|
|
1049
|
+
// `CT(n) = DT(n) + CTTS(n)` — ISO/IEC 14496-12 §8.6.1.3. The offset is
|
|
1050
|
+
// what turns decode order into the order frames are shown in, and it is
|
|
1051
|
+
// the timeline ffmpeg cuts on.
|
|
1052
|
+
times.push((ticks + (offsets?.get(sampleNumber) ?? 0)) / timescale);
|
|
1053
|
+
}
|
|
1054
|
+
ticks += delta;
|
|
1055
|
+
sampleNumber += 1;
|
|
1056
|
+
}
|
|
1057
|
+
cursor += 8;
|
|
1058
|
+
}
|
|
1059
|
+
return times;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* Composition offsets for the sample numbers asked for.
|
|
1064
|
+
*
|
|
1065
|
+
* `ctts` is run-length encoded like `stts`, and version 1 carries SIGNED
|
|
1066
|
+
* offsets — which is what the version exists for: a frame may be shown before
|
|
1067
|
+
* it is decoded. Reading them as unsigned turns a small negative offset into
|
|
1068
|
+
* roughly four billion ticks.
|
|
1069
|
+
*
|
|
1070
|
+
* @param {Buffer} buffer
|
|
1071
|
+
* @param {{ dataOffset: number, end: number }} ctts
|
|
1072
|
+
* @param {Set<number>} wanted - Sample numbers (1-based).
|
|
1073
|
+
* @returns {Map<number, number>} Sample number to offset in media ticks.
|
|
1074
|
+
*/
|
|
1075
|
+
function readCompositionOffsets(buffer, ctts, wanted) {
|
|
1076
|
+
const version = buffer[ctts.dataOffset];
|
|
1077
|
+
const entryCount = buffer.readUInt32BE(ctts.dataOffset + 4);
|
|
1078
|
+
const offsets = new Map();
|
|
1079
|
+
let sampleNumber = 1;
|
|
1080
|
+
let cursor = ctts.dataOffset + 8;
|
|
1081
|
+
for (let entry = 0; entry < entryCount && cursor + 8 <= ctts.end; entry += 1) {
|
|
1082
|
+
const count = buffer.readUInt32BE(cursor);
|
|
1083
|
+
const offset = version === 1 ? buffer.readInt32BE(cursor + 4) : buffer.readUInt32BE(cursor + 4);
|
|
1084
|
+
for (let index = 0; index < count; index += 1) {
|
|
1085
|
+
if (wanted.has(sampleNumber)) {
|
|
1086
|
+
offsets.set(sampleNumber, offset);
|
|
1087
|
+
}
|
|
1088
|
+
sampleNumber += 1;
|
|
1089
|
+
}
|
|
1090
|
+
cursor += 8;
|
|
1091
|
+
}
|
|
1092
|
+
return offsets;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/**
|
|
1096
|
+
* How far the edit list shifts this track's composition timeline, in media
|
|
1097
|
+
* ticks.
|
|
1098
|
+
*
|
|
1099
|
+
* ISO/IEC 14496-12 §8.6.6.3: `media_time` is the start of the edit within the
|
|
1100
|
+
* media, in the MEDIA timescale and in composition time, while
|
|
1101
|
+
* `segment_duration` is in the MOVIE timescale — two different units in one
|
|
1102
|
+
* structure, which is why only the first is read here. `media_time = -1` is an
|
|
1103
|
+
* empty edit: it inserts blank presentation time and starts no media, so the
|
|
1104
|
+
* first real edit is the one that matters.
|
|
1105
|
+
*
|
|
1106
|
+
* Measured 2026-08-19: every LostFilm MP4 that carries a composition offset
|
|
1107
|
+
* also carries an edit list cancelling it exactly, which is why decode times
|
|
1108
|
+
* have been right on those files. `Firefly.S01E03` has the offset and NO edit
|
|
1109
|
+
* list, and its times were 62.1 ms early on all 34 keyframes checked.
|
|
1110
|
+
*
|
|
1111
|
+
* @param {Buffer} buffer
|
|
1112
|
+
* @param {{ dataOffset: number, end: number }} elst
|
|
1113
|
+
* @returns {number} Ticks to subtract; zero when nothing is shifted.
|
|
1114
|
+
*/
|
|
1115
|
+
function readEditShift(buffer, elst) {
|
|
1116
|
+
const version = buffer[elst.dataOffset];
|
|
1117
|
+
const entryCount = buffer.readUInt32BE(elst.dataOffset + 4);
|
|
1118
|
+
const wide = version === 1;
|
|
1119
|
+
const entryBytes = wide ? 20 : 12;
|
|
1120
|
+
let cursor = elst.dataOffset + 8;
|
|
1121
|
+
for (let entry = 0; entry < entryCount && cursor + entryBytes <= elst.end; entry += 1) {
|
|
1122
|
+
const mediaTime = wide
|
|
1123
|
+
? Number(buffer.readBigInt64BE(cursor + 8))
|
|
1124
|
+
: buffer.readInt32BE(cursor + 4);
|
|
1125
|
+
if (mediaTime >= 0) {
|
|
1126
|
+
return mediaTime;
|
|
1127
|
+
}
|
|
1128
|
+
cursor += entryBytes;
|
|
1129
|
+
}
|
|
1130
|
+
return 0;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* Whether this track's handler says it carries video.
|
|
1135
|
+
*
|
|
1136
|
+
* The standard identifies a track by its `hdlr`, and nothing else does. Picking
|
|
1137
|
+
* "the first track that happens to carry sync samples" worked only because the
|
|
1138
|
+
* seven releases measured all put video first; a file whose audio track carries
|
|
1139
|
+
* them, or one that leads with a cover-art video track, would be read from the
|
|
1140
|
+
* wrong place. That is the same defect that was fixed in the Matroska reader on
|
|
1141
|
+
* 2026-08-18, arrived at from the other side.
|
|
1142
|
+
*
|
|
1143
|
+
* @param {Buffer} buffer
|
|
1144
|
+
* @param {{ dataOffset: number, end: number }} mdia
|
|
1145
|
+
* @returns {boolean}
|
|
1146
|
+
*/
|
|
1147
|
+
function isVideoTrack(buffer, mdia) {
|
|
1148
|
+
const hdlr = findBox(buffer, mdia.dataOffset, mdia.end, "hdlr");
|
|
1149
|
+
if (!hdlr || hdlr.dataOffset + 12 > hdlr.end) {
|
|
1150
|
+
return false;
|
|
1151
|
+
}
|
|
1152
|
+
// FullBox header (4) then a reserved pre_defined (4), then the handler type.
|
|
1153
|
+
return buffer.toString("latin1", hdlr.dataOffset + 8, hdlr.dataOffset + 12) === "vide";
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* Read the keyframe times of an MP4/MOV file.
|
|
1158
|
+
*
|
|
1159
|
+
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
1160
|
+
* @param {number} fileSize
|
|
1161
|
+
* @returns {Promise<number[] | null>} Ascending seconds, or null when the file
|
|
1162
|
+
* carries no usable index (fragmented MP4, truncated or damaged `moov`).
|
|
1163
|
+
*/
|
|
1164
|
+
async function readMp4KeyframeTimes(readRange, fileSize) {
|
|
1165
|
+
const moovBox = await findMoov(readRange, fileSize);
|
|
1166
|
+
if (!moovBox || moovBox.size > MAX_MOOV_BYTES) {
|
|
1167
|
+
return null;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
const moov = await readRange(moovBox.offset, Math.min(fileSize - 1, moovBox.offset + moovBox.size - 1));
|
|
1171
|
+
if (!moov || moov.length < moovBox.headerBytes) {
|
|
1172
|
+
return null;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// Examine every track, and take the one whose HANDLER says it is video. A
|
|
1176
|
+
// track with no `stss` has every sample a keyframe, so it constrains nothing
|
|
1177
|
+
// and is skipped even when it is the video one.
|
|
1178
|
+
for (const trak of findAllBoxes(moov, moovBox.headerBytes, moov.length, "trak")) {
|
|
1179
|
+
const mdia = findBox(moov, trak.dataOffset, trak.end, "mdia");
|
|
1180
|
+
if (!mdia || !isVideoTrack(moov, mdia)) {
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
const mdhd = findBox(moov, mdia.dataOffset, mdia.end, "mdhd");
|
|
1184
|
+
if (!mdhd) {
|
|
1185
|
+
continue;
|
|
1186
|
+
}
|
|
1187
|
+
// mdhd layout: version(1) + flags(3), then creation/modification times —
|
|
1188
|
+
// 32-bit each in version 0, 64-bit in version 1 — then the timescale.
|
|
1189
|
+
const version = moov[mdhd.dataOffset];
|
|
1190
|
+
const timescaleOffset = version === 1 ? mdhd.dataOffset + 20 : mdhd.dataOffset + 12;
|
|
1191
|
+
if (timescaleOffset + 4 > mdhd.end) {
|
|
1192
|
+
continue;
|
|
1193
|
+
}
|
|
1194
|
+
const timescale = moov.readUInt32BE(timescaleOffset);
|
|
1195
|
+
if (!timescale) {
|
|
1196
|
+
continue;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
const minf = findBox(moov, mdia.dataOffset, mdia.end, "minf");
|
|
1200
|
+
const stbl = minf && findBox(moov, minf.dataOffset, minf.end, "stbl");
|
|
1201
|
+
if (!stbl) {
|
|
1202
|
+
continue;
|
|
1203
|
+
}
|
|
1204
|
+
const stss = findBox(moov, stbl.dataOffset, stbl.end, "stss");
|
|
1205
|
+
const stts = findBox(moov, stbl.dataOffset, stbl.end, "stts");
|
|
1206
|
+
if (!stss || !stts) {
|
|
1207
|
+
continue;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
const syncCount = moov.readUInt32BE(stss.dataOffset + 4);
|
|
1211
|
+
const wanted = new Set();
|
|
1212
|
+
for (let index = 0; index < syncCount; index += 1) {
|
|
1213
|
+
const at = stss.dataOffset + 8 + index * 4;
|
|
1214
|
+
if (at + 4 > stss.end) {
|
|
1215
|
+
break;
|
|
1216
|
+
}
|
|
1217
|
+
wanted.add(moov.readUInt32BE(at));
|
|
1218
|
+
}
|
|
1219
|
+
if (wanted.size === 0) {
|
|
1220
|
+
continue;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// The two terms that turn decode times into the timeline ffmpeg cuts on.
|
|
1224
|
+
// Both are optional: a file without them is one whose decode and
|
|
1225
|
+
// composition orders already agree, and then nothing is added or taken.
|
|
1226
|
+
const ctts = findBox(moov, stbl.dataOffset, stbl.end, "ctts");
|
|
1227
|
+
const offsets = ctts ? readCompositionOffsets(moov, ctts, wanted) : null;
|
|
1228
|
+
const edts = findBox(moov, trak.dataOffset, trak.end, "edts");
|
|
1229
|
+
const elst = edts && findBox(moov, edts.dataOffset, edts.end, "elst");
|
|
1230
|
+
const editShift = elst ? readEditShift(moov, elst) : 0;
|
|
1231
|
+
|
|
1232
|
+
const times = resolveSampleTimes(moov, stts, timescale, wanted, offsets);
|
|
1233
|
+
if (times.length > 0) {
|
|
1234
|
+
// A shift applied after the division would be in the wrong units: the
|
|
1235
|
+
// edit's `media_time` is in MEDIA ticks, like everything else here.
|
|
1236
|
+
const shifted = editShift === 0 ? times : times.map((time) => time - editShift / timescale);
|
|
1237
|
+
// A negative time is not a position in the file. It happens when an edit
|
|
1238
|
+
// starts later than a keyframe the table lists, and those frames are not
|
|
1239
|
+
// presented at all.
|
|
1240
|
+
return shifted.filter((time) => time >= 0);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
return null;
|
|
1244
|
+
}
|
|
@@ -17,7 +17,7 @@ import { fileURLToPath } from "node:url";
|
|
|
17
17
|
import { spawn } from "node:child_process";
|
|
18
18
|
import { createRequire } from "node:module";
|
|
19
19
|
import { logger } from "../utils/logger.js";
|
|
20
|
-
import {
|
|
20
|
+
import { ContainerFactory } from "./container/ContainerFactory.js";
|
|
21
21
|
import { readMachineState, readProcessCpuSeconds, readProxyCpuSeconds, readSystemCpu, shareOfMachine } from "./host-load.js";
|
|
22
22
|
import { speedFromReadings } from "./encoder-readings.js";
|
|
23
23
|
import { availableShareFrom, correctForAvailability } from "./available-share.js";
|
|
@@ -2997,7 +2997,7 @@ export class HlsSessionManager {
|
|
|
2997
2997
|
}
|
|
2998
2998
|
};
|
|
2999
2999
|
|
|
3000
|
-
const result = await readKeyframeIndex({ readRange, fileSize, label: logName });
|
|
3000
|
+
const result = await ContainerFactory.readKeyframeIndex({ readRange, fileSize, label: logName });
|
|
3001
3001
|
this.keyframeIndexCache.set(cacheKey, result);
|
|
3002
3002
|
return result;
|
|
3003
3003
|
}
|
|
@@ -9,12 +9,11 @@
|
|
|
9
9
|
import { spawn } from "node:child_process";
|
|
10
10
|
import { logger } from "../utils/logger.js";
|
|
11
11
|
import { Container } from "./container/Container.js";
|
|
12
|
-
import { buildAudioInventory
|
|
12
|
+
import { buildAudioInventory } from "./audio-inventory.js";
|
|
13
13
|
import { countVideoFiles, matchSidecarFiles } from "./sidecar-files.js";
|
|
14
14
|
import {
|
|
15
15
|
parseFfmpegDurationSeconds,
|
|
16
16
|
parseFfmpegStartTimeSeconds,
|
|
17
|
-
parseFfmpegVideoDimensions,
|
|
18
17
|
parseFfmpegBitDepth,
|
|
19
18
|
parseFfmpegBitrateKbps,
|
|
20
19
|
parseFfmpegVideoFps,
|
|
@@ -431,7 +430,7 @@ export function createPlaybackPlanner({
|
|
|
431
430
|
// The picture's head is already downloaded — the codec probe just read it
|
|
432
431
|
// — so this is a parse and not a wait, but it is bounded like the rest.
|
|
433
432
|
const declared = await declaredAudioOf(fileIndex, "the picture");
|
|
434
|
-
const merged =
|
|
433
|
+
const merged = Container.mergeAudioFlags(banner, declared);
|
|
435
434
|
embedded = merged.tracks;
|
|
436
435
|
logger.info(
|
|
437
436
|
merged.aligned
|
|
@@ -650,10 +649,40 @@ export function createPlaybackPlanner({
|
|
|
650
649
|
`${codecsDetected ? `${videoCodec || "-"}/${audioCodec || "-"}` : "codecs NOT detected (will be polled again)"}`
|
|
651
650
|
);
|
|
652
651
|
|
|
652
|
+
// The picture's own facts come from two readings and only one was ever
|
|
653
|
+
// used: every figure the encode is planned from came from ffmpeg's
|
|
654
|
+
// banner, while the `VideoTrack` the container declares was read and used
|
|
655
|
+
// for nothing but a line in the log.
|
|
656
|
+
let declaredVideo = null;
|
|
657
|
+
if (typeof torrentPool?.getDeclaredVideoTrack === "function") {
|
|
658
|
+
try {
|
|
659
|
+
declaredVideo = await torrentPool.getDeclaredVideoTrack(torrent, fileIndex);
|
|
660
|
+
} catch (error) {
|
|
661
|
+
logger.info(`video track: could not be read (${error?.message ?? error})`);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
653
664
|
// `mode` is advisory only (audio-codec based). The browser makes the
|
|
654
665
|
// authoritative decision independently per stream via canPlayType /
|
|
655
666
|
// mediaCapabilities, transcoding only what it cannot play.
|
|
656
667
|
const requiresTranscode = audioCodec.length > 0 && !DIRECT_AUDIO_CODECS.has(audioCodec);
|
|
668
|
+
// The two readings of the picture, lined up. Which one answers is decided
|
|
669
|
+
// per field by what each IS — see `Container.mergeVideoFacts`.
|
|
670
|
+
const videoFacts = Container.mergeVideoFacts(
|
|
671
|
+
{
|
|
672
|
+
width: videoWidth,
|
|
673
|
+
height: videoHeight,
|
|
674
|
+
fps: parseFfmpegVideoFps(probe.stderr),
|
|
675
|
+
isHdr: parseFfmpegHdr(probe.stderr),
|
|
676
|
+
bitDepth: parseFfmpegBitDepth(probe.stderr)
|
|
677
|
+
},
|
|
678
|
+
declaredVideo
|
|
679
|
+
);
|
|
680
|
+
if (videoFacts.disagreements.length > 0) {
|
|
681
|
+
logger.info(
|
|
682
|
+
`video track: the file and the probe disagree — ${videoFacts.disagreements.join("; ")}; ` +
|
|
683
|
+
"the size and frame rate are the probe's, the bit depth and HDR the file's"
|
|
684
|
+
);
|
|
685
|
+
}
|
|
657
686
|
const plan = {
|
|
658
687
|
mode: requiresTranscode ? "hls" : "direct",
|
|
659
688
|
directUrl,
|
|
@@ -664,8 +693,8 @@ export function createPlaybackPlanner({
|
|
|
664
693
|
durationSeconds,
|
|
665
694
|
// Source coded resolution — drives the browser's manual quality menu
|
|
666
695
|
// (list of forced resolutions <= source). 0 when unknown.
|
|
667
|
-
videoWidth,
|
|
668
|
-
videoHeight,
|
|
696
|
+
videoWidth: videoFacts.width ?? 0,
|
|
697
|
+
videoHeight: videoFacts.height ?? 0,
|
|
669
698
|
// Full track inventory for the browser's audio/subtitle menus. The audio
|
|
670
699
|
// half spans the picture's own tracks AND the soundtracks shipped as
|
|
671
700
|
// files beside it, under one numbering — see `buildInventory`.
|
|
@@ -683,15 +712,15 @@ export function createPlaybackPlanner({
|
|
|
683
712
|
// does, as the host learns what this source costs. Stripped on the way
|
|
684
713
|
// out — it is not part of the plan the browser is given.
|
|
685
714
|
mediaInfoForOffer: {
|
|
686
|
-
width:
|
|
687
|
-
height:
|
|
688
|
-
fps:
|
|
715
|
+
width: videoFacts.width,
|
|
716
|
+
height: videoFacts.height,
|
|
717
|
+
fps: videoFacts.fps,
|
|
689
718
|
bitrateKbps: parseFfmpegBitrateKbps(probe.stderr),
|
|
690
719
|
// Which family of the decode measurement prices this source. A video
|
|
691
720
|
// that has to be re-encoded is one the browser could not play, so it
|
|
692
721
|
// is usually NOT H.264, and H.264 constants are wrong for it.
|
|
693
722
|
codec: videoCodec,
|
|
694
|
-
bitDepth:
|
|
723
|
+
bitDepth: videoFacts.bitDepth,
|
|
695
724
|
// Which file this is, so the offer can be answered from what an
|
|
696
725
|
// encoder has already learned about THIS source rather than from the
|
|
697
726
|
// startup clips — the same correction a live session applies.
|
|
@@ -708,7 +737,6 @@ export function createPlaybackPlanner({
|
|
|
708
737
|
cache.set(cacheKey, plan);
|
|
709
738
|
// Cache the full media info from THIS probe's banner (same helpers the
|
|
710
739
|
// session manager uses) so createSession can skip its own probe.
|
|
711
|
-
const dims = parseFfmpegVideoDimensions(probe.stderr);
|
|
712
740
|
mediaInfoCache.set(cacheKey, {
|
|
713
741
|
// The codecs, because the session manager asks this cache which
|
|
714
742
|
// tracks the output will carry — and they were never stored here. It
|
|
@@ -722,13 +750,13 @@ export function createPlaybackPlanner({
|
|
|
722
750
|
videoCodec: plan.videoCodec,
|
|
723
751
|
audioCodec: plan.audioCodec,
|
|
724
752
|
durationSeconds: parseFfmpegDurationSeconds(probe.stderr),
|
|
725
|
-
width:
|
|
726
|
-
height:
|
|
753
|
+
width: videoFacts.width,
|
|
754
|
+
height: videoFacts.height,
|
|
727
755
|
bitrateKbps: parseFfmpegBitrateKbps(probe.stderr),
|
|
728
|
-
fps:
|
|
756
|
+
fps: videoFacts.fps,
|
|
729
757
|
startTime: parseFfmpegStartTimeSeconds(probe.stderr),
|
|
730
|
-
isHdr:
|
|
731
|
-
bitDepth:
|
|
758
|
+
isHdr: videoFacts.isHdr,
|
|
759
|
+
bitDepth: videoFacts.bitDepth
|
|
732
760
|
});
|
|
733
761
|
// Warm the file-body start for the transcode session that follows.
|
|
734
762
|
// Fire-and-forget: never delays the plan response.
|