<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: mengyuxuan</title>
    <description>The latest articles on DEV Community by mengyuxuan (@mengyuxuan).</description>
    <link>https://dev.to/mengyuxuan</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4041464%2Fa7983a53-ea41-4f63-b0d1-4adfab57e3c1.jpg</url>
      <title>DEV Community: mengyuxuan</title>
      <link>https://dev.to/mengyuxuan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mengyuxuan"/>
    <language>en</language>
    <item>
      <title>WebCodecs is 10x faster than ffmpeg.wasm — and sometimes worse</title>
      <dc:creator>mengyuxuan</dc:creator>
      <pubDate>Tue, 15 Sep 2026 06:49:50 +0000</pubDate>
      <link>https://dev.to/mengyuxuan/webcodecs-is-10x-faster-than-ffmpegwasm-and-sometimes-worse-37jk</link>
      <guid>https://dev.to/mengyuxuan/webcodecs-is-10x-faster-than-ffmpegwasm-and-sometimes-worse-37jk</guid>
      <description>&lt;p&gt;WebCodecs is 7.5–10.1× faster than ffmpeg.wasm. That's the headline, and it's the least interesting thing we measured.&lt;/p&gt;

&lt;p&gt;In the same benchmark, at identical settings, ffmpeg.wasm produced a &lt;strong&gt;smaller&lt;/strong&gt; file — 10.23 MB against WebCodecs' 11.20 MB. Not noise, not a bug.&lt;/p&gt;

&lt;p&gt;And there's a &lt;strong&gt;5× performance spread hiding inside WebCodecs itself&lt;/strong&gt; that no feature detection will tell you about.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A while back I wrote up &lt;a href="https://dev.to/mengyuxuan/three-things-that-broke-when-i-moved-video-compression-into-the-browser-41l8"&gt;three things that broke when I moved video compression into the browser&lt;/a&gt; — the AVI remux that produced a 1151-byte file, the AAC priming delay that permanently defeats mediabunny's copy path, and why the multithreaded ffmpeg core costs you your ad revenue. That post was about the failures. &lt;strong&gt;This one is about the numbers.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The comparison is usually posed wrong
&lt;/h2&gt;

&lt;p&gt;Two mechanisms compress video in a browser without uploading it. &lt;strong&gt;ffmpeg.wasm&lt;/strong&gt; is FFmpeg compiled to WebAssembly — the whole toolchain running as a program in the tab. &lt;strong&gt;WebCodecs&lt;/strong&gt; is a browser API that hands JavaScript the platform's own encoder, usually the same hardware block your phone uses to record video.&lt;/p&gt;

&lt;p&gt;Written up as a &lt;em&gt;versus&lt;/em&gt;, the answer is always some variation of "WebCodecs if you can, ffmpeg.wasm if you must."&lt;/p&gt;

&lt;p&gt;That's true, and it's not very useful. It describes a decision made once, at the top of a project. &lt;strong&gt;The decision that actually matters is made per file&lt;/strong&gt; — and the two engines are not the only options on that decision.&lt;/p&gt;




&lt;h2&gt;
  
  
  The measurements
&lt;/h2&gt;

&lt;p&gt;Same clip, same settings, compressed twice through the production UI.&lt;/p&gt;

&lt;p&gt;The ffmpeg run is not a debug mode. The harness deletes &lt;code&gt;window.VideoEncoder&lt;/code&gt; and &lt;code&gt;window.VideoDecoder&lt;/code&gt; before any page script runs, so the engine selector takes the fallback branch exactly as it would in a browser that never shipped the API.&lt;/p&gt;

&lt;p&gt;Both runs start from a cold browser context, so the ffmpeg figures &lt;strong&gt;include fetching its WebAssembly core&lt;/strong&gt; — a real visitor pays for that too.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Clip&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;WebCodecs&lt;/th&gt;
&lt;th&gt;ffmpeg.wasm&lt;/th&gt;
&lt;th&gt;Slower by&lt;/th&gt;
&lt;th&gt;Output (WC / ff)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5 s, 1080p&lt;/td&gt;
&lt;td&gt;5.2 MB&lt;/td&gt;
&lt;td&gt;1.3 s&lt;/td&gt;
&lt;td&gt;9.8 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.5×&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3.24 / 3.33 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15 s, 1080p&lt;/td&gt;
&lt;td&gt;15.6 MB&lt;/td&gt;
&lt;td&gt;2.8 s&lt;/td&gt;
&lt;td&gt;28.4 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10.1×&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;9.72 / 9.83 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30 s, 720p&lt;/td&gt;
&lt;td&gt;15.9 MB&lt;/td&gt;
&lt;td&gt;2.8 s&lt;/td&gt;
&lt;td&gt;27.4 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9.8×&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;11.20 / &lt;strong&gt;10.23 MB&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Apple M3, 16 GB, Chromium 145, macOS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The gap grows with duration.&lt;/strong&gt; Fetching and instantiating the wasm core is a fixed cost, so on a five-second clip it's a large share of the ten seconds. By fifteen seconds it's been amortised — and the ratio still gets &lt;em&gt;worse&lt;/em&gt;, not better. That's the encoder itself, not the startup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The last row is the interesting one.&lt;/strong&gt; ffmpeg produced a smaller file on identical settings. libx264 is running CRF with a bitrate ceiling, so it spends what each scene needs and banks the rest. The WebCodecs encoder is given an average bitrate and hits it. On footage with large easy regions, CRF wins that trade comfortably.&lt;/p&gt;

