@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.87.17",
3
+ "version": "2.87.18",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -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 === "trex" && start + 20 <= end) {
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.values()].map((samples) => {
230
+ const mergedTracks = [...byTrack].map(([id, samples]) => {
219
231
  const merged = [];
220
- for (const sample of samples.sort((left, right) => left.start - right.start)) {
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 && sample.start - previous.end <= Number.EPSILON * Math.max(1, sample.end) * 8) {
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
- return merged;
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
- return mergedTracks.reduce((common, ranges) => common.flatMap((left) => ranges
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)), mergedTracks[0] ?? []);
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 segments = Array.from({ length: timeline?.segmentCount ?? 0 }, (_, index) => ({
569
- index,
570
- startSeconds: timeline.publishedStartOf(index),
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)), box("mdia", box("mdhd", mdhd))
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
+ });