@torrent-tv/proxy 2.87.17 → 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 +4 -0
- package/package.json +1 -1
- package/routes/api/transcode-sessions/net-report/post.js +1 -0
- package/services/encode/segment-formats/fmp4.js +4 -0
- package/services/encode/segment-formats/index.js +4 -0
- package/services/encode/segment-formats/mp4-boxes.js +47 -7
- package/services/server/ViewerRequests.js +15 -7
- package/services/viewer/Viewer.js +3 -0
- package/test/fmp4-presentation-clock.test.js +41 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
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
|
+
|
|
1
5
|
## 2.87.17
|
|
2
6
|
|
|
3
7
|
- **Fix**: Measure re-encoded HLS fragment intervals against their separate init and served timestamp position, without caching an absent header as empty media.
|
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
|
|
|
@@ -285,6 +286,9 @@ export const fmp4Format = {
|
|
|
285
286
|
const served = this.prepareSegmentBytes(raw, { initBytes, startSeconds, rawBytes: raw });
|
|
286
287
|
return readPresentationRanges(Buffer.concat([initBytes, served]));
|
|
287
288
|
},
|
|
289
|
+
clientMediaRanges(ranges, context) {
|
|
290
|
+
return translatePresentationRanges(ranges, context.initBytes, context.timestampOffsetSeconds);
|
|
291
|
+
},
|
|
288
292
|
|
|
289
293
|
/**
|
|
290
294
|
* Whether a segment carries every track the init promises.
|
|
@@ -52,6 +52,10 @@ import { mpegtsFormat } from "./mpegts.js";
|
|
|
52
52
|
* @property {(bytes: Buffer, context?: Partial<PrepareSegmentContext>) =>
|
|
53
53
|
* { start: number, end: number }[] | undefined} [readMediaRanges] - Actual
|
|
54
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.
|
|
55
59
|
*/
|
|
56
60
|
|
|
57
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,14 +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
|
-
endSeconds: timeline.publishedStartOf(index + 1),
|
|
572
|
-
mediaRanges: this.#host.segmentStore.mediaRangesOf(output.outputKey, index, {
|
|
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, {
|
|
573
571
|
startSeconds: timeline.publishedStartOf(index)
|
|
574
|
-
})
|
|
575
|
-
|
|
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
|
+
});
|
|
576
584
|
const sourceIndexes = [output.spec.video?.fileIndex, output.spec.audio?.fileIndex]
|
|
577
585
|
.filter((fileIndex) => Number.isInteger(fileIndex) && fileIndex >= 0);
|
|
578
586
|
if (sourceIndexes.length === 0) {
|
|
@@ -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) =>
|
|
@@ -6,6 +6,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
|
6
6
|
import os from "node:os";
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { SegmentStore } from "../services/storage/segment-store/SegmentStore.js";
|
|
9
|
+
import { Viewer } from "../services/viewer/Viewer.js";
|
|
9
10
|
|
|
10
11
|
function box(type, body) {
|
|
11
12
|
const header = Buffer.alloc(8);
|
|
@@ -14,13 +15,15 @@ function box(type, body) {
|
|
|
14
15
|
return Buffer.concat([header, body]);
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
function init(emptyMs, mediaTime) {
|
|
18
|
+
function init(emptyMs, mediaTime, kind = "vide") {
|
|
18
19
|
const mvhd = Buffer.alloc(24);
|
|
19
20
|
mvhd.writeUInt32BE(1000, 12);
|
|
20
21
|
const tkhd = Buffer.alloc(20);
|
|
21
22
|
tkhd.writeUInt32BE(1, 12);
|
|
22
23
|
const mdhd = Buffer.alloc(24);
|
|
23
24
|
mdhd.writeUInt32BE(24000, 12);
|
|
25
|
+
const hdlr = Buffer.alloc(12);
|
|
26
|
+
hdlr.write(kind, 8);
|
|
24
27
|
const elst = Buffer.alloc(32);
|
|
25
28
|
elst.writeUInt32BE(2, 4);
|
|
26
29
|
elst.writeUInt32BE(emptyMs, 8);
|
|
@@ -28,7 +31,8 @@ function init(emptyMs, mediaTime) {
|
|
|
28
31
|
elst.writeUInt32BE(1000, 20);
|
|
29
32
|
elst.writeInt32BE(mediaTime, 24);
|
|
30
33
|
return box("moov", Buffer.concat([box("mvhd", mvhd), box("trak", Buffer.concat([
|
|
31
|
-
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)]))
|
|
32
36
|
]))]));
|
|
33
37
|
}
|
|
34
38
|
|
|
@@ -95,3 +99,38 @@ test("HLS fragments use the separate init and the same timeline translation as s
|
|
|
95
99
|
readPresentationRanges(Buffer.concat([header, served])));
|
|
96
100
|
assert.ok(store.mediaRangesOf(key, 1)[0].start > 4);
|
|
97
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
|
+
});
|