@torrent-tv/proxy 2.87.17 → 2.87.19

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,13 @@
1
+ ## 2.87.19
2
+
3
+ - **Fix**: Return shared source bytes after whole-file assembly and retain the buffer held by concurrent readers instead of replacing it; fail malformed fragments explicitly rather than skipping source bytes.
4
+ - **Fix**: Establish decode timestamp ordering in a private preceding cut before a copied-video restart, preserving leading reordered frames at the requested boundary.
5
+ - **Fix**: Carry variable-rate video presentation across cuts only when decode timestamps prove continuity, and predict unproduced cuts in the measured track clock within the readiness integral.
6
+
7
+ ## 2.87.18
8
+
9
+ - **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.
10
+
1
11
  ## 2.87.17
2
12
 
3
13
  - **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.19",
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,
@@ -832,7 +832,12 @@ export class EncodeRuns {
832
832
  // Why this encoder exists, recorded with its argument list. It used to be
833
833
  // handed to a separate `start` call; there is no separate call now.
834
834
  because,
835
- onClosed: (name) => this.#host.segmentFiles.publish(session.outputKey ?? "", name, session.segmentFormat),
835
+ onClosed: (name) => {
836
+ const index = session.segmentFormat.segmentIndexFromName(
837
+ session.segmentFormat.servedNameOf?.(name) ?? name);
838
+ if (Number.isInteger(index) && index < safeIndex) return null;
839
+ return this.#host.segmentFiles.publish(session.outputKey ?? "", name, session.segmentFormat);
840
+ },
836
841
  onEnded: (ended) => this.noteRunEnded(session, run, ended)
837
842
  });
838
843
  // THE ONE FAULT THAT IS OTHERWISE SILENT, asked before this run produces a
