diff --git a/CHANGELOG.md b/CHANGELOG.md index 598e276b654..ddc9b96a86c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/website/docs/getting-started/usage.md b/apps/website/docs/getting-started/usage.md index 519a44bc5f3..f5d380c7c25 100644 --- a/apps/website/docs/getting-started/usage.md +++ b/apps/website/docs/getting-started/usage.md @@ -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. @@ -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 diff --git a/packages/ffmpeg/src/classes.ts b/packages/ffmpeg/src/classes.ts index dc472fcb934..b8308aa098c 100644 --- a/packages/ffmpeg/src/classes.ts +++ b/packages/ffmpeg/src/classes.ts @@ -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. * @@ -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 @@ -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. */ @@ -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 => { const trans: Transferable[] = []; - if (data instanceof Uint8Array) { + if (transfer && data instanceof Uint8Array) { trans.push(data.buffer); } return this.#send( diff --git a/packages/ffmpeg/src/errors.ts b/packages/ffmpeg/src/errors.ts index 6901e107a4d..0fd5cb5f018 100644 --- a/packages/ffmpeg/src/errors.ts +++ b/packages/ffmpeg/src/errors.ts @@ -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; diff --git a/packages/ffmpeg/src/worker-node-entry.mts b/packages/ffmpeg/src/worker-node-entry.mts index aa319267cb0..c93bec90c6c 100644 --- a/packages/ffmpeg/src/worker-node-entry.mts +++ b/packages/ffmpeg/src/worker-node-entry.mts @@ -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, @@ -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( @@ -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 | null = null; // `@project516/ffmpeg-wasm-core` resolves relative to the consuming // project's own node_modules, not this package, so a bare specifier is @@ -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 => { - 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 @@ -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 }) ); @@ -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 => { - 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 => { @@ -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; }; diff --git a/packages/ffmpeg/src/worker.ts b/packages/ffmpeg/src/worker.ts index 26c357f60e6..928ad2896dd 100644 --- a/packages/ffmpeg/src/worker.ts +++ b/packages/ffmpeg/src/worker.ts @@ -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 @@ -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 | null = null; const doLoad = async ({ coreURL: _coreURL, @@ -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; @@ -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 }) ); @@ -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 => { - 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 => { @@ -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; }; diff --git a/packages/util/src/index.ts b/packages/util/src/index.ts index 0e2c97ac4e6..05f6084bfa8 100644 --- a/packages/util/src/index.ts +++ b/packages/util/src/index.ts @@ -53,26 +53,8 @@ const readLocalFile = async (path: string | URL): Promise => { const isRemoteURL = (file: string): boolean => /^(https?|data|blob):/i.test(file); -const readFromBlobOrFile = (blob: Blob | File): Promise => - 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 => + new Uint8Array(await blob.arrayBuffer()); /** * An util function to fetch data from url string, base64, URL, File or Blob format. diff --git a/tests/ffmpeg-node.test.mjs b/tests/ffmpeg-node.test.mjs index 676fa1a5449..3b9ce8926b6 100644 --- a/tests/ffmpeg-node.test.mjs +++ b/tests/ffmpeg-node.test.mjs @@ -13,7 +13,7 @@ import { createRequire } from "node:module"; import { mkdtemp, writeFile as writeFileFs, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { pathToFileURL } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { expect } from "chai"; import { FFmpeg } from "@project516/ffmpeg-wasm"; import { fetchFile } from "@project516/ffmpeg-wasm-util"; @@ -34,6 +34,13 @@ const coreURL = .href : undefined; +const corePath = fileURLToPath( + new URL( + `../packages/core${FFMPEG_TYPE === "mt" ? "-mt" : ""}/dist/esm/ffmpeg-core.js`, + import.meta.url + ) +); + const load = (ffmpeg) => ffmpeg.load(coreURL ? { coreURL } : {}); // fetchFile()'s local-path and file: URL branches only run under Node.js, @@ -67,6 +74,25 @@ describe(genName("fetchFile() local files"), function () { }); }); +describe(genName("fetchFile() Blob"), function () { + it("reads a Blob without FileReader", async () => { + expect(globalThis.FileReader).to.be.undefined; + const data = await fetchFile(new Blob([new Uint8Array([1, 2, 3])])); + expect(Array.from(data)).to.deep.equal([1, 2, 3]); + }); +}); + +describe(genName("FFmpeg through a Proxy"), function () { + it("on() and off() work like Vue's reactive()", () => { + const ffmpeg = new Proxy(new FFmpeg(), {}); + const cb = () => {}; + ffmpeg.on("log", cb); + ffmpeg.off("log", cb); + ffmpeg.on("progress", cb); + ffmpeg.off("progress", cb); + }); +}); + describe(genName("global Worker leakage"), function () { this.timeout(60000); @@ -98,6 +124,18 @@ describe(genName("concurrent load()"), function () { } }); + it("keeps the loaded core when load() is called again", async () => { + const ffmpeg = new FFmpeg(); + try { + expect(await load(ffmpeg)).to.be.true; + await ffmpeg.writeFile("/kept.txt", "hello"); + expect(await load(ffmpeg)).to.be.false; + expect(await ffmpeg.readFile("/kept.txt", "utf8")).to.equal("hello"); + } finally { + ffmpeg.terminate(); + } + }); + it("still resolves first=true on the first successful load() after an earlier one failed", async () => { const ffmpeg = new FFmpeg(); try { @@ -117,6 +155,38 @@ describe(genName("concurrent load()"), function () { }); }); +describe(genName("load() inputs"), function () { + this.timeout(60000); + + it("accepts filesystem paths for coreURL and wasmURL", async () => { + const ffmpeg = new FFmpeg(); + try { + await ffmpeg.load({ + coreURL: corePath, + wasmURL: corePath.replace(/\.js$/, ".wasm"), + }); + expect(ffmpeg.loaded).to.be.true; + } finally { + ffmpeg.terminate(); + } + }); + + it("names the wasmURL when it is not a WebAssembly file", async () => { + const ffmpeg = new FFmpeg(); + try { + let message = ""; + try { + await ffmpeg.load({ coreURL: corePath, wasmURL: corePath }); + } catch (e) { + message = String(e); + } + expect(message).to.include("not a WebAssembly file"); + } finally { + ffmpeg.terminate(); + } + }); +}); + describe(genName("FFmpeg"), function () { this.timeout(60000); @@ -186,6 +256,24 @@ describe(genName("FFmpeg"), function () { await ffmpeg.deleteDir("/work"); }); + it("does not mount a name that only exists on the prototype", async () => { + expect(await ffmpeg.mount("__proto__", {}, "/proto")).to.be.false; + expect(await ffmpeg.mount("constructor", {}, "/proto")).to.be.false; + }); + + it("writeFile() transfers the buffer unless transfer is false", async () => { + const kept = new Uint8Array([1, 2, 3]); + await ffmpeg.writeFile("/kept.bin", kept, { transfer: false }); + expect(Array.from(kept)).to.deep.equal([1, 2, 3]); + const moved = new Uint8Array([1, 2, 3]); + await ffmpeg.writeFile("/moved.bin", moved); + expect(moved.length).to.equal(0); + const read = await ffmpeg.readFile("/kept.bin"); + expect(Array.from(read)).to.deep.equal([1, 2, 3]); + await ffmpeg.deleteFile("/kept.bin"); + await ffmpeg.deleteFile("/moved.bin"); + }); + it("transcodes a small video and reports progress", async () => { let progress = 0; const onProgress = (event) => (progress = event.progress);