@torrent-tv/proxy 2.96.1 → 2.96.3
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/services/encode/EncodeRun.js +9 -0
- package/services/encode/EncodeRuns.js +18 -4
- package/services/encode/output/OutputSpec.js +13 -0
- package/services/encode/piece-completeness.js +58 -55
- package/services/server/ViewerRequests.js +1 -2
- package/services/viewer/PriorityOrchestrator.js +34 -17
package/package.json
CHANGED
|
@@ -285,6 +285,15 @@ export class EncodeRun {
|
|
|
285
285
|
this.indexOfName = typeof indexOfName === "function" ? indexOfName : () => null;
|
|
286
286
|
/** The last thing ffmpeg said on stderr, which is what a failure is explained by. */
|
|
287
287
|
this.lastError = "";
|
|
288
|
+
/**
|
|
289
|
+
* How far after the start it was asked for this run's segment muxer counts
|
|
290
|
+
* its cuts from, in seconds. The muxer measures every cut from its first
|
|
291
|
+
* reference packet, and a copied stream's first packet lies up to one frame
|
|
292
|
+
* after the requested start, so every piece of the run ends that much later
|
|
293
|
+
* than its cut. `null` until the run's first piece has closed
|
|
294
|
+
* (`piece-completeness.js`, `cutShiftOf`).
|
|
295
|
+
*/
|
|
296
|
+
this.cutShiftSeconds = null;
|
|
288
297
|
// EXISTING IS RUNNING. There is no moment at which a built run is not yet a
|
|
289
298
|
// process, so there is no second act for two owners to perform.
|
|
290
299
|
this.#begin(because);
|
|
@@ -17,7 +17,7 @@ import { computeOutputDimensions } from "./args.js";
|
|
|
17
17
|
import { buildAdmittedCommand } from "./admitted-command.js";
|
|
18
18
|
import { buildOriginalCommand } from "./source-command.js";
|
|
19
19
|
import { writeAdmittedInput } from "./AdmittedInput.js";
|
|
20
|
-
import { cutOf, judgeNeighbors, judgePiece } from "./piece-completeness.js";
|
|
20
|
+
import { cutOf, cutShiftOf, judgeNeighbors, judgePiece } from "./piece-completeness.js";
|
|
21
21
|
import { presentationSegment } from "./segment-formats/presentation-segment.js";
|
|
22
22
|
import { InputFailures, failedAdmittedInput } from "./InputFailures.js";
|
|
23
23
|
|
|
@@ -351,9 +351,13 @@ export class EncodeRuns {
|
|
|
351
351
|
* @param {HlsSession} session
|
|
352
352
|
* @param {string} name - The working name the encoder closed it under.
|
|
353
353
|
* @param {number} index
|
|
354
|
+
* @param {object | false} [admitted] - The original input the run reads, when it reads one.
|
|
355
|
+
* @param {Buffer | null} [bytes]
|
|
356
|
+
* @param {EncodeRun | null} [run] - The run that closed it, which keeps where its muxer counts cuts from.
|
|
357
|
+
* @param {{ endMicros: bigint } | null} [timing] - The piece's end in the muxer's clock, from its segment list.
|
|
354
358
|
* @returns {object | null | false}
|
|
355
359
|
*/
|
|
356
|
-
#wholeClosedPiece(session, name, index, admitted = false, bytes = null) {
|
|
360
|
+
#wholeClosedPiece(session, name, index, admitted = false, bytes = null, run = null, timing = null) {
|
|
357
361
|
|
|
358
362
|
const format = session.segmentFormat;
|
|
359
363
|
if (!format?.readMediaRanges || !Number.isInteger(index) || index < 0) {
|
|
@@ -371,6 +375,16 @@ export class EncodeRuns {
|
|
|
371
375
|
.map(input => [input.track.type === "video" ? "vide" : "soun", input.sourceEndSeconds])) } : {}),
|
|
372
376
|
requiredKinds: [session.spec.carries !== "audio-only" ? "vide" : null,
|
|
373
377
|
session.spec.audio && !this.#host.servesAudioSeparately(session) ? "soun" : null].filter(Boolean) } : undefined;
|
|
378
|
+
if (run) {
|
|
379
|
+
// Where the muxer counts this run's cuts from is known once its first
|
|
380
|
+
// piece has closed, and holds for every piece after it. It is a fact
|
|
381
|
+
// of each piece the run makes, so it travels with the piece.
|
|
382
|
+
if (run.cutShiftSeconds === null && index === run.from) {
|
|
383
|
+
run.cutShiftSeconds = cutShiftOf(mediaRanges, grid?.[run.from], session.spec.carries !== "audio-only" ? "vide" : "soun",
|
|
384
|
+
timing?.endMicros ?? null);
|
|
385
|
+
}
|
|
386
|
+
if (mediaRanges && run.cutShiftSeconds !== null) mediaRanges.cutShiftSeconds = run.cutShiftSeconds;
|
|
387
|
+
}
|
|
374
388
|
const { whole, throughSeconds, reason } = judgePiece(format, mediaRanges, cut, interval);
|
|
375
389
|
if (!whole) {
|
|
376
390
|
this.#host.logger.warn(
|
|
@@ -990,7 +1004,7 @@ export class EncodeRuns {
|
|
|
990
1004
|
// Why this encoder exists, recorded with its argument list. It used to be
|
|
991
1005
|
// handed to a separate `start` call; there is no separate call now.
|
|
992
1006
|
because,
|
|
993
|
-
onClosed: (name, following) => {
|
|
1007
|
+
onClosed: (name, following, timing) => {
|
|
994
1008
|
|
|
995
1009
|
const index = session.segmentFormat.segmentIndexFromName(
|
|
996
1010
|
session.segmentFormat.servedNameOf?.(name) ?? name);
|
|
@@ -1012,7 +1026,7 @@ export class EncodeRuns {
|
|
|
1012
1026
|
throw error;
|
|
1013
1027
|
}
|
|
1014
1028
|
}
|
|
1015
|
-
const mediaRanges = this.#wholeClosedPiece(session, name, index, admittedInput, bytes);
|
|
1029
|
+
const mediaRanges = this.#wholeClosedPiece(session, name, index, admittedInput, bytes, run, timing);
|
|
1016
1030
|
if (mediaRanges === false) throw Object.assign(new Error(`Closed piece #${index} has incomplete media.`),
|
|
1017
1031
|
{ code: "ERR_INCOMPLETE_MEDIA" });
|
|
1018
1032
|
const published = this.#host.segmentFiles.publish(session.outputKey ?? "", name, session.segmentFormat, { mediaRanges, bytes });
|
|
@@ -258,6 +258,19 @@ export class OutputSpec {
|
|
|
258
258
|
return this.audio?.fileIndex ?? -1;
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
+
/**
|
|
262
|
+
* The files of the torrent whose bytes this output reads: the picture's file
|
|
263
|
+
* where it carries a picture, the soundtrack's where it carries sound. A
|
|
264
|
+
* soundtrack shipped beside the picture is a file of its own, and its bytes
|
|
265
|
+
* are wanted from that file and not from the picture's.
|
|
266
|
+
*
|
|
267
|
+
* @returns {number[]}
|
|
268
|
+
*/
|
|
269
|
+
get sourceFileIndexes() {
|
|
270
|
+
return [...new Set([this.video?.fileIndex, this.audio?.fileIndex]
|
|
271
|
+
.filter((fileIndex) => Number.isInteger(fileIndex) && fileIndex >= 0))];
|
|
272
|
+
}
|
|
273
|
+
|
|
261
274
|
/** @returns {number} */
|
|
262
275
|
get audioSourceTrackIndex() {
|
|
263
276
|
return this.audio?.trackIndex ?? 0;
|
|
@@ -30,82 +30,81 @@ export function cutOf(timeline, index) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
33
|
+
* Whether a closed piece holds its stretch of film. With `interval` (a run
|
|
34
|
+
* reading the original file, which states the stretch each piece covers) the
|
|
35
|
+
* piece's tracks are compared with that stretch; otherwise with the cut.
|
|
36
|
+
*
|
|
37
|
+
* Both are compared on the clock the producing run's muxer cut by: moved by
|
|
38
|
+
* `ranges.cutShiftSeconds`, which the run states for every piece it closes
|
|
39
|
+
* ({@link cutShiftOf}) and which travels with the piece. A piece whose run is
|
|
40
|
+
* not known — one adopted from an earlier life of the process — has none, and
|
|
41
|
+
* is compared with the cut as published.
|
|
37
42
|
*
|
|
38
43
|
* @param {{ producedThroughSeconds?: (ranges: object) => number | null }} format
|
|
39
44
|
* @param {object} ranges - The piece's media intervals, as the format read them.
|
|
40
45
|
* @param {number | undefined} cutSeconds - From {@link cutOf}.
|
|
41
|
-
* @
|
|
46
|
+
* @param {{ from: number, to: number, requiredKinds: string[], sourceEnds?: object }} [interval]
|
|
47
|
+
* @returns {{ whole: boolean, throughSeconds: number | null, reason?: string }}
|
|
42
48
|
*/
|
|
43
49
|
export function judgePiece(format, ranges, cutSeconds, interval) {
|
|
44
50
|
const throughSeconds = format.producedThroughSeconds?.(ranges) ?? null;
|
|
45
51
|
if (throughSeconds === null) {
|
|
46
52
|
return { whole: false, throughSeconds };
|
|
47
53
|
}
|
|
54
|
+
const shift = Number.isFinite(ranges?.cutShiftSeconds) ? ranges.cutShiftSeconds : 0;
|
|
48
55
|
if (interval) {
|
|
49
|
-
const reason = intervalFailure(ranges, interval);
|
|
56
|
+
const reason = intervalFailure(ranges, { ...interval, shift });
|
|
50
57
|
return reason ? { whole: false, throughSeconds, reason } : { whole: true, throughSeconds };
|
|
51
58
|
}
|
|
52
|
-
const cutMicros =
|
|
53
|
-
(Number.isFinite(cutSeconds) ? BigInt(Math.round(cutSeconds * 1_000_000)) : null);
|
|
59
|
+
const cutMicros = Number.isFinite(cutSeconds) ? BigInt(Math.round((cutSeconds + shift) * 1_000_000)) : null;
|
|
54
60
|
if (cutMicros !== null && ranges?.tracks?.length) {
|
|
55
|
-
const
|
|
56
|
-
const production = ranges.production;
|
|
57
|
-
const reference = production ? tracks.find(({ kind }) => kind === production.kind) : null;
|
|
58
|
-
const referenceEnd = reference?.ranges.at(-1)?.end;
|
|
59
|
-
const whole = tracks.every((track) => {
|
|
60
|
-
const { timescale, ranges: held, positionErrorTicks = 0n } = track;
|
|
61
|
+
const whole = ranges.tracks.every(({ timescale, ranges: held, positionErrorTicks = 0n }) => {
|
|
61
62
|
const end = held.at(-1)?.end;
|
|
62
|
-
|
|
63
|
-
if (referenceEnd !== undefined) {
|
|
64
|
-
if (track === reference) return 2n * production.endMicros + 1n >= 2n * cutMicros;
|
|
65
|
-
// The segment muxer's CSV reports packet time before movenc truncates
|
|
66
|
-
// the empty edit. Compare on that clock, including another track's
|
|
67
|
-
// relative end and the precision of both written track positions.
|
|
68
|
-
const difference = (end + positionErrorTicks) * reference.timescale -
|
|
69
|
-
(referenceEnd - (reference.positionErrorTicks ?? 0n)) * timescale;
|
|
70
|
-
const denominator = timescale * reference.timescale;
|
|
71
|
-
return 2n * difference * 1_000_000n +
|
|
72
|
-
(2n * production.endMicros + 1n) * denominator >= 2n * cutMicros * denominator;
|
|
73
|
-
}
|
|
74
|
-
return (end + positionErrorTicks) * 1_000_000n >= cutMicros * timescale;
|
|
63
|
+
return end !== undefined && (end + positionErrorTicks) * 1_000_000n >= cutMicros * timescale;
|
|
75
64
|
});
|
|
76
65
|
return { whole, throughSeconds };
|
|
77
66
|
}
|
|
78
67
|
if (cutMicros !== null && throughSeconds < Number(cutMicros) / 1_000_000) {
|
|
79
|
-
|
|
80
68
|
return { whole: false, throughSeconds };
|
|
81
69
|
}
|
|
82
70
|
return { whole: true, throughSeconds };
|
|
83
71
|
}
|
|
84
72
|
|
|
85
|
-
/** The muxer cuts relative to its first reference packet, not the requested seek. */
|
|
86
|
-
export function productionOf(timing, cutTimes, relativeIndex, kind) {
|
|
87
|
-
if (!timing || timing.originMicros === null || !Number.isFinite(cutTimes?.[relativeIndex])) return null;
|
|
88
|
-
return {
|
|
89
|
-
kind,
|
|
90
|
-
endMicros: timing.endMicros,
|
|
91
|
-
cutMicros: timing.originMicros + BigInt(Math.round(cutTimes[relativeIndex] * 1_000_000)) -
|
|
92
|
-
BigInt(Math.round(SEGMENT_CUT_TIME_DELTA_SECONDS * 1_000_000))
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
|
|
96
73
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
74
|
+
* How far after its requested start a run's segment muxer counts its cuts
|
|
75
|
+
* from, read from the run's FIRST piece, or `null` when that piece does not
|
|
76
|
+
* start within one frame of the start.
|
|
77
|
+
*
|
|
78
|
+
* With `endMicros`, the end of the piece in the muxer's own clock as its
|
|
79
|
+
* segment list states it, the first packet is that end less the span of the
|
|
80
|
+
* piece's samples: exact, because the span is a sum of sample durations and
|
|
81
|
+
* the muxer's clock needs no track position. Without it, the first sample's
|
|
82
|
+
* position is read, which the piece states only to within its position error.
|
|
83
|
+
*
|
|
84
|
+
* The muxer measures every cut time from its first packet of the reference
|
|
85
|
+
* stream, not from the start it was asked for (measured on ffmpeg 8.1.2,
|
|
86
|
+
* 2026-10-09: AAC copied from Matroska, asked to start at 10.385 s with its
|
|
87
|
+
* first packet at 10.403 s and a cut at 10.386 s from the start, closed the
|
|
88
|
+
* piece at 20.805 s — the first packet at or after 10.403 + 10.386). A copied
|
|
89
|
+
* stream cannot start between its packets, so its first packet lies up to one
|
|
90
|
+
* frame after the requested start, and every cut of the run moves by as much.
|
|
91
|
+
*
|
|
92
|
+
* @param {object} ranges - The first piece's media intervals.
|
|
93
|
+
* @param {number} startSeconds - Where the run was asked to start.
|
|
94
|
+
* @param {string} kind - The reference track: `vide` when the output carries a picture, else `soun`.
|
|
95
|
+
* @returns {number | null}
|
|
100
96
|
*/
|
|
101
|
-
export function
|
|
102
|
-
const track = ranges?.tracks?.find(
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
97
|
+
export function cutShiftOf(ranges, startSeconds, kind, endMicros = null) {
|
|
98
|
+
const track = ranges?.tracks?.find((candidate) => candidate.kind === kind);
|
|
99
|
+
const first = track?.ranges?.[0];
|
|
100
|
+
if (!first || !(track.timescale > 0n) || !Number.isFinite(startSeconds)) return null;
|
|
101
|
+
const timescale = Number(track.timescale);
|
|
102
|
+
const firstSeconds = typeof endMicros === "bigint" && track.firstSampleStart !== undefined
|
|
103
|
+
? Number(endMicros) / 1_000_000 - Number(track.ranges.at(-1).end - track.firstSampleStart) / timescale
|
|
104
|
+
: Number(first.start) / timescale;
|
|
105
|
+
const shift = firstSeconds - startSeconds;
|
|
106
|
+
const allowance = Number((track.productionFrame ?? first.frame) + (track.positionErrorTicks ?? 0n)) / timescale;
|
|
107
|
+
return Math.abs(shift) <= allowance ? shift : null;
|
|
109
108
|
}
|
|
110
109
|
|
|
111
110
|
/** Compare adjacent production ranges in their declared track clocks. */
|
|
@@ -129,15 +128,19 @@ export function judgeNeighbors(left, right) {
|
|
|
129
128
|
return { whole: true };
|
|
130
129
|
}
|
|
131
130
|
|
|
132
|
-
/**
|
|
133
|
-
|
|
131
|
+
/**
|
|
132
|
+
* Every required track must cover the interval with at most one-frame error,
|
|
133
|
+
* on the clock the run's muxer cut by: the interval moved by `shift`
|
|
134
|
+
* ({@link cutShiftOf}). A track's own end in the file is not moved.
|
|
135
|
+
*/
|
|
136
|
+
function intervalFailure(coverage, { from, to, requiredKinds, sourceEnds = {}, shift = 0 }) {
|
|
134
137
|
if (!Number.isFinite(from) || !Number.isFinite(to) || !(to > from) || !requiredKinds?.length) return "segment-interval-is-not-declared";
|
|
135
138
|
for (const kind of requiredKinds) {
|
|
136
139
|
const track = coverage?.tracks?.find(track => track.kind === kind);
|
|
137
140
|
if (!track?.ranges?.length || !(track.timescale > 0n)) return `segment-missing-${kind}`;
|
|
138
|
-
const start = BigInt(Math.round(from * Number(track.timescale)));
|
|
139
|
-
const through = Number.isFinite(sourceEnds[kind]) ? Math.min(to, sourceEnds[kind]) : to;
|
|
140
|
-
if (!(through > from)) return `segment-interval-is-empty-${kind}`;
|
|
141
|
+
const start = BigInt(Math.round((from + shift) * Number(track.timescale)));
|
|
142
|
+
const through = Number.isFinite(sourceEnds[kind]) ? Math.min(to + shift, sourceEnds[kind]) : to + shift;
|
|
143
|
+
if (!(through > from + shift)) return `segment-interval-is-empty-${kind}`;
|
|
141
144
|
const end = BigInt(Math.round(through * Number(track.timescale)));
|
|
142
145
|
const first = track.ranges[0];
|
|
143
146
|
const frame = track.productionFrame ?? first.frame;
|
|
@@ -584,8 +584,7 @@ export class ViewerRequests {
|
|
|
584
584
|
output.segmentFormat.servedMediaRanges(ranges, { initBytes: sessionInit }) : undefined
|
|
585
585
|
};
|
|
586
586
|
});
|
|
587
|
-
const sourceIndexes = [output.spec.
|
|
588
|
-
.filter((fileIndex) => Number.isInteger(fileIndex) && fileIndex >= 0);
|
|
587
|
+
const sourceIndexes = [...output.spec.sourceFileIndexes];
|
|
589
588
|
if (sourceIndexes.length === 0) {
|
|
590
589
|
sourceIndexes.push(output.file.fileIndex);
|
|
591
590
|
}
|
|
@@ -216,23 +216,34 @@ export class PriorityOrchestrator {
|
|
|
216
216
|
if (typeof sourceKey !== "string" || !sourceKey || !Number.isSafeInteger(fileIndex) || fileIndex < 0) {
|
|
217
217
|
throw new TypeError("A priority map requires its output file's source and file index.");
|
|
218
218
|
}
|
|
219
|
-
const key = `${sourceKey}:${fileIndex}`;
|
|
220
219
|
const durationSeconds = Number(session.file?.durationSeconds) || 0;
|
|
221
220
|
// The first band is as wide as an interruption this file has actually
|
|
222
221
|
// shown on this swarm, never a chosen number.
|
|
223
222
|
const allowanceSeconds = this.#allowanceFor(session);
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
byFile.
|
|
235
|
-
|
|
223
|
+
// EVERY FILE THE OUTPUT READS, not only the one it is named after. A
|
|
224
|
+
// soundtrack shipped beside the picture is an output of the picture's
|
|
225
|
+
// film whose bytes live in another file; mapped under the picture's
|
|
226
|
+
// file only, nobody asked the swarm for that file, its encoder waited
|
|
227
|
+
// for pieces for ever and the viewer who chose the soundtrack stood
|
|
228
|
+
// still (Home Assistant 2026-10-09, torrent-tv/meta#8).
|
|
229
|
+
const read = session.spec?.sourceFileIndexes;
|
|
230
|
+
const files = Array.isArray(read) && read.length ? read : [fileIndex];
|
|
231
|
+
const helds = files.map((index) => {
|
|
232
|
+
const fileKey = `${sourceKey}:${index}`;
|
|
233
|
+
let held = byFile.get(fileKey);
|
|
234
|
+
if (!held) {
|
|
235
|
+
held = {
|
|
236
|
+
sourceKey,
|
|
237
|
+
fileIndex: index,
|
|
238
|
+
durationSeconds,
|
|
239
|
+
allowanceSeconds,
|
|
240
|
+
viewers: [],
|
|
241
|
+
demandKey: []
|
|
242
|
+
};
|
|
243
|
+
byFile.set(fileKey, held);
|
|
244
|
+
}
|
|
245
|
+
return held;
|
|
246
|
+
});
|
|
236
247
|
// Every output anybody holds a session for, whether or not a viewer is
|
|
237
248
|
// consuming it — an output with nobody on it must get a map with
|
|
238
249
|
// nothing in it, which is how the plan is told to stop its encoders.
|
|
@@ -240,16 +251,19 @@ export class PriorityOrchestrator {
|
|
|
240
251
|
const address = session.outputKey ?? "";
|
|
241
252
|
let mine = byOutput.get(address);
|
|
242
253
|
if (!mine) {
|
|
243
|
-
|
|
254
|
+
// Counted over a file the output itself reads, which is always among
|
|
255
|
+
// the files mapped above; the picture's file is not one for a
|
|
256
|
+
// soundtrack read from a file of its own.
|
|
257
|
+
mine = { durationSeconds, allowanceSeconds, viewers: [], fileKey: `${sourceKey}:${files[0]}` };
|
|
244
258
|
byOutput.set(address, mine);
|
|
245
259
|
}
|
|
246
260
|
for (const viewer of this.#viewers.forOutput(session).values()) {
|
|
247
261
|
if (!viewer.isPresent()) {
|
|
248
262
|
continue;
|
|
249
263
|
}
|
|
250
|
-
|
|
264
|
+
const demand = [session.outputKey, viewer.id, viewer.audio?.trackIndex,
|
|
251
265
|
viewer.audio?.transcode, viewer.activeVariantId, viewer.warmingVariantId,
|
|
252
|
-
viewer.warmingAudioId, this.#watchedBy(session, viewer)]
|
|
266
|
+
viewer.warmingAudioId, this.#watchedBy(session, viewer)];
|
|
253
267
|
const stated = {
|
|
254
268
|
id: viewer.id,
|
|
255
269
|
atSeconds: viewer.positionSeconds(now) ?? 0,
|
|
@@ -267,7 +281,10 @@ export class PriorityOrchestrator {
|
|
|
267
281
|
? viewer.wantsFilmNow()
|
|
268
282
|
: viewer.playing === true || viewer.waiting === true)
|
|
269
283
|
};
|
|
270
|
-
held
|
|
284
|
+
for (const held of helds) {
|
|
285
|
+
held.demandKey.push(demand);
|
|
286
|
+
held.viewers.push(stated);
|
|
287
|
+
}
|
|
271
288
|
if (this.#watchedBy(session, viewer)) {
|
|
272
289
|
mine.viewers.push(stated);
|
|
273
290
|
}
|