&lt;p&gt;So the honest summary isn't "WebCodecs is better." It's:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;WebCodecs is much faster and usually close enough on size, and on some content it gives up a real amount of it.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The 5× spread hiding inside WebCodecs
&lt;/h2&gt;

&lt;p&gt;This is the part I'd most like other people to check.&lt;/p&gt;

&lt;p&gt;Our router has to guess how long a re-encode will take &lt;em&gt;before&lt;/em&gt; committing to one, so it carries a throughput figure in megapixels of source video per second:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Encoder&lt;/th&gt;
&lt;th&gt;Mpx/s&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;WebCodecs, hardware AVC (Apple M-series)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;305&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebCodecs, software fallback (no hardware AVC)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;60&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What the router actually assumes for WebCodecs&lt;/td&gt;
&lt;td&gt;150&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ffmpeg.wasm, single-threaded libx264&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A hardware AVC encoder on an M-series measured 305. The same API on a machine with no hardware AVC — falling back to a software encoder behind the same interface — lands near 60.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That's a 5× spread inside what the code calls "WebCodecs," and nothing in the API tells you which one you got.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;VideoEncoder.isConfigSupported()&lt;/code&gt; will happily tell you the config is supported. It will not tell you whether it's supported by silicon or by a software path that's five times slower. You can measure it at runtime — but only by encoding something first, which is the thing you were trying to decide about.&lt;/p&gt;

&lt;p&gt;So the shipped figure is 150. Deliberately between them, and deliberately not the number either machine produces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ENCODE_MEGAPIXELS_PER_SEC&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;webcodecs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ffmpegWasm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Over-estimating the machine costs the user a wait they didn't agree to. Under-estimating it costs them compression they wanted. Splitting the difference isn't indecision — it's the only position where both errors stay small.&lt;/p&gt;

&lt;p&gt;(ffmpeg.wasm gets 15 because we're on the single-threaded core. That's a revenue decision, not an oversight — &lt;a href="https://dev.to/mengyuxuan/three-things-that-broke-when-i-moved-video-compression-into-the-browser-41l8"&gt;explained in the earlier post&lt;/a&gt;.)&lt;/p&gt;




&lt;h2&gt;
  
  
  Four routes, not three
&lt;/h2&gt;

&lt;p&gt;The earlier post described three paths. Shipping it properly turned out to need four — and the two that are &lt;em&gt;neither engine&lt;/em&gt; are where a surprising share of the wins live.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Route&lt;/th&gt;
&lt;th&gt;What runs&lt;/th&gt;
&lt;th&gt;When&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Passthrough&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Copy encoded samples into a new container. No decode, no encode.&lt;/td&gt;
&lt;td&gt;Already meets target, is H.264 + AAC, and re-encoding would take &amp;gt;20 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;WebCodecs transcode&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Decode + re-encode via the browser's codec API&lt;/td&gt;
&lt;td&gt;Browser exposes WebCodecs, can read the container, can encode the target&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Remux first, then transcode&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Losslessly rewrap to Matroska with ffmpeg.wasm, then hand to WebCodecs&lt;/td&gt;
&lt;td&gt;WebCodecs can't read the container (AVI, FLV, WMV) but can decode the codec inside&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ffmpeg.wasm&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Full libx264 encode in wasm&lt;/td&gt;
&lt;td&gt;Nothing above applies&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The engine decision itself is trivial:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decideEngine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;caps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;EngineCaps&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;CompressionEngine&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;caps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hasWebCodecsApi&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;caps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inputReadable&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;caps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;canEncodeTarget&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;webcodecs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ffmpeg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All the interesting work is in deciding &lt;em&gt;whether to encode at all&lt;/em&gt;, and in making an unreadable container readable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Passthrough: the threshold is on the wrong axis if you use bitrate
&lt;/h2&gt;

&lt;p&gt;Copying encoded samples buys exactly one thing: &lt;strong&gt;time&lt;/strong&gt;. It can't win on size, because the encoder is separately instructed to spend at most the source's own bitrate — so a re-encode is essentially always smaller than a copy.&lt;/p&gt;

&lt;p&gt;On a five-second clip, skipping the encode saved &lt;strong&gt;0.8 seconds&lt;/strong&gt; and forfeited about &lt;strong&gt;19% of the file&lt;/strong&gt;. Nobody asked for that trade.&lt;/p&gt;

