@torrent-tv/proxy 2.87.14 → 2.87.16
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 +13 -0
- package/docs/browser-proxy-contract.md +9 -0
- package/package.json +1 -1
- package/routes/api/transcode-sessions/net-report/post.js +1 -0
- package/services/encode/EncodeRun.js +43 -6
- package/services/encode/EncodeRuns.js +2 -1
- package/services/encode/segment-formats/fmp4.js +10 -2
- package/services/encode/segment-formats/index.js +1 -0
- package/services/encode/segment-formats/mp4-boxes.js +137 -1
- package/services/server/SegmentServing.js +5 -1
- package/services/server/ViewerRequests.js +26 -6
- package/services/server/playback-readiness.js +139 -143
- package/services/storage/piece-store/piece-disk-store.js +23 -0
- package/services/storage/piece-store/shared-piece-store.js +7 -2
- package/services/storage/segment-store/SegmentStore.js +36 -1
- package/services/viewer/Viewer.js +6 -0
- package/test/encode-orchestrator.test.js +3 -3
- package/test/encode-plan-viewers.test.js +1 -1
- package/test/encode-run.test.js +1 -0
- package/test/fmp4-presentation-clock.test.js +72 -0
- package/test/piece-disk-store.test.js +20 -0
- package/test/piece-store-reservations.test.js +26 -1
- package/test/playback-readiness.test.js +82 -6
- package/test/playback-start-regressions.test.js +99 -0
- package/test/segment-store.test.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
## 2.87.16
|
|
2
|
+
|
|
3
|
+
- **Fix**: Hold spilled source bytes across memory reservation so concurrent eviction cannot terminate ffmpeg input with a missing disk piece.
|
|
4
|
+
- **Fix**: Publish a run's final closed segment only after complete input termination, retain earlier completed cuts, and drain output notifications before classifying an exit.
|
|
5
|
+
- **Fix**: Preserve copied media presentation times when replacing per-segment headers with one shared init, including backward seeks after opening at a later position.
|
|
6
|
+
- **Fix**: Feed measured segment intervals and separate browser track ranges into the service integral; retain media beyond holes, avoid duplicate transfer estimates, and price missing segments independently of another run's progress.
|
|
7
|
+
|
|
8
|
+
## 2.87.15
|
|
9
|
+
|
|
10
|
+
- **Fix**: Integrate a bounded, nonnegative forecast of recent measured service rates, so an old faster link sample cannot predict permanent delivery failure and prevent playback of fully prepared media.
|
|
11
|
+
- **Fix**: Treat the already-held browser buffer as a measured lower bound on capacity when its loader target has been reduced by fragment retries.
|
|
12
|
+
- **Chore**: Record forecast measurements when the playback-readiness reason changes.
|
|
13
|
+
|
|
1
14
|
## 2.87.14
|
|
2
15
|
|
|
3
16
|
- **Fix**: Evaluate source-stall coverage against the continuous timeline already prepared on the proxy, while independently checking client-buffer continuity and delivery capacity.
|
|
@@ -101,6 +101,15 @@ remaining-work estimates and share the measured download rate of their torrent.
|
|
|
101
101
|
The browser reports buffer state and delivery measurements, but does not
|
|
102
102
|
calculate or override readiness.
|
|
103
103
|
|
|
104
|
+
Service work is the integral of a nonnegative measured-rate forecast. The latest
|
|
105
|
+
rate relaxes over the observed measurement span toward the lower of its latest
|
|
106
|
+
value and the recent mean; the measurement history covers the output look-ahead.
|
|
107
|
+
A positive measured rate cannot become permanently zero from an old declining
|
|
108
|
+
linear fit. A measured zero remains zero until a new measurement arrives.
|
|
109
|
+
The reported loader limit is bounded below by the browser buffer already held.
|
|
110
|
+
During startup waiting the browser refreshes stale delivery measurements through
|
|
111
|
+
the existing link probe and reports their age to avoid clock-offset errors.
|
|
112
|
+
|
|
104
113
|
## Subtitles
|
|
105
114
|
|
|
106
115
|
`GET /api/subtitles` requires `sourceKey` and `fileIndex`. Without `trackIndex`
|
package/package.json
CHANGED
|
@@ -109,6 +109,7 @@ export async function handleApiTranscodeSessionNetReportPost(req, reply, { outpu
|
|
|
109
109
|
linkSampleMbps: Number.isFinite(linkSampleMbps) && linkSampleMbps > 0 ? linkSampleMbps : undefined,
|
|
110
110
|
linkSampleAt: Number.isFinite(linkSampleAt) && linkSampleAt > 0 ? linkSampleAt : undefined,
|
|
111
111
|
bufferedAheadSec,
|
|
112
|
+
bufferedRanges: body.bufferedRanges,
|
|
112
113
|
bufferLimitSeconds:
|
|
113
114
|
Number.isFinite(bufferLimitSeconds) && bufferLimitSeconds > 0 ? bufferLimitSeconds : undefined,
|
|
114
115
|
consumerId,
|
|
@@ -103,6 +103,10 @@ export class EncodeRun {
|
|
|
103
103
|
|
|
104
104
|
/** Half a name left over from the last chunk of the encoder's own channel. */
|
|
105
105
|
#closedTail = "";
|
|
106
|
+
#pendingClosed = null;
|
|
107
|
+
#inputTruncated = false;
|
|
108
|
+
#stderrReadTail = "";
|
|
109
|
+
#processExited = false;
|
|
106
110
|
|
|
107
111
|
/**
|
|
108
112
|
* The last piece named on the ready channel while the run was still running
|
|
@@ -242,7 +246,7 @@ export class EncodeRun {
|
|
|
242
246
|
this.onProgress = typeof onProgress === "function" ? onProgress : () => {};
|
|
243
247
|
// Told the NAME of every piece the encoder has closed. The name is the
|
|
244
248
|
// proof it is whole; nothing else here can prove that.
|
|
245
|
-
this.onClosed = typeof onClosed === "function" ? onClosed : () =>
|
|
249
|
+
this.onClosed = typeof onClosed === "function" ? onClosed : (name) => name;
|
|
246
250
|
this.lastSegmentIndex = typeof lastSegmentIndex === "function" ? lastSegmentIndex : () => null;
|
|
247
251
|
this.inputUnavailable = typeof inputUnavailable === "function" ? inputUnavailable : () => false;
|
|
248
252
|
this.argsDescribed = argsDescribed;
|
|
@@ -420,6 +424,9 @@ export class EncodeRun {
|
|
|
420
424
|
const line = String(chunk).trim();
|
|
421
425
|
if (line.length > 0) {
|
|
422
426
|
this.lastError = line;
|
|
427
|
+
const errorText = this.#stderrReadTail + String(chunk);
|
|
428
|
+
this.#inputTruncated ||= /Stream ends prematurely|Input\/output error|Error during demuxing/i.test(errorText);
|
|
429
|
+
this.#stderrReadTail = errorText.slice(-512);
|
|
423
430
|
this.logger.warn(`ffmpeg #${this.from}..#${this.to} of ${this.address}: ${line}`);
|
|
424
431
|
}
|
|
425
432
|
});
|
|
@@ -428,7 +435,9 @@ export class EncodeRun {
|
|
|
428
435
|
this.lastError = message;
|
|
429
436
|
this.#finish(ENCODE_EXIT.FAILED, `the process could not be started: ${message}`, null, null);
|
|
430
437
|
});
|
|
431
|
-
|
|
438
|
+
// `close` follows drained stdio; `exit` can precede the final filename.
|
|
439
|
+
process.on("exit", () => { this.#processExited = true; });
|
|
440
|
+
process.on("close", (code, signal) => this.#onExit(code, signal));
|
|
432
441
|
}
|
|
433
442
|
|
|
434
443
|
/**
|
|
@@ -504,11 +513,23 @@ export class EncodeRun {
|
|
|
504
513
|
if (name.length === 0) {
|
|
505
514
|
continue;
|
|
506
515
|
}
|
|
516
|
+
if (this.#stopping) continue;
|
|
517
|
+
// A subsequent closed file proves the previous one reached a cut.
|
|
518
|
+
// The last file can instead have been flushed by an input failure.
|
|
519
|
+
if (this.#pendingClosed !== null) {
|
|
520
|
+
this.#publishClosed(this.#pendingClosed);
|
|
521
|
+
}
|
|
522
|
+
this.#pendingClosed = name;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
#publishClosed(name) {
|
|
507
527
|
// ITS SERVED NAME, which is what whoever owns the disk gives it in answer.
|
|
508
528
|
// ffmpeg writes a piece under a working name and reports that; the piece
|
|
509
529
|
// becomes servable by being renamed, and everything below works in the
|
|
510
530
|
// name a request can actually ask for.
|
|
511
|
-
const served = this.onClosed(name)
|
|
531
|
+
const served = this.onClosed(name);
|
|
532
|
+
if (!served) return false;
|
|
512
533
|
if (!this.#stopping) {
|
|
513
534
|
this.#provenName = served;
|
|
514
535
|
}
|
|
@@ -524,7 +545,7 @@ export class EncodeRun {
|
|
|
524
545
|
if (Number.isInteger(index)) {
|
|
525
546
|
this.noteProduced(index);
|
|
526
547
|
}
|
|
527
|
-
|
|
548
|
+
return true;
|
|
528
549
|
}
|
|
529
550
|
|
|
530
551
|
noteSpeed(speedX) {
|
|
@@ -545,6 +566,12 @@ export class EncodeRun {
|
|
|
545
566
|
if (this.#process === null || this.#ended) {
|
|
546
567
|
return;
|
|
547
568
|
}
|
|
569
|
+
// A cut closed before our stop stays complete; the file flushed by the
|
|
570
|
+
// subsequent signal must never be published as that same proof.
|
|
571
|
+
if (this.#pendingClosed !== null && !this.#processExited && !this.#inputTruncated) {
|
|
572
|
+
this.#publishClosed(this.#pendingClosed);
|
|
573
|
+
this.#pendingClosed = null;
|
|
574
|
+
}
|
|
548
575
|
this.#stopping = true;
|
|
549
576
|
this.#stopReason = because;
|
|
550
577
|
this.#stopOrderedAt = this.now();
|
|
@@ -646,14 +673,24 @@ export class EncodeRun {
|
|
|
646
673
|
// that exits cleanly at #11 has not finished, whatever the film's length;
|
|
647
674
|
// and a run with no end has nothing but the film to be measured against.
|
|
648
675
|
const endOfWork = Number.isFinite(endOfRun(this)) ? this.to : this.lastSegmentIndex();
|
|
676
|
+
const pendingIndex = this.#pendingClosed === null ? null :
|
|
677
|
+
this.indexOfName(this.#pendingClosed);
|
|
649
678
|
const outcome = classifyEncodeExit({
|
|
650
679
|
code,
|
|
651
|
-
producedThrough: this.#
|
|
652
|
-
|
|
680
|
+
producedThrough: this.#inputTruncated ? this.reached :
|
|
681
|
+
(pendingIndex === this.head ? pendingIndex : this.reached),
|
|
682
|
+
producedCount: this.#produced.size + Number(pendingIndex === this.head && !this.#inputTruncated),
|
|
653
683
|
lastSegmentIndex: endOfWork,
|
|
654
684
|
inputUnavailable: this.inputUnavailable(this.lastError)
|
|
655
685
|
});
|
|
656
686
|
if (outcome === ENCODE_EXIT.COMPLETE) {
|
|
687
|
+
if (this.#pendingClosed !== null && !this.#inputTruncated) {
|
|
688
|
+
if (!this.#publishClosed(this.#pendingClosed)) {
|
|
689
|
+
this.#finish(ENCODE_EXIT.SHORT, "its final file could not be published", code, signal);
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
this.#pendingClosed = null;
|
|
693
|
+
}
|
|
657
694
|
this.#finish(ENCODE_EXIT.COMPLETE, "it reached the end of what it was given", code, signal);
|
|
658
695
|
return;
|
|
659
696
|
}
|
|
@@ -827,7 +827,8 @@ export class EncodeRuns {
|
|
|
827
827
|
session.timeline?.segmentCount > 0 ? session.timeline.segmentCount - 1 : null,
|
|
828
828
|
inputUnavailable: (message) => isInputUnavailable(message),
|
|
829
829
|
onProgress: (report) => this.#noteRunProgress(session, run, report),
|
|
830
|
-
indexOfName: (name) => session.segmentFormat.segmentIndexFromName(
|
|
830
|
+
indexOfName: (name) => session.segmentFormat.segmentIndexFromName(
|
|
831
|
+
session.segmentFormat.servedNameOf?.(name) ?? name),
|
|
831
832
|
// Why this encoder exists, recorded with its argument list. It used to be
|
|
832
833
|
// handed to a separate `start` call; there is no separate call now.
|
|
833
834
|
because,
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import {
|
|
14
|
+
rebaseSegmentDecodeTimes,
|
|
15
|
+
neutralizeEmptyEdits,
|
|
16
|
+
readPresentationRanges,
|
|
14
17
|
readSelfContainedStartSeconds,
|
|
15
18
|
readTrackTimescales,
|
|
16
19
|
readVideoSampleSize,
|
|
@@ -210,6 +213,7 @@ export const fmp4Format = {
|
|
|
210
213
|
const { firstFragment } = findFragmentBounds(bytes);
|
|
211
214
|
return firstFragment > 0 ? bytes.subarray(0, firstFragment) : null;
|
|
212
215
|
},
|
|
216
|
+
prepareSharedInit: neutralizeEmptyEdits,
|
|
213
217
|
|
|
214
218
|
/**
|
|
215
219
|
* A piece with its init header removed, and the trailing random-access index
|
|
@@ -275,6 +279,7 @@ export const fmp4Format = {
|
|
|
275
279
|
* transcode itself; the box walk never descends into `mdat`.
|
|
276
280
|
*/
|
|
277
281
|
needsSegmentRewrite: true,
|
|
282
|
+
readMediaRanges: readPresentationRanges,
|
|
278
283
|
|
|
279
284
|
/**
|
|
280
285
|
* Whether a segment carries every track the init promises.
|
|
@@ -425,13 +430,16 @@ export const fmp4Format = {
|
|
|
425
430
|
return readVideoSampleSize(initBytes);
|
|
426
431
|
},
|
|
427
432
|
|
|
428
|
-
prepareSegmentBytes(bytes, { startSeconds, initBytes }) {
|
|
433
|
+
prepareSegmentBytes(bytes, { startSeconds, initBytes, rawBytes }) {
|
|
429
434
|
if (!initBytes || initBytes.length === 0) {
|
|
430
435
|
// No init cached yet — nothing to read timescales from. The player always
|
|
431
436
|
// fetches `#EXT-X-MAP` before any segment, so this is not reachable in
|
|
432
437
|
// practice; serve unmodified rather than guess a timescale.
|
|
433
438
|
return bytes;
|
|
434
439
|
}
|
|
435
|
-
|
|
440
|
+
const ownInit = rawBytes ? this.extractInit(rawBytes) : null;
|
|
441
|
+
return ownInit
|
|
442
|
+
? rebaseSegmentDecodeTimes(bytes, ownInit, initBytes)
|
|
443
|
+
: stampSegmentStartTime(bytes, startSeconds, readTrackTimescales(initBytes));
|
|
436
444
|
}
|
|
437
445
|
};
|
|
@@ -20,6 +20,7 @@ import { mpegtsFormat } from "./mpegts.js";
|
|
|
20
20
|
* output timeline, from the session's segment-boundary table.
|
|
21
21
|
* @property {Buffer | null} initBytes - The init segment being served for this
|
|
22
22
|
* session, when the format has one (fMP4 needs it to read track timescales).
|
|
23
|
+
* @property {Buffer} [rawBytes] - Self-contained bytes before removing their init.
|
|
23
24
|
*/
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* @type {ReadonlySet<string>}
|
|
17
17
|
*/
|
|
18
|
-
const CONTAINER_BOXES = new Set(["moov", "trak", "mdia", "minf", "stbl", "edts", "moof", "traf"]);
|
|
18
|
+
const CONTAINER_BOXES = new Set(["moov", "mvex", "trak", "mdia", "minf", "stbl", "edts", "moof", "traf"]);
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Walk the box tree, invoking `visit` for every box encountered.
|
|
@@ -95,6 +95,142 @@ export function readTrackTimescales(initSegment) {
|
|
|
95
95
|
return timescales;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/** Movie presentation time minus track media time, in seconds per track. */
|
|
99
|
+
export function readTrackEditOffsets(initSegment) {
|
|
100
|
+
const scales = readTrackTimescales(initSegment);
|
|
101
|
+
const offsets = new Map([...scales.keys()].map((id) => [id, 0]));
|
|
102
|
+
let movieScale = 0;
|
|
103
|
+
let trackId = null;
|
|
104
|
+
walkBoxes(initSegment, (type, start, end) => {
|
|
105
|
+
const version = initSegment[start];
|
|
106
|
+
if (type === "mvhd") {
|
|
107
|
+
const at = start + (version === 1 ? 20 : 12);
|
|
108
|
+
if (at + 4 <= end) movieScale = initSegment.readUInt32BE(at);
|
|
109
|
+
} else if (type === "tkhd") {
|
|
110
|
+
const at = start + (version === 1 ? 20 : 12);
|
|
111
|
+
if (at + 4 <= end) trackId = initSegment.readUInt32BE(at);
|
|
112
|
+
} else if (type === "elst" && scales.has(trackId) && movieScale > 0 && start + 8 <= end) {
|
|
113
|
+
const count = initSegment.readUInt32BE(start + 4);
|
|
114
|
+
const width = version === 1 ? 20 : 12;
|
|
115
|
+
let movieStart = 0;
|
|
116
|
+
for (let entry = 0, at = start + 8; entry < count && at + width <= end; entry++, at += width) {
|
|
117
|
+
const duration = version === 1 ? Number(initSegment.readBigUInt64BE(at)) : initSegment.readUInt32BE(at);
|
|
118
|
+
const mediaTime = version === 1 ? Number(initSegment.readBigInt64BE(at + 8)) : initSegment.readInt32BE(at + 4);
|
|
119
|
+
if (mediaTime === -1) {
|
|
120
|
+
movieStart += duration / movieScale;
|
|
121
|
+
} else {
|
|
122
|
+
offsets.set(trackId, movieStart - mediaTime / scales.get(trackId));
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
return offsets;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** An init shared across seeks has no segment-specific empty edit. */
|
|
132
|
+
export function neutralizeEmptyEdits(initSegment) {
|
|
133
|
+
const neutral = Buffer.from(initSegment);
|
|
134
|
+
walkBoxes(neutral, (type, start, end) => {
|
|
135
|
+
if (type !== "elst" || start + 8 > end) return;
|
|
136
|
+
const version = neutral[start];
|
|
137
|
+
const width = version === 1 ? 20 : 12;
|
|
138
|
+
const count = neutral.readUInt32BE(start + 4);
|
|
139
|
+
for (let entry = 0, at = start + 8; entry < count && at + width <= end; entry++, at += width) {
|
|
140
|
+
const mediaTime = version === 1 ? neutral.readBigInt64BE(at + 8) : BigInt(neutral.readInt32BE(at + 4));
|
|
141
|
+
if (mediaTime !== -1n) continue;
|
|
142
|
+
if (version === 1) neutral.writeBigUInt64BE(0n, at);
|
|
143
|
+
else neutral.writeUInt32BE(0, at);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
return neutral;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Preserve sample presentation times when replacing a piece's own init. */
|
|
150
|
+
export function rebaseSegmentDecodeTimes(segment, ownInit, sessionInit) {
|
|
151
|
+
const scales = readTrackTimescales(sessionInit);
|
|
152
|
+
const ownOffsets = readTrackEditOffsets(ownInit);
|
|
153
|
+
const sessionOffsets = readTrackEditOffsets(sessionInit);
|
|
154
|
+
const stamped = Buffer.from(segment);
|
|
155
|
+
let trackId = null;
|
|
156
|
+
walkBoxes(stamped, (type, start, end) => {
|
|
157
|
+
if (type === "tfhd" && start + 8 <= end) {
|
|
158
|
+
trackId = stamped.readUInt32BE(start + 4);
|
|
159
|
+
} else if (type === "tfdt" && scales.has(trackId) && ownOffsets.has(trackId)) {
|
|
160
|
+
const version = stamped[start];
|
|
161
|
+
if (start + (version === 1 ? 12 : 8) > end) return;
|
|
162
|
+
const existing = version === 1 ? Number(stamped.readBigUInt64BE(start + 4)) : stamped.readUInt32BE(start + 4);
|
|
163
|
+
const value = existing + Math.round((ownOffsets.get(trackId) - sessionOffsets.get(trackId)) * scales.get(trackId));
|
|
164
|
+
if (value < 0) throw new Error("A fragment cannot be placed before its shared init's decode origin.");
|
|
165
|
+
if (version === 1) stamped.writeBigUInt64BE(BigInt(value), start + 4);
|
|
166
|
+
else if (value <= 0xffffffff) stamped.writeUInt32BE(value, start + 4);
|
|
167
|
+
else throw new Error("Fragment decode time exceeds its 32-bit field.");
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
return stamped;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Presentation intervals from the durations and composition times of samples. */
|
|
174
|
+
export function readPresentationRanges(raw) {
|
|
175
|
+
const scales = readTrackTimescales(raw);
|
|
176
|
+
const edits = readTrackEditOffsets(raw);
|
|
177
|
+
const byTrack = new Map();
|
|
178
|
+
const defaultDurations = new Map();
|
|
179
|
+
let trackId = null;
|
|
180
|
+
let decodeTime = 0;
|
|
181
|
+
let defaultDuration = 0;
|
|
182
|
+
walkBoxes(raw, (type, start, end) => {
|
|
183
|
+
if (type === "trex" && start + 20 <= end) {
|
|
184
|
+
defaultDurations.set(raw.readUInt32BE(start + 4), raw.readUInt32BE(start + 12));
|
|
185
|
+
} else if (type === "tfhd" && start + 8 <= end) {
|
|
186
|
+
const flags = raw.readUIntBE(start + 1, 3);
|
|
187
|
+
trackId = raw.readUInt32BE(start + 4);
|
|
188
|
+
const at = start + 8 + ((flags & 1) ? 8 : 0) + ((flags & 2) ? 4 : 0);
|
|
189
|
+
defaultDuration = (flags & 8) && at + 4 <= end ? raw.readUInt32BE(at) :
|
|
190
|
+
defaultDurations.get(trackId) ?? 0;
|
|
191
|
+
} else if (type === "tfdt" && start + (raw[start] === 1 ? 12 : 8) <= end) {
|
|
192
|
+
decodeTime = raw[start] === 1 ? Number(raw.readBigUInt64BE(start + 4)) : raw.readUInt32BE(start + 4);
|
|
193
|
+
} else if (type === "trun" && scales.has(trackId) && start + 8 <= end) {
|
|
194
|
+
const scale = scales.get(trackId);
|
|
195
|
+
const flags = raw.readUIntBE(start + 1, 3);
|
|
196
|
+
const count = raw.readUInt32BE(start + 4);
|
|
197
|
+
let at = start + 8 + ((flags & 1) ? 4 : 0) + ((flags & 4) ? 4 : 0);
|
|
198
|
+
const width = Number(Boolean(flags & 0x100)) * 4 + Number(Boolean(flags & 0x200)) * 4 +
|
|
199
|
+
Number(Boolean(flags & 0x400)) * 4 + Number(Boolean(flags & 0x800)) * 4;
|
|
200
|
+
const ranges = byTrack.get(trackId) ?? [];
|
|
201
|
+
for (let sample = 0; sample < count && at + width <= end; sample++) {
|
|
202
|
+
const duration = flags & 0x100 ? raw.readUInt32BE(at) : defaultDuration;
|
|
203
|
+
if (flags & 0x100) at += 4;
|
|
204
|
+
if (flags & 0x200) at += 4;
|
|
205
|
+
if (flags & 0x400) at += 4;
|
|
206
|
+
const composition = flags & 0x800 ?
|
|
207
|
+
(raw[start] === 1 ? raw.readInt32BE(at) : raw.readUInt32BE(at)) : 0;
|
|
208
|
+
if (flags & 0x800) at += 4;
|
|
209
|
+
if (!(duration > 0)) return;
|
|
210
|
+
const position = (decodeTime + composition) / scale + (edits.get(trackId) ?? 0);
|
|
211
|
+
ranges.push({ start: position, end: position + duration / scale });
|
|
212
|
+
decodeTime += duration;
|
|
213
|
+
}
|
|
214
|
+
byTrack.set(trackId, ranges);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
// Merge in presentation order: B frames arrive in decode order.
|
|
218
|
+
const mergedTracks = [...byTrack.values()].map((samples) => {
|
|
219
|
+
const merged = [];
|
|
220
|
+
for (const sample of samples.sort((left, right) => left.start - right.start)) {
|
|
221
|
+
const previous = merged.at(-1);
|
|
222
|
+
if (previous && sample.start - previous.end <= Number.EPSILON * Math.max(1, sample.end) * 8) {
|
|
223
|
+
previous.end = Math.max(previous.end, sample.end);
|
|
224
|
+
} else merged.push({ ...sample });
|
|
225
|
+
}
|
|
226
|
+
return merged;
|
|
227
|
+
});
|
|
228
|
+
// A multiplexed segment is playable only where every declared track exists.
|
|
229
|
+
return mergedTracks.reduce((common, ranges) => common.flatMap((left) => ranges
|
|
230
|
+
.map((right) => ({ start: Math.max(left.start, right.start), end: Math.min(left.end, right.end) }))
|
|
231
|
+
.filter(({ start, end }) => end > start)), mergedTracks[0] ?? []);
|
|
232
|
+
}
|
|
233
|
+
|
|
98
234
|
/**
|
|
99
235
|
* Rewrite every fragment's `tfdt` so the segment declares that it starts at
|
|
100
236
|
* `startSeconds` on the media timeline.
|
|
@@ -419,6 +419,7 @@ export class SegmentServing {
|
|
|
419
419
|
}
|
|
420
420
|
const prepared = session.segmentFormat.prepareSegmentBytes(bytes, {
|
|
421
421
|
startSeconds: stampStart,
|
|
422
|
+
rawBytes: raw,
|
|
422
423
|
initBytes: this.#host.segmentStore.initOf(session.outputKey ?? "")
|
|
423
424
|
});
|
|
424
425
|
this.#host.encodeRuns.noteRunProducedSegment(session, filePath);
|
|
@@ -541,6 +542,7 @@ export class SegmentServing {
|
|
|
541
542
|
const trueStart = selfContained ? format.readSegmentStartSeconds?.(raw) ?? null : null;
|
|
542
543
|
const prepared = format.prepareSegmentBytes(bytes, {
|
|
543
544
|
startSeconds: trueStart ?? this.#host.outputTimes.publishedStartTime(like, index),
|
|
545
|
+
rawBytes: raw,
|
|
544
546
|
initBytes: this.#host.segmentStore.initOf(goneKey)
|
|
545
547
|
});
|
|
546
548
|
logger.info(`[hold] ${fileName} served from the stored pieces of ${goneKey}, the output that first answered it`);
|
|
@@ -802,7 +804,9 @@ export class SegmentServing {
|
|
|
802
804
|
if (!found) {
|
|
803
805
|
continue;
|
|
804
806
|
}
|
|
805
|
-
const
|
|
807
|
+
const ownInit = session.segmentFormat.extractInit(cached ?? await readFile(found));
|
|
808
|
+
const init = ownInit && session.segmentFormat.prepareSharedInit ?
|
|
809
|
+
session.segmentFormat.prepareSharedInit(ownInit) : ownInit;
|
|
806
810
|
if (!init || init.length === 0) {
|
|
807
811
|
continue;
|
|
808
812
|
}
|
|
@@ -568,7 +568,8 @@ export class ViewerRequests {
|
|
|
568
568
|
const segments = Array.from({ length: timeline?.segmentCount ?? 0 }, (_, index) => ({
|
|
569
569
|
index,
|
|
570
570
|
startSeconds: timeline.publishedStartOf(index),
|
|
571
|
-
endSeconds: timeline.publishedStartOf(index + 1)
|
|
571
|
+
endSeconds: timeline.publishedStartOf(index + 1),
|
|
572
|
+
mediaRanges: this.#host.segmentStore.mediaRangesOf(output.outputKey, index)
|
|
572
573
|
}));
|
|
573
574
|
const sourceIndexes = [output.spec.video?.fileIndex, output.spec.audio?.fileIndex]
|
|
574
575
|
.filter((fileIndex) => Number.isInteger(fileIndex) && fileIndex >= 0);
|
|
@@ -618,6 +619,8 @@ export class ViewerRequests {
|
|
|
618
619
|
: await this.#host.quality.observedStreamMbps(output);
|
|
619
620
|
tracks.push({
|
|
620
621
|
id: output.outputKey,
|
|
622
|
+
clientRanges: viewerReading?.bufferedRanges?.[output === session ? "video" : "audio"] ??
|
|
623
|
+
viewerReading?.bufferedRanges?.media,
|
|
621
624
|
sourceIds,
|
|
622
625
|
processedSeconds: trackProgress?.processedSeconds,
|
|
623
626
|
bitsPerMediaSecond: Number.isFinite(observedMbps) && observedMbps > 0 ? observedMbps * 1_000_000 : 0,
|
|
@@ -663,7 +666,7 @@ export class ViewerRequests {
|
|
|
663
666
|
return Number.isFinite(segmentSeconds) && segmentSeconds > 0 ? segmentSeconds : 0;
|
|
664
667
|
}));
|
|
665
668
|
|
|
666
|
-
|
|
669
|
+
const input = {
|
|
667
670
|
now,
|
|
668
671
|
positionSeconds,
|
|
669
672
|
durationSeconds: session.file.durationSeconds,
|
|
@@ -675,7 +678,24 @@ export class ViewerRequests {
|
|
|
675
678
|
sources: [...sourceMeasurements.values()],
|
|
676
679
|
tracks,
|
|
677
680
|
linkReadings: measurement.link.snapshot()
|
|
678
|
-
}
|
|
681
|
+
};
|
|
682
|
+
const forecast = predictPlaybackReadiness(input);
|
|
683
|
+
if (measurement.forecastReason !== forecast.reason) {
|
|
684
|
+
measurement.forecastReason = forecast.reason;
|
|
685
|
+
logger.info(`playback readiness ${session.id} consumer=${consumerId} ${JSON.stringify({
|
|
686
|
+
...forecast,
|
|
687
|
+
positionSeconds,
|
|
688
|
+
durationSeconds: input.durationSeconds,
|
|
689
|
+
bufferLimitSeconds: input.bufferLimitSeconds,
|
|
690
|
+
lookaheadSeconds: input.lookaheadSeconds,
|
|
691
|
+
link: input.linkReadings,
|
|
692
|
+
sources: input.sources,
|
|
693
|
+
tracks: tracks.map(({ id, processedSeconds, readings, segments, readySegmentIndices }) => ({
|
|
694
|
+
id, processedSeconds, readings, segments: segments.length, prepared: readySegmentIndices.length
|
|
695
|
+
}))
|
|
696
|
+
})}`);
|
|
697
|
+
}
|
|
698
|
+
return forecast;
|
|
679
699
|
}
|
|
680
700
|
|
|
681
701
|
#rateStateFor(session, consumerId) {
|
|
@@ -687,7 +707,7 @@ export class ViewerRequests {
|
|
|
687
707
|
const id = typeof consumerId === "string" ? consumerId : "";
|
|
688
708
|
let state = byViewer.get(id);
|
|
689
709
|
if (!state) {
|
|
690
|
-
state = { link: new RateTrend(), downloads: new Map(), tracks: new Map() };
|
|
710
|
+
state = { link: new RateTrend(this.#host.lookaheadSeconds), downloads: new Map(), tracks: new Map() };
|
|
691
711
|
byViewer.set(id, state);
|
|
692
712
|
}
|
|
693
713
|
return state;
|
|
@@ -696,7 +716,7 @@ export class ViewerRequests {
|
|
|
696
716
|
#downloadRateReadings(state, serviceId, downloadSpeed, now) {
|
|
697
717
|
let trend = state.downloads.get(serviceId);
|
|
698
718
|
if (!trend) {
|
|
699
|
-
trend = new RateTrend();
|
|
719
|
+
trend = new RateTrend(this.#host.lookaheadSeconds);
|
|
700
720
|
state.downloads.set(serviceId, trend);
|
|
701
721
|
}
|
|
702
722
|
const speed = Number(downloadSpeed);
|
|
@@ -710,7 +730,7 @@ export class ViewerRequests {
|
|
|
710
730
|
const id = output.outputKey;
|
|
711
731
|
let trend = state.tracks.get(id);
|
|
712
732
|
if (!trend) {
|
|
713
|
-
trend = new RateTrend();
|
|
733
|
+
trend = new RateTrend(this.#host.lookaheadSeconds);
|
|
714
734
|
state.tracks.set(id, trend);
|
|
715
735
|
}
|
|
716
736
|
const reading = this.#host.encodeSpeedReadingOf?.(output, now) ?? null;
|