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

bitrate-js

v0.2.1

Published

Client-side HLS / adaptive-bitrate video packager. Chunk, transcode and upload large videos entirely in the browser — Rust/WASM + WebCodecs, no server.

Readme

bitrate

Client-side HLS / adaptive-bitrate video packager. Chunk, transcode and upload large videos entirely in the browser — no server, no upload-then-wait.

npm license

▶ Try it live — bitrate-js.vercel.app

Package a real video in your own browser. Nothing is uploaded; the file never leaves your machine.

Rust/WASM does the demuxing, segmenting and manifests. The browser's WebCodecs API does the encoding. You supply where the output goes.

npm install bitrate-js

No ffmpeg, no transcoding server, no build configuration — the WASM module is inlined into the bundle, so there is no .wasm asset to copy and consumers never install Rust.

Status: 0.1, early. The pipeline works end-to-end and is verified in a real browser against real files, but the API may still change before 1.0. See PLAN.md for the roadmap and SECURITY.md for the security model.


What it does

user picks files ─▶ QUEUE ─▶ chunk / encode ABR ladder ─▶ HLS ─▶ your storage
  (one or many)              (Rust/WASM + WebCodecs)              (S3, Supabase,
                                                                   Appwrite, anything)
  • Chunks large videos into seekable HLS — a 1 GB file streams and seeks instantly instead of downloading whole.
  • Adaptive bitrate — multiple quality rungs so players adapt to network speed.
  • Multi-file queue with progress, retries, and skip-and-continue on failure.
  • Resumes after a closed tab via IndexedDB, in both modes — no redoing a 40-minute job. A re-encode picks up at a segment boundary, which is exact: every segment starts on a keyframe and decodes on its own, so the join is seamless.
  • Keeps the audio — the source audio track is carried through untouched and muxed alongside the video, with both timelines kept in step.
  • Uploads anywhere through a pluggable adapter.
  • Flat memory — reads the source through Blob.slice and releases each output file as it is produced, so peak memory does not grow with file size.

Two modes

| Mode | Speed | Use when | |---|---|---| | remux | Near-instant (1 GB in seconds) | You just want chunking + seeking. No re-encode, no WebCodecs needed, works on more devices. | | transcode | Slower; needs a GPU encoder | You need a real multi-quality ABR ladder. Audio is re-encoded and carried through. |

Output

The root file is master.m3u8 (or <prefix>.m3u8 for a single rendition) — the one URL you hand to a player.

master.m3u8            ◀── ROOT: lists the quality options
├── video_1080p.m3u8   ◀── media playlist for this rung
│   ├── …_init.mp4     ◀── init segment (loaded once)
│   └── …_00000.m4s    ◀── ~6s segments (these enable seeking)
├── video_720p.m3u8  → …
└── video_480p.m3u8  → …

Segment URIs are relative, so the same output plays from any storage base URL without rewriting.


Try it

bitrate-js.vercel.app runs every feature against a real file you choose: a handbook, a segment timeline you can click to seek, a player with quality switching, live job cards, and security checks run against the library itself. Nothing is uploaded — the packaging happens in your own tab.

To run it locally instead:

npm install --prefix examples/react-app && npm run dev --prefix examples/react-app

Then open http://localhost:5180. It installs bitrate-js from npm, so this needs no Rust. To point it at your own working copy of the library instead:

cd js && npm link && cd ../examples/react-app && npm link bitrate-js

Usage

Queue: many files, uploaded anywhere

import { HlsQueue, JobStore } from "bitrate-js";
import { presignedAdapter } from "bitrate-js/adapters/presigned";

const q = new HlsQueue({
  mode: "remux",              // or "transcode" for an ABR ladder
  segmentDuration: 6,
  concurrency: 1,
  retries: 3,
  store: await JobStore.open(),          // enables resume after a reload

  upload: presignedAdapter({
    getUrl: (item) => fetch(`/api/sign?f=${item.name}`).then((r) => r.text()),
  }),

  onProgress: ({ jobId, percent }) => {},
  onJobDone:  ({ jobId, masterPlaylist }) => {},
  onJobError: ({ jobId, error }) => {},   // queue keeps going
});

