@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.87.16",
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
 
@@ -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: readPresentationRanges,
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 === "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,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 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)
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
- const ranges = format.readMediaRanges(readFileSync(filePath));
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)), 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)]))
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
+ });