npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@bdinfo-rs/wasm

v4.0.0

Published

In-browser Blu-ray disc analyzer — the bdinfo-rs measured scan compiled to WebAssembly, run off the main thread in a Web Worker over a webkitdirectory-picked BDMV folder.

Readme

@bdinfo-rs/wasm

npm license

In-browser Blu-ray disc analyzer — the bdinfo-rs measured scan compiled to WebAssembly. Point it at a disc's BDMV folder and it runs the full measured scan (M2TS demux + per-stream/per-chapter statistics) entirely in the browser, off the main thread in a Web Worker. No bytes leave the page, and a multi-GB *.m2ts never has to fit in memory — the files are read synchronously at byte offsets via FileReaderSync.

The rendered report is byte-for-byte the classic disc report, pinned to its own golden — rendered from the same Big Buck Bunny fixture the native end-to-end test scans, and held byte-identical across native, Node, and headless Chrome and Firefox.

Install

npm i @bdinfo-rs/wasm

The published payload is ~535 KB of WebAssembly + ~51 KB of JS, measured on the optimized build (wasm-opt -Oz). Only the main-thread entry you import (~12 KB) loads up front; the scan Worker (~4 KB) and the wasm-bindgen glue (~34 KB) that hosts the .wasm are fetched lazily inside the Worker, and nothing past the entry loads at all until the first scan.

Usage

Three calls mirror the CLI flow — inspect the disc, measure the playlists you want, re-render the report as you like. Each takes either a picked BDMV folder (as (relativePath, File) pairs) or a single Blu-ray .iso File, and each runs in the browser, off the main thread:

import { inspect, renderReport, scan } from "@bdinfo-rs/wasm";

// `picked`: the (relativePath, File) pairs from a <input type="file" webkitdirectory>.
const picked = [...input.files].map((file) => ({
  path: file.webkitRelativePath,
  file,
}));

// 1. Fast STRUCTURAL scan (like `--list`) → the whole disc model, no demux.
const disc = await inspect(picked);
for (const playlist of disc.playlists) {
  console.log(`${playlist.name}  ${playlist.totalLengthSeconds}s  ${playlist.chapterCount} ch`);
}

// 2. FULL measured scan → the classic report AND the same scan as data, from
//    one demux. Pass `selection` (playlist names, like `--mpls`) to measure only
//    chosen playlists; omit it to measure the `--whole` set.
const measured = await scan(
  picked,
  ({ file, done, total }) => console.log(`${file}: ${done}/${total}`),
  { selection: [disc.playlists[0].name] },
);

console.log(measured.report); // the classic BDInfo-style disc report

// 3. Re-render that report with different sections — no media, no rescan.
const brief = await renderReport(measured.disc, { quickSummary: false });

An .iso goes through the same three calls; pass the File instead of the list, and the image is opened through the read-only UDF reader:

const disc = await inspect(isoInput.files[0]);
const { report } = await scan(isoInput.files[0]);

The disc model

inspect and scan both give you a Disc: the disc-level properties (volumeLabel, discTitle, sizeBytes, is3d, isUhd, …) and every Playlist on it, each carrying its Streams, Clips and Chapters. Values cross as raw numbers with unit-bearing names — bitrateBps, sampleRateHz, heightPixels, lengthSeconds — so your UI can sort, filter and chart them rather than parse report text. The types (Disc, Playlist, Stream, Clip, ClipStream, Chapter, ScanError, HiddenRule, ScanResult) are exported from the package entry and generated from the Rust definitions.

disc.playlists is in the disc's own file-name order and holds every playlist. Each one also carries where it sits in the classic selection table — group (shared-clip group, from 1), position (table order, from 1) and hiddenBy — so you can build that table without reimplementing its grouping:

const table = disc.playlists
  .filter((playlist) => playlist.hiddenBy.length === 0)
  .sort((a, b) => a.position - b.position);

totalLengthTicks is totalLengthSeconds in the 100 ns ticks the report formats its times from. Integer-divide it by 10,000,000 for the table's hh:mm:ss cell; computing that from the f64 seconds can land a tick either side.

disc.measured tells the two scans apart: false after inspect, where every measured value — bitrates, packet counts, chapter rates — is zero because nothing measured it; true after scan, where a zero is a genuine zero.

Codec detail without a full demux

A plain inspect reads no stream bytes, so its streams carry only what the disc's metadata declares — codec name, resolution, channel layout — and none of the detail inside the streams themselves. codecs: true deepens the inspect to the bounded codec pass: each stream file's head is read just far enough to parse the first parameter sets, so every stream's fullDescription gains its profile, level and HDR metadata while the call stays far cheaper than a measured scan of a multi-GB disc:

const disc = await inspect(picked, { codecs: true });
// e.g. "1080p / 23.976 fps / 16:9 / High Profile 4.1"
console.log(disc.playlists[0].streams[0].fullDescription);

