@torrent-tv/proxy 2.10.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.
@@ -148,15 +148,37 @@ function safeDimensions(targetWidth, targetHeight) {
148
148
 
149
149
  /**
150
150
  * Force a keyframe on every segment boundary so each HLS segment is
151
- * independently decodable and exactly `segmentDurationSec` long.
151
+ * independently decodable.
152
+ *
153
+ * Two grids exist. The usual one is even — a keyframe every
154
+ * `segmentDurationSec` — and the encoder is free to place them because it is
155
+ * producing every frame anyway. The other is the SOURCE's own keyframe times,
156
+ * used when this encode has to be interchangeable with a stream that is
157
+ * COPIED: a copy can only be cut where the source already has a keyframe, so a
158
+ * rung meant to splice into it must be cut at exactly those times and nowhere
159
+ * else. Then the times are given outright.
152
160
  *
153
161
  * @param {number} segmentDurationSec
162
+ * @param {number[] | null} [forcedTimes] - Run-relative seconds, ascending.
154
163
  * @returns {string[]}
155
164
  */
156
- function keyFrameArgs(segmentDurationSec) {
165
+ function keyFrameArgs(segmentDurationSec, forcedTimes = null) {
166
+ if (Array.isArray(forcedTimes) && forcedTimes.length > 0) {
167
+ return ["-force_key_frames", forcedTimes.join(",")];
168
+ }
157
169
  return ["-force_key_frames", `expr:gte(t,n_forced*${segmentDurationSec})`];
158
170
  }
159
171
 
172
+ /**
173
+ * Whether an explicit cut list was supplied.
174
+ *
175
+ * @param {number[] | null | undefined} forcedTimes
176
+ * @returns {boolean}
177
+ */
178
+ function hasForcedTimes(forcedTimes) {
179
+ return Array.isArray(forcedTimes) && forcedTimes.length > 0;
180
+ }
181
+
160
182
  /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
161
183
  export function softwareDescriptor() {
162
184
  return {
@@ -164,7 +186,7 @@ export function softwareDescriptor() {
164
186
  kind: "software",
165
187
  device: null,
166
188
  inputArgs: [],
167
- buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, preset, fps, tonemap }) {
189
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, preset, fps, tonemap, forcedKeyframeTimes }) {
168
190
  const { w, h } = safeDimensions(targetWidth, targetHeight);
169
191
  const chosenPreset = typeof preset === "string" && preset.length > 0 ? preset : SOFTWARE_PRESET;
170
192
  // Output frame rate: inherited from the source (rounded/capped) by the
@@ -200,11 +222,24 @@ export function softwareDescriptor() {
200
222
  // independent of the PTS offset used on seek-restart — every HLS segment
201
223
  // is exactly segmentDurationSec long and starts on a keyframe, so segment
202
224
  // boundaries line up with the synthetic playlist with no gaps. (The old
203
- // `-force_key_frames expr:gte(t,n_forced*SEG)` broke after a seek because
204
- // `t` is offset by `-output_ts_offset`, forcing keyframes at the wrong
205
- // places.)
225
+ // the OLD `expr:` form of -force_key_frames broke after a seek, because
226
+ // the `t` it reads is shifted by `-output_ts_offset`.)
227
+ //
228
+ // An explicit cut LIST is a different thing and does work: verified by
229
+ // running it, its times are on the run's own timeline — the same one
230
+ // `-segment_times` is measured on — so both are given one list and
231
+ // cannot drift apart. It replaces the frame-count GOP, which cannot
232
+ // describe the source's keyframes because they are not evenly spaced.
233
+ // `-g` stays as an upper bound on the interval: an extra keyframe
234
+ // inside a segment costs a little bitrate and cuts nothing, while
235
+ // leaving the interval unbounded means a driver that ignores the list
236
+ // produces one enormous segment instead of a wrong but cut one.
237
+ // `-keyint_min` goes, since a MINIMUM interval is the one thing that
238
+ // could argue with a forced keyframe.
206
239
  "-g", String(segmentDurationSec * outFps),
207
- "-keyint_min", String(segmentDurationSec * outFps),
240
+ ...(hasForcedTimes(forcedKeyframeTimes)
241
+ ? keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
242
+ : ["-keyint_min", String(segmentDurationSec * outFps)]),
208
243
  "-sc_threshold", "0"
209
244
  ];
210
245
  }
@@ -224,14 +259,14 @@ function vaapiDescriptor(device) {
224
259
  inputArgs: ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", device],
225
260
  // No fps filter: VAAPI inherits the source rate and keeps keyframes on the
226
261
  // grid via time-based -force_key_frames, so it already honours source fps.
227
- buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
262
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, forcedKeyframeTimes }) {
228
263
  const { w, h } = safeDimensions(targetWidth, targetHeight);
229
264
  return [
230
265
  "-vf",
231
266
  `scale_vaapi=w=${w}:h=${h}:force_original_aspect_ratio=decrease`,
232
267
  "-c:v", "h264_vaapi",
233
268
  "-qp", "24",
234
- ...keyFrameArgs(segmentDurationSec)
269
+ ...keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
235
270
  ];
236
271
  }
237
272
  };
