@torrent-tv/proxy 2.25.0 → 2.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
## 2.25.1
|
|
2
|
+
|
|
3
|
+
- **Fix**: Picture and sound are back in step. Two releases in a row moved a segment's stamp toward the playlist — 2.24.1 per session, 2.25.0 by one offset for the whole family — and both desynced playback in the field the same day. The reason is what the first segment of a run is: it is not CUT at all, it begins where ffmpeg's seek landed, and the picture must land on a keyframe while the sound needs none, so after every restart the two runs genuinely begin at different real times and the whole run carries that difference (measured: the sound's #292 began at 1587.892 s and #293 at 1592.692 s, one segment apart, the run shifted 2.5 s from the grid). Labelling each track with its own true time is what keeps them together in real time; a segment is stamped with its own start again, as it was for weeks before 2.24.1. What stays from those releases is the part that was right: one published timeline per family, and a warning when a piece lands further from the playlist than a player will bridge.
|
|
4
|
+
- **Chore**: The run's state now answers the questions its process handle used to be asked. Ten sites that re-derived "is this run alive" from a child-process handle, and every read of "is it suspended", now read the state machine shipped in 2.23.0; the `encoderPaused` field is gone. The two places that ask about a NAMED process — the predecessor a restart is replacing, and a deliberate stop — still ask the OS, which remains the authority on whether a pid exists.
|
|
5
|
+
|
|
1
6
|
## 2.25.0
|
|
2
7
|
|
|
3
8
|
- **Fix**: Picture and sound drifted apart after a seek, by exactly the amount the grid had been corrected. 2.24.1 made every segment stamp itself against the playlist its own session published — but each session froze that playlist at its own creation, and a soundtrack or a quality step is created later than the picture it accompanies, so it froze a table that had since been corrected. Two members of one family then stated the same moment differently, and the corrections measured on the field file are 0.6-2.9 s. A family now publishes ONE timeline: a session created inside a family takes its base's published table verbatim and writes its own playlist from it, while the live table goes on being corrected for cutting, which is what keeps a re-encoded step aligned with the copy it joins.
|
package/package.json
CHANGED
|
@@ -20,7 +20,13 @@ import { logger } from "../utils/logger.js";
|
|
|
20
20
|
import { readKeyframeIndex } from "./container-index/index.js";
|
|
21
21
|
import { readMachineState, readProcessCpuSeconds, readProxyCpuSeconds, readSystemCpu, shareOfMachine } from "./host-load.js";
|
|
22
22
|
import { speedFromReadings } from "./encoder-readings.js";
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
ENCODE_RUN_EVENT,
|
|
25
|
+
ENCODE_RUN_STATE,
|
|
26
|
+
INITIAL_RUN_STATE,
|
|
27
|
+
nextState,
|
|
28
|
+
processCanBeSignalled
|
|
29
|
+
} from "./encode-run-state.js";
|
|
24
30
|
import { ENCODE_EXIT, classifyEncodeExit } from "./encode-exit.js";
|
|
25
31
|
|
|
26
32
|
/** Own package version, stamped onto session-start log lines. */
|
|
@@ -1817,7 +1823,7 @@ export class HlsSessionManager {
|
|
|
1817
1823
|
// `encode-run-state.js`. Written at every event today and read by nothing
|
|
1818
1824
|
// yet: a refused pair in the log is the model disagreeing with reality,
|
|
1819
1825
|
// and that disagreement is the measurement this release exists to take.
|
|
1820
|
-
// The fields it
|
|
1826
|
+
// The fields it replaces — `state`, `progress.state`
|
|
1821
1827
|
// and the repeated liveness checks — keep their current writes meanwhile.
|
|
1822
1828
|
runState: INITIAL_RUN_STATE,
|
|
1823
1829
|
lastError: "",
|
|
@@ -1952,7 +1958,6 @@ export class HlsSessionManager {
|
|
|
1952
1958
|
// encoder is currently suspended for running too far past it.
|
|
1953
1959
|
// See #enforceLookAhead.
|
|
1954
1960
|
lastRequestedSegment: null,
|
|
1955
|
-
encoderPaused: false,
|
|
1956
1961
|
encoderPauseUnsupported: false,
|
|
1957
1962
|
seekFirstFarAt: 0,
|
|
1958
1963
|
// Circuit breaker: consecutive FAST failures (see SEEK_FAST_FAIL_MS) at
|
|
@@ -2744,7 +2749,7 @@ export class HlsSessionManager {
|
|
|
2744
2749
|
if (aheadSeconds === null) {
|
|
2745
2750
|
// The segment the viewer needs does not exist. Whatever else is on disk,
|
|
2746
2751
|
// this encoder has work to do right now.
|
|
2747
|
-
if (session.
|
|
2752
|
+
if (session.runState === ENCODE_RUN_STATE.SUSPENDED && this.#resumeEncoder(session, "the viewer needs a segment nobody has made")) {
|
|
2748
2753
|
this.#transitionRun(session, ENCODE_RUN_EVENT.RESUME_ORDERED);
|
|
2749
2754
|
}
|
|
2750
2755
|
return;
|
|
@@ -2772,7 +2777,7 @@ export class HlsSessionManager {
|
|
|
2772
2777
|
);
|
|
2773
2778
|
}
|
|
2774
2779
|
|
|
2775
|
-
if (
|
|
2780
|
+
if (session.runState !== ENCODE_RUN_STATE.SUSPENDED && aheadSeconds > LOOKAHEAD_PAUSE_SECONDS) {
|
|
2776
2781
|
// The decision names what it was taken on. Suspending the encoder stops
|
|
2777
2782
|
// the only thing that reads the input, so a wrong reading here stops the
|
|
2778
2783
|
// download too — measured 2026-08-06: the log said "135s ahead" while
|
|
@@ -2786,7 +2791,7 @@ export class HlsSessionManager {
|
|
|
2786
2791
|
`(viewer at #${viewerSegment}, unbroken through #${reading.lastCovered}, ` +
|
|
2787
2792
|
`${reading.total} segment file(s) present)`
|
|
2788
2793
|
);
|
|
2789
|
-
} else if (session.
|
|
2794
|
+
} else if (session.runState === ENCODE_RUN_STATE.SUSPENDED && aheadSeconds <= LOOKAHEAD_RESUME_SECONDS) {
|
|
2790
2795
|
if (this.#resumeEncoder(session, `${Math.round(aheadSeconds)}s ahead of the viewer`)) {
|
|
2791
2796
|
this.#transitionRun(session, ENCODE_RUN_EVENT.RESUME_ORDERED);
|
|
2792
2797
|
}
|
|
@@ -2929,7 +2934,7 @@ export class HlsSessionManager {
|
|
|
2929
2934
|
#pauseEncoder(session, reason) {
|
|
2930
2935
|
// Any pair spanning this would count a stopped encoder as slow.
|
|
2931
2936
|
session.learnSample = null;
|
|
2932
|
-
if (session.
|
|
2937
|
+
if (session.runState === ENCODE_RUN_STATE.SUSPENDED || session.encoderPauseUnsupported || !session.ffmpeg?.pid) {
|
|
2933
2938
|
return;
|
|
2934
2939
|
}
|
|
2935
2940
|
try {
|
|
@@ -2942,7 +2947,6 @@ export class HlsSessionManager {
|
|
|
2942
2947
|
);
|
|
2943
2948
|
return;
|
|
2944
2949
|
}
|
|
2945
|
-
session.encoderPaused = true;
|
|
2946
2950
|
this.#transitionRun(session, ENCODE_RUN_EVENT.SUSPEND_ORDERED);
|
|
2947
2951
|
logger.info(
|
|
2948
2952
|
`transcode ${session.id} encoder suspended — ${reason} ` +
|
|
@@ -2965,7 +2969,7 @@ export class HlsSessionManager {
|
|
|
2965
2969
|
#resumeEncoder(session, reason) {
|
|
2966
2970
|
// Any pair spanning this would count a stopped encoder as slow.
|
|
2967
2971
|
session.learnSample = null;
|
|
2968
|
-
if (
|
|
2972
|
+
if (session.runState !== ENCODE_RUN_STATE.SUSPENDED || !session.ffmpeg?.pid) {
|
|
2969
2973
|
return false;
|
|
2970
2974
|
}
|
|
2971
2975
|
let continued = true;
|
|
@@ -2977,7 +2981,6 @@ export class HlsSessionManager {
|
|
|
2977
2981
|
// stops a dead run being reported as producing again.
|
|
2978
2982
|
continued = false;
|
|
2979
2983
|
}
|
|
2980
|
-
session.encoderPaused = false;
|
|
2981
2984
|
// Two records of one moment must not contradict each other: a line saying
|
|
2982
2985
|
// the encoder resumed, beside a return value saying nothing was resumed, is
|
|
2983
2986
|
// the sort of pair that costs an hour of reading a field log.
|
|
@@ -3080,9 +3083,9 @@ export class HlsSessionManager {
|
|
|
3080
3083
|
|
|
3081
3084
|
async #reportHostLoad() {
|
|
3082
3085
|
const encoding = [...this.sessionsById.values()].filter(
|
|
3083
|
-
(session) => session?.
|
|
3086
|
+
(session) => processCanBeSignalled(session?.runState) && session.state !== "disposed"
|
|
3084
3087
|
);
|
|
3085
|
-
const runningNow = encoding.filter((session) => session.
|
|
3088
|
+
const runningNow = encoding.filter((session) => session.runState !== ENCODE_RUN_STATE.SUSPENDED);
|
|
3086
3089
|
if (runningNow.length === 0) {
|
|
3087
3090
|
// No encoder is RUNNING. A suspended one costs nothing, and counting it
|
|
3088
3091
|
// as work meant this was never reached: measured 2026-08-15, four minutes
|
|
@@ -3156,7 +3159,7 @@ export class HlsSessionManager {
|
|
|
3156
3159
|
// the addon host actually were (2026-08-15: `ffmpeg=0% system=24%`, both
|
|
3157
3160
|
// encoders suspended, and the speed beside it a stale figure from before
|
|
3158
3161
|
// they stopped).
|
|
3159
|
-
const suspended = encoding.filter((session) => session.
|
|
3162
|
+
const suspended = encoding.filter((session) => session.runState === ENCODE_RUN_STATE.SUSPENDED).length;
|
|
3160
3163
|
const running = encoding.length - suspended;
|
|
3161
3164
|
const machine = await readMachineState();
|
|
3162
3165
|
const asPercent = (value) => (value === null ? "n/a" : `${Math.round(value * 100)}%`);
|
|
@@ -3753,7 +3756,6 @@ export class HlsSessionManager {
|
|
|
3753
3756
|
// judged finished — see getFileStream.
|
|
3754
3757
|
session.usesExplicitCuts = Boolean(cutTimes && cutTimes.length > 0);
|
|
3755
3758
|
session.encodeStartIndex = safeIndex;
|
|
3756
|
-
session.encoderPaused = false;
|
|
3757
3759
|
session.pendingRestartIndex = -1;
|
|
3758
3760
|
session.lastRestartAt = Date.now();
|
|
3759
3761
|
session.state = session.state === "disposed" ? "disposed" : "starting";
|
|
@@ -4191,7 +4193,7 @@ export class HlsSessionManager {
|
|
|
4191
4193
|
}
|
|
4192
4194
|
// Nothing is encoding: a rung the viewer has switched away from is left
|
|
4193
4195
|
// exactly so, and its held requests must not bring its encoder back.
|
|
4194
|
-
if (
|
|
4196
|
+
if (!processCanBeSignalled(session.runState)) {
|
|
4195
4197
|
return;
|
|
4196
4198
|
}
|
|
4197
4199
|
// A seek already settling is about to move the encoder to where the VIEWER
|
|
@@ -4375,7 +4377,7 @@ export class HlsSessionManager {
|
|
|
4375
4377
|
// "already covered", so nothing could ever restart it. Measured 2026-08-04:
|
|
4376
4378
|
// one ffmpeg failure turned into a session that answered 500 to every
|
|
4377
4379
|
// segment for as long as the viewer kept trying.
|
|
4378
|
-
const runIsAlive =
|
|
4380
|
+
const runIsAlive = processCanBeSignalled(session.runState);
|
|
4379
4381
|
if (runIsAlive && index >= head && index <= currentSeg + MAX_LOOKAHEAD_SEGMENTS) {
|
|
4380
4382
|
logger.info(
|
|
4381
4383
|
`transcode ${session.id} seek to ${positionSeconds.toFixed(1)}s (#${index}) ` +
|
|
@@ -4429,7 +4431,7 @@ export class HlsSessionManager {
|
|
|
4429
4431
|
// ten seconds, each killing a run that was encoding #865. The guard below
|
|
4430
4432
|
// did not catch it — it only decides whether to let the current run finish,
|
|
4431
4433
|
// not whether a new run is needed at all.
|
|
4432
|
-
if (target === session.encodeStartIndex &&
|
|
4434
|
+
if (target === session.encodeStartIndex && processCanBeSignalled(session.runState)) {
|
|
4433
4435
|
logger.info(
|
|
4434
4436
|
`transcode ${session.id} seek #${target} ignored — the current run already starts there`
|
|
4435
4437
|
);
|
|
@@ -4457,7 +4459,7 @@ export class HlsSessionManager {
|
|
|
4457
4459
|
// Holding it was also expensive in the other direction: a genuine second
|
|
4458
4460
|
// seek could be delayed by the whole grace.
|
|
4459
4461
|
const producedThisRun = this.#producedSecondsThisRun(session);
|
|
4460
|
-
const runIsAlive =
|
|
4462
|
+
const runIsAlive = processCanBeSignalled(session.runState);
|
|
4461
4463
|
const allowedBecause = !runIsAlive
|
|
4462
4464
|
? "run is dead"
|
|
4463
4465
|
: `viewer moved; run had produced ${producedThisRun.toFixed(1)}s`;
|
|
@@ -5059,7 +5061,7 @@ export class HlsSessionManager {
|
|
|
5059
5061
|
.map((member) => this.#observedAudioCost.get(this.#audioCostKey(member))?.version ?? 0)
|
|
5060
5062
|
.reduce((total, one) => total + one, 0);
|
|
5061
5063
|
const running = [...this.#familyOf(owner)]
|
|
5062
|
-
.filter((member) =>
|
|
5064
|
+
.filter((member) => processCanBeSignalled(member.runState)).length;
|
|
5063
5065
|
// What each running encode was last seen doing, which is BOTH an input to
|
|
5064
5066
|
// the answer twice over — it withdraws a step measured below realtime, and
|
|
5065
5067
|
// it prices every running picture in the committed total — and a figure
|
|
@@ -5217,7 +5219,7 @@ export class HlsSessionManager {
|
|
|
5217
5219
|
for (const session of this.sessionsById.values()) {
|
|
5218
5220
|
if (session?.ffmpeg != null &&
|
|
5219
5221
|
!hasChildExited(session.ffmpeg) &&
|
|
5220
|
-
session.
|
|
5222
|
+
session.runState !== ENCODE_RUN_STATE.SUSPENDED &&
|
|
5221
5223
|
session.state !== "disposed") {
|
|
5222
5224
|
running += 1;
|
|
5223
5225
|
}
|
|
@@ -5231,7 +5233,7 @@ export class HlsSessionManager {
|
|
|
5231
5233
|
session.state === "disposed" ||
|
|
5232
5234
|
session.state === "failed" ||
|
|
5233
5235
|
!session.ffmpeg ||
|
|
5234
|
-
session.
|
|
5236
|
+
session.runState === ENCODE_RUN_STATE.SUSPENDED
|
|
5235
5237
|
) {
|
|
5236
5238
|
session.learnSample = null;
|
|
5237
5239
|
return;
|
|
@@ -5336,7 +5338,7 @@ export class HlsSessionManager {
|
|
|
5336
5338
|
if (!Number.isFinite(runStartedAt) || Date.now() - runStartedAt < DECODE_LEARNING_SETTLE_MS) {
|
|
5337
5339
|
return;
|
|
5338
5340
|
}
|
|
5339
|
-
if (session.
|
|
5341
|
+
if (session.runState === ENCODE_RUN_STATE.SUSPENDED) {
|
|
5340
5342
|
return; // a suspended run reports a cumulative figure that is decaying
|
|
5341
5343
|
}
|
|
5342
5344
|
// Always asked, not only below realtime. A re-encode near 1x may be the
|
|
@@ -5387,7 +5389,7 @@ export class HlsSessionManager {
|
|
|
5387
5389
|
if (!Number.isFinite(runStartedAt) || Date.now() - runStartedAt < DECODE_LEARNING_SETTLE_MS) {
|
|
5388
5390
|
return;
|
|
5389
5391
|
}
|
|
5390
|
-
if (session.
|
|
5392
|
+
if (session.runState === ENCODE_RUN_STATE.SUSPENDED) {
|
|
5391
5393
|
return;
|
|
5392
5394
|
}
|
|
5393
5395
|
if (await this.#classifyTranscodeBound(session) === "download") {
|
|
@@ -5630,7 +5632,7 @@ export class HlsSessionManager {
|
|
|
5630
5632
|
#pricedConcurrentCost(session) {
|
|
5631
5633
|
let cost = 0;
|
|
5632
5634
|
for (const member of this.#familyOf(session)) {
|
|
5633
|
-
if (member === session ||
|
|
5635
|
+
if (member === session || !processCanBeSignalled(member.runState)) {
|
|
5634
5636
|
continue;
|
|
5635
5637
|
}
|
|
5636
5638
|
if (member.audioOnly === true) {
|
|
@@ -5659,7 +5661,7 @@ export class HlsSessionManager {
|
|
|
5659
5661
|
// price to look up for another film's session — so a reading taken while
|
|
5660
5662
|
// one is running cannot be attributed either.
|
|
5661
5663
|
return this.#runningEncoders() > this.#familyOf(session).filter(
|
|
5662
|
-
(member) =>
|
|
5664
|
+
(member) => processCanBeSignalled(member.runState)
|
|
5663
5665
|
).length
|
|
5664
5666
|
? null
|
|
5665
5667
|
: cost;
|
|
@@ -5683,7 +5685,7 @@ export class HlsSessionManager {
|
|
|
5683
5685
|
if (member.audioOnly === true || member.transcodeVideo !== true) {
|
|
5684
5686
|
continue;
|
|
5685
5687
|
}
|
|
5686
|
-
if (
|
|
5688
|
+
if (!processCanBeSignalled(member.runState)) {
|
|
5687
5689
|
continue;
|
|
5688
5690
|
}
|
|
5689
5691
|
const height = this.variantHeightOf(member);
|
|
@@ -5727,7 +5729,7 @@ export class HlsSessionManager {
|
|
|
5727
5729
|
// that cost is spread, not a discount on it — and pricing a parked
|
|
5728
5730
|
// encoder at zero would offer a step on the strength of a pause that ends
|
|
5729
5731
|
// the moment the viewer catches up.
|
|
5730
|
-
if (
|
|
5732
|
+
if (!processCanBeSignalled(member.runState)) {
|
|
5731
5733
|
continue;
|
|
5732
5734
|
}
|
|
5733
5735
|
if (member.audioOnly === true) {
|
|
@@ -7079,9 +7081,30 @@ export class HlsSessionManager {
|
|
|
7079
7081
|
// is kept, because it is the honest one and it is what keeps speech and
|
|
7080
7082
|
// subtitles together on a file whose index is slightly out (2026-08-06,
|
|
7081
7083
|
// 4.17 s of drift on a Matroska index that lied).
|
|
7082
|
-
|
|
7084
|
+
// STAMPED WITH ITS OWN TRUE START, always. Two attempts at moving it
|
|
7085
|
+
// toward the playlist both made things worse, and the reason is in what
|
|
7086
|
+
// the first segment of a run is: it is not CUT at all — it begins where
|
|
7087
|
+
// ffmpeg's seek landed. The picture must land on a keyframe; the sound
|
|
7088
|
+
// needs none and starts at the instant asked for. So after every
|
|
7089
|
+
// restart the two runs genuinely begin at different real times, and the
|
|
7090
|
+
// whole run carries that difference (field 2026-08-17: the sound's
|
|
7091
|
+
// #292 began at 1587.892 s and #293 at 1592.692 s — exactly one segment
|
|
7092
|
+
// apart, the whole run shifted 2.5 s from the grid).
|
|
7093
|
+
//
|
|
7094
|
+
// Labelling each track with its own true time is therefore what keeps
|
|
7095
|
+
// picture and sound together in real time. Moving them onto the
|
|
7096
|
+
// published grid — separately (2.24.1) or by one family offset (2.25.0)
|
|
7097
|
+
// — closes a gap that is real and opens one that is not: it desynced
|
|
7098
|
+
// playback in the field within the hour, twice.
|
|
7099
|
+
//
|
|
7100
|
+
// What that leaves unsolved is the reason those attempts were made: a
|
|
7101
|
+
// playlist that disagrees with the media by more than a player bridges
|
|
7102
|
+
// makes hls.js refetch the same fragment for ever (1908 times each for
|
|
7103
|
+
// two segments, measured). The answer to THAT is to make the published
|
|
7104
|
+
// grid agree with where the runs really begin — not to relabel the
|
|
7105
|
+
// media. Recorded as its own roadmap item rather than guessed at here.
|
|
7106
|
+
const stampStart = trueStart ?? publishedStart;
|
|
7083
7107
|
if (trueStart !== null && Math.abs(trueStart - publishedStart) > PLAYER_BUFFER_HOLE_SEC) {
|
|
7084
|
-
stampStart = publishedStart;
|
|
7085
7108
|
this.#notePlaylistDisagreement(session, index, trueStart, publishedStart);
|
|
7086
7109
|
}
|
|
7087
7110
|
const prepared = session.segmentFormat.prepareSegmentBytes(bytes, {
|
|
@@ -63,6 +63,7 @@ async function managerWithRunAhead() {
|
|
|
63
63
|
seekTarget: null,
|
|
64
64
|
waitEpoch: 0,
|
|
65
65
|
firstWantedAt: new Map(),
|
|
66
|
+
runState: "PRODUCING",
|
|
66
67
|
ffmpeg: { pid: 4321, exitCode: null, signalCode: null, kill() {}, once(event, handler) { if (event === "exit") handler(); } },
|
|
67
68
|
progress: { state: "running", processedSeconds: RUN_STARTS_AT * SEGMENT_SECONDS + 400, startPositionSeconds: RUN_STARTS_AT * SEGMENT_SECONDS }
|
|
68
69
|
};
|
|
@@ -286,6 +286,7 @@ test("a segment request hands the encoder to the variant the viewer moved to", a
|
|
|
286
286
|
base.lastRequestedSegment = 25;
|
|
287
287
|
const encoder = fakeEncoder();
|
|
288
288
|
base.ffmpeg = encoder;
|
|
289
|
+
base.runState = "PRODUCING";
|
|
289
290
|
|
|
290
291
|
const served = await manager.resolveVariantFile(BASE_ID, 540, "segment-00025.mp4");
|
|
291
292
|
|
|
@@ -317,6 +318,7 @@ test("a rung is placed where the player asked it for, not where the other rung h
|
|
|
317
318
|
manager.sessionsById.set(VARIANT_ID, variant);
|
|
318
319
|
base.variants = new Map([[540, VARIANT_ID]]);
|
|
319
320
|
base.ffmpeg = fakeEncoder();
|
|
321
|
+
base.runState = "PRODUCING";
|
|
320
322
|
// The rung being left had read fourteen segments further than the picture had
|
|
321
323
|
// played — an encoder running at several times realtime fills the buffer far
|
|
322
324
|
// ahead. Measured 2026-08-11: 56 s of gap, and using the read head placed the
|
|
@@ -347,6 +349,7 @@ test("warming a rung prepares it without taking the encoder from the one on scre
|
|
|
347
349
|
base.variants = new Map([[540, VARIANT_ID]]);
|
|
348
350
|
const encoder = fakeEncoder();
|
|
349
351
|
base.ffmpeg = encoder;
|
|
352
|
+
base.runState = "PRODUCING";
|
|
350
353
|
|
|
351
354
|
const prepared = await manager.prepareVariant(BASE_ID, 540, 240);
|
|
352
355
|
|
|
@@ -373,6 +376,7 @@ test("a rung warmed at the playhead survives the switch that lands just ahead of
|
|
|
373
376
|
manager.sessionsById.set(VARIANT_ID, variant);
|
|
374
377
|
base.variants = new Map([[540, VARIANT_ID]]);
|
|
375
378
|
base.ffmpeg = fakeEncoder();
|
|
379
|
+
base.runState = "PRODUCING";
|
|
376
380
|
|
|
377
381
|
// Warmed AT THE PLAYHEAD (240 s = segment #60), which is what the browser
|
|
378
382
|
// sends from server 0.10.0 onwards, and the run is alive and has produced a
|
|
@@ -380,6 +384,7 @@ test("a rung warmed at the playhead survives the switch that lands just ahead of
|
|
|
380
384
|
await manager.prepareVariant(BASE_ID, 540, 240);
|
|
381
385
|
variant.encodeStartIndex = 59;
|
|
382
386
|
variant.ffmpeg = fakeEncoder();
|
|
387
|
+
variant.runState = "PRODUCING";
|
|
383
388
|
variant.progress = { ...variant.progress, processedSeconds: 268 };
|
|
384
389
|
variant.seekTarget = null;
|
|
385
390
|
variant.seekSettleTimer = null;
|
|
@@ -413,6 +418,7 @@ test("a rung warmed PAST the switch is repositioned, which is what warming late
|
|
|
413
418
|
manager.sessionsById.set(VARIANT_ID, variant);
|
|
414
419
|
base.variants = new Map([[540, VARIANT_ID]]);
|
|
415
420
|
base.ffmpeg = fakeEncoder();
|
|
421
|
+
base.runState = "PRODUCING";
|
|
416
422
|
|
|
417
423
|
// The same session, warmed where the BUFFER ended rather than where the
|
|
418
424
|
// picture was — 60 s further on, which is an ordinary cushion. This is what
|
|
@@ -420,6 +426,7 @@ test("a rung warmed PAST the switch is repositioned, which is what warming late
|
|
|
420
426
|
await manager.prepareVariant(BASE_ID, 540, 300);
|
|
421
427
|
variant.encodeStartIndex = 74;
|
|
422
428
|
variant.ffmpeg = fakeEncoder();
|
|
429
|
+
variant.runState = "PRODUCING";
|
|
423
430
|
variant.progress = { ...variant.progress, processedSeconds: 310 };
|
|
424
431
|
variant.seekTarget = null;
|
|
425
432
|
variant.seekSettleTimer = null;
|
|
@@ -447,6 +454,7 @@ test("the rung on screen fetching its own segments does not cancel a warm-up", a
|
|
|
447
454
|
const warmedEncoder = fakeEncoder();
|
|
448
455
|
variant.ffmpeg = warmedEncoder;
|
|
449
456
|
base.ffmpeg = fakeEncoder();
|
|
457
|
+
base.runState = "PRODUCING";
|
|
450
458
|
await manager.prepareVariant(BASE_ID, 540, 100);
|
|
451
459
|
|
|
452
460
|
// The viewer has not moved: the rung they are watching goes on asking for its
|
|
@@ -521,6 +529,7 @@ test("a playlist or an init segment does not move the encoder", async (t) => {
|
|
|
521
529
|
manager.sessionsById.set(VARIANT_ID, variant);
|
|
522
530
|
base.variants = new Map([[540, VARIANT_ID]]);
|
|
523
531
|
base.ffmpeg = fakeEncoder();
|
|
532
|
+
base.runState = "PRODUCING";
|
|
524
533
|
|
|
525
534
|
base.variants = new Map([[540, VARIANT_ID]]);
|
|
526
535
|
await manager.resolveVariantFile(BASE_ID, 540, "index.m3u8");
|
|
@@ -719,6 +728,7 @@ test("an audio track is prepared at the position the switch will land on", async
|
|
|
719
728
|
rendition.audioOnly = true;
|
|
720
729
|
rendition.audioTrackIndex = 1;
|
|
721
730
|
rendition.ffmpeg = fakeEncoder();
|
|
731
|
+
rendition.runState = "PRODUCING";
|
|
722
732
|
rendition.encodeStartIndex = 0;
|
|
723
733
|
manager.sessionsById.set(VARIANT_ID, rendition);
|
|
724
734
|
base.audioRenditionSessions = new Map([[1, VARIANT_ID]]);
|
|
@@ -790,6 +800,7 @@ test("a quality step being warmed is not refused by its own cost", async (t) =>
|
|
|
790
800
|
// Running, and running well: it says of itself that it holds twice realtime,
|
|
791
801
|
// i.e. half a second of work per second of video.
|
|
792
802
|
warming.ffmpeg = fakeEncoder();
|
|
803
|
+
warming.runState = "PRODUCING";
|
|
793
804
|
warming.lastAloneSpeed = 2;
|
|
794
805
|
manager.sessionsById.set(BASE_ID, base);
|
|
795
806
|
manager.sessionsById.set(VARIANT_ID, warming);
|
|
@@ -1,338 +1,334 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file A finished segment on disk must reach the viewer as bytes.
|
|
3
|
-
*
|
|
4
|
-
* The module tests cover each piece of the fMP4 path on its own, and every one
|
|
5
|
-
* of them passed while playback was dead: 2.9.124 called
|
|
6
|
-
* `readSelfContainedStartSeconds` from `fmp4.js` without importing it, and the
|
|
7
|
-
* unit test imports that function straight from `mp4-boxes.js`, so the gap
|
|
8
|
-
* between a module and its CALLER was invisible. This test asks the session
|
|
9
|
-
* manager for a segment that exists and insists on getting it.
|
|
10
|
-
*
|
|
11
|
-
* The second half pins the reason a one-word slip cost a whole release: the
|
|
12
|
-
* failure was reported as "still being produced". Measured 2026-08-08 — segment
|
|
13
|
-
* #0 was held for 45 281 ms with twelve finished segments in the directory, and
|
|
14
|
-
* the log said nothing at all. Anything that goes wrong while preparing a file
|
|
15
|
-
* that EXISTS must be named and answered, never turned into an endless wait.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import test from "node:test";
|
|
19
|
-
import assert from "node:assert/strict";
|
|
20
|
-
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
21
|
-
import os from "node:os";
|
|
22
|
-
import path from "node:path";
|
|
23
|
-
import { HlsSessionManager } from "../services/hls-session-manager.js";
|
|
24
|
-
import { ENCODE_RUN_STATE } from "../services/encode-run-state.js";
|
|
25
|
-
import { fmp4Format } from "../services/segment-formats/fmp4.js";
|
|
26
|
-
|
|
27
|
-
const MOVIE_TIMESCALE = 1000;
|
|
28
|
-
const VIDEO_TIMESCALE = 90_000;
|
|
29
|
-
const AUDIO_TIMESCALE = 48_000;
|
|
30
|
-
const SEGMENT_START_SECONDS = 12.5;
|
|
31
|
-
const SESSION_ID = "11111111-2222-3333-4444-555555555555";
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* @param {string} type
|
|
35
|
-
* @param {Buffer} body
|
|
36
|
-
* @returns {Buffer}
|
|
37
|
-
*/
|
|
38
|
-
function box(type, body) {
|
|
39
|
-
const head = Buffer.alloc(8);
|
|
40
|
-
head.writeUInt32BE(8 + body.length, 0);
|
|
41
|
-
head.write(type, 4, "latin1");
|
|
42
|
-
return Buffer.concat([head, body]);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* `elst` holding one empty edit — how the `segment` muxer records where the
|
|
47
|
-
* piece sits on the source timeline.
|
|
48
|
-
*
|
|
49
|
-
* @param {number} offsetSeconds
|
|
50
|
-
* @returns {Buffer}
|
|
51
|
-
*/
|
|
52
|
-
function emptyEdit(offsetSeconds) {
|
|
53
|
-
const body = Buffer.alloc(16);
|
|
54
|
-
body.writeUInt32BE(1, 4); // entry count
|
|
55
|
-
body.writeUInt32BE(Math.round(offsetSeconds * MOVIE_TIMESCALE), 8); // duration
|
|
56
|
-
body.writeInt32BE(-1, 12); // media_time
|
|
57
|
-
return box("elst", body);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* @param {number} trackId
|
|
62
|
-
* @param {number} timescale
|
|
63
|
-
* @param {number} offsetSeconds
|
|
64
|
-
* @returns {Buffer}
|
|
65
|
-
*/
|
|
66
|
-
function trak(trackId, timescale, offsetSeconds) {
|
|
67
|
-
const tkhdBody = Buffer.alloc(84);
|
|
68
|
-
tkhdBody.writeUInt32BE(trackId, 12);
|
|
69
|
-
const mdhdBody = Buffer.alloc(20);
|
|
70
|
-
mdhdBody.writeUInt32BE(timescale, 12);
|
|
71
|
-
return box("trak", Buffer.concat([
|
|
72
|
-
box("tkhd", tkhdBody),
|
|
73
|
-
box("edts", emptyEdit(offsetSeconds)),
|
|
74
|
-
box("mdia", box("mdhd", mdhdBody))
|
|
75
|
-
]));
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* @param {number} trackId
|
|
80
|
-
* @returns {Buffer}
|
|
81
|
-
*/
|
|
82
|
-
function traf(trackId) {
|
|
83
|
-
const tfhdBody = Buffer.alloc(8);
|
|
84
|
-
tfhdBody.writeUInt32BE(trackId, 4);
|
|
85
|
-
const tfdtBody = Buffer.alloc(12);
|
|
86
|
-
tfdtBody.writeUInt8(1, 0); // version 1 — 64-bit
|
|
87
|
-
tfdtBody.writeBigUInt64BE(0n, 4); // what ffmpeg writes: zero
|
|
88
|
-
return box("traf", Buffer.concat([box("tfhd", tfhdBody), box("tfdt", tfdtBody)]));
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* A piece shaped like the `segment` muxer's output: header, two fragments and a
|
|
93
|
-
* trailing random-access index, all in one file.
|
|
94
|
-
*
|
|
95
|
-
* @param {number} offsetSeconds
|
|
96
|
-
* @returns {Buffer}
|
|
97
|
-
*/
|
|
98
|
-
function selfContainedPiece(offsetSeconds) {
|
|
99
|
-
const mvhdBody = Buffer.alloc(100);
|
|
100
|
-
mvhdBody.writeUInt32BE(MOVIE_TIMESCALE, 12);
|
|
101
|
-
const moov = box("moov", Buffer.concat([
|
|
102
|
-
box("mvhd", mvhdBody),
|
|
103
|
-
trak(1, VIDEO_TIMESCALE, offsetSeconds),
|
|
104
|
-
trak(2, AUDIO_TIMESCALE, offsetSeconds)
|
|
105
|
-
]));
|
|
106
|
-
const moof = box("moof", Buffer.concat([traf(1), traf(2)]));
|
|
107
|
-
const mdat = box("mdat", Buffer.alloc(64, 0x5a));
|
|
108
|
-
const mfra = box("mfra", Buffer.alloc(24, 0));
|
|
109
|
-
return Buffer.concat([box("ftyp", Buffer.alloc(16, 0)), moov, moof, mdat, mfra]);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* A manager holding one session whose segments are already on disk, cut at
|
|
114
|
-
* explicit times — the ordinary keyframe-cut path.
|
|
115
|
-
*
|
|
116
|
-
* @param {{ segmentFormat?: object }} [overrides]
|
|
117
|
-
* @returns {Promise<{ manager: HlsSessionManager, session: object, dirPath: string }>}
|
|
118
|
-
*/
|
|
119
|
-
async function managerWithReadySegment(overrides = {}) {
|
|
120
|
-
const dirPath = await mkdtemp(path.join(os.tmpdir(), "segment-serve-"));
|
|
121
|
-
const piece = selfContainedPiece(SEGMENT_START_SECONDS);
|
|
122
|
-
// Two segments, because a piece is only finished once the next one exists.
|
|
123
|
-
await writeFile(path.join(dirPath, "segment-00000.mp4"), piece);
|
|
124
|
-
await writeFile(path.join(dirPath, "segment-00001.mp4"), piece);
|
|
125
|
-
|
|
126
|
-
const manager = new HlsSessionManager({
|
|
127
|
-
enabled: true,
|
|
128
|
-
ffmpegBin: "ffmpeg",
|
|
129
|
-
localBindHost: "127.0.0.1",
|
|
130
|
-
localPort: 9090
|
|
131
|
-
});
|
|
132
|
-
const session = {
|
|
133
|
-
id: SESSION_ID,
|
|
134
|
-
dirPath,
|
|
135
|
-
state: "ready",
|
|
136
|
-
fileName: "video.mkv",
|
|
137
|
-
startedAt: Date.now(),
|
|
138
|
-
createEntryMs: Date.now(),
|
|
139
|
-
lastAccessedAt: Date.now(),
|
|
140
|
-
ffmpeg: null,
|
|
141
|
-
lastError: "",
|
|
142
|
-
consumers: new Set(),
|
|
143
|
-
segmentFormat: overrides.segmentFormat ?? fmp4Format,
|
|
144
|
-
usesExplicitCuts: true,
|
|
145
|
-
useSyntheticPlaylist: true,
|
|
146
|
-
playlistText: "#EXTM3U\n",
|
|
147
|
-
segmentBoundaries: [0, SEGMENT_START_SECONDS, 25],
|
|
148
|
-
initBytes: fmp4Format.extractInit(piece),
|
|
149
|
-
encodeStartIndex: 0,
|
|
150
|
-
firstSegmentLogged: false,
|
|
151
|
-
waitEpoch: 0
|
|
152
|
-
};
|
|
153
|
-
manager.sessionsById.set(SESSION_ID, session);
|
|
154
|
-
return { manager, session, dirPath };
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
test("serving a segment records what its real start says about the container's index", async (t) => {
|
|
158
|
-
const { manager, session, dirPath } = await managerWithReadySegment();
|
|
159
|
-
t.after(async () => {
|
|
160
|
-
await manager.disposeAll();
|
|
161
|
-
await rm(dirPath, { recursive: true, force: true });
|
|
162
|
-
});
|
|
163
|
-
// The tally is counted in the module tests; what this pins is that serving a
|
|
164
|
-
// segment reaches it at all. A counter nothing increments reports a clean
|
|
165
|
-
// index for every file forever, which is worse than no measurement.
|
|
166
|
-
session.indexCheck = { checked: 0, disagreed: 0, maxDeviationSec: 0, firstDisagreementIndex: -1, seen: new Set() };
|
|
167
|
-
|
|
168
|
-
await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
|
|
169
|
-
|
|
170
|
-
assert.equal(session.indexCheck.checked, 1, "the boundary that was just produced must have been examined");
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
test("a segment that exists is served, not reported as still being produced", async (t) => {
|
|
174
|
-
const { manager, dirPath, session } = await managerWithReadySegment();
|
|
175
|
-
t.after(async () => {
|
|
176
|
-
await manager.disposeAll();
|
|
177
|
-
await rm(dirPath, { recursive: true, force: true });
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
const result = await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
|
|
181
|
-
|
|
182
|
-
assert.equal(result.kind, "file", "a finished segment on disk must come back as bytes");
|
|
183
|
-
assert.equal(result.contentType, fmp4Format.segmentContentType);
|
|
184
|
-
|
|
185
|
-
const chunks = [];
|
|
186
|
-
for await (const chunk of result.stream) {
|
|
187
|
-
chunks.push(chunk);
|
|
188
|
-
}
|
|
189
|
-
const served = Buffer.concat(chunks);
|
|
190
|
-
assert.equal(served.toString("latin1", 4, 8), "moof", "the init header must be stripped off a media segment");
|
|
191
|
-
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
);
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
await
|
|
274
|
-
await
|
|
275
|
-
await
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
await
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
);
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
session.
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
session.
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
session.
|
|
334
|
-
|
|
335
|
-
await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
|
|
336
|
-
|
|
337
|
-
assert.equal(session.runState, ENCODE_RUN_STATE.STARTING);
|
|
338
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* @file A finished segment on disk must reach the viewer as bytes.
|
|
3
|
+
*
|
|
4
|
+
* The module tests cover each piece of the fMP4 path on its own, and every one
|
|
5
|
+
* of them passed while playback was dead: 2.9.124 called
|
|
6
|
+
* `readSelfContainedStartSeconds` from `fmp4.js` without importing it, and the
|
|
7
|
+
* unit test imports that function straight from `mp4-boxes.js`, so the gap
|
|
8
|
+
* between a module and its CALLER was invisible. This test asks the session
|
|
9
|
+
* manager for a segment that exists and insists on getting it.
|
|
10
|
+
*
|
|
11
|
+
* The second half pins the reason a one-word slip cost a whole release: the
|
|
12
|
+
* failure was reported as "still being produced". Measured 2026-08-08 — segment
|
|
13
|
+
* #0 was held for 45 281 ms with twelve finished segments in the directory, and
|
|
14
|
+
* the log said nothing at all. Anything that goes wrong while preparing a file
|
|
15
|
+
* that EXISTS must be named and answered, never turned into an endless wait.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import test from "node:test";
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import { HlsSessionManager } from "../services/hls-session-manager.js";
|
|
24
|
+
import { ENCODE_RUN_STATE } from "../services/encode-run-state.js";
|
|
25
|
+
import { fmp4Format } from "../services/segment-formats/fmp4.js";
|
|
26
|
+
|
|
27
|
+
const MOVIE_TIMESCALE = 1000;
|
|
28
|
+
const VIDEO_TIMESCALE = 90_000;
|
|
29
|
+
const AUDIO_TIMESCALE = 48_000;
|
|
30
|
+
const SEGMENT_START_SECONDS = 12.5;
|
|
31
|
+
const SESSION_ID = "11111111-2222-3333-4444-555555555555";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {string} type
|
|
35
|
+
* @param {Buffer} body
|
|
36
|
+
* @returns {Buffer}
|
|
37
|
+
*/
|
|
38
|
+
function box(type, body) {
|
|
39
|
+
const head = Buffer.alloc(8);
|
|
40
|
+
head.writeUInt32BE(8 + body.length, 0);
|
|
41
|
+
head.write(type, 4, "latin1");
|
|
42
|
+
return Buffer.concat([head, body]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* `elst` holding one empty edit — how the `segment` muxer records where the
|
|
47
|
+
* piece sits on the source timeline.
|
|
48
|
+
*
|
|
49
|
+
* @param {number} offsetSeconds
|
|
50
|
+
* @returns {Buffer}
|
|
51
|
+
*/
|
|
52
|
+
function emptyEdit(offsetSeconds) {
|
|
53
|
+
const body = Buffer.alloc(16);
|
|
54
|
+
body.writeUInt32BE(1, 4); // entry count
|
|
55
|
+
body.writeUInt32BE(Math.round(offsetSeconds * MOVIE_TIMESCALE), 8); // duration
|
|
56
|
+
body.writeInt32BE(-1, 12); // media_time
|
|
57
|
+
return box("elst", body);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {number} trackId
|
|
62
|
+
* @param {number} timescale
|
|
63
|
+
* @param {number} offsetSeconds
|
|
64
|
+
* @returns {Buffer}
|
|
65
|
+
*/
|
|
66
|
+
function trak(trackId, timescale, offsetSeconds) {
|
|
67
|
+
const tkhdBody = Buffer.alloc(84);
|
|
68
|
+
tkhdBody.writeUInt32BE(trackId, 12);
|
|
69
|
+
const mdhdBody = Buffer.alloc(20);
|
|
70
|
+
mdhdBody.writeUInt32BE(timescale, 12);
|
|
71
|
+
return box("trak", Buffer.concat([
|
|
72
|
+
box("tkhd", tkhdBody),
|
|
73
|
+
box("edts", emptyEdit(offsetSeconds)),
|
|
74
|
+
box("mdia", box("mdhd", mdhdBody))
|
|
75
|
+
]));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {number} trackId
|
|
80
|
+
* @returns {Buffer}
|
|
81
|
+
*/
|
|
82
|
+
function traf(trackId) {
|
|
83
|
+
const tfhdBody = Buffer.alloc(8);
|
|
84
|
+
tfhdBody.writeUInt32BE(trackId, 4);
|
|
85
|
+
const tfdtBody = Buffer.alloc(12);
|
|
86
|
+
tfdtBody.writeUInt8(1, 0); // version 1 — 64-bit
|
|
87
|
+
tfdtBody.writeBigUInt64BE(0n, 4); // what ffmpeg writes: zero
|
|
88
|
+
return box("traf", Buffer.concat([box("tfhd", tfhdBody), box("tfdt", tfdtBody)]));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A piece shaped like the `segment` muxer's output: header, two fragments and a
|
|
93
|
+
* trailing random-access index, all in one file.
|
|
94
|
+
*
|
|
95
|
+
* @param {number} offsetSeconds
|
|
96
|
+
* @returns {Buffer}
|
|
97
|
+
*/
|
|
98
|
+
function selfContainedPiece(offsetSeconds) {
|
|
99
|
+
const mvhdBody = Buffer.alloc(100);
|
|
100
|
+
mvhdBody.writeUInt32BE(MOVIE_TIMESCALE, 12);
|
|
101
|
+
const moov = box("moov", Buffer.concat([
|
|
102
|
+
box("mvhd", mvhdBody),
|
|
103
|
+
trak(1, VIDEO_TIMESCALE, offsetSeconds),
|
|
104
|
+
trak(2, AUDIO_TIMESCALE, offsetSeconds)
|
|
105
|
+
]));
|
|
106
|
+
const moof = box("moof", Buffer.concat([traf(1), traf(2)]));
|
|
107
|
+
const mdat = box("mdat", Buffer.alloc(64, 0x5a));
|
|
108
|
+
const mfra = box("mfra", Buffer.alloc(24, 0));
|
|
109
|
+
return Buffer.concat([box("ftyp", Buffer.alloc(16, 0)), moov, moof, mdat, mfra]);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A manager holding one session whose segments are already on disk, cut at
|
|
114
|
+
* explicit times — the ordinary keyframe-cut path.
|
|
115
|
+
*
|
|
116
|
+
* @param {{ segmentFormat?: object }} [overrides]
|
|
117
|
+
* @returns {Promise<{ manager: HlsSessionManager, session: object, dirPath: string }>}
|
|
118
|
+
*/
|
|
119
|
+
async function managerWithReadySegment(overrides = {}) {
|
|
120
|
+
const dirPath = await mkdtemp(path.join(os.tmpdir(), "segment-serve-"));
|
|
121
|
+
const piece = selfContainedPiece(SEGMENT_START_SECONDS);
|
|
122
|
+
// Two segments, because a piece is only finished once the next one exists.
|
|
123
|
+
await writeFile(path.join(dirPath, "segment-00000.mp4"), piece);
|
|
124
|
+
await writeFile(path.join(dirPath, "segment-00001.mp4"), piece);
|
|
125
|
+
|
|
126
|
+
const manager = new HlsSessionManager({
|
|
127
|
+
enabled: true,
|
|
128
|
+
ffmpegBin: "ffmpeg",
|
|
129
|
+
localBindHost: "127.0.0.1",
|
|
130
|
+
localPort: 9090
|
|
131
|
+
});
|
|
132
|
+
const session = {
|
|
133
|
+
id: SESSION_ID,
|
|
134
|
+
dirPath,
|
|
135
|
+
state: "ready",
|
|
136
|
+
fileName: "video.mkv",
|
|
137
|
+
startedAt: Date.now(),
|
|
138
|
+
createEntryMs: Date.now(),
|
|
139
|
+
lastAccessedAt: Date.now(),
|
|
140
|
+
ffmpeg: null,
|
|
141
|
+
lastError: "",
|
|
142
|
+
consumers: new Set(),
|
|
143
|
+
segmentFormat: overrides.segmentFormat ?? fmp4Format,
|
|
144
|
+
usesExplicitCuts: true,
|
|
145
|
+
useSyntheticPlaylist: true,
|
|
146
|
+
playlistText: "#EXTM3U\n",
|
|
147
|
+
segmentBoundaries: [0, SEGMENT_START_SECONDS, 25],
|
|
148
|
+
initBytes: fmp4Format.extractInit(piece),
|
|
149
|
+
encodeStartIndex: 0,
|
|
150
|
+
firstSegmentLogged: false,
|
|
151
|
+
waitEpoch: 0
|
|
152
|
+
};
|
|
153
|
+
manager.sessionsById.set(SESSION_ID, session);
|
|
154
|
+
return { manager, session, dirPath };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
test("serving a segment records what its real start says about the container's index", async (t) => {
|
|
158
|
+
const { manager, session, dirPath } = await managerWithReadySegment();
|
|
159
|
+
t.after(async () => {
|
|
160
|
+
await manager.disposeAll();
|
|
161
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
162
|
+
});
|
|
163
|
+
// The tally is counted in the module tests; what this pins is that serving a
|
|
164
|
+
// segment reaches it at all. A counter nothing increments reports a clean
|
|
165
|
+
// index for every file forever, which is worse than no measurement.
|
|
166
|
+
session.indexCheck = { checked: 0, disagreed: 0, maxDeviationSec: 0, firstDisagreementIndex: -1, seen: new Set() };
|
|
167
|
+
|
|
168
|
+
await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
|
|
169
|
+
|
|
170
|
+
assert.equal(session.indexCheck.checked, 1, "the boundary that was just produced must have been examined");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("a segment that exists is served, not reported as still being produced", async (t) => {
|
|
174
|
+
const { manager, dirPath, session } = await managerWithReadySegment();
|
|
175
|
+
t.after(async () => {
|
|
176
|
+
await manager.disposeAll();
|
|
177
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const result = await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
|
|
181
|
+
|
|
182
|
+
assert.equal(result.kind, "file", "a finished segment on disk must come back as bytes");
|
|
183
|
+
assert.equal(result.contentType, fmp4Format.segmentContentType);
|
|
184
|
+
|
|
185
|
+
const chunks = [];
|
|
186
|
+
for await (const chunk of result.stream) {
|
|
187
|
+
chunks.push(chunk);
|
|
188
|
+
}
|
|
189
|
+
const served = Buffer.concat(chunks);
|
|
190
|
+
assert.equal(served.toString("latin1", 4, 8), "moof", "the init header must be stripped off a media segment");
|
|
191
|
+
|
|
192
|
+
// The piece's OWN start, carried into the fragment it belongs to. Two
|
|
193
|
+
// releases tried moving it toward the playlist instead (2.24.1 per session,
|
|
194
|
+
// 2.25.0 by one family offset) and both desynced picture from sound in the
|
|
195
|
+
// field the same day: the first segment of a run is not cut, it begins where
|
|
196
|
+
// the seek landed, and the picture must land on a keyframe while the sound
|
|
197
|
+
// need not. Reading the piece's position is also the step that threw in
|
|
198
|
+
// 2.9.124, and it still feeds the index tally and the grid correction.
|
|
199
|
+
assert.equal(
|
|
200
|
+
Number(served.readBigUInt64BE(served.indexOf("tfdt") + 8)),
|
|
201
|
+
Math.round(SEGMENT_START_SECONDS * VIDEO_TIMESCALE),
|
|
202
|
+
"the segment must be stamped with where it really begins"
|
|
203
|
+
);
|
|
204
|
+
assert.equal(
|
|
205
|
+
session.indexCheck.checked,
|
|
206
|
+
1,
|
|
207
|
+
"and the piece's own position must still have been read, or nothing measures the index"
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("a fault while preparing an existing segment is named, not turned into a wait", async (t) => {
|
|
212
|
+
const broken = {
|
|
213
|
+
...fmp4Format,
|
|
214
|
+
readSegmentStartSeconds() {
|
|
215
|
+
throw new ReferenceError("readSelfContainedStartSeconds is not defined");
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
const { manager, dirPath } = await managerWithReadySegment({ segmentFormat: broken });
|
|
219
|
+
t.after(async () => {
|
|
220
|
+
await manager.disposeAll();
|
|
221
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const result = await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
|
|
225
|
+
|
|
226
|
+
assert.equal(
|
|
227
|
+
result.kind,
|
|
228
|
+
"failed",
|
|
229
|
+
"answering 'warming-up' hides the fault and holds every request until the viewer gives up"
|
|
230
|
+
);
|
|
231
|
+
assert.match(result.message, /readSelfContainedStartSeconds is not defined/);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("a run's FIRST segment is served once the encoder has passed it, without waiting for a next one", async (t) => {
|
|
235
|
+
const { manager, session, dirPath } = await managerWithReadySegment();
|
|
236
|
+
t.after(async () => {
|
|
237
|
+
await manager.disposeAll();
|
|
238
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
239
|
+
});
|
|
240
|
+
// The shape a resume takes: a run begun mid-file, so its first segment has no
|
|
241
|
+
// successor and nothing is producing one. Waiting for that successor is what
|
|
242
|
+
// held #317 for 46 s and then answered 404 to a browser that had given up.
|
|
243
|
+
await rm(path.join(dirPath, "segment-00001.mp4"));
|
|
244
|
+
session.encodeStartIndex = 0;
|
|
245
|
+
session.ffmpeg = { killed: true, kill() {} };
|
|
246
|
+
session.progress = { processedSeconds: SEGMENT_START_SECONDS + 10 };
|
|
247
|
+
|
|
248
|
+
const result = await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
|
|
249
|
+
|
|
250
|
+
assert.equal(
|
|
251
|
+
result.kind,
|
|
252
|
+
"file",
|
|
253
|
+
"the encoder is past this segment's end, so it is finished — the absence of a next one says nothing"
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("a segment is found in the run directory that produced it, newest run first", async (t) => {
|
|
258
|
+
const { manager, session, dirPath } = await managerWithReadySegment();
|
|
259
|
+
t.after(async () => {
|
|
260
|
+
await manager.disposeAll();
|
|
261
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
262
|
+
});
|
|
263
|
+
// Runs write into a directory each — that is what lets a restart begin
|
|
264
|
+
// without waiting for its predecessor to die, which measured 0.7-1.3 s of
|
|
265
|
+
// every seek. A later run's answer supersedes an earlier one's, because the
|
|
266
|
+
// older file may be the truncated output of a run that was killed mid-write.
|
|
267
|
+
const { mkdir } = await import("node:fs/promises");
|
|
268
|
+
const piece = selfContainedPiece(SEGMENT_START_SECONDS);
|
|
269
|
+
await mkdir(path.join(dirPath, "run-1"), { recursive: true });
|
|
270
|
+
await mkdir(path.join(dirPath, "run-2"), { recursive: true });
|
|
271
|
+
await writeFile(path.join(dirPath, "run-1", "segment-00000.mp4"), Buffer.alloc(8));
|
|
272
|
+
await writeFile(path.join(dirPath, "run-2", "segment-00000.mp4"), piece);
|
|
273
|
+
await writeFile(path.join(dirPath, "run-2", "segment-00001.mp4"), piece);
|
|
274
|
+
await rm(path.join(dirPath, "segment-00000.mp4"));
|
|
275
|
+
await rm(path.join(dirPath, "segment-00001.mp4"));
|
|
276
|
+
session.encodeStartIndex = 0;
|
|
277
|
+
|
|
278
|
+
const result = await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
|
|
279
|
+
|
|
280
|
+
assert.equal(result.kind, "file", "a segment produced by a run must be found in that run's directory");
|
|
281
|
+
const chunks = [];
|
|
282
|
+
for await (const chunk of result.stream) {
|
|
283
|
+
chunks.push(chunk);
|
|
284
|
+
}
|
|
285
|
+
assert.ok(
|
|
286
|
+
Buffer.concat(chunks).length > 8,
|
|
287
|
+
"the newest run's output must win — the older file here is the 8-byte stub a killed run leaves"
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("serving a run's own segment moves the run out of STARTING", async (t) => {
|
|
292
|
+
const { manager, session, dirPath } = await managerWithReadySegment();
|
|
293
|
+
t.after(async () => {
|
|
294
|
+
await manager.disposeAll();
|
|
295
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
296
|
+
});
|
|
297
|
+
// The table lives in `encode-run-state.js` and is tested there as a graph.
|
|
298
|
+
// What this pins is that a real serve REACHES it: a state nothing writes
|
|
299
|
+
// describes every run as still starting, for ever, and the log built on it
|
|
300
|
+
// would say so too.
|
|
301
|
+
session.runState = ENCODE_RUN_STATE.STARTING;
|
|
302
|
+
// Where the run in force is writing. The fixture's segments live directly in
|
|
303
|
+
// the session directory, which is exactly what a single run's directory is
|
|
304
|
+
// here.
|
|
305
|
+
session.runDirPath = dirPath;
|
|
306
|
+
|
|
307
|
+
await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
|
|
308
|
+
|
|
309
|
+
assert.equal(session.runState, ENCODE_RUN_STATE.PRODUCING);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("a segment left by an earlier run does not claim the new run has produced", async (t) => {
|
|
313
|
+
const { manager, session, dirPath } = await managerWithReadySegment();
|
|
314
|
+
t.after(async () => {
|
|
315
|
+
await manager.disposeAll();
|
|
316
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
317
|
+
});
|
|
318
|
+
// A seek places the new run in a directory of its own; the previous run's
|
|
319
|
+
// segments stay servable and are served from theirs. They say nothing about
|
|
320
|
+
// what the run now starting has done — and a run believed to be producing is
|
|
321
|
+
// one the look-ahead may suspend and the seek path may wave through as
|
|
322
|
+
// "already covered by the running encode".
|
|
323
|
+
//
|
|
324
|
+
// Deliberately a segment ABOVE the new run's start index, because that is the
|
|
325
|
+
// case an index comparison gets wrong: after a backward seek the old run's
|
|
326
|
+
// output sits ahead of the new run's beginning.
|
|
327
|
+
session.runState = ENCODE_RUN_STATE.STARTING;
|
|
328
|
+
session.encodeStartIndex = 0;
|
|
329
|
+
session.runDirPath = path.join(dirPath, "run-7");
|
|
330
|
+
|
|
331
|
+
await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
|
|
332
|
+
|
|
333
|
+
assert.equal(session.runState, ENCODE_RUN_STATE.STARTING);
|
|
334
|
+
});
|