disc.measured is still false: bitrates, packet counts and chapter rates stay zero (a parameter-declared rate, like LPCM's, is the one exception), because only a full scan measures them.

disc.isAacsEncrypted says the disc's stream content is AACS-encrypted. Neither call throws for it: the structure — playlists, streams as the disc declares them, chapters — comes from cleartext metadata and is correct either way. Only the stream content is unreadable, so a measured scan of such a disc demuxes ciphertext and every value it measures is meaningless. What to do about that is yours to decide; the demo tells the user and offers no measured scan.

Re-rendering the report

The model carries every value the report prints, so renderReport(disc) reproduces the report that scan returned byte for byte — a render, not an approximation, pinned against the same golden the scan itself is pinned to. The Disc is therefore the thing worth keeping: store it and every rendering of the report stays one call away, with no media and no rescan.

const { report, disc } = await scan(picked);
// later, from the held disc alone — the same bytes, minus one section:
const trimmed = await renderReport(disc, { streamDiagnostics: false });

streamDiagnostics and quickSummary both default to on, which is the report the CLI writes. A disc from a scan with a selection re-renders as that scan reported it: disc.reportOrder records the playlists it printed, so a playlist the scan never measured cannot reappear as a block of zeros. The model still holds every playlist on the disc — scan again to measure more of them.

Stream files that cannot be read to the end

A scan does not abandon a disc whose stream file fails partway through: it keeps what it measured up to the failing read, so the chapter rows, stream diagnostics and per-file seconds cover the span before the failure and stay zero after it, and it reports the failure in disc.errors.

const { disc } = await scan(picked, undefined, { keepPartial: false });

keepPartial: false discards that measured span instead, leaving those values zero throughout. The failure is reported in disc.errors either way, and a disc that reads cleanly produces the same bytes under both settings.

An .iso behaves differently one layer down, deliberately: a sector the image cannot serve is recorded by the UDF reader and served to the scan as zeros, so the scan reads on through the gap and the file is measured to its end. Those recordings reach disc.errors and the report WARNING: block like any other read failure — a damaged span shows up as depressed rates rather than as a truncated file, and keepPartial does not apply to it.

A stream file whose bytes simply stop before the span the disc declares is different again: it reads to a clean end of file, so nothing lands in disc.errors, the report carries no WARNING: line, and every value measured from it is silently smaller than the disc says. A scan names such files in disc.shortStreamNotices — one sentence per short file, worded exactly as the bdinfo-rs CLI's stderr notices and the desktop app's banner; the field is absent when nothing is short. The report bytes never change for it: raise the notices beside the report, the way the demo shows them above it.

Saving the report

reportFileName gives the name a report is saved under — BDINFO.<label>.txt, the same name the native CLI writes — with every character illegal in a file name replaced by _:

const name = await reportFileName(disc.volumeLabel); // "BDINFO.MY_DISC.txt"

The sanitizer is the core library's, property-tested there: whatever bytes a disc puts in its volume label, the result is one flat path component, so a hostile label can neither escape a chosen directory nor break the save.

Playlist filtering

The classic report withholds playlists shorter than 20 seconds and looping ones. This package never withholds anything: every playlist crosses, and each carries the rules that classify it as withheld in hiddenBy"short", "looping", both, or none. Filtering is therefore a client-side array operation, instant and rescan-free:

const disc = await inspect(picked);
const standard = disc.playlists.filter((playlist) => playlist.hiddenBy.length === 0);
const withoutShort = disc.playlists.filter(
  (playlist) => !playlist.hiddenBy.includes("short"),
);

shortPlaylistSeconds moves the length threshold behind "short". It is the one filter setting that has to be passed to the call, because it changes the classification rather than the view — pass it to inspect or scan and every playlist is judged against it:

const disc = await inspect(picked, { shortPlaylistSeconds: 5 });

It defaults to 20 seconds when omitted, and must be finite and within 0..=86400 (one day). Zero switches the short rule off — no playlist is shorter than zero seconds — so no Disc from that call names "short" in its hiddenBy. A value outside the domain (negative, non-finite, past the ceiling) rejects the call with an error rather than silently scanning with the default.

Nothing else moves with it: which playlists a Disc holds, which ones a selection measures, and the rendered report are the same either way.

Live numbers while the scan runs

onProgress says how far a scan has read; options.onMeasured says what it has measured so far, so a table can fill its measured cells during the scan instead of waiting for the report:

await scan(picked, undefined, {
  onMeasured: ({ file, playlists }) => {
    for (const playlist of playlists) {
      // `measuredBytes` per playlist, `clips[]` per stream file, `streams[]`
      // per (pid, angleIndex) — the live form of the same values `scan`
      // resolves with.
      cells.get(playlist.name).textContent = format(playlist.measuredBytes);
    }
  },
});

Each snapshot covers only the playlists that play the stream file named by file, so keep your last known numbers for the rest; within one scan the byte tallies only grow. The values land exactly on the finished ones, so a cell that ticks does not jump when the scan ends. The callback is called at most once a second whatever the read speed — the scan produces snapshots far faster on a quick source and the extra ones are dropped — and a scan given no onMeasured builds no snapshots at all.

Cancelling

Pass an AbortSignal. Aborting it terminates the scan Worker and rejects the promise with an AbortError, so a user cancel is distinguishable from a real failure by the rejection's name:

const controller = new AbortController();
const { report } = await scan(picked, undefined, { signal: controller.signal });

See index.html in the source repository for a complete vanilla example (the demo is not shipped in the npm package).

Bundler support

This is an ES-modules-only, browser-only package (no CommonJS build). It runs the scan off the main thread, so it ships two assets the analyzer loads at runtime: the Web Worker (dist/worker.js) and the WebAssembly module (pkg/bdinfo_rs_wasm_bg.wasm, fetched by the Worker). Your toolchain must emit both as addressable assets.

Every call spawns the Worker with the standard

new Worker(new URL("./worker.js", import.meta.url), { type: "module" });

pattern. Any bundler that understands it works out of the box:

  • Vite — handled natively (it rewrites the new URL(..., import.meta.url) worker reference and emits the .wasm as an asset).
  • webpack 5 — handled natively (the same worker/asset detection).
  • Native ES modules (no bundler — served straight from the package on a static host or via an import map) — works as published.

If your bundler can't follow that pattern, host the Worker yourself and pass a factory constructing it. The package's exports map deliberately keeps the internals private (dist/worker.js and pkg/ are not importable subpaths), so copy dist/worker.js together with the pkg/ directory out of node_modules into your own source, preserving their relative layout — worker.js loads the wasm-bindgen glue and .wasm via import "../pkg/bdinfo_rs_wasm.js", so pkg/ must stay one level below it. Then construct the module Worker from the URL your bundler produces for the copied worker:

import workerUrl from "./worker.js?worker&url"; // however your bundler exposes it

await scan(picked, onProgress, {
  createWorker: () => new Worker(workerUrl, { type: "module" }),
});

The raw wasm-bindgen module is also exported directly for advanced use:

import init, { scan_files } from "@bdinfo-rs/wasm/wasm";

It exports inspect_files, inspect_iso, scan_files, scan_iso, render_report and report_file_name — what inspect, scan, renderReport and reportFileName call, minus the Worker — plus one entry point the package deliberately does not wrap: scan_report(bytes) takes a whole disc pre-framed into one length-prefixed byte buffer and renders it from memory. It exists as the in-memory seam the byte-parity tests drive through both the native and the browser build; it is not a fourth way to scan a disc, and a real consumer has no such buffer.

Remember that every scanning export reads its bytes through FileReaderSync and therefore has to run inside a Web Worker.

Browser support

The scan needs two browser capabilities: <input type="file" webkitdirectory> for the folder pick and FileReaderSync for synchronous byte-range reads inside a Worker. Both are available on desktop Chrome / Edge, desktop Firefox, and Android Chrome. The package's parity suite runs on headless Chrome and Firefox (plus Node), so those are the verified engines; desktop Safari exposes the same APIs but is untested. FileReaderSync is Worker-only by design, which is why every call runs in a Web Worker and never on the main thread.

iOS is the one known gap: iOS WebKit could not pick a folder on iOS ≤ 18.3 (the webkitdirectory bit was unimplemented; it shipped in iOS 18.4). Treat the folder pick as progressive enhancement — when webkitdirectory is unavailable, degrade gracefully to a plain multi-file picker (<input type="file" multiple>) or drag-and-drop, and tell the user to select the disc's files individually or update to iOS 18.4+.

Content Security Policy

A --target web wasm module is compiled and instantiated at runtime, so a page that sets a script-src (or default-src) CSP must allow WebAssembly with 'wasm-unsafe-eval' (the broader 'unsafe-eval' also works); otherwise the module is blocked. With no CSP, wasm runs freely. The scan itself must run in a Web Worker — the package handles that for you.

License

LGPL-2.1-or-later. This package is a single WebAssembly module that statically links bdinfo-rs-core (itself a Rust port of, and derivative work based on, BDInfo © 2010 Cinema Squid), so the whole package is covered by the GNU Lesser General Public License, version 2.1 or (at your option) any later version.

The tarball ships the full license text (LICENSE) and the attribution and derivative-work notice (NOTICE). The complete corresponding source for the linked code is the public repository at the matching release tag — https://github.com/agentjp/bdinfo-rs at v<this package's version> — from which the .wasm is built (crates/bdinfo-rs-wasm).