q.add([file1, file2, file3]);
const report = await q.drain();
// report.succeeded / report.failed — drain never rejects

One file, streamed

import { remux } from "bitrate-js";

for await (const out of remux(file, { prefix: "720p", segmentDuration: 6 })) {
  await upload(out.name, out.blob, out.contentType);   // upload and release as you go
}

Transcode into an ABR ladder

import { transcode, isTranscodeSupported, LADDERS } from "bitrate-js";

if (isTranscodeSupported()) {
  for await (const out of transcode(file, { ladder: LADDERS.standard })) { … }
}

Choosing a ladder

A ladder is the set of qualities you produce. More rungs means better adaptation and more encoding time. Rungs taller than the source are dropped, so a preset can be handed any file without checking it first.

| Preset | Rungs | Use for | |---|---|---| | LADDERS.single | 720p | One quality — cheapest, no adaptation | | LADDERS.mobile | 720 · 480 · 360 | Phone-first audiences, poor networks | | LADDERS.standard | 1080 · 720 · 480 | The usual default | | LADDERS.wide | 1080 · 720 · 480 · 360 | Adds a rung for very bad connections | | LADDERS.uhd | 2160 · 1440 · 1080 · 720 | 4K sources. Expect real time |

Or write your own — a rung is just a height and a bitrate:

ladder: [
  { height: 1080, bitrate: 6_000_000 },   // a higher bitrate than the preset
  { height: 540,  bitrate: 1_000_000 },   // a size no preset offers
]

Every transcode option

transcode(file, {
  ladder: LADDERS.standard,      // which qualities to produce
  segmentDuration: 6,            // seconds per segment; also the keyframe interval
  prefix: "video",               // names files <prefix>_<height>p_00000.m4s

  audio: true,                   // re-encode and keep the sound (default)
  audioBitrate: 128_000,         // AAC bitrate

  profile: "main",               // "main" | "high" | "baseline"
  hardwareAcceleration: "prefer-hardware",
  latencyMode: "quality",        // "quality" | "realtime"
  allowUpscale: false,           // keep rungs taller than the source

  signal: controller.signal,
  onProgress: ({ fraction }) => {},
});

profile — "high" compresses better at the same bitrate and is safe on anything modern; "baseline" is the most compatible and the least efficient. Each falls back if the browser cannot honour it, so a preference never causes a failure.

hardwareAcceleration — "prefer-hardware" is dramatically faster where it exists. Software encoding of a long video in a tab is rarely practical.

latencyMode — "realtime" encodes faster and looks worse at the same bitrate. Worth it when a user is waiting on the result.

allowUpscale — off by default: upscaling costs encoding time and storage and adds no detail. Turn it on only if a fixed set of renditions matters more than the wasted work.

segmentDuration — also sets the keyframe interval, since every segment must start on one. Shorter segments adapt faster and seek more precisely; longer ones compress better and mean fewer files.

The same options are accepted by HlsQueue:

new HlsQueue({
  mode: "transcode",
  ladder: LADDERS.mobile,
  profile: "high",
  hardwareAcceleration: "prefer-hardware",
  upload: myAdapter,
});

Off the main thread

transcodeInWorker is the same generator as transcode, run on a worker.

import { transcodeInWorker } from "bitrate-js";

for await (const out of transcodeInWorker(file, { ladder: LADDERS.standard })) { … }

The tab stays usable, and a backgrounded tab keeps encoding at full speed instead of being throttled. The worker pulls rather than pushes — it holds after every file until the page asks for the next — so memory stays flat instead of growing with the video.

Processing several files? Pay the startup cost once:

const worker = createTranscodeWorker();
try {
  for (const file of files) {
    for await (const out of transcodeInWorker(file, { worker })) await upload(out);
  }
} finally {
  worker.terminate();
}

