@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 +10 -0
- package/package.json +1 -1
- package/routes/api/transcode-sessions/net-report/post.js +1 -0
- package/services/encode/EncodeRuns.js +6 -1
- package/services/encode/run-command.js +12 -5
- package/services/encode/segment-formats/fmp4.js +6 -0
- package/services/encode/segment-formats/index.js +4 -0
- package/services/encode/segment-formats/mp4-boxes.js +75 -7
- package/services/server/ViewerRequests.js +18 -7
- package/services/server/playback-readiness.js +20 -1
- package/services/storage/piece-store/shared-piece-store.js +13 -10
- package/services/torrent/worker/client.js +11 -11
- package/services/viewer/Viewer.js +3 -0
- package/test/fmp4-presentation-clock.test.js +53 -3
- package/test/piece-store-reservations.test.js +22 -0
- package/test/playback-readiness.test.js +11 -0
- package/test/quality-variants.test.js +4 -4
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
|
@@ -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) =>
|
|
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),
|
|
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
|
-
?
|
|
448
|
-
:
|
|
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(
|
|
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(
|
|
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 === "
|
|
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
|
|
235
|
+
const mergedTracks = [...byTrack].map(([id, samples]) => {
|
|
219
236
|
const merged = [];
|
|
220
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
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)),
|
|
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
|
|
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 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
|
-
|
|
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
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
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
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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)),
|
|
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
|
-
//
|
|
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
|
|
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
|
|