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

@jadujoel/bun-optimize-plugin

v0.1.4

Published

A Bun build plugin that re-encodes images to WebP and audio and video to WebM.

Readme

@jadujoel/bun-optimize-plugin

A Bun build plugin that optimizes the assets in your bundle. Images become WebP. Audio and video become WebM. Everything else is copied unchanged.

The plugin rewrites the resolved path of each asset import, so the emitted file carries the new extension and every <img>, <video>, <audio>, <link>, and import points at the optimized file. Nothing in your source changes.

<!-- you write this -->
<img src="./assets/logo.png" />
<video src="./assets/clip.mov"></video>
<!-- the build emits this -->
<img src="./logo-gy9ecr02.webp" />
<video src="./clip-2yapf8wf.webm"></video>

Install

bun add @jadujoel/bun-optimize-plugin

The ffmpeg binary ships with the ffmpeg-helper dependency. No system ffmpeg is used.

Usage

import { optimizePlugin } from "@jadujoel/bun-optimize-plugin";

await Bun.build({
  entrypoints: ["./index.html"],
  plugins: [optimizePlugin({ verbose: true })],
  outdir: "dist",
});

OptimizePlugin is the same plugin with the default options.

import { OptimizePlugin } from "@jadujoel/bun-optimize-plugin";

Formats

| Input | Output | Encoder | | ---------------- | ---------------- | ----------------------------- | | Audio | .webm | Opus, libopus | | Video | .webm | VP9 and Opus, libvpx-vp9 | | Animated image | .webp | animated WebP, libwebp_anim | | Still image | .webp | WebP, Bun.Image or ffmpeg | | Anything else | copied unchanged | — |

There are two output formats, .webp and .webm. One asset produces one file. There is no second output file and no alternative codec behind an option.

Which row an asset lands in is decided by its bytes, not by its name. A .jpg holding a PNG, an .mp4 holding one still picture, and a .ogg holding a film all reach the right encoder. The extension only decides whether the plugin opens the file at all:

  • Audio: .wav .wave .w64 .mp3 .mp2 .m4a .m4b .aac .ogg .oga .opus .flac .alac .aiff .aif .aifc .caf .wma .weba .mka .au .snd .amr .voc .ape
  • Video: .mov .qt .mp4 .m4v .webm .mkv .mk3d .avi .wmv .asf .flv .f4v .3gp .3g2 .mpg .mpeg .mpe .m1v .m2v .ogv .m2ts .mts .vob .dv
  • Image: .png .apng .jpg .jpeg .jpe .jfif .pjpeg .pjp .webp .gif .avif .avifs .heic .heif .heics .bmp .dib .tif .tiff .tga .pcx .ppm .pgm .pbm .pnm .pam .sgi .jp2 .j2k .jpf .jpx .psd .xbm .xpm .dpx

Bun.Image reads JPEG, PNG, WebP, GIF, BMP, and — through the OS codec, so on macOS and Windows only — TIFF, HEIC, and AVIF. Everything else in the image list is decoded by the bundled ffmpeg, and so is any file Bun.Image refuses.

Some things are deliberately left alone. .exr, .hdr, .dds, .ktx, and .basis hold dynamic range or GPU texture layout that a WebP cannot carry, and whatever reads them is not an <img> tag. .ico holds several resolutions where a WebP holds one. .svg needs a minifier this plugin does not have. .ts is TypeScript far more often than it is an MPEG transport stream. Use exclude to leave anything else alone as well.

A source the plugin cannot decode at all is emitted unchanged, and verbose says why. A build never fails because of an asset.

