@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.
Files changed (124) hide show
  1. package/CHANGELOG.md +1504 -1453
  2. package/CLAUDE.md +19 -6
  3. package/biome.json +182 -1
  4. package/docs/container-architecture.md +27 -6
  5. package/docs/encode-run-state.md +1 -1
  6. package/knip.json +14 -0
  7. package/package.json +1 -1
  8. package/routes/api/sources/warm/post.js +1 -1
  9. package/routes/api/transcode-sessions/post.js +185 -185
  10. package/routes/api/transcode-sessions/progress/get.js +5 -1
  11. package/routes/transcode/audio-file/get.js +11 -1
  12. package/routes/transcode/audio-warm/get.js +11 -1
  13. package/routes/transcode/session-file/get.js +1 -1
  14. package/routes/transcode/variant-file/get.js +10 -1
  15. package/scripts/render-run-graph.js +2 -2
  16. package/server.js +25 -0
  17. package/services/audio-inventory.js +9 -214
  18. package/services/container/AviContainer.js +266 -81
  19. package/services/container/Container.js +281 -80
  20. package/services/container/ContainerFactory.js +67 -0
  21. package/services/container/MatroskaContainer.js +327 -8
  22. package/services/container/Mp4Container.js +373 -27
  23. package/services/container/SubtitleFileContainer.js +0 -1
  24. package/services/controllers/SubtitleController.js +128 -128
  25. package/services/demand/index.js +7 -10
  26. package/services/download/registry.js +0 -14
  27. package/services/encode/CoverageMap.js +281 -0
  28. package/services/encode/EncodePlan.js +255 -0
  29. package/services/encode/EncodeRun.js +587 -0
  30. package/services/encode/Encoder.js +84 -0
  31. package/services/encode/NvencEncoder.js +45 -0
  32. package/services/encode/QsvEncoder.js +47 -0
  33. package/services/encode/SegmentDemand.js +0 -0
  34. package/services/encode/SegmentStore.js +529 -0
  35. package/services/encode/SoftwareEncoder.js +111 -0
  36. package/services/encode/V4l2m2mEncoder.js +53 -0
  37. package/services/encode/VaapiEncoder.js +53 -0
  38. package/services/encode/args.js +200 -0
  39. package/services/{encode-exit.js → encode/encode-exit.js} +17 -0
  40. package/services/encode/index.js +9 -0
  41. package/services/encode/run-command.js +647 -0
  42. package/services/hls-session-manager.js +11073 -10711
  43. package/services/hwaccel.js +1688 -1992
  44. package/services/orchestrators/EncodeOrchestrator.js +359 -0
  45. package/services/output/LiveOutputs.js +213 -0
  46. package/services/output/Output.js +94 -0
  47. package/services/output/OutputSpec.js +195 -0
  48. package/services/output/Timeline.js +220 -0
  49. package/services/output/index.js +1 -0
  50. package/services/output/ladder.js +26 -0
  51. package/services/playback-planner.js +806 -747
  52. package/services/produced-index.js +222 -300
  53. package/services/source/SourceFile.js +346 -0
  54. package/services/{sidecar-files.js → torrent/files.js} +107 -11
  55. package/services/torrent/naming.js +619 -0
  56. package/services/torrent-worker/client.js +10 -0
  57. package/services/torrent-worker/container-tracks.js +71 -43
  58. package/services/torrent-worker/pool-adapter.js +370 -333
  59. package/services/torrent-worker/protocol.js +7 -0
  60. package/services/torrent-worker/subtitle-cues.js +549 -549
  61. package/services/torrent-worker/worker.js +18 -0
  62. package/services/tracks/AudioTrack.js +131 -40
  63. package/services/tracks/TextSubtitleTrack.js +287 -287
  64. package/services/tracks/index.js +15 -14
  65. package/services/viewer/Viewer.js +145 -0
  66. package/services/viewer/Viewers.js +124 -0
  67. package/test/audio-inventory.test.js +176 -177
  68. package/test/auto-quality-step.test.js +508 -506
  69. package/test/behind-head-repair.test.js +17 -7
  70. package/test/coverage-map.test.js +153 -0
  71. package/test/cut-times-timeline.test.js +6 -5
  72. package/test/cuts-follow-published-grid.test.js +4 -4
  73. package/test/decode-cost.test.js +31 -12
  74. package/test/encode-exit.test.js +1 -1
  75. package/test/encode-orchestrator.test.js +196 -0
  76. package/test/encode-plan.test.js +245 -0
  77. package/test/encode-run-state.test.js +2 -2
  78. package/test/encode-run.test.js +168 -0
  79. package/test/encoder-kinds.test.js +122 -0
  80. package/test/held-request-width.test.js +9 -3
  81. package/test/helpers/encode-run.js +128 -0
  82. package/test/keyframe-index-accuracy.test.js +19 -12
  83. package/test/keyframes-belong-to-the-file.test.js +132 -0
  84. package/test/matroska-cues-track.test.js +192 -192
  85. package/test/mp4-composition-times.test.js +0 -0
  86. package/test/orchestrator-wired.test.js +164 -0
  87. package/test/output-shape.test.js +68 -0
  88. package/test/output-spec.test.js +157 -0
  89. package/test/produced-copy-choice.test.js +58 -92
  90. package/test/produced-index.test.js +142 -188
  91. package/test/quality-variants.test.js +1079 -1075
  92. package/test/run-graph-drift.test.js +1 -1
  93. package/test/run-intervals.test.js +329 -0
  94. package/test/run-position-follows-published-grid.test.js +4 -4
  95. package/test/seek-landing.test.js +8 -8
  96. package/test/seek-target-not-superseded.test.js +21 -9
  97. package/test/segment-demand.test.js +82 -0
  98. package/test/segment-serve-wiring.test.js +47 -52
  99. package/test/segment-store.test.js +187 -0
  100. package/test/segments-are-shared.test.js +175 -0
  101. package/test/sidecar-naming.test.js +142 -0
  102. package/test/source-file.test.js +133 -0
  103. package/test/stale-request-after-seek.test.js +18 -12
  104. package/test/subtitle-language.test.js +252 -252
  105. package/test/timeline.test.js +95 -0
  106. package/test/{sidecar-files.test.js → torrent-files.test.js} +44 -1
  107. package/test/torrent-naming.test.js +255 -0
  108. package/test/tracks-begin-together.test.js +44 -32
  109. package/test/two-viewers-one-picture.test.js +347 -0
  110. package/test/video-facts.test.js +102 -0
  111. package/test/viewer-outputs.test.js +273 -0
  112. package/test/viewer.test.js +91 -0
  113. package/utils/perf.js +1 -63
  114. package/services/container/index.js +0 -6
  115. package/services/container-index/avi.js +0 -167
  116. package/services/container-index/index.js +0 -118
  117. package/services/container-index/matroska.js +0 -336
  118. package/services/container-index/mp4.js +0 -358
  119. package/services/controllers/index.js +0 -2
  120. package/services/download/index.js +0 -8
  121. package/services/orchestrators/index.js +0 -2
  122. /package/services/{container-index → container}/ebml-reader.js +0 -0
  123. /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
  124. /package/services/{language-detect.js → tracks/language-detect.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.
@@ -506,7 +519,7 @@ export class Mp4Container extends Container {
506
519
  return decodeSubtitleSample(payload, codecId);
507
520
  }
508
521
 
509
- async readKeyframeIndex() {
522
+ async parseKeyframeIndex() {
510
523
  const r = await readMp4KeyframeTimes(this.readRange, this.fileSize);
511
524
  if (!r) return null;
512
525
  if (Array.isArray(r)) return { times: r, tolerance: 0 };
@@ -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
+ }
@@ -320,4 +320,3 @@ ${clean}`;
320
320
  }
321
321
  }
322
322
 
323
- export { EXTENSIONS as SUBTITLE_FILE_EXTENSIONS };