Add tests

This commit is contained in:
Jerome Wu
2022-10-04 12:17:04 +08:00
parent b496cf1f98
commit e17812a999
22 changed files with 1593 additions and 226 deletions

View File

@@ -4,15 +4,73 @@ import {
CallbackData,
Callbacks,
DownloadProgressEvent,
FFFSPaths,
FFMessageEventCallback,
FFMessageLoadConfig,
IsDone,
OK,
IsFirst,
LogEvent,
Message,
Progress,
FileData,
} from "./types";
import { getMessageID } from "./utils";
import { ERROR_TERMINATED, ERROR_NOT_LOADED } from "./errors";
export declare interface FFmpeg {
/**
* Listen to download progress events from `ffmpeg.load()`.
*
* @example
* ```ts
* ffmpeg.on(FFmpeg.DOWNLOAD, ({ url, total, received, delta, done }) => {
* // ...
* })
* ```
*
* @category Event
*/
on(
event: typeof FFmpeg.DOWNLOAD,
listener: (data: DownloadProgressEvent) => void
): this;
/**
* Listen to log events from `ffmpeg.exec()`.
*
* @example
* ```ts
* ffmpeg.on(FFmpeg.LOG, ({ message }) => {
* // ...
* })
* ```
*
* @remarks
* log includes output to stdout and stderr.
*
* @category Event
*/
on(event: typeof FFmpeg.LOG, listener: (log: LogEvent) => void): this;
/**
* Listen to progress events from `ffmpeg.exec()`.
*
* @example
* ```ts
* ffmpeg.on(FFmpeg.PROGRESS, ({ progress }) => {
* // ...
* })
* ```
*
* @remarks
* The progress events are accurate only when the length of
* input and output video/audio file are the same.
*
* @category Event
*/
on(
event: typeof FFmpeg.PROGRESS,
listener: (progress: Progress) => void
): this;
}
/**
* Provides APIs to interact with ffmpeg web worker.
@@ -27,80 +85,56 @@ export class FFmpeg extends EventEmitter {
/** @event */ static readonly LOG = "log" as const;
/** @event */ static readonly PROGRESS = "progress" as const;
#worker: Worker | null = null;
/**
* Listen to download progress events from `ffmpeg.load()`.
* #resolves and #rejects tracks Promise resolves and rejects to
* be called when we receive message from web worker.
*
* @category Event
*/
on(
event: typeof FFmpeg.DOWNLOAD,
listener: (data: DownloadProgressEvent) => void
): this;
/**
* Listen to log events from `ffmpeg.exec()`.
*
* @remarks
* log includes output to stdout and stderr.
*
* @category Event
*/
on(event: typeof FFmpeg.LOG, listener: (log: LogEvent) => void): this;
/**
* Listen to progress events from `ffmpeg.exec()`.
*
* @remarks
* The progress events are accurate only when the length of
* input and output video/audio file are the same.
*
* @category Event
*/
on(
event: typeof FFmpeg.PROGRESS,
listener: (progress: Progress) => void
): this;
on(event: string, listener: any): this {
return this;
}
#worker: Worker;
#resolves: Callbacks = {};
#rejects: Callbacks = {};
constructor() {
super();
this.#worker = new Worker(new URL("./worker.ts", import.meta.url));
this.#registerHandlers();
}
/** register worker message event handlers.
/**
* register worker message event handlers.
*/
#registerHandlers = () => {
this.#worker.onmessage = ({
data: { id, type, data },
}: FFMessageEventCallback) => {
switch (type) {
case FFMessageType.LOAD:
case FFMessageType.EXEC:
case FFMessageType.WRITE_FILE:
case FFMessageType.READ_FILE:
this.#resolves[id](data);
break;
case FFMessageType.DOWNLOAD:
this.emit(FFmpeg.DOWNLOAD, data as DownloadProgressEvent);
break;
case FFMessageType.LOG:
this.emit(FFmpeg.LOG, data as LogEvent);
break;
case FFMessageType.PROGRESS:
this.emit(FFmpeg.PROGRESS, data as Progress);
break;
case FFMessageType.ERROR:
this.#rejects[id](data);
break;
}
delete this.#resolves[id];
delete this.#rejects[id];
};
if (this.#worker) {
this.#worker.onmessage = ({
data: { id, type, data },
}: FFMessageEventCallback) => {
switch (type) {
case FFMessageType.LOAD:
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.DOWNLOAD:
this.emit(FFmpeg.DOWNLOAD, data as DownloadProgressEvent);
break;
case FFMessageType.LOG:
this.emit(FFmpeg.LOG, data as LogEvent);
break;
case FFMessageType.PROGRESS:
this.emit(FFmpeg.PROGRESS, data as Progress);
break;
case FFMessageType.ERROR:
this.#rejects[id](data);
break;
}
delete this.#resolves[id];
delete this.#rejects[id];
};
}
};
/**
@@ -109,13 +143,18 @@ export class FFmpeg extends EventEmitter {
#send = (
{ type, data }: Message,
trans: Transferable[] = []
): Promise<CallbackData> =>
new Promise((resolve, reject) => {
): Promise<CallbackData> => {
if (!this.#worker) {
return Promise.reject(ERROR_NOT_LOADED);
}
return new Promise((resolve, reject) => {
const id = getMessageID();
this.#worker.postMessage({ id, type, data }, trans);
this.#worker && this.#worker.postMessage({ id, type, data }, trans);
this.#resolves[id] = resolve;
this.#rejects[id] = reject;
});
};
/**
* Loads ffmpeg-core inside web worker. It is required to call this method first
@@ -124,11 +163,16 @@ export class FFmpeg extends EventEmitter {
* @category FFmpeg
* @returns `true` if ffmpeg core is loaded for the first time.
*/
public load = (config: FFMessageLoadConfig): Promise<IsFirst> =>
this.#send({
public load = (config: FFMessageLoadConfig = {}): Promise<IsFirst> => {
if (!this.#worker) {
this.#worker = new Worker(new URL("./worker.ts", import.meta.url));
this.#registerHandlers();
}
return this.#send({
type: FFMessageType.LOAD,
data: config,
}) as Promise<IsFirst>;
};
/**
* Execute ffmpeg command.
@@ -166,7 +210,28 @@ export class FFmpeg extends EventEmitter {
}) as Promise<number>;
/**
* Write data to ffmpeg.wasm in memory file system.
* Terminate all ongoing API calls and terminate web worker.
* `FFmpeg.load()` must be called again before calling any other APIs.
*
* @category FFmpeg
*/
public terminate = (): void => {
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;
}
};
/**
* Write data to ffmpeg.wasm.
*
* @example
* ```ts
@@ -178,25 +243,22 @@ export class FFmpeg extends EventEmitter {
*
* @category File System
*/
public writeFile = (
path: string,
bin: Uint8Array | string
): Promise<IsDone> => {
public writeFile = (path: string, data: FileData): Promise<OK> => {
const trans: Transferable[] = [];
if (bin instanceof Uint8Array) {
trans.push(bin.buffer);
if (data instanceof Uint8Array) {
trans.push(data.buffer);
}
return this.#send(
{
type: FFMessageType.WRITE_FILE,
data: { path, bin },
data: { path, data },
},
trans
) as Promise<IsDone>;
) as Promise<OK>;
};
/**
* Read data from ffmpeg.wasm in memory file system.
* Read data from ffmpeg.wasm.
*
* @example
* ```ts
@@ -207,9 +269,74 @@ export class FFmpeg extends EventEmitter {
*
* @category File System
*/
public readFile = (path: string): Promise<Uint8Array> =>
public readFile = (
path: string,
/**
* 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"
): Promise<FileData> =>
this.#send({
type: FFMessageType.READ_FILE,
data: { path, encoding },
}) as Promise<FileData>;
/**
* Delete a file.
*
* @category File System
*/
public deleteFile = (path: string): Promise<OK> =>
this.#send({
type: FFMessageType.DELETE_FILE,
data: { path },
}) as Promise<Uint8Array>;
}) as Promise<OK>;
/**
* Rename a file or directory.
*
* @category File System
*/
public rename = (oldPath: string, newPath: string): Promise<OK> =>
this.#send({
type: FFMessageType.RENAME,
data: { oldPath, newPath },
}) as Promise<OK>;
/**
* Create a directory.
*
* @category File System
*/
public createDir = (path: string): Promise<OK> =>
this.#send({
type: FFMessageType.CREATE_DIR,
data: { path },
}) as Promise<OK>;
/**
* List directory contents.
*
* @category File System
*/
public listDir = (path: string): Promise<FFFSPaths> =>
this.#send({
type: FFMessageType.LIST_DIR,
data: { path },
}) as Promise<FFFSPaths>;
/**
* Delete an empty directory.
*
* @category File System
*/
public deleteDir = (path: string): Promise<OK> =>
this.#send({
type: FFMessageType.DELETE_DIR,
data: { path },
}) as Promise<OK>;
}