&lt;p&gt;On a ten-minute file the same skip saves minutes.&lt;/p&gt;

&lt;p&gt;So the rule isn't about bitrate at all. It's about the wait:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PASSTHROUGH_MIN_TRANSCODE_SECONDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A threshold on the wrong axis would have been easy to ship and hard to notice.&lt;/p&gt;

&lt;p&gt;One trap while you're in there:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A source's audio codec being &lt;em&gt;unknown&lt;/em&gt; is not the same as it having &lt;em&gt;no&lt;/em&gt; audio.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The two want opposite handling — an unidentified track is precisely the kind you must not copy blind. Collapse both into a null check and you get silent audio in an output that otherwise looks fine.&lt;/p&gt;




&lt;h2&gt;
  
  
  What remux-first costs in memory
&lt;/h2&gt;

&lt;p&gt;Rewrapping an unreadable container so the fast encoder can take the job is the single best trick in the pipeline. &lt;a href="https://dev.to/mengyuxuan/three-things-that-broke-when-i-moved-video-compression-into-the-browser-41l8"&gt;Getting it to work at all&lt;/a&gt; was its own adventure. What I didn't appreciate until later is what it &lt;strong&gt;costs&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The Matroska output is built in the wasm heap → copied out to a &lt;code&gt;Uint8Array&lt;/code&gt; → copied again into a &lt;code&gt;File&lt;/code&gt; → and then mediabunny accumulates the whole MP4 output in a buffer on top of that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Four resident near-source-sized copies&lt;/strong&gt;, against the plain transcode's zero — that path mounts the input through the filesystem shim, and its output is smaller than its input.&lt;/p&gt;

&lt;p&gt;So the fast path has a ceiling: 256 MiB of source on the 8 GB that nearly every desktop Chrome reports. Above it we simply transcode with ffmpeg, exactly as before remux-first existed.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;An optimisation that declines to apply is fine. An out-of-memory crash on a file that used to work is not.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And if you budget against that number, know this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;navigator.deviceMemory&lt;/code&gt; caps its reported value at 8.&lt;/strong&gt; A 64 GB workstation and an 8 GB laptop are indistinguishable through that API. Budgets keyed to it are keyed to a number that stops rising.&lt;/p&gt;




&lt;h2&gt;
  
  
  The second bug that only a real browser found
&lt;/h2&gt;

&lt;p&gt;Last time it was an AVI that came out as 1151 bytes — loud, obvious once you looked. This one was the opposite, and I think it's the more instructive shape.&lt;/p&gt;

&lt;p&gt;There's a harness that drives a corpus of real files through the full pipeline in an actual Chromium, pulls the output back out, inspects it with &lt;code&gt;ffprobe&lt;/code&gt;, and observes which route each file took by stubbing the analytics sink the page already calls.&lt;/p&gt;

&lt;p&gt;It earned its keep on a regression that both code review and unit tests missed.&lt;/p&gt;

&lt;p&gt;The ffmpeg probe parses ffmpeg's own log output to learn what streams a file contains. It had begun reading ffmpeg's description of &lt;strong&gt;its own output stream&lt;/strong&gt; as though it were an input stream.&lt;/p&gt;

&lt;p&gt;Nothing threw. Nothing failed a test.&lt;/p&gt;

&lt;p&gt;AVI, FLV and WMV silently stopped qualifying for the remux-first fast path and quietly fell back to a full wasm transcode — the failure mode being that &lt;strong&gt;everything still worked, just several times slower&lt;/strong&gt;, on exactly the formats the optimisation existed for.&lt;/p&gt;

&lt;p&gt;That's the shape of bug that survives code review indefinitely. No exception, no red test, no error rate. Just a fast path that stopped being taken.&lt;/p&gt;




&lt;h2&gt;
  
  
  If you're building this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Use WebCodecs when you can.&lt;/strong&gt; The speed difference isn't marginal and it grows with file length.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;But budget for the fallback properly&lt;/strong&gt;, rather than treating it as a rare edge. Containers WebCodecs can't parse, codecs it can't decode (&lt;code&gt;AudioDecoder&lt;/code&gt; has no AC-3, MP2 or WMA), and browsers that don't ship it are collectively common enough that the fallback is a feature, not a safety net.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spend the effort on routing rather than on picking a winner.&lt;/strong&gt; The two paths that are neither engine are where a meaningful share of the wins are, and neither shows up if you've framed the problem as a choice between two encoders.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And test in a browser.&lt;/strong&gt; Both bugs — the loud one and the silent one — were found by running real files through real Chromium and inspecting what came out. Neither broke a unit test.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Measurements from &lt;a href="https://videocompress.dev" rel="noopener noreferrer"&gt;VideoCompress&lt;/a&gt;, a browser-based video compressor — the files never leave the tab, which is why all of this had to work client-side in the first place.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>performance</category>
      <category>webassembly</category>
    </item>
    <item>
      <title>Three things that surprised me shipping a 200-line npm package</title>
      <dc:creator>mengyuxuan</dc:creator>
      <pubDate>Mon, 07 Sep 2026 01:14:20 +0000</pubDate>
      <link>https://dev.to/mengyuxuan/three-things-that-surprised-me-shipping-a-200-line-npm-package-56a9</link>
      <guid>https://dev.to/mengyuxuan/three-things-that-surprised-me-shipping-a-200-line-npm-package-56a9</guid>
      <description>&lt;p&gt;I published a small thing last week — &lt;a href="https://www.npmjs.com/package/video-bitrate-calculator" rel="noopener noreferrer"&gt;video-bitrate-calculator&lt;/a&gt;, a zero-dependency library that works out what video bitrate fits under a target file size. About 200 lines of arithmetic.&lt;/p&gt;

