@muhkoo/theater-transcoder 0.2.2 → 0.3.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/README.md CHANGED
@@ -15,7 +15,19 @@ npm i -g @muhkoo/theater-transcoder
15
15
  MUHKOO_USERNAME=you MUHKOO_PASSWORD=… theater-transcoder
16
16
  ```
17
17
 
18
- You'll be prompted for your password if it isn't in `$MUHKOO_PASSWORD`. Credentials never leave the machine — it runs the same **zero-knowledge login** the browser uses. `ffmpeg` is bundled (`ffmpeg-static`); nothing else to install. Requires **Node ≥ 20** (≥ 22 recommended).
18
+ You'll be prompted for your password if it isn't in `$MUHKOO_PASSWORD`. Credentials never leave the machine — it runs the same **zero-knowledge login** the browser uses. Requires **Node ≥ 20** (≥ 22 recommended).
19
+
20
+ ### ffmpeg (hardware acceleration)
21
+
22
+ The transcoder prefers a **system-installed `ffmpeg`** and offloads work to the GPU when one is available — **Apple VideoToolbox**, **NVIDIA NVENC**, **Intel QuickSync**, or **VAAPI**. It runs the whole pipeline on-GPU where it can (**hardware decode → scale → encode**, frames never leaving the GPU), which on NVENC/QuickSync boxes hits many times realtime; on Apple Silicon the encoder is the ceiling but GPU decode still roughly halves CPU use. It automatically steps down — full-GPU → GPU-encode-only → software libx264 — to whatever actually runs on the machine.
23
+
24
+ - **Best:** install ffmpeg so hardware encoding kicks in automatically:
25
+ - macOS: `brew install ffmpeg`
26
+ - Debian/Ubuntu: `sudo apt install ffmpeg`
27
+ - (or point at a specific build with `FFMPEG_PATH=/path/to/ffmpeg`)
28
+ - **Fallback:** if no system ffmpeg is found, the bundled `ffmpeg-static` binary is used (software libx264 — works everywhere, just slower). You'll see a one-time warning nudging you to install ffmpeg.
29
+
30
+ On startup the worker logs exactly which binary and encoder it picked, e.g. `✓ hardware encoder: Apple VideoToolbox (h264_videotoolbox)`. If a GPU path is present but fails on a given file, it logs the fallback and keeps going — down to software libx264 if needed.
19
31
 
20
32
  Leave it running. Upload videos in the app and this box starts chewing through the queue. Ctrl-C to stop.
21
33
 
@@ -37,3 +49,5 @@ Offline + P2P are auto-disabled in Node (no IndexedDB / WebRTC), so its shard an
37
49
  | `--password` | `MUHKOO_PASSWORD` | prompt |
38
50
  | `--base` | `MUHKOO_BASE_URL` | `https://api.muhkoo.dev` |
39
51
  | `--app-key` | `MUHKOO_THEATER_KEY` | public prod key |
