@torrent-tv/proxy 2.9.6 → 2.9.9
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 +11 -0
- package/CLAUDE.md +8 -3
- package/package.json +1 -1
- package/server.js +9 -2
- package/services/hls-session-manager.js +151 -21
- package/services/hwaccel.js +88 -5
- package/services/playback-planner.js +42 -19
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
## 2.9.8
|
|
2
|
+
|
|
3
|
+
- **Fix**: Software (libx264) video transcode is much faster on weak ARM hosts, so playback keeps up with realtime: encode uses all CPU cores (`-threads`), and the scaler **never upscales** — the target box is capped to the source size via `min(W,iw)`/`min(H,ih)`, so a small source (e.g. 720x400) is encoded at its own resolution instead of being scaled up to the viewport (far fewer pixels).
|
|
4
|
+
- **New**: Adaptive software preset (preset auto-benchmark). At startup the proxy benchmarks libx264 presets (`fast`→`ultrafast`) on this host and records encode throughput (pixels/sec). Per stream, `hls-session-manager` picks the **highest-quality preset that still encodes the actual (source-capped) output resolution faster than realtime** with a safety margin, falling back to `ultrafast`. This maximises quality without dropping below 1× (which causes stalls). Logged as `video=libx264/<preset>` at session start.
|
|
5
|
+
- **New**: The input probe (`probeInputMediaInfo`, formerly `probeInputDurationSeconds`) now also extracts the source video resolution from the container header (used by the adaptive preset to compute the output pixel rate). Still returns on the header without decoding the stream.
|
|
6
|
+
- **Fix**: Transcode no longer thrashes between positions. `#ensureEncodingFor` now anchors the look-ahead window on the **current** encode position (not the run's start), and a `RESTART_COOLDOWN_MS` guard ignores competing seek-restart requests for a few seconds. Previously a stalled player requesting distant segments (e.g. #2 and #107) made ffmpeg ping-pong, restarting endlessly and producing nothing — which `Error opening input file` races confirmed.
|
|
7
|
+
|
|
8
|
+
## 2.9.7
|
|
9
|
+
|
|
10
|
+
- **Fix**: `playback-planner` retries the codec probe while the file header is still downloading and no longer caches an **empty** probe result. Previously a transient empty probe (common for a later file in a multi-file torrent whose pieces arrive late) was cached permanently, so the file was mis-planned as directly playable forever — an unsupported video codec (e.g. xvid) got copied and played as a **black screen**. The probe now retries (up to 60 s) until at least one codec is detected, and only a successful detection is cached.
|
|
11
|
+
|
|
1
12
|
## 2.9.6
|
|
2
13
|
|
|
3
14
|
- **Fix**: `probeInputDurationSeconds` now returns as soon as ffmpeg prints the container header (`Duration:`) instead of letting `-f null -` decode the whole stream until the 8 s timeout. Transcode-session creation was wasting ~8.6 s per session on this redundant decode (the duration was already available from the header, and `playback-plan` had probed it moments earlier). Cuts session-creation latency from ~9.7 s to ~1 s.
|
package/CLAUDE.md
CHANGED
|
@@ -50,9 +50,14 @@ Linux-only host (e.g. POSIX-only signals must degrade elsewhere).
|
|
|
50
50
|
## Changelog
|
|
51
51
|
|
|
52
52
|
Every behavioural change must be recorded in `CHANGELOG.md` — add an entry under
|
|
53
|
-
a new `## <version>` heading at the top
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
a new `## <version>` heading at the top, following the existing
|
|
54
|
+
`- **New**/**Fix**/**Chore**:` format.
|
|
55
|
+
|
|
56
|
+
**Do NOT edit `package.json` version.** `npm run patch`/`minor` runs `npm
|
|
57
|
+
version …` which bumps it. Write the CHANGELOG entry at the version that bump
|
|
58
|
+
will produce: **current `package.json` version + 1 patch** (or + 1 minor).
|
|
59
|
+
Accumulate bullets into that single pending entry until it's published. See the
|
|
60
|
+
parent `../CLAUDE.md`.
|
|
56
61
|
|
|
57
62
|
## Release
|
|
58
63
|
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -27,7 +27,7 @@ import { createSourceRegistry } from "./store/source-registry.js";
|
|
|
27
27
|
import { TorrentPool } from "./services/torrent-pool.js";
|
|
28
28
|
import { HlsSessionManager } from "./services/hls-session-manager.js";
|
|
29
29
|
import { createPlaybackPlanner } from "./services/playback-planner.js";
|
|
30
|
-
import { detectVideoEncoder } from "./services/hwaccel.js";
|
|
30
|
+
import { detectVideoEncoder, benchmarkSoftwarePresets } from "./services/hwaccel.js";
|
|
31
31
|
import { logger } from "./utils/logger.js";
|
|
32
32
|
|
|
33
33
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -99,12 +99,19 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin }
|
|
|
99
99
|
const videoEncoder = transcodeAudio
|
|
100
100
|
? await detectVideoEncoder({ ffmpegBin, logger })
|
|
101
101
|
: null;
|
|
102
|
+
// For software libx264, benchmark preset throughput once at startup so the
|
|
103
|
+
// session manager can pick the highest-quality preset that still encodes each
|
|
104
|
+
// stream faster than realtime. Hardware encoders use their own fixed preset.
|
|
105
|
+
const softwarePresetBenchmark = videoEncoder?.kind === "software"
|
|
106
|
+
? await benchmarkSoftwarePresets({ ffmpegBin, logger })
|
|
107
|
+
: null;
|
|
102
108
|
const hlsSessionManager = new HlsSessionManager({
|
|
103
109
|
enabled: transcodeAudio,
|
|
104
110
|
ffmpegBin,
|
|
105
111
|
localBindHost: host,
|
|
106
112
|
localPort: selectedPort,
|
|
107
|
-
videoEncoder
|
|
113
|
+
videoEncoder,
|
|
114
|
+
softwarePresetBenchmark
|
|
108
115
|
});
|
|
109
116
|
const playbackPlanner = createPlaybackPlanner({
|
|
110
117
|
ffmpegBin,
|
|
@@ -15,7 +15,7 @@ import path from "node:path";
|
|
|
15
15
|
import { randomUUID } from "node:crypto";
|
|
16
16
|
import { spawn } from "node:child_process";
|
|
17
17
|
import { logger } from "../utils/logger.js";
|
|
18
|
-
import { softwareDescriptor } from "./hwaccel.js";
|
|
18
|
+
import { softwareDescriptor, pickSoftwarePreset, TRANSCODE_FPS } from "./hwaccel.js";
|
|
19
19
|
|
|
20
20
|
const PLAYLIST_FILE_NAME = "index.m3u8";
|
|
21
21
|
const SEGMENT_FILE_NAME_PATTERN = /^segment-\d{5}\.ts$/;
|
|
@@ -25,6 +25,11 @@ const DEFAULT_SEGMENT_DURATION_SEC = 4;
|
|
|
25
25
|
// is allowed to be before we restart ffmpeg at that position (server-side seek).
|
|
26
26
|
// Requests within the window are served by waiting for the running encode.
|
|
27
27
|
const MAX_LOOKAHEAD_SEGMENTS = 8;
|
|
28
|
+
// After a seek-restart, ignore competing restart requests for this long. The
|
|
29
|
+
// synthetic VOD playlist lets the player request distant segments in quick
|
|
30
|
+
// succession (stall-recovery seeks); without a cooldown ffmpeg ping-pongs
|
|
31
|
+
// between positions, restarting endlessly and producing nothing.
|
|
32
|
+
const RESTART_COOLDOWN_MS = 4_000;
|
|
28
33
|
// Idle TTL: a session is disposed this long after the last segment/playlist
|
|
29
34
|
// access. Kept short so an ffmpeg process does not keep burning CPU after the
|
|
30
35
|
// viewer stops or navigates away. Active playback refreshes the timer on every
|
|
@@ -247,14 +252,39 @@ function computeProgressMetrics(processedSeconds, totalSeconds, startPositionSec
|
|
|
247
252
|
}
|
|
248
253
|
|
|
249
254
|
/**
|
|
250
|
-
*
|
|
251
|
-
*
|
|
255
|
+
* Parse the source video resolution from ffmpeg's stderr (the "Stream … Video:
|
|
256
|
+
* … WxH" line). Returns `{ width: null, height: null }` when absent.
|
|
257
|
+
*
|
|
258
|
+
* @param {string} stderrText
|
|
259
|
+
* @returns {{ width: number | null, height: number | null }}
|
|
260
|
+
*/
|
|
261
|
+
function parseFfmpegVideoDimensions(stderrText) {
|
|
262
|
+
if (typeof stderrText !== "string" || stderrText.length === 0) {
|
|
263
|
+
return { width: null, height: null };
|
|
264
|
+
}
|
|
265
|
+
const match = stderrText.match(/Video:[^\n]*?\b(\d{2,5})x(\d{2,5})\b/i);
|
|
266
|
+
if (!match) {
|
|
267
|
+
return { width: null, height: null };
|
|
268
|
+
}
|
|
269
|
+
const width = Number(match[1]);
|
|
270
|
+
const height = Number(match[2]);
|
|
271
|
+
return {
|
|
272
|
+
width: Number.isFinite(width) && width > 0 ? width : null,
|
|
273
|
+
height: Number.isFinite(height) && height > 0 ? height : null
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Run a short ffmpeg probe to extract the total duration AND video resolution
|
|
279
|
+
* of a stream from the container header. Both are printed almost immediately
|
|
280
|
+
* (before any decoding), so this returns as soon as they are seen; an 8 s
|
|
281
|
+
* timeout guards the rest.
|
|
252
282
|
*
|
|
253
283
|
* @param {string} ffmpegBin - Path to the ffmpeg executable.
|
|
254
284
|
* @param {string | URL} inputUrl - URL of the stream to probe.
|
|
255
|
-
* @returns {Promise<number | null
|
|
285
|
+
* @returns {Promise<{ durationSeconds: number | null, width: number | null, height: number | null }>}
|
|
256
286
|
*/
|
|
257
|
-
async function
|
|
287
|
+
async function probeInputMediaInfo(ffmpegBin, inputUrl) {
|
|
258
288
|
return new Promise((resolve) => {
|
|
259
289
|
const ffmpeg = spawn(ffmpegBin, ["-hide_banner", "-loglevel", "info", "-i", inputUrl, "-f", "null", "-"], {
|
|
260
290
|
stdio: ["ignore", "ignore", "pipe"],
|
|
@@ -262,44 +292,74 @@ async function probeInputDurationSeconds(ffmpegBin, inputUrl) {
|
|
|
262
292
|
});
|
|
263
293
|
let stderr = "";
|
|
264
294
|
let settled = false;
|
|
265
|
-
const finish = (
|
|
295
|
+
const finish = () => {
|
|
266
296
|
if (settled) {
|
|
267
297
|
return;
|
|
268
298
|
}
|
|
269
299
|
settled = true;
|
|
270
|
-
|
|
300
|
+
const dims = parseFfmpegVideoDimensions(stderr);
|
|
301
|
+
resolve({ durationSeconds: parseFfmpegDurationSeconds(stderr), width: dims.width, height: dims.height });
|
|
271
302
|
};
|
|
272
303
|
const timeoutId = setTimeout(() => {
|
|
273
304
|
if (!ffmpeg.killed) {
|
|
274
305
|
ffmpeg.kill("SIGTERM");
|
|
275
306
|
}
|
|
276
|
-
finish(
|
|
307
|
+
finish();
|
|
277
308
|
}, 8_000);
|
|
278
309
|
ffmpeg.stderr.on("data", (chunk) => {
|
|
279
310
|
stderr += String(chunk);
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
311
|
+
// The header ("Duration:" then the "Video: … WxH" stream line) is printed
|
|
312
|
+
// before any decoding. Bail as soon as both are present instead of letting
|
|
313
|
+
// `-f null -` decode the whole stream until the 8 s timeout.
|
|
283
314
|
const duration = parseFfmpegDurationSeconds(stderr);
|
|
284
|
-
|
|
315
|
+
const dims = parseFfmpegVideoDimensions(stderr);
|
|
316
|
+
if (duration != null && dims.width != null) {
|
|
285
317
|
clearTimeout(timeoutId);
|
|
286
318
|
if (!ffmpeg.killed) {
|
|
287
319
|
ffmpeg.kill("SIGTERM");
|
|
288
320
|
}
|
|
289
|
-
finish(
|
|
321
|
+
finish();
|
|
290
322
|
}
|
|
291
323
|
});
|
|
292
324
|
ffmpeg.on("error", () => {
|
|
293
325
|
clearTimeout(timeoutId);
|
|
294
|
-
finish(
|
|
326
|
+
finish();
|
|
295
327
|
});
|
|
296
328
|
ffmpeg.on("exit", () => {
|
|
297
329
|
clearTimeout(timeoutId);
|
|
298
|
-
finish(
|
|
330
|
+
finish();
|
|
299
331
|
});
|
|
300
332
|
});
|
|
301
333
|
}
|
|
302
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Compute the actual output resolution ffmpeg will produce: the target box
|
|
337
|
+
* capped to the source (never upscaled), preserving aspect, divisible by 2.
|
|
338
|
+
* Mirrors the `scale='min(w,iw)':'min(h,ih)':force_original_aspect_ratio=decrease`
|
|
339
|
+
* filter. Returns `null` when the source size is unknown.
|
|
340
|
+
*
|
|
341
|
+
* @param {number} targetWidth
|
|
342
|
+
* @param {number} targetHeight
|
|
343
|
+
* @param {number | null} sourceWidth
|
|
344
|
+
* @param {number | null} sourceHeight
|
|
345
|
+
* @returns {{ w: number, h: number } | null}
|
|
346
|
+
*/
|
|
347
|
+
function computeOutputDimensions(targetWidth, targetHeight, sourceWidth, sourceHeight) {
|
|
348
|
+
const sw = Number.isFinite(sourceWidth) && sourceWidth > 0 ? sourceWidth : 0;
|
|
349
|
+
const sh = Number.isFinite(sourceHeight) && sourceHeight > 0 ? sourceHeight : 0;
|
|
350
|
+
if (!sw || !sh) {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
const tw = Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : sw;
|
|
354
|
+
const th = Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : sh;
|
|
355
|
+
const scale = Math.min(tw / sw, th / sh, 1);
|
|
356
|
+
let w = Math.round(sw * scale);
|
|
357
|
+
let h = Math.round(sh * scale);
|
|
358
|
+
w -= w % 2;
|
|
359
|
+
h -= h % 2;
|
|
360
|
+
return { w: Math.max(2, w), h: Math.max(2, h) };
|
|
361
|
+
}
|
|
362
|
+
|
|
303
363
|
function isWarmupTimeoutError(error) {
|
|
304
364
|
if (!(error instanceof Error)) {
|
|
305
365
|
return false;
|
|
@@ -364,7 +424,8 @@ export class HlsSessionManager {
|
|
|
364
424
|
segmentDurationSec = DEFAULT_SEGMENT_DURATION_SEC,
|
|
365
425
|
sessionTtlMs = DEFAULT_SESSION_TTL_MS,
|
|
366
426
|
startupWaitMs = DEFAULT_STARTUP_WAIT_MS,
|
|
367
|
-
videoEncoder = null
|
|
427
|
+
videoEncoder = null,
|
|
428
|
+
softwarePresetBenchmark = null
|
|
368
429
|
}) {
|
|
369
430
|
this.enabled = Boolean(enabled);
|
|
370
431
|
this.ffmpegBin = ffmpegBin;
|
|
@@ -372,6 +433,10 @@ export class HlsSessionManager {
|
|
|
372
433
|
// software libx264 when no detection result is supplied. May be downgraded
|
|
373
434
|
// to software at runtime if a hardware encode fails.
|
|
374
435
|
this.videoEncoder = videoEncoder ?? softwareDescriptor();
|
|
436
|
+
// Per-preset software encode throughput (pixels/sec) measured at startup,
|
|
437
|
+
// used to pick the best preset per stream. Null when unavailable (hardware
|
|
438
|
+
// encoder, or benchmark skipped/failed).
|
|
439
|
+
this.softwarePresetBenchmark = Array.isArray(softwarePresetBenchmark) ? softwarePresetBenchmark : null;
|
|
375
440
|
this.segmentDurationSec = segmentDurationSec;
|
|
376
441
|
this.sessionTtlMs = sessionTtlMs;
|
|
377
442
|
this.startupWaitMs = startupWaitMs;
|
|
@@ -469,7 +534,10 @@ export class HlsSessionManager {
|
|
|
469
534
|
// playlist (terminated with #EXT-X-ENDLIST) immediately. This gives the
|
|
470
535
|
// player the correct total duration and a fully seekable timeline before a
|
|
471
536
|
// single segment has been transcoded.
|
|
472
|
-
const
|
|
537
|
+
const mediaInfo = await probeInputMediaInfo(this.ffmpegBin, inputUrl.toString());
|
|
538
|
+
const durationSeconds = mediaInfo.durationSeconds;
|
|
539
|
+
const sourceWidth = mediaInfo.width;
|
|
540
|
+
const sourceHeight = mediaInfo.height;
|
|
473
541
|
const hasDuration = Number.isFinite(durationSeconds) && durationSeconds > 0;
|
|
474
542
|
const logName = normalizeLogFileName(fileName, fileIndex);
|
|
475
543
|
if (!hasDuration) {
|
|
@@ -482,6 +550,18 @@ export class HlsSessionManager {
|
|
|
482
550
|
? Math.max(1, Math.ceil(durationSeconds / this.segmentDurationSec))
|
|
483
551
|
: 0;
|
|
484
552
|
|
|
553
|
+
// Pick the highest-quality software preset that still encodes the actual
|
|
554
|
+
// (source-capped) output resolution faster than realtime. Null for hardware
|
|
555
|
+
// encoders or when the source size / benchmark is unavailable — buildVideoArgs
|
|
556
|
+
// then uses its static default preset.
|
|
557
|
+
const softwarePreset = this.#chooseSoftwarePreset({
|
|
558
|
+
transcodeVideo,
|
|
559
|
+
targetWidth: normalizedTargetWidth,
|
|
560
|
+
targetHeight: normalizedTargetHeight,
|
|
561
|
+
sourceWidth,
|
|
562
|
+
sourceHeight
|
|
563
|
+
});
|
|
564
|
+
|
|
485
565
|
const session = {
|
|
486
566
|
id: sessionId,
|
|
487
567
|
sourceMapKey,
|
|
@@ -501,6 +581,10 @@ export class HlsSessionManager {
|
|
|
501
581
|
transcodeAudio,
|
|
502
582
|
targetWidth: normalizedTargetWidth,
|
|
503
583
|
targetHeight: normalizedTargetHeight,
|
|
584
|
+
sourceWidth,
|
|
585
|
+
sourceHeight,
|
|
586
|
+
// Chosen libx264 preset for this stream (software only), or null.
|
|
587
|
+
softwarePreset,
|
|
504
588
|
inputUrl: inputUrl.toString(),
|
|
505
589
|
// VOD playlist bookkeeping.
|
|
506
590
|
useSyntheticPlaylist: hasDuration,
|
|
@@ -511,6 +595,8 @@ export class HlsSessionManager {
|
|
|
511
595
|
encodeStartIndex: 0,
|
|
512
596
|
// Guards against repeatedly restarting to the same seek position.
|
|
513
597
|
pendingRestartIndex: -1,
|
|
598
|
+
// Timestamp of the last encode (re)start, for the restart cooldown.
|
|
599
|
+
lastRestartAt: 0,
|
|
514
600
|
progress: {
|
|
515
601
|
state: "starting",
|
|
516
602
|
processedSeconds: 0,
|
|
@@ -528,7 +614,9 @@ export class HlsSessionManager {
|
|
|
528
614
|
|
|
529
615
|
logger.info(
|
|
530
616
|
`transcode ${sessionId} start "${logName}" ` +
|
|
531
|
-
`video=${transcodeVideo ? this.videoEncoder.name
|
|
617
|
+
`video=${transcodeVideo ? `${this.videoEncoder.name}${softwarePreset ? `/${softwarePreset}` : ""}` : "copy"} ` +
|
|
618
|
+
`audio=${transcodeAudio ? "aac" : "copy"} ` +
|
|
619
|
+
`${sourceWidth && sourceHeight ? `src=${sourceWidth}x${sourceHeight} ` : ""}` +
|
|
532
620
|
`duration=${hasDuration ? formatSeconds(durationSeconds) : "unknown"} segments=${segmentCount}`
|
|
533
621
|
);
|
|
534
622
|
|
|
@@ -580,6 +668,28 @@ export class HlsSessionManager {
|
|
|
580
668
|
return `${lines.join("\n")}\n`;
|
|
581
669
|
}
|
|
582
670
|
|
|
671
|
+
/**
|
|
672
|
+
* Choose the libx264 preset for a software video transcode: the highest
|
|
673
|
+
* quality the startup benchmark says this host can encode at the actual
|
|
674
|
+
* (source-capped) output resolution faster than realtime. Returns null when
|
|
675
|
+
* not applicable (no video transcode, hardware encoder, or missing
|
|
676
|
+
* benchmark/source size) — buildVideoArgs then uses its default preset.
|
|
677
|
+
*
|
|
678
|
+
* @param {{ transcodeVideo: boolean, targetWidth: number, targetHeight: number, sourceWidth: number | null, sourceHeight: number | null }} params
|
|
679
|
+
* @returns {string | null}
|
|
680
|
+
*/
|
|
681
|
+
#chooseSoftwarePreset({ transcodeVideo, targetWidth, targetHeight, sourceWidth, sourceHeight }) {
|
|
682
|
+
if (!transcodeVideo || this.videoEncoder?.kind !== "software" || !this.softwarePresetBenchmark) {
|
|
683
|
+
return null;
|
|
684
|
+
}
|
|
685
|
+
const out = computeOutputDimensions(targetWidth, targetHeight, sourceWidth, sourceHeight);
|
|
686
|
+
if (!out) {
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
const pixelsPerSecNeeded = out.w * out.h * TRANSCODE_FPS;
|
|
690
|
+
return pickSoftwarePreset(this.softwarePresetBenchmark, pixelsPerSecNeeded);
|
|
691
|
+
}
|
|
692
|
+
|
|
583
693
|
/**
|
|
584
694
|
* (Re)start the ffmpeg encode run beginning at segment `startIndex`.
|
|
585
695
|
*
|
|
@@ -614,7 +724,9 @@ export class HlsSessionManager {
|
|
|
614
724
|
? this.videoEncoder.buildVideoArgs({
|
|
615
725
|
targetWidth: session.targetWidth,
|
|
616
726
|
targetHeight: session.targetHeight,
|
|
617
|
-
segmentDurationSec: this.segmentDurationSec
|
|
727
|
+
segmentDurationSec: this.segmentDurationSec,
|
|
728
|
+
// Software-only; hardware descriptors ignore it.
|
|
729
|
+
preset: session.softwarePreset ?? undefined
|
|
618
730
|
})
|
|
619
731
|
: ["-c:v", "copy"];
|
|
620
732
|
const audioCodecArgs = session.transcodeAudio
|
|
@@ -668,6 +780,7 @@ export class HlsSessionManager {
|
|
|
668
780
|
session.ffmpeg = ffmpeg;
|
|
669
781
|
session.encodeStartIndex = safeIndex;
|
|
670
782
|
session.pendingRestartIndex = -1;
|
|
783
|
+
session.lastRestartAt = Date.now();
|
|
671
784
|
session.state = session.state === "disposed" ? "disposed" : "starting";
|
|
672
785
|
session.progress.state = "running";
|
|
673
786
|
session.progress.processedSeconds = startSeconds;
|
|
@@ -815,15 +928,32 @@ export class HlsSessionManager {
|
|
|
815
928
|
return;
|
|
816
929
|
}
|
|
817
930
|
const head = session.encodeStartIndex;
|
|
818
|
-
|
|
931
|
+
// Anchor the look-ahead window on the CURRENT encode position (start index +
|
|
932
|
+
// seconds already processed), not the run's start index. Otherwise a long
|
|
933
|
+
// run that has encoded well past `head` would needlessly restart for a
|
|
934
|
+
// request just ahead of the live edge.
|
|
935
|
+
const processed = Number.isFinite(session.progress?.processedSeconds)
|
|
936
|
+
? session.progress.processedSeconds
|
|
937
|
+
: head * this.segmentDurationSec;
|
|
938
|
+
const currentSeg = Math.max(head, Math.floor(processed / this.segmentDurationSec));
|
|
939
|
+
const withinWindow = index >= head && index <= currentSeg + MAX_LOOKAHEAD_SEGMENTS;
|
|
819
940
|
if (withinWindow) {
|
|
820
941
|
return;
|
|
821
942
|
}
|
|
822
943
|
if (session.pendingRestartIndex === index) {
|
|
823
944
|
return;
|
|
824
945
|
}
|
|
946
|
+
// Restart cooldown: a stalled player requests several distant segments in
|
|
947
|
+
// quick succession; without this guard ffmpeg ping-pongs between them and
|
|
948
|
+
// never makes progress. Skip the restart during the cooldown — the caller
|
|
949
|
+
// long-polls / the client retries, and a genuine seek is honored once the
|
|
950
|
+
// cooldown elapses.
|
|
951
|
+
const sinceLastRestart = Date.now() - (session.lastRestartAt ?? 0);
|
|
952
|
+
if (sinceLastRestart < RESTART_COOLDOWN_MS) {
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
825
955
|
logger.info(
|
|
826
|
-
`transcode ${session.id} seek → restart at segment #${index} (encode head #${head})`
|
|
956
|
+
`transcode ${session.id} seek → restart at segment #${index} (encode head #${head}, current #${currentSeg})`
|
|
827
957
|
);
|
|
828
958
|
this.#startEncodeRun(session, index);
|
|
829
959
|
}
|
package/services/hwaccel.js
CHANGED
|
@@ -25,9 +25,20 @@ import { mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
|
25
25
|
import os from "node:os";
|
|
26
26
|
import path from "node:path";
|
|
27
27
|
|
|
28
|
-
const SOFTWARE_PRESET = "
|
|
28
|
+
const SOFTWARE_PRESET = "ultrafast";
|
|
29
29
|
const SOFTWARE_CRF = "24";
|
|
30
|
-
const TRANSCODE_FPS = 24;
|
|
30
|
+
export const TRANSCODE_FPS = 24;
|
|
31
|
+
// Software x264 on weak ARM hosts is the transcode bottleneck — use all cores.
|
|
32
|
+
const CPU_THREADS = Math.max(1, os.cpus().length);
|
|
33
|
+
|
|
34
|
+
// libx264 presets to benchmark, ordered slowest/highest-quality → fastest.
|
|
35
|
+
const BENCHMARK_PRESETS = ["fast", "faster", "veryfast", "superfast", "ultrafast"];
|
|
36
|
+
const BENCHMARK_REF_W = 640;
|
|
37
|
+
const BENCHMARK_REF_H = 360;
|
|
38
|
+
const BENCHMARK_DURATION_SEC = 3;
|
|
39
|
+
// Require the encoder to be this much faster than realtime for the target
|
|
40
|
+
// resolution, leaving headroom for complex scenes and delivery.
|
|
41
|
+
const PRESET_SPEED_MARGIN = 1.3;
|
|
31
42
|
|
|
32
43
|
/**
|
|
33
44
|
* @param {number} targetWidth
|
|
@@ -58,14 +69,23 @@ export function softwareDescriptor() {
|
|
|
58
69
|
kind: "software",
|
|
59
70
|
device: null,
|
|
60
71
|
inputArgs: [],
|
|
61
|
-
buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
|
|
72
|
+
buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, preset }) {
|
|
62
73
|
const { w, h } = safeDimensions(targetWidth, targetHeight);
|
|
74
|
+
const chosenPreset = typeof preset === "string" && preset.length > 0 ? preset : SOFTWARE_PRESET;
|
|
63
75
|
return [
|
|
76
|
+
// Never upscale: cap the target box to the source size (min with
|
|
77
|
+
// iw/ih), so a small source (e.g. 720x400) is encoded at its own
|
|
78
|
+
// resolution instead of being scaled up to the viewport — far fewer
|
|
79
|
+
// pixels, much faster on ARM. force_original_aspect_ratio keeps aspect.
|
|
64
80
|
"-vf",
|
|
65
|
-
`scale
|
|
81
|
+
`scale='min(${w},iw)':'min(${h},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${TRANSCODE_FPS}`,
|
|
66
82
|
"-c:v", "libx264",
|
|
67
|
-
|
|
83
|
+
// Preset is chosen per stream by the session manager from the startup
|
|
84
|
+
// benchmark (highest quality that still encodes the source resolution
|
|
85
|
+
// faster than realtime); falls back to the static default.
|
|
86
|
+
"-preset", chosenPreset,
|
|
68
87
|
"-crf", SOFTWARE_CRF,
|
|
88
|
+
"-threads", String(CPU_THREADS),
|
|
69
89
|
"-pix_fmt", "yuv420p",
|
|
70
90
|
...keyFrameArgs(segmentDurationSec)
|
|
71
91
|
];
|
|
@@ -416,3 +436,66 @@ export async function detectVideoEncoder({ ffmpegBin, logger, segmentDurationSec
|
|
|
416
436
|
log.info("hwaccel: no working hardware encoder; using software libx264");
|
|
417
437
|
return software;
|
|
418
438
|
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Benchmark software libx264 presets on this host. Encodes a short synthetic
|
|
442
|
+
* clip at a fixed reference resolution with each preset and measures encoder
|
|
443
|
+
* throughput in pixels/second. The session manager uses this to pick, per
|
|
444
|
+
* stream, the highest-quality preset that still encodes the actual
|
|
445
|
+
* (source-capped) resolution faster than realtime.
|
|
446
|
+
*
|
|
447
|
+
* Runs once at startup; bounded by a per-encode timeout. Presets that fail are
|
|
448
|
+
* omitted from the result.
|
|
449
|
+
*
|
|
450
|
+
* @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void } }} options
|
|
451
|
+
* @returns {Promise<Array<{ preset: string, pixelsPerSec: number }>>} Ordered slowest→fastest.
|
|
452
|
+
*/
|
|
453
|
+
export async function benchmarkSoftwarePresets({ ffmpegBin, logger }) {
|
|
454
|
+
const log = logger ?? { info: () => {}, warn: () => {} };
|
|
455
|
+
const totalPixels = BENCHMARK_REF_W * BENCHMARK_REF_H * TRANSCODE_FPS * BENCHMARK_DURATION_SEC;
|
|
456
|
+
/** @type {Array<{ preset: string, pixelsPerSec: number }>} */
|
|
457
|
+
const results = [];
|
|
458
|
+
for (const preset of BENCHMARK_PRESETS) {
|
|
459
|
+
const args = [
|
|
460
|
+
"-hide_banner", "-loglevel", "error",
|
|
461
|
+
"-f", "lavfi", "-i", `testsrc2=s=${BENCHMARK_REF_W}x${BENCHMARK_REF_H}:r=${TRANSCODE_FPS}:d=${BENCHMARK_DURATION_SEC}`,
|
|
462
|
+
"-c:v", "libx264", "-preset", preset, "-crf", SOFTWARE_CRF, "-pix_fmt", "yuv420p",
|
|
463
|
+
"-f", "null", "-"
|
|
464
|
+
];
|
|
465
|
+
const startedAt = Date.now();
|
|
466
|
+
const { code } = await runFfmpeg(ffmpegBin, args, 30000);
|
|
467
|
+
const elapsedSec = (Date.now() - startedAt) / 1000;
|
|
468
|
+
if (code !== 0 || elapsedSec <= 0) {
|
|
469
|
+
log.warn(`hwaccel: preset benchmark "${preset}" failed; skipping`);
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
const pixelsPerSec = totalPixels / elapsedSec;
|
|
473
|
+
results.push({ preset, pixelsPerSec });
|
|
474
|
+
log.info(
|
|
475
|
+
`hwaccel: preset "${preset}" ~= ${(pixelsPerSec / 1e6).toFixed(1)} Mpx/s ` +
|
|
476
|
+
`(${(BENCHMARK_DURATION_SEC / elapsedSec).toFixed(2)}x @ ${BENCHMARK_REF_W}x${BENCHMARK_REF_H})`
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
return results;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Pick the highest-quality (slowest) benchmarked preset that can encode
|
|
484
|
+
* `pixelsPerSecNeeded` with the speed margin. Falls back to the fastest
|
|
485
|
+
* benchmarked preset, or `"ultrafast"` when no benchmark is available.
|
|
486
|
+
*
|
|
487
|
+
* @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark - slowest→fastest
|
|
488
|
+
* @param {number} pixelsPerSecNeeded
|
|
489
|
+
* @returns {string}
|
|
490
|
+
*/
|
|
491
|
+
export function pickSoftwarePreset(benchmark, pixelsPerSecNeeded) {
|
|
492
|
+
if (!Array.isArray(benchmark) || benchmark.length === 0) {
|
|
493
|
+
return "ultrafast";
|
|
494
|
+
}
|
|
495
|
+
for (const entry of benchmark) {
|
|
496
|
+
if (entry.pixelsPerSec >= pixelsPerSecNeeded * PRESET_SPEED_MARGIN) {
|
|
497
|
+
return entry.preset;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return benchmark[benchmark.length - 1].preset;
|
|
501
|
+
}
|
|
@@ -97,6 +97,18 @@ function probeStreamCodecs({ ffmpegBin, inputUrl, userAgent = "", timeoutMs = 8_
|
|
|
97
97
|
});
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Resolve after a given number of milliseconds.
|
|
102
|
+
*
|
|
103
|
+
* @param {number} ms
|
|
104
|
+
* @returns {Promise<void>}
|
|
105
|
+
*/
|
|
106
|
+
function delay(ms) {
|
|
107
|
+
return new Promise((resolve) => {
|
|
108
|
+
setTimeout(resolve, ms);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
100
112
|
/**
|
|
101
113
|
* Build the direct stream URL for a source file served by the local proxy.
|
|
102
114
|
*
|
|
@@ -198,28 +210,34 @@ export function createPlaybackPlanner({
|
|
|
198
210
|
return plan;
|
|
199
211
|
}
|
|
200
212
|
|
|
201
|
-
// Pre-fetch file edges (head + tail)
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
213
|
+
// Pre-fetch file edges (head + tail), then probe — retrying while the
|
|
214
|
+
// file header is still downloading. In a multi-file torrent the pieces
|
|
215
|
+
// for a given file arrive unevenly, so the first probe can return empty
|
|
216
|
+
// codecs. A transient empty probe must NOT be cached: otherwise the wrong
|
|
217
|
+
// plan (file treated as directly playable) sticks permanently for this
|
|
218
|
+
// file, and an unsupported codec like xvid gets copied → black video.
|
|
205
219
|
await torrentPool.prefetchFileEdges(torrent, fileIndex);
|
|
220
|
+
let probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
|
|
221
|
+
const probeDeadline = Date.now() + 60_000;
|
|
222
|
+
let attempt = 0;
|
|
223
|
+
while (
|
|
224
|
+
probe.audioCodec.length === 0 &&
|
|
225
|
+
probe.videoCodec.length === 0 &&
|
|
226
|
+
Date.now() < probeDeadline
|
|
227
|
+
) {
|
|
228
|
+
attempt += 1;
|
|
229
|
+
await delay(Math.min(3_000, 500 + attempt * 250));
|
|
230
|
+
await torrentPool.prefetchFileEdges(torrent, fileIndex);
|
|
231
|
+
probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
|
|
232
|
+
}
|
|
233
|
+
const { audioCodec, videoCodec, container, durationSeconds } = probe;
|
|
234
|
+
const codecsDetected = audioCodec.length > 0 || videoCodec.length > 0;
|
|
206
235
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
userAgent
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
// Only transcode when the codec is known AND not natively supported.
|
|
214
|
-
// When ffprobe cannot detect the codec (e.g. the torrent has just started
|
|
215
|
-
// downloading and the MOOV atom at the end of the MP4 is not yet available),
|
|
216
|
-
// fall back to "direct" so the browser can attempt native playback. The
|
|
217
|
-
// browser-side loading pipeline already has its own transcode fallback.
|
|
236
|
+
// `mode` is advisory only (audio-codec based). The browser makes the
|
|
237
|
+
// authoritative decision independently per stream via canPlayType /
|
|
238
|
+
// mediaCapabilities, transcoding only what it cannot play.
|
|
218
239
|
const requiresTranscode = audioCodec.length > 0 && !DIRECT_AUDIO_CODECS.has(audioCodec);
|
|
219
240
|
const plan = {
|
|
220
|
-
// `mode` is advisory only (audio-codec based). The browser makes the
|
|
221
|
-
// authoritative decision independently per stream via canPlayType /
|
|
222
|
-
// mediaCapabilities, transcoding only what it cannot play.
|
|
223
241
|
mode: requiresTranscode ? "hls" : "direct",
|
|
224
242
|
directUrl,
|
|
225
243
|
reason: requiresTranscode ? "audio-codec-transcode-required" : "audio-codec-supported",
|
|
@@ -228,7 +246,12 @@ export function createPlaybackPlanner({
|
|
|
228
246
|
container,
|
|
229
247
|
durationSeconds
|
|
230
248
|
};
|
|
231
|
-
cache.
|
|
249
|
+
// Only cache a plan whose codecs were actually detected. An empty probe is
|
|
250
|
+
// a "header not downloaded yet" signal, not a valid result — caching it
|
|
251
|
+
// would permanently mis-plan the file.
|
|
252
|
+
if (codecsDetected) {
|
|
253
|
+
cache.set(cacheKey, plan);
|
|
254
|
+
}
|
|
232
255
|
return plan;
|
|
233
256
|
}
|
|
234
257
|
};
|