@@ -247,13 +282,13 @@ function qsvDescriptor(device) {
247
282
  kind: "qsv",
248
283
  device,
249
284
  inputArgs: ["-hwaccel", "qsv", "-qsv_device", device],
250
- buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
285
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, forcedKeyframeTimes }) {
251
286
  const { w, h } = safeDimensions(targetWidth, targetHeight);
252
287
  return [
253
288
  "-vf", `scale_qsv=w=${w}:h=${h}`,
254
289
  "-c:v", "h264_qsv",
255
290
  "-global_quality", "24",
256
- ...keyFrameArgs(segmentDurationSec)
291
+ ...keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
257
292
  ];
258
293
  }
259
294
  };
@@ -269,7 +304,7 @@ function nvencDescriptor() {
269
304
  // No fps filter: NVENC is fast and places keyframes by time-based
270
305
  // -force_key_frames, so it inherits the exact source rate (fractional
271
306
  // included) with no need to round or cap. Same rationale as VAAPI/QSV.
272
- buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
307
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, forcedKeyframeTimes }) {
273
308
  const { w, h } = safeDimensions(targetWidth, targetHeight);
274
309
  return [
275
310
  "-vf",
@@ -278,7 +313,7 @@ function nvencDescriptor() {
278
313
  "-preset", "p4",
279
314
  "-cq", "24",
280
315
  "-pix_fmt", "yuv420p",
281
- ...keyFrameArgs(segmentDurationSec)
316
+ ...keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
282
317
  ];
283
318
  }
284
319
  };
@@ -296,7 +331,7 @@ function v4l2m2mDescriptor() {
296
331
  kind: "v4l2m2m",
297
332
  device: null,
298
333
  inputArgs: [],
299
- buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, fps }) {
334
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, fps, forcedKeyframeTimes }) {
300
335
  const { w, h } = safeDimensions(targetWidth, targetHeight);
301
336
  const outFps = Number.isInteger(fps) && fps > 0 ? fps : TRANSCODE_FPS;
302
337
  return [
@@ -308,8 +343,12 @@ function v4l2m2mDescriptor() {
308
343
  // userspace").
309
344
  "-num_capture_buffers", "32",
310
345
  "-b:v", "3M",
346
+ // Kept even with an explicit cut list, as an upper bound on the
347
+ // interval: this encoder is the one known not always to honour keyframe
348
+ // hints, and without any bound a list it ignores yields one segment for
349
+ // the whole file rather than a wrongly-cut one.
311
350
  "-g", String(outFps * segmentDurationSec),
312
- ...keyFrameArgs(segmentDurationSec)
351
+ ...keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
313
352
  ];
314
353
  }
315
354
  };