52
+ | — | `FFMPEG_PATH` | system `ffmpeg` → bundled static |
53
+ | — | `VAAPI_DEVICE` | `/dev/dri/renderD128` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muhkoo/theater-transcoder",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Drain a Muhkoo Theater transcode queue with NATIVE ffmpeg. Run it on any machine signed in as you to add transcoding power to your library.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/ffmpeg.js ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Resolve which ffmpeg binary to run and which H.264 encoder to use.
3
+ *
4
+ * Preference order, best → worst:
5
+ * 1. A **system-installed ffmpeg** (via `FFMPEG_PATH` or on `PATH`) — usually
6
+ * built with hardware encoders (Apple VideoToolbox, NVIDIA NVENC, Intel
7
+ * QuickSync, VAAPI). These offload encoding to dedicated silicon and are
8
+ * dramatically faster than software libx264.
9
+ * 2. The bundled **`ffmpeg-static`** binary (software libx264 only). Works
10
+ * everywhere with zero install, but slow — so we warn and nudge the user to
11
+ * `brew/apt install ffmpeg` for hardware acceleration.
12
+ *
13
+ * We probe the chosen binary's encoder list AND run a tiny functional test
14
+ * encode (an encoder can be listed but fail at runtime — e.g. NVENC with no GPU),
15
+ * then pick the fastest encoder that actually works.
16
+ */
17
+ import { spawnSync } from "node:child_process";
18
+ import ffmpegStatic from "ffmpeg-static";
19
+
20
+ const log = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a);
21
+
22
+ /** Hardware H.264 encoders, in preference order. */
23
+ const HW_ENCODERS = [
24
+ { codec: "h264_videotoolbox", label: "Apple VideoToolbox" },
25
+ { codec: "h264_nvenc", label: "NVIDIA NVENC" },
26
+ { codec: "h264_qsv", label: "Intel QuickSync" },
27
+ { codec: "h264_vaapi", label: "VAAPI" },
28
+ ];
29
+
30
+ function runnable(bin) {
31
+ try {
32
+ const r = spawnSync(bin, ["-hide_banner", "-version"], { encoding: "utf8", timeout: 5000 });
33
+ return r.status === 0 && /ffmpeg version/i.test(r.stdout || "");
34
+ } catch { return false; }
35
+ }
36
+
37
+ /** Find a runnable ffmpeg: FFMPEG_PATH → system PATH → bundled static. */
38
+ function resolveBinary() {
39
+ const explicit = process.env.FFMPEG_PATH;
40
+ if (explicit) {
41
+ if (runnable(explicit)) return { path: explicit, system: true, source: `FFMPEG_PATH (${explicit})` };
42
+ log(`⚠︎ FFMPEG_PATH="${explicit}" is not a runnable ffmpeg — ignoring it.`);
43
+ }
44
+ if (runnable("ffmpeg")) return { path: "ffmpeg", system: true, source: "system install" };
45
+ return { path: ffmpegStatic, system: false, source: "ffmpeg-static (bundled)" };
46
+ }
47
+
48
+ function listEncoders(bin) {
49
+ try {
50
+ const r = spawnSync(bin, ["-hide_banner", "-encoders"], { encoding: "utf8", timeout: 5000 });
51
+ return r.status === 0 ? (r.stdout || "") : "";
52
+ } catch { return ""; }
53
+ }
54
+
55
+ /** Encode a couple of synthetic frames with `codec` to /dev/null; true if it works. */
56
+ function functionalTest(bin, codec) {
57
+ const common = ["-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=size=192x108:rate=5:duration=1"];
58
+ const args = codec === "h264_vaapi"
59
+ ? ["-vaapi_device", process.env.VAAPI_DEVICE || "/dev/dri/renderD128", ...common,
60
+ "-vf", "format=nv12,hwupload", "-c:v", codec, "-frames:v", "3", "-f", "null", "-"]
61
+ : [...common, "-c:v", codec, "-frames:v", "3", "-f", "null", "-"];
62
+ try {
63
+ const r = spawnSync(bin, args, { encoding: "utf8", timeout: 12000 });
64
+ return r.status === 0;
65
+ } catch { return false; }
66
+ }
67
+
68
+ /**
69
+ * @returns {{ ffmpegPath: string, encoder: { codec: string, label: string, hw: boolean } }}
70
+ */
71
+ export function resolveEncoder() {
72
+ const bin = resolveBinary();
73
+ const encoders = listEncoders(bin.path);
74
+
75
+ let chosen = null;
76
+ for (const cand of HW_ENCODERS) {
77
+ if (encoders.includes(cand.codec) && functionalTest(bin.path, cand.codec)) { chosen = cand; break; }
78
+ }
79
+ const encoder = chosen
80
+ ? { ...chosen, hw: true }
81
+ : { codec: "libx264", label: "libx264 (software)", hw: false };
82
+
83
+ if (bin.system) {
84
+ log(`✓ ffmpeg: ${bin.source}`);
85
+ } else {
86
+ log("⚠︎ Using the bundled ffmpeg (no system install found). This encodes in software");
87
+ log(" and is much slower. Install ffmpeg for hardware-accelerated transcoding:");
88
+ log(" macOS: brew install ffmpeg");
89
+ log(" Debian/Ubuntu: sudo apt install ffmpeg");
90
+ log(" The transcoder auto-detects it on next start (or set FFMPEG_PATH).");
91
+ }
92
+ if (encoder.hw) log(`✓ hardware encoder: ${encoder.label} (${encoder.codec})`);
93
+ else log(`ℹ no usable hardware H.264 encoder — using software libx264 (${bin.source}).`);
94
+
95
+ return { ffmpegPath: bin.path, encoder };
96
+ }
package/src/transcode.js CHANGED
@@ -2,20 +2,101 @@
2
2
  * Native-ffmpeg transcode → HLS, producing the SAME structure the browser writes
3
3
  * so playback is identical: each ~6s segment is stored as an encrypted shard via
