Compare commits

10 Commits

Author SHA1 Message Date
bc9e1670b2 fix: add vite plugins for wasm
Signed-off-by: Alix von Schirp <github@avonschirp.bootmedia.de>
2026-02-19 13:44:55 +01:00
c0e13cdaa1 chore: update submodule ref
Signed-off-by: Alix von Schirp <github@avonschirp.bootmedia.de>
2026-02-19 13:44:39 +01:00
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
Tomás Fox
e447a242c7 Bundle browser script, add package exports, add sourcemaps 2022-03-15 15:29:17 -03:00
21 changed files with 2539 additions and 205 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

@@ -1,11 +1,10 @@
ffprobe-wasm
==========
# ffprobe-wasm
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
@@ -18,25 +17,32 @@ npm install ffprobe-wasm
Node.js
```ts
import { FFprobeWorker } from 'ffprobe-wasm/node.mjs';
import { FFprobeWorker } from "ffprobe-wasm";
const worker = new FFprobeWorker();
const fileInfo = await worker.getFileInfo('file.mp4');
const fileInfo = await worker.getFileInfo("file.mp4");
console.log(fileInfo);
```
Browser
```ts
import { FFprobeWorker } from 'ffprobe-wasm/browser.mjs';
import { FFprobeWorker } from "ffprobe-wasm";
const worker = new FFprobeWorker();
// input is the reference to a <input type="file" /> element
input.addEventListener('change', (event) => {
const file = event.target.files[0]
// input is the reference to an <input type="file" /> element
input.addEventListener("change", (event) => {
const file = event.target.files[0];
const fileInfo = await worker.getFileInfo(file);
console.log(fileInfo);
});
```
## 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

@@ -9,14 +9,18 @@ rm -rf ffprobe-wasm-app/dist
# Build wasm
cd ffprobe-wasm-app
# sed -i -e 's/ffprobe-wasm\.js/ffprobe-wasm.mjs/g;s/-o dist\/ffprobe-wasm\.mjs \\/-o dist\/ffprobe-wasm.mjs -s EXPORT_NAME=ffprobe \\/' Makefile
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
# Build browser/node workers
npm run build
# Remove unnecessary files
rm dist/browser-vite.* dist/ffprobe-wasm.d.mts dist/ffprobe-wasm.mjs dist/worker-browser.*
# Copy files for npm publish
cp package.json LICENSE README.md dist

2093
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.1.0",
"version": "0.3.0",
"description": "ffprobe-like for browser and node, powered by WebAssembly",
"repository": {
"type": "git",
@@ -17,11 +17,24 @@
],
"author": "Tomás Fox <tomas.c.fox@gmail.com>",
"license": "MIT",
"exports": {
"node": "./node.mjs",
"types": "./ffprobe-worker.d.mts",
"default": "./browser.mjs"
},
"main": "./node.mjs",
"types": "./ffprobe-worker.d.mts",
"browser": "./browser.mjs",
"scripts": {
"build": "tsc"
"build": "npm run tsc && npm run vite",
"tsc": "tsc",
"vite": "vite build"
},
"devDependencies": {
"@types/node": "^17.0.21",
"typescript": "^4.5.5"
"@types/node": "^25.2.3",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.5.0"
}
}

27
scripts/replace.js Normal file
View File

@@ -0,0 +1,27 @@
const { readFile, writeFile } = require("fs/promises");
const { dirname, resolve } = require("path");
main();
async function main() {
const root = dirname(__dirname);
const wasmJsPath = resolve(root, "dist/ffprobe-wasm.mjs");
let content = await readFile(wasmJsPath, { encoding: "utf8" });
content = `\
import initWasmInstance from "./ffprobe-wasm.wasm";
const initWasm = (info) =>
initWasmInstance(info).then((exports) => ({ instance: { exports } }));
${content}`;
content = content.replace(`import.meta.url`, `''`);
content = content.replace(
`instantiateAsync().catch(readyPromiseReject)`,
`initWasm(info).then(receiveInstantiatedSource, readyPromiseReject)`
);
await writeFile(wasmJsPath, content, { encoding: "utf8" });
}

88
src/browser-vite.mts Normal file
View File

@@ -0,0 +1,88 @@
import type { FFprobeWorker as AbstractFFprobeWorker } from "./ffprobe-worker.mjs";
import type {
Chapter,
Disposition,
FileInfo,
Format,
Frame,
FramesInfo,
Rational,
Stream,
} from "./types.mjs";
import BrowserWorker from "./worker-browser.mjs?worker&inline";
import type {
IncomingMessage,
IncomingData,
OutgoingMessage,
} from "./worker.mjs";
export class FFprobeWorker implements AbstractFFprobeWorker {
readonly #worker: Worker;
constructor() {
this.#worker = new BrowserWorker();
}
async getFileInfo(file: File): Promise<FileInfo> {
this.#validateFile(file);
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> {
this.#validateFile(file);
return this.#postMessage({
type: "getFrames",
payload: [file.name, { files: [file] }, offset],
});
}
terminate(): void {
this.#worker.terminate();
}
#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",
);
}
}
#postMessage(data: IncomingData): Promise<any> {
const channel = new MessageChannel();
const message: IncomingMessage = {
...data,
port: channel.port2,
};
this.#worker.postMessage(message, [channel.port2]);
return new Promise((resolve, reject) => {
channel.port1.onmessage = (event: MessageEvent<OutgoingMessage>) => {
const { data } = event;
if (data.status === "success") {
resolve(data.payload);
} else {
reject(new Error(data.message));
}
};
});
}
}
export type {
Chapter,
Disposition,
FileInfo,
Format,
Frame,
FramesInfo,
Rational,
Stream,
};