@@ -358,6 +358,11 @@ export function buildRunCommand({
358
358
  // the cut points and, when re-encoding, as the keyframes to force — one
359
359
  // list, so the two cannot drift apart.
360
360
  const explicitTimes = segmentFormat.explicitTimesMuxerArgs?.() ?? null;
361
+ // Matroska seeks begin without the preceding decode timestamps. Copy one
362
+ // preceding cut privately so reordered frames have established DTS before
363
+ // the first requested cut. It is never published over another run's output.
364
+ const inputIndex = !transcodeVideo && !audioOnly && explicitTimes && safeIndex > 0
365
+ ? safeIndex - 1 : safeIndex;
361
366
  // A COPY is cut by this list whatever grid it ended up on. Even when no
362
367
  // keyframe index could be read and the boundaries are a plain grid, saying
363
368
  // them outright is what keeps the playlist and the muxer agreeing — ffmpeg
@@ -382,7 +387,7 @@ export function buildRunCommand({
382
387
  // playlist and its own cuts agree from the start. What they may not do is
383
388
  // move the cuts of a session whose playlist is already being read.
384
389
  const gridCutTimes = explicitTimes && (!transcodeVideo || timeline.cutGrid === "keyframe")
385
- ? segmentCutTimesFrom(publishedGridFor(timeline), safeIndex)
390
+ ? segmentCutTimesFrom(publishedGridFor(timeline), inputIndex)
386
391
  : null;
387
392
  // Cut times are stated on the grid, for both branches.
388
393
  //
@@ -443,9 +448,11 @@ export function buildRunCommand({
443
448
  // back on to reach it; on the uniform grid it is a plain offset. This
444
449
  // follows the GRID, not whether the video is re-encoded — a variant cut on
445
450
  // the source's keyframes has to seek to them like the copy it accompanies.
451
+ const inputStartSeconds = inputIndex === safeIndex ? startSeconds :
452
+ publishedStartTime(timeline, inputIndex, segmentDurationSec);
446
453
  const seekSeconds = timeline.cutGrid === "keyframe"
447
- ? startSeconds + sourceStartTime
448
- : startSeconds;
454
+ ? inputStartSeconds + sourceStartTime
455
+ : inputStartSeconds;
449
456
  // Two-step seek when we have a real keyframe map: jump to a KNOWN-valid
450
457
  // keyframe (coarse, before -i — safe because WE sourced it from ffprobe,
451
458
  // not the container's own on-the-fly seek/index) and trim the short
@@ -477,7 +484,7 @@ export function buildRunCommand({
477
484
  // The search stays for a grid restored without its source clock, which is the
478
485
  // only case that has nothing to carry.
479
486
  const carriedKeyframe = timeline.cutGrid === "keyframe" && typeof timeline.sourceStartOf === "function"
480
- ? timeline.sourceStartOf(safeIndex)
487
+ ? timeline.sourceStartOf(inputIndex)
481
488
  : null;
482
489
  const snappedKeyframe = Number.isFinite(carriedKeyframe)
483
490
  ? carriedKeyframe
@@ -689,7 +696,7 @@ export function buildRunCommand({
689
696
  "-segment_time_delta",
690
697
  "0.05",
691
698
  "-segment_start_number",
692
- String(safeIndex),
699
+ String(inputIndex),
693
700
  // THE ENCODER SAYS WHEN A PIECE IS FINISHED, on a channel of its own.
694
701
  //
695
702
  // Measured on the addon host 2026-09-05: a name appears in this list when
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import {
14
+ continuePresentationRanges,
14
15
  rebaseSegmentDecodeTimes,
15
16
  neutralizeEmptyEdits,
16
17
  readPresentationRanges,
@@ -18,6 +19,7 @@ import {
18
19
  readTrackTimescales,
19
20
  readVideoSampleSize,
20
21
  stampSegmentStartTime,
22
+ translatePresentationRanges,
21
23
  walkBoxes
22
24
  } from "./mp4-boxes.js";
23
25
 
@@ -285,6 +287,10 @@ export const fmp4Format = {
285
287
  const served = this.prepareSegmentBytes(raw, { initBytes, startSeconds, rawBytes: raw });
286
288
  return readPresentationRanges(Buffer.concat([initBytes, served]));
287
289
  },
290
+ clientMediaRanges(ranges, context) {
291
+ return translatePresentationRanges(continuePresentationRanges(ranges, context.nextRanges),
292
+ context.initBytes, context.timestampOffsetSeconds);
293
+ },
288
294
 
289
295
  /**
290
296
  * 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, nextRanges?: Array<{ start: number, end: 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.
@@ -175,12 +176,24 @@ export function readPresentationRanges(raw) {
175
176
  const scales = readTrackTimescales(raw);
176
177
  const edits = readTrackEditOffsets(raw);
177
178
  const byTrack = new Map();
179
+ const decodeRanges = new Map();
178
180
  const defaultDurations = new Map();
179
181
  let trackId = null;
180
182
  let decodeTime = 0;
181
183
  let defaultDuration = 0;
184
+ let movieScale = 0;
185
+ let declaredTrack = null;
186
+ const kinds = new Map();
182
187
  walkBoxes(raw, (type, start, end) => {
183
- if (type === "trex" && start + 20 <= end) {
188
+ if (type === "mvhd") {
189
+ const at = start + (raw[start] === 1 ? 20 : 12);
190
+ if (at + 4 <= end) movieScale = raw.readUInt32BE(at);
191
+ } else if (type === "tkhd") {
192
+ const at = start + (raw[start] === 1 ? 20 : 12);
193
+ if (at + 4 <= end) declaredTrack = raw.readUInt32BE(at);
194
+ } else if (type === "hdlr" && start + 12 <= end) {
195
+ kinds.set(declaredTrack, raw.toString("latin1", start + 8, start + 12));
196
+ } else if (type === "trex" && start + 20 <= end) {
184
197
  defaultDurations.set(raw.readUInt32BE(start + 4), raw.readUInt32BE(start + 12));
185
198
  } else if (type === "tfhd" && start + 8 <= end) {
186
199
  const flags = raw.readUIntBE(start + 1, 3);
@@ -209,26 +222,81 @@ export function readPresentationRanges(raw) {
209
222
  if (!(duration > 0)) return;
210
223
  const position = (decodeTime + composition) / scale + (edits.get(trackId) ?? 0);
211
224
  ranges.push({ start: position, end: position + duration / scale });
225
+ const decode = decodeRanges.get(trackId) ?? [];
226
+ decode.push({ start: decodeTime / scale + (edits.get(trackId) ?? 0),
227
+ end: (decodeTime + duration) / scale + (edits.get(trackId) ?? 0) });
228
+ decodeRanges.set(trackId, decode);
212
229
  decodeTime += duration;
213
230
  }
214
231
  byTrack.set(trackId, ranges);
215
232
  }
216
233
  });
217
234
  // Merge in presentation order: B frames arrive in decode order.
218
- const mergedTracks = [...byTrack.values()].map((samples) => {
235
+ const mergedTracks = [...byTrack].map(([id, samples]) => {
219
236
  const merged = [];
220
- for (const sample of samples.sort((left, right) => left.start - right.start)) {
237
+ // Sample durations are decode durations. A displayed video frame remains
238
+ // present until the next presentation sample, including variable-rate holds.
239
+ const ordered = samples.sort((left, right) => left.start - right.start);
240
+ const precision = 1 / (movieScale || scales.get(id));
241
+ for (const sample of ordered) {
221
242
  const previous = merged.at(-1);
222
- if (previous && sample.start - previous.end <= Number.EPSILON * Math.max(1, sample.end) * 8) {
243
+ if (previous && (kinds.get(id) === "vide" ||
244
+ sample.start - previous.end <= precision + Number.EPSILON * Math.max(1, sample.end) * 8)) {
223
245
  previous.end = Math.max(previous.end, sample.end);
224
246
  } else merged.push({ ...sample });
225
247
  }
226
- return merged;
248
+ // Movie edits quantize independent pieces to movie ticks. Preserve that
249
+ // declared resolution at joins; it is not a playback buffer threshold.
250
+ for (const range of merged) range.end += precision;
251
+ const decoded = decodeRanges.get(id);
252
+ return { id, ranges: merged, kind: kinds.get(id), precision,
253
+ decodeStart: Math.min(...decoded.map(({ start }) => start)),
254
+ decodeEnd: Math.max(...decoded.map(({ end }) => end)) };
227
255
  });
228
256
  // A multiplexed segment is playable only where every declared track exists.
229
- return mergedTracks.reduce((common, ranges) => common.flatMap((left) => ranges
257
+ const result = intersectPresentationTracks(mergedTracks);
258
+ presentationTracks.set(result, mergedTracks);
259
+ return result;
260
+ }
261
+
262
+ /** A video frame can cross a file boundary when decoding remains continuous. */
263
+ export function continuePresentationRanges(ranges, nextRanges) {
264
+ const tracks = presentationTracks.get(ranges);
265
+ const next = presentationTracks.get(nextRanges);
266
+ if (!tracks || !next) return ranges;
267
+ const continued = tracks.map((track) => {
268
+ const following = next.find(({ id }) => id === track.id);
269
+ const held = track.ranges.map((range) => ({ ...range }));
270
+ if (track.kind === "vide" && following && held.length > 0 && following.ranges.length > 0 &&
271
+ Math.abs(track.decodeEnd - following.decodeStart) <= track.precision + following.precision +
272
+ Number.EPSILON * Math.max(1, following.decodeStart) * 8) {
273
+ held.at(-1).end = Math.max(held.at(-1).end, following.ranges[0].start);
274
+ }
275
+ return { ...track, ranges: held };
276
+ });
277
+ const result = intersectPresentationTracks(continued);
278
+ presentationTracks.set(result, continued);
279
+ return result;
280
+ }
281
+
282
+ function intersectPresentationTracks(tracks) {
283
+ return tracks.map(({ ranges }) => ranges).reduce((common, ranges) => common.flatMap((left) => ranges
230
284
  .map((right) => ({ start: Math.max(left.start, right.start), end: Math.min(left.end, right.end) }))
231
- .filter(({ start, end }) => end > start)), mergedTracks[0] ?? []);
285
+ .filter(({ start, end }) => end > start)), tracks[0]?.ranges ?? []);
286
+ }
287
+
288
+ /** Project the same parsed samples onto the media player's reported clock. */
289
+ export function translatePresentationRanges(ranges, initBytes, timestampOffsetSeconds) {
290
+ const tracks = presentationTracks.get(ranges);
291
+ if (!tracks || !initBytes?.length || !Number.isFinite(timestampOffsetSeconds)) return ranges;
292
+ const edits = readTrackEditOffsets(initBytes);
293
+ return intersectPresentationTracks(tracks.map(({ id, ranges: held }) => ({
294
+ id,
295
+ ranges: held.map(({ start, end }) => ({
296
+ start: Math.max(0, start + timestampOffsetSeconds - (edits.get(id) ?? 0)),
297
+ end: end + timestampOffsetSeconds - (edits.get(id) ?? 0)
298
+ })).filter(({ start, end }) => end > start)
299
+ })));
232
300
  }
233
301
 
234
302
  /**
@@ -565,14 +565,25 @@ 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 nativeRanges = Array.from({ length: timeline?.segmentCount ?? 0 }, (_, index) =>
570
+ this.#host.segmentStore.mediaRangesOf(output.outputKey, index, {
573
571
  startSeconds: timeline.publishedStartOf(index)
574
- })
575
- }));
572
+ }));
573
+ const segments = Array.from({ length: timeline?.segmentCount ?? 0 }, (_, index) => {
574
+ const ranges = nativeRanges[index];
575
+ return {
576
+ index,
577
+ startSeconds: timeline.publishedStartOf(index),
578
+ endSeconds: timeline.publishedStartOf(index + 1),
579
+ mediaRanges: ranges && output.segmentFormat?.clientMediaRanges ?
580
+ output.segmentFormat.clientMediaRanges(ranges, {
581
+ initBytes: this.#host.segmentStore.initOf(output.outputKey),
582
+ nextRanges: nativeRanges[index + 1],
583
+ timestampOffsetSeconds: viewerReading?.timestampOffsets?.[trackName]
584
+ }) : ranges
585
+ };
586
+ });
576
587
  const sourceIndexes = [output.spec.video?.fileIndex, output.spec.audio?.fileIndex]
577
588
  .filter((fileIndex) => Number.isInteger(fileIndex) && fileIndex >= 0);
578
589
  if (sourceIndexes.length === 0) {
@@ -128,7 +128,7 @@ export function predictPlaybackReadiness(input = {}) {
128
128
  curve: rateCurve(track.readings, now),
129
129
  bitsPerMediaSecond: averageBitsPerMediaSecond(track, track.segments,
130
130
  new Set(Array.isArray(track.readySegmentIndices) ? track.readySegmentIndices : [])),
131
- segments: track.segments
131
+ segments: predictedMediaSegments(track.segments)
132
132
  .filter((segment) => Number.isInteger(segment?.index) &&
133
133
  Number.isFinite(segment?.startSeconds) && Number.isFinite(segment?.endSeconds) &&
134
134
  segment.endSeconds > segment.startSeconds && segment.endSeconds > position)
@@ -388,6 +388,25 @@ export function predictPlaybackReadiness(input = {}) {
388
388
  "minimum-safe-delay", preparedSegments, readiness.bufferedAtStart);
389
389
  }
390
390
 
391
+ /** Predict only unknown cuts in the same clock as the measured track. */
392
+ function predictedMediaSegments(segments) {
393
+ const anchors = segments.filter(({ mediaRanges }) => mediaRanges?.length > 0);
394
+ return segments.map((segment) => {
395
+ if (segment.mediaRanges !== undefined && segment.mediaRanges !== null || anchors.length === 0) return segment;
396
+ const previous = anchors.findLast(({ index }) => index < segment.index);
397
+ const next = anchors.find(({ index }) => index > segment.index);
398
+ const nominalLeft = previous?.endSeconds ?? next.startSeconds;
399
+ const actualLeft = previous?.mediaRanges.at(-1).end ?? next.mediaRanges[0].start;
400
+ const nominalRight = next?.startSeconds ?? nominalLeft;
401
+ const actualRight = next?.mediaRanges[0].start ?? actualLeft;
402
+ const scale = nominalRight > nominalLeft ? (actualRight - actualLeft) / (nominalRight - nominalLeft) : 1;
403
+ return { ...segment, mediaRanges: [{
404
+ start: actualLeft + (segment.startSeconds - nominalLeft) * scale,
405
+ end: actualLeft + (segment.endSeconds - nominalLeft) * scale
406
+ }] };
407
+ });
408
+ }
409
+
391
410
  function rateCurve(readings, now) {
392
411
  if (readings && Number.isFinite(readings.count) && readings.count > 0 &&
393
412
  Number.isFinite(readings.lastAt) && Number.isFinite(readings.lastValue) &&
@@ -933,13 +933,19 @@ export class SharedPieceStore {
933
933
  // THREE pieces reported 812 MB committed against 68 MB allowed, 739 blocks
934
934
  // allocated and 676 still alive, and the process was killed at 4.37 GB.
935
935
  const displaced = this.#buffers.get(index);
936
- this.#buffers.set(index, buffer);
937
936
  if (displaced !== undefined && displaced !== buffer) {
938
937
  this.#counters.blocksDisplaced += 1;
939
- this.#returnBlock(displaced);
938
+ // A reader may still hold the existing buffer pinned. Verified bytes for
939
+ // this index are identical, so retain that buffer and return the duplicate.
940
+ this.#returnBlock(buffer);
941
+ this.#lru.touch(index);
942
+ this.#noteProgress();
943
+ return displaced;
940
944
  }
945
+ this.#buffers.set(index, buffer);
941
946
  this.#lru.touch(index);
942
947
  this.#noteProgress();
948
+ return buffer;
943
949
  }
944
950
 
945
951
  /**
@@ -1394,12 +1400,9 @@ export class SharedPieceStore {
1394
1400
  return null;
1395
1401
  }
1396
1402
  this.#counters.fromWholeFile += 1;
1397
- const release = await this.#claimSlotOrNull();
1398
- if (release === null) {
1399
- // No block to put it in. The caller gets the bytes anyway; what it
1400
- // loses is only that the next read pays for this one again.
1401
- return whole;
1402
- }
1403
+ // Fragment readers require shared memory, including when the bytes now
1404
+ // live in an assembled file. Wait for the same reservation as disk reads.
1405
+ const release = await this.#claimSlot();
1403
1406
  try {
1404
1407
  return this.#registerPiece(index, this.#copyIntoNewBuffer(index, whole));
1405
1408
  } finally {
@@ -1432,11 +1435,11 @@ export class SharedPieceStore {
1432
1435
  this.#returnBlock(target);
1433
1436
  throw error;
1434
1437
  }
1435
- this.#registerPiece(index, target);
1438
+ const registered = this.#registerPiece(index, target);
1436
1439
  this.#counters.fromDisk += 1;
1437
1440
  this.#counters.revivals += 1;
1438
1441
  this.#noteRevival(index);
1439
- return target;
1442
+ return registered;
1440
1443
  } finally {
1441
1444
  release?.();
1442
1445
  releaseDisk();
@@ -149,18 +149,18 @@ export class TorrentWorkerClient {
149
149
  switch (message?.type) {
150
150
  case Event.FRAGMENT: {
151
151
  const buffer = message.buffer;
152
- if (!buffer) {
153
- // No buffer means no way to read the fragment; confirm it so the
154
- // worker is not left waiting, and let the read end short.
155
- this.#worker.postMessage({ type: Event.FRAGMENT_DONE, id: message.id });
156
- break;
157
- }
158
- if (message.offset + message.length > buffer.byteLength) {
159
- logger.warn(
160
- `torrent-worker: fragment ${message.offset}+${message.length} lies outside ` +
161
- `its buffer of ${buffer.byteLength}B (read ${message.id}) — ending the read short`
162
- );
152
+ if (!buffer || !Number.isInteger(message.offset) || message.offset < 0 ||
153
+ !Number.isInteger(message.length) || message.length <= 0 ||
154
+ message.offset + message.length > buffer.byteLength) {
155
+ const error = new Error(`Invalid source fragment ${message.offset}+${message.length} ` +
156
+ `in a buffer of ${buffer?.byteLength ?? 0}B (read ${message.id}).`);
157
+ logger.warn(`torrent-worker: ${error.message}`);
158
+ this.#fragmentReaders.get(message.id)?.fail(error);
159
+ this.#fragmentReaders.delete(message.id);
160
+ this.#reads.get(message.id)?.fail(error);
161
+ this.#reads.delete(message.id);
163
162
  this.#worker.postMessage({ type: Event.FRAGMENT_DONE, id: message.id });
163
+ void this.#caller.call(Command.CANCEL_READ, { readId: message.id }).catch(() => undefined);
164
164
  break;
165
165
  }
166
166
  // A sequential read walks the file forwards, so each fragment either
@@ -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) =>
@@ -1,11 +1,12 @@
1
1
  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
- import { readPresentationRanges, walkBoxes } from "../services/encode/segment-formats/mp4-boxes.js";
4
+ import { continuePresentationRanges, readPresentationRanges, walkBoxes } from "../services/encode/segment-formats/mp4-boxes.js";
5
5
  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,49 @@ 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 variable-rate frame crosses a cut only with continuous decode samples", () => {
131
+ const first = readPresentationRanges(Buffer.concat([init(0, 0), fragment(0, 0)]));
132
+ const continuous = readPresentationRanges(Buffer.concat([init(0, 0), fragment(1000, 72000)]));
133
+ assert.equal(continuePresentationRanges(first, continuous)[0].end, continuous[0].start);
134
+ const missing = readPresentationRanges(Buffer.concat([init(0, 0), fragment(24000, 72000)]));
135
+ assert.deepEqual(continuePresentationRanges(first, missing), first);
136
+ const audioFirst = readPresentationRanges(Buffer.concat([init(0, 0, "soun"), fragment(0, 0)]));
137
+ const audioNext = readPresentationRanges(Buffer.concat([init(0, 0, "soun"), fragment(1000, 72000)]));
138
+ assert.deepEqual(continuePresentationRanges(audioFirst, audioNext), audioFirst);
139
+ });
140
+
141
+ test("a viewer retains only finite per-track player clock offsets", () => {
142
+ const viewer = new Viewer("clock-reading", 1);
143
+ viewer.report({ timestampOffsets: { video: -0.083, audio: 0, media: 400 } }, 2);
144
+ assert.deepEqual(viewer.linkReading().timestampOffsets, { video: -0.083, audio: 0 });
145
+ viewer.report({ timestampOffsets: { video: Number.POSITIVE_INFINITY } }, 3);
146
+ assert.deepEqual(viewer.linkReading().timestampOffsets, {});
147
+ });
@@ -148,6 +148,28 @@ test("a revival holds disk bytes across the asynchronous memory reservation", as
148
148
  }
149
149
  });
150
150
 
151
+ test("an assembled-file revival returns shared bytes and preserves a concurrent pinned reader", async () => {
152
+ const disk = makeDisk();
153
+ const store = new SharedPieceStore(CHUNK, {
154
+ length: CHUNK * 16, memoryBytes: CHUNK * 4, disk,
155
+ readPieceElsewhere: async ({ index }) => piece(index), name: "whole-file-read"
156
+ });
157
+ try {
158
+ store.pin(0);
159
+ const [first, second] = await Promise.all([store.reside(0), store.reside(0)]);
160
+ assert.ok(first.buffer instanceof SharedArrayBuffer);
161
+ assert.equal(first.buffer, second.buffer);
162
+ await put(store, 1);
163
+ await put(store, 2);
164
+ assert.deepEqual(Buffer.from(first.buffer, first.offset, first.length), piece(0));
165
+ assert.equal(store.locate(0).buffer, first.buffer);
166
+ assert.equal(store.stats().outstanding, 0);
167
+ store.unpin(0);
168
+ } finally {
169
+ await new Promise((resolve) => store.destroy(resolve));
170
+ }
171
+ });
172
+
151
173
  /**
152
174
  * Wait until a condition holds, rather than for a chosen interval — a test that
153
175
  * sleeps samples, it does not check (roadmap item 54).
@@ -39,6 +39,17 @@ test("finds the first safe start from segments delivered before playback", () =>
39
39
  assert.ok(Math.abs(forecast.delaySeconds - 1) < 1e-8);
40
40
  });
41
41
 
42
+ test("future cuts follow measured track time without inventing a permanent clock gap", () => {
43
+ const state = input({ bufferedAheadSeconds: 3.9, reserveSeconds: 0 });
44
+ state.tracks[0].clientRanges = [{ start: 0, end: 3.9 }];
45
+ state.tracks[0].segments[0].mediaRanges = [{ start: 0, end: 3.9 }];
46
+ const forecast = predictPlaybackReadiness(state);
47
+ assert.notEqual(forecast.reason, "no-safe-start-found");
48
+ state.tracks[0].segments[1].mediaRanges = [{ start: 4, end: 8 }];
49
+ state.tracks[0].readySegmentIndices = [0, 1];
50
+ assert.equal(predictPlaybackReadiness(state).reason, "no-safe-start-found");
51
+ });
52
+
42
53
  test("keeps source-stall reserve in the proxy's prepared timeline, not the capped browser buffer", () => {
43
54
  const now = 10_000;
44
55
  const forecast = predictPlaybackReadiness({
@@ -761,8 +761,7 @@ test("a run on the keyframe grid is given no trim to apply", () => {
761
761
  videoEncoder: { name: "libx264" },
762
762
  segmentDurationSec: SEGMENT_SECONDS
763
763
  });
764
- // Exactly one `-ss`, and it is the input seek: the trim is not there to be
765
- // inherited by the cuts.
764
+ // One input seek establishes decode order in the preceding private cut.
766
765
  assert.equal(
767
766
  args.filter((one) => one === "-ss").length,
768
767
  1,
@@ -770,9 +769,10 @@ test("a run on the keyframe grid is given no trim to apply", () => {
770
769
  );
771
770
  const seek = Number(args[args.indexOf("-ss") + 1]);
772
771
  assert.ok(
773
- Math.abs(seek - grid.sourceTimes[index]) < 0.2,
774
- `run at #${index} seeks to ${seek}, not to its own keyframe ${grid.sourceTimes[index]}`
772
+ Math.abs(seek - grid.sourceTimes[index - 1]) < 0.2,
773
+ `run at #${index} seeks to ${seek}, not to the preceding keyframe ${grid.sourceTimes[index - 1]}`
775
774
  );
775
+ assert.equal(Number(args[args.indexOf("-segment_start_number") + 1]), index - 1);
776
776
  }
777
777
  });
778
778