@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.
@@ -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 { readKeyframeIndex } from "./container-index/index.js";
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, mergeContainerAudioFlags } from "./audio-inventory.js";
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 = mergeContainerAudioFlags(banner, declared);
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: videoWidth,
687
- height: videoHeight,
688
- fps: parseFfmpegVideoFps(probe.stderr),
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: parseFfmpegBitDepth(probe.stderr),
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: dims.width,
726
- height: dims.height,
753
+ width: videoFacts.width,
754
+ height: videoFacts.height,
727
755
  bitrateKbps: parseFfmpegBitrateKbps(probe.stderr),
728
- fps: parseFfmpegVideoFps(probe.stderr),
756
+ fps: videoFacts.fps,
729
757
  startTime: parseFfmpegStartTimeSeconds(probe.stderr),
730
- isHdr: parseFfmpegHdr(probe.stderr),
731
- bitDepth: parseFfmpegBitDepth(probe.stderr)
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.