@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.
Maintainers
Readme
@bdinfo-rs/wasm
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/wasmThe 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.wasmas 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).
