Skip to content

Commit 1bcdf9a

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 281116c commit 1bcdf9a

16 files changed

Lines changed: 223 additions & 38 deletions

File tree

‎.github/workflows/CI.yml‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,10 @@ jobs:
5050
with:
5151
name: ffmpeg-core
5252
path: packages/core/dist
53-
- name: Use Node.js 20
53+
- name: Use Node.js 22
5454
uses: actions/setup-node@v4
5555
with:
56-
node-version: 20.x
56+
node-version: 22.x
5757
- name: Cache dependencies
5858
id: cache-dependencies
5959
uses: actions/cache@v4
@@ -68,6 +68,30 @@ jobs:
6868
run: npm run test:install
6969
- name: Run tests
7070
run: npm test
71+
node:
72+
# The Node.js API on every supported Node.js version.
73+
runs-on: ubuntu-latest
74+
needs:
75+
- build-core
76+
strategy:
77+
matrix:
78+
node: [20, 22, 24]
79+
steps:
80+
- name: Checkout Source Code
81+
uses: actions/checkout@v4
82+
- name: Download ffmpeg-core
83+
uses: actions/download-artifact@v4
84+
with:
85+
name: ffmpeg-core
86+
path: packages/core/dist
87+
- name: Use Node.js ${{ matrix.node }}
88+
uses: actions/setup-node@v4
89+
with:
90+
node-version: ${{ matrix.node }}
91+
- name: Install dependencies
92+
run: npm install
93+
- name: Run Node.js tests
94+
run: npm run test:node
7195
heavy:
7296
# FATE samples + benchmarks: nightly, on demand, or with the "heavy" PR label.
7397
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'heavy')

‎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
44+
-sENVIRONMENT=web,worker,node # node: @ffmpeg/ffmpeg runs the core in worker_threads
4545
-sSTACK_SIZE=5MB # increase stack size to support libopus
4646
-sDEFAULT_PTHREAD_STACK_SIZE=2MB # the 64KB default overflows in x264 and decoder threads
4747
-sMODULARIZE # modularized to use as a library
@@ -53,6 +53,7 @@ CONF_FLAGS=(
5353
-sEXPORTED_FUNCTIONS=$(node src/bind/ffmpeg/export.js) # exported functions
5454
-sEXPORTED_RUNTIME_METHODS=$(node src/bind/ffmpeg/export-runtime.js) # exported built-in functions
5555
-lworkerfs.js
56+
-lnodefs.js # mount host directories in Node.js
5657
--pre-js src/bind/ffmpeg/bind.js # extra bindings, contains most of the ffmpeg.wasm javascript code
5758
--js-library src/bind/ffmpeg/library.js # overrides of emscripten library functions
5859
'-sDEFAULT_LIBRARY_FUNCS_TO_INCLUDE=$holdRuntime' # see library.js

‎package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
"build:wasm": "bash scripts/build-wasm.sh",
1010
"serve": "http-server -c-1 -s -p 3000 . --cors --headers '{\"Cross-Origin-Embedder-Policy\":\"require-corp\",\"Cross-Origin-Opener-Policy\":\"same-origin\",\"Cross-Origin-Resource-Policy\":\"cross-origin\",\"Origin-Agent-Cluster\":\"?1\"}'",
1111
"pretest": "npm run build",
12-
"test": "node tests/run.mjs",
12+
"test": "node tests/run.mjs && npm run test:node",
13+
"test:node": "node --test tests/node/*.test.mjs",
1314
"test:install": "node node_modules/playwright/cli.js install --with-deps chromium",
1415
"test:heavy": "node tests/heavy/fate.mjs",
1516
"bench": "node tests/heavy/bench.mjs",

‎packages/ffmpeg/package.json‎

Lines changed: 4 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": "npm run clean && npm run build:esm && npm run build:umd",
2828
"prepublishOnly": "npm run build"
@@ -48,7 +48,7 @@
4848
"url": "https://github.com/ffmpegwasm/ffmpeg.wasm/issues"
4949
},
5050
"engines": {
51-
"node": ">=18.x"
51+
"node": ">=20"
5252
},
5353
"homepage": "https://github.com/ffmpegwasm/ffmpeg.wasm#readme",
5454
"publishConfig": {

‎packages/ffmpeg/src/classes.ts‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,17 @@ export class FFmpeg {
183183
}
184184
}
185185

186+
/**
187+
* Starts the worker that runs ffmpeg-core (`FFmpeg` from the `node` export
188+
* overrides it with a worker_threads worker).
189+
*/
190+
protected createWorker(classWorkerURL?: string): Worker {
191+
return classWorkerURL
192+
? new Worker(new URL(classWorkerURL, import.meta.url), { type: "module" })
193+
: // Keep the literal URL here so bundlers pick up worker.js.
194+
new Worker(new URL("./worker.js", import.meta.url), { type: "module" });
195+
}
196+
186197
/**
187198
* Loads ffmpeg-core inside web worker. It is required to call this method first
188199
* as it initializes WebAssembly and other essential variables.
@@ -195,15 +206,7 @@ export class FFmpeg {
195206
{ signal }: FFMessageOptions = {}
196207
): Promise<IsFirst> => {
197208
if (!this.#worker) {
198-
this.#worker = classWorkerURL ?
199-
new Worker(new URL(classWorkerURL, import.meta.url), {
200-
type: "module",
201-
}) :
202-
// We need to duplicated the code here to enable webpack
203-
// to bundle worker.js here.
204-
new Worker(new URL("./worker.js", import.meta.url), {
205-
type: "module",
206-
});
209+
this.#worker = this.createWorker(classWorkerURL);
207210
this.#registerHandlers();
208211
}
209212
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+
// The Web Worker globals ../worker.js uses, on a worker_threads worker.
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: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Node.js entry of @ffmpeg/ffmpeg (the "node" export condition): the same
2+
// FFmpeg class, with ffmpeg-core running in a worker_threads worker.
3+
import { Worker as ThreadWorker } from "node:worker_threads";
4+
import { FFmpeg as BaseFFmpeg } from "../classes.js";
5+
6+
export * from "../types.js";
7+
export * from "../const.js";
8+
9+
/** The subset of the Web Worker interface the FFmpeg class uses. */
10+
class NodeWorker {
11+
onmessage: ((event: { data: unknown }) => void) | null = null;
12+
onerror: ((event: { error: unknown; preventDefault(): void }) => void) | null = null;
13+
#worker: ThreadWorker;
14+
15+
constructor(url: URL) {
16+
this.#worker = new ThreadWorker(url);
17+
this.#worker.on("message", (data) => this.onmessage?.({ data }));
18+
this.#worker.on("error", (error) => this.onerror?.({ error, preventDefault() {} }));
19+
}
20+
21+
postMessage(message: unknown, transfer: Transferable[] = []) {
22+
this.#worker.postMessage(message, transfer as never[]);
23+
}
24+
25+
terminate() {
26+
void this.#worker.terminate();
27+
}
28+
}
29+
30+
export class FFmpeg extends BaseFFmpeg {
31+
protected createWorker(classWorkerURL?: string): Worker {
32+
const url = new URL(classWorkerURL ?? "./worker.js", import.meta.url);
33+
return new NodeWorker(url) as unknown as Worker;
34+
}
35+
}

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

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

0 commit comments

Comments
 (0)