Compare commits

7 Commits

Author SHA1 Message Date
3d1951c50a chore: update submodule ref
Signed-off-by: Alix von Schirp <github@avonschirp.bootmedia.de>
2026-02-19 12:43:27 +01:00
90864d1b05 fix: rename ffprobe-wasm.js to .mjs on build
Signed-off-by: Alix von Schirp <github@avonschirp.bootmedia.de>
2026-02-19 12:36:22 +01:00
e7dbb8c0c9 chore: update submodule ref
Signed-off-by: Alix von Schirp <github@avonschirp.bootmedia.de>
2026-02-19 10:05:02 +01:00
cece48aaeb chore: change submodule ref
Signed-off-by: Alix von Schirp <github@avonschirp.bootmedia.de>
2026-02-18 22:50:08 +01:00
5214d5c530 chore: updates to submodule
Signed-off-by: Alix von Schirp <github@avonschirp.bootmedia.de>
2026-02-18 21:30:44 +01:00
ff2bef3e9c deps: update devDependencies
Signed-off-by: Alix von Schirp <github@avonschirp.bootmedia.de>
2026-02-18 20:40:23 +01:00
Tomás Fox
193d2e7b9e Make file info output more similar to ffprobe 2022-03-17 17:49:20 -03:00
16 changed files with 1725 additions and 784 deletions

2
.gitmodules vendored
View File

@@ -1,3 +1,3 @@
[submodule "ffprobe-wasm-app"]
path = ffprobe-wasm-app
url = https://github.com/alfg/ffprobe-wasm
url = https://git.cislabs.de/cis-oss/ffprobe-wasm

1
.prettierrc.yml Normal file
View File

@@ -0,0 +1 @@
trailingComma: all

View File

@@ -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.

View File

@@ -12,6 +12,7 @@ cd ffprobe-wasm-app
docker-compose run ffprobe-wasm make
cd ..
cp -R ffprobe-wasm-app/dist dist
mv dist/ffprobe-wasm.js dist/ffprobe-wasm.mjs
node scripts/replace.js
cp src/*.d.* dist

2092
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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",
@@ -31,8 +31,8 @@
"vite": "vite build"
},
"devDependencies": {
"@types/node": "^17.0.21",
"typescript": "^4.5.5",
"vite": "^2.8.6"
"@types/node": "^25.2.3",
"typescript": "^5.9.3",
"vite": "^7.3.1"
}
}

View File

@@ -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<FileInfo> {
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<FramesInfo> {
@@ -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,
};

View File

@@ -1,3 +1,5 @@
import { FileInfo, FramesInfo } from "./types.mjs";
export interface FFprobe {
get_file_info(path: string): Raw<FileInfo>;
get_frames(path: string, offset: number): Raw<FramesInfo>;
@@ -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<T> {
export interface Vector<T> {
count: { value: number };
ptr: number;
ptrType: any;
@@ -92,57 +79,17 @@ export interface Collection<T> {
size(): number;
}
export type Raw<T> = {
[K in keyof T]: T[K] extends Array<infer U> ? Collection<Raw<U>> : 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<T> = {
[K in keyof T]: T[K] extends Array<infer U>
? Vector<Raw<U>>
: T[K] extends Record<string, string>
? Vector<DictionaryEntry>
: T[K] extends string | number | boolean | undefined | null
? T[K]
: Raw<T[K]>;
};

View File

@@ -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<FFprobe>;

View File

@@ -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;

17
src/ffprobe-worker.d.mts Normal file
View File

@@ -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<FileInfo>;
getFrames(file: File | string, offset: number): Promise<FramesInfo>;
terminate(): void;
}

View File

@@ -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<FileInfo>;
getFrames(file: File | string, offset: number): Promise<FramesInfo>;
terminate(): void;
}
export { Chapter, ChapterTag, FileInfo, Frame, FramesInfo, Stream };

View File

@@ -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<FileInfo> {
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<FramesInfo> {
@@ -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,
};

131
src/types.d.mts Normal file
View File

@@ -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<string, string>;
}
export interface Chapter {
/**
* Chapter end time in time_base units
*/
end: number;
/**
* unique ID to identify the chapter
*/
id: number;
tags: Record<string, string>;
/**
* 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<string, string>;
}
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;
}

View File

@@ -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<FileInfo> {
function vectorToArray<T>(vector: Vector<T>): T[] {
const array: T[] = [];
for (let i = 0; i < vector.size(); i++) {
array.push(vector.get(i));
}
return array;
}
function dictionaryVectorToRecord(
vector: Vector<DictionaryEntry>,
): Record<string, string> {
return Object.fromEntries(
vectorToArray(vector).map(({ key, value }) => [key, value]),
);
}
function serializeStreams(streams: Vector<Raw<Stream>>) {
return vectorToArray(streams).map((stream) => ({
...stream,
tags: dictionaryVectorToRecord(stream.tags),
}));
}
async function getFileInfo(
fileName: string,
mountOptions: FSMountOptions,
): Promise<FileInfo> {
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<FramesInfo> {
async function getFrames(
fileName: string,
mountOptions: FSMountOptions,
offset: number,
): Promise<FramesInfo> {
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.