commit 6f73cd3bdbb7663369543692ebd7c654be095c4c Author: Tomás Fox Date: Sun Mar 6 22:46:03 2022 -0300 Add project files diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..66d5107 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +.vscode/ +node_modules/ +dist/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..b686c2f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "ffprobe-wasm-app"] + path = ffprobe-wasm-app + url = https://github.com/alfg/ffprobe-wasm diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b596a9e --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2022 Tomás Fox + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..4f377c1 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +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. + +## Installation + +```sh +npm install ffprobe-wasm +``` + +## Examples + +Node.js + +```ts +import { FFprobeWorker } from 'ffprobe-wasm/node.mjs'; + +const worker = new FFprobeWorker(); + +const fileInfo = await worker.getFileInfo('file.mp4'); +console.log(fileInfo); +``` + +Browser + +```ts +import { FFprobeWorker } from 'ffprobe-wasm/browser.mjs'; + +const worker = new FFprobeWorker(); + +// input is the reference to a element +input.addEventListener('change', (event) => { + const file = event.target.files[0] + const fileInfo = await worker.getFileInfo(file); + console.log(fileInfo); +}); +``` diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..2d65354 --- /dev/null +++ b/build.sh @@ -0,0 +1,22 @@ +#/bin/sh + +# Exit on error +set -e + +# Clean +rm -rf dist +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 +cp src/*.d.* dist + +# Build browser/node workers +npm run build + +# Copy package.json +cp package.json dist diff --git a/ffprobe-wasm-app b/ffprobe-wasm-app new file mode 160000 index 0000000..ff36b01 --- /dev/null +++ b/ffprobe-wasm-app @@ -0,0 +1 @@ +Subproject commit ff36b01373c09002a996d456993e826ecdcfdaf1 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..3cf85f8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,50 @@ +{ + "name": "ffprobe-wasm", + "version": "0.1.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "ffprobe-wasm", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@types/node": "^17.0.21", + "typescript": "^4.5.5" + } + }, + "node_modules/@types/node": { + "version": "17.0.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.21.tgz", + "integrity": "sha512-DBZCJbhII3r90XbQxI8Y9IjjiiOGlZ0Hr32omXIZvwwZ7p4DMMXGrKXVyPfuoBOri9XNtL0UK69jYIBIsRX3QQ==", + "dev": true + }, + "node_modules/typescript": { + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.5.tgz", + "integrity": "sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + } + }, + "dependencies": { + "@types/node": { + "version": "17.0.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.21.tgz", + "integrity": "sha512-DBZCJbhII3r90XbQxI8Y9IjjiiOGlZ0Hr32omXIZvwwZ7p4DMMXGrKXVyPfuoBOri9XNtL0UK69jYIBIsRX3QQ==", + "dev": true + }, + "typescript": { + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.5.tgz", + "integrity": "sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==", + "dev": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3668ec3 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "ffprobe-wasm", + "version": "0.1.0", + "description": "ffprobe-like for browser and node, powered by WebAssembly", + "repository": { + "type": "git", + "url": "https://github.com/tfoxy/ffprobe-wasm" + }, + "bugs": { + "url": "https://github.com/tfoxy/ffprobe-wasm/issues" + }, + "keywords": [ + "ffprobe", + "WebAssembly", + "ffmpeg", + "video" + ], + "author": "Tomás Fox ", + "license": "MIT", + "scripts": { + "build": "tsc" + }, + "devDependencies": { + "@types/node": "^17.0.21", + "typescript": "^4.5.5" + } +} diff --git a/src/browser.mts b/src/browser.mts new file mode 100644 index 0000000..7e190c6 --- /dev/null +++ b/src/browser.mts @@ -0,0 +1,49 @@ +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 { + return this.#postMessage({ type: "getFileInfo", payload: [file.name, { files: [file] }] }); + } + + async getFrames(file: File, offset: number): Promise { + return this.#postMessage({ type: "getFrames", payload: [file.name, { files: [file] }, offset] }); + } + + terminate(): void { + this.#worker.terminate(); + } + + #postMessage(data: IncomingData): Promise { + 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) => { + const { data } = event; + if (data.status === "success") { + resolve(data.payload); + } else { + reject(new Error(data.message)); + } + } + }); + } +} + +export type { Chapter, ChapterTag, FileInfo, Frame, FramesInfo, Stream }; diff --git a/src/ffprobe-wasm-shared.d.ts b/src/ffprobe-wasm-shared.d.ts new file mode 100644 index 0000000..1248e19 --- /dev/null +++ b/src/ffprobe-wasm-shared.d.ts @@ -0,0 +1,145 @@ +export interface FFprobe { + get_file_info(path: string): Raw; + get_frames(path: string, offset: number): Raw; + FS: FS; + avutil_version(): string; + avcodec_version(): string; + avformat_version(): string; + onRuntimeInitialized(): void; +} + +export type FSMountOptions = WorkerFSMountOptions | NodeFSMountOptions; + +export interface WorkerFSMountOptions { + files?: File[]; + blobs?: Blob[]; + packages?: any[]; +} + +export interface NodeFSMountOptions { + root: string; +} + +export interface FSFilesystemMountOptions { + type: FSFilesystem; + opts: FSFilesystemMountOptions; + mountpoint: string; + mounts: any[]; +} + +export interface FSFilesystem { + mount(opts: FSFilesystemMountOptions): FSNode; +} + +export interface FSNode { + contents: Record; + id: number; + mode: number; + name: string; + parent: FSNode; + timestamp: number; + isDevice(): boolean; + isFolder(): boolean; + [key: string]: any; +} + +export interface FSFilesystems { + MEMFS: FSFilesystem; + WORKERFS: FSFilesystem; + NODEFS: FSFilesystem; +} + +export interface FS { + analyzePath(path: string, dontResolveLastLink?: boolean): AnalyzePathReturn; + mkdir(path: string, mode?: number): number; + mount(type: FSFilesystem, opts: FSMountOptions, mountpoint: string): FSNode; + unmount(mountpoint: string): void; + filesystems: FSFilesystems; +} + +export interface AnalyzePathReturn { + isRoot: boolean; + exists: boolean; + error: number; + name: string | null; + path: string | null; + object: any | null; + parentExists: boolean; + parentPath: string | null; + 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 { + count: { value: number } + ptr: number; + ptrType: any; + get(index: number): T; + 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 { + 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 +} diff --git a/src/ffprobe-wasm.d.mts b/src/ffprobe-wasm.d.mts new file mode 100644 index 0000000..f7bd601 --- /dev/null +++ b/src/ffprobe-wasm.d.mts @@ -0,0 +1,9 @@ +import { FFprobe } from "./ffprobe-wasm-shared"; + +export * from "./ffprobe-wasm-shared"; + +export default function loadFFprobe(ffprobe?: FFprobeInit): Promise; + +export interface FFprobeInit { + locateFile?(path: string, scriptDirectory: string): string; +} diff --git a/src/ffprobe-wasm.d.ts b/src/ffprobe-wasm.d.ts new file mode 100644 index 0000000..b2b5e4f --- /dev/null +++ b/src/ffprobe-wasm.d.ts @@ -0,0 +1,7 @@ +import { FFprobe } from "./ffprobe-wasm-shared"; + +export * from "./ffprobe-wasm-shared"; + +declare const ffprobe: FFprobe; + +export default ffprobe; diff --git a/src/node.mts b/src/node.mts new file mode 100644 index 0000000..d70ae20 --- /dev/null +++ b/src/node.mts @@ -0,0 +1,52 @@ +import { basename, dirname } from "path"; +import { fileURLToPath } from 'url'; +import { MessageChannel, Worker } from "worker_threads"; +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() { + const __dirname = dirname(fileURLToPath(import.meta.url)); + this.#worker = new Worker(`${__dirname}/worker-node.mjs`); + } + + async getFileInfo(filePath: string): Promise { + return this.#postMessage({ type: "getFileInfo", payload: [basename(filePath), { root: dirname(filePath) }] }); + } + + async getFrames(filePath: string, offset: number): Promise { + return this.#postMessage({ type: "getFrames", payload: [basename(filePath), { root: dirname(filePath) }, offset] }); + } + + terminate(): void { + this.#worker.terminate(); + } + + #postMessage(data: IncomingData): Promise { + const channel = new MessageChannel(); + const message: IncomingMessage = { + ...data, + port: channel.port2 + }; + + this.#worker.postMessage(message, [channel.port2]); + + return new Promise((resolve, reject) => { + channel.port1.on("message", (data: OutgoingMessage) => { + if (data.status === "success") { + resolve(data.payload); + } else { + reject(new Error(data.message)); + } + }); + }); + } +} + +export type { Chapter, ChapterTag, FileInfo, Frame, FramesInfo, Stream }; diff --git a/src/worker-browser.mts b/src/worker-browser.mts new file mode 100644 index 0000000..b66f1a2 --- /dev/null +++ b/src/worker-browser.mts @@ -0,0 +1,11 @@ +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", +); + +self.onmessage = (event: MessageEvent) => listener(event.data); diff --git a/src/worker-node.mts b/src/worker-node.mts new file mode 100644 index 0000000..6957cf7 --- /dev/null +++ b/src/worker-node.mts @@ -0,0 +1,19 @@ +import { createRequire } from "module"; +import { parentPort } from "worker_threads"; +import type { FFprobe } from "./ffprobe-wasm.js"; +import { createListener } from "./worker.mjs"; + +if (!parentPort) { + throw new Error("parentPort must be defined. Are you sure you are in a worker context?"); +} + +const require = createRequire(import.meta.url); + +const listener = createListener(new Promise((resolve) => { + const ffprobe: FFprobe = require('./ffprobe-wasm.js') + ffprobe.onRuntimeInitialized = () => { + resolve(ffprobe); + } +}), "NODEFS"); + +parentPort.on("message", listener); diff --git a/src/worker.mts b/src/worker.mts new file mode 100644 index 0000000..3d3012a --- /dev/null +++ b/src/worker.mts @@ -0,0 +1,133 @@ +import type { MessagePort as NodeMessagePort } from "worker_threads"; +import type { Chapter, ChapterTag, FFprobe, FileInfo, FramesInfo, FSFilesystems, FSMountOptions, Stream } from "./ffprobe-wasm-shared"; + +export type IncomingMessage = { port: MessagePort | NodeMessagePort } & IncomingData; + +export type IncomingData = + | { + type: "getFileInfo"; + payload: [fileName: string, mountOptions: FSMountOptions]; + } + | { + type: "getFrames"; + payload: [fileName: string, mountOptions: FSMountOptions, offset: number]; + }; + +export type OutgoingMessage = + | ({ status: "success" } & OutgoingData) + | { status: "error"; message: string }; + +export type OutgoingData = + | { + type: "getFileInfo"; + payload: FileInfo; + } + | { + type: "getFrames"; + payload: FramesInfo; + }; + +export function createListener( + ffprobePromise: Promise, + fsType: keyof FSFilesystems, +) { + return onmessage; + + async function onmessage(data: IncomingMessage) { + try { + switch (data.type) { + case "getFileInfo": + data.port.postMessage({ + status: "success", + payload: await getFileInfo(...data.payload), + type: data.type, + }); + break; + case "getFrames": + data.port.postMessage({ + status: "success", + payload: await getFrames(...data.payload), + type: data.type, + }); + break; + default: + const _: never = data; + throw new Error(`Unknown event: ${JSON.stringify(_)}`); + } + } catch (error) { + console.error(error); + data.port.postMessage({ + status: "error", + message: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + async function getFileInfo(fileName: string, mountOptions: FSMountOptions): Promise { + const { FS, get_file_info } = await ffprobePromise; + try { + if (!FS.analyzePath("/work").exists) { + FS.mkdir("/work"); + } + 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, + }; + } finally { + // Cleanup mount. + FS.unmount("/work"); + } + } + + async function getFrames(fileName: string, mountOptions: FSMountOptions, offset: number): Promise { + const { FS, get_frames } = await ffprobePromise; + try { + if (!FS.analyzePath("/work").exists) { + FS.mkdir("/work"); + } + 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, + }; + } finally { + // Cleanup mount. + FS.unmount("/work"); + } + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7c370ec --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2021", + "lib": ["DOM", "ES2021"], + "module": "ES2020", + "moduleResolution": "Node", + "allowSyntheticDefaultImports": true, + "allowJs": false, + "strict": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "outDir": "./dist", + "declaration": true + }, + "include": ["./src"] +}