Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,31 @@
All notable changes to this project are documented in this file. The format
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## Unreleased

### Added

- `writeFile(path, data, { transfer: false })` copies a `Uint8Array` to the
worker instead of transferring it, so the caller's array is not emptied.
The default is unchanged.
- Under Node.js, `coreURL` and `wasmURL` accept filesystem paths, including
Windows paths, as well as `file:` URLs.

### Fixed

- A second `load()` on a loaded `FFmpeg` no longer creates a second core and
leaks the first. It keeps the loaded core and resolves `false`.
- `load()` names the `wasmURL` in its error when the response is not a
WebAssembly file.
- `mount()` returns `false` for names such as `__proto__` or `constructor`
instead of treating them as filesystems.
- `on()` and `off()` work when the `FFmpeg` instance is wrapped in a Proxy,
such as Vue's `reactive()`.
- `fetchFile()` reads a `Blob` or `File` in Node.js, where `FileReader` does
not exist.
- The dynamic core import carries `turbopackIgnore`, so Turbopack leaves it
to run at load time.

## 0.15.0

### Added
Expand Down
10 changes: 8 additions & 2 deletions apps/website/docs/getting-started/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,12 @@ const url = URL.createObjectURL(new Blob([frames[0]], { type: 'image/png' }));
A helper rejects with ffmpeg's last log lines when the command fails.
Options beyond the ones shown go in `args` for `transcode()`, and every
helper accepts `timeout` and `signal` like `exec()`. A `Uint8Array` input is
copied, because `writeFile()` hands its buffer to the worker.
copied, because `writeFile()` hands its buffer to the worker. To keep your own
`Uint8Array` intact, call `writeFile(path, data, { transfer: false })`, which
copies it instead.

Calling `load()` on an already loaded `FFmpeg` keeps the loaded core and
resolves `false`. Call `terminate()` first to load a different core.

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

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

### Multithread core

Expand Down
36 changes: 20 additions & 16 deletions packages/ffmpeg/src/classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ type FFMessageOptions = {
signal?: AbortSignal;
};

type EventListenerMethod = {
(event: "log", callback: LogEventCallback): void;
(event: "progress", callback: ProgressEventCallback): void;
};

/**
* Provides APIs to interact with ffmpeg web worker.
*
Expand Down Expand Up @@ -176,30 +181,22 @@ export class FFmpeg {
*
* @category FFmpeg
*/
public on(event: "log", callback: LogEventCallback): void;
public on(event: "progress", callback: ProgressEventCallback): void;
public on(
event: "log" | "progress",
callback: LogEventCallback | ProgressEventCallback
) {
// Arrow fields reach the #private fields when called through a Proxy such
// as Vue's reactive(); prototype methods would throw a TypeError.
public on: EventListenerMethod = (event, callback) => {
if (event === "log") {
this.#logEventCallbacks.push(callback as LogEventCallback);
} else if (event === "progress") {
this.#progressEventCallbacks.push(callback as ProgressEventCallback);
}
}
};

/**
* Unlisten to log or progress events from `ffmpeg.exec()`.
*
* @category FFmpeg
*/
public off(event: "log", callback: LogEventCallback): void;
public off(event: "progress", callback: ProgressEventCallback): void;
public off(
event: "log" | "progress",
callback: LogEventCallback | ProgressEventCallback
) {
public off: EventListenerMethod = (event, callback) => {
if (event === "log") {
this.#logEventCallbacks = this.#logEventCallbacks.filter(
(f) => f !== callback
Expand All @@ -209,12 +206,15 @@ export class FFmpeg {
(f) => f !== callback
);
}
}
};

