diff --git a/README.md b/README.md
index d337dcf..9be86b6 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,17 @@
+
+
+
+
+[![Maintenance](https://img.shields.io/badge/Maintained%3F-yes-green.svg)](https://github.com/diffusion-studio/ffmpeg-js/graphs/commit-activity)
+[![Website shields.io](https://img.shields.io/website-up-down-green-red/http/shields.io.svg)](https://ffmpeg-js-preview.vercel.app)
+[![Discord](https://badgen.net/badge/icon/discord?icon=discord&label)](https://discord.gg/n3mpzfejAb)
+[![GitHub license](https://badgen.net/github/license/Naereen/Strapdown.js)](https://github.com/diffusion-studio/ffmpeg-js/blob/main/LICENSE)
+[![TypeScript](https://badgen.net/badge/icon/typescript?icon=typescript&label)](https://typescriptlang.org)
+
# 🎥 FFmpeg.js: A WebAssembly-powered FFmpeg Interface for Browsers
Welcome to FFmpeg.js, an innovative library that offers a WebAssembly-powered interface for utilizing FFmpeg in the browser. 🌐💡
-### [👥Join our Discord](https://discord.gg/n3mpzfejAb)
-
## Demo
![GIF Converter Demo](./public/preview.gif)
@@ -58,6 +66,7 @@ server: {
### △Next.js
Here is an example `next.config.js` that supports the SharedArrayBuffer:
+
```
module.exports = {
async headers() {
diff --git a/package.json b/package.json
index 48c61d9..4b98ce5 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "@diffusion-studio/ffmpeg-js",
"private": false,
- "version": "0.1.0",
+ "version": "0.2.0",
"description": "FFmpeg.js - Use FFmpeg in the browser powered by WebAssembly",
"type": "module",
"files": [
@@ -28,6 +28,7 @@
"keywords": [
"ffmpeg",
"webassembly",
+ "emscripten",
"audio",
"browser",
"video",
@@ -42,9 +43,14 @@
"mp3",
"wav",
"flac",
+ "mkv",
+ "mov",
"ogg",
"hevc",
"h264",
+ "h265",
+ "quicktime",
+ "matroska",
"editing",
"cutting"
],
diff --git a/public/icon.png b/public/icon.png
new file mode 100644
index 0000000..4d51a74
Binary files /dev/null and b/public/icon.png differ
diff --git a/public/samples/video.mkv b/public/samples/video.mkv
new file mode 100644
index 0000000..7df16f7
Binary files /dev/null and b/public/samples/video.mkv differ
diff --git a/public/samples/video_xl.mp4 b/public/samples/video_xl.mp4
deleted file mode 100644
index caa57b2..0000000
Binary files a/public/samples/video_xl.mp4 and /dev/null differ
diff --git a/src/ffmpeg-base.ts b/src/ffmpeg-base.ts
index 550778f..5bc576b 100644
--- a/src/ffmpeg-base.ts
+++ b/src/ffmpeg-base.ts
@@ -222,7 +222,7 @@ export class FFmpegBase {
* have been written to the memfs memory
*/
public clearMemory(): void {
- for (const path of this._memory) {
+ for (const path of [...new Set(this._memory)]) {
this.deleteFile(path);
}
this._memory = [];
diff --git a/src/ffmpeg.ts b/src/ffmpeg.ts
index 6238ac6..59e004f 100644
--- a/src/ffmpeg.ts
+++ b/src/ffmpeg.ts
@@ -2,7 +2,7 @@ import { IFFmpegConfiguration } from './interfaces';
import { FFmpegBase } from './ffmpeg-base';
import * as types from './types';
import configs from './ffmpeg-config';
-import { noop } from './utils';
+import { noop, parseMetadata } from './utils';
export class FFmpeg<
Config extends IFFmpegConfiguration<
@@ -220,6 +220,82 @@ export class FFmpeg<
return file;
}
+ /**
+ * Get the meta data of a the specified file.
+ * Returns information such as codecs, fps, bitrate etc.
+ */
+ public async meta(source: string | Blob): Promise {
+ await this.writeFile('probe', source);
+ const meta: types.Metadata = {
+ streams: { audio: [], video: [] },
+ };
+ const callback = parseMetadata(meta);
+ ffmpeg.onMessage(callback);
+ await this.exec(['-i', 'probe']);
+ ffmpeg.removeOnMessage(callback);
+ this.clearMemory();
+ return meta;
+ }
+
+ /**
+ * Generate a series of thumbnails
+ * @param source Your input file
+ * @param count The number of thumbnails to generate
+ * @param start Lower time limit in seconds
+ * @param stop Upper time limit in seconds
+ * @example
+ * // type AsyncGenerator
+ * const generator = ffmpeg.thumbnails('/samples/video.mp4');
+ *
+ * for await (const image of generator) {
+ * const img = document.createElement('img');
+ * img.src = URL.createObjectURL(image);
+ * document.body.appendChild(img);
+ * }
+ */
+ public async *thumbnails(
+ source: string | Blob,
+ count: number = 5,
+ start: number = 0,
+ stop?: number
+ ): AsyncGenerator {
+ // make sure start and stop are defined
+ if (!stop) {
+ const { duration } = await this.meta(source);
+
+ // make sure the duration is defined
+ if (duration) stop = duration;
+ else {
+ console.warn(
+ 'Could not extract duration from meta data please provide a stop argument. Falling back to 1sec otherwise.'
+ );
+ stop = 1;
+ }
+ }
+
+ // get the time increase for each iteration
+ const step = (stop - start) / count;
+
+ await this.writeFile('input', source);
+
+ for (let i = start; i < stop; i += step) {
+ await ffmpeg.exec([
+ '-ss',
+ i.toString(),
+ '-i',
+ 'input',
+ '-frames:v',
+ '1',
+ 'image.jpg',
+ ]);
+ try {
+ const res = await ffmpeg.readFile('image.jpg');
+ yield new Blob([res], { type: 'image/jpeg' });
+ } catch (e) {}
+ }
+ this.clearMemory();
+ }
+
private parseOutputOptions(): string[] {
if (!this._output) {
throw new Error('Please define the output first');
diff --git a/src/types/common.ts b/src/types/common.ts
index 8c2054c..fcaf22f 100644
--- a/src/types/common.ts
+++ b/src/types/common.ts
@@ -108,3 +108,86 @@ export type WasmModuleURIs = {
*/
worker: string;
};
+
+/**
+ * Defines the metadata of an audio stream
+ */
+export type AudioStream = {
+ /**
+ * String containing the id given
+ * by ffmpeg, e.g. 0:1
+ */
+ id?: string;
+ /**
+ * String containing the audio codec
+ */
+ codec?: string;
+ /**
+ * Number containing the audio sample rate
+ */
+ sampleRate?: number;
+};
+
+/**
+ * Defines the metadata of a video stream
+ */
+export type VideoStream = {
+ /**
+ * String containing the id given
+ * by ffmpeg, e.g. 0:0
+ */
+ id?: string;
+ /**
+ * String containing the video codec
+ */
+ codec?: string;
+ /**
+ * Number containing the video width
+ */
+ width?: number;
+ /**
+ * Number containing the video height
+ */
+ height?: number;
+ /**
+ * Number containing the fps
+ */
+ fps?: number;
+};
+
+/**
+ * Defines the metadata of a ffmpeg input log.
+ * These information will be extracted from
+ * the -i command.
+ */
+export type Metadata = {
+ /**
+ * Number containing the duration of the
+ * input in seconds
+ */
+ duration?: number;
+ /**
+ * String containing the bitrate of the file.
+ * E.g 16 kb/s
+ */
+ bitrate?: string;
+ /**
+ * Array of strings containing the applicable
+ * container formats. E.g. mov, mp4, m4a,
+ * 3gp, 3g2, mj2
+ */
+ formats?: string[];
+ /**
+ * Separation in audio and video streams
+ */
+ streams: {
+ /**
+ * Array of audio streams
+ */
+ audio: AudioStream[];
+ /**
+ * Array of video streams
+ */
+ video: VideoStream[];
+ };
+};
diff --git a/src/types/gpl-extended.ts b/src/types/gpl-extended.ts
index 97c1f06..1141b9f 100644
--- a/src/types/gpl-extended.ts
+++ b/src/types/gpl-extended.ts
@@ -71,6 +71,7 @@ export type ExtensionGPLExtended =
| 'mp4'
| 'mpg'
| 'mpeg'
+ | 'mkv'
| 'mov'
| 'ts'
| 'm2t'
diff --git a/src/types/lgpl-base.ts b/src/types/lgpl-base.ts
index fd351e0..3cb5ebd 100644
--- a/src/types/lgpl-base.ts
+++ b/src/types/lgpl-base.ts
@@ -72,6 +72,7 @@ export type ExtensionBase =
| 'mpg'
| 'mpeg'
| 'mov'
+ | 'mkv'
| 'ts'
| 'm2t'
| 'm2ts'
diff --git a/src/utils.ts b/src/utils.ts
index fbbfcc0..dcf84e8 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -1,3 +1,5 @@
+import * as types from './types';
+
/**
* Get uint 8 array from a blob or url
*/
@@ -51,3 +53,107 @@ export const parseProgress = (msg: string): number => {
return parseInt(match ?? '0');
};
+
+/**
+ * Parse a ffmpeg message and extract the meta
+ * data of the input
+ * @param data reference to the object that should
+ * recieve the data
+ * @returns Callback function that can be passed into
+ * the onMessage function
+ */
+export const parseMetadata = (data: types.Metadata) => (msg: string) => {
+ // this string contains the format of the input
+ if (msg.match(/Input #/)) {
+ Object.assign(data, {
+ formats: msg
+ .replace(/(Input #|from 'probe')/gm, '')
+ .split(',')
+ .map((f) => f.trim())
+ .filter((f) => f.length > 1),
+ });
+ }
+
+ // this string contains the duration of the input
+ if (msg.match(/Duration:/)) {
+ const splits = msg.split(',');
+ for (const split of splits) {
+ if (split.match(/Duration:/)) {
+ const duration = split.replace(/Duration:/, '').trim();
+ Object.assign(data, {
+ duration: Date.parse(`01 Jan 1970 ${duration} GMT`) / 1000,
+ });
+ }
+ if (split.match(/bitrate:/)) {
+ const bitrate = split.replace(/bitrate:/, '').trim();
+ Object.assign(data, { bitrate });
+ }
+ }
+ }
+
+ // there can be one or more streams
+ if (msg.match(/Stream #/)) {
+ const splits = msg.split(',');
+
+ // id is the same for all streams
+ const base = {
+ id: splits
+ ?.at(0)
+ ?.match(/[0-9]{1,2}:[0-9]{1,2}/)
+ ?.at(0),
+ };
+
+ // match video streams
+ if (msg.match(/Video/)) {
+ const stream: types.VideoStream = base;
+ for (const split of splits) {
+ // match codec
+ if (split.match(/Video:/)) {
+ Object.assign(stream, {
+ codec: split
+ .match(/Video:\W*[a-z0-9_-]*\W/i)
+ ?.at(0)
+ ?.replace(/Video:/, '')
+ ?.trim(),
+ });
+ }
+ // match size
+ if (split.match(/[0-9]*x[0-9]*/)) {
+ Object.assign(stream, { width: parseFloat(split.split('x')[0]) });
+ Object.assign(stream, { height: parseFloat(split.split('x')[1]) });
+ }
+ // match fps
+ if (split.match(/fps/)) {
+ Object.assign(stream, {
+ fps: parseFloat(split.replace('fps', '').trim()),
+ });
+ }
+ }
+ data.streams.video.push(stream);
+ }
+
+ // match audio streams
+ if (msg.match(/Audio/)) {
+ const stream: types.AudioStream = base;
+ for (const split of splits) {
+ // match codec
+ if (split.match(/Audio:/)) {
+ Object.assign(stream, {
+ codec: split
+ .match(/Audio:\W*[a-z0-9_-]*\W/i)
+ ?.at(0)
+ ?.replace(/Audio:/, '')
+ ?.trim(),
+ });
+ }
+ // match samle rate unit
+ if (split.match(/hz/i)) {
+ Object.assign(stream, {
+ sampleRate: parseInt(split.replace(/[\D]/gm, '')),
+ });
+ }
+ }
+ data.streams.audio.push(stream);
+ }
+ }
+};
diff --git a/tests/capabilities.spec.ts b/tests/capabilities.spec.ts
index 52ade53..074c874 100644
--- a/tests/capabilities.spec.ts
+++ b/tests/capabilities.spec.ts
@@ -140,4 +140,27 @@ test.describe('FFmpeg get capabilities', async () => {
expect(Object.keys(formats.demuxers).includes('wav')).toBe(true);
expect(Object.keys(formats.demuxers).includes('mpeg')).toBe(true);
});
+
+ test('test that .meta returns a valid meta data dictionary', async () => {
+ const metadata = await page.evaluate(async () => {
+ return await ffmpeg.meta('/samples/video.mp4');
+ });
+
+ expect(Object.keys(metadata).length).toBeGreaterThan(0);
+ expect(metadata.bitrate?.length).toBeGreaterThan(0);
+ expect(metadata.duration).toBeGreaterThanOrEqual(30);
+ expect(metadata.formats?.includes('mp4')).toBe(true);
+ expect(metadata.streams.audio.length).toBe(1);
+ expect(metadata.streams.video.length).toBe(1);
+ // audio checks
+ expect(metadata.streams.audio.at(0)?.codec).toBe('aac');
+ expect(metadata.streams.audio.at(0)?.id).toBe('0:1');
+ expect(metadata.streams.audio.at(0)?.sampleRate).toBe(48000);
+ // video checks
+ expect(metadata.streams.video.at(0)?.codec).toBe('h264');
+ expect(metadata.streams.video.at(0)?.fps).toBe(30);
+ expect(metadata.streams.video.at(0)?.id).toBe('0:0');
+ expect(metadata.streams.video.at(0)?.height).toBe(270);
+ expect(metadata.streams.video.at(0)?.width).toBe(480);
+ });
});
diff --git a/tests/fixtures.ts b/tests/fixtures.ts
index e8861e2..bdacb9a 100644
--- a/tests/fixtures.ts
+++ b/tests/fixtures.ts
@@ -19,6 +19,10 @@ export const SUPPORTED_VIDEO_CONVERSIONS = [
['webm', 'avi'],
['webm', 'mov'],
['webm', 'wmv'],
+ ['mkv', 'mp4'],
+ ['mkv', 'avi'],
+ ['mkv', 'mov'],
+ ['mkv', 'wmv'],
['wmv', 'mp4'],
['wmv', 'avi'],
['wmv', 'mov'],
@@ -32,4 +36,5 @@ export const VIDEO_EXTENSIONS = [
'ogg',
'webm',
'wmv',
+ 'mkv',
] as const;
diff --git a/tests/thumbnails.spec.ts b/tests/thumbnails.spec.ts
new file mode 100644
index 0000000..65dd0a5
--- /dev/null
+++ b/tests/thumbnails.spec.ts
@@ -0,0 +1,63 @@
+import { test, expect, Page } from '@playwright/test';
+
+// Annotate entire file as serial.
+test.describe.configure({ mode: 'serial' });
+
+let page: Page;
+
+test.describe('FFmpeg thumbnails extraction tests', async () => {
+ /**
+ * Get index page and wait until ffmpeg is ready
+ */
+ test.beforeAll(async ({ browser }) => {
+ page = await browser.newPage();
+ await page.goto('http://localhost:5173/');
+
+ const ready = await page.evaluate(async () => {
+ if (!ffmpeg.isReady) {
+ await new Promise((resolve) => {
+ ffmpeg.whenReady(resolve);
+ });
+ }
+
+ return ffmpeg.isReady;
+ });
+ expect(ready).toBe(true);
+ });
+
+ test('test thumbnail extraction with default values', async () => {
+ const images = await page.evaluate(async () => {
+ const images: number[] = [];
+ const generator = ffmpeg.thumbnails('/samples/video.mp4');
+
+ for await (const image of generator) {
+ images.push((await image.arrayBuffer()).byteLength);
+ }
+
+ return images;
+ });
+
+ expect(images.length).toBe(5);
+ for (const image of images) {
+ expect(image).toBeGreaterThan(0);
+ }
+ });
+
+ test('test thumbnail extraction with start stop and count', async () => {
+ const images = await page.evaluate(async () => {
+ const images: number[] = [];
+ const generator = ffmpeg.thumbnails('/samples/video.mov', 12, 3, 12);
+
+ for await (const image of generator) {
+ images.push((await image.arrayBuffer()).byteLength);
+ }
+
+ return images;
+ });
+
+ expect(images.length).toBe(12);
+ for (const image of images) {
+ expect(image).toBeGreaterThan(0);
+ }
+ });
+});