Poster frames and scrub thumbnails

Every upload form needs a still before the video plays. This decodes one from a few hundred kilobytes of the file — no server, no ffmpeg.

import { posterFrame, thumbnailSprite } from "bitrate-js";

const poster = await posterFrame(file, { atFraction: 0.1 });
img.src = URL.createObjectURL(poster.blob);

// The grid a player shows when you drag the scrub bar.
const sheet = await thumbnailSprite(file, { count: 20, maxWidth: 160 });

Defaults to a tenth of the way in rather than 0:00, because plenty of videos open on black or a fade. WebP by default, which is far smaller than JPEG here.

Subtitles and captions

Subtitles come from a transcription service or an uploaded .srt, not from the video, so they compose rather than being a packager option:

import { subtitleFiles, attachSubtitles } from "bitrate-js";

const tracks = [
  { language: "en", name: "English", content: srtText, default: true },
  { language: "es", name: "Español", content: spanishVtt },
];

// SubRip is converted to WebVTT automatically.
const extra = await subtitleFiles(tracks, { prefix: "video", duration: info.duration });
const master = attachSubtitles(masterText, tracks, { prefix: "video" });

Upload extra alongside the rest and the rewritten master, and players list the tracks. Serve the .vtt as text/vtt — as text/plain the cues fetch fine and display nothing.

Captions already embedded in the video (CEA-608/708 in the H.264 bitstream) are not extracted; that means parsing NAL units and is not supported.

Frames from somewhere else

import { packageFrames } from "bitrate-js";

// Canvas animation, screen capture, MediaStreamTrackProcessor…
for await (const out of packageFrames(myVideoFrames, { segmentDuration: 2 })) { … }

Resume an interrupted job

const store = await JobStore.open();
const [interrupted] = await store.resumableJobs();

if (interrupted) {
  // Ask the user to re-pick the same file; it is verified before resuming.
  q.addResume({ stored: interrupted, file: rePickedFile });
  await q.drain();
}

Works for transcode as well as remux. The queue you resume into must use the same mode and ladder the job started with — stored.settings records both, and addResume refuses a mismatch rather than appending renditions that do not match the segments already uploaded.

A resumed re-encode decodes a short run-up from the keyframe before the restart point, since an inter frame cannot be decoded on its own. That costs a second or two of work, not a re-run of the job.


Security — read before writing an adapter

This package never accepts cloud credentials, and you must never put them in browser code.

A browser has no secure place for a secret. Anything you pass to client-side code is readable by every visitor in DevTools.

// ❌ NEVER — leaks your AWS secret to the world
{ accessKeyId: "AKIA…", secretAccessKey: "…" }

// ✅ Backend signs a short-lived URL for one object (recommended)
presignedAdapter({ getUrl: (i) => fetch(`/api/sign?f=${i.name}`).then(r => r.text()) })

// ✅ Or hand over a client your app already authenticated
s3Adapter({ client: appS3Client, putObjectCommand: PutObjectCommand, bucket: "videos" })

| Provider | Import | Correct client-side auth | |---|---|---| | S3 / R2 / B2 / MinIO / Spaces | adapters/s3 | Pre-signed URL or STS temporary credentials | | Azure Blob / Google Cloud Storage | adapters/presigned | SAS or V4 signed URL from your backend | | Supabase | adapters/supabase | anon key + Row Level Security on the bucket | | Appwrite | adapters/appwrite | Session-scoped client + bucket permissions | | Firebase | adapters/firebase | Signed-in user + Security Rules | | Anything else | adapters/presigned | A short-lived signed URL |

Five adapters cover all of it: the S3-compatible services share one, and signed-URL storage shares another. Each handles cache headers (segments immutable, playlists short-lived) and tells a permanent failure from a retryable one, so a permissions error skips that file rather than consuming its retries.

Complete configuration for every provider — including CORS, cache headers and the settings people usually miss — is in ADAPTERS.md.