/**
* Loads ffmpeg-core inside web worker. It is required to call this method first
* as it initializes WebAssembly and other essential variables.
*
* Calling it again on a loaded instance keeps the loaded core and resolves
* `false`. Call `terminate()` first to load a different core.
*
* @category FFmpeg
* @returns `true` if ffmpeg core is loaded for the first time.
*/
Expand Down Expand Up @@ -353,15 +353,19 @@ export class FFmpeg {
* await ffmpeg.writeFile("text.txt", "hello world");
* ```
*
* @remarks
* A `Uint8Array` is transferred to the worker without a copy, which leaves
* it empty in the caller. Pass `{ transfer: false }` to copy it instead.
*
* @category File System
*/
public writeFile = (
path: string,
data: FileData,
{ signal }: FFMessageOptions = {}
{ signal, transfer = true }: FFMessageOptions & { transfer?: boolean } = {}
): Promise<OK> => {
const trans: Transferable[] = [];
if (data instanceof Uint8Array) {
if (transfer && data instanceof Uint8Array) {
trans.push(data.buffer);
}
return this.#send(
Expand Down
9 changes: 9 additions & 0 deletions packages/ffmpeg/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,12 @@ export const ERROR_IMPORT_FAILURE = new Error(
export const ERROR_WORKER = new Error(
"worker encountered an error, this is most likely caused by the worker script failing to load (CORP, network error, 404, etc.)"
);

// The core does not say which URL it fetched. Matches the bad-header error
// of V8 ("magic word"), SpiderMonkey ("magic number") and JSC ("\0asm").
export const wasmLoadError = (wasmURL: string, e: unknown): unknown =>
/magic (word|number)|\\0asm/i.test(String(e))
? new Error(`${wasmURL} is not a WebAssembly file (${String(e)})`, {
cause: e,
})
: e;
61 changes: 44 additions & 17 deletions packages/ffmpeg/src/worker-node-entry.mts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// on why that chunk is fragile to touch), so the message dispatch below
// is kept as its own copy instead of importing from worker.ts.
import { parentPort } from "node:worker_threads";
import { pathToFileURL } from "node:url";
import type { FFmpegCoreModule, FFmpegCoreModuleFactory } from "@project516/ffmpeg-wasm-types";
import type {
FFMessage,
Expand All @@ -26,7 +27,11 @@ import type {
FileData,
} from "./types.js";
import { FFMessageType } from "./const.js";
import { ERROR_UNKNOWN_MESSAGE_TYPE, ERROR_NOT_LOADED } from "./errors.js";
import {
ERROR_UNKNOWN_MESSAGE_TYPE,
ERROR_NOT_LOADED,
wasmLoadError,
} from "./errors.js";

if (!parentPort) {
throw new Error(
Expand All @@ -35,8 +40,8 @@ if (!parentPort) {
}

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

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

// Two or more scheme characters, so a Windows drive path like C:\x is a path
// and not a "c:" URL.
const toURL = (location: string): string =>
/^[a-z][a-z0-9+.-]+:/i.test(location)
? location
: pathToFileURL(location).href;

const doLoad = async ({
coreURL: _coreURL,
wasmURL: _wasmURL,
}: FFMessageLoadConfig): Promise<void> => {
const coreURL = _coreURL || defaultCoreURL();
const wasmURL = _wasmURL ? _wasmURL : coreURL.replace(/\.js$/, ".wasm");
const coreURL = _coreURL ? toURL(_coreURL) : defaultCoreURL();
const wasmURL = _wasmURL
? toURL(_wasmURL)
: coreURL.replace(/\.js$/, ".wasm");

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

ffmpeg = await createFFmpegCore({
// Fix `Overload resolution failed.` when using multi-threaded ffmpeg-core.
// Encoded wasmURL in the URL as a hack to fix locateFile issue.
mainScriptUrlOrBlob: `${coreURL}#${btoa(JSON.stringify({ wasmURL }))}`,
});
try {
ffmpeg = await createFFmpegCore({
// Fix `Overload resolution failed.` when using multi-threaded ffmpeg-core.
// Encoded wasmURL in the URL as a hack to fix locateFile issue.
mainScriptUrlOrBlob: `${coreURL}#${btoa(JSON.stringify({ wasmURL }))}`,
});
} catch (e) {
throw wasmLoadError(wasmURL, e);
}
ffmpeg.setLogger((data) =>
parentPort!.postMessage({ type: FFMessageType.LOG, data })
);
Expand All @@ -103,13 +121,22 @@ const doLoad = async ({
);
};

// first is decided after doLoad() succeeds, so a failed load never claims
// it and exactly one of several concurrent successful loads reports true.
// Only the call that starts the load reports true. Later calls wait for it
// and report false, so a second load() never creates a second core. A failed
// load clears the slot so the next call can retry.
const load = async (config: FFMessageLoadConfig): Promise<IsFirst> => {
await doLoad(config);
const first = !loaded;
loaded = true;
return first;
if (loading) {
await loading;
return false;
}
loading = doLoad(config);
try {
await loading;
} catch (e) {
loading = null;
throw e;
}
return true;
};

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

