Make file info output more similar to ffprobe

This commit is contained in:
Tomás Fox
2022-03-17 17:48:33 -03:00
parent e447a242c7
commit 193d2e7b9e
14 changed files with 270 additions and 142 deletions

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. 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 ## Installation
@@ -42,5 +42,7 @@ input.addEventListener("change", (event) => {
## Notes ## 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, `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. - 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.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "ffprobe-wasm", "name": "ffprobe-wasm",
"version": "0.2.0", "version": "0.3.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ffprobe-wasm", "name": "ffprobe-wasm",
"version": "0.2.0", "version": "0.3.0",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/node": "^17.0.21", "@types/node": "^17.0.21",

View File

@@ -1,6 +1,6 @@
{ {
"name": "ffprobe-wasm", "name": "ffprobe-wasm",
"version": "0.2.0", "version": "0.3.0",
"description": "ffprobe-like for browser and node, powered by WebAssembly", "description": "ffprobe-like for browser and node, powered by WebAssembly",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -19,11 +19,11 @@
"license": "MIT", "license": "MIT",
"exports": { "exports": {
"node": "./node.mjs", "node": "./node.mjs",
"types": "./ffprobe-worker.d.ts", "types": "./ffprobe-worker.d.mts",
"default": "./browser.mjs" "default": "./browser.mjs"
}, },
"main": "./node.mjs", "main": "./node.mjs",
"types": "./ffprobe-worker.d.ts", "types": "./ffprobe-worker.d.mts",
"browser": "./browser.mjs", "browser": "./browser.mjs",
"scripts": { "scripts": {
"build": "npm run tsc && npm run vite", "build": "npm run tsc && npm run vite",

View File

@@ -1,12 +1,14 @@
import type { FFprobeWorker as AbstractFFprobeWorker } from "./ffprobe-worker.mjs";
import type { import type {
Chapter, Chapter,
ChapterTag, Disposition,
FileInfo, FileInfo,
Format,
Frame, Frame,
FramesInfo, FramesInfo,
Rational,
Stream, Stream,
} from "./ffprobe-wasm.mjs"; } from "./types.mjs";
import type { FFprobeWorker as AbstractFFprobeWorker } from "./ffprobe-worker.js";
import BrowserWorker from "./worker-browser.mjs?worker&inline"; import BrowserWorker from "./worker-browser.mjs?worker&inline";
import type { import type {
IncomingMessage, IncomingMessage,
@@ -23,10 +25,13 @@ export class FFprobeWorker implements AbstractFFprobeWorker {
async getFileInfo(file: File): Promise<FileInfo> { async getFileInfo(file: File): Promise<FileInfo> {
this.#validateFile(file); this.#validateFile(file);
return this.#postMessage({ const fileInfo: FileInfo = await this.#postMessage({
type: "getFileInfo", type: "getFileInfo",
payload: [file.name, { files: [file] }], 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> { 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 { #validateFile(file: File | string): asserts file is File {
if (typeof file === "string") { if (typeof file === "string") {
throw new Error( 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 { export interface FFprobe {
get_file_info(path: string): Raw<FileInfo>; get_file_info(path: string): Raw<FileInfo>;
get_frames(path: string, offset: number): Raw<FramesInfo>; get_frames(path: string, offset: number): Raw<FramesInfo>;
@@ -69,22 +71,7 @@ export interface AnalyzePathReturn {
parentObject: any | null; parentObject: any | null;
} }
export interface FileInfo { export interface Vector<T> {
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> {
count: { value: number }; count: { value: number };
ptr: number; ptr: number;
ptrType: any; ptrType: any;
@@ -92,57 +79,17 @@ export interface Collection<T> {
size(): number; size(): number;
} }
export type Raw<T> = { export interface DictionaryEntry {
[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; key: string;
value: string; value: string;
} }
export interface Stream { export type Raw<T> = {
bit_rate: number; [K in keyof T]: T[K] extends Array<infer U>
channels: number; ? Vector<Raw<U>>
codec_name: string; : T[K] extends Record<string, string>
codec_type: number; ? Vector<DictionaryEntry>
duration: number; : T[K] extends string | number | boolean | undefined | null
format: string; ? T[K]
frame_size: number; : Raw<T[K]>;
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;
}

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>; 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; 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 { basename, dirname } from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { MessageChannel, Worker } from "worker_threads"; import { MessageChannel, Worker } from "worker_threads";
import type { FFprobeWorker as AbstractFFprobeWorker } from "./ffprobe-worker.mjs";
import type { import type {
Chapter, Chapter,
ChapterTag, Disposition,
FileInfo, FileInfo,
Format,
Frame, Frame,
FramesInfo, FramesInfo,
Rational,
Stream, Stream,
} from "./ffprobe-wasm.js"; } from "./types.mjs";
import type { FFprobeWorker as AbstractFFprobeWorker } from "./ffprobe-worker.js";
import type { import type {
IncomingMessage, IncomingMessage,
IncomingData, IncomingData,
@@ -26,10 +29,13 @@ export class FFprobeWorker implements AbstractFFprobeWorker {
async getFileInfo(filePath: string): Promise<FileInfo> { async getFileInfo(filePath: string): Promise<FileInfo> {
this.#validateFile(filePath); this.#validateFile(filePath);
return this.#postMessage({ const fileInfo: FileInfo = await this.#postMessage({
type: "getFileInfo", type: "getFileInfo",
payload: [basename(filePath), { root: dirname(filePath) }], 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> { 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 { #validateFile(filePath: string | File): asserts filePath is string {
if (typeof filePath === "object") { if (typeof filePath === "object") {
throw new Error( 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 { 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 = 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; const { FS, get_file_info } = await ffprobePromise;
try { try {
if (!FS.analyzePath("/work").exists) { if (!FS.analyzePath("/work").exists) {
@@ -71,32 +107,18 @@ export function createListener(
} }
FS.mount(FS.filesystems[fsType], mountOptions, "/work"); FS.mount(FS.filesystems[fsType], mountOptions, "/work");
// Call the wasm module.
const rawInfo = get_file_info(`/work/${fileName}`); 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 { return {
...rawInfo, streams: serializeStreams(rawInfo.streams),
streams, chapters: vectorToArray(rawInfo.chapters).map((chapter) => ({
chapters, ...chapter,
tags: dictionaryVectorToRecord(chapter.tags),
})),
format: {
...rawInfo.format,
tags: dictionaryVectorToRecord(rawInfo.format.tags),
},
}; };
} finally { } finally {
// Cleanup mount. // 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; const { FS, get_frames } = await ffprobePromise;
try { try {
if (!FS.analyzePath("/work").exists) { if (!FS.analyzePath("/work").exists) {
@@ -112,18 +138,11 @@ export function createListener(
} }
FS.mount(FS.filesystems.WORKERFS, mountOptions, "/work"); FS.mount(FS.filesystems.WORKERFS, mountOptions, "/work");
// Call the wasm module.
const framesInfo = get_frames(`/work/${fileName}`, offset); 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 { return {
...framesInfo, ...framesInfo,
frames, frames: vectorToArray(framesInfo.frames),
}; };
} finally { } finally {
// Cleanup mount. // Cleanup mount.