Full security model in SECURITY.md: path-traversal defence, untrusted-input parsing, supply chain, CSP, and local-data hygiene.

CSP note: WASM needs script-src 'wasm-unsafe-eval'. The package uses no eval, injects no DOM, and makes no network requests of its own — the only traffic is the upload adapter you supply.

Input requirements

Sources need an H.264 video track in an MP4 container. Both layouts are read:

  • Progressive — samples described in stbl. What most cameras and editors write.
  • Fragmented — samples described in moof boxes. What streaming-oriented writers, many phones, and anything that had to start writing before knowing the final length produce.

For a fragmented source only the moof boxes are read, never the mdat payloads, so a multi-gigabyte file costs a few megabytes of reading to index.

Browser support

| | remux | transcode | seamless resume | |---|---|---|---| | Chrome / Edge | ✅ | ✅ | ✅ | | Safari 16.4+ | ✅ | ✅ | re-pick file | | Firefox (recent) | ✅ | ✅ | re-pick file |

import { isSupported } from "bitrate-js";
const { remux, transcode, reasons } = isSupported();

The WASM module is inlined into the bundle, so there is no .wasm asset to copy and no bundler configuration — npm install is enough.

Using it without a bundler

The package is a single ESM file, so it works from a plain <script type="module">. A browser cannot resolve a bare name like "bitrate-js" on its own, so declare it in an import map:

<script type="importmap">
  { "imports": { "bitrate-js": "/node_modules/bitrate-js/dist/index.js" } }
</script>

<script type="module">
  import { remux, isSupported } from "bitrate-js";
  // …
</script>

Or skip the import map and use the path directly:

<script type="module">
  import { remux } from "/node_modules/bitrate-js/dist/index.js";
</script>

Saving the output

A rendition is many files, and saving them one at a time makes the browser prompt about multiple downloads and scatters them away from the playlist that references them. There is a dependency-free ZIP writer for that:

import { downloadZip } from "bitrate-js/zip";

await downloadZip(
  output.map((f) => ({ name: f.name, data: f.blob })),
  "hls-output.zip",
);

Entries are stored rather than deflated: HLS output is already-compressed video, so compressing again costs CPU for nothing.

Without modules at all

There is also a classic-script build that defines window.bitrate. ES modules cannot load over file://, so this is what a page opened straight from disk needs:

<script src="/node_modules/bitrate-js/dist/bitrate.global.js"></script>
<script>
  bitrate.isSupported();
</script>

Failed to resolve module specifier "bitrate-js" means neither is in place — the page is being served without a bundler and without an import map. It also appears if you open an HTML file straight from disk (file://) instead of through a server.

Development

Requires Rust + wasm-pack and Node 18+. Rust is needed only to build the package; consumers install a pre-compiled .wasm and never need a toolchain.

# Windows: MSVC build tools are required for the host linker
winget install Microsoft.VisualStudio.2022.BuildTools --override "--wait --quiet --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"
rustup target add wasm32-unknown-unknown && npm install -g wasm-pack
cd js && npm install && npm run build && npm test
cargo test && cargo clippy --all-targets

Run the showcase:

npm install --prefix examples/react-app && npm run dev --prefix examples/react-app

Releasing

Publishing is tag-driven, so a release is an explicit act rather than a side effect of merging. Bump js/package.json, tag the same version, and push the tag:

npm version minor --prefix js --no-git-tag-version
git commit -am "Release v0.2.0" && git tag v0.2.0 && git push --follow-tags

.github/workflows/release.yml then checks the tag against package.json, runs prepublishOnly (README sync, WASM build, typecheck, tests) and publishes with npm provenance — an OIDC attestation tying the published tarball to this repository and commit, so nobody has to trust that the bytes on npm came from this source.

It needs one repository secret, NPM_TOKEN (an npm automation token), and the repository field in js/package.json must match the repository it runs in or provenance is rejected.

js/README.md and js/LICENSE are generated from the repository copies by npm run sync:readme — edit the root files, not those. CI fails if they drift.

License

MIT