View File

@@ -1,49 +0,0 @@
import type { Chapter, ChapterTag, FileInfo, Frame, FramesInfo, Stream } from "./ffprobe-wasm.mjs";
import type {
IncomingMessage,
IncomingData,
OutgoingMessage,
} from "./worker.mjs";
export class FFprobeWorker {
readonly #worker: Worker;
constructor() {
this.#worker = new Worker("./worker-browser.mjs");
}
async getFileInfo(file: File): Promise<FileInfo> {
return this.#postMessage({ type: "getFileInfo", payload: [file.name, { files: [file] }] });
}
async getFrames(file: File, offset: number): Promise<FramesInfo> {
return this.#postMessage({ type: "getFrames", payload: [file.name, { files: [file] }, offset] });
}
terminate(): void {
this.#worker.terminate();
}
#postMessage(data: IncomingData): Promise<any> {
const channel = new MessageChannel();
const message: IncomingMessage = {
...data,
port: channel.port2
};
this.#worker.postMessage(message, [channel.port2]);
return new Promise((resolve, reject) => {
channel.port1.onmessage = (event: MessageEvent<OutgoingMessage>) => {
const { data } = event;
if (data.status === "success") {
resolve(data.payload);
} else {
reject(new Error(data.message));
}
}
});
}
}
export type { Chapter, ChapterTag, FileInfo, Frame, FramesInfo, Stream };

1
src/declarations/vite.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

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,77 +71,25 @@ export interface AnalyzePathReturn {
parentObject: any | null;
}
export interface FileInfo {
bit_rate: number
chapters: Chapter[]
duration: number
flags: number
name: string
nb_chapters: number
nb_streams: number
streams: Stream[]
url: string
}
export interface Collection<T> {
count: { value: number }
export interface Vector<T> {
count: { value: number };
ptr: number;
ptrType: any;
get(index: number): T;
size(): number;
}
export interface DictionaryEntry {
key: string;
value: string;
}
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 {
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
}
[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,14 +1,25 @@
import { stat } from "fs/promises";
import { basename, dirname } from "path";
import { fileURLToPath } from 'url';
import { fileURLToPath } from "url";
import { MessageChannel, Worker } from "worker_threads";
import type { Chapter, ChapterTag, FileInfo, Frame, FramesInfo, Stream } from "./ffprobe-wasm.mjs";
import type { FFprobeWorker as AbstractFFprobeWorker } from "./ffprobe-worker.mjs";
import type {
Chapter,
Disposition,
FileInfo,
Format,
Frame,
FramesInfo,
Rational,
Stream,
} from "./types.mjs";
import type {
IncomingMessage,
IncomingData,
OutgoingMessage,
} from "./worker.mjs";
export class FFprobeWorker {
export class FFprobeWorker implements AbstractFFprobeWorker {
readonly #worker: Worker;
constructor() {
@@ -17,22 +28,41 @@ export class FFprobeWorker {
}
async getFileInfo(filePath: string): Promise<FileInfo> {
return this.#postMessage({ type: "getFileInfo", payload: [basename(filePath), { root: dirname(filePath) }] });
this.#validateFile(filePath);
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> {
return this.#postMessage({ type: "getFrames", payload: [basename(filePath), { root: dirname(filePath) }, offset] });
this.#validateFile(filePath);
return this.#postMessage({
type: "getFrames",
payload: [basename(filePath), { root: dirname(filePath) }, offset],
});
}
terminate(): void {
this.#worker.terminate();
}
#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)",
);
}
}
#postMessage(data: IncomingData): Promise<any> {
const channel = new MessageChannel();
const message: IncomingMessage = {
...data,
port: channel.port2
port: channel.port2,
};
this.#worker.postMessage(message, [channel.port2]);
@@ -49,4 +79,13 @@ export class FFprobeWorker {
}
}
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,11 +1,6 @@
import loadFFprobe from "./ffprobe-wasm.mjs";
import { createListener, IncomingMessage } from "./worker.mjs";
const listener = createListener(
loadFFprobe({
locateFile: (path) => `${location.origin}/node_modules/ffprobe-wasm/${path}`
}),
"WORKERFS",
);
const listener = createListener(loadFFprobe(), "WORKERFS");
self.onmessage = (event: MessageEvent<IncomingMessage>) => listener(event.data);

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.

View File

@@ -11,7 +11,8 @@
"resolveJsonModule": true,
"isolatedModules": true,
"outDir": "./dist",
"declaration": true
"declaration": true,
"sourceMap": true
},
"include": ["./src"]
}

38
vite.config.ts Normal file
View File

@@ -0,0 +1,38 @@
import { resolve } from "path";
import { defineConfig } from "vite";
import topLevelAwait from "vite-plugin-top-level-await";
import wasm from "vite-plugin-wasm";
// https://vitejs.dev/config/
export default defineConfig({
build: {
outDir: resolve(__dirname, "dist"),
lib: {
entry: resolve(__dirname, "dist/browser-vite.mjs"),
formats: ["es"],
fileName: () => "browser.mjs",
},
emptyOutDir: false,
minify: false,
sourcemap: true,
},
plugins: [
wasm(),
topLevelAwait()
],
// worker: {
// plugins: [
// // {
// // name: "append-source-url",
// // generateBundle(options, bundle) {
// // Object.entries(bundle).forEach(([file, output]) => {
// // if (output.type === "chunk") {
// // output.code += `\n//# sourceURL=${file}`;
// // }
// // });
// // },
// // },
// ],
// },
});