&lt;p&gt;The code was the easy part. Shipping it surfaced three failures that all had the same shape: &lt;strong&gt;the tool told me something, the something was misleading, and the correct response was the opposite of the obvious one.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. npm said my bin was "invalid and removed". It was neither.
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;npm publish&lt;/code&gt;, and this scrolls past:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;npm warn publish "bin[video-bitrate-calculator]" script name src/cli.js was invalid and removed
npm warn publish "bin[vbc]" script name src/cli.js was invalid and removed
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both CLI entry points, gone. That's the headline feature of the package — &lt;code&gt;npx video-bitrate-calculator discord 90s&lt;/code&gt; is the first line of the README. I hit Ctrl+C.&lt;/p&gt;

&lt;p&gt;My &lt;code&gt;package.json&lt;/code&gt; had:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"bin"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"video-bitrate-calculator"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./src/cli.js"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"vbc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./src/cli.js"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing wrong with that. &lt;code&gt;./&lt;/code&gt; prefixes are normal. So I went and read npm's own normalizer, &lt;code&gt;@npmcli/package-json/lib/normalize.js&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;binTarget&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;secureAndUnixifyPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bin&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;binKey&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;   &lt;span class="c1"&gt;// "./src/cli.js" → "src/cli.js"&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;binTarget&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bin&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;binKey&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`"bin[&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;]" script name &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;binTarget&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; was invalid and removed`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bin&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;binTarget&lt;/span&gt;     &lt;span class="c1"&gt;// ← assigned anyway&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The assignment runs unconditionally. &lt;strong&gt;The warning fires whenever the path is &lt;em&gt;normalized&lt;/em&gt;, and describes a deletion that never happens.&lt;/strong&gt; Stripping a leading &lt;code&gt;./&lt;/code&gt; is enough to trigger it.&lt;/p&gt;

&lt;p&gt;I confirmed it by running npm's own normalizer over my package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;bin&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;after&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;normalize:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"video-bitrate-calculator"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"src/cli.js"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"vbc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"src/cli.js"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both entries alive. The publish would have been fine.&lt;/p&gt;

&lt;p&gt;The fix is to write the normalized form yourself — &lt;code&gt;"src/cli.js"&lt;/code&gt;, no &lt;code&gt;./&lt;/code&gt; — so the warning never appears. Not because the warning was right, but because nobody should have to read npm's source to dismiss it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson isn't "ignore npm warnings".&lt;/strong&gt; It's that a warning claiming destructive behaviour deserves 60 seconds of verification before you trust &lt;em&gt;or&lt;/em&gt; dismiss it. I nearly published without checking; I also nearly rewrote half my package layout to appease something that wasn't broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. My tests silently didn't run on two Node versions
&lt;/h2&gt;

