@torrent-tv/proxy 2.87.15 → 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 +7 -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 +4 -1
- package/services/server/playback-readiness.js +55 -16
- 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-start-regressions.test.js +99 -0
- package/test/segment-store.test.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
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
|
+
|
|
1
8
|
## 2.87.15
|
|
2
9
|
|
|
3
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.
|
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,
|
|
@@ -85,16 +85,24 @@ export class RateTrend {
|
|
|
85
85
|
*/
|
|
86
86
|
export function predictPlaybackReadiness(input = {}) {
|
|
87
87
|
const now = Number.isFinite(input.now) ? input.now : Date.now();
|
|
88
|
-
const
|
|
88
|
+
const tracks = Array.isArray(input.tracks) ? input.tracks : [];
|
|
89
|
+
const mediaOrigin = Math.max(0, ...tracks.map((track) =>
|
|
90
|
+
track.segments?.find(({ index }) => index === 0)?.mediaRanges?.[0]?.start ?? 0));
|
|
91
|
+
const position = Math.max(mediaOrigin, finiteNonNegative(input.positionSeconds));
|
|
89
92
|
const duration = finiteNonNegative(input.durationSeconds);
|
|
90
93
|
const remaining = Math.max(0, duration - position);
|
|
91
|
-
const
|
|
94
|
+
const measuredRanges = tracks.map((track) => Array.isArray(track.clientRanges) ?
|
|
95
|
+
normalizedRanges(track.clientRanges) : null);
|
|
96
|
+
const buffered = measuredRanges.length > 0 && measuredRanges.every(Boolean) ?
|
|
97
|
+
Math.max(0, Math.min(...measuredRanges.map((ranges) => contiguousEnd(ranges, position) - position))) :
|
|
98
|
+
finiteNonNegative(input.bufferedAheadSeconds);
|
|
92
99
|
// A loader target can be lowered after a fragment retry. Bytes already held
|
|
93
100
|
// prove a larger capacity, even when an older page reports only that target.
|
|
94
|
-
const capacity = Math.max(buffered, finiteNonNegative(input.bufferLimitSeconds)
|
|
101
|
+
const capacity = Math.max(buffered, finiteNonNegative(input.bufferLimitSeconds),
|
|
102
|
+
...measuredRanges.map((ranges) => ranges ? ranges.reduce((total, range) =>
|
|
103
|
+
total + Math.max(0, range.end - Math.max(position, range.start)), 0) : 0));
|
|
95
104
|
const reserve = Math.min(remaining, finiteNonNegative(input.reserveSeconds));
|
|
96
105
|
const lookahead = finiteNonNegative(input.lookaheadSeconds);
|
|
97
|
-
const tracks = Array.isArray(input.tracks) ? input.tracks : [];
|
|
98
106
|
const sources = new Map((Array.isArray(input.sources) ? input.sources : [])
|
|
99
107
|
.filter((source) => typeof source?.id === "string")
|
|
100
108
|
.map((source) => [source.id, source]));
|
|
@@ -114,6 +122,8 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
114
122
|
|
|
115
123
|
const trackState = tracks.map((track) => ({
|
|
116
124
|
track,
|
|
125
|
+
clientRanges: Array.isArray(track.clientRanges) ? normalizedRanges(track.clientRanges) :
|
|
126
|
+
[{ start: position, end: position + buffered }],
|
|
117
127
|
ready: new Set(Array.isArray(track.readySegmentIndices) ? track.readySegmentIndices : []),
|
|
118
128
|
curve: rateCurve(track.readings, now),
|
|
119
129
|
bitsPerMediaSecond: averageBitsPerMediaSecond(track, track.segments,
|
|
@@ -157,11 +167,13 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
157
167
|
const { track, segments } = state;
|
|
158
168
|
const measuredBitsPerMediaSecond = state.bitsPerMediaSecond;
|
|
159
169
|
for (const segment of segments) {
|
|
160
|
-
if (
|
|
170
|
+
if (coveredSeconds(state.clientRanges, Math.max(position, segment.startSeconds), segment.endSeconds) >=
|
|
171
|
+
segment.endSeconds - Math.max(position, segment.startSeconds) - Number.EPSILON * duration) {
|
|
161
172
|
continue;
|
|
162
173
|
}
|
|
163
174
|
const exactBytes = sizeOf(track.segmentSizesBytes, segment.index);
|
|
164
175
|
const segmentSeconds = segment.endSeconds - segment.startSeconds;
|
|
176
|
+
// HLS fetches the complete file when any required part is absent.
|
|
165
177
|
const sizeBits = exactBytes > 0
|
|
166
178
|
? exactBytes * 8
|
|
167
179
|
: measuredBitsPerMediaSecond * segmentSeconds;
|
|
@@ -170,9 +182,9 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
170
182
|
}
|
|
171
183
|
|
|
172
184
|
const produced = state.ready.has(segment.index);
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
185
|
+
// Global encode progress may belong to another run beyond a missing
|
|
186
|
+
// segment. Only publication proves this segment's work is complete.
|
|
187
|
+
const workStart = Math.max(position, segment.startSeconds);
|
|
176
188
|
const encodeWork = produced ? 0 : Math.max(0, segment.endSeconds - workStart);
|
|
177
189
|
if (!produced && !(encodeWork > 0)) {
|
|
178
190
|
unknownReason ??= "segment-production-delay-unavailable";
|
|
@@ -479,8 +491,8 @@ function isSafeSchedule(completions, productions, trackState, position, duration
|
|
|
479
491
|
startDelay) {
|
|
480
492
|
// Client continuity is constrained by its buffer limit; source-stall reserve
|
|
481
493
|
// is covered by the larger union of client data and output already prepared here.
|
|
482
|
-
const clientRanges = trackState.map(() =>
|
|
483
|
-
const preparedRanges = trackState.map(() =>
|
|
494
|
+
const clientRanges = trackState.map((state) => state.clientRanges.map((range) => ({ ...range })));
|
|
495
|
+
const preparedRanges = trackState.map((state) => state.clientRanges.map((range) => ({ ...range })));
|
|
484
496
|
const orderedCompletions = [...completions]
|
|
485
497
|
.filter(({ at }) => Number.isFinite(at))
|
|
486
498
|
.sort((left, right) => left.at - right.at || left.trackIndex - right.trackIndex);
|
|
@@ -502,13 +514,17 @@ function isSafeSchedule(completions, productions, trackState, position, duration
|
|
|
502
514
|
const addThrough = (time) => {
|
|
503
515
|
while (productionIndex < orderedProductions.length && orderedProductions[productionIndex].at <= time) {
|
|
504
516
|
const { trackIndex, segment } = orderedProductions[productionIndex];
|
|
505
|
-
|
|
517
|
+
for (const range of segment.mediaRanges ?? [{ start: segment.startSeconds, end: segment.endSeconds }]) {
|
|
518
|
+
addRange(preparedRanges[trackIndex], range.start, range.end);
|
|
519
|
+
}
|
|
506
520
|
productionIndex += 1;
|
|
507
521
|
}
|
|
508
522
|
while (completionIndex < orderedCompletions.length && orderedCompletions[completionIndex].at <= time) {
|
|
509
523
|
const { trackIndex, segment } = orderedCompletions[completionIndex];
|
|
510
|
-
|
|
511
|
-
|
|
524
|
+
for (const range of segment.mediaRanges ?? [{ start: segment.startSeconds, end: segment.endSeconds }]) {
|
|
525
|
+
addRange(clientRanges[trackIndex], range.start, range.end);
|
|
526
|
+
addRange(preparedRanges[trackIndex], range.start, range.end);
|
|
527
|
+
}
|
|
512
528
|
completionIndex += 1;
|
|
513
529
|
}
|
|
514
530
|
};
|
|
@@ -517,13 +533,18 @@ function isSafeSchedule(completions, productions, trackState, position, duration
|
|
|
517
533
|
if (playerPosition >= duration) {
|
|
518
534
|
return { safe: true, stockSafe: true, available: 0, neededSeconds: 0 };
|
|
519
535
|
}
|
|
520
|
-
const
|
|
521
|
-
|
|
536
|
+
const trackEnd = (index) => {
|
|
537
|
+
const last = trackState[index].segments.at(-1);
|
|
538
|
+
return last?.mediaRanges?.at(-1)?.end ?? duration;
|
|
539
|
+
};
|
|
540
|
+
const available = Math.min(capacity, ...clientRanges.map((trackRanges, index) =>
|
|
541
|
+
playerPosition >= trackEnd(index) ? Number.POSITIVE_INFINITY :
|
|
542
|
+
Math.max(0, contiguousEnd(trackRanges, playerPosition) - playerPosition)));
|
|
522
543
|
let neededSeconds = 0;
|
|
523
544
|
let stockSafe = true;
|
|
524
545
|
for (let trackIndex = 0; trackIndex < trackState.length; trackIndex += 1) {
|
|
525
546
|
const required = time <= reserveUntil[trackIndex]
|
|
526
|
-
? Math.min(reserve, Math.max(0,
|
|
547
|
+
? Math.min(reserve, Math.max(0, trackEnd(trackIndex) - playerPosition))
|
|
527
548
|
: 0;
|
|
528
549
|
const preparedAhead = Math.max(0,
|
|
529
550
|
contiguousEnd(preparedRanges[trackIndex], playerPosition) - playerPosition);
|
|
@@ -547,6 +568,9 @@ function isSafeSchedule(completions, productions, trackState, position, duration
|
|
|
547
568
|
|
|
548
569
|
const playbackEnd = startDelay + Math.max(0, duration - position);
|
|
549
570
|
const playbackBoundaries = [position + buffered,
|
|
571
|
+
...trackState.flatMap(({ clientRanges }) => clientRanges.flatMap(({ start, end }) => [start, end])),
|
|
572
|
+
...trackState.flatMap(({ segments }) => segments.flatMap((segment) =>
|
|
573
|
+
(segment.mediaRanges ?? []).flatMap(({ start, end }) => [start, end]))),
|
|
550
574
|
...trackState.flatMap(({ segments }) => segments.map(({ endSeconds }) => endSeconds))]
|
|
551
575
|
.filter((boundary) => boundary > position && boundary < duration)
|
|
552
576
|
.map((boundary) => startDelay + boundary - position);
|
|
@@ -598,6 +622,21 @@ function addRange(ranges, start, end) {
|
|
|
598
622
|
ranges.splice(0, ranges.length, ...merged);
|
|
599
623
|
}
|
|
600
624
|
|
|
625
|
+
function normalizedRanges(ranges) {
|
|
626
|
+
const result = [];
|
|
627
|
+
for (const range of ranges) {
|
|
628
|
+
if (Number.isFinite(range?.start) && Number.isFinite(range?.end) && range.end > range.start) {
|
|
629
|
+
addRange(result, range.start, range.end);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return result;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function coveredSeconds(ranges, start, end) {
|
|
636
|
+
return ranges.reduce((total, range) => total + Math.max(0,
|
|
637
|
+
Math.min(end, range.end) - Math.max(start, range.start)), 0);
|
|
638
|
+
}
|
|
639
|
+
|
|
601
640
|
function contiguousEnd(ranges, position) {
|
|
602
641
|
for (const range of ranges) {
|
|
603
642
|
if (range.start <= position && position <= range.end) {
|
|
@@ -300,6 +300,26 @@ export class PieceDiskStore {
|
|
|
300
300
|
* @param {number} [at] - Offset within the piece to start at.
|
|
301
301
|
* @returns {Promise<number>} Bytes read.
|
|
302
302
|
*/
|
|
303
|
+
hold(index) {
|
|
304
|
+
if (!this.#stored.has(index)) {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
this.#reading.set(index, (this.#reading.get(index) ?? 0) + 1);
|
|
308
|
+
let held = true;
|
|
309
|
+
return () => {
|
|
310
|
+
if (!held) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
held = false;
|
|
314
|
+
const outstanding = (this.#reading.get(index) ?? 1) - 1;
|
|
315
|
+
if (outstanding > 0) {
|
|
316
|
+
this.#reading.set(index, outstanding);
|
|
317
|
+
} else {
|
|
318
|
+
this.#reading.delete(index);
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
303
323
|
async read(index, target, at = 0) {
|
|
304
324
|
if (!this.#stored.has(index)) {
|
|
305
325
|
throw new Error(`Piece ${index} is not on disk.`);
|
|
@@ -338,6 +358,9 @@ export class PieceDiskStore {
|
|
|
338
358
|
* @returns {void}
|
|
339
359
|
*/
|
|
340
360
|
forget(index) {
|
|
361
|
+
if (this.#reading.has(index)) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
341
364
|
const length = this.#stored.get(index);
|
|
342
365
|
if (length === undefined) {
|
|
343
366
|
return;
|
|
@@ -1407,8 +1407,12 @@ export class SharedPieceStore {
|
|
|
1407
1407
|
}
|
|
1408
1408
|
}
|
|
1409
1409
|
|
|
1410
|
-
|
|
1410
|
+
// Hold the disk bytes before waiting for memory: another reader may move
|
|
1411
|
+
// the eviction boundary while this read is waiting for a slot.
|
|
1412
|
+
const releaseDisk = this.#disk.hold?.(index) ?? (() => {});
|
|
1413
|
+
let release = null;
|
|
1411
1414
|
try {
|
|
1415
|
+
release = await this.#claimSlot();
|
|
1412
1416
|
// Another caller may have brought it back while this one waited for a
|
|
1413
1417
|
// slot. Registering a second buffer for the same piece would leave
|
|
1414
1418
|
// whoever holds the first reading memory nothing evicts.
|
|
@@ -1434,7 +1438,8 @@ export class SharedPieceStore {
|
|
|
1434
1438
|
this.#noteRevival(index);
|
|
1435
1439
|
return target;
|
|
1436
1440
|
} finally {
|
|
1437
|
-
release();
|
|
1441
|
+
release?.();
|
|
1442
|
+
releaseDisk();
|
|
1438
1443
|
}
|
|
1439
1444
|
}
|
|
1440
1445
|
|
|
@@ -83,6 +83,7 @@ export class SegmentStore {
|
|
|
83
83
|
|
|
84
84
|
/** Output key → what its directory holds. @type {Map<string, HeldContents>} */
|
|
85
85
|
#held = new Map();
|
|
86
|
+
#mediaRanges = new Map();
|
|
86
87
|
|
|
87
88
|
/** Output key → how to read its file names. @type {Map<string, object>} */
|
|
88
89
|
#formats = new Map();
|
|
@@ -431,6 +432,17 @@ export class SegmentStore {
|
|
|
431
432
|
return null;
|
|
432
433
|
}
|
|
433
434
|
const dir = path.join(this.#root, directoryNameFor(key));
|
|
435
|
+
const index = format?.segmentIndexFromName?.(served) ?? -1;
|
|
436
|
+
let mediaRanges = null;
|
|
437
|
+
if (format?.readMediaRanges && index >= 0) {
|
|
438
|
+
try {
|
|
439
|
+
mediaRanges = format.readMediaRanges(readFileSync(path.join(dir, makingName)));
|
|
440
|
+
if (mediaRanges.length === 0) throw new Error("The closed file contains no playable media intervals.");
|
|
441
|
+
} catch (error) {
|
|
442
|
+
this.#logger?.warn?.(`segment store: could not read media coverage of ${makingName}: ${error.message}`);
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
434
446
|
try {
|
|
435
447
|
renameSync(path.join(dir, makingName), path.join(dir, served));
|
|
436
448
|
} catch (error) {
|
|
@@ -445,8 +457,12 @@ export class SegmentStore {
|
|
|
445
457
|
}
|
|
446
458
|
// What the directory holds has changed, so the memory of it is stale.
|
|
447
459
|
this.#held.delete(key);
|
|
448
|
-
const index = format?.segmentIndexFromName?.(served) ?? -1;
|
|
449
460
|
if (index >= 0) {
|
|
461
|
+
if (mediaRanges) {
|
|
462
|
+
const byIndex = this.#mediaRanges.get(key) ?? new Map();
|
|
463
|
+
byIndex.set(index, mediaRanges);
|
|
464
|
+
this.#mediaRanges.set(key, byIndex);
|
|
465
|
+
}
|
|
450
466
|
this.announce(key, index);
|
|
451
467
|
for (const listener of this.#publishedListeners) {
|
|
452
468
|
listener(key, index);
|
|
@@ -455,6 +471,24 @@ export class SegmentStore {
|
|
|
455
471
|
return served;
|
|
456
472
|
}
|
|
457
473
|
|
|
474
|
+
mediaRangesOf(key, index) {
|
|
475
|
+
const known = this.#mediaRanges.get(key)?.get(index);
|
|
476
|
+
if (known) return known;
|
|
477
|
+
const format = this.#formats.get(key);
|
|
478
|
+
if (!format?.readMediaRanges) return undefined;
|
|
479
|
+
const filePath = this.pathOf(key, index);
|
|
480
|
+
if (!filePath) return undefined;
|
|
481
|
+
try {
|
|
482
|
+
const ranges = format.readMediaRanges(readFileSync(filePath));
|
|
483
|
+
const byIndex = this.#mediaRanges.get(key) ?? new Map();
|
|
484
|
+
byIndex.set(index, ranges);
|
|
485
|
+
this.#mediaRanges.set(key, byIndex);
|
|
486
|
+
return ranges;
|
|
487
|
+
} catch {
|
|
488
|
+
return [];
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
458
492
|
/**
|
|
459
493
|
* The init segment kept for this output, or null until one was read whole.
|
|
460
494
|
*
|
|
@@ -842,6 +876,7 @@ export class SegmentStore {
|
|
|
842
876
|
}
|
|
843
877
|
this.#held.delete(key);
|
|
844
878
|
this.#formats.delete(key);
|
|
879
|
+
this.#mediaRanges.delete(key);
|
|
845
880
|
this.#touched.delete(key);
|
|
846
881
|
this.#inits.delete(key);
|
|
847
882
|
this.#logger.info(`segment-store dropped ${directoryNameFor(key)} (${because})`);
|
|
@@ -391,6 +391,7 @@ export class Viewer {
|
|
|
391
391
|
linkSampleAt = null,
|
|
392
392
|
linkSampleAgeMs = null,
|
|
393
393
|
bufferedAheadSec,
|
|
394
|
+
bufferedRanges,
|
|
394
395
|
bufferLimitSeconds,
|
|
395
396
|
positionSeconds = null,
|
|
396
397
|
playing,
|
|
@@ -434,6 +435,11 @@ export class Viewer {
|
|
|
434
435
|
: now
|
|
435
436
|
: this.netReport?.linkSampleMeasuredAt ?? null,
|
|
436
437
|
bufferedAheadSec: held,
|
|
438
|
+
bufferedRanges: bufferedRanges && typeof bufferedRanges === "object" ?
|
|
439
|
+
Object.fromEntries(["video", "audio", "media"].filter((key) => Array.isArray(bufferedRanges[key]))
|
|
440
|
+
.map((key) => [key, bufferedRanges[key].slice(0, 256).filter((range) =>
|
|
441
|
+
Number.isFinite(range?.start) && Number.isFinite(range?.end) && range.start >= 0 && range.end > range.start)
|
|
442
|
+
.map(({ start, end }) => ({ start, end }))])) : null,
|
|
437
443
|
bufferLimitSeconds: Number.isFinite(bufferLimitSeconds) && bufferLimitSeconds > 0
|
|
438
444
|
? bufferLimitSeconds
|
|
439
445
|
: this.netReport?.bufferLimitSeconds ?? null,
|
|
@@ -44,7 +44,7 @@ class FakeProcess extends EventEmitter {
|
|
|
44
44
|
this.signals.push(signal);
|
|
45
45
|
// A real ffmpeg dies a moment later; the test wants the bookkeeping to
|
|
46
46
|
// happen where it can be seen, so the exit is immediate and synchronous.
|
|
47
|
-
this.emit("
|
|
47
|
+
this.emit("close", null, signal);
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
@@ -172,7 +172,7 @@ test("how many encoders are running is answered by the one thing that makes them
|
|
|
172
172
|
assert.equal(made.runningCount(), 2, "two processes on one output are two encoders, not one");
|
|
173
173
|
|
|
174
174
|
// And a run that has ended is forgotten as it ends, not at the next pass.
|
|
175
|
-
processes.get(first).emit("
|
|
175
|
+
processes.get(first).emit("close", 0, null);
|
|
176
176
|
assert.equal(made.runningCount(), 1);
|
|
177
177
|
|
|
178
178
|
// A SUSPENDED run is stopped where it stands and is using nothing, so a cost
|
|
@@ -372,7 +372,7 @@ test("every ending is counted, and our own kill is not counted as normal", () =>
|
|
|
372
372
|
wants(made, [{ from: 100, to: 130 }]);
|
|
373
373
|
made.reconcile();
|
|
374
374
|
const run = made.runsOn(PICTURE)[0];
|
|
375
|
-
processes.get(run).emit("
|
|
375
|
+
processes.get(run).emit("close", 255, null);
|
|
376
376
|
wants(made, []);
|
|
377
377
|
made.reconcile();
|
|
378
378
|
const tally = made.endings();
|
package/test/encode-run.test.js
CHANGED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { fmp4Format } from "../services/encode/segment-formats/fmp4.js";
|
|
4
|
+
import { readPresentationRanges, walkBoxes } from "../services/encode/segment-formats/mp4-boxes.js";
|
|
5
|
+
|
|
6
|
+
function box(type, body) {
|
|
7
|
+
const header = Buffer.alloc(8);
|
|
8
|
+
header.writeUInt32BE(body.length + 8);
|
|
9
|
+
header.write(type, 4);
|
|
10
|
+
return Buffer.concat([header, body]);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function init(emptyMs, mediaTime) {
|
|
14
|
+
const mvhd = Buffer.alloc(24);
|
|
15
|
+
mvhd.writeUInt32BE(1000, 12);
|
|
16
|
+
const tkhd = Buffer.alloc(20);
|
|
17
|
+
tkhd.writeUInt32BE(1, 12);
|
|
18
|
+
const mdhd = Buffer.alloc(24);
|
|
19
|
+
mdhd.writeUInt32BE(24000, 12);
|
|
20
|
+
const elst = Buffer.alloc(32);
|
|
21
|
+
elst.writeUInt32BE(2, 4);
|
|
22
|
+
elst.writeUInt32BE(emptyMs, 8);
|
|
23
|
+
elst.writeInt32BE(-1, 12);
|
|
24
|
+
elst.writeUInt32BE(1000, 20);
|
|
25
|
+
elst.writeInt32BE(mediaTime, 24);
|
|
26
|
+
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))
|
|
28
|
+
]))]));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function fragment(decodeTime, composition) {
|
|
32
|
+
const tfhd = Buffer.alloc(12);
|
|
33
|
+
tfhd.writeUIntBE(8, 1, 3);
|
|
34
|
+
tfhd.writeUInt32BE(1, 4);
|
|
35
|
+
tfhd.writeUInt32BE(1000, 8);
|
|
36
|
+
const tfdt = Buffer.alloc(12);
|
|
37
|
+
tfdt[0] = 1;
|
|
38
|
+
tfdt.writeBigUInt64BE(BigInt(decodeTime), 4);
|
|
39
|
+
const trun = Buffer.alloc(12);
|
|
40
|
+
trun.writeUIntBE(0x800, 1, 3);
|
|
41
|
+
trun.writeUInt32BE(1, 4);
|
|
42
|
+
trun.writeUInt32BE(composition, 8);
|
|
43
|
+
return box("moof", box("traf", Buffer.concat([
|
|
44
|
+
box("tfhd", tfhd), box("tfdt", tfdt), box("trun", trun)
|
|
45
|
+
])));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test("a shared init preserves presentation times without adding composition delay twice", () => {
|
|
49
|
+
const shared = fmp4Format.prepareSharedInit(init(83, 2000));
|
|
50
|
+
const own = init(4125, 3000);
|
|
51
|
+
const media = Buffer.concat([fragment(0, 3000), fragment(48000, 3000)]);
|
|
52
|
+
const raw = Buffer.concat([own, media]);
|
|
53
|
+
const prepared = fmp4Format.prepareSegmentBytes(media, {
|
|
54
|
+
startSeconds: 4.125, initBytes: shared, rawBytes: raw
|
|
55
|
+
});
|
|
56
|
+
assert.deepEqual(readPresentationRanges(Buffer.concat([shared, prepared])), readPresentationRanges(raw));
|
|
57
|
+
const times = [];
|
|
58
|
+
walkBoxes(prepared, (type, start) => {
|
|
59
|
+
if (type === "tfdt") times.push(Number(prepared.readBigUInt64BE(start + 4)));
|
|
60
|
+
});
|
|
61
|
+
assert.deepEqual(times, [98000, 146000]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a shared init first read after a seek also supports returning to the beginning", () => {
|
|
65
|
+
const shared = fmp4Format.prepareSharedInit(init(304083, 2000));
|
|
66
|
+
const media = fragment(0, 2000);
|
|
67
|
+
const raw = Buffer.concat([init(83, 2000), media]);
|
|
68
|
+
const prepared = fmp4Format.prepareSegmentBytes(media, {
|
|
69
|
+
startSeconds: 0.083, initBytes: shared, rawBytes: raw
|
|
70
|
+
});
|
|
71
|
+
assert.deepEqual(readPresentationRanges(Buffer.concat([shared, prepared])), readPresentationRanges(raw));
|
|
72
|
+
});
|
|
@@ -18,6 +18,26 @@ import { PieceDiskStore } from "../services/storage/piece-store/piece-disk-store
|
|
|
18
18
|
|
|
19
19
|
const PIECE = 4096;
|
|
20
20
|
|
|
21
|
+
test("bytes held before a memory wait cannot be forgotten or evicted", async () => {
|
|
22
|
+
const { store } = await makeStore(PIECE);
|
|
23
|
+
try {
|
|
24
|
+
await store.write(0, Buffer.alloc(PIECE, 7));
|
|
25
|
+
const release = store.hold(0);
|
|
26
|
+
store.forget(0);
|
|
27
|
+
store.forgetBehind([1]);
|
|
28
|
+
await store.write(1, Buffer.alloc(PIECE, 8));
|
|
29
|
+
const target = Buffer.alloc(PIECE);
|
|
30
|
+
assert.equal(await store.read(0, target), PIECE);
|
|
31
|
+
assert.equal(target[0], 7);
|
|
32
|
+
release();
|
|
33
|
+
release();
|
|
34
|
+
store.forget(0);
|
|
35
|
+
assert.equal(store.has(0), false);
|
|
36
|
+
} finally {
|
|
37
|
+
await store.destroy();
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
21
41
|
/**
|
|
22
42
|
* @param {number | null} allowanceBytes
|
|
23
43
|
* @returns {Promise<{ store: PieceDiskStore, directory: string, clock: { at: number } }>}
|
|
@@ -22,6 +22,7 @@ const CHUNK = 1024;
|
|
|
22
22
|
*/
|
|
23
23
|
function makeDisk({ failWrite = false, failRead = false, holdWrites = false } = {}) {
|
|
24
24
|
const stored = new Map();
|
|
25
|
+
const reading = new Map();
|
|
25
26
|
/** @type {Array<() => void>} */
|
|
26
27
|
const held = [];
|
|
27
28
|
return {
|
|
@@ -61,6 +62,15 @@ function makeDisk({ failWrite = false, failRead = false, holdWrites = false } =
|
|
|
61
62
|
has(index) {
|
|
62
63
|
return stored.has(index);
|
|
63
64
|
},
|
|
65
|
+
hold(index) {
|
|
66
|
+
if (!stored.has(index)) return null;
|
|
67
|
+
reading.set(index, (reading.get(index) ?? 0) + 1);
|
|
68
|
+
return () => {
|
|
69
|
+
const count = reading.get(index) - 1;
|
|
70
|
+
if (count > 0) reading.set(index, count);
|
|
71
|
+
else reading.delete(index);
|
|
72
|
+
};
|
|
73
|
+
},
|
|
64
74
|
async write(index, bytes) {
|
|
65
75
|
if (holdWrites) {
|
|
66
76
|
await new Promise((resolve) => held.push(resolve));
|
|
@@ -82,7 +92,7 @@ function makeDisk({ failWrite = false, failRead = false, holdWrites = false } =
|
|
|
82
92
|
return bytes.length;
|
|
83
93
|
},
|
|
84
94
|
forget(index) {
|
|
85
|
-
stored.delete(index);
|
|
95
|
+
if (!reading.has(index)) stored.delete(index);
|
|
86
96
|
},
|
|
87
97
|
async close() {},
|
|
88
98
|
async destroy() {
|
|
@@ -123,6 +133,21 @@ const get = (store, index) =>
|
|
|
123
133
|
store.get(index, undefined, (error, bytes) => (error ? reject(error) : resolve(bytes)))
|
|
124
134
|
);
|
|
125
135
|
|
|
136
|
+
test("a revival holds disk bytes across the asynchronous memory reservation", async () => {
|
|
137
|
+
const { store, disk } = makeStore();
|
|
138
|
+
disk.stored.set(0, piece(0));
|
|
139
|
+
try {
|
|
140
|
+
const revival = store.reside(0);
|
|
141
|
+
disk.forget(0);
|
|
142
|
+
const located = await revival;
|
|
143
|
+
assert.deepEqual(Buffer.from(located.buffer, located.offset, located.length), piece(0));
|
|
144
|
+
disk.forget(0);
|
|
145
|
+
assert.equal(disk.has(0), false);
|
|
146
|
+
} finally {
|
|
147
|
+
await new Promise((resolve) => store.destroy(resolve));
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
126
151
|
/**
|
|
127
152
|
* Wait until a condition holds, rather than for a chosen interval — a test that
|
|
128
153
|
* sleeps samples, it does not check (roadmap item 54).
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
import { EncodeRun } from "../services/encode/EncodeRun.js";
|
|
5
|
+
import { SoftwareEncoder } from "../services/encode/SoftwareEncoder.js";
|
|
6
|
+
import { predictPlaybackReadiness } from "../services/server/playback-readiness.js";
|
|
7
|
+
|
|
8
|
+
function encode() {
|
|
9
|
+
const child = new EventEmitter();
|
|
10
|
+
child.stdio = [null, new EventEmitter(), new EventEmitter(), new EventEmitter()];
|
|
11
|
+
child.stderr = child.stdio[2];
|
|
12
|
+
child.kill = () => {};
|
|
13
|
+
const published = [];
|
|
14
|
+
const ended = [];
|
|
15
|
+
const run = new EncodeRun({
|
|
16
|
+
address: "fake", from: 0, to: 1, encoder: new SoftwareEncoder(),
|
|
17
|
+
buildArgs: () => [], spawn: () => child, logger: { info() {}, warn() {} },
|
|
18
|
+
indexOfName: (name) => Number(name),
|
|
19
|
+
onClosed: (name) => { published.push(name); return name; },
|
|
20
|
+
onEnded: (value) => ended.push(value)
|
|
21
|
+
});
|
|
22
|
+
return { child, run, published, ended };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test("a clean short input never publishes its flushed tail", () => {
|
|
26
|
+
const { child, published, ended } = encode();
|
|
27
|
+
child.stderr.emit("data", "Stream ends prematurely at 1048576");
|
|
28
|
+
child.stdio[3].emit("data", "0\n");
|
|
29
|
+
child.emit("close", 0, null);
|
|
30
|
+
assert.deepEqual(published, []);
|
|
31
|
+
assert.equal(ended[0].ending, "short");
|
|
32
|
+
assert.equal(ended[0].reached, -1);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("completed cuts survive a failure but its last flushed file does not", () => {
|
|
36
|
+
const { child, published } = encode();
|
|
37
|
+
child.stdio[3].emit("data", "0\n");
|
|
38
|
+
child.stderr.emit("data", "Stream ends prematurely at 1048576");
|
|
39
|
+
child.stdio[3].emit("data", "1\n");
|
|
40
|
+
child.emit("close", 0, null);
|
|
41
|
+
assert.deepEqual(published, ["0"]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("normal completion drains the filename pipe before publishing the tail", () => {
|
|
45
|
+
const { child, published, ended } = encode();
|
|
46
|
+
child.emit("exit", 0, null);
|
|
47
|
+
assert.deepEqual(ended, []);
|
|
48
|
+
child.stdio[3].emit("data", "0\n1\n");
|
|
49
|
+
child.emit("close", 0, null);
|
|
50
|
+
assert.deepEqual(published, ["0", "1"]);
|
|
51
|
+
assert.equal(ended[0].ending, "complete");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("stopping retains an earlier closed cut and rejects the signal-flushed tail", () => {
|
|
55
|
+
const { child, run, published } = encode();
|
|
56
|
+
child.stdio[3].emit("data", "0\n");
|
|
57
|
+
run.stop("a different range is needed");
|
|
58
|
+
child.stdio[3].emit("data", "1\n");
|
|
59
|
+
child.emit("close", null, "SIGTERM");
|
|
60
|
+
assert.deepEqual(published, ["0"]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
function state(clientRanges) {
|
|
64
|
+
return {
|
|
65
|
+
now: 10_000, positionSeconds: 0, durationSeconds: 8,
|
|
66
|
+
bufferedAheadSeconds: 8, bufferLimitSeconds: 8, reserveSeconds: 0, lookaheadSeconds: 8,
|
|
67
|
+
sources: [{ id: "source", complete: true }],
|
|
68
|
+
linkReadings: [{ at: 10_000, value: 80 }],
|
|
69
|
+
tracks: [{ id: "video", sourceIds: ["source"], processedSeconds: 8,
|
|
70
|
+
bitsPerMediaSecond: 80, readings: [{ at: 10_000, value: 2 }],
|
|
71
|
+
readySegmentIndices: [0, 1], segmentSizesBytes: new Map([[0, 40], [1, 40]]),
|
|
72
|
+
clientRanges, segments: [{ index: 0, startSeconds: 0, endSeconds: 4 },
|
|
73
|
+
{ index: 1, startSeconds: 4, endSeconds: 8 }] }]
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
test("future browser ranges are retained rather than transferred again", () => {
|
|
78
|
+
const forecast = predictPlaybackReadiness(state([{ start: 4, end: 8 }]));
|
|
79
|
+
assert.equal(forecast.ready, false);
|
|
80
|
+
assert.equal(forecast.reason, "minimum-safe-delay");
|
|
81
|
+
assert.ok(Math.abs(forecast.delaySeconds - 4) < 1e-8);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("a real media hole is not hidden by the scalar buffer or ready file count", () => {
|
|
85
|
+
const input = state([{ start: 0, end: 3 }, { start: 4, end: 8 }]);
|
|
86
|
+
input.tracks[0].segments[0].mediaRanges = [{ start: 0, end: 3 }];
|
|
87
|
+
const forecast = predictPlaybackReadiness(input);
|
|
88
|
+
assert.equal(forecast.ready, false);
|
|
89
|
+
assert.equal(forecast.bufferedSeconds, 3);
|
|
90
|
+
assert.equal(forecast.reason, "no-safe-start-found");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("missing work before global progress can still be produced", () => {
|
|
94
|
+
const input = state([]);
|
|
95
|
+
input.tracks[0].readySegmentIndices = [1];
|
|
96
|
+
const forecast = predictPlaybackReadiness(input);
|
|
97
|
+
assert.equal(forecast.reason, "minimum-safe-delay");
|
|
98
|
+
assert.ok(Number.isFinite(forecast.delaySeconds));
|
|
99
|
+
});
|
|
@@ -113,7 +113,7 @@ test("a piece still being written is not a piece, and its name says so", (t) =>
|
|
|
113
113
|
assert.equal(store.pathOf(KEY, 1), null);
|
|
114
114
|
|
|
115
115
|
// And closing it is one rename, after which it is servable.
|
|
116
|
-
assert.equal(store.publish(KEY, "making-0-00001.mp4", fmp4Format), "segment-00001.mp4");
|
|
116
|
+
assert.equal(store.publish(KEY, "making-0-00001.mp4", { ...fmp4Format, readMediaRanges: undefined }), "segment-00001.mp4");
|
|
117
117
|
assert.equal(store.isClosed(KEY, 1), true);
|
|
118
118
|
});
|
|
119
119
|
|