<?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: John Tian</title>
    <description>The latest articles on DEV Community by John Tian (@john_tian_4ad000bd7bfe7dd).</description>
    <link>https://dev.to/john_tian_4ad000bd7bfe7dd</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%2F4146433%2F87708a39-b383-4126-a8bc-dbb193c3d644.jpg</url>
      <title>DEV Community: John Tian</title>
      <link>https://dev.to/john_tian_4ad000bd7bfe7dd</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/john_tian_4ad000bd7bfe7dd"/>
    <language>en</language>
    <item>
      <title>How I built a video compressor that hits an exact file size — 100% in the browser</title>
      <dc:creator>John Tian</dc:creator>
      <pubDate>Fri, 02 Oct 2026 05:14:06 +0000</pubDate>
      <link>https://dev.to/john_tian_4ad000bd7bfe7dd/how-i-built-a-video-compressor-that-hits-an-exact-file-size-100-in-the-browser-3hd6</link>
      <guid>https://dev.to/john_tian_4ad000bd7bfe7dd/how-i-built-a-video-compressor-that-hits-an-exact-file-size-100-in-the-browser-3hd6</guid>
      <description>&lt;p&gt;Ever needed a video to be &lt;em&gt;exactly&lt;/em&gt; under 25 MB for a Gmail attachment, or exactly 20 MB for Discord? Most "compressors" give you a quality slider and wish you luck. I built &lt;a href="https://vidsizer.com" rel="noopener noreferrer"&gt;VidSizer&lt;/a&gt; to do the opposite: you type the target size, it computes the exact bitrate needed, and the output lands under it. No upload, no server, no account — the whole encode runs in your browser with FFmpeg.wasm.&lt;/p&gt;

&lt;p&gt;Here are the engineering lessons that surprised me.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Exact size is just arithmetic — if you're honest about overhead
&lt;/h2&gt;

&lt;p&gt;The core formula is trivial:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;total_bitrate = target_bytes × 8 / duration_seconds
video_bitrate = total_bitrate − audio_bitrate − container_overhead
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The devil is in the honesty:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Container overhead isn't zero.&lt;/strong&gt; MP4 muxing eats ~1–2%. I plan with a 6% safety margin: aim for 94% of the target. After one encode I measure the real output; if it's slightly over, I adjust and re-encode once more (max 2 passes).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audio is a fixed tax.&lt;/strong&gt; 128k audio on a 10 MB target is a rounding error; on a 1 MB target it's a quarter of your budget. So I use an audio ladder: 128k → 96k → 64k as the budget shrinks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolution is the real lever.&lt;/strong&gt; Dropping 1080p → 360p cuts the pixels by 9×. Nobody wants to admit it, but for a hard size target, resolution beats bitrate fiddling every time. I use a resolution ladder (1080p → 720p → 480p → 360p) and pick the highest rung whose bitrate budget still gives decent quality — then &lt;em&gt;tell the user&lt;/em&gt; what they'll get before encoding ("Planned output: 360p · 0.8 Mbps — expect softer detail").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail fast on impossible targets.&lt;/strong&gt; If the math says even 360p + 64k audio can't fit, don't burn 5 minutes encoding — say so up front and suggest a bigger target. Users respect honesty more than a spinning progress bar.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. The 25 MB wall: Cloudflare Pages won't serve your .wasm
&lt;/h2&gt;

&lt;p&gt;FFmpeg.wasm's core is ~31 MB. Cloudflare Pages refuses to deploy any single file over 25 MiB. The fix: split the wasm into two chunks at build time, deploy both, and reassemble in the browser:&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;p0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/vendor/ffmpeg-core.wasm.part00&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
  &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/vendor/ffmpeg-core.wasm.part01&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;()),&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;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;p0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/wasm&lt;/span&gt;&lt;span class="dl"&gt;'&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;coreURL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Feed &lt;code&gt;coreURL&lt;/code&gt; to FFmpeg as the core. Works for single-threaded and multi-threaded cores alike.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The worker cross-origin trap
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@ffmpeg/ffmpeg&lt;/code&gt; (the JS wrapper) loads fine from a CDN — but then it spawns its &lt;em&gt;worker&lt;/em&gt; from the CDN origin, and any relative &lt;code&gt;coreURL&lt;/code&gt; you pass gets resolved against &lt;code&gt;cdn.jsdelivr.net&lt;/code&gt; instead of your domain. Your self-hosted wasm 404s and you get a cryptic load failure.&lt;/p&gt;

&lt;p&gt;Fix: self-host the wrapper's &lt;code&gt;dist/esm&lt;/code&gt; files too (I copied them to &lt;code&gt;/vendor/lib&lt;/code&gt;), so wrapper and worker share your origin. While I was at it, I dropped the &lt;code&gt;@ffmpeg/util&lt;/code&gt; dependency entirely — v0.12 of the wrapper doesn't export &lt;code&gt;fetchFile&lt;/code&gt; anymore, so I wrote a 10-line local replacement.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Multi-threading: fast on paper, treacherous in practice
&lt;/h2&gt;

&lt;p&gt;The multi-threaded core (&lt;code&gt;@ffmpeg/core-mt&lt;/code&gt;) needs &lt;code&gt;crossOriginIsolated&lt;/code&gt; — i.e. &lt;code&gt;Cross-Origin-Opener-Policy: same-origin&lt;/code&gt; + &lt;code&gt;Cross-Origin-Embedder-Policy: credentialless&lt;/code&gt; headers on every response. I set those in Cloudflare Pages' &lt;code&gt;_headers&lt;/code&gt; file, and it works: ~3–4× faster on a real machine.&lt;/p&gt;

&lt;p&gt;But in a headless VM it hung at 0% progress with no error — twice. No logs, no crash, just silence. My takeaway: &lt;strong&gt;ship single-threaded as the default and gate multi-threading behind a flag&lt;/strong&gt; (&lt;code&gt;?mt=1&lt;/code&gt;) until you've validated it on real hardware. A slow success beats a fast hang. (Also: cap x264 threads at 8 — more threads on more cores gave diminishing returns and spikier memory.)&lt;/p&gt;

&lt;h2&gt;
  
  
  5. If the file is already under target, don't encode
&lt;/h2&gt;

&lt;p&gt;Obvious in retrospect: check &lt;code&gt;input.size &amp;lt;= target&lt;/code&gt; first and skip encoding entirely — just hand back the original file. It also makes for a great demo moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The 200 MB honesty note
&lt;/h2&gt;

&lt;p&gt;Browser encoding has a memory wall. Files over ~200 MB &lt;em&gt;can&lt;/em&gt; work in desktop Chrome but may be slow or OOM on weaker machines. I show a banner for big files instead of pretending everything is fine. Users forgive a warning; they don't forgive a crashed tab.&lt;/p&gt;

&lt;p&gt;The whole thing is a static site — zero server cost, which is the only reason a free unlimited tool like this is sustainable. If you want to try it: &lt;a href="https://vidsizer.com" rel="noopener noreferrer"&gt;vidsizer.com&lt;/a&gt; — pick any exact size, it hits it.&lt;/p&gt;

&lt;p&gt;Happy to answer questions about FFmpeg.wasm, the bitrate math, or the Pages deployment hacks.&lt;/p&gt;

</description>
      <category>ffmpeg</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
