@torrent-tv/proxy 2.95.34 → 2.95.36
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 +8 -4
- package/services/encode/EncodeRuns.js +3 -1
- package/services/encode/output/Timeline.js +13 -2
- package/services/encode/source-command.js +10 -3
- package/services/media/container/AviContainer.js +10 -3
- package/services/media/container/MatroskaContainer.js +10 -3
- package/services/media/container/ffmpeg-stream-search.js +15 -0
package/package.json
CHANGED
|
@@ -21,10 +21,14 @@ export function handleEncodeInputGet(req, reply, { inputOf, refused = () => {} }
|
|
|
21
21
|
if (!range || range.start >= length) return reply.code(416).header("Content-Range", `bytes */${length}`).send();
|
|
22
22
|
// The answer runs to the end of the admitted range the read starts in, which
|
|
23
23
|
// is what keeps FFmpeg inside the bytes the run holds. A shorter answer is
|
|
24
|
-
// not continued by every FFmpeg:
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// segment longer than that on 6.1 (torrent-tv/meta#151).
|
|
24
|
+
// not continued by every FFmpeg: measured, 8.1.2 and 9.0.2 send a new
|
|
25
|
+
// request, while 5.1, 6.1, 7.0.2 (ffmpeg-static on Linux), 7.1 and 8.0 take
|
|
26
|
+
// it for a read error. A cap of 1 MiB here ended every original-source
|
|
27
|
+
// segment longer than that on 6.1 (torrent-tv/meta#151). The end of an
|
|
28
|
+
// admitted range is therefore only reached where FFmpeg needs nothing more,
|
|
29
|
+
// and a byte past it is refused, which from 7.1 on ends the run: every read
|
|
30
|
+
// FFmpeg makes, its stream search at open included, must be named by the
|
|
31
|
+
// container (torrent-tv/meta#165).
|
|
28
32
|
const held = input.read(fileIndex, range.start, range.end, true);
|
|
29
33
|
if (!held) {
|
|
30
34
|
// FFmpeg asked for a byte the run was not given: the container named too
|
|
@@ -494,11 +494,13 @@ export class EncodeRuns {
|
|
|
494
494
|
// each: 454 pieces against 401 on the field file.
|
|
495
495
|
for (const [address, sessions] of byOutput) {
|
|
496
496
|
const timeline = sessions[0].timeline;
|
|
497
|
+
const spec = sessions[0].spec;
|
|
497
498
|
this.#host.encodeOrchestrator.notePriorityMap(
|
|
498
499
|
address,
|
|
499
500
|
timeline?.inSegments?.(
|
|
500
501
|
this.#host.priority.mapForOutput(address),
|
|
501
|
-
Number(timeline?.segmentCount) || 0
|
|
502
|
+
Number(timeline?.segmentCount) || 0,
|
|
503
|
+
{ runsPastEnd: Boolean(spec?.audio) && spec?.transcodesAudio !== true }
|
|
502
504
|
) ?? []
|
|
503
505
|
);
|
|
504
506
|
}
|
|
@@ -159,10 +159,13 @@ export class Timeline {
|
|
|
159
159
|
* @param {import("../../viewer/PriorityMap.js").PriorityMap} map - One number
|
|
160
160
|
* per second of film.
|
|
161
161
|
* @param {number} segmentCount - How many pieces this output has.
|
|
162
|
+
* @param {{ runsPastEnd?: boolean }} [shape] - `runsPastEnd`: the output's
|
|
163
|
+
* pieces copy a stream, which is cut where its packets end, so a piece
|
|
164
|
+
* holds sound past its published end by up to one packet.
|
|
162
165
|
* @returns {import("../../viewer/PriorityMap.js").DemandZone[]} Runs of pieces
|
|
163
166
|
* that agree. Empty where nothing is stated, which says nobody is coming.
|
|
164
167
|
*/
|
|
165
|
-
inSegments(map, segmentCount) {
|
|
168
|
+
inSegments(map, segmentCount, { runsPastEnd = false } = {}) {
|
|
166
169
|
if (!(segmentCount > 0) || !map || !(map.durationSeconds > 0)) {
|
|
167
170
|
return [];
|
|
168
171
|
}
|
|
@@ -173,9 +176,17 @@ export class Timeline {
|
|
|
173
176
|
// whole, so it is wanted as soon as the soonest second inside it is
|
|
174
177
|
// wanted — and the piece a viewer is standing in the middle of is wanted
|
|
175
178
|
// exactly as much as the second under their feet.
|
|
179
|
+
// A COPIED PIECE ALSO HOLDS THE SECOND ITS END FALLS IN. A copy ends at
|
|
180
|
+
// the first packet boundary at or after its published end, and the
|
|
181
|
+
// player places the next piece where this one truly ends (hls.js
|
|
182
|
+
// `updateFragPTSDTS` carries a parsed piece's end to every later one).
|
|
183
|
+
// Field 2026-10-09, MP3 copied out of an AVI on a 4 s grid: pieces ended
|
|
184
|
+
// up to 24 ms late, and a seek to 4000 s asked for the piece ending at
|
|
185
|
+
// 4000 s, which nothing made because second 4000 belonged to the next.
|
|
176
186
|
const from = Math.max(0, Math.floor(this.publishedStartOf(index)));
|
|
187
|
+
const end = this.publishedStartOf(index + 1);
|
|
177
188
|
const until = index + 1 < segmentCount
|
|
178
|
-
? Math.max(from + 1, Math.
|
|
189
|
+
? Math.max(from + 1, runsPastEnd ? Math.floor(end) + 1 : Math.ceil(end))
|
|
179
190
|
: map.durationSeconds;
|
|
180
191
|
let priority = 0;
|
|
181
192
|
let withinSeconds = Number.POSITIVE_INFINITY;
|
|
@@ -17,6 +17,8 @@ export function buildRunCommand({
|
|
|
17
17
|
audioFile,
|
|
18
18
|
inputUrl,
|
|
19
19
|
audioInputUrl: audioInputUrlGiven,
|
|
20
|
+
inputOpenArgs = [],
|
|
21
|
+
audioInputOpenArgs = [],
|
|
20
22
|
timeline,
|
|
21
23
|
output,
|
|
22
24
|
segmentFormat,
|
|
@@ -126,7 +128,7 @@ export function buildRunCommand({
|
|
|
126
128
|
if (audioSeek > audioFileStartTime) {
|
|
127
129
|
args.push("-seek_timestamp", "1", ...inputSeekArgs, "-ss", ffmpegSeconds(audioSeek));
|
|
128
130
|
}
|
|
129
|
-
args.push("-i", audioInputUrl);
|
|
131
|
+
args.push(...audioInputOpenArgs, "-i", audioInputUrl);
|
|
130
132
|
};
|
|
131
133
|
|
|
132
134
|
// Whether an output `-ss` already cuts at the interval's start.
|
|
@@ -136,7 +138,7 @@ export function buildRunCommand({
|
|
|
136
138
|
if (snappedKeyframe > sourceStartTime) {
|
|
137
139
|
args.push("-seek_timestamp", "1", ...inputSeekArgs, "-ss", ffmpegSeconds(snappedKeyframe + seekLandingOffsetFor({ audioOnly, transcodeVideo, keyframes, reorderDepth }, snappedKeyframe)));
|
|
138
140
|
}
|
|
139
|
-
args.push("-i", inputUrl);
|
|
141
|
+
args.push(...inputOpenArgs, "-i", inputUrl);
|
|
140
142
|
pushAudioInput(snappedKeyframe);
|
|
141
143
|
if (residualSeconds > 0 && !keyframeGrid) {
|
|
142
144
|
args.push("-ss", ffmpegSeconds(residualSeconds));
|
|
@@ -146,7 +148,7 @@ export function buildRunCommand({
|
|
|
146
148
|
if (seekAt > sourceStartTime) {
|
|
147
149
|
args.push("-seek_timestamp", "1", ...inputSeekArgs, "-ss", ffmpegSeconds(seekAt));
|
|
148
150
|
}
|
|
149
|
-
args.push("-i", inputUrl);
|
|
151
|
+
args.push(...inputOpenArgs, "-i", inputUrl);
|
|
150
152
|
pushAudioInput(seekAt);
|
|
151
153
|
}
|
|
152
154
|
// An input seek lands on the picture's keyframe at or before the time asked
|
|
@@ -269,6 +271,10 @@ export function buildOriginalCommand(params) {
|
|
|
269
271
|
const audio = sourceFor("audio") ?? primary;
|
|
270
272
|
if (!primary) throw new Error("The selected source track is absent from original input.");
|
|
271
273
|
const url = source => new URL(`/encode-input/${inputToken}/${source.fileIndex}`, baseUrl).href;
|
|
274
|
+
// The container named the bytes FFmpeg's stream search reads for this much
|
|
275
|
+
// media time; the search is told the same, whatever its own default.
|
|
276
|
+
const openArgs = source => Number.isFinite(source.input.streamSearchSeconds)
|
|
277
|
+
? ["-analyzeduration", String(Math.round(source.input.streamSearchSeconds * 1_000_000))] : [];
|
|
272
278
|
const local = [...stretch, to + (to - last)];
|
|
273
279
|
const localTimeline = { ...timeline, published: local,
|
|
274
280
|
boundaries: local, sourceStartOf: () => primary.input.from ?? from + primary.timeShiftSeconds };
|
|
@@ -277,6 +283,7 @@ export function buildOriginalCommand(params) {
|
|
|
277
283
|
reorderDepth: primary.input.selections.find(selection => selection.track.type === "video")?.track.reorderDepth ?? 0,
|
|
278
284
|
inputFile: { startTime: primary.timeShiftSeconds }, audioFile: { startTime: audio.timeShiftSeconds },
|
|
279
285
|
inputUrl: url(primary), audioInputUrl: audio !== primary && !audioOnly ? url(audio) : "",
|
|
286
|
+
inputOpenArgs: openArgs(primary), audioInputOpenArgs: openArgs(audio),
|
|
280
287
|
audioSourceTrackIndex: audio.input.selections.find(selection => selection.track.type === "audio")?.index ?? 0 });
|
|
281
288
|
const args = command.args;
|
|
282
289
|
// The segment muxer otherwise shifts negative initial DTS to zero before
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* reassembled only for an AVI that states no index.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { FFMPEG_STREAM_SEARCH_SECONDS } from "./ffmpeg-stream-search.js";
|
|
10
11
|
import { Container } from "./Container.js";
|
|
11
12
|
import { VideoTrack } from "../tracks/VideoTrack.js";
|
|
12
13
|
import { AudioTrack } from "../tracks/AudioTrack.js";
|
|
@@ -162,7 +163,8 @@ export class AviContainer extends Container {
|
|
|
162
163
|
* The bytes FFmpeg reads to produce `[from, to)` from the original file:
|
|
163
164
|
* the declarations and the start of `movi` it probes, the index it loads at
|
|
164
165
|
* open (`idx1` to the end of the file, or every OpenDML standard index), the
|
|
165
|
-
* first packet of each stream it inspects,
|
|
166
|
+
* first packet of each stream it inspects, the stretch its stream search
|
|
167
|
+
* reads from the start of `movi`, and the selected streams' packets
|
|
166
168
|
* around the interval. Each range is held one input buffer of FFmpeg past its
|
|
167
169
|
* last named byte, because that buffer is filled ahead of the packet parsed.
|
|
168
170
|
*/
|
|
@@ -182,7 +184,11 @@ export class AviContainer extends Container {
|
|
|
182
184
|
const requested = tracks.filter(track => ["video", "audio"].includes(track.type));
|
|
183
185
|
const picture = ContainerTrack.firstUsable(tracks, "video");
|
|
184
186
|
const padded = ([start, end]) => [Math.max(0, start), Math.min(this.fileSize - 1, end + FFMPEG_INPUT_BUFFER_BYTES)];
|
|
185
|
-
|
|
187
|
+
// FFmpeg's stream search reads `movi` on from its start for its stated
|
|
188
|
+
// media time: every stream's packets in file order, from the first.
|
|
189
|
+
const search = index.mediaRanges({ from: 0, to: FFMPEG_STREAM_SEARCH_SECONDS, picture: null,
|
|
190
|
+
streams: requested.map(track => track.trackNumber) });
|
|
191
|
+
const ranges = [[0, movi.start + 3], ...index.indexRanges, ...index.firstPackets(), ...search,
|
|
186
192
|
...index.mediaRanges({ from: interval.from, to: interval.to, picture: picture?.trackNumber ?? null,
|
|
187
193
|
streams: requested.map(track => track.trackNumber) })].map(padded);
|
|
188
194
|
ranges.sort((left, right) => left[0] - right[0]);
|
|
@@ -192,7 +198,8 @@ export class AviContainer extends Container {
|
|
|
192
198
|
if (previous && range[0] <= previous[1] + 1) previous[1] = Math.max(previous[1], range[1]);
|
|
193
199
|
else union.push([...range]);
|
|
194
200
|
}
|
|
195
|
-
return { kind: "result", from: interval.from, to: interval.to, ranges: union, fileLength: this.fileSize
|
|
201
|
+
return { kind: "result", from: interval.from, to: interval.to, ranges: union, fileLength: this.fileSize,
|
|
202
|
+
streamSearchSeconds: FFMPEG_STREAM_SEARCH_SECONDS };
|
|
196
203
|
}
|
|
197
204
|
|
|
198
205
|
packetIndexBytes() { return (this.#packets ?? this.#scan?.index)?.allocatedBytes() ?? 0; }
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
* only way in.
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
|
+
import { FFMPEG_STREAM_SEARCH_SECONDS } from "./ffmpeg-stream-search.js";
|
|
35
36
|
import { Container } from "./Container.js";
|
|
36
37
|
import { VideoTrack } from "../tracks/VideoTrack.js";
|
|
37
38
|
import { h264Configuration } from "./h264-configuration.js";
|
|
@@ -572,7 +573,8 @@ export class MatroskaContainer extends Container {
|
|
|
572
573
|
}
|
|
573
574
|
|
|
574
575
|
/** Original-file ranges from Cues alone, without reading media block headers.
|
|
575
|
-
* FFmpeg needs the declarations
|
|
576
|
+
* FFmpeg needs the declarations, the Clusters its stream search reads from the
|
|
577
|
+
* first one, and the seek table as well as complete clusters.
|
|
576
578
|
* One preceding cluster retains preroll; one following cluster covers demux
|
|
577
579
|
* lookahead beyond the presentation cut. No packet payload is parsed here.
|
|
578
580
|
*/
|
|
@@ -627,7 +629,11 @@ export class MatroskaContainer extends Container {
|
|
|
627
629
|
if (cuesEnd === null) return { kind: "terminal", reason: "source-seek-index-is-unbounded" };
|
|
628
630
|
const requestBytes = 1024 * 1024;
|
|
629
631
|
const paddedEnd = end => Math.min(this.fileSize - 1, end + requestBytes);
|
|
630
|
-
|
|
632
|
+
// FFmpeg's stream search reads on from the first Cluster for its stated
|
|
633
|
+
// media time and stops on the first packet past it, which lies before the
|
|
634
|
+
// first Cue later than that time; the padding holds its read-ahead.
|
|
635
|
+
const searchEnd = points[firstAt(points[0].seconds + FFMPEG_STREAM_SEARCH_SECONDS, true)];
|
|
636
|
+
const ranges = [[0, paddedEnd((searchEnd ? searchEnd.at : layout.segmentEnd) - 1)],
|
|
631
637
|
[points[first].at, paddedEnd(mediaEnd)], [layout.cuesAt, paddedEnd(cuesEnd - 1)],
|
|
632
638
|
[Math.max(0, this.fileSize - requestBytes), this.fileSize - 1]];
|
|
633
639
|
// SeekHead may name top-level declarations after media clusters. Preserve
|
|
@@ -645,7 +651,8 @@ export class MatroskaContainer extends Container {
|
|
|
645
651
|
if (previous && range[0] <= previous[1] + 1) previous[1] = Math.max(previous[1], range[1]);
|
|
646
652
|
else union.push([...range]);
|
|
647
653
|
}
|
|
648
|
-
return { kind: "result", from: interval.from, to: interval.to, ranges: union, fileLength: this.fileSize
|
|
654
|
+
return { kind: "result", from: interval.from, to: interval.to, ranges: union, fileLength: this.fileSize,
|
|
655
|
+
streamSearchSeconds: FFMPEG_STREAM_SEARCH_SECONDS };
|
|
649
656
|
}
|
|
650
657
|
|
|
651
658
|
async readPacketIndex(interval) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How far into a file FFmpeg reads, at open, to learn its streams
|
|
3
|
+
* (`avformat_find_stream_info`): until a stream has given it this much media
|
|
4
|
+
* time (`-analyzeduration`), unless every stream is known sooner.
|
|
5
|
+
*
|
|
6
|
+
* A container that names the bytes of an original-source run names this
|
|
7
|
+
* stretch from the first media too, and the run gives FFmpeg the same figure,
|
|
8
|
+
* so the read and the bytes held agree whatever FFmpeg's own default is. A
|
|
9
|
+
* read past the bytes held is refused, and from 7.1 on FFmpeg does not recover
|
|
10
|
+
* from that refusal: the run ends with nothing made (torrent-tv/meta#165).
|
|
11
|
+
*
|
|
12
|
+
* Five seconds is FFmpeg's own default for these containers
|
|
13
|
+
* (`5*AV_TIME_BASE` in avformat_find_stream_info), stated rather than chosen.
|
|
14
|
+
*/
|
|
15
|
+
export const FFMPEG_STREAM_SEARCH_SECONDS = 5;
|