Skip to content

Commit eea1ef5

Browse files
committed
Node.js support: FFmpeg runs ffmpeg-core in a worker_threads worker
@ffmpeg/ffmpeg's "node" export used to be a stub whose constructor threw "ffmpeg.wasm does not support nodejs" (#659, #773), which also broke server rendering and test runners. It is now the same FFmpeg class, with the core in a worker_threads worker instead of a Web Worker (#897): - src/node/index.ts: FFmpeg with createWorker() overridden to adapt a worker_threads Worker to the Web Worker interface the class uses. - src/node/worker.ts (+ globals.ts): gives worker.js its self/postMessage globals and defaults coreURL to the installed @ffmpeg/core. - The core is built for web, worker and node, with NODEFS to mount host directories; in Node its threads start from the core module itself. - It works from the main thread and from inside a worker_threads worker. - dist/esm is marked "type": "module" so Node loads it as ESM. - tests/node (node --test, part of npm test): load with the default core, exec, files, ffprobe, NODEFS, terminate, and from a worker thread.
1 parent 81c1f45 commit eea1ef5

18 files changed

Lines changed: 244 additions & 55 deletions

File tree

‎.github/workflows/CI.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ jobs:
8484
node-version: ${{ matrix.node }}
8585
cache: pnpm
8686
- run: pnpm install --frozen-lockfile
87-
- run: pnpm test --project core-st --project core-mt
87+
- run: pnpm test --project core-st --project core-mt --project node
8888
browser:
8989
# @ffmpeg/ffmpeg and @ffmpeg/util in Chromium.
9090
runs-on: ubuntu-latest

‎apps/website/docs/faq.md‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
11
# FAQ
22

3-
### Why ffmpeg.wasm doesn't support nodejs?
3+
### Does ffmpeg.wasm work in Node.js?
44

5-
ffmpeg.wasm did support nodejs before 0.12.0, but decided to discontinue nodejs support due to:
5+
Yes, since @ffmpeg/ffmpeg 0.13: the same `FFmpeg` class runs ffmpeg-core in a
6+
`worker_threads` worker, loads the installed `@ffmpeg/core` by default, and can
7+
mount host directories with `NODEFS`:
68

7-
- It takes extra effort to maintain nodejs support
8-
- If you are not in browser, there are a lot of better choices than using WebAssembly for a better performance, ex:
9-
- nodejs: https://www.npmjs.com/package/fluent-ffmpeg
10-
- react-native: https://github.com/arthenica/ffmpeg-kit
9+
```js
10+
import { FFmpeg } from "@ffmpeg/ffmpeg";
1111

12-
Of course, it is still highly welcome to contribute a nodejs version of ffmpeg.wasm.
12+
const ffmpeg = new FFmpeg();
13+
await ffmpeg.load();
14+
await ffmpeg.createDir("/host");
15+
await ffmpeg.mount("NODEFS", { root: process.cwd() }, "/host");
16+
await ffmpeg.exec(["-i", "/host/input.webm", "/host/output.mp4"]);
17+
```
18+
19+
A native `ffmpeg` binary is still faster if you can ship one.
1320

1421
### Why ffmpeg.wasm is so slow comparing to ffmpeg?
1522