Rules

  1. Animation is detected from the bytes. Bun.Image decodes a .gif or an .apng as a single frame and drops the animation without an error, so the plugin checks the GIF image descriptors, the PNG acTL chunk, and the WebP ANIM chunk before it picks an encoder. A .png that is really an APNG takes the animated path.
  2. Every image output works in an <img> tag. An animated image becomes an animated WebP, never a .webm.
  3. Alpha is preserved. Animated WebP and still WebP carry alpha. A video's alpha channel is measured: a channel the source uses is kept as -pix_fmt yuva420p, and a channel that is fully opaque is dropped for the smaller file. See Alpha.
  4. The smallest file that still looks right wins. Every lossy candidate is decoded again and compared to the source pixel for pixel. Anything past the gate is discarded, so which quality an asset ships at is measured rather than assumed. See The quality gate.
  5. An encode is proved before it is kept. An animated WebP is replayed frame by frame against the source. A video and an audio file are decoded again and checked for lost frames and a changed runtime. An encoder that drops most of its frames reports success and produces a much smaller file, so nothing but a decode of the result catches it.
  6. The smaller file wins. If every candidate is larger than the source, the source is emitted unchanged. Set force: true to turn this rule off and get one format per media type instead.
  7. An asset already in the target format is not re-encoded. A .webp source passes through, and so does a .webm that really holds WebM codecs. An Opus track is copied into the WebM rather than encoded again, and so is a VP8, VP9, or AV1 picture. The two tracks are decided one at a time, so VP9 video beside AAC audio keeps its picture and re-encodes only the sound. Album art is not a picture track and never routes a song to the video encoder.
  8. Results are cached by content hash. The key is the source bytes plus the encode options, the gate and the width cap included. A rebuild with an unchanged asset runs no encoder.
  9. Metadata is stripped. EXIF, GPS, and ICC profiles other than sRGB are removed. The EXIF orientation is applied first.
  10. An encode may lose fidelity inside a channel. It may never drop a channel. An encode that was meant to keep the source's alpha channel and came out without one is refused, and the source ships instead. A second audio track is reported in the log, because only the first one is encoded. A dropped alpha channel used to pass every check here: the runtime matched, the frames matched, and the flattened file was smaller, so rule 6 shipped it. See Alpha.

Options

optimizePlugin({
  /** WebP qualities to try, smallest passing step wins. Default [92, 88, 82]. */
  quality: [92, 88, 82],
  /** How much error a lossy candidate may carry. Default { rmse: 4, p999: 44 }. */
  gate: { rmse: 4, p999: 44 },
  /** Also encode a lossless WebP and keep the smaller file. Default true. */
  tryLossless: true,
  /** Resample any still image wider than this. Default off. */
  maxWidth: undefined,
  /** VP9 constant quality, 0 to 63. Lower is better quality. Default 32. */
  videoQuality: 32,
  /** What to do with a video's alpha channel: auto, keep, or drop. Default auto. */
  alpha: "auto",
  /** Opus bitrate. Default: 48k mono, 96k stereo, 128k above that. */
  audioBitrate: undefined,
  /** Emit the converted file even when it is larger. Default false. */
  force: false,
  /** Leave any source whose path matches this alone. Default off. */
  exclude: undefined,
  /** Per-asset options. The first match wins. Default none. */
  overrides: undefined,
  /** Cache directory. Default node_modules/.cache/bun-optimize-plugin. */
  cacheDir: undefined,
  /** Read the cache but never write it. Default false. */
  disableCache: false,
  /** Print one line per asset and a summary. Default false. */
  verbose: false,
  /** Maximum number of encode jobs at the same time. Default the CPU count. */
  concurrency: navigator.hardwareConcurrency,
});

tryLossless wins on flat art, screenshots, and logos. It loses on photographs, and it doubles the image encode time. It is also the floor under the gate: a lossless candidate carries no error, so it always passes. With it off, an image whose every lossy candidate is refused keeps its source.

The quality gate

A file size does not describe a picture. Picking the smallest candidate is not the same as picking the smallest candidate that still looks right, so every lossy encode is decoded again and measured against the source.

The measurement is two numbers over alpha-premultiplied RGBA.

  • rmse — the average channel error, in 8-bit levels.
  • p999 — the 99.9th-percentile channel error. This catches banding in one small gradient that the average would hide.

quality is a ladder, not a setting. Every step is encoded and measured, and the smallest one inside the gate wins. So the gate decides the quality and the ladder decides where the encoder may look. A stricter gate ships better pictures and more bytes.

import { optimizePlugin, STRICT_GATE } from "@jadujoel/bun-optimize-plugin";

// Flat art, screenshots, and logos.
optimizePlugin({ gate: STRICT_GATE });      // { rmse: 2, p999: 24 }

// Photographs. This is the default.
optimizePlugin({ gate: { rmse: 4, p999: 44 } });

// Pick by file size alone, the way every other asset plugin does.
optimizePlugin({ gate: false, quality: 80 });