View File

@@ -3,13 +3,18 @@ export const MIME_TYPE_JAVASCRIPT = "text/javascript";
export const MIME_TYPE_WASM = "application/wasm";
export const CORE_VERSION = "0.12.0";
export const CORE_URL = `https://unpkg.com/@ffmpeg/core@${CORE_VERSION}/dist/umd/ffmpeg-core.js`;
export const CORE_URL = `https://unpkg.com/@ffmpeg/core@${CORE_VERSION}/dist/ffmpeg-core.js`;
export enum FFMessageType {
LOAD = "load",
WRITE_FILE = "WRITE_FILE",
EXEC = "EXEC",
WRITE_FILE = "WRITE_FILE",
READ_FILE = "READ_FILE",
DELETE_FILE = "DELETE_FILE",
RENAME = "RENAME",
CREATE_DIR = "CREATE_DIR",
LIST_DIR = "LIST_DIR",
DELETE_DIR = "DELETE_DIR",
ERROR = "ERROR",
DOWNLOAD = "DOWNLOAD",

View File

@@ -11,3 +11,4 @@ export const ERROR_NOT_LOADED = new Error(
export const ERROR_INCOMPLETED_DOWNLOAD = new Error(
"failed to complete download"
);
export const ERROR_TERMINATED = new Error("called FFmpeg.terminate()");

View File

@@ -1,4 +1,5 @@
export type FFFSPath = string;
export type FFFSPaths = FFFSPath[];
/**
* ffmpeg-core loading configuration.
@@ -48,25 +49,56 @@ export interface FFMessageLoadConfig {
thread?: boolean;
}
export interface FFMessageWriteFileData {
path: FFFSPath;
bin: Uint8Array | string;
}
export interface FFMessageExecData {
args: string[];
timeout?: number;
}
export interface FFMessageWriteFileData {
path: FFFSPath;
data: FileData;
}
export interface FFMessageReadFileData {
path: FFFSPath;
encoding: string;
}
export interface FFMessageDeleteFileData {
path: FFFSPath;
}
export interface FFMessageRenameData {
oldPath: FFFSPath;
newPath: FFFSPath;
}
export interface FFMessageCreateDirData {
path: FFFSPath;
}
export interface FFMessageListDirData {
path: FFFSPath;
}
/**
* @remarks
* Only deletes empty directory.
*/
export interface FFMessageDeleteDirData {
path: FFFSPath;
}
export type FFMessageData =
| FFMessageLoadConfig
| FFMessageWriteFileData
| FFMessageExecData
| FFMessageReadFileData;
| FFMessageWriteFileData
| FFMessageReadFileData
| FFMessageDeleteFileData
| FFMessageRenameData
| FFMessageCreateDirData
| FFMessageListDirData
| FFMessageDeleteDirData;
export interface Message {
type: string;
@@ -94,12 +126,15 @@ export interface LogEvent {
message: string;
}
export interface Progress {
progress: number;
}
export type ExitCode = number;
export type ErrorMessage = string;
export type FileData = Uint8Array;
export type Progress = number;
export type FileData = Uint8Array | string;
export type IsFirst = boolean;
export type IsDone = boolean;
export type OK = boolean;
export type CallbackData =
| FileData
@@ -109,8 +144,9 @@ export type CallbackData =
| LogEvent
| Progress
| IsFirst
| IsDone
| OK
| Error
| FFFSPaths
| undefined;
export interface Callbacks {

View File

@@ -16,36 +16,56 @@ export const getMessageID = (() => {
/**
* Download content of a URL with progress.
*
* Progress only works when Content-Length is provided by the server.
*
*/
export const downloadWithProgress = async (
url: string | URL,
cb: ProgressCallback
): Promise<Uint8Array> => {
): Promise<ArrayBuffer> => {
const resp = await fetch(url);
const reader = resp.body?.getReader();
if (!reader) throw ERROR_RESPONSE_BODY_READER;
let buf;
const total = parseInt(resp.headers.get(HeaderContentLength) || "0");
if (total === 0) throw ERROR_ZERO_CONTENT_LENGTH;
try {
const total = parseInt(resp.headers.get(HeaderContentLength) || "0");
if (total === 0) throw ERROR_ZERO_CONTENT_LENGTH;
const data = new Uint8Array(total);
let received = 0;
for (;;) {
const { done, value } = await reader.read();
const delta = value ? value.length : 0;
const reader = resp.body?.getReader();
if (!reader) throw ERROR_RESPONSE_BODY_READER;
if (done) {
if (total !== received) throw ERROR_INCOMPLETED_DOWNLOAD;
const data = new Uint8Array(total);
let received = 0;
for (;;) {
const { done, value } = await reader.read();
const delta = value ? value.length : 0;
if (done) {
if (total !== received) throw ERROR_INCOMPLETED_DOWNLOAD;
cb({ url, total, received, delta, done });
break;
}
data.set(value, received);
received += delta;
cb({ url, total, received, delta, done });
break;
}
data.set(value, received);
received += delta;
cb({ url, total, received, delta, done });
buf = data.buffer;
} catch (e) {
console.log(`failed to send download progress event: `, e);
// Fetch arrayBuffer directly when it is not possible to get progress.
buf = await resp.arrayBuffer();
cb({
url,
total: buf.byteLength,
received: buf.byteLength,
delta: 0,
done: true,
});
}
return data;
return buf;
};
/**
@@ -58,5 +78,7 @@ export const toBlobURL = async (
cb: ProgressCallback
): Promise<string> =>
URL.createObjectURL(
new Blob([(await downloadWithProgress(url, cb)).buffer], { type: mimeType })
new Blob([await downloadWithProgress(url, cb)], {
type: mimeType,
})
);

View File

@@ -6,13 +6,20 @@ import type { FFmpegCoreModule, FFmpegCoreModuleFactory } from "@ffmpeg/types";
import type {
FFMessageEvent,
FFMessageLoadConfig,
FFMessageWriteFileData,
FFMessageExecData,
FFMessageWriteFileData,
FFMessageReadFileData,
FFMessageDeleteFileData,
FFMessageRenameData,
FFMessageCreateDirData,
FFMessageListDirData,
FFMessageDeleteDirData,
CallbackData,
IsFirst,
IsDone,
OK,
ExitCode,
FFFSPaths,
FileData,
} from "./types";
import { toBlobURL } from "./utils";
import {
@@ -53,18 +60,15 @@ const load = async ({
self.postMessage({ type: FFMessageType.DOWNLOAD, data })
);
if (thread) {
try {
workerURL = await toBlobURL(workerURL, MIME_TYPE_JAVASCRIPT, (data) =>
self.postMessage({ type: FFMessageType.DOWNLOAD, data })
);
// eslint-disable-next-line
} catch (e) {}
workerURL = await toBlobURL(workerURL, MIME_TYPE_JAVASCRIPT, (data) =>
self.postMessage({ type: FFMessageType.DOWNLOAD, data })
);
}
}
importScripts(coreURL);
ffmpeg = await (self as WorkerGlobalScope).createFFmpegCore({
// Fixed `Overload resolution failed.` when using multi-threaded ffmpeg-core.
// Fix `Overload resolution failed.` when using multi-threaded ffmpeg-core.
mainScriptUrlOrBlob: coreURL,
locateFile: (path: string, prefix: string): string => {
if (path.endsWith(".wasm")) return wasmURL;
@@ -75,17 +79,12 @@ const load = async ({
ffmpeg.setLogger((data) =>
self.postMessage({ type: FFMessageType.LOG, data })
);
ffmpeg.setProgress((data: number) =>
self.postMessage({ type: FFMessageType.PROGRESS, data })
ffmpeg.setProgress((progress: number) =>
self.postMessage({ type: FFMessageType.PROGRESS, data: { progress } })
);
return first;
};
const writeFile = ({ path, bin }: FFMessageWriteFileData): IsDone => {
ffmpeg.FS.writeFile(path, bin);
return true;
};
const exec = ({ args, timeout = -1 }: FFMessageExecData): ExitCode => {
ffmpeg.setTimeout(timeout);
ffmpeg.exec(...args);
@@ -94,8 +93,40 @@ const exec = ({ args, timeout = -1 }: FFMessageExecData): ExitCode => {
return ret;
};
const readFile = ({ path }: FFMessageReadFileData): Uint8Array =>
ffmpeg.FS.readFile(path);
const writeFile = ({ path, data }: FFMessageWriteFileData): OK => {
ffmpeg.FS.writeFile(path, data);
return true;
};
const readFile = ({ path, encoding }: FFMessageReadFileData): FileData =>
ffmpeg.FS.readFile(path, { encoding });
// TODO: check if deletion works.
const deleteFile = ({ path }: FFMessageDeleteFileData): OK => {
ffmpeg.FS.unlink(path);
return true;
};
const rename = ({ oldPath, newPath }: FFMessageRenameData): OK => {
ffmpeg.FS.rename(oldPath, newPath);
return true;
};
// TODO: check if creation works.
const createDir = ({ path }: FFMessageCreateDirData): OK => {
ffmpeg.FS.mkdir(path);
return true;
};
const listDir = ({ path }: FFMessageListDirData): FFFSPaths => {
return ffmpeg.FS.readdir(path);
};
// TODO: check if deletion works.
const deleteDir = ({ path }: FFMessageDeleteDirData): OK => {
ffmpeg.FS.rmdir(path);
return true;
};
self.onmessage = async ({
data: { id, type, data: _data },
@@ -109,15 +140,30 @@ self.onmessage = async ({
case FFMessageType.LOAD:
data = await load(_data as FFMessageLoadConfig);
break;
case FFMessageType.WRITE_FILE:
data = writeFile(_data as FFMessageWriteFileData);
break;
case FFMessageType.EXEC:
data = exec(_data as FFMessageExecData);
break;
case FFMessageType.WRITE_FILE:
data = writeFile(_data as FFMessageWriteFileData);
break;
case FFMessageType.READ_FILE:
data = readFile(_data as FFMessageReadFileData);
break;
case FFMessageType.DELETE_FILE:
data = deleteFile(_data as FFMessageDeleteFileData);
break;
case FFMessageType.RENAME:
data = rename(_data as FFMessageRenameData);
break;
case FFMessageType.CREATE_DIR:
data = createDir(_data as FFMessageCreateDirData);
break;
case FFMessageType.LIST_DIR:
data = listDir(_data as FFMessageListDirData);
break;
case FFMessageType.DELETE_DIR:
data = deleteDir(_data as FFMessageDeleteDirData);
break;
default:
throw ERROR_UNKNOWN_MESSAGE_TYPE;
}