@torrent-tv/proxy 2.11.0 → 2.12.0
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,12 @@
|
|
|
1
|
+
## 2.12.0
|
|
2
|
+
|
|
3
|
+
- **Fix**: A rung warmed for a switch the viewer did not make is stopped. Only becoming active stopped the rung being left, so trying two rungs in a row left the first encoding for nobody — three encoders at once on a host sized for one, which is the opposite of what warming is for.
|
|
4
|
+
- **Fix**: The warm-up closes the handle it opened. It answers without sending the bytes, and on formats whose segments are served straight off disk that left a file descriptor behind on every quality pick; enough of them and every read fails, segments included.
|
|
5
|
+
|
|
6
|
+
- **New**: A quality rung is prepared before the player is told to switch to it — `GET /transcode/:id/v/:height/warm?position=<seconds>`. A rung is an encoder that does not exist until it is asked for, so switching first and waiting second put the whole of its cold start on screen as a spinner: measured 2026-08-11, the first segment of a 240p rung producing at 1.2x took 15 988 ms, and the viewer watched all of it. The rung on screen deliberately keeps its own encoder until the player actually moves, so the wait happens behind a picture that is still playing. Both encoders run for the length of the warm-up, which is what the switch costs to be invisible.
|
|
7
|
+
- **Fix**: A viewer who names a resolution keeps the ladder beneath it. Forcing a rung disabled the realtime budget outright, so on 2026-08-11 a viewer picked 480p on a host that encodes it at 0.27-0.78x and the stream simply never caught up — nothing could step in, because the one thing that steps in had been switched off. The encode now STARTS at the size asked for and may still be stepped down under it. The rung's height is its name and does not move with a downshift, so the player goes on addressing it by the height it chose; what changes is the picture, and a smaller picture that plays beats a correct label that freezes.
|
|
8
|
+
- **New**: When a produced segment starts somewhere other than the playlist says, the line now names which boundary it DOES fall on. The two possible faults need opposite fixes and the numbers alone do not separate them: matching boundary #N-1 means this proxy's own numbering is shifted, matching none means the container's index describes times the file does not have. Measured 2026-08-11 on a 1080p Matroska, three samples out by 3.5-4.6 s, all matching #N-1.
|
|
9
|
+
|
|
1
10
|
## 2.11.0
|
|
2
11
|
|
|
3
12
|
- **Fix**: A quality change places the new rung where the PLAYER asked for it, not where the rung being left had read to. After a level switch hls.js discards what it had buffered ahead and fetches from the picture's own position, so its first request for the new rung IS that position; the read head is a whole buffer further on. Measured 2026-08-11 on a switch back up to 400p: a 240p rung encoding at 5-6x had read 56 s past the picture, the run was placed at 3084 s, the player needed 3028 s, and nothing it asked for was ever produced.
|
package/package.json
CHANGED
|
@@ -140,7 +140,7 @@ export async function serveSessionFile(req, reply, { hlsSessionManager, sessionI
|
|
|
140
140
|
* @param {number} timeoutMs
|
|
141
141
|
* @returns {Promise<Awaited<ReturnType<import("../../../services/hls-session-manager.js").HlsSessionManager["getFileStream"]>>>}
|
|
142
142
|
*/
|
|
143
|
-
async function waitForSessionFile(hlsSessionManager, sessionId, fileName, timeoutMs) {
|
|
143
|
+
export async function waitForSessionFile(hlsSessionManager, sessionId, fileName, timeoutMs) {
|
|
144
144
|
const startedAt = Date.now();
|
|
145
145
|
// One sequence number for THIS request, reused by every poll below, so the
|
|
146
146
|
// session can tell a newly-arrived request apart from an old one polling
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file GET /transcode/:sessionId/v/:height/warm?position=<seconds> — prepare a
|
|
3
|
+
* quality rung before the player is told to switch to it.
|
|
4
|
+
*
|
|
5
|
+
* A rung is an encoder that does not exist until someone asks for it, so a
|
|
6
|
+
* switch made first and waited for second shows the viewer a spinner for as
|
|
7
|
+
* long as the first segment takes to produce — 15 988 ms, measured 2026-08-11
|
|
8
|
+
* on a rung producing at 1.2x. Asking first and switching second moves that
|
|
9
|
+
* wait to where it cannot be seen: the rung on screen goes on playing, and it
|
|
10
|
+
* keeps its own encoder until the player actually moves.
|
|
11
|
+
*
|
|
12
|
+
* Answers when the segment at that position is ready, so the caller can switch
|
|
13
|
+
* knowing there is something to fetch.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { waitForSessionFile } from "../session-file/get.js";
|
|
17
|
+
|
|
18
|
+
/** How long to hold the warm-up request before telling the caller to retry. */
|
|
19
|
+
const WARM_WAIT_MS = 30_000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {import("fastify").FastifyRequest} req
|
|
23
|
+
* @param {import("fastify").FastifyReply} reply
|
|
24
|
+
* @param {{ hlsSessionManager: import("../../../services/hls-session-manager.js").HlsSessionManager }} deps
|
|
25
|
+
* @returns {Promise<void>}
|
|
26
|
+
*/
|
|
27
|
+
export async function handleTranscodeVariantWarmGet(req, reply, { hlsSessionManager }) {
|
|
28
|
+
const baseSessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
|
|
29
|
+
const height = Number(req.params.height);
|
|
30
|
+
const positionSeconds = Number(req.query?.position);
|
|
31
|
+
|
|
32
|
+
if (!Number.isInteger(height) || height <= 0 || !Number.isFinite(positionSeconds) || positionSeconds < 0) {
|
|
33
|
+
return reply.code(400).send({ error: "A height and a non-negative position are required." });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let prepared;
|
|
37
|
+
try {
|
|
38
|
+
prepared = await hlsSessionManager.prepareVariant(baseSessionId, height, positionSeconds);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
41
|
+
reply.header("Retry-After", "1");
|
|
42
|
+
return reply.code(503).send({ error: `Could not prepare the quality variant: ${message}` });
|
|
43
|
+
}
|
|
44
|
+
if (!prepared) {
|
|
45
|
+
return reply.code(404).send({ error: "No such quality variant for this transcode session." });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const result = await waitForSessionFile(
|
|
49
|
+
hlsSessionManager,
|
|
50
|
+
prepared.sessionId,
|
|
51
|
+
prepared.fileName,
|
|
52
|
+
WARM_WAIT_MS
|
|
53
|
+
);
|
|
54
|
+
if (result.kind === "file") {
|
|
55
|
+
// The bytes are not sent — the player fetches them itself the moment it
|
|
56
|
+
// switches, and by then they are on disk — but the handle opened to reach
|
|
57
|
+
// them is ours to close. Some formats answer with a real file descriptor
|
|
58
|
+
// rather than bytes already in memory, and one left behind per quality pick
|
|
59
|
+
// walks a long-running proxy to EMFILE, where every read fails, segments
|
|
60
|
+
// included.
|
|
61
|
+
result.stream?.destroy?.();
|
|
62
|
+
return reply.code(204).send();
|
|
63
|
+
}
|
|
64
|
+
if (result.kind === "failed") {
|
|
65
|
+
return reply.code(500).send({ error: result.message });
|
|
66
|
+
}
|
|
67
|
+
// Still being produced. The caller may switch anyway — it will simply wait
|
|
68
|
+
// where it would have waited before — or ask again.
|
|
69
|
+
reply.header("Retry-After", "1");
|
|
70
|
+
return reply.code(503).send({ error: "The quality variant is still warming up." });
|
|
71
|
+
}
|
package/server.js
CHANGED
|
@@ -30,6 +30,7 @@ import { handleApiTranscodeSessionSeekPost } from "./routes/api/transcode-sessio
|
|
|
30
30
|
import { handleStreamGet } from "./routes/stream/get.js";
|
|
31
31
|
import { handleTranscodeSessionFileGet } from "./routes/transcode/session-file/get.js";
|
|
32
32
|
import { handleTranscodeVariantFileGet } from "./routes/transcode/variant-file/get.js";
|
|
33
|
+
import { handleTranscodeVariantWarmGet } from "./routes/transcode/variant-warm/get.js";
|
|
33
34
|
import { createSourceRegistry } from "./store/source-registry.js";
|
|
34
35
|
import { WorkerTorrentPool } from "./services/torrent-worker/pool-adapter.js";
|
|
35
36
|
import { HlsSessionManager } from "./services/hls-session-manager.js";
|
|
@@ -219,6 +220,12 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
|
|
|
219
220
|
// A quality variant's files. Registered before the static handler for the
|
|
220
221
|
// same reason as the line above, and kept a separate route rather than a
|
|
221
222
|
// wildcard so the height stays a parsed parameter.
|
|
223
|
+
// Registered BEFORE the variant file route: `warm` is not a file name, and
|
|
224
|
+
// Fastify matches a static segment ahead of a parameter either way — stated
|
|
225
|
+
// here so the order is not "tidied" into a bug.
|
|
226
|
+
app.get("/transcode/:sessionId/v/:height/warm", async (req, reply) =>
|
|
227
|
+
handleTranscodeVariantWarmGet(req, reply, { hlsSessionManager })
|
|
228
|
+
);
|
|
222
229
|
app.get("/transcode/:sessionId/v/:height/:fileName", async (req, reply) =>
|
|
223
230
|
handleTranscodeVariantFileGet(req, reply, { hlsSessionManager })
|
|
224
231
|
);
|
|
@@ -155,6 +155,34 @@ export function noteIndexDeviation(check, index, deviationSec) {
|
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
/**
|
|
159
|
+
* The same budget, starting at the top of its own ladder.
|
|
160
|
+
*
|
|
161
|
+
* The automatic choice takes the highest rung this host can encode faster than
|
|
162
|
+
* realtime. A viewer who names a resolution has already made that choice, so
|
|
163
|
+
* the encode starts where they said — and the ladder stays, because a host that
|
|
164
|
+
* turns out unable to keep up must still have somewhere to go.
|
|
165
|
+
*
|
|
166
|
+
* @param {{ ladder: { width: number, height: number }[], rungIndex: number } | null} budget
|
|
167
|
+
* @param {number} outputFps
|
|
168
|
+
* @param {unknown} benchmark
|
|
169
|
+
* @returns {object | null}
|
|
170
|
+
*/
|
|
171
|
+
function startAtLadderTop(budget, outputFps, benchmark) {
|
|
172
|
+
const top = budget?.ladder?.[0];
|
|
173
|
+
if (!top) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
const fps = Number.isInteger(outputFps) && outputFps > 0 ? outputFps : TRANSCODE_FPS;
|
|
177
|
+
return {
|
|
178
|
+
...budget,
|
|
179
|
+
width: top.width,
|
|
180
|
+
height: top.height,
|
|
181
|
+
preset: pickSoftwarePreset(benchmark, top.width * top.height * fps),
|
|
182
|
+
rungIndex: 0
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
158
186
|
/**
|
|
159
187
|
* The consumer a base session registers on its variants.
|
|
160
188
|
*
|
|
@@ -1414,16 +1442,23 @@ export class HlsSessionManager {
|
|
|
1414
1442
|
// resolution, so encode exactly that box (capped to source by the scale
|
|
1415
1443
|
// filter) with the default preset, and the runtime downswitch is skipped
|
|
1416
1444
|
// for the session (budgetLadder stays null).
|
|
1417
|
-
const
|
|
1418
|
-
|
|
1419
|
-
:
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1445
|
+
const chosenBudget = this.#chooseEncodeBudget({
|
|
1446
|
+
transcodeVideo,
|
|
1447
|
+
targetWidth: normalizedTargetWidth,
|
|
1448
|
+
targetHeight: normalizedTargetHeight,
|
|
1449
|
+
sourceWidth,
|
|
1450
|
+
sourceHeight,
|
|
1451
|
+
outputFps
|
|
1452
|
+
});
|
|
1453
|
+
// A forced resolution starts at exactly that size — the viewer asked for it
|
|
1454
|
+
// — but KEEPS the ladder beneath it. Discarding the ladder is what left a
|
|
1455
|
+
// viewer with no picture at all on 2026-08-11: they picked 480p on a host
|
|
1456
|
+
// that encodes it at 0.27-0.78x, and with the runtime downshift disabled
|
|
1457
|
+
// nothing could step in, so the stream simply never caught up. A smaller
|
|
1458
|
+
// picture that plays beats a correct label that freezes. The rung's NAME is
|
|
1459
|
+
// settled separately and does not move with a downshift, so the player goes
|
|
1460
|
+
// on addressing it by the height it chose.
|
|
1461
|
+
const encodeBudget = forceManualQuality ? startAtLadderTop(chosenBudget, outputFps, this.softwarePresetBenchmark) : chosenBudget;
|
|
1427
1462
|
const softwarePreset = encodeBudget?.preset ?? null;
|
|
1428
1463
|
// Effective encode box: the budget's downscaled resolution when applied,
|
|
1429
1464
|
// otherwise the client target (0 = keep source, handled by buildVideoArgs).
|
|
@@ -1607,8 +1642,12 @@ export class HlsSessionManager {
|
|
|
1607
1642
|
`${sourceWidth && sourceHeight ? `src=${sourceWidth}x${sourceHeight} ` : ""}` +
|
|
1608
1643
|
// Effective encode resolution: budget-on (auto downscale from the
|
|
1609
1644
|
// ceiling), manual (user-forced, budget off), or unset (keep source).
|
|
1610
|
-
`${transcodeVideo && encodeBudget
|
|
1611
|
-
|
|
1645
|
+
`${transcodeVideo && encodeBudget
|
|
1646
|
+
? `enc=${encodeWidth}x${encodeHeight}@${outputFps} ` +
|
|
1647
|
+
`quality=${forceManualQuality ? "manual" : "auto"} ` +
|
|
1648
|
+
`budget=${encodeBudget.ladder ? `rung ${encodeBudget.rungIndex + 1}/${encodeBudget.ladder.length}` : "off"} `
|
|
1649
|
+
: ""}` +
|
|
1650
|
+
`${transcodeVideo && !encodeBudget && forceManualQuality ? `enc=${encodeWidth || "src"}x${encodeHeight || "src"}@${outputFps} quality=manual budget=off ` : ""}` +
|
|
1612
1651
|
// HDR source and whether the tone-map chain was applied (vs washed-out
|
|
1613
1652
|
// fallback when the filters are missing or on a hardware encoder).
|
|
1614
1653
|
`${transcodeVideo && mediaInfo.isHdr ? `hdr=1 tonemap=${applyTonemap ? "on" : "off"} ` : ""}` +
|
|
@@ -3735,9 +3774,18 @@ export class HlsSessionManager {
|
|
|
3735
3774
|
session.indexCheck ??= newIndexCheck();
|
|
3736
3775
|
noteIndexDeviation(session.indexCheck, index, deviation);
|
|
3737
3776
|
if (deviation > SEGMENT_START_DISAGREEMENT_SEC) {
|
|
3777
|
+
// Which boundary the true start DOES match, if any. This is what tells
|
|
3778
|
+
// the two possible faults apart, and they need opposite fixes: matching
|
|
3779
|
+
// boundary #N-1 means our numbering is shifted by one — a fault in this
|
|
3780
|
+
// code, where the run begins — while matching nothing means the container
|
|
3781
|
+
// index describes times the file does not have. Measured 2026-08-11,
|
|
3782
|
+
// three samples all matched N-1, which is why the line now says so
|
|
3783
|
+
// instead of leaving it to be inferred from the numbers.
|
|
3784
|
+
const at = this.#boundaryIndexAt(session, trueStart);
|
|
3738
3785
|
logger.warn(
|
|
3739
3786
|
`transcode ${session.id} segment #${index} really starts at ` +
|
|
3740
|
-
`${trueStart.toFixed(3)}s
|
|
3787
|
+
`${trueStart.toFixed(3)}s (boundary ${at === null ? "none" : `#${at}`}), ` +
|
|
3788
|
+
`the playlist says ${declaredStart.toFixed(3)}s — ` +
|
|
3741
3789
|
(session.transcodeVideo
|
|
3742
3790
|
// A re-encode was TOLD to put a keyframe here and did not, so this
|
|
3743
3791
|
// rung's segments no longer stand where the stream it accompanies
|
|
@@ -3748,6 +3796,29 @@ export class HlsSessionManager {
|
|
|
3748
3796
|
}
|
|
3749
3797
|
}
|
|
3750
3798
|
|
|
3799
|
+
/**
|
|
3800
|
+
* The boundary a time falls on, or null when it falls on none of them.
|
|
3801
|
+
*
|
|
3802
|
+
* Within the same tolerance a disagreement is judged by, so "matches boundary
|
|
3803
|
+
* #N-1" and "matches nothing" mean what they say.
|
|
3804
|
+
*
|
|
3805
|
+
* @param {HlsSession} session
|
|
3806
|
+
* @param {number} seconds
|
|
3807
|
+
* @returns {number | null}
|
|
3808
|
+
*/
|
|
3809
|
+
#boundaryIndexAt(session, seconds) {
|
|
3810
|
+
const boundaries = session.segmentBoundaries;
|
|
3811
|
+
if (!Array.isArray(boundaries)) {
|
|
3812
|
+
return null;
|
|
3813
|
+
}
|
|
3814
|
+
for (let index = 0; index < boundaries.length; index += 1) {
|
|
3815
|
+
if (Math.abs(boundaries[index] - seconds) <= SEGMENT_START_DISAGREEMENT_SEC) {
|
|
3816
|
+
return index;
|
|
3817
|
+
}
|
|
3818
|
+
}
|
|
3819
|
+
return null;
|
|
3820
|
+
}
|
|
3821
|
+
|
|
3751
3822
|
/**
|
|
3752
3823
|
* What this session learned about its container's keyframe index, as one
|
|
3753
3824
|
* line, at the end.
|
|
@@ -4133,6 +4204,68 @@ export class HlsSessionManager {
|
|
|
4133
4204
|
return { sessionId: variant.id };
|
|
4134
4205
|
}
|
|
4135
4206
|
|
|
4207
|
+
/**
|
|
4208
|
+
* Prepare a rung the viewer is about to switch to, without switching to it.
|
|
4209
|
+
*
|
|
4210
|
+
* The rung does not exist until it is asked for, so the moment the player is
|
|
4211
|
+
* told to switch it has nothing to fetch and the viewer watches a spinner
|
|
4212
|
+
* while an encoder starts from nothing — measured 2026-08-11 at 15 988 ms for
|
|
4213
|
+
* the first segment of a rung producing at 1.2x. Nothing can make that
|
|
4214
|
+
* production instant; what CAN be done is to have it happen while the rung
|
|
4215
|
+
* the viewer is on is still playing.
|
|
4216
|
+
*
|
|
4217
|
+
* So this creates and positions the variant and says which segment to wait
|
|
4218
|
+
* for, and deliberately does NOT mark it active: the rung on screen keeps its
|
|
4219
|
+
* encoder until the player actually moves. Both encoders run for the length
|
|
4220
|
+
* of the warm-up, which is the price of the switch not being visible.
|
|
4221
|
+
*
|
|
4222
|
+
* @param {string} baseSessionId
|
|
4223
|
+
* @param {number} height
|
|
4224
|
+
* @param {number} positionSeconds - Where the switch will happen.
|
|
4225
|
+
* @returns {Promise<{ sessionId: string, fileName: string } | null>}
|
|
4226
|
+
*/
|
|
4227
|
+
async prepareVariant(baseSessionId, height, positionSeconds) {
|
|
4228
|
+
if (!isSafeSessionId(baseSessionId)) {
|
|
4229
|
+
return null;
|
|
4230
|
+
}
|
|
4231
|
+
const base = this.sessionsById.get(baseSessionId);
|
|
4232
|
+
if (!base || base.state === "disposed") {
|
|
4233
|
+
return null;
|
|
4234
|
+
}
|
|
4235
|
+
if (!this.#variantHeights(base).includes(height)) {
|
|
4236
|
+
return null;
|
|
4237
|
+
}
|
|
4238
|
+
const index = this.#segmentIndexForTime(base, positionSeconds);
|
|
4239
|
+
const variant = await this.resolveVariantSession(baseSessionId, height, index);
|
|
4240
|
+
if (!variant) {
|
|
4241
|
+
return null;
|
|
4242
|
+
}
|
|
4243
|
+
// A rung warmed for a switch that was never made. Nothing else would ever
|
|
4244
|
+
// stop it: only becoming active stops the rung being left, so a viewer
|
|
4245
|
+
// trying two rungs in a row would leave the first encoding for nobody until
|
|
4246
|
+
// the look-ahead cap suspended it — three encoders at once on a host sized
|
|
4247
|
+
// for one, which is the opposite of what warming is for.
|
|
4248
|
+
const stillWarming = base.warmingVariantId;
|
|
4249
|
+
if (stillWarming && stillWarming !== variant.id) {
|
|
4250
|
+
const abandoned = this.sessionsById.get(stillWarming);
|
|
4251
|
+
if (abandoned && abandoned.id !== this.#activeVariant(base).id) {
|
|
4252
|
+
this.#stopEncodeRun(abandoned, "warmed for a switch the viewer did not make");
|
|
4253
|
+
}
|
|
4254
|
+
}
|
|
4255
|
+
base.warmingVariantId = variant.id === base.id ? null : variant.id;
|
|
4256
|
+
// An existing rung may be parked wherever it was left, so it is pointed at
|
|
4257
|
+
// the switch position exactly as an activation would — the difference is
|
|
4258
|
+
// only that the rung on screen keeps its own encoder meanwhile.
|
|
4259
|
+
variant.lastAccessedAt = Date.now();
|
|
4260
|
+
if (variant.id !== base.id) {
|
|
4261
|
+
this.requestSeek(variant.id, this.#segmentStartTime(base, index));
|
|
4262
|
+
}
|
|
4263
|
+
logger.info(
|
|
4264
|
+
`transcode ${base.id} warming ${height}p at ${positionSeconds.toFixed(1)}s (segment #${index})`
|
|
4265
|
+
);
|
|
4266
|
+
return { sessionId: variant.id, fileName: variant.segmentFormat.segmentFileName(index) };
|
|
4267
|
+
}
|
|
4268
|
+
|
|
4136
4269
|
/**
|
|
4137
4270
|
* Record which variant the viewer is watching, and give it the encoder.
|
|
4138
4271
|
*
|
|
@@ -4148,6 +4281,18 @@ export class HlsSessionManager {
|
|
|
4148
4281
|
*/
|
|
4149
4282
|
#noteVariantActive(base, variant, wantedIndex = -1) {
|
|
4150
4283
|
const previous = this.#activeVariant(base);
|
|
4284
|
+
// Whatever was warmed is decided now: either it is the rung being switched
|
|
4285
|
+
// to, or the viewer went elsewhere and it must stop like any other rung
|
|
4286
|
+
// nobody is watching. Nothing else would ever stop it — only the rung being
|
|
4287
|
+
// LEFT is stopped below.
|
|
4288
|
+
const warmed = base.warmingVariantId;
|
|
4289
|
+
base.warmingVariantId = null;
|
|
4290
|
+
if (warmed && warmed !== variant.id && warmed !== previous.id) {
|
|
4291
|
+
const abandoned = this.sessionsById.get(warmed);
|
|
4292
|
+
if (abandoned) {
|
|
4293
|
+
this.#stopEncodeRun(abandoned, "warmed for a switch the viewer did not make");
|
|
4294
|
+
}
|
|
4295
|
+
}
|
|
4151
4296
|
if (previous.id === variant.id) {
|
|
4152
4297
|
return;
|
|
4153
4298
|
}
|
|
@@ -283,6 +283,33 @@ test("a rung is placed where the player asked it for, not where the other rung h
|
|
|
283
283
|
);
|
|
284
284
|
});
|
|
285
285
|
|
|
286
|
+
test("warming a rung prepares it without taking the encoder from the one on screen", async (t) => {
|
|
287
|
+
const { manager, base, dirPath } = await managerWithBase();
|
|
288
|
+
t.after(async () => {
|
|
289
|
+
await manager.disposeAll();
|
|
290
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
291
|
+
});
|
|
292
|
+
const variant = fakeSession({ id: VARIANT_ID, encodeHeight: 540, dirPath });
|
|
293
|
+
variant.variantHeight = 540;
|
|
294
|
+
variant.variantBases = new Set([BASE_ID]);
|
|
295
|
+
manager.sessionsById.set(VARIANT_ID, variant);
|
|
296
|
+
base.variants = new Map([[540, VARIANT_ID]]);
|
|
297
|
+
const encoder = fakeEncoder();
|
|
298
|
+
base.ffmpeg = encoder;
|
|
299
|
+
|
|
300
|
+
const prepared = await manager.prepareVariant(BASE_ID, 540, 240);
|
|
301
|
+
|
|
302
|
+
assert.deepEqual(
|
|
303
|
+
prepared,
|
|
304
|
+
{ sessionId: VARIANT_ID, fileName: "segment-00060.mp4" },
|
|
305
|
+
"the caller is told which segment to wait for — 240 s on a four-second grid"
|
|
306
|
+
);
|
|
307
|
+
assert.equal(variant.seekTarget, 59, "the rung is pointed at the switch position, one back for the keyframe");
|
|
308
|
+
assert.equal(base.activeVariantId, undefined, "nothing has switched yet");
|
|
309
|
+
assert.equal(base.ffmpeg, encoder, "the picture on screen keeps its encoder until the player actually moves");
|
|
310
|
+
assert.deepEqual(encoder.signals, [], "stopping it here is what would put the spinner back");
|
|
311
|
+
});
|
|
312
|
+
|
|
286
313
|
test("the viewer's position is kept current by the segments they ask for", async (t) => {
|
|
287
314
|
const { manager, base, dirPath } = await managerWithBase();
|
|
288
315
|
t.after(async () => {
|