diff --git a/.prettierrc.yml b/.prettierrc.yml new file mode 100644 index 0000000..d3baa6e --- /dev/null +++ b/.prettierrc.yml @@ -0,0 +1 @@ +trailingComma: all diff --git a/README.md b/README.md index f6230ae..043b2d5 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Gather information from multimedia streams. Works on the browser and Node.js. Uses the code at [alfg/ffprobe-wasm](https://github.com/alfg/ffprobe-wasm), but in a packaged format, so it can be reused in other projects. -_Note_: This project doesn't build or use FFProbe. Instead it uses FFmpeg's libavformat and libavcodec to output similar results. +_For limitations and recommendations, see [Notes section](#notes)._ ## Installation @@ -42,5 +42,7 @@ input.addEventListener("change", (event) => { ## Notes +- This project doesn't build or use FFprobe. Instead it uses FFmpeg's libavformat and libavcodec to output similar results. This means that not everything that FFprobe supports is bundled, so there are some containers and codecs that are not supported. +- In Node.js, it works on version >= 16. - In browser, `SharedArrayBuffer` is being used. To enable this in your server, read [Security requirements](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer#security_requirements). - In browser, everything is bundled in the `browser.mjs` script. When gzipped, this file is bigger than 1 MiB, so it's recommended to use `import()` to lazy load the asset. The good side of this is that you don't have to configure your bundler to include the worker or wasm files and you won't face [same-origin](https://developer.mozilla.org/en-US/docs/Web/API/Worker/Worker) issues with the worker. diff --git a/ffprobe-wasm-app b/ffprobe-wasm-app index c0f96a1..d07d02b 160000 --- a/ffprobe-wasm-app +++ b/ffprobe-wasm-app @@ -1 +1 @@ -Subproject commit c0f96a17558032bd08a2bb3a215c42723bf0e0ef +Subproject commit d07d02b6e64b309a788efe19fe383dde6682f9b2 diff --git a/package-lock.json b/package-lock.json index 62bbd38..9d971d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ffprobe-wasm", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "ffprobe-wasm", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "devDependencies": { "@types/node": "^17.0.21", diff --git a/package.json b/package.json index a8974ce..64d06d2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ffprobe-wasm", - "version": "0.2.0", + "version": "0.3.0", "description": "ffprobe-like for browser and node, powered by WebAssembly", "repository": { "type": "git", @@ -19,11 +19,11 @@ "license": "MIT", "exports": { "node": "./node.mjs", - "types": "./ffprobe-worker.d.ts", + "types": "./ffprobe-worker.d.mts", "default": "./browser.mjs" }, "main": "./node.mjs", - "types": "./ffprobe-worker.d.ts", + "types": "./ffprobe-worker.d.mts", "browser": "./browser.mjs", "scripts": { "build": "npm run tsc && npm run vite", diff --git a/src/browser-vite.mts b/src/browser-vite.mts index d462574..20c85d3 100644 --- a/src/browser-vite.mts +++ b/src/browser-vite.mts @@ -1,12 +1,14 @@ +import type { FFprobeWorker as AbstractFFprobeWorker } from "./ffprobe-worker.mjs"; import type { Chapter, - ChapterTag, + Disposition, FileInfo, + Format, Frame, FramesInfo, + Rational, Stream, -} from "./ffprobe-wasm.mjs"; -import type { FFprobeWorker as AbstractFFprobeWorker } from "./ffprobe-worker.js"; +} from "./types.mjs"; import BrowserWorker from "./worker-browser.mjs?worker&inline"; import type { IncomingMessage, @@ -23,10 +25,13 @@ export class FFprobeWorker implements AbstractFFprobeWorker { async getFileInfo(file: File): Promise { this.#validateFile(file); - return this.#postMessage({ + const fileInfo: FileInfo = await this.#postMessage({ type: "getFileInfo", payload: [file.name, { files: [file] }], }); + fileInfo.format.filename = file.name; + fileInfo.format.size = file.size.toString(); + return fileInfo; } async getFrames(file: File, offset: number): Promise { @@ -44,7 +49,7 @@ export class FFprobeWorker implements AbstractFFprobeWorker { #validateFile(file: File | string): asserts file is File { if (typeof file === "string") { throw new Error( - "String only supported in Node.js, you must provide a File" + "String only supported in Node.js, you must provide a File", ); } } @@ -71,4 +76,13 @@ export class FFprobeWorker implements AbstractFFprobeWorker { } } -export type { Chapter, ChapterTag, FileInfo, Frame, FramesInfo, Stream }; +export type { + Chapter, + Disposition, + FileInfo, + Format, + Frame, + FramesInfo, + Rational, + Stream, +}; diff --git a/src/ffprobe-wasm-shared.d.ts b/src/ffprobe-wasm-shared.d.mts similarity index 60% rename from src/ffprobe-wasm-shared.d.ts rename to src/ffprobe-wasm-shared.d.mts index 12c8157..090df0f 100644 --- a/src/ffprobe-wasm-shared.d.ts +++ b/src/ffprobe-wasm-shared.d.mts @@ -1,3 +1,5 @@ +import { FileInfo, FramesInfo } from "./types.mjs"; + export interface FFprobe { get_file_info(path: string): Raw; get_frames(path: string, offset: number): Raw; @@ -69,22 +71,7 @@ export interface AnalyzePathReturn { parentObject: any | null; } -export interface FileInfo { - bit_rate: number; - chapters: Chapter[]; - /** - * Duration in microseconds - */ - duration: number; - flags: number; - name: string; - nb_chapters: number; - nb_streams: number; - streams: Stream[]; - url: string; -} - -export interface Collection { +export interface Vector { count: { value: number }; ptr: number; ptrType: any; @@ -92,57 +79,17 @@ export interface Collection { size(): number; } -export type Raw = { - [K in keyof T]: T[K] extends Array ? Collection> : T[K]; -}; - -export interface Chapter { - end: number; - id: number; - start: number; - tags: ChapterTag[]; - /** - * @example "1/1000" - */ - time_base: string; -} - -export interface ChapterTag { +export interface DictionaryEntry { key: string; value: string; } -export interface Stream { - bit_rate: number; - channels: number; - codec_name: string; - codec_type: number; - duration: number; - format: string; - frame_size: number; - height: number; - id: number; - level: number; - profile: string; - sample_rate: number; - start_time: number; - width: number; -} - -export interface FramesInfo { - avg_frame_rate: number; - duration: number; - frames: Frame[]; - gop_size: number; - nb_frames: number; - time_base: number; -} - -export interface Frame { - dts: number; - frame_number: number; - pict_type: number; - pkt_size: number; - pos: number; - pts: number; -} +export type Raw = { + [K in keyof T]: T[K] extends Array + ? Vector> + : T[K] extends Record + ? Vector + : T[K] extends string | number | boolean | undefined | null + ? T[K] + : Raw; +}; diff --git a/src/ffprobe-wasm.d.mts b/src/ffprobe-wasm.d.mts index f7bd601..5de42e0 100644 --- a/src/ffprobe-wasm.d.mts +++ b/src/ffprobe-wasm.d.mts @@ -1,6 +1,6 @@ -import { FFprobe } from "./ffprobe-wasm-shared"; +import { FFprobe } from "./ffprobe-wasm-shared.mjs"; -export * from "./ffprobe-wasm-shared"; +export * from "./ffprobe-wasm-shared.mjs"; export default function loadFFprobe(ffprobe?: FFprobeInit): Promise; diff --git a/src/ffprobe-wasm.d.ts b/src/ffprobe-wasm.d.ts index b2b5e4f..59f3f56 100644 --- a/src/ffprobe-wasm.d.ts +++ b/src/ffprobe-wasm.d.ts @@ -1,6 +1,6 @@ -import { FFprobe } from "./ffprobe-wasm-shared"; +import { FFprobe } from "./ffprobe-wasm-shared.mjs"; -export * from "./ffprobe-wasm-shared"; +export * from "./ffprobe-wasm-shared.mjs"; declare const ffprobe: FFprobe; diff --git a/src/ffprobe-worker.d.mts b/src/ffprobe-worker.d.mts new file mode 100644 index 0000000..ca4d63d --- /dev/null +++ b/src/ffprobe-worker.d.mts @@ -0,0 +1,17 @@ +import { FileInfo, FramesInfo } from "./types.mjs"; + +export * from "./types.mjs"; + +export declare class FFprobeWorker { + /** + * This function tries to be equivalent to + * ``` + * ffprobe -hide_banner -loglevel fatal -show_format -show_streams -show_chapters -show_private_data -print_format json + * ``` + */ + getFileInfo(file: File | string): Promise; + + getFrames(file: File | string, offset: number): Promise; + + terminate(): void; +} diff --git a/src/ffprobe-worker.d.ts b/src/ffprobe-worker.d.ts deleted file mode 100644 index d6d1cb2..0000000 --- a/src/ffprobe-worker.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { - Chapter, - ChapterTag, - FileInfo, - Frame, - FramesInfo, - Stream, -} from "./ffprobe-wasm-shared"; - -export declare class FFprobeWorker { - getFileInfo(file: File | string): Promise; - - getFrames(file: File | string, offset: number): Promise; - - terminate(): void; -} - -export { Chapter, ChapterTag, FileInfo, Frame, FramesInfo, Stream }; diff --git a/src/node.mts b/src/node.mts index 5fa6b9a..13647ea 100644 --- a/src/node.mts +++ b/src/node.mts @@ -1,15 +1,18 @@ +import { stat } from "fs/promises"; import { basename, dirname } from "path"; import { fileURLToPath } from "url"; import { MessageChannel, Worker } from "worker_threads"; +import type { FFprobeWorker as AbstractFFprobeWorker } from "./ffprobe-worker.mjs"; import type { Chapter, - ChapterTag, + Disposition, FileInfo, + Format, Frame, FramesInfo, + Rational, Stream, -} from "./ffprobe-wasm.js"; -import type { FFprobeWorker as AbstractFFprobeWorker } from "./ffprobe-worker.js"; +} from "./types.mjs"; import type { IncomingMessage, IncomingData, @@ -26,10 +29,13 @@ export class FFprobeWorker implements AbstractFFprobeWorker { async getFileInfo(filePath: string): Promise { this.#validateFile(filePath); - return this.#postMessage({ + const fileInfo: FileInfo = await this.#postMessage({ type: "getFileInfo", payload: [basename(filePath), { root: dirname(filePath) }], }); + fileInfo.format.filename = filePath; + fileInfo.format.size = (await stat(filePath)).size.toString(); + return fileInfo; } async getFrames(filePath: string, offset: number): Promise { @@ -47,7 +53,7 @@ export class FFprobeWorker implements AbstractFFprobeWorker { #validateFile(filePath: string | File): asserts filePath is string { if (typeof filePath === "object") { throw new Error( - "File object only supported in Browser, you must provide a string (path)" + "File object only supported in Browser, you must provide a string (path)", ); } } @@ -73,4 +79,13 @@ export class FFprobeWorker implements AbstractFFprobeWorker { } } -export type { Chapter, ChapterTag, FileInfo, Frame, FramesInfo, Stream }; +export type { + Chapter, + Disposition, + FileInfo, + Format, + Frame, + FramesInfo, + Rational, + Stream, +}; diff --git a/src/types.d.mts b/src/types.d.mts new file mode 100644 index 0000000..a36f55f --- /dev/null +++ b/src/types.d.mts @@ -0,0 +1,131 @@ +export type Rational = `${number}/${number}`; + +export interface FileInfo { + streams: Stream[]; + chapters: Chapter[]; + format: Format; +} + +export interface Format { + filename: string; + nb_streams: number; + nb_programs: number; + format_name: string; + format_long_name: string; + start_time: string; + duration: string; + size: string; + bit_rate: string; + probe_score: number; + tags: Record; +} + +export interface Chapter { + /** + * Chapter end time in time_base units + */ + end: number; + /** + * unique ID to identify the chapter + */ + id: number; + tags: Record; + /** + * Chapter start time in time_base units + */ + start: number; + /** + * Time base in which the start/end timestamps are specified + * @example "1/1000" + */ + time_base: Rational; +} + +export interface Stream { + index: number; + codec_name: string; + codec_long_name: string; + profile: string; + codec_type: string; + codec_tag_string: string; + codec_tag: string; + + width: number; + height: number; + codec_width: number; + codec_height: number; + closed_captions: number; + has_b_frames: number; + pix_fmt: string; + level: number; + color_range: string; + color_primaries: string; + chroma_location: string; + refs: number; + is_avc: string; + nal_length_size: string; + + sample_fmt: string; + sample_rate: string; + channels: number; + channel_layout: string; + bits_per_sample: number; + + r_frame_rate: string; + avg_frame_rate: string; + /** + * This is the fundamental unit of time (in seconds) in terms + * of which frame timestamps are represented. + */ + time_base: Rational; + start_pts: number; + start_time: string; + duration_ts: number; + /** + * Duration of the stream, in stream time base. + * If a source file does not specify a duration, but does specify + * a bitrate, this value will be estimated from bitrate and file size. + */ + duration: string; + /** + * Total stream bitrate in bit/s, 0 if not available. + */ + bit_rate: string; + bits_per_raw_sample: string; + nb_frames: string; + disposition: Disposition; + tags: Record; +} + +export interface Disposition { + default: 0 | 1; + dub: 0 | 1; + original: 0 | 1; + comment: 0 | 1; + lyrics: 0 | 1; + karaoke: 0 | 1; + forced: 0 | 1; + hearing_impaired: 0 | 1; + visual_impaired: 0 | 1; + clean_effects: 0 | 1; + attached_pic: 0 | 1; + timed_thumbnails: 0 | 1; +} + +export interface FramesInfo { + avg_frame_rate: number; + duration: number; + frames: Frame[]; + gop_size: number; + nb_frames: number; + time_base: number; +} + +export interface Frame { + dts: number; + frame_number: number; + pict_type: number; + pkt_size: number; + pos: number; + pts: number; +} diff --git a/src/worker.mts b/src/worker.mts index 3d3012a..6d4715e 100644 --- a/src/worker.mts +++ b/src/worker.mts @@ -1,7 +1,17 @@ import type { MessagePort as NodeMessagePort } from "worker_threads"; -import type { Chapter, ChapterTag, FFprobe, FileInfo, FramesInfo, FSFilesystems, FSMountOptions, Stream } from "./ffprobe-wasm-shared"; +import type { + DictionaryEntry, + FFprobe, + FSFilesystems, + FSMountOptions, + Raw, + Vector, +} from "./ffprobe-wasm-shared.mjs"; +import { FileInfo, FramesInfo, Stream } from "./types.mjs"; -export type IncomingMessage = { port: MessagePort | NodeMessagePort } & IncomingData; +export type IncomingMessage = { + port: MessagePort | NodeMessagePort; +} & IncomingData; export type IncomingData = | { @@ -63,7 +73,33 @@ export function createListener( } } - async function getFileInfo(fileName: string, mountOptions: FSMountOptions): Promise { + function vectorToArray(vector: Vector): T[] { + const array: T[] = []; + for (let i = 0; i < vector.size(); i++) { + array.push(vector.get(i)); + } + return array; + } + + function dictionaryVectorToRecord( + vector: Vector, + ): Record { + return Object.fromEntries( + vectorToArray(vector).map(({ key, value }) => [key, value]), + ); + } + + function serializeStreams(streams: Vector>) { + return vectorToArray(streams).map((stream) => ({ + ...stream, + tags: dictionaryVectorToRecord(stream.tags), + })); + } + + async function getFileInfo( + fileName: string, + mountOptions: FSMountOptions, + ): Promise { const { FS, get_file_info } = await ffprobePromise; try { if (!FS.analyzePath("/work").exists) { @@ -71,32 +107,18 @@ export function createListener( } FS.mount(FS.filesystems[fsType], mountOptions, "/work"); - // Call the wasm module. const rawInfo = get_file_info(`/work/${fileName}`); - // Remap streams into collection. - const streams: Stream[] = []; - for (let i = 0; i < rawInfo.streams.size(); i++) { - streams.push(rawInfo.streams.get(i)); - } - - // Remap chapters into collection. - const chapters: Chapter[] = []; - for (let i = 0; i < rawInfo.chapters.size(); i++) { - const rawChapter = rawInfo.chapters.get(i); - - const tags: ChapterTag[] = []; - for (let j = 0; j < rawChapter.tags.size(); j++) { - tags.push(rawChapter.tags.get(j)); - } - - chapters.push({ ...rawChapter, tags }); - } - return { - ...rawInfo, - streams, - chapters, + streams: serializeStreams(rawInfo.streams), + chapters: vectorToArray(rawInfo.chapters).map((chapter) => ({ + ...chapter, + tags: dictionaryVectorToRecord(chapter.tags), + })), + format: { + ...rawInfo.format, + tags: dictionaryVectorToRecord(rawInfo.format.tags), + }, }; } finally { // Cleanup mount. @@ -104,7 +126,11 @@ export function createListener( } } - async function getFrames(fileName: string, mountOptions: FSMountOptions, offset: number): Promise { + async function getFrames( + fileName: string, + mountOptions: FSMountOptions, + offset: number, + ): Promise { const { FS, get_frames } = await ffprobePromise; try { if (!FS.analyzePath("/work").exists) { @@ -112,18 +138,11 @@ export function createListener( } FS.mount(FS.filesystems.WORKERFS, mountOptions, "/work"); - // Call the wasm module. const framesInfo = get_frames(`/work/${fileName}`, offset); - // Remap frames into collection. - const frames = []; - for (let i = 0; i < framesInfo.frames.size(); i++) { - frames.push(framesInfo.frames.get(i)); - } - return { ...framesInfo, - frames, + frames: vectorToArray(framesInfo.frames), }; } finally { // Cleanup mount.