@@ -322,7 +361,7 @@ function v4l2m2mDescriptor() {
322
361
  * @property {"software"|"vaapi"|"qsv"|"nvenc"|"v4l2m2m"} kind
323
362
  * @property {string|null} device
324
363
  * @property {string[]} inputArgs
325
- * @property {(opts: { targetWidth: number, targetHeight: number, segmentDurationSec: number }) => string[]} buildVideoArgs
364
+ * @property {(opts: { targetWidth: number, targetHeight: number, segmentDurationSec: number, preset?: string, fps?: number, tonemap?: boolean, forcedKeyframeTimes?: number[] | null }) => string[]} buildVideoArgs
326
365
  */
327
366
 
328
367
  /**
@@ -0,0 +1,187 @@
1
+ /**
2
+ * @file A segment request below the running encode must not be held for ever.
3
+ *
4
+ * The encoder only moves forward from where its run began, so a request BELOW
5
+ * that point cannot be answered by anything the run does. Every other far
6
+ * request is a claim the running encode may yet reach; this one is a hole.
7
+ *
8
+ * Measured 2026-08-11: a quality switch placed a run at segment #770 while the
9
+ * player needed #757, and the request was held for two minutes forty-one while
10
+ * the encoder produced 409 s of video nobody had asked for at 2.48x. The
11
+ * placement that caused it is fixed; this is the guard that stops the SHAPE
12
+ * from hanging a session again, whatever puts it there.
13
+ */
14
+
15
+ import test from "node:test";
16
+ import assert from "node:assert/strict";
17
+ import { mkdtemp, rm } from "node:fs/promises";
18
+ import os from "node:os";
19
+ import path from "node:path";
20
+ import { HlsSessionManager } from "../services/hls-session-manager.js";
21
+ import { fmp4Format } from "../services/segment-formats/fmp4.js";
22
+
23
+ const SESSION_ID = "bbbbbbbb-cccc-dddd-eeee-ffffffffffff";
24
+ const SEGMENT_SECONDS = 4;
25
+ const RUN_STARTS_AT = 770;
26
+ const WANTED = 757;
27
+
28
+ /**
29
+ * A session whose run began well past the segment being asked for.
30
+ *
31
+ * @returns {Promise<{ manager: HlsSessionManager, session: object, dirPath: string, restarts: number[] }>}
32
+ */
33
+ async function managerWithRunAhead() {
34
+ const dirPath = await mkdtemp(path.join(os.tmpdir(), "behind-head-"));
35
+ const manager = new HlsSessionManager({
36
+ enabled: true,
37
+ ffmpegBin: "ffmpeg",
38
+ localBindHost: "127.0.0.1",
39
+ localPort: 9090
40
+ });
41
+ const session = {
42
+ id: SESSION_ID,
43
+ dirPath,
44
+ state: "ready",
45
+ fileName: "film.avi",
46
+ startedAt: Date.now(),
47
+ createEntryMs: Date.now(),
48
+ lastAccessedAt: Date.now(),
49
+ lastError: "",
50
+ consumers: new Set(),
51
+ segmentFormat: fmp4Format,
52
+ transcodeVideo: true,
53
+ useSyntheticPlaylist: true,
54
+ playlistText: "#EXTM3U\n",
55
+ segmentBoundaries: Array.from({ length: 1937 }, (_, index) => index * SEGMENT_SECONDS),
56
+ segmentCount: 1936,
57
+ encodeStartIndex: RUN_STARTS_AT,
58
+ encodeRunGeneration: 0,
59
+ lastRestartAt: 0,
60
+ seekFailureTarget: -1,
61
+ seekFailureCount: 0,
62
+ seekSettleTimer: null,
63
+ seekTarget: null,
64
+ waitEpoch: 0,
65
+ firstWantedAt: new Map(),
66
+ ffmpeg: { pid: 4321, exitCode: null, signalCode: null, kill() {}, once(event, handler) { if (event === "exit") handler(); } },
67
+ progress: { state: "running", processedSeconds: RUN_STARTS_AT * SEGMENT_SECONDS + 400, startPositionSeconds: RUN_STARTS_AT * SEGMENT_SECONDS }
68
+ };
69
+ manager.sessionsById.set(SESSION_ID, session);
70
+ const restarts = [];
71
+ return { manager, session, dirPath, restarts };
72
+ }
73
+
74
+ test("a request behind the run is repaired once it has waited", async (t) => {
75
+ const { manager, session, dirPath } = await managerWithRunAhead();
76
+ t.after(async () => {
77
+ await manager.disposeAll();
78
+ await rm(dirPath, { recursive: true, force: true });
79
+ });
80
+ // Asked for four seconds ago and still unanswerable.
81
+ session.firstWantedAt.set(WANTED, Date.now() - 4000);
82
+
83
+ await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(WANTED), { requestSeq: 1 });
84
+
85
+ assert.equal(
86
+ session.seekTarget,
87
+ WANTED - 1,
88
+ "the encoder must be moved back to it — one segment early, for the preceding keyframe"
89
+ );
90
+ });
91
+
92
+ test("a request behind the run is left alone at first", async (t) => {
93
+ const { manager, session, dirPath } = await managerWithRunAhead();
94
+ t.after(async () => {
95
+ await manager.disposeAll();
96
+ await rm(dirPath, { recursive: true, force: true });
97
+ });
98
+
99
+ await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(WANTED), { requestSeq: 1 });
100
+
101
+ assert.equal(
102
+ session.seekTarget,
103
+ null,
104
+ "a burst around a reported seek settles by itself, and the seek is what should move the encoder"
105
+ );
106
+ });
107
+
108
+ test("a seek already settling is not overridden", async (t) => {
109
+ const { manager, session, dirPath } = await managerWithRunAhead();
110
+ t.after(async () => {
111
+ await manager.disposeAll();
112
+ await rm(dirPath, { recursive: true, force: true });
113
+ });
114
+ session.firstWantedAt.set(WANTED, Date.now() - 4000);
115
+ // The viewer has stated where they are and it is about to be acted on.
116
+ session.seekTarget = 1200;
117
+ session.seekSettleTimer = setTimeout(() => {}, 60_000);
118
+ t.after(() => clearTimeout(session.seekSettleTimer));
119
+
120
+ await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(WANTED), { requestSeq: 1 });
121
+
122
+ assert.equal(
123
+ session.seekTarget,
124
+ 1200,
125
+ "a statement from the viewer outranks anything inferred from what the player is fetching"
126
+ );
127
+ });
128
+
129
+ test("a playlist scan far below the run is left where it belongs", async (t) => {
130
+ const { manager, session, dirPath } = await managerWithRunAhead();
131
+ t.after(async () => {
132
+ await manager.disposeAll();
133
+ await rm(dirPath, { recursive: true, force: true });
134
+ });
135
+ // A player that cannot get what it wants scans the playlist: field log
136
+ // 2026-08-02, probes at #178, #681, #725, #807, #74, #245, #387 within half a
137
+ // second. Steering on the lowest of those put the encoder at the start of the
138
+ // film and left the viewer's own requests unreachable ahead of it — the exact
139
+ // reason request-steering was removed from this proxy.
140
+ const probe = 74;
141
+ session.firstWantedAt.set(probe, Date.now() - 30_000);
142
+
143
+ await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(probe), { requestSeq: 1 });
144
+
145
+ assert.equal(
146
+ session.seekTarget,
147
+ null,
148
+ "a misplaced run is out by a buffer; a scan probe is out by anything, and the two must not be confused"
149
+ );
150
+ });
151
+
152
+ test("a rung whose encoder was stopped is not brought back by a held request", async (t) => {
153
+ const { manager, session, dirPath } = await managerWithRunAhead();
154
+ t.after(async () => {
155
+ await manager.disposeAll();
156
+ await rm(dirPath, { recursive: true, force: true });
157
+ });
158
+ // What a quality switch leaves behind: the rung nobody is watching, parked.
159
+ session.ffmpeg = null;
160
+ session.firstWantedAt.set(WANTED, Date.now() - 4000);
161
+
162
+ await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(WANTED), { requestSeq: 1 });
163
+
164
+ assert.equal(
165
+ session.seekTarget,
166
+ null,
167
+ "restarting it would put a second encoder on a host sized for one, for a rung nobody is watching"
168
+ );
169
+ });
170
+
171
+ test("a request ahead of the run is not touched", async (t) => {
172
+ const { manager, session, dirPath } = await managerWithRunAhead();
173
+ t.after(async () => {
174
+ await manager.disposeAll();
175
+ await rm(dirPath, { recursive: true, force: true });
176
+ });
177
+ const ahead = RUN_STARTS_AT + 400;
178
+ session.firstWantedAt.set(ahead, Date.now() - 30_000);
179
+
180
+ await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(ahead), { requestSeq: 1 });
181
+
182
+ assert.equal(
183
+ session.seekTarget,
184
+ null,
185
+ "the running encode may yet reach it; restarting on a far request is what produced nine restarts in a minute"
186
+ );
187
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @file How well a container's keyframe index describes its own file.
3
+ *
4
+ * The cut times of a copied video ARE its index — ffmpeg can only cut where a
5
+ * keyframe already is — and an index can be wrong: measured 2026-08-06, one
6
+ * claimed a keyframe at 157.99 s where the real ones were 153.82 and 164.247.
7
+ * Whether a re-encoded rung can be cut on that same grid and spliced into the
8
+ * copy depends entirely on how often that happens, so it is counted.
9
+ *
10
+ * No scan is involved and no undownloaded byte is touched: each produced piece
11
+ * states where it truly begins, and it is already read whole in order to be
12
+ * stamped. Only boundaries that were actually produced are counted — the parts
13
+ * somebody watched.
14
+ */
15
+
16
+ import test from "node:test";
17
+ import assert from "node:assert/strict";
18
+ import { newIndexCheck, noteIndexDeviation } from "../services/hls-session-manager.js";
19
+
20
+ test("an index that describes its file exactly is reported as such", () => {
21
+ const check = newIndexCheck();
22
+
23
+ for (let index = 0; index < 4; index += 1) {
24
+ noteIndexDeviation(check, index, 0);
25
+ }
26
+
27
+ assert.equal(check.checked, 4);
28
+ assert.equal(check.disagreed, 0, "nothing disagreed — which is a finding, not silence");
29
+ assert.equal(check.maxDeviationSec, 0);
30
+ });
31
+
32
+ test("a boundary the index placed wrongly is counted, with how far out it was", () => {
33
+ const check = newIndexCheck();
34
+
35
+ noteIndexDeviation(check, 0, 0);
36
+ // The measured shape: the playlist said 157.99 s, the file cut at 153.82 s.
37
+ noteIndexDeviation(check, 2, 4.17);
38
+ noteIndexDeviation(check, 3, 0.01);
39
+
40
+ assert.equal(check.checked, 3);
41
+ assert.equal(check.disagreed, 1);
42
+ assert.equal(check.firstDisagreementIndex, 2);
43
+ assert.equal(
44
+ check.maxDeviationSec,
45
+ 4.17,
46
+ "the size of the error is what decides whether a rung can be cut on this grid"
47
+ );
48
+ });
49
+
50
+ test("a deviation within tolerance is not a disagreement, but still shows in the worst case", () => {
51
+ const check = newIndexCheck();
52
+
53
+ noteIndexDeviation(check, 0, 0.2);
54
+
55
+ assert.equal(check.disagreed, 0, "rounding in a container's timestamps is not the index being wrong");
56
+ assert.equal(check.maxDeviationSec, 0.2, "and it is still worth knowing how close to the line it ran");
57
+ });
58
+
59
+ test("a segment requested again is not new evidence", () => {
60
+ const check = newIndexCheck();
61
+
62
+ noteIndexDeviation(check, 1, 0.9);
63
+ noteIndexDeviation(check, 1, 0.9);
64
+ noteIndexDeviation(check, 1, 0.9);
65
+
66
+ assert.equal(check.checked, 1, "a repeat request is the same boundary, counted once");
67
+ assert.equal(check.disagreed, 1);
68
+ });
@@ -133,19 +133,37 @@ test("the master offers every rung, the session's own height among them", async
133
133
  );
134
134
  });
