@torrent-tv/proxy 2.87.16 → 2.87.18
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/CHANGELOG.md +8 -0
- package/package.json +1 -1
- package/routes/api/transcode-sessions/net-report/post.js +1 -0
- package/services/encode/segment-formats/fmp4.js +10 -1
- package/services/encode/segment-formats/index.js +7 -0
- package/services/encode/segment-formats/mp4-boxes.js +47 -7
- package/services/server/ViewerRequests.js +16 -6
- package/services/storage/segment-store/SegmentStore.js +11 -2
- package/services/viewer/Viewer.js +3 -0
- package/test/fmp4-presentation-clock.test.js +66 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 2.87.18
|
|
2
|
+
|
|
3
|
+
- **Fix**: Interpret variable-rate video sample presentation holds and movie-clock resolution as media coverage; project prepared intervals onto the browser's reported HLS timestamp offsets per track.
|
|
4
|
+
|
|
5
|
+
## 2.87.17
|
|
6
|
+
|
|
7
|
+
- **Fix**: Measure re-encoded HLS fragment intervals against their separate init and served timestamp position, without caching an absent header as empty media.
|
|
8
|
+
|
|
1
9
|
## 2.87.16
|
|
2
10
|
|
|
3
11
|
- **Fix**: Hold spilled source bytes across memory reservation so concurrent eviction cannot terminate ffmpeg input with a missing disk piece.
|
package/package.json
CHANGED
|
@@ -110,6 +110,7 @@ export async function handleApiTranscodeSessionNetReportPost(req, reply, { outpu
|
|
|
110
110
|
linkSampleAt: Number.isFinite(linkSampleAt) && linkSampleAt > 0 ? linkSampleAt : undefined,
|
|
111
111
|
bufferedAheadSec,
|
|
112
112
|
bufferedRanges: body.bufferedRanges,
|
|
113
|
+
timestampOffsets: body.bufferedRanges?.timestampOffsets,
|
|
113
114
|
bufferLimitSeconds:
|
|
114
115
|
Number.isFinite(bufferLimitSeconds) && bufferLimitSeconds > 0 ? bufferLimitSeconds : undefined,
|
|
115
116
|
consumerId,
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
readTrackTimescales,
|
|
19
19
|
readVideoSampleSize,
|
|
20
20
|
stampSegmentStartTime,
|
|
21
|
+
translatePresentationRanges,
|
|
21
22
|
walkBoxes
|
|
22
23
|
} from "./mp4-boxes.js";
|
|
23
24
|
|
|
@@ -279,7 +280,15 @@ export const fmp4Format = {
|
|
|
279
280
|
* transcode itself; the box walk never descends into `mdat`.
|
|
280
281
|
*/
|
|
281
282
|
needsSegmentRewrite: true,
|
|
282
|
-
readMediaRanges
|
|
283
|
+
readMediaRanges(raw, { initBytes = null, startSeconds = 0 } = {}) {
|
|
284
|
+
if (this.extractInit(raw)) return readPresentationRanges(raw);
|
|
285
|
+
if (!initBytes?.length) return undefined;
|
|
286
|
+
const served = this.prepareSegmentBytes(raw, { initBytes, startSeconds, rawBytes: raw });
|
|
287
|
+
return readPresentationRanges(Buffer.concat([initBytes, served]));
|
|
288
|
+
},
|
|
289
|
+
clientMediaRanges(ranges, context) {
|
|
290
|
+
return translatePresentationRanges(ranges, context.initBytes, context.timestampOffsetSeconds);
|
|
291
|
+
},
|
|
283
292
|
|
|
284
293
|
/**
|
|
285
294
|
* Whether a segment carries every track the init promises.
|
|
@@ -49,6 +49,13 @@ import { mpegtsFormat } from "./mpegts.js";
|
|
|
49
49
|
* @property {(bytes: Buffer, context: PrepareSegmentContext) => Buffer}
|
|
50
50
|
* prepareSegmentBytes - Correct a segment before serving. Identity for
|
|
51
51
|
* formats that need nothing.
|
|
52
|
+
* @property {(bytes: Buffer, context?: Partial<PrepareSegmentContext>) =>
|
|
53
|
+
* { start: number, end: number }[] | undefined} [readMediaRanges] - Actual
|
|
54
|
+
* served presentation intervals, or undefined until their header is available.
|
|
55
|
+
* @property {(ranges: { start: number, end: number }[], context: {
|
|
56
|
+
* initBytes: Buffer | null, timestampOffsetSeconds?: number }) =>
|
|
57
|
+
* { start: number, end: number }[]} [clientMediaRanges] - Project parsed
|
|
58
|
+
* presentation intervals onto the clock reported by this viewer's player.
|
|
52
59
|
*/
|
|
53
60
|
|
|
54
61
|
/** @type {Readonly<Record<string, SegmentFormat>>} */
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* @type {ReadonlySet<string>}
|
|
17
17
|
*/
|
|
18
18
|
const CONTAINER_BOXES = new Set(["moov", "mvex", "trak", "mdia", "minf", "stbl", "edts", "moof", "traf"]);
|
|
19
|
+
const presentationTracks = new WeakMap();
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Walk the box tree, invoking `visit` for every box encountered.
|
|
@@ -179,8 +180,19 @@ export function readPresentationRanges(raw) {
|
|
|
179
180
|
let trackId = null;
|
|
180
181
|
let decodeTime = 0;
|
|
181
182
|
let defaultDuration = 0;
|
|
183
|
+
let movieScale = 0;
|
|
184
|
+
let declaredTrack = null;
|
|
185
|
+
const kinds = new Map();
|
|
182
186
|
walkBoxes(raw, (type, start, end) => {
|
|
183
|
-
if (type === "
|
|
187
|
+
if (type === "mvhd") {
|
|
188
|
+
const at = start + (raw[start] === 1 ? 20 : 12);
|
|
189
|
+
if (at + 4 <= end) movieScale = raw.readUInt32BE(at);
|
|
190
|
+
} else if (type === "tkhd") {
|
|
191
|
+
const at = start + (raw[start] === 1 ? 20 : 12);
|
|
192
|
+
if (at + 4 <= end) declaredTrack = raw.readUInt32BE(at);
|
|
193
|
+
} else if (type === "hdlr" && start + 12 <= end) {
|
|
194
|
+
kinds.set(declaredTrack, raw.toString("latin1", start + 8, start + 12));
|
|
195
|
+
} else if (type === "trex" && start + 20 <= end) {
|
|
184
196
|
defaultDurations.set(raw.readUInt32BE(start + 4), raw.readUInt32BE(start + 12));
|
|
185
197
|
} else if (type === "tfhd" && start + 8 <= end) {
|
|
186
198
|
const flags = raw.readUIntBE(start + 1, 3);
|
|
@@ -215,20 +227,48 @@ export function readPresentationRanges(raw) {
|
|
|
215
227
|
}
|
|
216
228
|
});
|
|
217
229
|
// Merge in presentation order: B frames arrive in decode order.
|
|
218
|
-
const mergedTracks = [...byTrack
|
|
230
|
+
const mergedTracks = [...byTrack].map(([id, samples]) => {
|
|
219
231
|
const merged = [];
|
|
220
|
-
|
|
232
|
+
// Sample durations are decode durations. A displayed video frame remains
|
|
233
|
+
// present until the next presentation sample, including variable-rate holds.
|
|
234
|
+
const ordered = samples.sort((left, right) => left.start - right.start);
|
|
235
|
+
const precision = 1 / (movieScale || scales.get(id));
|
|
236
|
+
for (const sample of ordered) {
|
|
221
237
|
const previous = merged.at(-1);
|
|
222
|
-
if (previous &&
|
|
238
|
+
if (previous && (kinds.get(id) === "vide" ||
|
|
239
|
+
sample.start - previous.end <= precision + Number.EPSILON * Math.max(1, sample.end) * 8)) {
|
|
223
240
|
previous.end = Math.max(previous.end, sample.end);
|
|
224
241
|
} else merged.push({ ...sample });
|
|
225
242
|
}
|
|
226
|
-
|
|
243
|
+
// Movie edits quantize independent pieces to movie ticks. Preserve that
|
|
244
|
+
// declared resolution at joins; it is not a playback buffer threshold.
|
|
245
|
+
for (const range of merged) range.end += precision;
|
|
246
|
+
return { id, ranges: merged };
|
|
227
247
|
});
|
|
228
248
|
// A multiplexed segment is playable only where every declared track exists.
|
|
229
|
-
|
|
249
|
+
const result = intersectPresentationTracks(mergedTracks);
|
|
250
|
+
presentationTracks.set(result, mergedTracks);
|
|
251
|
+
return result;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function intersectPresentationTracks(tracks) {
|
|
255
|
+
return tracks.map(({ ranges }) => ranges).reduce((common, ranges) => common.flatMap((left) => ranges
|
|
230
256
|
.map((right) => ({ start: Math.max(left.start, right.start), end: Math.min(left.end, right.end) }))
|
|
231
|
-
.filter(({ start, end }) => end > start)),
|
|
257
|
+
.filter(({ start, end }) => end > start)), tracks[0]?.ranges ?? []);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Project the same parsed samples onto the media player's reported clock. */
|
|
261
|
+
export function translatePresentationRanges(ranges, initBytes, timestampOffsetSeconds) {
|
|
262
|
+
const tracks = presentationTracks.get(ranges);
|
|
263
|
+
if (!tracks || !initBytes?.length || !Number.isFinite(timestampOffsetSeconds)) return ranges;
|
|
264
|
+
const edits = readTrackEditOffsets(initBytes);
|
|
265
|
+
return intersectPresentationTracks(tracks.map(({ id, ranges: held }) => ({
|
|
266
|
+
id,
|
|
267
|
+
ranges: held.map(({ start, end }) => ({
|
|
268
|
+
start: Math.max(0, start + timestampOffsetSeconds - (edits.get(id) ?? 0)),
|
|
269
|
+
end: end + timestampOffsetSeconds - (edits.get(id) ?? 0)
|
|
270
|
+
})).filter(({ start, end }) => end > start)
|
|
271
|
+
})));
|
|
232
272
|
}
|
|
233
273
|
|
|
234
274
|
/**
|
|
@@ -565,12 +565,22 @@ export class ViewerRequests {
|
|
|
565
565
|
const trackProgress = output === session ? progress : this.#host.encodeRuns.progressOf(output);
|
|
566
566
|
const trackRates = this.#trackRateReadings(measurement, output, now);
|
|
567
567
|
const timeline = output.timeline;
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
568
|
+
const trackName = output === session ? "video" : "audio";
|
|
569
|
+
const segments = Array.from({ length: timeline?.segmentCount ?? 0 }, (_, index) => {
|
|
570
|
+
const ranges = this.#host.segmentStore.mediaRangesOf(output.outputKey, index, {
|
|
571
|
+
startSeconds: timeline.publishedStartOf(index)
|
|
572
|
+
});
|
|
573
|
+
return {
|
|
574
|
+
index,
|
|
575
|
+
startSeconds: timeline.publishedStartOf(index),
|
|
576
|
+
endSeconds: timeline.publishedStartOf(index + 1),
|
|
577
|
+
mediaRanges: ranges && output.segmentFormat?.clientMediaRanges ?
|
|
578
|
+
output.segmentFormat.clientMediaRanges(ranges, {
|
|
579
|
+
initBytes: this.#host.segmentStore.initOf(output.outputKey),
|
|
580
|
+
timestampOffsetSeconds: viewerReading?.timestampOffsets?.[trackName]
|
|
581
|
+
}) : ranges
|
|
582
|
+
};
|
|
583
|
+
});
|
|
574
584
|
const sourceIndexes = [output.spec.video?.fileIndex, output.spec.audio?.fileIndex]
|
|
575
585
|
.filter((fileIndex) => Number.isInteger(fileIndex) && fileIndex >= 0);
|
|
576
586
|
if (sourceIndexes.length === 0) {
|
|
@@ -471,7 +471,7 @@ export class SegmentStore {
|
|
|
471
471
|
return served;
|
|
472
472
|
}
|
|
473
473
|
|
|
474
|
-
mediaRangesOf(key, index) {
|
|
474
|
+
mediaRangesOf(key, index, { startSeconds = 0 } = {}) {
|
|
475
475
|
const known = this.#mediaRanges.get(key)?.get(index);
|
|
476
476
|
if (known) return known;
|
|
477
477
|
const format = this.#formats.get(key);
|
|
@@ -479,7 +479,16 @@ export class SegmentStore {
|
|
|
479
479
|
const filePath = this.pathOf(key, index);
|
|
480
480
|
if (!filePath) return undefined;
|
|
481
481
|
try {
|
|
482
|
-
|
|
482
|
+
let initBytes = this.initOf(key);
|
|
483
|
+
if (!initBytes && format.initFileName) {
|
|
484
|
+
try {
|
|
485
|
+
initBytes = readFileSync(path.join(this.pathFor(key), format.initFileName));
|
|
486
|
+
} catch {
|
|
487
|
+
// A header not yet produced is not an empty media interval.
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
const ranges = format.readMediaRanges(readFileSync(filePath), { initBytes, startSeconds });
|
|
491
|
+
if (!ranges) return undefined;
|
|
483
492
|
const byIndex = this.#mediaRanges.get(key) ?? new Map();
|
|
484
493
|
byIndex.set(index, ranges);
|
|
485
494
|
this.#mediaRanges.set(key, byIndex);
|
|
@@ -392,6 +392,7 @@ export class Viewer {
|
|
|
392
392
|
linkSampleAgeMs = null,
|
|
393
393
|
bufferedAheadSec,
|
|
394
394
|
bufferedRanges,
|
|
395
|
+
timestampOffsets,
|
|
395
396
|
bufferLimitSeconds,
|
|
396
397
|
positionSeconds = null,
|
|
397
398
|
playing,
|
|
@@ -435,6 +436,8 @@ export class Viewer {
|
|
|
435
436
|
: now
|
|
436
437
|
: this.netReport?.linkSampleMeasuredAt ?? null,
|
|
437
438
|
bufferedAheadSec: held,
|
|
439
|
+
timestampOffsets: Object.fromEntries(["video", "audio"].filter((key) =>
|
|
440
|
+
Number.isFinite(timestampOffsets?.[key])).map((key) => [key, timestampOffsets[key]])),
|
|
438
441
|
bufferedRanges: bufferedRanges && typeof bufferedRanges === "object" ?
|
|
439
442
|
Object.fromEntries(["video", "audio", "media"].filter((key) => Array.isArray(bufferedRanges[key]))
|
|
440
443
|
.map((key) => [key, bufferedRanges[key].slice(0, 256).filter((range) =>
|
|
@@ -2,6 +2,11 @@ import test from "node:test";
|
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { fmp4Format } from "../services/encode/segment-formats/fmp4.js";
|
|
4
4
|
import { readPresentationRanges, walkBoxes } from "../services/encode/segment-formats/mp4-boxes.js";
|
|
5
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { SegmentStore } from "../services/storage/segment-store/SegmentStore.js";
|
|
9
|
+
import { Viewer } from "../services/viewer/Viewer.js";
|
|
5
10
|
|
|
6
11
|
function box(type, body) {
|
|
7
12
|
const header = Buffer.alloc(8);
|
|
@@ -10,13 +15,15 @@ function box(type, body) {
|
|
|
10
15
|
return Buffer.concat([header, body]);
|
|
11
16
|
}
|
|
12
17
|
|
|
13
|
-
function init(emptyMs, mediaTime) {
|
|
18
|
+
function init(emptyMs, mediaTime, kind = "vide") {
|
|
14
19
|
const mvhd = Buffer.alloc(24);
|
|
15
20
|
mvhd.writeUInt32BE(1000, 12);
|
|
16
21
|
const tkhd = Buffer.alloc(20);
|
|
17
22
|
tkhd.writeUInt32BE(1, 12);
|
|
18
23
|
const mdhd = Buffer.alloc(24);
|
|
19
24
|
mdhd.writeUInt32BE(24000, 12);
|
|
25
|
+
const hdlr = Buffer.alloc(12);
|
|
26
|
+
hdlr.write(kind, 8);
|
|
20
27
|
const elst = Buffer.alloc(32);
|
|
21
28
|
elst.writeUInt32BE(2, 4);
|
|
22
29
|
elst.writeUInt32BE(emptyMs, 8);
|
|
@@ -24,7 +31,8 @@ function init(emptyMs, mediaTime) {
|
|
|
24
31
|
elst.writeUInt32BE(1000, 20);
|
|
25
32
|
elst.writeInt32BE(mediaTime, 24);
|
|
26
33
|
return box("moov", Buffer.concat([box("mvhd", mvhd), box("trak", Buffer.concat([
|
|
27
|
-
box("tkhd", tkhd), box("edts", box("elst", elst)),
|
|
34
|
+
box("tkhd", tkhd), box("edts", box("elst", elst)),
|
|
35
|
+
box("mdia", Buffer.concat([box("mdhd", mdhd), box("hdlr", hdlr)]))
|
|
28
36
|
]))]));
|
|
29
37
|
}
|
|
30
38
|
|
|
@@ -70,3 +78,59 @@ test("a shared init first read after a seek also supports returning to the begin
|
|
|
70
78
|
});
|
|
71
79
|
assert.deepEqual(readPresentationRanges(Buffer.concat([shared, prepared])), readPresentationRanges(raw));
|
|
72
80
|
});
|
|
81
|
+
|
|
82
|
+
test("HLS fragments use the separate init and the same timeline translation as serving", (t) => {
|
|
83
|
+
const root = mkdtempSync(path.join(os.tmpdir(), "presentation-ranges-"));
|
|
84
|
+
const store = new SegmentStore({ root });
|
|
85
|
+
t.after(() => {
|
|
86
|
+
store.dropAll("the check is over");
|
|
87
|
+
rmSync(root, { recursive: true, force: true });
|
|
88
|
+
});
|
|
89
|
+
const key = "encoded-video";
|
|
90
|
+
store.useFormat(key, fmp4Format);
|
|
91
|
+
const dir = store.directoryFor(key);
|
|
92
|
+
const media = fragment(0, 2000);
|
|
93
|
+
writeFileSync(path.join(dir, fmp4Format.segmentFileName(1)), media);
|
|
94
|
+
assert.equal(store.mediaRangesOf(key, 1, { startSeconds: 4 }), undefined);
|
|
95
|
+
const header = init(0, 0);
|
|
96
|
+
writeFileSync(path.join(dir, fmp4Format.initFileName), header);
|
|
97
|
+
const served = fmp4Format.prepareSegmentBytes(media, { initBytes: header, startSeconds: 4 });
|
|
98
|
+
assert.deepEqual(store.mediaRangesOf(key, 1, { startSeconds: 4 }),
|
|
99
|
+
readPresentationRanges(Buffer.concat([header, served])));
|
|
100
|
+
assert.ok(store.mediaRangesOf(key, 1)[0].start > 4);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("variable-rate video retains its frame until the next presentation sample", () => {
|
|
104
|
+
const media = Buffer.concat([fragment(0, 0), fragment(72000, 0)]);
|
|
105
|
+
const video = readPresentationRanges(Buffer.concat([init(0, 0), media]));
|
|
106
|
+
assert.equal(video.length, 1);
|
|
107
|
+
assert.equal(video[0].start, 0);
|
|
108
|
+
assert.ok(video[0].end > 3);
|
|
109
|
+
const audio = readPresentationRanges(Buffer.concat([init(0, 0, "soun"), media]));
|
|
110
|
+
assert.equal(audio.length, 2);
|
|
111
|
+
assert.ok(audio[0].end < 0.05);
|
|
112
|
+
assert.equal(audio[1].start, 3);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("client clock projection accounts for shared edits and audio initPTS", () => {
|
|
116
|
+
const audioHeader = init(0, 0, "soun");
|
|
117
|
+
const audio = readPresentationRanges(Buffer.concat([audioHeader, fragment(96000, 0)]));
|
|
118
|
+
const shifted = fmp4Format.clientMediaRanges(audio, {
|
|
119
|
+
initBytes: audioHeader, timestampOffsetSeconds: -0.083
|
|
120
|
+
});
|
|
121
|
+
assert.equal(shifted[0].start, 4 - 0.083);
|
|
122
|
+
const videoHeader = fmp4Format.prepareSharedInit(init(83, 2000));
|
|
123
|
+
const video = readPresentationRanges(Buffer.concat([init(83, 2000), fragment(0, 2000)]));
|
|
124
|
+
const projected = fmp4Format.clientMediaRanges(video, {
|
|
125
|
+
initBytes: videoHeader, timestampOffsetSeconds: -2000 / 24000
|
|
126
|
+
});
|
|
127
|
+
assert.deepEqual(projected, video);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("a viewer retains only finite per-track player clock offsets", () => {
|
|
131
|
+
const viewer = new Viewer("clock-reading", 1);
|
|
132
|
+
viewer.report({ timestampOffsets: { video: -0.083, audio: 0, media: 400 } }, 2);
|
|
133
|
+
assert.deepEqual(viewer.linkReading().timestampOffsets, { video: -0.083, audio: 0 });
|
|
134
|
+
viewer.report({ timestampOffsets: { video: Number.POSITIVE_INFINITY } }, 3);
|
|
135
|
+
assert.deepEqual(viewer.linkReading().timestampOffsets, {});
|
|
136
|
+
});
|