&lt;p&gt;I used Node's built-in test runner, no framework:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node --test &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;test/*.test.js&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Green locally. First CI run, Node 22 passed and Node 18 and 20 failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Could not find '/home/runner/work/.../test/*.test.js'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The quotes are the bug. They stop the &lt;em&gt;shell&lt;/em&gt; from expanding the glob, so Node receives the literal string &lt;code&gt;test/*.test.js&lt;/code&gt;. Newer Node expands that itself. Node 18 and 20 don't — they look for a file with an asterisk in its name, find nothing, and exit.&lt;/p&gt;

&lt;p&gt;What makes this worth writing down is the failure mode one Node version over. &lt;strong&gt;On Node 22+, the runner expands the pattern itself — and a pattern that matches nothing is not an error.&lt;/strong&gt; &lt;code&gt;node --test "test/*.test.js"&lt;/code&gt; in a tree where that matches zero files prints &lt;code&gt;tests 0&lt;/code&gt; and exits 0. Green CI, no coverage, no signal. Node 18 and 20 were doing me a favour by failing loudly.&lt;/p&gt;

&lt;p&gt;The fix was not better quoting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node --test"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bare &lt;code&gt;node --test&lt;/code&gt; discovers the test directory itself, consistently, on every version that still gets security patches. I also dropped Node 18 from the matrix — it went EOL in April 2025, and keeping it was buying compatibility debt with no user attached to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never let your test command's meaning depend on which shell expands it.&lt;/strong&gt; Hand the discovery to the runner.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. A registry mirror redirected my publish, quietly
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;npm login&lt;/code&gt; opened a signup page for a registry I'd never heard of, showing:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Public registration is not allowed&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;My &lt;code&gt;~/.npmrc&lt;/code&gt;, set up years ago for faster installs in China:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;https://registry.npmmirror.com&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That mirror is &lt;strong&gt;read-only&lt;/strong&gt;. Installs work beautifully. Publishing silently goes to a host that will never accept it — and the failure surfaces as a confusing signup wall, not as "you cannot publish here".&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;--registry&lt;/code&gt; flag fixes the immediate problem, but it fixes it on &lt;em&gt;my&lt;/em&gt; machine, for &lt;em&gt;this&lt;/em&gt; invocation. The durable fix belongs in the package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"publishConfig"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"registry"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://registry.npmjs.org"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"access"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"public"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The publish destination is a property of the package, not of whoever runs the command.&lt;/strong&gt; With that in place, a contributor behind any mirror — or CI with an inherited &lt;code&gt;.npmrc&lt;/code&gt; — publishes to the right place without knowing this trap exists. If you maintain a public package and haven't set &lt;code&gt;publishConfig&lt;/code&gt;, you're relying on every future publisher having a clean config.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;All three were the same mistake in different clothes: &lt;strong&gt;I read a message instead of verifying a state.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;npm said "removed" → I should have checked whether &lt;code&gt;bin&lt;/code&gt; was actually gone. It wasn't.&lt;/li&gt;
&lt;li&gt;CI said "passed" on one version → I should have checked whether tests &lt;em&gt;ran&lt;/em&gt; on the others. They didn't.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;npm login&lt;/code&gt; showed a signup page → I should have checked which registry I was talking to. Not the one I thought.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each check took under a minute. Each message, taken at face value, pointed the wrong way — one toward unnecessary work, one toward false confidence, one toward confusion.&lt;/p&gt;

&lt;p&gt;The tooling is not lying to you. It is describing its own internals in language that sounds like it's describing yours.&lt;/p&gt;




&lt;p&gt;The package that surfaced all this: &lt;a href="https://github.com/mengyuxuan2012/video-bitrate-calculator" rel="noopener noreferrer"&gt;video-bitrate-calculator&lt;/a&gt; — &lt;code&gt;size = bitrate × duration&lt;/code&gt;, plus the safety margins that stop your file from missing a hard upload limit by 40 KB. Zero dependencies, MIT.&lt;/p&gt;

&lt;p&gt;It came out of &lt;a href="https://videocompress.dev" rel="noopener noreferrer"&gt;videocompress.dev&lt;/a&gt;, a video compressor that runs entirely in the browser. Same arithmetic, extracted and made standalone.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>node</category>
      <category>npm</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Three things that broke when I moved video compression into the browser</title>
      <dc:creator>mengyuxuan</dc:creator>
      <pubDate>Wed, 19 Aug 2026 02:41:10 +0000</pubDate>
      <link>https://dev.to/mengyuxuan/three-things-that-broke-when-i-moved-video-compression-into-the-browser-41l8</link>
      <guid>https://dev.to/mengyuxuan/three-things-that-broke-when-i-moved-video-compression-into-the-browser-41l8</guid>
      <description>&lt;p&gt;I run &lt;a href="https://videocompress.dev/" rel="noopener noreferrer"&gt;a video compressor that works entirely in the browser&lt;/a&gt;.&lt;br&gt;
No upload, no server, nothing leaves the machine. Two of the three bugs below&lt;br&gt;
only showed up when I stopped reading code and started timing things, so I want&lt;br&gt;
to write them down while the numbers are still in front of me.&lt;/p&gt;

&lt;p&gt;The pipeline has three paths. If the file already meets the target, copy the&lt;br&gt;
encoded samples and encode nothing. If the browser can decode the container,&lt;br&gt;
transcode with WebCodecs. Otherwise fall back to ffmpeg.wasm, which is roughly&lt;br&gt;
an order of magnitude slower. mediabunny handles the demux and remux.&lt;/p&gt;

&lt;h2&gt;
  
  
  AVI to Matroska with -c copy produces a 1151 byte file
&lt;/h2&gt;

&lt;p&gt;The fast path needs mediabunny to be able to read the container. It cannot read&lt;br&gt;
AVI, but plenty of AVI files carry H.264 inside, so the plan was to rewrap&lt;br&gt;
losslessly and stay on the fast path:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ffmpeg -i in.avi -map 0:v:0 -map 0:a? -c copy \
       -avoid_negative_ts make_zero out.mkv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;That fails. Not slowly, not subtly:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[matroska] Timestamps are unset in a packet for stream 0
[matroska] Can't write packet with unknown timestamp
[out#0/matroska] Error muxing a packet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;AVI has no per-packet timestamps. It stores a fixed frame rate plus an index and&lt;br&gt;
lets the demuxer work them out. Matroska requires timestamps on every block, so&lt;br&gt;
the copy has nothing to write. What comes out is a 1151 byte header with zero&lt;br&gt;
clusters, which is a valid Matroska file containing no media.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;-avoid_negative_ts&lt;/code&gt; does not help, and it took me longer than it should have to&lt;br&gt;
see why: the timestamps are not negative, they are absent. Two different&lt;br&gt;
problems that produce similar looking errors.&lt;/p&gt;

&lt;p&gt;The fix is one flag on the input side:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ffmpeg -fflags +genpts -i in.avi -map 0:v:0 -map 0:a? -c copy \
       -avoid_negative_ts make_zero out.mkv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;code&gt;+genpts&lt;/code&gt; synthesises presentation timestamps from the stream's frame rate.&lt;br&gt;
Output went from 1151 bytes to 55.8 MB on a 20 second 1080p30 clip, all 600&lt;br&gt;
frames decoding cleanly.&lt;/p&gt;

&lt;p&gt;Then I measured it against a control. Same geometry, same duration, same target,&lt;br&gt;
but Xvid instead of H.264 so it takes the ffmpeg path:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;H.264 AVI, rewrap then WebCodecs   7.5 s    53.3 MB to 12.3 MB
Xvid AVI, ffmpeg.wasm             53.4 s    48.2 MB to 12.5 MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Output sizes within 2 percent, wall clock 7x apart. Both numbers come from&lt;br&gt;
headless Chromium with software encoding, so a real machine with a hardware&lt;br&gt;
encoder should do better than this.&lt;/p&gt;

&lt;p&gt;One more thing worth doing: ffmpeg.wasm resolves with the exit code instead of&lt;br&gt;
rejecting, and a muxer that dies mid run still leaves a readable file behind. A&lt;br&gt;
truncated remux looks exactly like a complete one unless you check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every AAC track defeats mediabunny's copy path, by design
&lt;/h2&gt;

&lt;p&gt;The passthrough case is supposed to touch nothing. Give mediabunny an empty&lt;br&gt;
video config and it copies encoded samples straight through, since&lt;br&gt;
&lt;code&gt;forceTranscode&lt;/code&gt; defaults to false.&lt;/p&gt;

&lt;p&gt;It worked for video and not for audio. A 3.3 MB source came back at 3.6 MB, so&lt;br&gt;
I parsed the output boxes:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;source   moov 33,948   mdat 3,432,287
output   moov 17,879   mdat 3,788,908
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The moov got smaller. The growth was all in mdat. Per track:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;avc1   900 samples   3,071,520 bytes
mp4a  1295 samples     717,380 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Video was byte identical to the source. Audio was double: 717 KB against the&lt;br&gt;
source's 360 KB, about 191 kbps from a 96 kbps original.&lt;/p&gt;

&lt;p&gt;The reason is in mediabunny's copy conditions. The fast path requires, among&lt;br&gt;
other things, &lt;code&gt;!needsTrimming&lt;/code&gt;, where &lt;code&gt;needsTrimming&lt;/code&gt; is&lt;br&gt;
&lt;code&gt;firstTimestamp &amp;lt; startTimestamp&lt;/code&gt;. I asked the library what it saw:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;video codec: avc   firstTimestamp: 0
audio codec: aac   firstTimestamp: -0.023219954648526078
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Negative. And 0.0232 seconds at 44100 Hz is 1024 samples, which is exactly one&lt;br&gt;
AAC frame. That is encoder priming delay, and every AAC track produced by any&lt;br&gt;
normal encoder has it. So the audio copy path is not occasionally unavailable,&lt;br&gt;
it is never available.&lt;/p&gt;

&lt;p&gt;You cannot fix this from the outside. Passing a bitrate to keep the size down&lt;br&gt;
forces a transcode by itself, because &lt;code&gt;!trackOptions.bitrate&lt;/code&gt; is one of the&lt;br&gt;
copy conditions. Passing nothing lets the library re-encode at its own default.&lt;/p&gt;

&lt;p&gt;I went with a floor instead: if a passthrough would return more bytes than it&lt;br&gt;
received, and the source is already MP4, hand back the source untouched.&lt;br&gt;
Verified byte exact, 3,466,275 in and 3,466,275 out where it used to be&lt;br&gt;
3,806,815. For a file that was already close to optimal, "nothing needed doing"&lt;br&gt;
is a more honest answer than a 10 percent larger file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The multithreaded ffmpeg core costs you your ad revenue
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@ffmpeg/core-mt&lt;/code&gt; is 4 to 8 times faster than the single threaded core. It needs&lt;br&gt;
SharedArrayBuffer, which needs cross origin isolation, which means sending&lt;br&gt;
COOP and COEP headers.&lt;/p&gt;

&lt;p&gt;COEP: require-corp breaks third party embeds that do not opt in. On a site&lt;br&gt;
funded by AdSense that is not a technical tradeoff, it is a revenue decision.&lt;br&gt;
I stayed single threaded and put the effort into not reaching for ffmpeg at all,&lt;br&gt;
which is what the rewrap path above is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would take away from this
&lt;/h2&gt;

&lt;p&gt;Both real bugs were invisible to code review and to unit tests. The AVI failure&lt;br&gt;
had a passing test suite and a plausible looking implementation sitting on top&lt;br&gt;
of it. What found it was dragging an actual AVI file into an actual browser and&lt;br&gt;
noticing that a 54 MB input had produced 1151 bytes.&lt;/p&gt;

&lt;p&gt;If you want to try it on something awkward, the two cases I get asked about most&lt;br&gt;
have their own pages: &lt;a href="https://videocompress.dev/compress-mp4-for-discord" rel="noopener noreferrer"&gt;compress video for Discord&lt;/a&gt;&lt;br&gt;
if you are fighting the 10 MB free tier limit, and&lt;br&gt;
&lt;a href="https://videocompress.dev/compress-video-to-10mb" rel="noopener noreferrer"&gt;compress video to 10 MB&lt;/a&gt; if the&lt;br&gt;
cap is the whole problem. Both run on your own machine.&lt;/p&gt;

</description>
      <category>video</category>
      <category>compress</category>
      <category>compressor</category>
    </item>
    <item>
      <title>I built a video compressor with zero backend — your files never leave the tab</title>
      <dc:creator>mengyuxuan</dc:creator>
      <pubDate>Wed, 22 Jul 2026 08:07:50 +0000</pubDate>
      <link>https://dev.to/mengyuxuan/i-built-a-video-compressor-with-zero-backend-your-files-never-leave-the-tab-13b6</link>
      <guid>https://dev.to/mengyuxuan/i-built-a-video-compressor-with-zero-backend-your-files-never-leave-the-tab-13b6</guid>
      <description>&lt;p&gt;Drop a 200 MB screen recording into most "free video compressor" sites and here's what actually happens: the file uploads to someone's server, gets transcoded there, and you download the result. Your video sat on a stranger's disk. The site paid for bandwidth and CPU, so it pays that back with aggressive ads or a paywall at 3 files a day.&lt;/p&gt;

&lt;p&gt;I wanted the opposite: the compression runs &lt;strong&gt;in your browser tab&lt;/strong&gt;, the file never leaves your machine, and because there's no server doing the work, the whole thing is genuinely free to run.&lt;/p&gt;

&lt;p&gt;That's &lt;a href="https://videocompress.dev" rel="noopener noreferrer"&gt;videocompress.dev&lt;/a&gt;. This post is how it works under the hood.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core constraint: no upload, ever
&lt;/h2&gt;

&lt;p&gt;The entire architecture falls out of one rule — &lt;strong&gt;the video bytes stay client-side&lt;/strong&gt;. That immediately kills the "rent a beefy transcoding server" design and forces the work into the browser. Two APIs can do it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;WebCodecs&lt;/strong&gt; — a low-level browser API that talks to the OS's hardware video encoder/decoder. Fast, uses almost no CPU, native-quality H.264.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ffmpeg.wasm&lt;/strong&gt; — the full FFmpeg compiled to WebAssembly. Works literally everywhere, but it's a software encoder running in a WASM sandbox, so it's slower.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;WebCodecs is the better experience when it's available. It isn't always. So the app probes the browser and picks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking an engine at runtime
&lt;/h2&gt;

&lt;p&gt;The selection logic is deliberately boring — a pure function that's trivial to unit test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;CompressionEngine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;webcodecs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ffmpeg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;EngineCaps&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;hasWebCodecsApi&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// VideoEncoder + VideoDecoder exist&lt;/span&gt;
  &lt;span class="nl"&gt;inputReadable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// we can actually demux + decode this file&lt;/span&gt;
  &lt;span class="nl"&gt;canEncodeTarget&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// the target H.264 config is encodable here&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decideEngine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;caps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;EngineCaps&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;CompressionEngine&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;caps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hasWebCodecsApi&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;caps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inputReadable&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;caps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;canEncodeTarget&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;webcodecs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ffmpeg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important detail is the third capability. Having the WebCodecs &lt;em&gt;API&lt;/em&gt; isn't enough — the browser also has to be able to demux the specific container you dropped in and encode the target config. So before committing, the app asks &lt;a href="https://www.npmjs.com/package/mediabunny" rel="noopener noreferrer"&gt;&lt;code&gt;mediabunny&lt;/code&gt;&lt;/a&gt; whether it can read the input's primary video track and whether &lt;code&gt;VideoEncoder.isConfigSupported()&lt;/code&gt; accepts the output config.&lt;/p&gt;

&lt;p&gt;Any &lt;code&gt;false&lt;/code&gt;, anything unprobed, anything that throws → fall back to ffmpeg.wasm. WebCodecs is the fast path you &lt;em&gt;earn&lt;/em&gt; by passing every check; ffmpeg is the floor that always catches you. Users on the happy path get hardware speed; everyone else still gets a working compressor. Nobody gets a broken page.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual "compression": it's bitrate math
&lt;/h2&gt;

&lt;p&gt;"Compress this to 25 MB for email" sounds like a quality knob, but under the hood it's arithmetic. A video file's size is roughly &lt;code&gt;bitrate × duration&lt;/code&gt;. If you know the target size and the duration, you can solve for the bitrate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AUDIO_KBPS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TARGET_SIZE_SAFETY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.94&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// aim slightly under so we don't overshoot&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;computeTargetVideoKbps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;targetSizeMB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;durationSec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;removeAudio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;targetSizeMB&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;durationSec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;removeAudio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;audioKbps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;removeAudio&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AUDIO_KBPS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;safeTargetMB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;targetSizeMB&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;TARGET_SIZE_SAFETY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;videoKbps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;safeTargetMB&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;8192&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;durationSec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;audioKbps&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;videoKbps&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things I learned the hard way and baked in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The 6% safety margin (&lt;code&gt;0.94&lt;/code&gt;).&lt;/strong&gt; Encoders don't hit a bitrate target exactly; they hover around it. If you aim for exactly 25 MB you'll ship 26 and blow the email limit. Aiming for ~23.5 lands under it reliably.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subtract the audio budget first.&lt;/strong&gt; The size cap is for the &lt;em&gt;whole file&lt;/em&gt;. Spend 128 kbps on audio and the video has to fit in what's left — otherwise your "target size" mode quietly ignores the audio track and overshoots.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The other mode is quality-based, which maps straight to H.264's CRF (constant rate factor):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;QUALITY_CRF&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;high&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;balanced&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;23&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;small&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;28&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lower CRF = better quality + bigger file. 23 is a genuinely good default for most footage; 28 is where you go when "smaller" matters more than "pristine."&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "no server" changes the whole economics
&lt;/h2&gt;

&lt;p&gt;This is the part I think other devs will appreciate. Because the transcode happens in the tab:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hosting is basically free.&lt;/strong&gt; The app is 100% static — it's just HTML, JS, and a WASM blob on a CDN. No transcoding boxes, no per-video cost, no scaling worries. A traffic spike costs bandwidth on static assets, nothing more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privacy is architectural, not a promise.&lt;/strong&gt; Plenty of sites &lt;em&gt;say&lt;/em&gt; "we delete your files." Here there's nothing to delete because nothing was ever sent. Open DevTools → Network tab and compress a file: zero upload requests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It works offline&lt;/strong&gt; once the page and core are cached.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The one gotcha: &lt;code&gt;@ffmpeg/core-mt&lt;/code&gt; (the multithreaded build) needs &lt;code&gt;SharedArrayBuffer&lt;/code&gt;, which needs cross-origin isolation (COOP/COEP headers), which breaks a lot of third-party embeds. I chose the &lt;strong&gt;single-thread&lt;/strong&gt; &lt;code&gt;@ffmpeg/core&lt;/code&gt; on purpose — slightly slower, but no header gymnastics and full compatibility with everything else on the page. Know your tradeoff before you reach for the MT build.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;p&gt;Nothing exotic, deliberately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js 16&lt;/strong&gt; (App Router) + &lt;strong&gt;React 19&lt;/strong&gt; + &lt;strong&gt;TypeScript&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tailwind CSS 4&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@ffmpeg/ffmpeg&lt;/code&gt; 0.12&lt;/strong&gt; for the fallback path, &lt;strong&gt;&lt;code&gt;mediabunny&lt;/code&gt;&lt;/strong&gt; for WebCodecs demux/probing&lt;/li&gt;
&lt;li&gt;Static export — every page is SSG, deployable to Vercel / Cloudflare Pages / Netlify free tiers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The compression parameter functions (the bitrate/CRF math above) are pure and covered by &lt;code&gt;node --test&lt;/code&gt; unit tests. When the thing that matters is "did we compute the right bitrate," you want that logic testable without spinning up a browser or a real encoder.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it / read it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The tool: &lt;strong&gt;&lt;a href="https://videocompress.dev" rel="noopener noreferrer"&gt;videocompress.dev&lt;/a&gt;&lt;/strong&gt; — drop a file in, pick a target size or quality, download. No account, no upload, no watermark.&lt;/li&gt;
&lt;li&gt;If you're building something similar, the WebCodecs-with-ffmpeg-fallback pattern and the bitrate math are the two pieces worth stealing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you end up shipping in-browser media processing, I'd genuinely like to hear how you handled the engine-selection edge cases — the "API exists but can't encode &lt;em&gt;this&lt;/em&gt;" gap is where most of the real bugs live. Drop a comment.&lt;/p&gt;

</description>
      <category>compress</category>
      <category>video</category>
      <category>free</category>
    </item>
  </channel>
</rss>