135
135
 
136
- test("a copied video is offered no variants", async (t) => {
136
+ test("a copied video is offered variants when its cut grid is real", async (t) => {
137
137
  const { manager, base, dirPath } = await managerWithBase();
138
138
  t.after(async () => {
139
139
  await manager.disposeAll();
140
140
  await rm(dirPath, { recursive: true, force: true });
141
141
  });
142
+ // A copy is cut at the source's own keyframes — it has no other choice. A
143
+ // re-encoded rung CAN be cut there too, by being told those times, and then
144
+ // its segments cover the same spans and can stand in the copy's place.
142
145
  base.transcodeVideo = false;
146
+ base.cutGrid = "keyframe";
143
147
 
144
- assert.equal(
145
- manager.buildMasterPlaylist(BASE_ID),
146
- null,
147
- "its segments are cut at the source's own keyframes, so a re-encoded rung cannot be spliced into it"
148
- );
148
+ const master = manager.buildMasterPlaylist(BASE_ID);
149
+
150
+ assert.ok(master, "the obstacle was never the encoder, it was the cut points");
151
+ assert.match(master, /^v\/1080\/index\.m3u8$/m, "the copy itself is the top rung no encoder, no cost");
152
+ assert.match(master, /^v\/540\/index\.m3u8$/m);
153
+ });
154
+
155
+ test("a copied video with no readable keyframe index is offered nothing", async (t) => {
156
+ const { manager, base, dirPath } = await managerWithBase();
157
+ t.after(async () => {
158
+ await manager.disposeAll();
159
+ await rm(dirPath, { recursive: true, force: true });
160
+ });
161
+ // Its playlist claims an even grid that ffmpeg does not cut on. Aligning a
162
+ // rung to that is aligning it to a fiction.
163
+ base.transcodeVideo = false;
164
+ base.cutGrid = "uniform";
165
+
166
+ assert.equal(manager.buildMasterPlaylist(BASE_ID), null);
149
167
  });
150
168
 
151
169
  test("the session's own height resolves to the session itself", async (t) => {
@@ -236,6 +254,62 @@ test("a segment request hands the encoder to the variant the viewer moved to", a
236
254
  );
237
255
  });
238
256
 
257
+ test("a rung is placed where the player asked it for, not where the other rung had read to", async (t) => {
258
+ const { manager, base, dirPath } = await managerWithBase();
259
+ t.after(async () => {
260
+ await manager.disposeAll();
261
+ await rm(dirPath, { recursive: true, force: true });
262
+ });
263
+ const variant = fakeSession({ id: VARIANT_ID, encodeHeight: 540, dirPath });
264
+ variant.variantHeight = 540;
265
+ variant.variantBases = new Set([BASE_ID]);
266
+ manager.sessionsById.set(VARIANT_ID, variant);
267
+ base.variants = new Map([[540, VARIANT_ID]]);
268
+ base.ffmpeg = fakeEncoder();
269
+ // The rung being left had read fourteen segments further than the picture had
270
+ // played — an encoder running at several times realtime fills the buffer far
271
+ // ahead. Measured 2026-08-11: 56 s of gap, and using the read head placed the
272
+ // new run past everything the player then asked for, which no request could
273
+ // ever be answered from.
274
+ base.lastRequestedSegment = 70;
275
+ base.viewerPositionSeconds = 280;
276
+
277
+ await manager.resolveVariantFile(BASE_ID, 540, "segment-00056.mp4");
278
+
279
+ assert.equal(
280
+ variant.seekTarget,
281
+ 55,
282
+ "the segment the player asked this rung for is where it must begin (one back for the keyframe)"
283
+ );
284
+ });
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
+
239
313
  test("the viewer's position is kept current by the segments they ask for", async (t) => {
240
314
  const { manager, base, dirPath } = await managerWithBase();
241
315
  t.after(async () => {
@@ -306,21 +380,38 @@ test("a downshift does not rename the variant the viewer is watching", async (t)
306
380
  );
307
381
  });
308
382
 
309
- test("the cut grid does not depend on the encode height", () => {
310
- // Why a segment from one encoder can stand where another's would have: on the
311
- // re-encode path the boundaries are a uniform grid and the encoder is given a
312
- // fixed GOP of the same length, so segment N covers the same span at every
313
- // rung. Nothing about the height enters this and nothing may, or the
314
- // variants stop being interchangeable.
315
- const shape = { transcodeVideo: true, durationSeconds: 100, segDur: SEGMENT_SECONDS, startTime: 0 };
316
- const withIndex = computeSegmentBoundaries({ ...shape, keyframeTimes: [0, 3.1, 9.7, 14.2] });
317
- const withoutIndex = computeSegmentBoundaries({ ...shape, keyframeTimes: null });
383
+ test("the cut grid follows the grid asked for, not who produces the frames", () => {
384
+ // Why a segment from one encoder can stand where another's would have: both
385
+ // are cut at the same times. Which times is a property of the SESSION the
386
+ // even grid, or the source's own keyframes and it must not be re-derived
387
+ // from whether the video is copied, because a variant of a copied stream is
388
+ // re-encoded and still has to land on the copy's cuts.
389
+ const shape = { durationSeconds: 100, segDur: SEGMENT_SECONDS, startTime: 0 };
390
+ const keyframeTimes = [0, 3.1, 9.7, 14.2, 21.5, 40, 61.25];
318
391
 
392
+ const even = computeSegmentBoundaries({ ...shape, useKeyframeGrid: false, keyframeTimes });
393
+ assert.equal(even[1], SEGMENT_SECONDS, "the even grid ignores the source's keyframes");
394
+ assert.equal(even.at(-1), 100);
395
+
396
+ const source = computeSegmentBoundaries({ ...shape, useKeyframeGrid: true, keyframeTimes });
397
+ assert.deepEqual(
398
+ source,
399
+ [0, 9.7, 14.2, 21.5, 40, 61.25, 100],
400
+ "the source's own keyframes, kept only where they are at least a segment apart"
401
+ );
402
+
403
+ // The one that matters: a copy and a re-encoded rung of it, given the same
404
+ // grid, produce the SAME table. Segment N then covers the same span in both,
405
+ // which is what lets one stand where the other would have.
406
+ assert.deepEqual(
407
+ computeSegmentBoundaries({ ...shape, useKeyframeGrid: true, keyframeTimes }),
408
+ source,
409
+ "a variant inherits the grid, so its boundaries are the same values"
410
+ );
411
+ // And with no index there is nothing to align to — the even grid, whoever asks.
319
412
  assert.deepEqual(
320
- withIndex,
321
- withoutIndex,
322
- "a re-encode forces its own keyframes onto the grid, so the source's keyframes cannot move the cuts"
413
+ computeSegmentBoundaries({ ...shape, useKeyframeGrid: true, keyframeTimes: null }),
414
+ even,
415
+ "no keyframes means no keyframe grid, however the caller asks"
323
416
  );
324
- assert.equal(withIndex[1], SEGMENT_SECONDS);
325
- assert.equal(withIndex.at(-1), 100);
326
417
  });
@@ -153,6 +153,22 @@ async function managerWithReadySegment(overrides = {}) {
153
153
  return { manager, session, dirPath };
154
154
  }
155
155
 
156
+ test("serving a segment records what its real start says about the container's index", async (t) => {
157
+ const { manager, session, dirPath } = await managerWithReadySegment();
158
+ t.after(async () => {
159
+ await manager.disposeAll();
160
+ await rm(dirPath, { recursive: true, force: true });
161
+ });
162
+ // The tally is counted in the module tests; what this pins is that serving a
163
+ // segment reaches it at all. A counter nothing increments reports a clean
164
+ // index for every file forever, which is worse than no measurement.
165
+ session.indexCheck = { checked: 0, disagreed: 0, maxDeviationSec: 0, firstDisagreementIndex: -1, seen: new Set() };
166
+
167
+ await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
168
+
169
+ assert.equal(session.indexCheck.checked, 1, "the boundary that was just produced must have been examined");
170
+ });
171
+
156
172
  test("a segment that exists is served, not reported as still being produced", async (t) => {
157
173
  const { manager, dirPath } = await managerWithReadySegment();
158
174
  t.after(async () => {
@@ -226,37 +242,37 @@ test("a run's FIRST segment is served once the encoder has passed it, without wa
226
242
  "the encoder is past this segment's end, so it is finished — the absence of a next one says nothing"
227
243
  );
228
244
  });
229
-
230
- test("a segment is found in the run directory that produced it, newest run first", async (t) => {
231
- const { manager, session, dirPath } = await managerWithReadySegment();
232
- t.after(async () => {
233
- await manager.disposeAll();
234
- await rm(dirPath, { recursive: true, force: true });
235
- });
236
- // Runs write into a directory each — that is what lets a restart begin
237
- // without waiting for its predecessor to die, which measured 0.7-1.3 s of
238
- // every seek. A later run's answer supersedes an earlier one's, because the
239
- // older file may be the truncated output of a run that was killed mid-write.
240
- const { mkdir } = await import("node:fs/promises");
241
- const piece = selfContainedPiece(SEGMENT_START_SECONDS);
242
- await mkdir(path.join(dirPath, "run-1"), { recursive: true });
243
- await mkdir(path.join(dirPath, "run-2"), { recursive: true });
244
- await writeFile(path.join(dirPath, "run-1", "segment-00000.mp4"), Buffer.alloc(8));
245
- await writeFile(path.join(dirPath, "run-2", "segment-00000.mp4"), piece);
246
- await writeFile(path.join(dirPath, "run-2", "segment-00001.mp4"), piece);
247
- await rm(path.join(dirPath, "segment-00000.mp4"));
248
- await rm(path.join(dirPath, "segment-00001.mp4"));
249
- session.encodeStartIndex = 0;
250
-
251
- const result = await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
252
-
253
- assert.equal(result.kind, "file", "a segment produced by a run must be found in that run's directory");
254
- const chunks = [];
255
- for await (const chunk of result.stream) {
256
- chunks.push(chunk);
257
- }
258
- assert.ok(
259
- Buffer.concat(chunks).length > 8,
260
- "the newest run's output must win — the older file here is the 8-byte stub a killed run leaves"
261
- );
262
- });
245
+
246
+ test("a segment is found in the run directory that produced it, newest run first", async (t) => {
247
+ const { manager, session, dirPath } = await managerWithReadySegment();
248
+ t.after(async () => {
249
+ await manager.disposeAll();
250
+ await rm(dirPath, { recursive: true, force: true });
251
+ });
252
+ // Runs write into a directory each — that is what lets a restart begin
253
+ // without waiting for its predecessor to die, which measured 0.7-1.3 s of
254
+ // every seek. A later run's answer supersedes an earlier one's, because the
255
+ // older file may be the truncated output of a run that was killed mid-write.
256
+ const { mkdir } = await import("node:fs/promises");
257
+ const piece = selfContainedPiece(SEGMENT_START_SECONDS);
258
+ await mkdir(path.join(dirPath, "run-1"), { recursive: true });
259
+ await mkdir(path.join(dirPath, "run-2"), { recursive: true });
260
+ await writeFile(path.join(dirPath, "run-1", "segment-00000.mp4"), Buffer.alloc(8));
261
+ await writeFile(path.join(dirPath, "run-2", "segment-00000.mp4"), piece);
262
+ await writeFile(path.join(dirPath, "run-2", "segment-00001.mp4"), piece);
263
+ await rm(path.join(dirPath, "segment-00000.mp4"));
264
+ await rm(path.join(dirPath, "segment-00001.mp4"));
265
+ session.encodeStartIndex = 0;
266
+
267
+ const result = await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
268
+
269
+ assert.equal(result.kind, "file", "a segment produced by a run must be found in that run's directory");
270
+ const chunks = [];
271
+ for await (const chunk of result.stream) {
272
+ chunks.push(chunk);
273
+ }
274
+ assert.ok(
275
+ Buffer.concat(chunks).length > 8,
276
+ "the newest run's output must win — the older file here is the 8-byte stub a killed run leaves"
277
+ );
278
+ });