The default is not the strict number, and the reason is worth knowing. Photographs do not behave like flat art: on a photographic collage every lossy WebP fails STRICT_GATE — q92 measures around rmse 3.5 and p99.9 40, and the error barely moves down to q65, because it is spread thinly across high-frequency texture rather than concentrated anywhere the eye lands. Refusing all of them means shipping lossless, which costs roughly 5× the bytes for a difference nobody can see at 1:1.

The verbose log reports what was measured, and what was refused when nothing was accepted.

optimize  assets/photo.png    6.0 MB -> 497.0 kB (-92%)  webp q82 (rmse 2.81, p99.9 11)
optimize  assets/logo.png     1.7 kB ->   84 B (-95%)  webp lossless
optimize  assets/ink.png      1.7 kB ->  1.7 kB (0%)   kept source, already optimal; webp q82 rmse 5.39, p99.9 80

Animation

Animation gets the same gate, and it is the reason the gate exists at all. libwebp_anim merges frames. Some merges are invisible — two identical frames become one — and some are a different animation, such as 63 frames collapsing to 4. Both produce a smaller file, so a pipeline that judges by size alone ships the second one and calls it a win.

The candidate is therefore replayed frame by frame and compared to the source by time, not by frame index. Each source frame's midpoint is looked up in the candidate's timeline, and the two pictures at that instant are compared. A harmless merge passes. A merge that holds one picture for half a second does not. A lossless encode is measured too, because frame merging is a property of the encoder and not of the quality setting.

Alpha

A video's alpha channel is measured, not assumed. Two questions decide it, and they are separate.

Does the source have one? The pixel format answers that. yuva420p, yuva444p12le, bgra, and rgba carry a channel. yuv420p does not.

Does the source use one? Only a measurement answers that, and it is the question that matters. A ProRes 4444 export routinely carries a fully opaque alpha channel, and dropping that one costs nothing and saves bytes. So the lowest alpha value in the whole clip is measured. 255 means the channel is unused and it is dropped. Anything lower means the channel is used and it is kept as -pix_fmt yuva420p.

optimize  assets/rose.mov    87.4 MB -> 1.2 MB (-99%)  vp9 crf32; alpha kept as yuva420p, lowest alpha 0 of 255, which Safari does not play; 60 frames and the alpha channel verified
optimize  assets/hero.mov    12.1 MB -> 806.4 kB (-93%)  vp9 crf32, opus 96k; the alpha channel is fully opaque, so it was dropped; 300 frames verified

Keeping the channel loses Safari. VP9 alpha in a WebM plays in Chrome, Edge, and Firefox. Safari wants HEVC with alpha in an MP4, and one asset produces one file here, so both cannot ship. alpha: "auto" keeps the channel anyway, because the other answer is worse in more places: a flattened overlay plays an opaque rectangle in every browser, and a yuva420p overlay is right everywhere except one. Set alpha: "drop" to choose the other way, for the whole build or for one asset through overrides.

| alpha | An unused channel | A used channel | | -------- | ----------------- | ----------------------------- | | "auto" | dropped | kept, and Safari loses it | | "keep" | kept | kept, and Safari loses it | | "drop" | dropped | dropped, and the log says so |

Whatever the policy, an encode that lost a channel it was meant to keep is refused, and the source ships instead. This is not a policy, it is rule 10. It cannot be checked by reading the output's pixel format: Matroska keeps a VP9 alpha plane in BlockAdditional side data, so a WebM carrying a perfectly good alpha channel still reports yuv420p to a probe and to the native vp9 decoder. The output is decoded with libvpx-vp9 and the channel is measured again.

Per-asset options

The build's options are wrong for some assets. overrides is how one asset is told something different. The first entry whose match tests true against the asset's absolute path wins, and its settings are merged over the build's own.