‎build/ffmpeg-wasm.sh‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ CONF_FLAGS=(
4141
-lavutil
4242
$LDFLAGS
4343
-sDEFAULT_TO_CXX # x265, zimg and harfbuzz are C++
44-
-sENVIRONMENT=web,worker,node # node: the core tests run in Node.js
44+
-sENVIRONMENT=web,worker,node # node: @ffmpeg/ffmpeg's node export, and the core tests
4545
-sSTACK_SIZE=5MB # increase stack size to support libopus
4646
-sMODULARIZE # modularized to use as a library
4747
-sALLOW_MEMORY_GROWTH
@@ -50,6 +50,7 @@ CONF_FLAGS=(
5050
-sEXPORTED_FUNCTIONS=$(node src/bind/ffmpeg/export.js) # exported functions
5151
-sEXPORTED_RUNTIME_METHODS=$(node src/bind/ffmpeg/export-runtime.js) # exported built-in functions
5252
-lworkerfs.js
53+
-lnodefs.js # mount host directories in Node.js
5354
--pre-js src/bind/ffmpeg/bind.js # extra bindings, contains most of the ffmpeg.wasm javascript code
5455
--js-library src/bind/ffmpeg/library.js # overrides of emscripten library functions
5556
"${OBJS[@]}"

‎packages/core/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
"url": "https://github.com/ffmpegwasm/ffmpeg.wasm/issues"
3434
},
3535
"engines": {
36-
"node": ">=16.x"
36+
"node": ">=22"
3737
},
3838
"homepage": "https://github.com/ffmpegwasm/ffmpeg.wasm#readme",
3939
"publishConfig": {

‎packages/ffmpeg/package.json‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
{
22
"name": "@ffmpeg/ffmpeg",
33
"version": "0.12.15",
4-
"description": "FFmpeg WebAssembly version for browser",
4+
"description": "FFmpeg WebAssembly version for browsers and Node.js",
55
"main": "./dist/umd/ffmpeg.js",
66
"types": "./dist/esm/index.d.ts",
77
"exports": {
88
".": {
99
"types": "./dist/esm/index.d.ts",
10-
"node": "./dist/esm/empty.mjs",
10+
"node": "./dist/esm/node/index.js",
1111
"default": {
1212
"import": "./dist/esm/index.js",
1313
"require": "./dist/umd/ffmpeg.js"
@@ -22,7 +22,7 @@
2222
"dev": "webpack -w --mode development",
2323
"lint": "eslint src",
2424
"clean": "rimraf dist",
25-
"build:esm": "tsc -p tsconfig.esm.json",
25+
"build:esm": "tsc -p tsconfig.esm.json && node -e \"require('fs').writeFileSync('dist/esm/package.json', '{\\\"type\\\": \\\"module\\\"}')\"",
2626
"build:umd": "webpack",
2727
"build": "pnpm clean && pnpm build:esm && pnpm build:umd",
2828
"prepublishOnly": "pnpm build"
@@ -48,13 +48,15 @@
4848
"url": "https://github.com/ffmpegwasm/ffmpeg.wasm/issues"
4949
},
5050
"engines": {
51-
"node": ">=18.x"
51+
"node": ">=22"
5252
},
5353
"homepage": "https://github.com/ffmpegwasm/ffmpeg.wasm#readme",
5454
"publishConfig": {
5555
"access": "public"
5656
},
5757
"devDependencies": {
58+
"@ffmpeg/core": "workspace:*",
59+
"@types/node": "^22.20.4",
5860
"@typescript-eslint/eslint-plugin": "^6.1.0",
5961
"@typescript-eslint/parser": "^6.1.0",
6062
"eslint": "^8.45.0",
@@ -65,5 +67,13 @@
6567
},
6668
"dependencies": {
6769
"@ffmpeg/types": "workspace:*"
70+
},
71+
"peerDependencies": {
72+
"@ffmpeg/core": "workspace:^"
73+
},
74+
"peerDependenciesMeta": {
75+
"@ffmpeg/core": {
76+
"optional": true
77+
}
6878
}
6979
}

‎packages/ffmpeg/src/classes.ts‎

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,13 @@ export class FFmpeg {
182182
}
183183
}
184184

185+
protected createWorker(classWorkerURL?: string): Worker {
186+
return classWorkerURL
187+
? new Worker(new URL(classWorkerURL, import.meta.url), { type: "module" })
188+
: // Keep the literal URL here so bundlers pick up worker.js.
189+
new Worker(new URL("./worker.js", import.meta.url), { type: "module" });
190+
}
191+
185192
/**
186193
* Loads ffmpeg-core inside web worker. It is required to call this method first
187194
* as it initializes WebAssembly and other essential variables.
@@ -194,15 +201,7 @@ export class FFmpeg {
194201
{ signal }: FFMessageOptions = {}
195202
): Promise<IsFirst> => {
196203
if (!this.#worker) {
197-
this.#worker = classWorkerURL ?
198-
new Worker(new URL(classWorkerURL, import.meta.url), {
199-
type: "module",
200-
}) :
201-
// We need to duplicated the code here to enable webpack
202-
// to bundle worker.js here.
203-
new Worker(new URL("./worker.js", import.meta.url), {
204-
type: "module",
205-
});
204+
this.#worker = this.createWorker(classWorkerURL);
206205
this.#registerHandlers();
207206
}
208207
return this.#send(

‎packages/ffmpeg/src/empty.mts‎

Lines changed: 0 additions & 7 deletions
This file was deleted.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Web Worker globals for ../worker.js
2+
import { parentPort } from "node:worker_threads";
3+
4+
const scope = globalThis as unknown as {
5+
self: unknown;
6+
postMessage: (message: unknown, transfer?: Transferable[]) => void;
7+
};
8+
scope.self = globalThis;
9+
scope.postMessage = (message, transfer = []) => parentPort!.postMessage(message, transfer as never[]);

‎packages/ffmpeg/src/node/index.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { Worker as ThreadWorker } from "node:worker_threads";
2+
import { FFmpeg as BaseFFmpeg } from "../classes.js";
3+
4+
export * from "../types.js";
5+
export * from "../const.js";
6+
7+
class NodeWorker {
8+
onmessage: ((event: { data: unknown }) => void) | null = null;
9+
onerror: ((event: { error: unknown; preventDefault(): void }) => void) | null = null;
10+
#worker: ThreadWorker;
11+
12+
constructor(url: URL) {
13+
this.#worker = new ThreadWorker(url);
14+
this.#worker.on("message", (data) => this.onmessage?.({ data }));
15+
this.#worker.on("error", (error) => this.onerror?.({ error, preventDefault() {} }));
16+
}
17+
18+
postMessage(message: unknown, transfer: Transferable[] = []) {
19+
this.#worker.postMessage(message, transfer as never[]);
20+
}
21+
22+
terminate() {
23+
void this.#worker.terminate();
24+
}
25+
}
26+
27+
export class FFmpeg extends BaseFFmpeg {
28+
protected createWorker(classWorkerURL?: string): Worker {
29+
const url = new URL(classWorkerURL ?? "./worker.js", import.meta.url);
30+
return new NodeWorker(url) as unknown as Worker;
31+
}
32+
}

‎packages/ffmpeg/src/node/worker.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import "./globals.js";
2+
import "../worker.js";
3+
import { parentPort } from "node:worker_threads";
4+
import { createRequire } from "node:module";
5+
import { pathToFileURL } from "node:url";
6+
import type { FFMessageEvent, FFMessageLoadConfig } from "../types.js";
7+
8+
const scope = globalThis as unknown as { onmessage: (event: { data: unknown }) => void };
9+
10+
const defaultCoreURL = () => {
11+
const umd = createRequire(import.meta.url).resolve("@ffmpeg/core");
12+
return pathToFileURL(umd.replace(/[\\/]umd[\\/]/, "/esm/")).href;
13+
};
14+
15+
parentPort!.on("message", (data: FFMessageEvent["data"]) => {
16+
if (data.type === "LOAD") {
17+
const config = (data.data ?? {}) as FFMessageLoadConfig;
18+
data.data = { ...config, coreURL: config.coreURL ?? defaultCoreURL() };
19+
}
20+
scope.onmessage({ data });
21+
});

0 commit comments

Comments
 (0)