diff --git a/README.md b/README.md index d337dcf..9be86b6 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,17 @@ +

+ Library Icon +

+ +[![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); + } + }); +});