@torrent-tv/proxy 2.9.0 → 2.9.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.0",
3
+ "version": "2.9.2",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -512,7 +512,7 @@ export class HlsSessionManager {
512
512
 
513
513
  logger.info(
514
514
  `transcode ${sessionId} start "${logName}" ` +
515
- `video=${transcodeVideo ? "x264" : "copy"} audio=${transcodeAudio ? "aac" : "copy"} ` +
515
+ `video=${transcodeVideo ? this.videoEncoder.name : "copy"} audio=${transcodeAudio ? "aac" : "copy"} ` +
516
516
  `duration=${hasDuration ? formatSeconds(durationSeconds) : "unknown"} segments=${segmentCount}`
517
517
  );
518
518
 
@@ -21,7 +21,9 @@
21
21
  */
22
22
 
23
23
  import { spawn } from "node:child_process";
24
- import { readdirSync } from "node:fs";
24
+ import { mkdtempSync, readdirSync, rmSync } from "node:fs";
25
+ import os from "node:os";
26
+ import path from "node:path";
25
27
 
26
28
  const SOFTWARE_PRESET = "superfast";
27
29
  const SOFTWARE_CRF = "24";
@@ -141,8 +143,11 @@ function nvencDescriptor() {
141
143
 
142
144
  /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
143
145
  function v4l2m2mDescriptor() {
144
- // ARM SoC (e.g. Raspberry Pi) stateful M2M encoder. No GPU scaler — scale in
145
- // software, then hand YUV420 frames to the hardware encoder.
146
+ // ARM SoC (e.g. Raspberry Pi / HA Yellow) stateful M2M encoder. No GPU
147
+ // scaler — scale in software, hand YUV420 frames to the hardware encoder.
148
+ // `-g` aligns the GOP to the segment length so an IDR lands on every segment
149
+ // boundary; this is verified by the keyframe-alignment test before use,
150
+ // because v4l2m2m does not always honour these hints.
146
151
  return {
147
152
  name: "h264_v4l2m2m",
148
153
  kind: "v4l2m2m",
@@ -155,12 +160,14 @@ function v4l2m2mDescriptor() {
155
160
  `scale=${w}:${h}:force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${TRANSCODE_FPS},format=yuv420p`,
156
161
  "-c:v", "h264_v4l2m2m",
157
162
  "-b:v", "3M",
163
+ "-g", String(TRANSCODE_FPS * segmentDurationSec),
158
164
  ...keyFrameArgs(segmentDurationSec)
159
165
  ];
160
166
  }
161
167
  };
162
168
  }
163
169
 
170
+
164
171
  /**
165
172
  * @typedef {Object} VideoEncoderDescriptor
166
173
  * @property {string} name
@@ -253,44 +260,89 @@ function hasV4l2Device() {
253
260
  }
254
261
 
255
262
  /**
256
- * Kind-specific test-encode args that verify the encoder initialises and
257
- * encodes a few frames from a synthetic source.
263
+ * Build a full ffmpeg command that encodes a short, *moving* synthetic clip
264
+ * (testsrc2 far more representative than a static black frame) through the
265
+ * candidate encoder into real HLS segments in `outDir`, with keyframes forced
266
+ * on segment boundaries. Verifying the resulting segments (see
267
+ * {@link verifySegmentsDecodeCleanly}) catches encoders that silently produce
268
+ * a corrupted or non-IDR-aligned stream (e.g. some V4L2 M2M builds).
258
269
  *
259
270
  * @param {VideoEncoderDescriptor} descriptor
271
+ * @param {number} segmentDurationSec
272
+ * @param {string} outDir
260
273
  * @returns {string[]}
261
274
  */
262
- function testEncodeArgs(descriptor) {
263
- const src = ["-f", "lavfi", "-i", "color=c=black:s=320x240:r=15:d=0.4"];
275
+ function buildEncoderTestArgs(descriptor, segmentDurationSec, outDir) {
276
+ const durationSec = Math.max(8, segmentDurationSec * 3);
277
+ const source = ["-f", "lavfi", "-i", `testsrc2=s=640x360:r=${TRANSCODE_FPS}:d=${durationSec}`];
278
+ const kf = keyFrameArgs(segmentDurationSec);
279
+
280
+ /** @type {string[]} */
281
+ let pre = ["-hide_banner", "-loglevel", "error"];
282
+ /** @type {string[]} */
283
+ let encode;
264
284
  switch (descriptor.kind) {
265
285
  case "vaapi":
266
- return [
267
- "-hide_banner", "-loglevel", "error",
268
- "-vaapi_device", String(descriptor.device),
269
- ...src,
270
- "-vf", "format=nv12,hwupload", "-c:v", "h264_vaapi",
271
- "-f", "null", "-"
272
- ];
286
+ pre = [...pre, "-vaapi_device", String(descriptor.device)];
287
+ encode = ["-vf", "format=nv12,hwupload", "-c:v", "h264_vaapi", "-qp", "24", ...kf];
288
+ break;
273
289
  case "qsv":
274
- return [
275
- "-hide_banner", "-loglevel", "error",
276
- "-qsv_device", String(descriptor.device),
277
- ...src,
278
- "-vf", "hwupload=extra_hw_frames=16,format=qsv", "-c:v", "h264_qsv",
279
- "-f", "null", "-"
280
- ];
290
+ pre = [...pre, "-qsv_device", String(descriptor.device)];
291
+ encode = ["-vf", "hwupload=extra_hw_frames=16,format=qsv", "-c:v", "h264_qsv", "-global_quality", "24", ...kf];
292
+ break;
281
293
  case "nvenc":
282
- return ["-hide_banner", "-loglevel", "error", ...src, "-c:v", "h264_nvenc", "-f", "null", "-"];
294
+ encode = ["-c:v", "h264_nvenc", "-preset", "p4", "-cq", "24", "-pix_fmt", "yuv420p", ...kf];
295
+ break;
283
296
  case "v4l2m2m":
284
- return [
285
- "-hide_banner", "-loglevel", "error",
286
- ...src, "-pix_fmt", "yuv420p", "-c:v", "h264_v4l2m2m", "-f", "null", "-"
287
- ];
297
+ encode = ["-pix_fmt", "yuv420p", "-c:v", "h264_v4l2m2m", "-b:v", "3M", "-g", String(TRANSCODE_FPS * segmentDurationSec), ...kf];
298
+ break;
288
299
  default:
289
- return [
290
- "-hide_banner", "-loglevel", "error",
291
- ...src, "-c:v", "libx264", "-preset", "ultrafast", "-f", "null", "-"
292
- ];
300
+ encode = ["-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", ...kf];
301
+ break;
293
302
  }
303
+
304
+ const hlsOut = [
305
+ "-f", "hls",
306
+ "-hls_time", String(segmentDurationSec),
307
+ "-hls_list_size", "0",
308
+ "-hls_flags", "independent_segments",
309
+ "-hls_segment_filename", path.join(outDir, "seg-%03d.ts"),
310
+ path.join(outDir, "index.m3u8")
311
+ ];
312
+ return [...pre, ...source, ...encode, ...hlsOut];
313
+ }
314
+
315
+ /**
316
+ * Verify the HLS segments produced by the test encode are valid: at least two
317
+ * segments exist, and each decodes standalone without errors. A segment that
318
+ * does not begin with a keyframe (broken/corrupted output) emits decode errors
319
+ * when read on its own, which fails this check.
320
+ *
321
+ * @param {string} ffmpegBin
322
+ * @param {string} outDir
323
+ * @returns {Promise<boolean>}
324
+ */
325
+ async function verifySegmentsDecodeCleanly(ffmpegBin, outDir) {
326
+ let files;
327
+ try {
328
+ files = readdirSync(outDir).filter((n) => /^seg-\d+\.ts$/.test(n)).sort();
329
+ } catch {
330
+ return false;
331
+ }
332
+ if (files.length < 2) {
333
+ return false;
334
+ }
335
+ for (const file of files) {
336
+ const result = await runFfmpeg(
337
+ ffmpegBin,
338
+ ["-hide_banner", "-loglevel", "error", "-i", path.join(outDir, file), "-f", "null", "-"],
339
+ 8000
340
+ );
341
+ if (result.code !== 0 || result.stderr.trim().length > 0) {
342
+ return false;
343
+ }
344
+ }
345
+ return true;
294
346
  }
295
347
 
296
348
  /**
@@ -298,10 +350,10 @@ function testEncodeArgs(descriptor) {
298
350
  * software libx264). Each hardware candidate is verified with a real
299
351
  * test-encode before being selected.
300
352
  *
301
- * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void } }} options
353
+ * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void }, segmentDurationSec?: number }} options
302
354
  * @returns {Promise<VideoEncoderDescriptor>}
303
355
  */
304
- export async function detectVideoEncoder({ ffmpegBin, logger }) {
356
+ export async function detectVideoEncoder({ ffmpegBin, logger, segmentDurationSec = 4 }) {
305
357
  const log = logger ?? { info: () => {}, warn: () => {} };
306
358
  const software = softwareDescriptor();
307
359
 
@@ -324,20 +376,41 @@ export async function detectVideoEncoder({ ffmpegBin, logger }) {
324
376
  if (has("h264_vaapi") && renderNodes.length > 0) {
325
377
  candidates.push(vaapiDescriptor(renderNodes[0]));
326
378
  }
379
+ // h264_v4l2m2m (ARM SoC / Raspberry Pi / HA Yellow). It is gated behind the
380
+ // strict keyframe-alignment test below, because some V4L2 M2M builds silently
381
+ // emit a corrupted / non-IDR-aligned stream; the test rejects those and the
382
+ // host falls back to software libx264.
327
383
  if (has("h264_v4l2m2m") && hasV4l2Device()) {
328
384
  candidates.push(v4l2m2mDescriptor());
329
385
  }
330
386
 
331
387
  for (const candidate of candidates) {
332
- const result = await runFfmpeg(ffmpegBin, testEncodeArgs(candidate), 12000);
333
- if (result.code === 0) {
388
+ const dir = mkdtempSync(path.join(os.tmpdir(), "tt-hwtest-"));
389
+ let ok = false;
390
+ try {
391
+ const encoded = await runFfmpeg(
392
+ ffmpegBin,
393
+ buildEncoderTestArgs(candidate, segmentDurationSec, dir),
394
+ 25000
395
+ );
396
+ if (encoded.code === 0) {
397
+ ok = await verifySegmentsDecodeCleanly(ffmpegBin, dir);
398
+ }
399
+ } finally {
400
+ try {
401
+ rmSync(dir, { recursive: true, force: true });
402
+ } catch {
403
+ // best effort
404
+ }
405
+ }
406
+ if (ok) {
334
407
  log.info(
335
408
  `hwaccel: using hardware encoder ${candidate.name}` +
336
409
  `${candidate.device ? ` (${candidate.device})` : ""}`
337
410
  );
338
411
  return candidate;
339
412
  }
340
- log.warn(`hwaccel: ${candidate.name} present but test-encode failed; skipping`);
413
+ log.warn(`hwaccel: ${candidate.name} failed the HLS keyframe-alignment test; skipping`);
341
414
  }
342
415
 
343
416
  log.info("hwaccel: no working hardware encoder; using software libx264");