Skip to content

Commit 87ec901

Browse files
authored
fix(ffmpeg): backport upstream load, proxy, writeFile and Node path fixes (#72)
* fix(ffmpeg): keep the loaded core on a second load() and backport upstream load, proxy and writeFile fixes * fix(ffmpeg): match only bad-header errors as a non-wasm wasmURL
1 parent bed7e59 commit 87ec901

8 files changed

Lines changed: 226 additions & 71 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,31 @@
33
All notable changes to this project are documented in this file. The format
44
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
55

6+
## Unreleased
7+
8+
### Added
9+
10+
- `writeFile(path, data, { transfer: false })` copies a `Uint8Array` to the
11+
worker instead of transferring it, so the caller's array is not emptied.
12+
The default is unchanged.
13+
- Under Node.js, `coreURL` and `wasmURL` accept filesystem paths, including
14+
Windows paths, as well as `file:` URLs.
15+
16+
### Fixed
17+
18+
- A second `load()` on a loaded `FFmpeg` no longer creates a second core and
19+
leaks the first. It keeps the loaded core and resolves `false`.
20+
- `load()` names the `wasmURL` in its error when the response is not a
21+
WebAssembly file.
22+
- `mount()` returns `false` for names such as `__proto__` or `constructor`
23+
instead of treating them as filesystems.
24+
- `on()` and `off()` work when the `FFmpeg` instance is wrapped in a Proxy,
25+
such as Vue's `reactive()`.
26+
- `fetchFile()` reads a `Blob` or `File` in Node.js, where `FileReader` does
27+
not exist.
28+
- The dynamic core import carries `turbopackIgnore`, so Turbopack leaves it
29+
to run at load time.
30+
631
## 0.15.0
732

833
### Added

‎apps/website/docs/getting-started/usage.md‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -482,7 +482,12 @@ const url = URL.createObjectURL(new Blob([frames[0]], { type: 'image/png' }));
482482
A helper rejects with ffmpeg's last log lines when the command fails.
483483
Options beyond the ones shown go in `args` for `transcode()`, and every
484484
helper accepts `timeout` and `signal` like `exec()`. A `Uint8Array` input is
485-
copied, because `writeFile()` hands its buffer to the worker.
485+
copied, because `writeFile()` hands its buffer to the worker. To keep your own
486+
`Uint8Array` intact, call `writeFile(path, data, { transfer: false })`, which
487+
copies it instead.
488+
489+
Calling `load()` on an already loaded `FFmpeg` keeps the loaded core and
490+
resolves `false`. Call `terminate()` first to load a different core.
486491

487492
`extractFrames()` writes `png`, `jpg` or `webp` images. `webp` needs a core
488493
built with libwebp, which the default core has.
@@ -516,7 +521,8 @@ await ffmpeg.terminate();
516521
```
517522

518523
`fetchFile` reads a local path or a `file:` URL directly in Node.js instead
519-
of going through `fetch()`.
524+
of going through `fetch()`. Under Node.js, `coreURL` and `wasmURL` also accept
525+
a filesystem path, including a Windows path, or a `file:` URL.
520526

521527
### Multithread core
522528

‎packages/ffmpeg/src/classes.ts‎

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ type FFMessageOptions = {
3131
signal?: AbortSignal;
3232
};
3333

34+
type EventListenerMethod = {
35+
(event: "log", callback: LogEventCallback): void;
36+
(event: "progress", callback: ProgressEventCallback): void;
37+
};
38+
3439
/**
3540
* Provides APIs to interact with ffmpeg web worker.
3641
*
@@ -176,30 +181,22 @@ export class FFmpeg {
176181
*
177182
* @category FFmpeg
178183
*/
179-
public on(event: "log", callback: LogEventCallback): void;
180-
public on(event: "progress", callback: ProgressEventCallback): void;
181-
public on(
182-
event: "log" | "progress",
183-
callback: LogEventCallback | ProgressEventCallback
184-
) {
184+
// Arrow fields reach the #private fields when called through a Proxy such
185+
// as Vue's reactive(); prototype methods would throw a TypeError.
186+
public on: EventListenerMethod = (event, callback) => {
185187
if (event === "log") {
186188
this.#logEventCallbacks.push(callback as LogEventCallback);
187189
} else if (event === "progress") {
188190
this.#progressEventCallbacks.push(callback as ProgressEventCallback);
189191
}
190-
}
192+
};
191193

192194
/**
193195
* Unlisten to log or progress events from `ffmpeg.exec()`.
194196
*
195197
* @category FFmpeg
196198
*/
197-
public off(event: "log", callback: LogEventCallback): void;
198-
public off(event: "progress", callback: ProgressEventCallback): void;
199-
public off(
200-
event: "log" | "progress",
201-
callback: LogEventCallback | ProgressEventCallback
202-
) {
199+
public off: EventListenerMethod = (event, callback) => {
203200
if (event === "log") {
204201
this.#logEventCallbacks = this.#logEventCallbacks.filter(
205202
(f) => f !== callback
@@ -209,12 +206,15 @@ export class FFmpeg {
209206
(f) => f !== callback
210207
);
211208
}
212-
}
209+
};
213210

214211
/**
215212
* Loads ffmpeg-core inside web worker. It is required to call this method first
216213
* as it initializes WebAssembly and other essential variables.
217214
*
215+
* Calling it again on a loaded instance keeps the loaded core and resolves
216+
* `false`. Call `terminate()` first to load a different core.
217+
*
218218
* @category FFmpeg
219219
* @returns `true` if ffmpeg core is loaded for the first time.
220220
*/
@@ -353,15 +353,19 @@ export class FFmpeg {
353353
* await ffmpeg.writeFile("text.txt", "hello world");
354354
* ```
355355
*
356+
* @remarks
357+
* A `Uint8Array` is transferred to the worker without a copy, which leaves
358+
* it empty in the caller. Pass `{ transfer: false }` to copy it instead.
359+
*
356360
* @category File System
357361
*/
358362
public writeFile = (
359363
path: string,
360364
data: FileData,
361-
{ signal }: FFMessageOptions = {}
365+
{ signal, transfer = true }: FFMessageOptions & { transfer?: boolean } = {}
362366
): Promise<OK> => {
363367
const trans: Transferable[] = [];
364-
if (data instanceof Uint8Array) {
368+
if (transfer && data instanceof Uint8Array) {
365369
trans.push(data.buffer);
366370
}
367371
return this.#send(

‎packages/ffmpeg/src/errors.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,12 @@ export const ERROR_IMPORT_FAILURE = new Error(
99
export const ERROR_WORKER = new Error(
1010
"worker encountered an error, this is most likely caused by the worker script failing to load (CORP, network error, 404, etc.)"
1111
);
12+
13+
// The core does not say which URL it fetched. Matches the bad-header error
14+
// of V8 ("magic word"), SpiderMonkey ("magic number") and JSC ("\0asm").
15+
export const wasmLoadError = (wasmURL: string, e: unknown): unknown =>
16+
/magic (word|number)|\\0asm/i.test(String(e))
17+
? new Error(`${wasmURL} is not a WebAssembly file (${String(e)})`, {
18+
cause: e,
19+
})
20+
: e;

‎packages/ffmpeg/src/worker-node-entry.mts‎

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
// on why that chunk is fragile to touch), so the message dispatch below
55
// is kept as its own copy instead of importing from worker.ts.
66
import { parentPort } from "node:worker_threads";
7+
import { pathToFileURL } from "node:url";
78
import type { FFmpegCoreModule, FFmpegCoreModuleFactory } from "@project516/ffmpeg-wasm-types";
89
import type {
910
FFMessage,
@@ -26,7 +27,11 @@ import type {
2627
FileData,
2728
} from "./types.js";
2829
import { FFMessageType } from "./const.js";
29-
import { ERROR_UNKNOWN_MESSAGE_TYPE, ERROR_NOT_LOADED } from "./errors.js";
30+
import {
31+
ERROR_UNKNOWN_MESSAGE_TYPE,
32+
ERROR_NOT_LOADED,
33+
wasmLoadError,
34+
} from "./errors.js";
3035

3136
if (!parentPort) {
3237
throw new Error(
@@ -35,8 +40,8 @@ if (!parentPort) {
3540
}
3641

3742
let ffmpeg: FFmpegCoreModule;
38-
// True once a load() has succeeded; see load().
39-
let loaded = false;
43+
// Set while a load() is in flight or done; see load().
44+
let loading: Promise<void> | null = null;
4045

4146
// `@project516/ffmpeg-wasm-core` resolves relative to the consuming
4247
// project's own node_modules, not this package, so a bare specifier is
@@ -59,12 +64,21 @@ const defaultCoreURL = (): string => {
5964
}
6065
};
6166

67+
// Two or more scheme characters, so a Windows drive path like C:\x is a path
68+
// and not a "c:" URL.
69+
const toURL = (location: string): string =>
70+
/^[a-z][a-z0-9+.-]+:/i.test(location)
71+
? location
72+
: pathToFileURL(location).href;
73+
6274
const doLoad = async ({
6375
coreURL: _coreURL,
6476
wasmURL: _wasmURL,
6577
}: FFMessageLoadConfig): Promise<void> => {
66-
const coreURL = _coreURL || defaultCoreURL();
67-
const wasmURL = _wasmURL ? _wasmURL : coreURL.replace(/\.js$/, ".wasm");
78+
const coreURL = _coreURL ? toURL(_coreURL) : defaultCoreURL();
79+
const wasmURL = _wasmURL
80+
? toURL(_wasmURL)
81+
: coreURL.replace(/\.js$/, ".wasm");
6882

6983
if (coreURL.startsWith("blob:")) {
7084
// Node's ESM loader cannot import() a blob: URL (unlike fetch(), which
@@ -90,11 +104,15 @@ const doLoad = async ({
90104
throw new Error(`failed to import ffmpeg-core from ${coreURL}`);
91105
}
92106

93-
ffmpeg = await createFFmpegCore({
94-
// Fix `Overload resolution failed.` when using multi-threaded ffmpeg-core.
95-
// Encoded wasmURL in the URL as a hack to fix locateFile issue.
96-
mainScriptUrlOrBlob: `${coreURL}#${btoa(JSON.stringify({ wasmURL }))}`,
97-
});
107+
try {
108+
ffmpeg = await createFFmpegCore({
109+
// Fix `Overload resolution failed.` when using multi-threaded ffmpeg-core.
110+
// Encoded wasmURL in the URL as a hack to fix locateFile issue.
111+
mainScriptUrlOrBlob: `${coreURL}#${btoa(JSON.stringify({ wasmURL }))}`,
112+
});
113+
} catch (e) {
114+
throw wasmLoadError(wasmURL, e);
115+
}
98116
ffmpeg.setLogger((data) =>
99117
parentPort!.postMessage({ type: FFMessageType.LOG, data })
100118
);
@@ -103,13 +121,22 @@ const doLoad = async ({
103121
);
104122
};
105123

106-
// first is decided after doLoad() succeeds, so a failed load never claims
107-
// it and exactly one of several concurrent successful loads reports true.
124+
// Only the call that starts the load reports true. Later calls wait for it
125+
// and report false, so a second load() never creates a second core. A failed
126+
// load clears the slot so the next call can retry.
108127
const load = async (config: FFMessageLoadConfig): Promise<IsFirst> => {
109-
await doLoad(config);
110-
const first = !loaded;
111-
loaded = true;
112-
return first;
128+
if (loading) {
129+
await loading;
130+
return false;
131+
}
132+
loading = doLoad(config);
133+
try {
134+
await loading;
135+
} catch (e) {
136+
loading = null;
137+
throw e;
138+
}
139+
return true;
113140
};
114141

115142
const exec = ({ args, timeout = -1 }: FFMessageExecData): ExitCode => {
@@ -169,8 +196,8 @@ const deleteDir = ({ path }: FFMessageDeleteDirData): OK => {
169196

170197
const mount = ({ fsType, options, mountPoint }: FFMessageMountData): OK => {
171198
const str = fsType as keyof typeof ffmpeg.FS.filesystems;
199+
if (!Object.hasOwn(ffmpeg.FS.filesystems, str)) return false;
172200
const fs = ffmpeg.FS.filesystems[str];
173-
if (!fs) return false;
174201
ffmpeg.FS.mount(fs, options, mountPoint);
175202
return true;
176203
};

‎packages/ffmpeg/src/worker.ts‎

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
ERROR_UNKNOWN_MESSAGE_TYPE,
2929
ERROR_NOT_LOADED,
3030
ERROR_IMPORT_FAILURE,
31+
wasmLoadError,
3132
} from "./errors.js";
3233

3334
// Set by importScripts() of the UMD core, or assigned from the ESM core's
@@ -41,8 +42,8 @@ interface ImportedFFmpegCoreModuleFactory {
4142
}
4243

4344
let ffmpeg: FFmpegCoreModule;
44-
// True once a load() has succeeded; see load().
45-
let loaded = false;
45+
// Set while a load() is in flight or done; see load().
46+
let loading: Promise<void> | null = null;
4647

4748
const doLoad = async ({
4849
coreURL: _coreURL,
@@ -63,7 +64,7 @@ const doLoad = async ({
6364
// when web worker type is `module`.
6465
self.createFFmpegCore = (
6566
(await import(
66-
/* @vite-ignore */ _coreURL
67+
/* @vite-ignore */ /* turbopackIgnore: true */ _coreURL
6768
)) as ImportedFFmpegCoreModuleFactory
6869
).default;
6970

@@ -88,11 +89,15 @@ const doLoad = async ({
8889

8990
const createFFmpegCore = self.createFFmpegCore;
9091
if (!createFFmpegCore) throw ERROR_IMPORT_FAILURE;
91-
ffmpeg = await createFFmpegCore({
92-
// Fix `Overload resolution failed.` when using multi-threaded ffmpeg-core.
93-
// Encoded wasmURL in the URL as a hack to fix locateFile issue.
94-
mainScriptUrlOrBlob: `${coreURL}#${btoa(JSON.stringify({ wasmURL }))}`,
95-
});
92+
try {
93+
ffmpeg = await createFFmpegCore({
94+
// Fix `Overload resolution failed.` when using multi-threaded ffmpeg-core.
95+
// Encoded wasmURL in the URL as a hack to fix locateFile issue.
96+
mainScriptUrlOrBlob: `${coreURL}#${btoa(JSON.stringify({ wasmURL }))}`,
97+
});
98+
} catch (e) {
99+
throw wasmLoadError(wasmURL, e);
100+
}
96101
ffmpeg.setLogger((data) =>
97102
self.postMessage({ type: FFMessageType.LOG, data })
98103
);
@@ -104,13 +109,22 @@ const doLoad = async ({
104109
);
105110
};
106111

107-
// first is decided after doLoad() succeeds, so a failed load never claims
108-
// it and exactly one of several concurrent successful loads reports true.
112+
// Only the call that starts the load reports true. Later calls wait for it
113+
// and report false, so a second load() never creates a second core. A failed
114+
// load clears the slot so the next call can retry.
109115
const load = async (config: FFMessageLoadConfig): Promise<IsFirst> => {
110-
await doLoad(config);
111-
const first = !loaded;
112-
loaded = true;
113-
return first;
116+
if (loading) {
117+
await loading;
118+
return false;
119+
}
120+
loading = doLoad(config);
121+
try {
122+
await loading;
123+
} catch (e) {
124+
loading = null;
125+
throw e;
126+
}
127+
return true;
114128
};
115129

116130
const exec = ({ args, timeout = -1 }: FFMessageExecData): ExitCode => {
@@ -173,8 +187,8 @@ const deleteDir = ({ path }: FFMessageDeleteDirData): OK => {
173187

174188
const mount = ({ fsType, options, mountPoint }: FFMessageMountData): OK => {
175189
const str = fsType as keyof typeof ffmpeg.FS.filesystems;
190+
if (!Object.hasOwn(ffmpeg.FS.filesystems, str)) return false;
176191
const fs = ffmpeg.FS.filesystems[str];
177-
if (!fs) return false;
178192
ffmpeg.FS.mount(fs, options, mountPoint);
179193
return true;
180194
};

‎packages/util/src/index.ts‎

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -53,26 +53,8 @@ const readLocalFile = async (path: string | URL): Promise<Uint8Array> => {
5353
const isRemoteURL = (file: string): boolean =>
5454
/^(https?|data|blob):/i.test(file);
5555

56-
const readFromBlobOrFile = (blob: Blob | File): Promise<Uint8Array> =>
57-
new Promise((resolve, reject) => {
58-
const fileReader = new FileReader();
59-
fileReader.onload = () => {
60-
const { result } = fileReader;
61-
if (result instanceof ArrayBuffer) {
62-
resolve(new Uint8Array(result));
63-
} else {
64-
resolve(new Uint8Array(0));
65-
}
66-
};
67-
fileReader.onerror = (event) => {
68-
reject(
69-
Error(
70-
`File could not be read! Code=${event?.target?.error?.code || -1}`
71-
)
72-
);
73-
};
74-
fileReader.readAsArrayBuffer(blob);
75-
});
56+
const readFromBlobOrFile = async (blob: Blob | File): Promise<Uint8Array> =>
57+
new Uint8Array(await blob.arrayBuffer());
7658

7759
/**
7860
* An util function to fetch data from url string, base64, URL, File or Blob format.

0 commit comments

Comments
 (0)