@muhkoo/theater-transcoder 0.1.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 +39 -0
- package/package.json +27 -0
- package/src/client.js +76 -0
- package/src/index.js +78 -0
- package/src/transcode.js +112 -0
- package/src/worker.js +121 -0
package/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# @muhkoo/theater-transcoder
|
|
2
|
+
|
|
3
|
+
A tiny **native-ffmpeg worker** for [Muhkoo Theater](https://muhkoo-theater.apps.muhkoo.dev). Sign in as yourself and it drains your library's transcode queue using all your cores — so uploads finish far faster than in-browser WebAssembly (which is ~20–40× slower and capped at a few threads).
|
|
4
|
+
|
|
5
|
+
Run it on a spare machine (a many-core server, say) **alongside or instead of** the browser. It coordinates with your browser tabs as a peer over your private Muhkoo Space: any device can claim a job, progress streams live everywhere, and pause/cancel from any device stops it here too.
|
|
6
|
+
|
|
7
|
+
## Install & run
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# one-off (no install)
|
|
11
|
+
npx @muhkoo/theater-transcoder --username you
|
|
12
|
+
|
|
13
|
+
# or install globally
|
|
14
|
+
npm i -g @muhkoo/theater-transcoder
|
|
15
|
+
MUHKOO_USERNAME=you MUHKOO_PASSWORD=… theater-transcoder
|
|
16
|
+
```
|
|
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).
|
|
19
|
+
|
|
20
|
+
Leave it running. Upload videos in the app and this box starts chewing through the queue. Ctrl-C to stop.
|
|
21
|
+
|
|
22
|
+
## How it works
|
|
23
|
+
|
|
24
|
+
1. **ZK login** as you → obtains the group key for your per-user library Space via the always-online keeper (no other device need be online).
|
|
25
|
+
2. **Claims** queued jobs from the shared `jobs` table (best-effort claim + heartbeat lease, identical to the browser worker).
|
|
26
|
+
3. **Fetches the raw** upload by manifest (`client.storage.readByManifest` — over origin; the browser peers it over WebRTC on a LAN).
|
|
27
|
+
4. **Transcodes** with native ffmpeg to HLS (720p, 6s segments, MSE-safe: `yuv420p` / `main` / stereo / keyframe-per-segment) — byte-compatible with what the browser writes.
|
|
28
|
+
5. **Stores** each segment as an encrypted shard and writes the `media` row + marks the job done, then nudges the `@scanner` agent to fetch poster/metadata.
|
|
29
|
+
|
|
30
|
+
Offline + P2P are auto-disabled in Node (no IndexedDB / WebRTC), so its shard and DB writes are identical to the browser's.
|
|
31
|
+
|
|
32
|
+
## Options
|
|
33
|
+
|
|
34
|
+
| flag | env | default |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| `--username` | `MUHKOO_USERNAME` | — (required) |
|
|
37
|
+
| `--password` | `MUHKOO_PASSWORD` | prompt |
|
|
38
|
+
| `--base` | `MUHKOO_BASE_URL` | `https://api.muhkoo.dev` |
|
|
39
|
+
| `--app-key` | `MUHKOO_THEATER_KEY` | public prod key |
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@muhkoo/theater-transcoder",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"theater-transcoder": "src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"keywords": ["muhkoo", "transcode", "ffmpeg", "hls", "worker"],
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@muhkoo/connect": "0.10.1-alpha.0",
|
|
23
|
+
"ffmpeg-static": "^5.2.0",
|
|
24
|
+
"snarkjs": "^0.7.5",
|
|
25
|
+
"ws": "^8.18.0"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connect to Muhkoo as a user (headless), and open the per-user media Space.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the browser app: the media Space name is derived from the user's
|
|
5
|
+
* commitment, and its group key is obtained via the always-online keeper — so a
|
|
6
|
+
* worker can join with nobody else present and read/write the same encrypted
|
|
7
|
+
* shards the browser does. Offline + P2P are auto-disabled in Node (no
|
|
8
|
+
* IndexedDB / RTCPeerConnection), so shard + DB writes are byte-identical.
|
|
9
|
+
*/
|
|
10
|
+
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { WebSocket } from "ws";
|
|
14
|
+
import { Client } from "@muhkoo/connect";
|
|
15
|
+
|
|
16
|
+
// Node 20's global WebSocket is flag-gated; ensure the Space socket has one.
|
|
17
|
+
if (!globalThis.WebSocket) globalThis.WebSocket = WebSocket;
|
|
18
|
+
|
|
19
|
+
/** snarkjs (Node) reads circuit assets off disk — download them to a temp dir. */
|
|
20
|
+
async function downloadCircuits(baseUrl) {
|
|
21
|
+
const dir = await mkdtemp(join(tmpdir(), "muhkoo-circuits-"));
|
|
22
|
+
const fetchTo = async (path, file) => {
|
|
23
|
+
const res = await fetch(baseUrl + path);
|
|
24
|
+
if (!res.ok) throw new Error(`circuit asset ${path} → ${res.status}`);
|
|
25
|
+
const out = join(dir, file);
|
|
26
|
+
await writeFile(out, Buffer.from(await res.arrayBuffer()));
|
|
27
|
+
return out;
|
|
28
|
+
};
|
|
29
|
+
const [wasmUrl, zkeyUrl] = await Promise.all([
|
|
30
|
+
fetchTo("/circuits/build/preimagePoK_js/preimagePoK.wasm", "preimagePoK.wasm"),
|
|
31
|
+
fetchTo("/circuits/build/preimagePoK_0001.zkey", "preimagePoK_0001.zkey"),
|
|
32
|
+
]);
|
|
33
|
+
return { wasmUrl, zkeyUrl };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The per-user media Space name (must match the browser app's ids.ts). */
|
|
37
|
+
export function mediaSpaceName(commitment) {
|
|
38
|
+
const suffix = commitment ? commitment.replace(/\D/g, "").slice(-24) : "anon";
|
|
39
|
+
return `theater-${suffix}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Log in as the user and return a ready Client. */
|
|
43
|
+
export async function connect({ baseUrl, appKey, username, password }) {
|
|
44
|
+
const circuits = await downloadCircuits(baseUrl);
|
|
45
|
+
const client = new Client({
|
|
46
|
+
baseUrl,
|
|
47
|
+
apiKey: appKey,
|
|
48
|
+
circuits,
|
|
49
|
+
offline: { enabled: false },
|
|
50
|
+
p2p: { enabled: false },
|
|
51
|
+
});
|
|
52
|
+
const user = await client.auth.zk.login(username, password);
|
|
53
|
+
return { client, user };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Join (or create) the user's media Space and wait until we hold the group key. */
|
|
57
|
+
export async function openMediaSpace(client) {
|
|
58
|
+
const commitment = client.auth.zk.user?.commitment;
|
|
59
|
+
const name = mediaSpaceName(commitment);
|
|
60
|
+
let space;
|
|
61
|
+
try { space = await client.space.joinChannel(name); }
|
|
62
|
+
catch { space = await client.space.createChannel(name); }
|
|
63
|
+
const kr = space.keyring;
|
|
64
|
+
if (kr) {
|
|
65
|
+
try { await kr.loadFromCache(); } catch { /* new */ }
|
|
66
|
+
const deadline = Date.now() + 20000;
|
|
67
|
+
while (!kr.hasAnyKey() && Date.now() < deadline) {
|
|
68
|
+
await kr.requestKey().catch(() => {});
|
|
69
|
+
await kr.pullKeys().catch(() => {});
|
|
70
|
+
if (kr.hasAnyKey()) break;
|
|
71
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
72
|
+
}
|
|
73
|
+
if (!kr.hasAnyKey()) throw new Error("could not obtain the library group key");
|
|
74
|
+
}
|
|
75
|
+
return { space, name };
|
|
76
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* theater-transcoder — a native-ffmpeg worker for Muhkoo Theater.
|
|
4
|
+
*
|
|
5
|
+
* Sign in as yourself and it drains your library's transcode queue using all
|
|
6
|
+
* your cores. Run it on a beefy machine (a 48-core box, say) alongside — or
|
|
7
|
+
* instead of — the browser, and uploads finish far faster. Coordinates with your
|
|
8
|
+
* browsers as a peer over your private Space; the raw file comes from whichever
|
|
9
|
+
* device has it (P2P in the browser, origin here).
|
|
10
|
+
*/
|
|
11
|
+
import { connect, openMediaSpace } from "./client.js";
|
|
12
|
+
import { startWorker } from "./worker.js";
|
|
13
|
+
|
|
14
|
+
const HELP = `theater-transcoder — native-ffmpeg worker for Muhkoo Theater
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
theater-transcoder --username <you> [--password <pw>]
|
|
18
|
+
|
|
19
|
+
Auth (credentials never leave your machine — they run the same zero-knowledge
|
|
20
|
+
login the browser uses):
|
|
21
|
+
--username <u> or $MUHKOO_USERNAME
|
|
22
|
+
--password <p> or $MUHKOO_PASSWORD (omit to be prompted; avoid on the CLI)
|
|
23
|
+
|
|
24
|
+
Options:
|
|
25
|
+
--base <url> API base (default https://api.muhkoo.dev, or $MUHKOO_BASE_URL)
|
|
26
|
+
--app-key <mk_*_pk_*> Theater app key (defaults to the public prod key)
|
|
27
|
+
-h, --help
|
|
28
|
+
|
|
29
|
+
Requires: Node >= 20. ffmpeg is bundled (ffmpeg-static) — nothing to install.`;
|
|
30
|
+
|
|
31
|
+
function parseArgs(argv) {
|
|
32
|
+
const a = {};
|
|
33
|
+
for (let i = 0; i < argv.length; i++) {
|
|
34
|
+
const t = argv[i];
|
|
35
|
+
if (t === "-h" || t === "--help") a.help = true;
|
|
36
|
+
else if (t.startsWith("--")) { const k = t.slice(2); a[k] = argv[i + 1]?.startsWith("--") || argv[i + 1] === undefined ? true : argv[++i]; }
|
|
37
|
+
}
|
|
38
|
+
return a;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function promptHidden(q) {
|
|
42
|
+
if (!process.stdin.isTTY) return "";
|
|
43
|
+
process.stdout.write(q);
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.setEncoding("utf8");
|
|
46
|
+
let buf = "";
|
|
47
|
+
const on = (ch) => {
|
|
48
|
+
if (ch === "\n" || ch === "\r" || ch === "") { process.stdin.setRawMode(false); process.stdin.pause(); process.stdin.removeListener("data", on); process.stdout.write("\n"); resolve(buf); }
|
|
49
|
+
else if (ch === "") process.exit(130);
|
|
50
|
+
else if (ch === "") buf = buf.slice(0, -1);
|
|
51
|
+
else buf += ch;
|
|
52
|
+
};
|
|
53
|
+
process.stdin.on("data", on);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function main() {
|
|
58
|
+
const args = parseArgs(process.argv.slice(2));
|
|
59
|
+
if (args.help) { console.log(HELP); return; }
|
|
60
|
+
|
|
61
|
+
const baseUrl = (args.base || process.env.MUHKOO_BASE_URL || "https://api.muhkoo.dev").replace(/\/+$/, "");
|
|
62
|
+
const appKey = args["app-key"] || process.env.MUHKOO_THEATER_KEY || "mk_live_pk_80c0758c9e2f7d695d3f5f7fd2ee8458";
|
|
63
|
+
const username = args.username || process.env.MUHKOO_USERNAME;
|
|
64
|
+
let password = args.password || process.env.MUHKOO_PASSWORD;
|
|
65
|
+
if (!username) { console.error("Missing --username (or $MUHKOO_USERNAME).\n\n" + HELP); process.exit(1); }
|
|
66
|
+
if (!password) password = await promptHidden(`Password for ${username}: `);
|
|
67
|
+
if (!password) { console.error("A password is required."); process.exit(1); }
|
|
68
|
+
|
|
69
|
+
console.log(`Signing in as ${username} → ${baseUrl}…`);
|
|
70
|
+
const { client, user } = await connect({ baseUrl, appKey, username, password });
|
|
71
|
+
console.log(`✓ signed in as ${user.username} (…${user.commitment.slice(-8)})`);
|
|
72
|
+
const { space, name } = await openMediaSpace(client);
|
|
73
|
+
console.log(`✓ joined your library space "${name}"`);
|
|
74
|
+
startWorker({ client, space, appKey, baseUrl });
|
|
75
|
+
console.log("Ready — waiting for jobs. Leave this running; Ctrl-C to stop.");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
main().catch((e) => { console.error("ERROR:", e?.stack || e?.message || e); process.exit(1); });
|
package/src/transcode.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native-ffmpeg transcode → HLS, producing the SAME structure the browser writes
|
|
3
|
+
* so playback is identical: each ~6s segment is stored as an encrypted shard via
|
|
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`.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import ffmpegPath from "ffmpeg-static";
|
|
14
|
+
|
|
15
|
+
// Match the browser's rendition ladder (single 720p for now).
|
|
16
|
+
const RENDITIONS = [{ height: 720, videoBitrate: "2500k", audioBitrate: "128k", bandwidth: 2_800_000 }];
|
|
17
|
+
const SEGMENT_SECONDS = 6;
|
|
18
|
+
|
|
19
|
+
function parseDurations(m3u8) {
|
|
20
|
+
const out = [];
|
|
21
|
+
for (const line of m3u8.split("\n")) {
|
|
22
|
+
const m = line.match(/^#EXTINF:([\d.]+)/);
|
|
23
|
+
if (m) out.push(parseFloat(m[1]));
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function hhmmssToSec(s) {
|
|
29
|
+
const m = /(\d+):(\d+):(\d+(?:\.\d+)?)/.exec(s);
|
|
30
|
+
return m ? Number(m[1]) * 3600 + Number(m[2]) * 60 + Number(m[3]) : 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Run one ffmpeg rendition to HLS; report fraction via stderr `time=` vs total. */
|
|
34
|
+
function runFfmpeg(inPath, dir, rend, playlist, onFrac, signal) {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const args = [
|
|
37
|
+
"-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",
|
|
43
|
+
"-c:a", "aac", "-ac", "2", "-b:a", rend.audioBitrate,
|
|
44
|
+
"-hls_time", String(SEGMENT_SECONDS), "-hls_playlist_type", "vod",
|
|
45
|
+
"-hls_segment_filename", join(dir, `r${rend.height}_%03d.ts`),
|
|
46
|
+
"-y", playlist,
|
|
47
|
+
];
|
|
48
|
+
const ff = spawn(ffmpegPath, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
49
|
+
let total = 0;
|
|
50
|
+
let stderr = "";
|
|
51
|
+
ff.stderr.on("data", (b) => {
|
|
52
|
+
const s = b.toString();
|
|
53
|
+
stderr = (stderr + s).slice(-4000);
|
|
54
|
+
if (!total) { const d = /Duration:\s*(\d+:\d+:[\d.]+)/.exec(s); if (d) total = hhmmssToSec(d[1]); }
|
|
55
|
+
const t = /time=(\d+:\d+:[\d.]+)/.exec(s);
|
|
56
|
+
if (t && total) onFrac(Math.max(0, Math.min(1, hhmmssToSec(t[1]) / total)));
|
|
57
|
+
});
|
|
58
|
+
if (signal) signal.addEventListener("abort", () => { try { ff.kill("SIGKILL"); } catch { /* dead */ } }, { once: true });
|
|
59
|
+
ff.on("error", reject);
|
|
60
|
+
ff.on("close", (code) => {
|
|
61
|
+
if (signal?.aborted) reject(new Error("aborted"));
|
|
62
|
+
else if (code === 0) resolve();
|
|
63
|
+
else reject(new Error(`ffmpeg exited ${code}: ${stderr.slice(-500)}`));
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Transcode raw bytes into HLS shards stored in `space`.
|
|
70
|
+
* @returns {{ hls_index: unknown, duration: number, size: number }}
|
|
71
|
+
*/
|
|
72
|
+
export async function transcodeToHls({ rawBytes, filename, space, onProgress, signal }) {
|
|
73
|
+
const dir = await mkdtemp(join(tmpdir(), "tt-"));
|
|
74
|
+
try {
|
|
75
|
+
const ext = filename.match(/\.\w+$/)?.[0] || ".mp4";
|
|
76
|
+
const inPath = join(dir, `input${ext}`);
|
|
77
|
+
await writeFile(inPath, rawBytes);
|
|
78
|
+
|
|
79
|
+
const nR = RENDITIONS.length;
|
|
80
|
+
const variants = [];
|
|
81
|
+
let size = 0;
|
|
82
|
+
for (let r = 0; r < nR; r++) {
|
|
83
|
+
const rend = RENDITIONS[r];
|
|
84
|
+
const playlist = join(dir, `r${rend.height}.m3u8`);
|
|
85
|
+
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);
|
|
87
|
+
|
|
88
|
+
const durations = parseDurations(await readFile(playlist, "utf8"));
|
|
89
|
+
const segments = [];
|
|
90
|
+
for (let i = 0; i < durations.length; i++) {
|
|
91
|
+
if (signal?.aborted) throw new Error("aborted");
|
|
92
|
+
const name = `r${rend.height}_${String(i).padStart(3, "0")}.ts`;
|
|
93
|
+
const bytes = await readFile(join(dir, name));
|
|
94
|
+
size += bytes.length;
|
|
95
|
+
const { manifest } = await space.putFile(new Uint8Array(bytes), { name, type: "video/mp2t" });
|
|
96
|
+
segments.push({ duration: durations[i], manifest });
|
|
97
|
+
onProgress?.({ fraction: (r + 1) / nR, label: `Uploading ${i + 1}/${durations.length} segments…` });
|
|
98
|
+
}
|
|
99
|
+
variants.push({ height: rend.height, bandwidth: rend.bandwidth, segments });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const duration = variants[0]?.segments.reduce((s, x) => s + x.duration, 0) ?? 0;
|
|
103
|
+
const index = { variants, duration };
|
|
104
|
+
const { manifest: hls_index } = await space.putFile(
|
|
105
|
+
new TextEncoder().encode(JSON.stringify(index)),
|
|
106
|
+
{ name: "hls-index.json", type: "application/json" },
|
|
107
|
+
);
|
|
108
|
+
return { hls_index, duration, size };
|
|
109
|
+
} finally {
|
|
110
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
111
|
+
}
|
|
112
|
+
}
|
package/src/worker.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The worker: drains the shared `jobs` queue with the same claim/lease + Space
|
|
3
|
+
* signaling protocol the browser uses, so browsers and transcoders coordinate as
|
|
4
|
+
* peers. Only jobs whose raw file has been persisted to shards (`input_manifest`
|
|
5
|
+
* set) are claimable here — a browser's freshly-created job is stored in the
|
|
6
|
+
* background, and the browser can also process it locally from memory.
|
|
7
|
+
*/
|
|
8
|
+
import { hostname } from "node:os";
|
|
9
|
+
import { transcodeToHls } from "./transcode.js";
|
|
10
|
+
|
|
11
|
+
const WORKER_ID = `node-${Math.random().toString(36).slice(2, 10)}`;
|
|
12
|
+
const WORKER_LABEL = `the transcoder (${hostname()})`;
|
|
13
|
+
const STALE_MS = 90_000;
|
|
14
|
+
const POLL_MS = 4_000;
|
|
15
|
+
|
|
16
|
+
const log = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a);
|
|
17
|
+
|
|
18
|
+
export function startWorker({ client, space, appKey, baseUrl }) {
|
|
19
|
+
const commitment = client.auth.zk.user.commitment;
|
|
20
|
+
const jobs = () => client.db.table("jobs");
|
|
21
|
+
const media = () => client.db.table("media");
|
|
22
|
+
let activeJobId = null;
|
|
23
|
+
let abort = null; // AbortController for the running ffmpeg
|
|
24
|
+
|
|
25
|
+
const postSignal = async (sig) => {
|
|
26
|
+
try { await space.sendMessage({ _t: "jobsig", src: WORKER_ID, ...sig }); } catch { /* best-effort */ }
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Cross-device control: another device asks to pause/cancel a job we're running.
|
|
30
|
+
space.onMessage((e) => {
|
|
31
|
+
const b = e?.message?.body;
|
|
32
|
+
if (!b || b._t !== "jobsig" || b.src === WORKER_ID) return;
|
|
33
|
+
if (b.kind === "control" && b.jobId === activeJobId && abort) { log("remote stop for job", b.jobId); abort.abort(); }
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const patch = (id, values) => jobs().update(id, { ...values, updated_at: Date.now() });
|
|
37
|
+
|
|
38
|
+
async function claimNext() {
|
|
39
|
+
const { rows } = await jobs().query({
|
|
40
|
+
where: [{ column: "owner", op: "eq", value: commitment }],
|
|
41
|
+
orderBy: { column: "created_at", dir: "asc" },
|
|
42
|
+
limit: 100,
|
|
43
|
+
});
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
const cand = rows.find((j) =>
|
|
46
|
+
j.input_manifest && (
|
|
47
|
+
j.status === "queued" ||
|
|
48
|
+
(j.status === "processing" && (!j.heartbeat_at || now - j.heartbeat_at > STALE_MS))
|
|
49
|
+
));
|
|
50
|
+
if (!cand) return null;
|
|
51
|
+
await patch(cand._id, { status: "processing", worker_id: WORKER_ID, worker_label: WORKER_LABEL, claimed_at: now, heartbeat_at: now });
|
|
52
|
+
const fresh = await jobs().get(cand._id);
|
|
53
|
+
const row = fresh?.row ?? fresh;
|
|
54
|
+
if (!row || row.worker_id !== WORKER_ID) return claimNext(); // lost the race
|
|
55
|
+
return row;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function process(job) {
|
|
59
|
+
activeJobId = job._id;
|
|
60
|
+
abort = new AbortController();
|
|
61
|
+
let lastBeat = 0;
|
|
62
|
+
const onProgress = ({ fraction, label }) => {
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
if (now - lastBeat > 4000) {
|
|
65
|
+
lastBeat = now;
|
|
66
|
+
void patch(job._id, { progress: fraction, phase: label, heartbeat_at: now }).catch(() => {});
|
|
67
|
+
void postSignal({ kind: "progress", jobId: job._id, progress: fraction, phase: label, worker: WORKER_LABEL });
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
try {
|
|
71
|
+
log(`claim job ${job._id} "${job.title}" — downloading raw…`);
|
|
72
|
+
const { data } = await client.storage.readByManifest(job.input_manifest);
|
|
73
|
+
log(`transcoding ${(data.length / 1e6).toFixed(1)}MB…`);
|
|
74
|
+
const { hls_index, duration, size } = await transcodeToHls({
|
|
75
|
+
rawBytes: data, filename: job.filename || "input.mp4", space, onProgress, signal: abort.signal,
|
|
76
|
+
});
|
|
77
|
+
const { row } = await media().insert({
|
|
78
|
+
title: job.title, year: null, genre: null, description: null, kind: "video",
|
|
79
|
+
duration, poster: null, hls_index, size, owner: commitment, created_at: Date.now(),
|
|
80
|
+
});
|
|
81
|
+
await patch(job._id, { status: "done", progress: 1, phase: "Done", media_id: row._id });
|
|
82
|
+
await postSignal({ kind: "done", jobId: job._id });
|
|
83
|
+
void requestScan(row._id, job.filename || job.title);
|
|
84
|
+
log(`✓ done job ${job._id} → media ${row._id}`);
|
|
85
|
+
} catch (e) {
|
|
86
|
+
if (abort.signal.aborted) { log(`job ${job._id} stopped remotely`); }
|
|
87
|
+
else { await patch(job._id, { status: "error", error: String(e?.message || e) }).catch(() => {}); log(`✗ job ${job._id} failed:`, e?.message || e); }
|
|
88
|
+
} finally {
|
|
89
|
+
activeJobId = null; abort = null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Best-effort: ask the @scanner agent to enrich the new media (title/poster).
|
|
94
|
+
let scannerSpace;
|
|
95
|
+
async function requestScan(mediaId, filename) {
|
|
96
|
+
try {
|
|
97
|
+
if (!scannerSpace) scannerSpace = await client.space.joinChannel("scanner").catch(() => client.space.createChannel("scanner"));
|
|
98
|
+
await scannerSpace.sendMessage({ contents: `@scanner scan mediaId=${mediaId} file=${JSON.stringify(filename)}` });
|
|
99
|
+
} catch { /* enrichment is optional */ }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let stopped = false;
|
|
103
|
+
(async () => {
|
|
104
|
+
log(`worker ${WORKER_ID} draining queue as ${client.auth.zk.user.username} (${WORKER_LABEL})`);
|
|
105
|
+
// Real-time wake on new jobs; the poll below is the durable fallback.
|
|
106
|
+
space.onMessage((e) => { const b = e?.message?.body; if (b?._t === "jobsig" && b.kind === "new" && b.src !== WORKER_ID) tick(); });
|
|
107
|
+
let ticking = false;
|
|
108
|
+
async function tick() {
|
|
109
|
+
if (ticking || stopped) return;
|
|
110
|
+
ticking = true;
|
|
111
|
+
try { for (let job = await claimNext(); job; job = await claimNext()) await process(job); }
|
|
112
|
+
catch (e) { log("loop error:", e?.message || e); }
|
|
113
|
+
finally { ticking = false; }
|
|
114
|
+
}
|
|
115
|
+
await tick();
|
|
116
|
+
const iv = setInterval(tick, POLL_MS);
|
|
117
|
+
process.on?.("SIGINT", () => { stopped = true; clearInterval(iv); log("shutting down"); process.exit(0); });
|
|
118
|
+
})();
|
|
119
|
+
|
|
120
|
+
void baseUrl; void appKey; // reserved for future direct-API needs
|
|
121
|
+
}
|