@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.
- package/package.json +1 -1
- package/routes/encode-input/get.js +29 -0
- package/server.js +61 -12
- package/services/encode/EncodeInputs.js +8 -2
- package/services/encode/EncodeRuns.js +21 -3
- package/services/encode/OriginalInput.js +59 -0
- package/services/encode/admitted-command.js +8 -2
- package/services/encode/output/Timeline.js +4 -0
- package/services/encode/piece-completeness.js +6 -2
- package/services/encode/run-command.js +5 -5
- package/services/encode/segment-formats/fmp4.js +1 -0
- package/services/encode/segment-formats/mp4-boxes.js +26 -2
- package/services/encode/source-command.js +239 -0
- package/services/media/ContainerOrchestrator.js +7 -1
- package/services/media/container/MatroskaContainer.js +84 -3
- package/services/media/container/Mp4Container.js +10 -2
- package/services/media/container/ffprobe-record.js +2 -0
- package/services/media/playback-declarations.js +6 -2
- package/services/media/tracks/ContainerTrack.js +23 -0
- package/services/server/SegmentServing.js +3 -1
- package/services/server/wire-outputs.js +2 -1
- package/services/storage/piece-store/shared-piece-store.js +8 -1
- package/services/torrent/download/FutureDownload.js +98 -150
- package/services/torrent/download/SwarmSelection.js +45 -33
- package/services/torrent/download/bands.js +5 -0
- package/services/torrent/download/registry.js +5 -11
- package/services/viewer/DownloadMaps.js +1 -1
- package/services/viewer/NativeSourceMap.js +25 -0
- package/services/viewer/PriorityMap.js +1 -0
- package/services/viewer/playback-readiness.js +61 -19
- package/services/viewer/segment-demands.js +6 -2
- 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
|
-
|
|
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
|
-
|
|
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 =
|
|
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()
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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 =
|
|
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,
|