optimizePlugin({
  maxWidth: 1840,
  alpha: "drop",
  overrides: [
    // No background of its own. The transparent field is the design.
    { match: /signup-rose\.mov$/, alpha: "keep" },
    // Flat art, so the default gate would pass a posterised gradient.
    { match: /\/screenshots\//, gate: STRICT_GATE, maxWidth: 2400 },
  ],
});

An override inherits everything it does not name, so the rose above still gets maxWidth: 1840. quality, gate, tryLossless, maxWidth, videoQuality, alpha, audioBitrate, and force can all be set. cacheDir, concurrency, verbose, and exclude cannot: those belong to the build and not to an asset. The cache key is built from the options an asset was actually encoded with, so two assets under one build never share an entry they disagree about.

maxWidth

Off by default, because resampling changes the picture. It is still worth setting.

Bytes on the wire are not the cost of an oversized image. A 3911 × 4050 background for a 920 px layout compresses to 329 kB, so no byte count ever complains about it, and it still costs 60 MB of resident bitmap and the decode time that goes with it — on the landing page, before anything else can paint.

optimizePlugin({ maxWidth: 1840 });  // 2× a 920 px layout

An image over the cap is resampled before a single candidate is encoded, and the gate then runs against the resampled image, so a shipped file is judged against the picture it is meant to be. An image over LARGE_DECODE is reported in the verbose log even when no cap is set.

optimize  assets/sky.png    6.0 MB -> 508.8 kB (-92%)  webp q82 (rmse 2.81, p99.9 11); 3000×2000 resampled to 1840px wide
optimize  assets/sky.png    6.0 MB -> 372.8 kB (-94%)  webp lossless; 3000×2000 decodes to 22.9 MB

Animations are never resampled.

force

By default an asset that no encoder can shrink is emitted unchanged, so a bundle can still hold an .avif, an .ogg, or a .png. force: true emits the converted file anyway. Every image becomes a .webp and every audio and video file becomes a .webm, whatever the size.

optimizePlugin({ force: true });
optimize  assets/sample.avif    401 B ->  654 B (+63%)  webp lossless, forced
optimize  assets/sample.ogg    9.3 kB -> 9.9 kB (+7%)   remux opus to webm, forced; runtime verified
optimize  assets/sample.png    1.7 kB ->   84 B (-95%)  webp lossless
optimize  16 assets  274.4 kB -> 93.3 kB (-66%)

Two cases ignore force, because neither one has a conversion to do.

  • A .webp or .webm source is already in the target format. It passes through untouched.
  • A file no encoder can read is copied unchanged, and the plugin prints a warning.

Use force when one format per media type matters more than bytes, such as a player that must not branch on the container. Leave it off to ship the smallest bundle.

Example

bun run example

The example builds example/index.html, which references every format in example/assets.

optimize  assets/sample.wav   86.2 kB -> 7.8 kB (-91%)  opus 48k; runtime verified
optimize  assets/sample.png    1.7 kB ->  84 B (-95%)  webp lossless
optimize  assets/sample.mov   17.4 kB -> 14.9 kB (-14%)  vp9 crf32; 15 frames verified
optimize  assets/sample.gif     397 B ->  140 B (-65%)  animated webp lossless (rmse 0.00, p99.9 0)
optimize  assets/sample.avif    401 B ->  401 B (-0%)  kept source, it is smaller
optimize  16 assets  274.4 kB -> 91.8 kB (-67%)

Known limits

  • iOS Safari older than 17.4 does not play WebM. Audio and video break on those versions. The single-format rule accepts this.
  • SVG is copied unchanged. Minification needs a dependency, and none is chosen.
  • Bun.Image cannot decode TIFF, HEIC, or AVIF on Linux. Those three formats need the OS codec, which macOS and Windows have. A refused format falls back to ffmpeg, which decodes TIFF. The bundled ffmpeg is 5.0.1, and it does not read a still HEIC or AVIF, so a HEIC or an AVIF is copied unchanged on Linux, even under force: true.
  • A failed encode is not an error. The plugin prints a warning and copies the source.
  • The gate measures a picture against its own source. It cannot tell you that the source is the wrong size for the box it is drawn in, or that an asset is loaded on a page that never shows it. Those are the two largest wins on a real site, and they are decisions only the page can make.
  • The frame check on video is deliberately loose. A variable-frame-rate source encoded at a constant rate legitimately changes its frame count, so demanding equality would refuse correct encodes. The check catches catastrophic loss; the runtime check catches re-timing.
  • No single file carries alpha to every browser. See Alpha.
  • Rule 10 is enforced for the alpha channel and reported for audio tracks past the first. Three other channels are neither: an HDR colour volume flattens to bt709 with no tone map, a bit depth above 8 truncates, and subtitle and caption streams are never mapped.
  • Measuring a video's alpha channel costs one extra decode pass. It only runs for a source whose pixel format can hold alpha, so ordinary yuv420p footage pays nothing, and the result is cached with everything else.

Test

bun test
bun run typecheck