4
4
  * `space.putFile` (→ manifest), and an HLS index `{variants:[{height,bandwidth,
5
- * segments:[{duration,manifest}]}],duration}` is stored as its own file. Native
6
- * ffmpeg has no wasm limits, so we use all cores (`-threads 0`) and a nicer
7
- * preset than the browser's `ultrafast`.
5
+ * segments:[{duration,manifest}]}],duration}` is stored as its own file.
6
+ *
7
+ * The binary + encoder are resolved once (`ffmpeg.js`): a system ffmpeg with a
8
+ * hardware encoder (VideoToolbox/NVENC/QuickSync/VAAPI) when available, else the
9
+ * bundled software libx264. Hardware encoding offloads to dedicated silicon and
10
+ * is far faster than the browser's wasm build; software libx264 uses all cores.
8
11
  */
9
12
  import { spawn } from "node:child_process";
10
13
  import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises";
11
14
  import { tmpdir } from "node:os";
12
15
  import { join } from "node:path";
13
- import ffmpegPath from "ffmpeg-static";
16
+ import { resolveEncoder } from "./ffmpeg.js";
17
+
18
+ const log = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a);
19
+
20
+ // Resolved once, lazily (probing spawns a few ffmpeg processes). `ensureEncoder`
21
+ // lets the worker trigger + log the choice at startup instead of on first job.
22
+ let _resolved;
23
+ export function ensureEncoder() {
24
+ return (_resolved ??= resolveEncoder());
25
+ }
14
26
 
15
27
  // Match the browser's rendition ladder (single 720p for now).
16
28
  const RENDITIONS = [{ height: 720, videoBitrate: "2500k", audioBitrate: "128k", bandwidth: 2_800_000 }];
17
29
  const SEGMENT_SECONDS = 6;
18
30
 
31
+ /**
32
+ * Ordered encode strategies for a rendition on the resolved encoder — fastest
33
+ * first, each a full recipe of { input flags before -i, -vf filter, codec args }:
34
+ * 1. full GPU pipeline: hardware decode + scale + encode, frames never leave the
35
+ * GPU (huge win on NVENC/QuickSync/VAAPI);
36
+ * 2. hardware decode + encode, CPU scale (frames round-trip system memory);
37
+ * 3. software libx264 (works everywhere).
38
+ * `encodeRendition` tries them in order and remembers which one runs on this box.
39
+ */
40
+ function buildStrategies(codec, rend) {
41
+ const kf = ["-force_key_frames", `expr:gte(t,n_forced*${SEGMENT_SECONDS})`];
42
+ const scale = `scale=-2:${rend.height}`;
43
+ const bv = ["-b:v", rend.videoBitrate];
44
+ const vaapiDev = process.env.VAAPI_DEVICE || "/dev/dri/renderD128";
45
+
46
+ const software = {
47
+ label: "software libx264", input: [], filter: scale,
48
+ video: ["-threads", "0", "-c:v", "libx264", "-preset", "veryfast", ...bv,
49
+ "-pix_fmt", "yuv420p", "-profile:v", "main", "-level", "4.0", ...kf, "-sc_threshold", "0"],
50
+ };
51
+
52
+ switch (codec) {
53
+ case "h264_videotoolbox":
54
+ // VideoToolbox on Apple Silicon is encoder-bound, so a GPU scale buys little;
55
+ // GPU *decode* still halves CPU. One hw tier, then software.
56
+ return [
57
+ { label: "VideoToolbox (GPU decode+encode)", input: ["-hwaccel", "videotoolbox"], filter: scale,
58
+ video: ["-c:v", "h264_videotoolbox", ...bv, "-profile:v", "main", "-pix_fmt", "yuv420p", "-realtime", "1", ...kf] },
59
+ software,
60
+ ];
61
+ case "h264_nvenc":
62
+ return [
63
+ { label: "NVENC (full GPU pipeline)", input: ["-hwaccel", "cuda", "-hwaccel_output_format", "cuda"],
64
+ filter: `scale_cuda=-2:${rend.height}:format=yuv420p`,
65
+ video: ["-c:v", "h264_nvenc", "-preset", "p4", "-tune", "hq", "-rc", "vbr", ...bv, "-profile:v", "main", ...kf] },
66
+ { label: "NVENC (GPU encode, CPU scale)", input: ["-hwaccel", "cuda"], filter: scale,
67
+ video: ["-c:v", "h264_nvenc", "-preset", "p4", "-tune", "hq", "-rc", "vbr", ...bv, "-profile:v", "main", "-pix_fmt", "yuv420p", ...kf] },
68
+ software,
69
+ ];
70
+ case "h264_qsv":
71
+ return [
72
+ { label: "QuickSync (full GPU pipeline)", input: ["-hwaccel", "qsv", "-hwaccel_output_format", "qsv"],
73
+ filter: `scale_qsv=-2:${rend.height}`,
74
+ video: ["-c:v", "h264_qsv", "-preset", "veryfast", ...bv, "-profile:v", "main", ...kf] },
75
+ { label: "QuickSync (GPU encode, CPU scale)", input: ["-hwaccel", "qsv"], filter: scale,
76
+ video: ["-c:v", "h264_qsv", "-preset", "veryfast", ...bv, "-profile:v", "main", "-pix_fmt", "nv12", ...kf] },
77
+ software,
78
+ ];
79
+ case "h264_vaapi":
80
+ return [
81
+ { label: "VAAPI (full GPU pipeline)", input: ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", vaapiDev],
82
+ filter: `scale_vaapi=-2:${rend.height}`,
83
+ video: ["-c:v", "h264_vaapi", ...bv, "-profile:v", "main", ...kf] },
84
+ { label: "VAAPI (GPU encode, CPU scale)", input: ["-vaapi_device", vaapiDev],
85
+ filter: `${scale},format=nv12,hwupload`,
86
+ video: ["-c:v", "h264_vaapi", ...bv, "-profile:v", "main", ...kf] },
87
+ software,
88
+ ];
89
+ default:
90
+ return [software];
91
+ }
92
+ }
93
+
94
+ // Index into the encoder's strategy list to START from — advanced permanently
95
+ // only when a strategy fails *fast* (unavailable on this machine), so a one-off
96
+ // bad file doesn't downgrade every subsequent job. See encodeRendition.
97
+ let _stratStart = 0;
98
+ const FAST_FAIL_MS = 8000;
99
+
19
100
  function parseDurations(m3u8) {
20
101
  const out = [];
21
102
  for (const line of m3u8.split("\n")) {
@@ -30,16 +111,15 @@ function hhmmssToSec(s) {
30
111
  return m ? Number(m[1]) * 3600 + Number(m[2]) * 60 + Number(m[3]) : 0;
31
112
  }
32
113
 
33
- /** Run one ffmpeg rendition to HLS; report fraction via stderr `time=` vs total. */
34
- function runFfmpeg(inPath, dir, rend, playlist, onFrac, signal) {
114
+ /** Run one ffmpeg rendition to HLS with a given strategy; report fraction via
115
+ * stderr `time=` vs total. */
116
+ function runFfmpeg(ffmpegPath, strat, inPath, dir, rend, playlist, onFrac, signal) {
35
117
  return new Promise((resolve, reject) => {
36
118
  const args = [
119
+ ...strat.input,
37
120
  "-i", inPath,
38
- "-vf", `scale=-2:${rend.height}`,
39
- "-threads", "0", // native: use all cores
40
- "-c:v", "libx264", "-preset", "veryfast", "-b:v", rend.videoBitrate,
41
- "-pix_fmt", "yuv420p", "-profile:v", "main", "-level", "4.0",
42
- "-force_key_frames", `expr:gte(t,n_forced*${SEGMENT_SECONDS})`, "-sc_threshold", "0",
121
+ "-vf", strat.filter,
122
+ ...strat.video,
43
123
  "-c:a", "aac", "-ac", "2", "-b:a", rend.audioBitrate,
44
124
  "-hls_time", String(SEGMENT_SECONDS), "-hls_playlist_type", "vod",
45
125
  "-hls_segment_filename", join(dir, `r${rend.height}_%03d.ts`),
@@ -65,11 +145,44 @@ function runFfmpeg(inPath, dir, rend, playlist, onFrac, signal) {
65
145
  });
66
146
  }
67
147
 
148
+ /**
149
+ * Encode one rendition, trying strategies fastest→slowest and falling back on
150
+ * failure. If a strategy fails *fast* (before real encoding — i.e. it's not
151
+ * available on this machine, like a missing GPU filter), we advance the starting
152
+ * point permanently so future jobs skip it. A *slow* failure (mid-encode, likely
153
+ * file-specific) falls back for this file only, leaving fast paths for next time.
154
+ */
155
+ async function encodeRendition(ffmpegPath, encoder, inPath, dir, rend, playlist, onFrac, signal) {
156
+ const strategies = buildStrategies(encoder.codec, rend);
157
+ const start = Math.min(_stratStart, strategies.length - 1);
158
+ let lastErr;
159
+ for (let i = start; i < strategies.length; i++) {
160
+ const strat = strategies[i];
161
+ const t0 = Date.now();
162
+ try {
163
+ await runFfmpeg(ffmpegPath, strat, inPath, dir, rend, playlist, onFrac, signal);
164
+ if (i > start) log(`✓ "${strat.label}" — using it for subsequent jobs.`);
165
+ return;
166
+ } catch (e) {
167
+ if (signal?.aborted) throw e;
168
+ lastErr = e;
169
+ // Fast failure ⇒ this strategy isn't available on this box; skip it for good.
170
+ if (Date.now() - t0 < FAST_FAIL_MS) _stratStart = Math.max(_stratStart, i + 1);
171
+ const next = strategies[i + 1];
172
+ if (next) {
173
+ log(`⚠︎ "${strat.label}" failed (${String(e?.message || e).slice(0, 140)}) — falling back to "${next.label}".`);
174
+ }
175
+ }
176
+ }
177
+ throw lastErr;
178
+ }
179
+
68
180
  /**
69
181
  * Transcode raw bytes into HLS shards stored in `space`.
70
182
  * @returns {{ hls_index: unknown, duration: number, size: number }}
71
183
  */
72
184
  export async function transcodeToHls({ rawBytes, filename, space, onProgress, signal }) {
185
+ const { ffmpegPath, encoder } = ensureEncoder();
73
186
  const dir = await mkdtemp(join(tmpdir(), "tt-"));
74
187
  try {
75
188
  const ext = filename.match(/\.\w+$/)?.[0] || ".mp4";
@@ -82,8 +195,9 @@ export async function transcodeToHls({ rawBytes, filename, space, onProgress, si
82
195
  for (let r = 0; r < nR; r++) {
83
196
  const rend = RENDITIONS[r];
84
197
  const playlist = join(dir, `r${rend.height}.m3u8`);
198
+ const onFrac = (f) => onProgress?.({ fraction: (r + f) / nR, label: `Encoding ${rend.height}p…` });
85
199
  onProgress?.({ fraction: r / nR, label: `Encoding ${rend.height}p…` });
86
- await runFfmpeg(inPath, dir, rend, playlist, (f) => onProgress?.({ fraction: (r + f) / nR, label: `Encoding ${rend.height}p…` }), signal);
200
+ await encodeRendition(ffmpegPath, encoder, inPath, dir, rend, playlist, onFrac, signal);
87
201
 
88
202
  const durations = parseDurations(await readFile(playlist, "utf8"));
89
203
  const segments = [];
package/src/worker.js CHANGED
@@ -6,14 +6,14 @@
6
6
  * background, and the browser can also process it locally from memory.
7
7
  */
8
8
  import { hostname, cpus } from "node:os";
9
- import { transcodeToHls } from "./transcode.js";
9
+ import { transcodeToHls, ensureEncoder } from "./transcode.js";
10
10
 
11
11
  const WORKER_ID = `node-${Math.random().toString(36).slice(2, 10)}`;
12
12
  const WORKER_LABEL = `the transcoder (${hostname()})`;
13
13
  // Capability score: native ffmpeg on many cores far outranks a browser's wasm
14
14
  // (cores×~30), so browsers defer manifest-ready jobs to this machine.
15
15
  const MY_SCORE = cpus().length * 30;
16
- const STALE_MS = 90_000;
16
+ const STALE_MS = 45_000;
17
17
  const POLL_MS = 4_000;
18
18
 
19
19
  const log = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a);
@@ -108,6 +108,7 @@ export function startWorker({ client, space, appKey, baseUrl }) {
108
108
  let stopped = false;
109
109
  (async () => {
110
110
  log(`worker ${WORKER_ID} draining queue as ${client.auth.zk.user.username} (${WORKER_LABEL}, score ${MY_SCORE})`);
111
+ ensureEncoder(); // probe + log the ffmpeg binary / hardware encoder up front
111
112
  // Announce capability so browsers defer manifest-ready jobs to us.
112
113
  const hello = () => void postSignal({ kind: "hello", score: MY_SCORE, worker: WORKER_LABEL, native: true });
113
114
  hello(); setInterval(hello, 4_000);