const mount = ({ fsType, options, mountPoint }: FFMessageMountData): OK => {
const str = fsType as keyof typeof ffmpeg.FS.filesystems;
if (!Object.hasOwn(ffmpeg.FS.filesystems, str)) return false;
const fs = ffmpeg.FS.filesystems[str];
if (!fs) return false;
ffmpeg.FS.mount(fs, options, mountPoint);
return true;
};
Expand Down
44 changes: 29 additions & 15 deletions packages/ffmpeg/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
ERROR_UNKNOWN_MESSAGE_TYPE,
ERROR_NOT_LOADED,
ERROR_IMPORT_FAILURE,
wasmLoadError,
} from "./errors.js";

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

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

const doLoad = async ({
coreURL: _coreURL,
Expand All @@ -63,7 +64,7 @@ const doLoad = async ({
// when web worker type is `module`.
self.createFFmpegCore = (
(await import(
/* @vite-ignore */ _coreURL
/* @vite-ignore */ /* turbopackIgnore: true */ _coreURL
)) as ImportedFFmpegCoreModuleFactory
).default;

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

const createFFmpegCore = self.createFFmpegCore;
if (!createFFmpegCore) throw ERROR_IMPORT_FAILURE;
ffmpeg = await createFFmpegCore({
// Fix `Overload resolution failed.` when using multi-threaded ffmpeg-core.
// Encoded wasmURL in the URL as a hack to fix locateFile issue.
mainScriptUrlOrBlob: `${coreURL}#${btoa(JSON.stringify({ wasmURL }))}`,
});
try {
ffmpeg = await createFFmpegCore({
// Fix `Overload resolution failed.` when using multi-threaded ffmpeg-core.
// Encoded wasmURL in the URL as a hack to fix locateFile issue.
mainScriptUrlOrBlob: `${coreURL}#${btoa(JSON.stringify({ wasmURL }))}`,
});
} catch (e) {
throw wasmLoadError(wasmURL, e);
}
ffmpeg.setLogger((data) =>
self.postMessage({ type: FFMessageType.LOG, data })
);
Expand All @@ -104,13 +109,22 @@ const doLoad = async ({
);
};

// first is decided after doLoad() succeeds, so a failed load never claims
// it and exactly one of several concurrent successful loads reports true.
// Only the call that starts the load reports true. Later calls wait for it
// and report false, so a second load() never creates a second core. A failed
// load clears the slot so the next call can retry.
const load = async (config: FFMessageLoadConfig): Promise<IsFirst> => {
await doLoad(config);
const first = !loaded;
loaded = true;
return first;
if (loading) {
await loading;
return false;
}
loading = doLoad(config);
try {
await loading;
} catch (e) {
loading = null;
throw e;
}
return true;
};

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

const mount = ({ fsType, options, mountPoint }: FFMessageMountData): OK => {
const str = fsType as keyof typeof ffmpeg.FS.filesystems;
if (!Object.hasOwn(ffmpeg.FS.filesystems, str)) return false;
const fs = ffmpeg.FS.filesystems[str];
if (!fs) return false;
ffmpeg.FS.mount(fs, options, mountPoint);
return true;
};
Expand Down
22 changes: 2 additions & 20 deletions packages/util/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,26 +53,8 @@ const readLocalFile = async (path: string | URL): Promise<Uint8Array> => {
const isRemoteURL = (file: string): boolean =>
/^(https?|data|blob):/i.test(file);

const readFromBlobOrFile = (blob: Blob | File): Promise<Uint8Array> =>
new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = () => {
const { result } = fileReader;
if (result instanceof ArrayBuffer) {
resolve(new Uint8Array(result));
} else {
resolve(new Uint8Array(0));
}
};
fileReader.onerror = (event) => {
reject(
Error(
`File could not be read! Code=${event?.target?.error?.code || -1}`
)
);
};
fileReader.readAsArrayBuffer(blob);
});
const readFromBlobOrFile = async (blob: Blob | File): Promise<Uint8Array> =>
new Uint8Array(await blob.arrayBuffer());

/**
* An util function to fetch data from url string, base64, URL, File or Blob format.
Expand Down
Loading
Loading