@torrent-tv/proxy 2.95.2 → 2.95.4

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 (32) hide show
  1. package/package.json +1 -1
  2. package/routes/encode-input/get.js +29 -0
  3. package/server.js +61 -12
  4. package/services/encode/EncodeInputs.js +8 -2
  5. package/services/encode/EncodeRuns.js +21 -3
  6. package/services/encode/OriginalInput.js +59 -0
  7. package/services/encode/admitted-command.js +8 -2
  8. package/services/encode/output/Timeline.js +4 -0
  9. package/services/encode/piece-completeness.js +6 -2
  10. package/services/encode/run-command.js +5 -5
  11. package/services/encode/segment-formats/fmp4.js +1 -0
  12. package/services/encode/segment-formats/mp4-boxes.js +26 -2
  13. package/services/encode/source-command.js +239 -0
  14. package/services/media/ContainerOrchestrator.js +7 -1
  15. package/services/media/container/MatroskaContainer.js +84 -3
  16. package/services/media/container/Mp4Container.js +10 -2
  17. package/services/media/container/ffprobe-record.js +2 -0
  18. package/services/media/playback-declarations.js +6 -2
  19. package/services/media/tracks/ContainerTrack.js +23 -0
  20. package/services/server/SegmentServing.js +3 -1
  21. package/services/server/wire-outputs.js +2 -1
  22. package/services/storage/piece-store/shared-piece-store.js +8 -1
  23. package/services/torrent/download/FutureDownload.js +98 -150
  24. package/services/torrent/download/SwarmSelection.js +45 -33
  25. package/services/torrent/download/bands.js +5 -0
  26. package/services/torrent/download/registry.js +5 -11
  27. package/services/viewer/DownloadMaps.js +1 -1
  28. package/services/viewer/NativeSourceMap.js +25 -0
  29. package/services/viewer/PriorityMap.js +1 -0
  30. package/services/viewer/playback-readiness.js +61 -19
  31. package/services/viewer/segment-demands.js +6 -2
  32. package/services/torrent/download/DeadlineScheduler.js +0 -195
