import { FFMessageType } from "./const.js"; import { getMessageID } from "./utils.js"; import { ERROR_TERMINATED, ERROR_NOT_LOADED } from "./errors.js"; /** * Provides APIs to interact with ffmpeg web worker. * * @example * ```ts * const ffmpeg = new FFmpeg(); * ``` */ export class FFmpeg { #worker = null; /** * #resolves and #rejects tracks Promise resolves and rejects to * be called when we receive message from web worker. */ #resolves = {}; #rejects = {}; #logEventCallbacks = []; #progressEventCallbacks = []; loaded = false; /** * register worker message event handlers. */ #registerHandlers = () => { if (this.#worker) { this.#worker.onmessage = ({ data: { id, type, data }, }) => { switch (type) { case FFMessageType.LOAD: this.loaded = true; this.#resolves[id](data); break; case FFMessageType.MOUNT: case FFMessageType.UNMOUNT: case FFMessageType.EXEC: case FFMessageType.WRITE_FILE: case FFMessageType.READ_FILE: case FFMessageType.DELETE_FILE: case FFMessageType.RENAME: case FFMessageType.CREATE_DIR: case FFMessageType.LIST_DIR: case FFMessageType.DELETE_DIR: this.#resolves[id](data); break; case FFMessageType.LOG: this.#logEventCallbacks.forEach((f) => f(data)); break; case FFMessageType.PROGRESS: this.#progressEventCallbacks.forEach((f) => f(data)); break; case FFMessageType.ERROR: this.#rejects[id](data); break; } delete this.#resolves[id]; delete this.#rejects[id]; }; } }; /** * Generic function to send messages to web worker. */ #send = ({ type, data }, trans = [], signal) => { if (!this.#worker) { return Promise.reject(ERROR_NOT_LOADED); } return new Promise((resolve, reject) => { const id = getMessageID(); this.#worker && this.#worker.postMessage({ id, type, data }, trans); this.#resolves[id] = resolve; this.#rejects[id] = reject; signal?.addEventListener("abort", () => { reject(new DOMException(`Message # ${id} was aborted`, "AbortError")); }, { once: true }); }); }; on(event, callback) { if (event === "log") { this.#logEventCallbacks.push(callback); } else if (event === "progress") { this.#progressEventCallbacks.push(callback); } } off(event, callback) { if (event === "log") { this.#logEventCallbacks = this.#logEventCallbacks.filter((f) => f !== callback); } else if (event === "progress") { this.#progressEventCallbacks = this.#progressEventCallbacks.filter((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. * * @category FFmpeg * @returns `true` if ffmpeg core is loaded for the first time. */ load = (config = {}, { signal } = {}) => { if (!this.#worker) { this.#worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module", }); this.#registerHandlers(); } return this.#send({ type: FFMessageType.LOAD, data: config, }, undefined, signal); }; /** * Execute ffmpeg command. * * @remarks * To avoid common I/O issues, ["-nostdin", "-y"] are prepended to the args * by default. * * @example * ```ts * const ffmpeg = new FFmpeg(); * await ffmpeg.load(); * await ffmpeg.writeFile("video.avi", ...); * // ffmpeg -i video.avi video.mp4 * await ffmpeg.exec(["-i", "video.avi", "video.mp4"]); * const data = ffmpeg.readFile("video.mp4"); * ``` * * @returns `0` if no error, `!= 0` if timeout (1) or error. * @category FFmpeg */ exec = ( /** ffmpeg command line args */ args, /** * milliseconds to wait before stopping the command execution. * * @defaultValue -1 */ timeout = -1, { signal } = {}) => this.#send({ type: FFMessageType.EXEC, data: { args, timeout }, }, undefined, signal); /** * Terminate all ongoing API calls and terminate web worker. * `FFmpeg.load()` must be called again before calling any other APIs. * * @category FFmpeg */ terminate = () => { const ids = Object.keys(this.#rejects); // rejects all incomplete Promises. for (const id of ids) { this.#rejects[id](ERROR_TERMINATED); delete this.#rejects[id]; delete this.#resolves[id]; } if (this.#worker) { this.#worker.terminate(); this.#worker = null; this.loaded = false; } }; /** * Write data to ffmpeg.wasm. * * @example * ```ts * const ffmpeg = new FFmpeg(); * await ffmpeg.load(); * await ffmpeg.writeFile("video.avi", await fetchFile("../video.avi")); * await ffmpeg.writeFile("text.txt", "hello world"); * ``` * * @category File System */ writeFile = (path, data, { signal } = {}) => { const trans = []; if (data instanceof Uint8Array) { trans.push(data.buffer); } return this.#send({ type: FFMessageType.WRITE_FILE, data: { path, data }, }, trans, signal); }; mount = (fsType, options, mountPoint) => { const trans = []; return this.#send({ type: FFMessageType.MOUNT, data: { fsType, options, mountPoint }, }, trans); }; unmount = (mountPoint) => { const trans = []; return this.#send({ type: FFMessageType.UNMOUNT, data: { mountPoint }, }, trans); }; /** * Read data from ffmpeg.wasm. * * @example * ```ts * const ffmpeg = new FFmpeg(); * await ffmpeg.load(); * const data = await ffmpeg.readFile("video.mp4"); * ``` * * @category File System */ readFile = (path, /** * File content encoding, supports two encodings: * - utf8: read file as text file, return data in string type. * - binary: read file as binary file, return data in Uint8Array type. * * @defaultValue binary */ encoding = "binary", { signal } = {}) => this.#send({ type: FFMessageType.READ_FILE, data: { path, encoding }, }, undefined, signal); /** * Delete a file. * * @category File System */ deleteFile = (path, { signal } = {}) => this.#send({ type: FFMessageType.DELETE_FILE, data: { path }, }, undefined, signal); /** * Rename a file or directory. * * @category File System */ rename = (oldPath, newPath, { signal } = {}) => this.#send({ type: FFMessageType.RENAME, data: { oldPath, newPath }, }, undefined, signal); /** * Create a directory. * * @category File System */ createDir = (path, { signal } = {}) => this.#send({ type: FFMessageType.CREATE_DIR, data: { path }, }, undefined, signal); /** * List directory contents. * * @category File System */ listDir = (path, { signal } = {}) => this.#send({ type: FFMessageType.LIST_DIR, data: { path }, }, undefined, signal); /** * Delete an empty directory. * * @category File System */ deleteDir = (path, { signal } = {}) => this.#send({ type: FFMessageType.DELETE_DIR, data: { path }, }, undefined, signal); }