@@ -0,0 +1,239 @@
1
+ import { PLAYLIST_FILE_NAME } from "./output/playlists.js";
2
+ import { AUDIO_TRANSCODE_KBPS } from "./args.js";
3
+ import { cutsAtGivenTimes, SEGMENT_CUT_TIME_DELTA_SECONDS } from "./output/index.js";
4
+ import { ffmpegSeconds, onKeyframeGridFor, publishedGridFor, publishedStartTime, segmentCutTimesFrom, nearestKeyframeAtOrBefore } from "./run-command.js";
5
+
6
+ /** Account for FFmpeg's Matroska B-frame input-seek adjustment (ffmpeg_demux.c). */
7
+ function seekLandingOffsetFor(material, keyframe) {
8
+ if (material.transcodeVideo || material.audioOnly || !(material.reorderDepth > 0)) return 0;
9
+ const wanted = 3 / 23 + Math.max(0, material.keyframes?.tolerance ?? 0);
10
+ const next = material.keyframes?.times?.find(time => time > keyframe + 0.001);
11
+ return next === undefined ? wanted : Math.min(wanted, (next - keyframe) / 2);
12
+ }
13
+
14
+ export function buildRunCommand({
15
+ keyframes,
16
+ inputFile,
17
+ audioFile,
18
+ inputUrl,
19
+ audioInputUrl: audioInputUrlGiven,
20
+ timeline,
21
+ output,
22
+ segmentFormat,
23
+ transcodeVideo,
24
+ transcodeAudio,
25
+ audioOnly,
26
+ audioSeparate,
27
+ audioSourceTrackIndex,
28
+ videoSourceTrackIndex = 0,
29
+ rateControl,
30
+ startIndex,
31
+ endIndex,
32
+ positionSecondsOverride,
33
+ videoEncoder,
34
+ segmentDurationSec,
35
+ reorderDepth = 0
36
+ }) {
37
+ const safeIndex = Number.isInteger(startIndex) && startIndex > 0 ? startIndex : 0;
38
+ const startSeconds = Number.isFinite(positionSecondsOverride)
39
+ ? positionSecondsOverride
40
+ : publishedStartTime(timeline, safeIndex, segmentDurationSec);
41
+ const keyframeGrid = onKeyframeGridFor({ audioOnly, timeline, transcodeVideo });
42
+ const servesAudioSeparately = audioOnly !== true && audioSeparate === true;
43
+ const audioFileStartTime = audioFile.startTime;
44
+ const sourceStartTime = inputFile.startTime;
45
+ const explicitTimes = segmentFormat.explicitTimesMuxerArgs?.() ?? null;
46
+ const inputIndex = !transcodeVideo && !audioOnly && explicitTimes && safeIndex > 0
47
+ ? safeIndex - 1 : safeIndex;
48
+ const gridCutTimes = cutsAtGivenTimes({ segmentFormat, timeline })
49
+ ? segmentCutTimesFrom(publishedGridFor(timeline), inputIndex)
50
+ : null;
51
+ const cutTimes = gridCutTimes;
52
+ const videoCodecArgs = transcodeVideo
53
+ ? videoEncoder.buildVideoArgs({
54
+ targetWidth: output.encodeWidth,
55
+ targetHeight: output.encodeHeight,
56
+ segmentDurationSec: segmentDurationSec,
57
+ fps: output.outputFps,
58
+ preset: output.softwarePreset ?? undefined,
59
+ tonemap: output.applyTonemap === true,
60
+ forcedKeyframeTimes: cutTimes,
61
+ rateControl: rateControl ?? null
62
+ })
63
+ : ["-c:v", "copy"];
64
+ const audioCodecArgs = transcodeAudio
65
+ ? ["-c:a", "aac", "-ac", "2", "-b:a", `${AUDIO_TRANSCODE_KBPS}k`]
66
+ : ["-c:a", "copy"];
67
+
68
+ const args = ["-hide_banner", "-nostats", "-loglevel", "error", "-progress", "pipe:1"];
69
+ if (transcodeVideo && Array.isArray(videoEncoder.inputArgs)) {
70
+ args.push(...videoEncoder.inputArgs);
71
+ }
72
+ const inputStartSeconds = inputIndex === safeIndex ? startSeconds :
73
+ publishedStartTime(timeline, inputIndex, segmentDurationSec);
74
+ const seekSeconds = timeline.cutGrid === "keyframe"
75
+ ? inputStartSeconds + sourceStartTime
76
+ : inputStartSeconds;
77
+ const carriedKeyframe = timeline.cutGrid === "keyframe" && typeof timeline.sourceStartOf === "function"
78
+ ? timeline.sourceStartOf(inputIndex)
79
+ : null;
80
+ const snappedKeyframe = Number.isFinite(carriedKeyframe)
81
+ ? carriedKeyframe
82
+ : (Array.isArray(keyframes?.times) && keyframes.times.length > 0
83
+ ? nearestKeyframeAtOrBefore(keyframes.times, seekSeconds)
84
+ : null);
85
+ const audioInputUrl =
86
+ typeof audioInputUrlGiven === "string" && audioInputUrlGiven.length > 0
87
+ ? audioInputUrlGiven
88
+ : "";
89
+ const audioTimelineShift = audioInputUrl
90
+ ? audioFileStartTime - sourceStartTime
91
+ : 0;
92
+ /**
93
+ * Add the second input, if there is one, with its own seek.
94
+ *
95
+ * Called between the first `-i` and any OUTPUT option, because ffmpeg reads
96
+ * these positionally: an option written after the last `-i` applies to the
97
+ * output, and the residual seek below is exactly such an option. Getting the
98
+ * order wrong would silently turn the audio file's seek into a trim of the
99
+ * finished stream.
100
+ *
101
+ * @param {number} inputSeekSeconds - Where to start, on the PICTURE's
102
+ * timeline. Translated to the soundtrack file's own here.
103
+ */
104
+ const pushAudioInput = (inputSeekSeconds) => {
105
+ if (!audioInputUrl) {
106
+ return;
107
+ }
108
+ if (audioTimelineShift !== 0 && keyframeGrid) {
109
+ args.push("-itsoffset", ffmpegSeconds(-audioTimelineShift));
110
+ }
111
+ const audioSeek = Math.max(0, inputSeekSeconds + audioTimelineShift);
112
+ if (audioSeek > 0) {
113
+ args.push("-accurate_seek", "-ss", ffmpegSeconds(audioSeek));
114
+ }
115
+ args.push("-i", audioInputUrl);
116
+ };
117
+
118
+ if (snappedKeyframe !== null) {
119
+ const residualSeconds = Math.max(0, seekSeconds - snappedKeyframe);
120
+ if (snappedKeyframe > 0) {
121
+ args.push("-ss", ffmpegSeconds(snappedKeyframe + seekLandingOffsetFor({ audioOnly, transcodeVideo, keyframes, reorderDepth }, snappedKeyframe)));
122
+ }
123
+ args.push("-i", inputUrl);
124
+ pushAudioInput(snappedKeyframe);
125
+ if (residualSeconds > 0 && !keyframeGrid) {
126
+ args.push("-ss", ffmpegSeconds(residualSeconds));
127
+ }
128
+ } else {
129
+ if (seekSeconds > 0) {
130
+ args.push("-accurate_seek", "-ss", ffmpegSeconds(seekSeconds));
131
+ }
132
+ args.push("-i", inputUrl);
133
+ pushAudioInput(seekSeconds);
134
+ }
135
+ if (!keyframeGrid) {
136
+ if (startSeconds > 0) {
137
+ args.push("-output_ts_offset", ffmpegSeconds(startSeconds));
138
+ }
139
+ } else {
140
+ args.push("-copyts");
141
+ if (sourceStartTime !== 0) {
142
+ args.push("-output_ts_offset", ffmpegSeconds(-sourceStartTime));
143
+ }
144
+ }
145
+ const runEnd = Number.isInteger(endIndex) ? endIndex : -1;
146
+ const publishedGrid = publishedGridFor(timeline);
147
+ if (runEnd >= safeIndex && Array.isArray(publishedGrid) && publishedGrid[runEnd + 1] > 0) {
148
+ const endsAt = publishedGrid[runEnd + 1];
149
+ if (transcodeVideo) {
150
+ args.push("-t", ffmpegSeconds(Math.max(0.1, endsAt - publishedGrid[safeIndex])));
151
+ } else {
152
+ args.push("-to", ffmpegSeconds(endsAt));
153
+ }
154
+ }
155
+ if (audioOnly === true) {
156
+ args.push("-vn", "-map", `0:a:${audioSourceTrackIndex}?`, ...audioCodecArgs);
157
+ } else if (servesAudioSeparately) {
158
+ args.push("-an", "-map", `0:v:${videoSourceTrackIndex}?`, ...videoCodecArgs);
159
+ } else {
160
+ args.push(
161
+ "-map",
162
+ `0:v:${videoSourceTrackIndex}?`,
163
+ "-map",
164
+ `${audioInputUrl ? 1 : 0}:a:${audioSourceTrackIndex}?`,
165
+ ...videoCodecArgs,
166
+ ...audioCodecArgs
167
+ );
168
+ }
169
+ if (cutTimes && cutTimes.length > 0) {
170
+ args.push(
171
+ "-f",
172
+ "segment",
173
+ "-segment_times",
174
+ cutTimes.join(","),
175
+ "-segment_time_delta",
176
+ String(SEGMENT_CUT_TIME_DELTA_SECONDS),
177
+ "-segment_start_number",
178
+ String(inputIndex),
179
+ "-segment_list",
180
+ "pipe:3",
181
+ "-segment_list_type",
182
+ "csv",
183
+ "-segment_list_flags",
184
+ "+live",
185
+ ...explicitTimes,
186
+ segmentFormat.makingFileNameTemplate(String(safeIndex))
187
+ );
188
+ } else {
189
+ args.push(
190
+ "-f",
191
+ "hls",
192
+ "-hls_time",
193
+ String(segmentDurationSec),
194
+ "-hls_list_size",
195
+ "0",
196
+ "-hls_flags",
197
+ "independent_segments+temp_file",
198
+ ...segmentFormat.muxerArgs(),
199
+ "-start_number",
200
+ String(safeIndex),
201
+ PLAYLIST_FILE_NAME
202
+ );
203
+ }
204
+ return { args, safeIndex, inputIndex, startSeconds, cutTimes };
205
+
206
+ }
207
+
208
+ /** One finite original-source run, preserving the published interval and track choice. */
209
+ export function buildOriginalCommand(params) {
210
+ const { admittedInput, timeline, startIndex, inputToken, baseUrl, audioOnly } = params;
211
+ const grid = publishedGridFor(timeline);
212
+ const from = grid[startIndex], to = grid[startIndex + 1];
213
+ if (!(to > from)) throw new Error("Original-source encoding requires a finite published interval.");
214
+ const sourceFor = kind => admittedInput.sources.find(source => source.input.selections.some(selection => selection.track.type === kind));
215
+ const primary = sourceFor(audioOnly ? "audio" : "video");
216
+ const audio = sourceFor("audio") ?? primary;
217
+ if (!primary) throw new Error("The selected source track is absent from original input.");
218
+ const url = source => new URL(`/encode-input/${inputToken}/${source.fileIndex}`, baseUrl).href;
219
+ const localTimeline = { ...timeline, published: [from, to, to + (to - from)],
220
+ boundaries: [from, to, to + (to - from)], sourceStartOf: () => timeline.sourceStartOf?.(startIndex) ?? from + primary.timeShiftSeconds };
221
+ const command = buildRunCommand({ ...params, startIndex: 0, endIndex: 0, timeline: localTimeline,
222
+ videoSourceTrackIndex: primary.input.selections.find(selection => selection.track.type === "video")?.index ?? 0,
223
+ reorderDepth: primary.input.selections.find(selection => selection.track.type === "video")?.track.reorderDepth ?? 0,
224
+ inputFile: { startTime: primary.timeShiftSeconds }, audioFile: { startTime: audio.timeShiftSeconds },
225
+ inputUrl: url(primary), audioInputUrl: audio !== primary && !audioOnly ? url(audio) : "",
226
+ audioSourceTrackIndex: audio.input.selections.find(selection => selection.track.type === "audio")?.index ?? 0 });
227
+ const args = command.args;
228
+ // The segment muxer otherwise shifts negative initial DTS to zero before
229
+ // the inner muxer writes its edit list, moving B-picture PTS off the source.
230
+ args.splice(args.indexOf("-f"), 0, "-avoid_negative_ts", "disabled");
231
+ const formatOptions = args.indexOf("-segment_format_options");
232
+ if (formatOptions >= 0) args[formatOptions + 1] += ":avoid_negative_ts=disabled";
233
+ const numbering = args.indexOf("-segment_start_number");
234
+ if (numbering < 0) throw new Error("The format cannot produce finite original-source segments.");
235
+ args[numbering + 1] = String(startIndex);
236
+ args[args.length - 1] = params.segmentFormat.makingFileNameTemplate(admittedInput.runTag);
237
+ if (audioOnly) args[args.indexOf("-segment_time_delta") + 1] = "0";
238
+ return { ...command, safeIndex: startIndex, inputIndex: startIndex };
239
+ }
@@ -129,7 +129,7 @@ export class ContainerOrchestrator {
129
129
 
130
130
  /** Read one statement without converting missing bytes into an empty answer. */
131
131
  async inspect(params, statement = "tracks") {
132
- if (!["container", "tracks", "media-info", "keyframes", "packets", "fingerprint", "work-tags", "cover"].includes(statement)) {
132
+ if (!["container", "tracks", "media-info", "keyframes", "packets", "source-navigation", "source-ranges", "fingerprint", "work-tags", "cover"].includes(statement)) {
133
133
  throw new TypeError(`Unknown media statement: ${statement}`);
134
134
  }
135
135
  const key = `${params.sourceKey}:${params.fileIndex}`;
@@ -191,6 +191,12 @@ export class ContainerOrchestrator {
191
191
  } else if (statement === "work-tags" || statement === "cover") {
192
192
  value = await (statement === "cover" ? container.readCover(edgesOf(params)) : container.readWorkTags(edgesOf(params)));
193
193
  if (value === null) return { kind: "terminal", reason: statement === "cover" ? "no-cover" : "format-states-nothing", requestId };
194
+ } else if (statement === "source-navigation") {
195
+ value = await container.supportsOriginalSourceRanges?.() === true;
196
+ } else if (statement === "source-ranges") {
197
+ if (typeof container.readSourceRanges !== "function") return { kind: "terminal", reason: "source-range-index-not-supported", requestId };
198
+ value = await container.readSourceRanges(params.packetInterval);
199
+ if (value.kind !== "result") return { ...value, requestId };
194
200
  } else if (statement === "packets") {
195
201
  value = await container.readPacketIndex(params.packetInterval);
196
202
  await value?.prepareAudioDependencies?.(params.packetInterval, container.readRange);
@@ -145,6 +145,8 @@ export class MatroskaContainer extends Container {
145
145
 
146
146
  /** @type {SegmentLayout | null} */
147
147
  #layout = null;
148
+ #sourceElementEnds = new Map();
149
+ #sourceSeekPoints = null;
148
150
 
149
151
  /** @type {Array<import("../tracks/ContainerTrack.js").ContainerTrack> | null} */
150
152
  #tracks = null;
@@ -569,6 +571,83 @@ export class MatroskaContainer extends Container {
569
571
  return result;
570
572
  }
571
573
 
574
+ /** Original-file ranges from Cues alone, without reading media block headers.
575
+ * FFmpeg needs the declarations and seek table as well as complete clusters.
576
+ * One preceding cluster retains preroll; one following cluster covers demux
577
+ * lookahead beyond the presentation cut. No packet payload is parsed here.
578
+ */
579
+ async supportsOriginalSourceRanges() {
580
+ const cues = await this.readCues();
581
+ if (!cues?.points.length) return false;
582
+ const video = ContainerTrack.firstUsable(await this.readTracks(), "video");
583
+ return cues.points.some(point => point.positions.some(position => !video || position.track === video.trackNumber));
584
+ }
585
+
586
+ async readMappedSourceRanges(interval) { return this.readSourceRanges(interval); }
587
+
588
+ async readSourceRanges(interval) {
589
+ if (!Number.isFinite(interval?.from) || !Number.isFinite(interval?.to) || interval.to <= interval.from) {
590
+ throw new TypeError("Source ranges require a finite increasing interval.");
591
+ }
592
+ const layout = await this.#segmentLayout();
593
+ if (!layout || layout.firstClusterAt === null) return { kind: "terminal", reason: "source-has-no-clusters" };
594
+ const cues = await this.readCues();
595
+ if (!await this.supportsOriginalSourceRanges()) return { kind: "needs-index", reason: "source-has-no-cue-map" };
596
+ const tracks = await this.readTracks();
597
+ const requested = tracks.filter(track => !interval.trackIds?.length || interval.trackIds.includes(track.trackNumber));
598
+ const video = ContainerTrack.firstUsable(tracks, "video");
599
+ const points = this.#sourceSeekPoints ??= cues.points.flatMap(point => point.positions
600
+ .filter(position => !video || position.track === video.trackNumber)
601
+ .map(position => ({ at: position.clusterAt, seconds: point.ticks * layout.secondsPerTick })))
602
+ .sort((left, right) => left.seconds - right.seconds || left.at - right.at);
603
+ if (!points.length) return super.readSourceRanges(interval);
604
+ const preroll = Math.max(0, ...requested.map(track => track.seekPrerollSeconds ?? 0));
605
+ const firstAt = (seconds, strict) => {
606
+ let low = 0, high = points.length;
607
+ while (low < high) {
608
+ const middle = Math.floor((low + high) / 2);
609
+ if (points[middle].seconds < seconds || strict && points[middle].seconds === seconds) low = middle + 1;
610
+ else high = middle;
611
+ }
612
+ return low;
613
+ };
614
+ const first = Math.max(0, firstAt(interval.from - preroll, true) - 2);
615
+ const beyond = firstAt(interval.to, false);
616
+ const following = points[beyond + 1];
617
+ const mediaEnd = following ? following.at - 1 : layout.segmentEnd - 1;
618
+ const elementEnd = async at => {
619
+ if (this.#sourceElementEnds.has(at)) return this.#sourceElementEnds.get(at);
620
+ const reader = new ElementReader({ read: this.readRange, fileSize: this.fileSize, portionBytes: this.portionBytes });
621
+ const header = await reader.header(at, layout.segmentEnd);
622
+ if (!header || header.end === null) return null;
623
+ this.#sourceElementEnds.set(at, header.end);
624
+ return header.end;
625
+ };
626
+ const cuesEnd = await elementEnd(layout.cuesAt);
627
+ if (cuesEnd === null) return { kind: "terminal", reason: "source-seek-index-is-unbounded" };
628
+ const requestBytes = 1024 * 1024;
629
+ const paddedEnd = end => Math.min(this.fileSize - 1, end + requestBytes);
630
+ const ranges = [[0, paddedEnd(layout.firstClusterAt - 1)],
631
+ [points[first].at, paddedEnd(mediaEnd)], [layout.cuesAt, paddedEnd(cuesEnd - 1)],
632
+ [Math.max(0, this.fileSize - requestBytes), this.fileSize - 1]];
633
+ // SeekHead may name top-level declarations after media clusters. Preserve
634
+ // their original bytes instead of reconstructing declarations for FFmpeg.
635
+ for (const at of [layout.infoAt, layout.tracksAt]) {
636
+ if (at === null || at < layout.firstClusterAt) continue;
637
+ const end = await elementEnd(at);
638
+ if (end === null) return { kind: "terminal", reason: "source-declarations-are-unbounded" };
639
+ ranges.push([at, paddedEnd(end - 1)]);
640
+ }
641
+ ranges.sort((left, right) => left[0] - right[0]);
642
+ const union = [];
643
+ for (const range of ranges) {
644
+ const previous = union.at(-1);
645
+ if (previous && range[0] <= previous[1] + 1) previous[1] = Math.max(previous[1], range[1]);
646
+ else union.push([...range]);
647
+ }
648
+ return { kind: "result", from: interval.from, to: interval.to, ranges: union, fileLength: this.fileSize };
649
+ }
650
+
572
651
  async readPacketIndex(interval) {
573
652
  if (this.#packets) return this.#packets;
574
653
  const layout = await this.#segmentLayout();
@@ -604,7 +683,7 @@ export class MatroskaContainer extends Container {
604
683
  // picture cues alone cannot prove that an earlier subtitle has ended.
605
684
  if (requested.every(track => track.type === "subtitle")) return first;
606
685
  const cueTrack = requested.find(track => track.type === "video") ??
607
- tracks.find(track => track.type === "video") ?? requested[0];
686
+ ContainerTrack.firstUsable(tracks, "video") ?? requested[0];
608
687
  if (!cueTrack) return first;
609
688
  const points = (cues?.points ?? []).flatMap(point => point.positions
610
689
  .filter(position => position.track === cueTrack.trackNumber && position.clusterAt >= first && position.clusterAt < layout.segmentEnd)
@@ -823,7 +902,7 @@ export class MatroskaContainer extends Container {
823
902
  }
824
903
 
825
904
  /**
826
- * The keyframe times of the first video track, from the one Cues reading.
905
+ * The keyframe times of the first usable video track, from the one Cues reading.
827
906
  *
828
907
  * A CuePoint belongs to the track named inside its CueTrackPositions, and a
829
908
  * muxer indexes whatever tracks it likes — field files index their subtitle
@@ -845,7 +924,9 @@ export class MatroskaContainer extends Container {
845
924
  return null;
846
925
  }
847
926
  const tracks = await this.readTracks();
848
- const video = tracks.find((track) => track.type === "video")?.trackNumber ?? null;
927
+ // The picture's own entries: a video track the file marks unusable is not
928
+ // played, so its keyframes are not where the picture can be cut.
929
+ const video = ContainerTrack.firstUsable(tracks, "video")?.trackNumber ?? null;
849
930
  const timesFor = (wanted) =>
850
931
  cues.points
851
932
  .filter((point) => wanted === null || point.positions.some((position) => position.track === wanted))
@@ -21,6 +21,7 @@ import { VideoTrack } from "../tracks/VideoTrack.js";
21
21
  import { AudioTrack } from "../tracks/AudioTrack.js";
22
22
  import { TextSubtitleTrack, TEXT_FORMATS_MP4 } from "../tracks/TextSubtitleTrack.js";
23
23
  import { ImageSubtitleTrack } from "../tracks/ImageSubtitleTrack.js";
24
+ import { ContainerTrack } from "../tracks/ContainerTrack.js";
24
25
  import { isUnavailable } from "./unavailable.js";
25
26
  import { PacketIndex } from "./PacketIndex.js";
26
27
  import { h264Configuration } from "./h264-configuration.js";
@@ -565,7 +566,7 @@ export class Mp4Container extends Container {
565
566
  this.fragmentState ??= {};
566
567
  index = await readMp4Fragments({ readRange: this.readRange, fileSize: this.fileSize,
567
568
  tracks: fragmentTracks(held, tracks), memory: this.packetMemory, state: this.fragmentState });
568
- const timeline = tracks.find(track => track.type === "video") ?? tracks.find(track => track.type === "audio");
569
+ const timeline = ContainerTrack.firstUsable(tracks, "video") ?? ContainerTrack.firstUsable(tracks, "audio");
569
570
  const bounds = timeline && index.boundsOf(timeline.trackNumber);
570
571
  if (bounds) this.mediaInfo = { ...(await this.readMediaInfo()), startTimeSeconds: bounds.start,
571
572
  durationSeconds: bounds.end - bounds.start };
@@ -842,7 +843,7 @@ export class Mp4Container extends Container {
842
843
  async parseKeyframeIndex() {
843
844
  const held = await this.#moovBuffer();
844
845
  if (held && childOf(held.moov, held.header, held.moov.length, "mvex")) {
845
- const video = (await this.readTracks()).find(track => track.type === "video");
846
+ const video = ContainerTrack.firstUsable(await this.readTracks(), "video");
846
847
  return video ? { times: (await this.readPacketIndex()).keyframesOf(video.trackNumber), tolerance: 0 } : null;
847
848
  }
848
849
  const r = held ? keyframeTimesFromMoov(held.moov, held.header) : null;
@@ -1664,6 +1665,13 @@ function keyframeTimesFromMoov(moov, headerBytes) {
1664
1665
  if (!mdia || !isVideoTrack(moov, mdia)) {
1665
1666
  continue;
1666
1667
  }
1668
+ // "A disabled track (the low bit is zero) is treated as if it were not
1669
+ // present" (ISO/IEC 14496-12 §8.3.2), so it is not the picture whose
1670
+ // keyframes decide the cuts (torrent-tv/meta#49).
1671
+ const tkhd = findBox(moov, trak.dataOffset, trak.end, "tkhd");
1672
+ if (tkhd && tkhd.dataOffset + 4 <= tkhd.end && (moov.readUIntBE(tkhd.dataOffset + 1, 3) & 0x000001) === 0) {
1673
+ continue;
1674
+ }
1667
1675
  const mdhd = findBox(moov, mdia.dataOffset, mdia.end, "mdhd");
1668
1676
  if (!mdhd) {
1669
1677
  continue;
@@ -15,6 +15,8 @@ export function ffprobeRecord(line) {
15
15
  const kind = fields.shift();
16
16
  const record = { kind };
17
17
  for (const [position, value] of fields.entries()) {
18
+ // Disabled nested sections may leave a trailing separator in FFprobe 8.
19
+ if (value === "" && fields.slice(position + 1).every(field => field === "")) break;
18
20
  // ffprobe 5 emits an empty nested section even when its entries are disabled.
19
21
  if (value === "side_data" && fields.slice(position + 1).every(field => field === "")) break;
20
22
  const separator = value.indexOf("=");
@@ -1,5 +1,6 @@
1
1
  import { AudioTrack } from "./tracks/AudioTrack.js";
2
2
  import { TextSubtitleTrack } from "./tracks/TextSubtitleTrack.js";
3
+ import { ContainerTrack } from "./tracks/ContainerTrack.js";
3
4
 
4
5
  const VIDEO_CODECS = new Map([
5
6
  ["V_MPEG4/ISO/AVC", "h264"], ["avc1", "h264"], ["avc3", "h264"],
@@ -33,7 +34,9 @@ function videoCodecOf(track) {
33
34
  export function playbackDeclarations({ tracks, media, fileBytes = null }) {
34
35
  const ordered = type => tracks.filter(track => track.type === type)
35
36
  .sort((a, b) => a.declaredIndex - b.declaredIndex);
36
- const video = ordered("video")[0];
37
+ // The picture is the first video track the container marks usable; a
38
+ // disabled one is not played (torrent-tv/meta#49).
39
+ const video = ContainerTrack.firstUsable(tracks, "video") ?? undefined;
37
40
  const inventory = type => ordered(type).map((track, index) => ({
38
41
  ...track, index, streamIndex: track.declaredIndex,
39
42
  codec: type === "audio" ? AudioTrack.codecNameOf(track)
@@ -46,7 +49,8 @@ export function playbackDeclarations({ tracks, media, fileBytes = null }) {
46
49
  }));
47
50
  const audioTracks = inventory("audio"), subtitleTracks = inventory("subtitle");
48
51
  return { tracks, video, audioTracks, subtitleTracks,
49
- audioCodec: audioTracks[0]?.codec ?? "",
52
+ // The soundtrack a file opens with is its first usable one.
53
+ audioCodec: ContainerTrack.firstUsable(audioTracks, "audio")?.codec ?? "",
50
54
  videoCodec: video ? videoCodecOf(video) : "",
51
55
  container: media.format,
52
56
  durationSeconds: media.durationSeconds,
@@ -72,6 +72,29 @@ export class ContainerTrack {
72
72
  this.alternateGroup = Number.isFinite(alternateGroup) ? alternateGroup : 0;
73
73
  }
74
74
 
75
+ /**
76
+ * The first track of `type` the container marks usable, or null.
77
+ *
78
+ * Matroska `FlagEnabled`: "Set to 1 if the track is usable" (RFC 9559
79
+ * §5.1.4.1.3). MP4 `track_enabled`: "A disabled track … is treated as if it
80
+ * were not present" (ISO/IEC 14496-12 §8.3.2). So the picture a file is
81
+ * played with, and the soundtrack it opens with when nobody chose one, are the
82
+ * first USABLE ones — not the first declared. A disabled track keeps its
83
+ * position in the list, because ffmpeg numbers it either way.
84
+ *
85
+ * Takes plain objects as well as tracks: tracks cross the worker boundary as
86
+ * plain objects.
87
+ *
88
+ * @param {Array<{ type: string, isEnabled?: boolean, declaredIndex?: number }>} tracks
89
+ * @param {"video" | "audio" | "subtitle"} type
90
+ * @returns {object | null}
91
+ */
92
+ static firstUsable(tracks, type) {
93
+ return (Array.isArray(tracks) ? tracks : [])
94
+ .filter((track) => track?.type === type && track.isEnabled !== false)
95
+ .sort((a, b) => (a.declaredIndex ?? 0) - (b.declaredIndex ?? 0))[0] ?? null;
96
+ }
97
+
75
98
  /** Whether this track should be offered in UI menus. Base rule: disabled tracks hidden but still counted for declaredIndex. */
76
99
  isOfferable() {
77
100
  return this.isEnabled;
@@ -794,7 +794,9 @@ export class SegmentServing {
794
794
  }
795
795
  const ownInit = session.segmentFormat.extractInit(cached ?? await readFile(found));
796
796
  const init = ownInit && session.segmentFormat.prepareSharedInit ?
797
- session.segmentFormat.prepareSharedInit(ownInit) : ownInit;
797
+ session.segmentFormat.prepareSharedInit(ownInit, {
798
+ audioEncoderDelay: session.spec.transcodesAudio ? 1024 : 0
799
+ }) : ownInit;
798
800
  if (!init || init.length === 0) {
799
801
  continue;
800
802
  }
@@ -341,6 +341,7 @@ export function wireOutputs({
341
341
  get encodeCost() { return parts.encodeCost; },
342
342
  get encodeOrchestrator() { return parts.encodeOrchestrator; },
343
343
  get ffmpegBin() { return parts.ffmpegBin; },
344
+ get localBaseUrl() { return parts.localBaseUrl; },
344
345
  get outputTimes() { return parts.outputTimes; },
345
346
  get outputs() { return parts.outputs; },
346
347
  get priority() { return parts.priority; },
@@ -972,7 +973,7 @@ export function wireOutputs({
972
973
  reviseBudget: () => parts.machineBudget.revise(),
973
974
  capacity: () => parts.machineBudget.capacityOf("memory"),
974
975
  urgent: (output, index) => parts.encodeOrchestrator.wantedSegmentsOn(output.outputKey).some(window =>
975
- index >= window.from && index <= window.to && window.withinSeconds === 0),
976
+ index >= window.from && index <= window.to && window.urgent === true),
976
977
  changed: (output, result) => {
977
978
  if (result.kind === "terminal") {
978
979
  logger.warn(`encode input output=${output.outputKey} terminal=${result.reason}: ${result.message ?? ""}` +
@@ -52,6 +52,7 @@ import { PieceDiskStore } from "./piece-disk-store.js";
52
52
  * @type {Set<SharedPieceStore>}
53
53
  */
54
54
  const liveStores = new Set();
55
+ let assignedMemoryAllowance = null;
55
56
 
56
57
  export function collectStoreStats() {
57
58
  return [...liveStores].map((store) => store.stats());
@@ -107,11 +108,17 @@ export function machineReserveBytes() {
107
108
  /** Forget what other processes have needed. For tests, which share a module. */
108
109
  export function forgetMachineMemory() {
109
110
  otherDemand.forget();
111
+ assignedMemoryAllowance = null;
110
112
  }
111
113
 
112
114
  export { divideAllowance, machineAllowanceBytes };
113
115
 
114
- export function reviseStoreBudgets(allowanceBytes = null) {
116
+ export function reviseStoreBudgets(allowanceBytes = assignedMemoryAllowance) {
117
+ // Periodic maintenance must retain the main thread's latest allocation.
118
+ // Recomputing it from host free memory would overwrite a fixed limit and
119
+ // the memory reserved for admitted encoder input.
120
+ assignedMemoryAllowance = Number.isFinite(allowanceBytes) && allowanceBytes !== null
121
+ ? Math.max(0, allowanceBytes) : null;
115
122
  const stores = [...liveStores];
116
123
  const held = stores.reduce((sum, store) => sum + store.residentBytes, 0);
117
124
  // TOLD, NOT WORKED OUT. Memory has one owner and it is on the main thread,