<?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: Max/Wang</title>
    <description>The latest articles on DEV Community by Max/Wang (@maxslashwang).</description>
    <link>https://dev.to/maxslashwang</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%2F2255922%2Fdd90580a-3288-48c4-8cc2-aa2bf7ab9671.jpg</url>
      <title>DEV Community: Max/Wang</title>
      <link>https://dev.to/maxslashwang</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/maxslashwang"/>
    <language>en</language>
    <item>
      <title>My browser video compressor choked on a 2.2 GB AVI. Here's why</title>
      <dc:creator>Max/Wang</dc:creator>
      <pubDate>Mon, 05 Oct 2026 02:52:52 +0000</pubDate>
      <link>https://dev.to/maxslashwang/my-browser-video-compressor-choked-on-a-22-gb-avi-heres-why-on8</link>
      <guid>https://dev.to/maxslashwang/my-browser-video-compressor-choked-on-a-22-gb-avi-heres-why-on8</guid>
      <description>&lt;p&gt;I run SquishyFile, a video compressor that works entirely in the browser. Most files go through WebCodecs with Mediabunny. Files the browser can't decode natively, like AVI, MPG, MTS and FLV, fall back to ffmpeg.wasm. The same fallback kicks in when a browser has no working H.264 encoder.&lt;/p&gt;

&lt;p&gt;Last week a user sent me a bug report. A 2.24 GB .avi failed in Chromium with my generic "Something went wrong" message, before encoding even started. They had already traced it to one line in my code, and they were right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ffmpeg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;file&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Almost every ffmpeg.wasm example I found online uses this pattern, and I copied it without thinking. It does two expensive things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;file.arrayBuffer()&lt;/code&gt; reads the entire file into one JavaScript buffer. In Chromium that read fails at just under 2 GiB with &lt;code&gt;NotReadableError&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;writeFile&lt;/code&gt; then copies those bytes into MEMFS, ffmpeg's in-memory filesystem. MEMFS lives on the wasm heap, and a 32-bit wasm module can't address more than 4 GB in total. The input, the output and the codec buffers all fight for that space.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So even if step 1 passed, step 2 would hit a wall soon after. My probe step, which runs &lt;code&gt;ffmpeg -i&lt;/code&gt; to read duration and resolution, used the same line. That's why the error showed up before any encoding.&lt;/p&gt;

&lt;p&gt;I had the same code in two other tools on the site, MP4 to audio and video to MP3. Same bug, nobody had reported it there yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  WORKERFS
&lt;/h2&gt;

&lt;p&gt;Emscripten ships a filesystem called WORKERFS. Instead of copying a File into memory, it mounts the File object and reads slices from it on demand with &lt;code&gt;FileReaderSync&lt;/code&gt; inside the worker. ffmpeg sees a normal path and seeks around in it as usual. @ffmpeg/ffmpeg 0.12 exposes it through &lt;code&gt;ffmpeg.mount()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Before switching, I checked that the core build I load (@ffmpeg/core 0.12.10) actually includes WORKERFS. Emscripten only bundles it when the build asks for it, so a quick grep through &lt;code&gt;ffmpeg-core.js&lt;/code&gt; is worth doing. Mine had it.&lt;/p&gt;

&lt;p&gt;I wrote a small helper and pointed all six call sites at it:&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;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;FFmpeg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;FFFSType&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@ffmpeg/ffmpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;seq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;mountInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ffmpeg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FFmpeg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;File&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;baseName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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;ext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;()?.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bin&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;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;baseName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`/in&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="nx"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Wrapping a File in a new File doesn't read it. It only renames it.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;renamed&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;File&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;name&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="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ffmpeg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createDir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ffmpeg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WORKERFS&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;FFFSType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;renamed&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;release&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ffmpeg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unmount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ffmpeg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deleteDir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then each call site looks like this:&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;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;mountInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ffmpeg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;input&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ffmpeg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-i&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;output.mp4&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;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;release&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;A few details I chose on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;I rename the file.&lt;/strong&gt; WORKERFS uses &lt;code&gt;file.name&lt;/code&gt; as the filename inside the mount. User filenames have spaces, emoji and odd characters that make ffmpeg arguments annoying. &lt;code&gt;new File([file], name)&lt;/code&gt; creates a new reference to the same data, so it costs nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Each mount gets its own directory.&lt;/strong&gt; The probe and the encode share one ffmpeg instance. A counter avoids a clash if a mount from a cancelled run never got cleaned up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I pass the string instead of importing the enum.&lt;/strong&gt; &lt;code&gt;FFFSType&lt;/code&gt; is a runtime enum. I load @ffmpeg/ffmpeg lazily with a dynamic import, and importing the enum at the top of the file would pull the package into the main bundle. A type-only import plus the string value keeps it lazy.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What it doesn't fix
&lt;/h2&gt;

&lt;p&gt;The output still goes into MEMFS, because ffmpeg has to write it somewhere. For a compressor that's fine, since the output is usually a fraction of the input. A tool that makes files bigger would still hit the ceiling.&lt;/p&gt;

&lt;p&gt;It also doesn't make anything faster. I use the single-threaded core so I don't need COOP/COEP headers, and a 2 GB AVI on one thread takes a while. I'll take a slow encode that finishes over a fast "Something went wrong".&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell anyone using ffmpeg.wasm
&lt;/h2&gt;

&lt;p&gt;Search your code for &lt;code&gt;arrayBuffer()&lt;/code&gt; next to &lt;code&gt;writeFile&lt;/code&gt;. That pattern works in every test you run with a 50 MB sample and breaks the first time a real user drops in a long recording. Mount the File instead.&lt;/p&gt;

&lt;p&gt;Thanks to the person who sent such a precise bug report. If you want to try it, the compressor is at &lt;a href="https://squishyfile.com/" rel="noopener noreferrer"&gt;SquishyFile&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>showdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>I’ve been building a tool site for a little over a month, so I figured I’d share where it’s at.</title>
      <dc:creator>Max/Wang</dc:creator>
      <pubDate>Sun, 04 Oct 2026 14:31:58 +0000</pubDate>
      <link>https://dev.to/maxslashwang/ive-been-building-a-tool-site-for-a-little-over-a-month-so-i-figured-id-share-where-its-at-1b3g</link>
      <guid>https://dev.to/maxslashwang/ive-been-building-a-tool-site-for-a-little-over-a-month-so-i-figured-id-share-where-its-at-1b3g</guid>
      <description>&lt;p&gt;I’ve been building &lt;a href="https://squishyfile.com/" rel="noopener noreferrer"&gt;SquishyFile&lt;/a&gt; for a little over a month, so I figured I’d share where it’s at.&lt;/p&gt;

&lt;p&gt;I’ve seen quite a few tool sites doing surprisingly well with ads, and I came across a bunch of those cases on X. That got me thinking: why not build one myself and see where it goes, haha.&lt;/p&gt;

&lt;p&gt;So I started building a bunch of video tools and put them all into one site.&lt;/p&gt;

&lt;p&gt;Right now there are tools for things like compressing videos, converting formats, video to text, upscaling, extracting audio and frames, GIFs, filters, and a bunch of smaller stuff.&lt;/p&gt;

&lt;p&gt;I’ve been building it with a lot of help from Claude, and after a little over a month it’s doing around &lt;strong&gt;100 users/day&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The traffic is still pretty interesting though.&lt;/p&gt;

&lt;p&gt;Google isn't really sending much traffic yet, which I honestly expected to be the opposite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bing is actually sending more traffic than Google right now&lt;/strong&gt;, and a decent chunk is coming from social, mostly Threads.&lt;/p&gt;

&lt;p&gt;So I'm basically still figuring out where the traffic is going to come from.&lt;/p&gt;

&lt;p&gt;Some tools get impressions, some get almost nothing. Some keywords look like they should have traffic but don't. And Google seems to be taking its sweet time.&lt;/p&gt;

&lt;p&gt;The most fun part so far has actually been getting emails from users.&lt;/p&gt;

&lt;p&gt;A few people have emailed me because they ran into bugs, which is already really useful because I obviously can't test every possible video and browser combination myself.&lt;/p&gt;

&lt;p&gt;But some people have also emailed me just to suggest things they'd like to see added.&lt;/p&gt;

&lt;p&gt;That was honestly pretty cool.&lt;/p&gt;

&lt;p&gt;When you're building a site by yourself, you spend a lot of time staring at Analytics and Search Console, so getting an actual email from someone saying “hey, it'd be great if you added this” feels much more encouraging than another 20 users showing up in Analytics.&lt;/p&gt;

&lt;p&gt;For now I'm just going to keep adding tools, trying different ways to get traffic, and seeing what happens.&lt;/p&gt;

&lt;p&gt;Eventually I'd like to monetize it with ads. That's kind of the whole experiment for me — I've seen other tool sites make it work, so I want to see if I can make one work too.&lt;/p&gt;

&lt;p&gt;No idea how far it'll go yet, but it's been pretty fun so far.&lt;/p&gt;

&lt;p&gt;I'll post another update when something interesting happens.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>ai</category>
    </item>
    <item>
      <title>I put gifsicle in the browser to build a GIF compressor</title>
      <dc:creator>Max/Wang</dc:creator>
      <pubDate>Sun, 04 Oct 2026 13:06:13 +0000</pubDate>
      <link>https://dev.to/maxslashwang/i-put-gifsicle-in-the-browser-to-build-a-gif-compressor-2e1f</link>
      <guid>https://dev.to/maxslashwang/i-put-gifsicle-in-the-browser-to-build-a-gif-compressor-2e1f</guid>
      <description>&lt;p&gt;I run &lt;a href="https://squishyfile.com" rel="noopener noreferrer"&gt;SquishyFile&lt;/a&gt;, a small site of free video tools, and I recently added a &lt;a href="https://squishyfile.com/compress-gif" rel="noopener noreferrer"&gt;GIF compressor&lt;/a&gt; to it. It runs gifsicle, Eddie Kohler's GIF optimizer, compiled to WebAssembly inside a Web Worker. You drop in a GIF, pick a target size or a few settings, and get a smaller GIF back. All of the work happens on your own device, so there's no upload and no queue.&lt;/p&gt;

&lt;p&gt;This post covers how I picked the defaults, two bugs I hit when dropping frames, and how the tool hits a size like 256 KB when gifsicle has no flag for that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why gifsicle
&lt;/h2&gt;

&lt;p&gt;GIF compression is an old problem, and gifsicle has handled it well for a very long time. The part I cared about most is its lossy mode. GIF uses LZW, which loves long runs of identical pixels and hates noise. Lossy mode lets the encoder swap a pixel for a close neighbor color when that creates a longer run. On camera footage you see a bit of grain. On the file size you see a big drop.&lt;/p&gt;

&lt;p&gt;I used the &lt;code&gt;gifsicle-wasm-browser&lt;/code&gt; package. It ships its worker as a source string with the &lt;code&gt;.wasm&lt;/code&gt; inlined as base64, so nothing loads from a CDN at run time. The module weighs about 340 KB, and most people who open the page never press Compress, so I only import it on the first click.&lt;/p&gt;

&lt;p&gt;Before I picked defaults I ran some tests. On a 36 MB GIF (800px wide, 120 frames), &lt;code&gt;-O3&lt;/code&gt; gave me the same size as &lt;code&gt;-O2&lt;/code&gt; and took about three times longer. So every level uses &lt;code&gt;-O2&lt;/code&gt;, and only the lossy value changes: 30 for Light, 80 for Balanced, 140 for Strong. Balanced took that 36 MB file down to about 17 MB in roughly 5 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each setting buys you
&lt;/h2&gt;

&lt;p&gt;The tool has four knobs: compression level, colors, max width and frames. I ran them on a 3-second, 480×270 sample with 36 frames that starts at 4.0 MB:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Settings&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;Saved&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Light&lt;/td&gt;
&lt;td&gt;2.5 MB&lt;/td&gt;
&lt;td&gt;38%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Balanced&lt;/td&gt;
&lt;td&gt;1.9 MB&lt;/td&gt;
&lt;td&gt;53%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strong&lt;/td&gt;
&lt;td&gt;1.6 MB&lt;/td&gt;
&lt;td&gt;59%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Balanced + 64 colors&lt;/td&gt;
&lt;td&gt;1.5 MB&lt;/td&gt;
&lt;td&gt;63%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Balanced + 360px wide&lt;/td&gt;
&lt;td&gt;1.1 MB&lt;/td&gt;
&lt;td&gt;74%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strong + 64 colors + 360px + every 2nd frame&lt;/td&gt;
&lt;td&gt;343 KB&lt;/td&gt;
&lt;td&gt;92%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Balanced + 64 colors + 240px + every 2nd frame&lt;/td&gt;
&lt;td&gt;206 KB&lt;/td&gt;
&lt;td&gt;95%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Compression alone got this GIF to about half its size. To get it really small I had to cut width and frames too. Width saves the most because halving it leaves a quarter of the pixels, but it's also the change people notice first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dropping frames broke two things
&lt;/h2&gt;

&lt;p&gt;Keeping every 2nd or 3rd frame sounds simple. My first version just picked frames 0, 2, 4 and so on, and it went wrong in two ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The GIF played at double speed.&lt;/strong&gt; Each GIF frame carries its own delay. Throw away half the frames and keep the delays as they are, and a 3-second loop finishes in 1.5 seconds. It looked like fast-forward. Now each kept frame takes over the delays of the frames I drop after it:&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;keptFrames&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delaysCs&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="nx"&gt;step&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;kept&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&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;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;delaysCs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;delaysCs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;delaysCs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="nx"&gt;kept&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="na"&gt;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;delayCs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt; &lt;span class="p"&gt;});&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;kept&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;&lt;strong&gt;Frames came out half drawn.&lt;/strong&gt; Optimized GIFs, including plenty you'll find online, store most frames as a patch over the previous frame. When I picked frame 2 and skipped frame 1, frame 2 got painted over a frame that wasn't there anymore, and I got smeared, broken images. So frame dropping now runs in two passes. The first pass runs gifsicle with &lt;code&gt;-U&lt;/code&gt; to turn every frame back into a full image. The second pass picks the frames, compresses them and optimizes again.&lt;/p&gt;

&lt;p&gt;One more gifsicle detail bit me here. A &lt;code&gt;-d&lt;/code&gt; delay flag applies to every frame after it until the next &lt;code&gt;-d&lt;/code&gt;. So as soon as any kept frame has a delay, I write an explicit &lt;code&gt;-d&lt;/code&gt; in front of every kept frame, even when the value is 0.&lt;/p&gt;

&lt;p&gt;The unoptimize pass also prints "GIF too complex to unoptimize" on almost every run, even when the output is fine. I compared the results frame by frame against the source to make sure, then filtered that line out of the console so it doesn't scare anyone who opens DevTools. Real failures still show up, because they produce no output file and the promise rejects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hitting a size target
&lt;/h2&gt;

&lt;p&gt;Most people compress a GIF because something rejected it. A Discord emoji slot wants 256 KB, a chat app or forum has its own cap. So the tool has a "Fit under a size" mode with presets from 256 KB to 10 MB, plus a custom value.&lt;/p&gt;

&lt;p&gt;gifsicle can't aim at a byte budget, so the tool searches. It walks a ladder from gentle to harsh and stops at the first result that fits:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Light, Balanced, Strong&lt;/li&gt;
&lt;li&gt;Every 2nd frame&lt;/li&gt;
&lt;li&gt;128 colors, then 64&lt;/li&gt;
&lt;li&gt;Width, last&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every step is a full gifsicle run, so I didn't want to run all of them every time. I measured how much each step usually shrinks a file compared to the step before it. Balanced comes out around 0.76 of Light, Strong around 0.86 of Balanced, and dropping half the frames around 0.55. The tool uses those factors to estimate the next result and skips steps that will clearly still be too big.&lt;/p&gt;

&lt;p&gt;For width I stopped stepping down blindly. GIF size roughly follows pixel count, so the tool solves for the width it needs:&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;next&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;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;width&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;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;budget&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;measured&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.97&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that lands far under the budget, it spends one more pass on a wider version, so you don't get a GIF smaller than it had to be. It also aims at 98% of the target, because sites don't always count bytes the same way. Most fits finish in 2 to 5 passes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Smaller things
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reading the GIF.&lt;/strong&gt; I wrote a tiny parser that walks the GIF block structure to get width, height, frame count and each frame's delay. It never decodes pixels, so even a 50 MB GIF reads instantly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cancel.&lt;/strong&gt; The package's own &lt;code&gt;run()&lt;/code&gt; helper owns its worker and you can't stop it. I start a worker from the same source myself, so the Cancel button can call &lt;code&gt;terminate()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Progress.&lt;/strong&gt; gifsicle reports nothing while it works, so the progress bar runs on a time estimate. I measured about 7 MB of GIF per second and let the bar park at 95% until the worker answers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bigger output.&lt;/strong&gt; On some already tight GIFs the result comes out larger. In that case the tool tells you so and doesn't offer the bigger file as a download.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The tool lives at &lt;a href="https://squishyfile.com/compress-gif" rel="noopener noreferrer"&gt;squishyfile.com/compress-gif&lt;/a&gt;. If you have a GIF that comes out weird or barely shrinks, send it my way. Those are the ones I learn the most from.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>typescript</category>
      <category>showdev</category>
    </item>
    <item>
      <title>How I Made a Simple Browser-Based Video Compressor for Discord</title>
      <dc:creator>Max/Wang</dc:creator>
      <pubDate>Sun, 27 Sep 2026 14:14:47 +0000</pubDate>
      <link>https://dev.to/maxslashwang/how-i-made-a-simple-browser-based-video-compressor-for-discord-2f48</link>
      <guid>https://dev.to/maxslashwang/how-i-made-a-simple-browser-based-video-compressor-for-discord-2f48</guid>
      <description>&lt;p&gt;I kept running into the same stupid problem: a video is just a few MB too large to upload.&lt;/p&gt;

&lt;p&gt;Especially with Discord.&lt;/p&gt;

&lt;p&gt;So I made a small browser-based video compressor that lets you shrink videos to a specific target size — 8MB, 10MB, 20MB, 25MB, 50MB, etc.&lt;/p&gt;

&lt;p&gt;It runs locally in the browser using WebAssembly, so the video doesn't need to be uploaded to a server.&lt;/p&gt;

&lt;p&gt;Useful when you need to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;compress a video for Discord&lt;/li&gt;
&lt;li&gt;make an MP4 smaller&lt;/li&gt;
&lt;li&gt;get a video under a specific file size&lt;/li&gt;
&lt;li&gt;reduce video size before uploading&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I built it mainly because I got tired of opening a full video editor just to make a 30MB clip fit under a file-size limit.&lt;/p&gt;

&lt;p&gt;If you need something like this, it's here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://squishyfile.com/" rel="noopener noreferrer"&gt;https://squishyfile.com/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Would be interested to hear what file-size limits people run into most often.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>productivity</category>
      <category>javascript</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Upscaling anime in the browser: why I stopped trusting the AI model alone</title>
      <dc:creator>Max/Wang</dc:creator>
      <pubDate>Sat, 26 Sep 2026 09:30:48 +0000</pubDate>
      <link>https://dev.to/maxslashwang/upscaling-anime-in-the-browser-why-i-stopped-trusting-the-ai-model-alone-52ba</link>
      <guid>https://dev.to/maxslashwang/upscaling-anime-in-the-browser-why-i-stopped-trusting-the-ai-model-alone-52ba</guid>
      <description>&lt;p&gt;I run a free video upscaler that works entirely in the browser: no upload, the video never leaves your machine. For sources up to 720p it runs a small super-resolution model (IMDN_RTE, about 100k parameters) through onnxruntime-web on WebGPU. Above 720p it switches to AMD's FSR 1.0 shaders, because at that point speed matters more than squeezing out a few extra pixels.&lt;/p&gt;

&lt;p&gt;It worked fine on phone footage. Then I fed it anime, and it looked bad.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: the model learned from photos
&lt;/h2&gt;

&lt;p&gt;IMDN_RTE learned on DIV2K, a set of photographs. Photos have soft gradients, texture, noise. Anime has none of that: it's flat colour fills separated by dark, hard ink lines. So when the model enlarges a cel-shaded frame, it does what it learned to do on photos. It keeps the edges smooth. The lines come out a little blurry and a little grey, and the whole frame looks like a slightly out-of-focus scan.&lt;/p&gt;

&lt;p&gt;My first instinct was the obvious one: throw a sharpening filter on top. That made it worse. A normal unsharp mask overshoots on both sides of an edge, so every black line gets a thin white glow next to it. On live action you barely notice. On anime, with its perfectly flat fills, those halos jump out immediately.&lt;/p&gt;

&lt;p&gt;So I wrote a dedicated "anime mode" pass that runs on the GPU right after the upscale. It only touches luma (brightness) and leaves the colour channels alone, so it can't create colour fringes. It does three things.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: sharpen, but never overshoot
&lt;/h2&gt;

&lt;p&gt;I still use an unsharp mask, but I clamp the result to the darkest and brightest luma values already present in the pixel's own small neighbourhood:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let Ls = clamp(L + u.sharpen * (L - blurred), localMin, localMax);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one &lt;code&gt;clamp&lt;/code&gt; is the whole trick. The edge gets steeper, but a pixel can never become brighter or darker than something that's already next to it. No overshoot means no halo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: darken the lines, not the edges
&lt;/h2&gt;

&lt;p&gt;Anime lines should be dark. But "darken edges" is the wrong rule, because not every edge is a line. A white shirt against a blue wall is an edge between two flat regions, and if I darken it, I get a fake outline around everything.&lt;/p&gt;

&lt;p&gt;I tried a Difference-of-Gaussians darken first (the approach Anime4K uses). It did exactly that: every colour boundary got a dark rim, and the result looked like a cheap cartoon filter.&lt;/p&gt;

&lt;p&gt;What works better is a morphological &lt;strong&gt;black top-hat&lt;/strong&gt;: take a grayscale closing of the image (max filter, then min filter) and subtract the original. The closing erases any dark stroke thinner than the window, so &lt;code&gt;closing(L) - L&lt;/code&gt; is positive only on thin dark lines. A boundary between two wide regions survives the closing, so it gets zero response. I add that response back as extra darkness, and only real ink lines get darker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: thin the lines
&lt;/h2&gt;

&lt;p&gt;Upscaled lines also come out too thick. For this I borrowed Anime4K's "thin lines" idea: compute the Sobel gradient, blur it into a smooth "edge field", then for each pixel sample the image a tiny step &lt;em&gt;down&lt;/em&gt; that field's slope, away from the centre of the line. Lines shrink a bit, the transitions between flat regions get steeper, and flat areas don't move at all because the field is zero there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The details that made it actually work
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Everything scales with the upscale factor.&lt;/strong&gt; A line that's 2 px wide in the source is 8 px wide after a 4x upscale. So every blur radius, the top-hat window and the warp distance multiply by &lt;code&gt;k = scale / 2&lt;/code&gt;. With fixed radii, the pass looked great at 2x and did nothing at 4x.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Separable passes.&lt;/strong&gt; The Gaussian blurs and the min/max filters for the top-hat all split into a horizontal pass and a vertical pass. The whole thing ends up as 9 compute passes plus a final warp, ping-ponging between two &lt;code&gt;rgba16float&lt;/code&gt; textures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bands instead of full frames.&lt;/strong&gt; A 720p video at 4x becomes 5120×2880. Two full-size half-float textures at that resolution cost about 236 MB, which is a great way to crash a laptop GPU. So I process the frame in horizontal bands of roughly 4 megapixels, with extra rows above and below each band. That halo equals the sum of every vertical kernel radius in the chain, so each band's output matches a full-frame run exactly, and the seams stay invisible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A reference before shaders.&lt;/strong&gt; I wrote the whole chain in numpy first, tuned it on real anime frames, and only then ported it to WGSL. The GPU output matches the numpy reference to f16 precision. Debugging a 10-pass shader chain by staring at video is miserable; diffing against a known-good array is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the FSR path, it replaces a pass.&lt;/strong&gt; FSR normally runs EASU (the upscale) then RCAS (its own sharpener). In anime mode I drop RCAS and run my pass instead, since mine already sharpens without halos, and sharpening twice just brings the halos back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things I'd tell you if you're trying this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Don't reach for a bigger model first. The fix here wasn't more AI, it was about 350 lines of plain image processing that knows what anime looks like.&lt;/li&gt;
&lt;li&gt;Work in luma only whenever you can. You avoid a whole class of colour bugs for free.&lt;/li&gt;
&lt;li&gt;Clamp every sharpening step to its local range. It's one line and it kills halos.&lt;/li&gt;
&lt;li&gt;Test at every scale factor. Anything with a radius in it needs to scale with the output.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to see it on your own clips, the tool is here: &lt;a href="https://squishyfile.com/video-upscaler" rel="noopener noreferrer"&gt;squishyfile.com/video-upscaler&lt;/a&gt;. Tick "Anime / cartoon" before you hit start. It needs a browser with WebGPU (recent Chrome or Edge works); without WebGPU the upscale still runs, just on the CPU and without the anime pass.&lt;/p&gt;

&lt;p&gt;I'm happy to go deeper on any of the passes in the comments.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>programming</category>
    </item>
    <item>
      <title>8mb.video Alternative: Skip the Line, Skip the Upsell</title>
      <dc:creator>Max/Wang</dc:creator>
      <pubDate>Wed, 23 Sep 2026 03:47:24 +0000</pubDate>
      <link>https://dev.to/maxslashwang/8mbvideo-alternative-skip-the-line-skip-the-upsell-3h3p</link>
      <guid>https://dev.to/maxslashwang/8mbvideo-alternative-skip-the-line-skip-the-upsell-3h3p</guid>
      <description>&lt;p&gt;&lt;em&gt;I compressed a video on 8mb.video to see what "free" means there. Position 64 in line, and a paywall behind the one feature that would have solved my problem.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why 8MB specifically
&lt;/h2&gt;

&lt;p&gt;Almost nobody wakes up wanting an 8MB video for its own sake. They want to send a clip somewhere that caps uploads at 8MB — usually Discord without Nitro, sometimes an old forum or a slow upload form — and their phone just handed them a 40MB file. "8MB video compressor" is a size problem with a deadline, not a hobby, which is exactly the moment a queue is the worst possible answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "free" gets you on 8mb.video
&lt;/h2&gt;

&lt;p&gt;I opened 8mb.video to compress a video to 8MB — the same job my own &lt;a href="https://squishyfile.com/8mb-video-compressor" rel="noopener noreferrer"&gt;8MB compressor&lt;/a&gt; does. Instead of a progress bar, I got this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Waiting in Line&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You are position 64 in line. There are hundreds of people using the service right now, hold on a second and we'll be with you soon.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;HOLD MUSIC: clink clank clinkity clop&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;💖 A labor of love by a cool girl. Upgrade for a few dollars and skip the line immediately while funding continued improvements.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I checked twice while writing this, a few minutes apart, from a normal residential connection with nothing unusual going on. Both times: a queue, a position number, and the same offer to pay to skip it. That's not a bad day for their servers — it's the free tier working as designed. Every video someone uploads has to run through 8mb.video's own hardware, one at a time per worker, so when enough people show up at once, someone waits. The wait is also part of the pitch: it's what makes "upgrade for a few dollars" worth clicking rather than closing the tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  The custom size field is a paywall, not a feature
&lt;/h2&gt;

&lt;p&gt;8mb.video's presets are 8MB, 10MB, 20MB, 25MB, 50MB and 100MB. Below the presets there's a "Custom" field that looks like it takes any number up to 200MB — which would matter if you needed a 15MB file for a form with an odd limit, or a 35MB file because that's what someone specifically asked for. Type a number in and try to run it, though, and what comes back isn't a compressed video — it's a subscription prompt. The field shows you what you're missing rather than giving it to you.&lt;/p&gt;

&lt;p&gt;So a free account on 8mb.video really does mean six sizes and no others. Anyone who needs a seventh number is the customer the upgrade prompt is built for, not an edge case the site forgot to handle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a browser-based compressor doesn't have either problem
&lt;/h2&gt;

&lt;p&gt;SquishyFile compresses video with WebAssembly running in your own browser tab. There's no upload step, because there's no server on the other end doing the work — your device does it, the same way it would if you'd installed FFmpeg locally instead of using a website at all.&lt;/p&gt;

&lt;p&gt;That has one direct consequence for each problem above. There's no queue because there's nothing to queue: my video doesn't wait behind anyone else's, because it's never in a line with anyone else's — it just runs on my own CPU while I watch. A thousand people could be compressing video on SquishyFile at the same moment and none of us would notice, because none of us are sharing a server or a worker pool. There's no capacity to run out of.&lt;/p&gt;

&lt;p&gt;And the target size box takes any number I type, not six of them. It's the same input whether I want 8MB or 37MB, free, because there's no server cost that scales with how unusual my number is — the work happens on my machine either way, so there's nothing to gate behind a subscription.&lt;/p&gt;

&lt;p&gt;None of that is free of trade-offs. A browser tab needs to stay open and in the foreground while it works — some browsers throttle background tabs — and a large 4K source on an old laptop takes real time and real battery, because it's using that laptop's CPU rather than a server farm's. That's the one place 8mb.video's model genuinely helps: if your device is weak enough that local encoding would crawl, borrowing someone else's hardware is a real advantage, queue or not.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I tested this
&lt;/h2&gt;

&lt;p&gt;Same 42-second 1080p H.264 clip on both, targeting 8MB, over a normal home connection, no VPN, checked on two separate visits a few minutes apart. 8mb.video queued both times with a different position number; SquishyFile started encoding immediately both times, as it always does, since there's no shared server to queue behind. I'm not claiming 8mb.video is always this busy — server load moves around — but the custom-size paywall isn't load-dependent. That one's there every time, queue or no queue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Side by side
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;8mb.video (free)&lt;/th&gt;
&lt;th&gt;SquishyFile&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Where it runs&lt;/td&gt;
&lt;td&gt;Their server&lt;/td&gt;
&lt;td&gt;Your device&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;File leaves your device&lt;/td&gt;
&lt;td&gt;Yes, uploaded&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Size options&lt;/td&gt;
&lt;td&gt;8/10/20/25/50/100MB presets only&lt;/td&gt;
&lt;td&gt;Any number you type&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom size&lt;/td&gt;
&lt;td&gt;Paywalled&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Queue at busy times&lt;/td&gt;
&lt;td&gt;Yes, with a position number&lt;/td&gt;
&lt;td&gt;None — nothing to share&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Skip the wait&lt;/td&gt;
&lt;td&gt;Pay to skip&lt;/td&gt;
&lt;td&gt;Nothing to skip&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Good for weak/old hardware&lt;/td&gt;
&lt;td&gt;Yes — uses their servers&lt;/td&gt;
&lt;td&gt;Slower on very old devices&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Common questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why do I have to wait in line on 8mb.video?
&lt;/h3&gt;

&lt;p&gt;Because it compresses video on its own servers, one file at a time per available worker. When enough people use it at once, new jobs queue behind the ones already running — and the site offers to let you pay to jump the line.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I get a custom size for free on 8mb.video?
&lt;/h3&gt;

&lt;p&gt;There's a custom size field, but entering a number and submitting it prompts a subscription upgrade rather than compressing the file. Free accounts are limited to the six preset sizes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is there a free video compressor with no queue and no size limit?
&lt;/h3&gt;

&lt;p&gt;Yes — anything that runs in your browser instead of on a server, like &lt;a href="https://squishyfile.com/8mb-video-compressor" rel="noopener noreferrer"&gt;SquishyFile&lt;/a&gt;. Since it uses your own device's processing power rather than shared server capacity, there's no line to wait in and no reason to cap which sizes are free.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does my video leave my device with 8mb.video?
&lt;/h3&gt;

&lt;p&gt;Yes. It has to — the compression happens on their servers, so the file uploads before any processing starts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does Discord need an 8MB video anyway?
&lt;/h3&gt;

&lt;p&gt;8MB is Discord's upload limit for accounts without Nitro. It hasn't changed in years, phone cameras keep getting bigger files, and the gap between the two is exactly what an 8MB compressor exists to close.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest version
&lt;/h2&gt;

&lt;p&gt;8mb.video isn't broken — it's a server-based tool with a free tier shaped to nudge people toward paying, which is a completely reasonable business to run. If your device is old enough that local compression would genuinely be slow, uploading to someone else's hardware is a fair trade, queue or not. But if you just need a video under a certain size and you're not in a hurry to fund someone's server bill, &lt;a href="https://squishyfile.com/8mb-video-compressor" rel="noopener noreferrer"&gt;running it locally&lt;/a&gt; skips the queue and the upsell by never having either one to skip.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>productivity</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I built a free tool that compresses video down to 8MB</title>
      <dc:creator>Max/Wang</dc:creator>
      <pubDate>Mon, 21 Sep 2026 13:05:56 +0000</pubDate>
      <link>https://dev.to/maxslashwang/i-built-a-free-tool-that-compresses-video-down-to-8mb-1din</link>
      <guid>https://dev.to/maxslashwang/i-built-a-free-tool-that-compresses-video-down-to-8mb-1din</guid>
      <description>&lt;p&gt;Quick share for anyone who still needs a video under 8MB — some old forums, upload forms and bots still cap at that number even though Discord doesn't anymore.&lt;/p&gt;

&lt;p&gt;I made a small browser tool that does exactly this: drop in a video, the target's already set to 8MB, hit compress, get an MP4 that fits. It runs entirely client-side (WebAssembly), so the file never leaves your machine.&lt;/p&gt;

&lt;p&gt;A few things I learned building it that might save you a headache:&lt;/p&gt;

&lt;p&gt;Length matters way more than resolution. At 8MB you're roughly looking at: 20 seconds at 1080p, 30 seconds at 720p, about a minute at 540p. Past 90 seconds it still fits, just looks rough. It's pure bitrate math — 8MB is only ~67 megabits total once you subtract a safety margin.&lt;/p&gt;

&lt;p&gt;If it's actually for Discord: they bumped the free upload limit to 20MB back in August 2026 (it's been 8 → 25 → 10 → 20 over the years). So compress to 20MB for Discord, not 8 — that's 2.5x the bitrate budget for the same clip. Only use 8MB when something explicitly states that limit.&lt;/p&gt;

&lt;p&gt;"Under 8MB" isn't the same number everywhere. Windows counts 1MB as 1,048,576 bytes; Mac Finder counts it as 1,000,000. A file that reads 7.6MB on Windows can show as 8.0MB on a Mac. If a site keeps rejecting your file, target 7MB instead of 8 to leave no doubt.&lt;/p&gt;

&lt;p&gt;Link: &lt;a href="https://squishyfile.com/8mb-video-compressor" rel="noopener noreferrer"&gt;https://squishyfile.com/8mb-video-compressor&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Happy to answer questions if anyone hits a weird edge case.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I built a browser tool that compresses video to 8MB. The bitrate math was the easy part.</title>
      <dc:creator>Max/Wang</dc:creator>
      <pubDate>Mon, 21 Sep 2026 10:38:33 +0000</pubDate>
      <link>https://dev.to/maxslashwang/i-built-a-browser-tool-that-compresses-video-to-8mb-the-bitrate-math-was-the-easy-part-428d</link>
      <guid>https://dev.to/maxslashwang/i-built-a-browser-tool-that-compresses-video-to-8mb-the-bitrate-math-was-the-easy-part-428d</guid>
      <description>&lt;p&gt;"Compress this video to 8MB" sounds like a one-liner. I thought so too. Take the target size, divide by the duration, hand the encoder a bitrate, done.&lt;/p&gt;

&lt;p&gt;I shipped that version. Then real people started feeding it real videos, and I spent the next few weeks learning that hitting a file size and producing something watchable are two very different problems.&lt;/p&gt;

&lt;p&gt;This post walks through how my &lt;a href="https://squishyfile.com/8mb-video-compressor" rel="noopener noreferrer"&gt;8MB video compressor&lt;/a&gt; actually decides what to do with your clip. Everything runs client-side: no upload, no server, no queue. That constraint shaped almost every decision below.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part everyone gets right: the budget
&lt;/h2&gt;

&lt;p&gt;8MB is a hard number, so the budget is pure arithmetic.&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="c1"&gt;// 8 MB in bits&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;targetBits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 67,108,864&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MUX_OVERHEAD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.985&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// MP4 container + index&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TARGET_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;// encoders overshoot more than they undershoot&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;totalBudget&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;targetBits&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;MUX_OVERHEAD&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;TARGET_SAFETY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;durationSec&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That leaves me roughly 62 megabits for the whole clip, video and audio together. A 30-second clip gets about 2 Mbps. A 2-minute clip gets about 0.5 Mbps.&lt;/p&gt;

&lt;p&gt;I aim about 6% under on purpose. One wasted re-encode costs the user far more time than a few percent of unused budget, and "at most 8MB" is a promise. A file that lands at 8.1MB is a file that fails to upload.&lt;/p&gt;

&lt;p&gt;If you stop here, you get a tool that hits the size every time. You also get a tool that turns a 90-second 1080p clip into blocky soup, because 0.7 Mbps spread over two million pixels per frame is nowhere near enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually matters: bits per pixel
&lt;/h2&gt;

&lt;p&gt;Bitrate on its own tells you nothing about how a video will look. 2 Mbps is generous at 480p and unwatchable at 4K. The number I care about is bits per pixel per frame:&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;bpp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;videoBitrate&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;frameRate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the same 1 Mbps budget at two resolutions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1920×1080 at 30fps → &lt;strong&gt;0.016 bpp&lt;/strong&gt;. Falling apart.&lt;/li&gt;
&lt;li&gt;960×540 at 30fps → &lt;strong&gt;0.064 bpp&lt;/strong&gt;. Soft, but clean.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So when a bitrate is forced on me, I don't keep the source resolution and let the encoder smear it. I walk down a ladder and pick the largest size the budget can actually sustain:&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;SHORT_EDGE_LADDER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2160&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1440&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1080&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;720&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;540&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;480&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;270&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;144&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ladder on the &lt;em&gt;short&lt;/em&gt; edge, not the height. Every phone video is portrait, and a 1080×1920 iPhone clip is "1080p", not "1920p". Treating it as landscape would have made every vertical video pick the wrong rung.&lt;/p&gt;

&lt;p&gt;I also trade frame rate before I trade too much resolution. Once the budget drops a clip below 720p, I halve 60fps to 30fps and re-run the ladder. For gameplay and screen recordings, 30 sharp frames beat 60 mushy ones.&lt;/p&gt;

&lt;p&gt;In practice this works out to roughly 20 seconds at 1080p, 30 seconds at 720p and a minute at 540p for a typical iPhone clip. Past 90 seconds it still fits in 8MB, but I show a warning before download, because it will look rough and I'd rather say so than let the user find out after they post it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #1: I turned a 1080p video into a postage stamp
&lt;/h2&gt;

&lt;p&gt;My first quality floor was a fixed H.264 reference table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;bpp&lt;/th&gt;
&lt;th&gt;what it looks like&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.15+&lt;/td&gt;
&lt;td&gt;visually transparent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.10&lt;/td&gt;
&lt;td&gt;good&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.075&lt;/td&gt;
&lt;td&gt;acceptable, my floor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.05&lt;/td&gt;
&lt;td&gt;soft, blocky on motion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.03 and below&lt;/td&gt;
&lt;td&gt;falling apart&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Then a user sent me a 1080p clip at about 700 kbps and got back &lt;strong&gt;480×270&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The table wasn't wrong. I was using it on the wrong videos. That table describes a &lt;em&gt;first-generation&lt;/em&gt; encode, straight out of a camera. Most files people compress have already been through something: a messaging app, a download, an export. That earlier pass already stripped out the noise and fine detail, which are exactly the parts that cost bits. A re-shared clip holds up fine at a bpp that would look dreadful from a camera.&lt;/p&gt;

&lt;p&gt;My planner read "low bpp" as "damaged" and "rescued" the video by throwing away three quarters of its pixels.&lt;/p&gt;

&lt;p&gt;The fix: I judge the source against itself. The floor becomes whichever is lower, the absolute table value or a fraction of the bpp the source already carries:&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;TARGET_BPP_FLOOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.075&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;TARGET_SOURCE_FLOOR_RATIO&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.2&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;bppFloor&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;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TARGET_BPP_FLOOR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;srcBpp&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;TARGET_SOURCE_FLOOR_RATIO&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I tried keeping a small absolute floor underneath that, too. It produced my favorite absurd result: a source at 0.022 bpp got downscaled from 1858×1660 to 806×720 just to reach 0.032 bpp. I threw away most of the pixels to &lt;em&gt;exceed&lt;/em&gt; the quality of the original. Any floor that can sit above the source's own bpp does this, so now the floor is purely relative.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #2: HEVC made good videos look bad
&lt;/h2&gt;

&lt;p&gt;I always encode H.264, because it plays everywhere: Discord, iPhone, Android, every browser. But sources arrive as HEVC, VP9 and AV1, and those codecs hold the same picture in noticeably fewer bits.&lt;/p&gt;

&lt;p&gt;Every iPhone records HEVC by default. So a clean iPhone clip at 700 kbps of HEVC looked, to my planner, like a starved H.264 file. I was punishing sources for being efficient.&lt;/p&gt;

&lt;p&gt;Now I convert the source bitrate into "the H.264 bitrate that would look the same" before comparing it with anything:&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;CODEC_EFFICIENCY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;avc&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="na"&gt;hevc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.45&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;vp9&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;av1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;vp8&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;prores&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// intra-only, spends huge bitrates on a picture H.264 carries cheaply&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are conservative mid-points from the usual codec comparisons, not gospel. When I can't detect the codec, I assume H.264. Guessing something more efficient would inflate my estimate of the source and over-compress it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #3: the encoder refused to spend my budget
&lt;/h2&gt;

&lt;p&gt;This one surprised me. A user asked for 60MB and got 28MB. My retry logic noticed the undershoot, "corrected" the target to 58.2MB, and the next pass produced 28MB again.&lt;/p&gt;

&lt;p&gt;The cause: in most WebCodecs implementations, a constant-bitrate request is a ceiling, not a promise. Easy content simply costs less than the budget allows, and the encoder won't pad it.&lt;/p&gt;

&lt;p&gt;My correction step clamped the retry to just under the target, which meant it asked for &lt;em&gt;less&lt;/em&gt; than the pass that had already undershot. Useless. Now, on an undershoot, I ask for &lt;em&gt;more&lt;/em&gt; than the target:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;actualBytes&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;desiredBytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Overshoot: mandatory fix. The promise is "at most N MB".&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;previousTarget&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;desiredBytes&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;actualBytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.96&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&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;attempt&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;cappedBySource&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;actualBytes&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;desiredBytes&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Undershoot: raise the ask once.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scale&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;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;desiredBytes&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;actualBytes&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;previousTarget&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.95&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;That's safe because the overshoot branch runs first and is mandatory. If the raised ask lands over 8MB, the next pass pulls it back down. I cap it at three passes total, and I only raise once. If the content is cheap, it will undershoot at any budget, and every extra pass is a full re-encode the user sits through for nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #4: "compress" made a file bigger
&lt;/h2&gt;

&lt;p&gt;A 684 kbps clip came back 10% &lt;em&gt;larger&lt;/em&gt;. The culprit was audio. The source had a 48 kbps audio track, and I re-encoded it at 128 kbps.&lt;/p&gt;

&lt;p&gt;Audio gets re-encoded, not copied, so giving it more bits than the original had invents nothing. It just makes the track bigger. On a thin video, that alone pushes the file past the original.&lt;/p&gt;

&lt;p&gt;Audio now follows three rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It never gets more than the source had.&lt;/li&gt;
&lt;li&gt;It never gets more than 18% of the total budget.&lt;/li&gt;
&lt;li&gt;It snaps to a standard ladder (128, 96, 64, 48, 32, 24, 16 kbps) instead of arbitrary values, because some browsers' AAC encoders throw &lt;code&gt;OperationError: Encoding error&lt;/code&gt; on odd low bitrates instead of just sounding worse.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At 64 kbps and below I switch to mono, since stereo at that rate sounds worse than mono. I only drop audio entirely when the budget can't carry even 16 kbps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it in the browser
&lt;/h2&gt;

&lt;p&gt;I use two engines behind the same plan:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mediabunny on top of WebCodecs&lt;/strong&gt; for modern containers. It uses the hardware encoder, so it's fast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ffmpeg.wasm&lt;/strong&gt; for everything else, and as a fallback when a hardware encode fails with a recoverable error. It's pure software, slower, but it doesn't depend on whatever GPU driver the user has.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both engines consume the exact same &lt;code&gt;CompressionPlan&lt;/code&gt; object (resolution, frame rate, bitrate, audio settings, keyframe interval), so they behave identically. The planner is a pure module with no browser APIs in it, which also means I can unit-test every one of the bugs above without touching a video file.&lt;/p&gt;

&lt;p&gt;The client-side constraint cost me some speed, but it bought two things I care about. Nobody's video touches my server, and the source file can be as large as the user's device can handle.&lt;/p&gt;

&lt;h2&gt;
  
  
  One gotcha that isn't mine: what "8MB" means
&lt;/h2&gt;

&lt;p&gt;I count 1 MB as 1,048,576 bytes, like Windows Explorer does. macOS Finder counts 1 MB as 1,000,000 bytes, so the same file looks bigger on a Mac: 7.6MB on Windows shows as 8.0MB in Finder.&lt;/p&gt;

&lt;p&gt;My first pass aims at roughly 7.9 million bytes, which clears both definitions. If some upload form still rejects the file, the honest answer is to type 7 instead of 8.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell anyone building this
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The size is arithmetic. The quality is judgment.&lt;/strong&gt; Spend your time on resolution and frame-rate decisions, not on the division.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Judge a source against itself.&lt;/strong&gt; Absolute quality tables assume camera-fresh footage, and almost nobody compresses camera-fresh footage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know which codec spent the bits.&lt;/strong&gt; An HEVC bitrate and an H.264 bitrate aren't the same number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat encoder bitrate as a ceiling.&lt;/strong&gt; Verify the output size and correct in both directions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tell the user the truth.&lt;/strong&gt; If 8MB can't hold their 3-minute clip at a decent resolution, warn them before they download.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want to see how it behaves on your own clips, the tool is here: &lt;a href="https://squishyfile.com/8mb-video-compressor" rel="noopener noreferrer"&gt;compress video to 8MB&lt;/a&gt;. It's free, there's no watermark or sign-up, and the video never leaves your browser.&lt;/p&gt;

&lt;p&gt;I'm curious how other people handle the undershoot problem with WebCodecs. If you've found a cleaner fix than "ask for more and correct on the next pass", tell me in the comments.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Handbrake Alternatives: What to Use When You Don't Want to Install It</title>
      <dc:creator>Max/Wang</dc:creator>
      <pubDate>Mon, 21 Sep 2026 10:08:28 +0000</pubDate>
      <link>https://dev.to/maxslashwang/handbrake-alternatives-what-to-use-when-you-dont-want-to-install-it-1fi9</link>
      <guid>https://dev.to/maxslashwang/handbrake-alternatives-what-to-use-when-you-dont-want-to-install-it-1fi9</guid>
      <description>&lt;p&gt;People look for a Handbrake alternative for one of three reasons, and they need different answers.&lt;/p&gt;

&lt;p&gt;Some want the &lt;strong&gt;same power without the interface&lt;/strong&gt; — they're fine installing software, they just find Handbrake's settings panel unfriendly. Some want &lt;strong&gt;no install at all&lt;/strong&gt;, usually because it's one file, or a work laptop that won't allow it, or a phone. And some want &lt;strong&gt;something Handbrake can't do&lt;/strong&gt; — editing, trimming, subtitles, format conversions outside its range.&lt;/p&gt;

&lt;p&gt;Worth saying clearly first: for batch-processing a library with precise encoder control, Handbrake is still the best free option and nothing listed below beats it at that. This is about the jobs where it's more tool than the task needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Browser-based compressors
&lt;/h2&gt;

&lt;p&gt;The category that barely existed a few years ago. Browsers can now encode video natively through the WebCodecs API, which means a web page can compress a file on your own machine instead of uploading it somewhere — the same basic operation Handbrake performs, minus the install.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://squishyfile.com" rel="noopener noreferrer"&gt;SquishyFile&lt;/a&gt; is built this way: open the page, drop in a video, either pick a compression level or type an exact target size in MB, download the result. Nothing uploads, there's no account and no watermark, and it works the same in Safari on an iPhone as in Chrome on a desktop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it wins:&lt;/strong&gt; one-off files, locked-down machines, phones and tablets, and the specific case of "this has to be under 25MB" — where you type 25 instead of calculating a bitrate and running a two-pass encode.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it doesn't:&lt;/strong&gt; encoding runs on your own hardware, so a multi-gigabyte 4K source on a modest laptop takes real time. There's no queue of fifty files running unattended overnight, and no frame-level encoder control. If either of those is your workflow, stay with Handbrake.&lt;/p&gt;

&lt;h2&gt;
  
  
  Upload-based online converters
&lt;/h2&gt;

&lt;p&gt;Clideo, VEED, FreeConvert, Media.io and a long tail of similar services take a different approach: you upload the file, their servers compress it, you download the result. That's genuinely useful when your own machine is slow, since the work happens on hardware you don't own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it wins:&lt;/strong&gt; heavy files on weak hardware, and feature sets that go well beyond compression — trimming, subtitles, format conversion, basic editing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it doesn't:&lt;/strong&gt; your video leaves your device, which rules the category out for anything confidential. Free tiers vary a lot and change often — caps on file size or duration, watermarks, required accounts, daily limits. Check the current terms of whichever one you're considering rather than trusting a comparison article, this one included. And large uploads on a slow connection can take longer than compressing locally would have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other desktop tools
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;FFmpeg&lt;/strong&gt; is what Handbrake is built on, minus the interface. If you're comfortable at a command line it's strictly more capable, scriptable, and the fastest way to batch anything. If you're not, it's a wall of flags.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VLC&lt;/strong&gt; is already installed on most machines and has a convert/save function that will compress video. It works. It's also poorly signposted, gives much less control than Handbrake, and fails in ways that are hard to diagnose. Fine in a pinch, not a real replacement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shutter Encoder&lt;/strong&gt; is the closest thing to a direct Handbrake competitor — an FFmpeg front-end with a broader feature set covering conversion, trimming and some professional formats. Different interface philosophy; whether it's friendlier is a matter of taste. Free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Adobe Media Encoder&lt;/strong&gt; and similar paid tools are excellent and priced for people who do this professionally. Not a sensible answer to "I don't want to install Handbrake".&lt;/p&gt;

&lt;h2&gt;
  
  
  Built-in options you already have
&lt;/h2&gt;

&lt;p&gt;On &lt;strong&gt;macOS&lt;/strong&gt;, QuickTime Player's File → Export As offers fixed resolution presets (1080p, 720p, 480p). Crude, no size control, already installed.&lt;/p&gt;

&lt;p&gt;On &lt;strong&gt;iOS&lt;/strong&gt;, Mail's attachment size menu and the Shortcuts app's Encode Media action both compress without any download — covered properly in &lt;a href="https://squishyfile.com/blog/how-to-compress-a-video-on-iphone" rel="noopener noreferrer"&gt;how to compress a video on iPhone&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;On &lt;strong&gt;Windows&lt;/strong&gt;, the Photos app can export at a lower resolution, though it's slow and the controls are minimal.&lt;/p&gt;

&lt;p&gt;All of these share the same limitation: resolution presets, not size targets. They'll make a file smaller. They won't make it smaller than a specific number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking one
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you need&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One file, under a specific MB limit&lt;/td&gt;
&lt;td&gt;A browser-based compressor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fifty files, overnight, precise settings&lt;/td&gt;
&lt;td&gt;Handbrake or FFmpeg&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can't install software on this machine&lt;/td&gt;
&lt;td&gt;A browser-based compressor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Huge file, slow hardware, not confidential&lt;/td&gt;
&lt;td&gt;An upload-based service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compress plus trim, subtitles or editing&lt;/td&gt;
&lt;td&gt;An upload-based editor, or Shutter Encoder&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Confidential footage&lt;/td&gt;
&lt;td&gt;Anything local — Handbrake, FFmpeg, or a browser-based tool&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scriptable and repeatable&lt;/td&gt;
&lt;td&gt;FFmpeg&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Common questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is there an online version of Handbrake?
&lt;/h3&gt;

&lt;p&gt;Not officially — Handbrake is desktop-only and there's no web edition. The closest equivalent is a browser-based compressor that encodes on your own device, which matches the "nothing uploads" property without matching Handbrake's depth of settings.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the best free alternative to Handbrake?
&lt;/h3&gt;

&lt;p&gt;It depends on the job. FFmpeg if you want more power and are fine with a command line, Shutter Encoder for a broader GUI feature set, and &lt;a href="https://squishyfile.com" rel="noopener noreferrer"&gt;a browser-based compressor&lt;/a&gt; for one-off files where you'd rather not install anything at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I compress a video without downloading software?
&lt;/h3&gt;

&lt;p&gt;Yes. Browser-based compressors encode on your device with nothing installed, and upload-based services do the work on their servers. The difference that matters is whether your file leaves your machine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Handbrake still worth using?
&lt;/h3&gt;

&lt;p&gt;Very much so, for batch work and precise encoder control — it remains a strong free desktop option, and our &lt;a href="https://squishyfile.com/blog/handbrake-compress-video-settings" rel="noopener noreferrer"&gt;settings guide&lt;/a&gt; covers getting good results from it. It's just heavier than a single file usually warrants.&lt;/p&gt;

&lt;h3&gt;
  
  
  Are online video compressors safe?
&lt;/h3&gt;

&lt;p&gt;Browser-based ones don't transmit your file at all, so there's nothing to intercept or store. Upload-based ones necessarily send it to a third-party server — fine for a holiday clip, worth thinking twice about for anything confidential.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest summary
&lt;/h2&gt;

&lt;p&gt;Most people searching for a Handbrake alternative don't need a smaller, friendlier Handbrake — they need a video under a size limit in the next few minutes, and a full encoding suite is the wrong shape of tool for that.&lt;/p&gt;

&lt;p&gt;If that's you, &lt;a href="https://squishyfile.com" rel="noopener noreferrer"&gt;type the number into a browser-based compressor&lt;/a&gt; and move on. If you're processing a library, or you need specific encoder parameters, install Handbrake and read &lt;a href="https://squishyfile.com/blog/handbrake-compress-video-settings" rel="noopener noreferrer"&gt;what its settings actually do&lt;/a&gt; — you'll get better results than a simpler alternative will give you.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>productivity</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>I downscaled a 1080p video to 480x270 to protect its quality</title>
      <dc:creator>Max/Wang</dc:creator>
      <pubDate>Sun, 20 Sep 2026 14:57:42 +0000</pubDate>
      <link>https://dev.to/maxslashwang/we-downscaled-a-1080p-video-to-480x270-to-protect-its-quality-1m46</link>
      <guid>https://dev.to/maxslashwang/we-downscaled-a-1080p-video-to-480x270-to-protect-its-quality-1m46</guid>
      <description>&lt;p&gt;Someone handed my compressor a 1080p clip. It gave back a 480×270 postage stamp.&lt;/p&gt;

&lt;p&gt;Nothing crashed. Every line did what I wrote it to do. I had written code that looked at that video, decided it was too damaged to deserve its own resolution, and rescued it by throwing away three quarters of its pixels.&lt;/p&gt;

&lt;p&gt;I build this thing on my own, so there's nobody to hide behind here. Every bug below is mine, and I shipped six of them in about two weeks.&lt;/p&gt;

&lt;p&gt;Quick context, then I'll get to the interesting part. The tool compresses video entirely client-side — nothing uploads. I run two engines: WebCodecs through &lt;a href="https://mediabunny.dev/" rel="noopener noreferrer"&gt;Mediabunny&lt;/a&gt; as the fast path, using the browser's own hardware encoders where they exist, and single-threaded &lt;code&gt;ffmpeg.wasm&lt;/code&gt; (~32 MB, fetched on first use, then parked in the Cache API) for containers WebCodecs can't demux — AVI, WMV, FLV, MPEG program streams, older 3GP. I picked the single-threaded core deliberately: the multi-threaded one needs &lt;code&gt;SharedArrayBuffer&lt;/code&gt;, which needs COOP/COEP headers, which would break static hosting for the sake of files that are rare and usually small.&lt;/p&gt;

&lt;p&gt;None of that matters much for what follows. I barely had a single bug in the encoders. All of them lived in the ~700 lines that decide &lt;em&gt;what to ask an encoder for&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bitrate is the wrong number to look at
&lt;/h2&gt;

&lt;p&gt;A compressor has to answer one question: given this source and this goal, what resolution, frame rate and bitrate do I encode at? And the quantity that decides how a video &lt;em&gt;looks&lt;/em&gt; isn't bitrate. It's bits per pixel per frame.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bpp = videoBitrate / (width * height * frameRate)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;2 Mbps is generous at 480p and unwatchable at 4K. Rough H.264 landmarks:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;bpp&lt;/th&gt;
&lt;th&gt;looks like&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.15+&lt;/td&gt;
&lt;td&gt;visually transparent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.10&lt;/td&gt;
&lt;td&gt;good (YouTube-ish)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.075&lt;/td&gt;
&lt;td&gt;acceptable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.05&lt;/td&gt;
&lt;td&gt;soft, blocky on motion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.03−&lt;/td&gt;
&lt;td&gt;falling apart&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So when a bitrate gets forced on me — "fit this in 25 MB" — I don't keep the source resolution and let the encoder smear it into mush. I ladder down: take the largest resolution whose bpp still clears a floor. I set that floor at 0.075, straight off the table above.&lt;/p&gt;

&lt;p&gt;That floor is the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The floor ate the picture
&lt;/h2&gt;

&lt;p&gt;Here's the clip that broke it. 1080p, 30 fps, 700 kbps. The level's retention cut takes the budget to ~455 kbps first, then my ladder runs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;short edge&lt;/th&gt;
&lt;th&gt;dimensions&lt;/th&gt;
&lt;th&gt;bpp at 455 kbps&lt;/th&gt;
&lt;th&gt;clears 0.075?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1080&lt;/td&gt;
&lt;td&gt;1920×1080&lt;/td&gt;
&lt;td&gt;0.0073&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;720&lt;/td&gt;
&lt;td&gt;1280×720&lt;/td&gt;
&lt;td&gt;0.0165&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;540&lt;/td&gt;
&lt;td&gt;960×540&lt;/td&gt;
&lt;td&gt;0.029&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;480&lt;/td&gt;
&lt;td&gt;852×480&lt;/td&gt;
&lt;td&gt;0.037&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;360&lt;/td&gt;
&lt;td&gt;640×360&lt;/td&gt;
&lt;td&gt;0.066&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;270&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;480×270&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.117&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every step is correct. The conclusion is insane.&lt;/p&gt;

&lt;p&gt;My table lies in two ways it doesn't warn you about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It describes a first-generation encode.&lt;/strong&gt; It tells you what a fresh capture out of a camera needs. But most files people compress have already been through a pass — a messaging app, a download, an export. That pass already stripped the noise and fine detail, which are the parts that cost bits. A second-generation clip holds up fine at a bpp that would look dreadful straight off a sensor. I read a re-shared clip's low bpp as &lt;em&gt;damage&lt;/em&gt;, and that's the whole bug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I double-charged.&lt;/strong&gt; I cut the source bitrate to a fraction first, and &lt;em&gt;then&lt;/em&gt; ran the resolution ladder against the already-cut number. My cut caused my downscale, and both landed on the same clip.&lt;/p&gt;

&lt;p&gt;Target-size mode produced the stupidest version of this. A 77.5 MB source carrying 0.022 bpp, asked to fit in 25 MB, went from 1858×1660 down to 806×720 — a downscale whose entire purpose was to reach 0.032 bpp. &lt;em&gt;Half again the density the source itself had.&lt;/em&gt; I threw away three quarters of someone's pixels in order to beat the quality of their original.&lt;/p&gt;

&lt;p&gt;Here's the one thing in this post I'd actually put on a wall:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Any absolute quality floor that can sit above the source's own value will destroy the source in order to meet it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So I made the floor purely relative. Now it can never get up there:&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;bppFloor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;srcBpp&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;TARGET_BPP_FLOOR&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;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TARGET_BPP_FLOOR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;srcBpp&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A fifth of whatever the source itself carried. I landed on 0.2 because bpp is really standing in for how hard the content is to encode, and a source's own bpp is the best measure of that I have: footage that already survives at a low bpp is cheap footage, and it stays cheap at a fifth of that.&lt;/p&gt;

&lt;p&gt;That downscale was also worse than it looked, for a reason I didn't see coming. In target-size mode it defeats itself — a smaller frame is cheaper to encode, so the encoder stops spending the budget I gave it and lands far under target. The user pays in resolution &lt;em&gt;and&lt;/em&gt; gets a file half the size they asked for. One 91 MB file asked to fit in 60 MB — a budget covering two thirds of its own bitrate — came back at 720p for exactly this reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  I was dividing a number I never measured
&lt;/h2&gt;

&lt;p&gt;Underneath all of that sat something worse. The number I kept dividing wasn't a video bitrate at all.&lt;/p&gt;

&lt;p&gt;When my packet-index read failed or looked unrepresentative, I fell back to this:&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="nx"&gt;videoBitrate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;durationSec&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the wrong &lt;em&gt;quantity&lt;/em&gt;, not a rougher version of the right one. It folds in the audio track and every byte of container overhead, so it reports a video bitrate the video track never had. Then my planner divides it by the pixel count and decides, from a number nobody measured, whether the source keeps its resolution.&lt;/p&gt;

&lt;p&gt;I had a real reason for that fallback. I used to sample only the first 120 packets (~4 s) to get the frame rate, and a prefix that short genuinely misleads whenever the opening seconds are simpler than the rest — so I threw away the bitrate that came with it.&lt;/p&gt;

&lt;p&gt;Then I stopped being scared of the full pass:&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;stats&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;videoTrack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;computePacketStats&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// metadataOnly&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;stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;averagePacketRate&lt;/span&gt; &lt;span class="o"&gt;&amp;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;frameRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;averagePacketRate&lt;/span&gt;&lt;span class="p"&gt;;&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;stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;averageBitrate&lt;/span&gt; &lt;span class="o"&gt;&amp;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;videoBitrate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;averageBitrate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scanning every packet costs almost nothing, despite how it sounds. With &lt;code&gt;metadataOnly&lt;/code&gt; it reads each sample's size and timestamp straight out of the container index — &lt;code&gt;stbl&lt;/code&gt;/&lt;code&gt;stsz&lt;/code&gt; for MP4, Cues for MKV/WebM — and never touches a frame of pixel data, let alone decodes one. The file is already a local &lt;code&gt;Blob&lt;/code&gt;, so nothing goes over the network. You get both numbers exactly: &lt;code&gt;averagePacketRate&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; the real frame rate, and &lt;code&gt;averageBitrate&lt;/code&gt; is the video track's own bitrate, measured from actual packet sizes.&lt;/p&gt;

&lt;h2&gt;
  
  
  I punished files for being efficient
&lt;/h2&gt;

&lt;p&gt;I always &lt;em&gt;write&lt;/em&gt; H.264. But sources show up as HEVC, VP9 and AV1 — every iPhone since iOS 11 records HEVC by default, and the messaging apps most videos arrive through re-encode to HEVC or VP9 too.&lt;/p&gt;

&lt;p&gt;700 kbps of HEVC carries roughly what 1 Mbps of H.264 carries. Compare a source bitrate against an H.264 table without converting it first and you'll conclude that a lean phone clip is a bad one, then compress it accordingly. I was penalising files for being efficient.&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;CODEC_EFFICIENCY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;avc&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="na"&gt;vp8&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;vp9&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hevc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.45&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;av1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;prores&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ProRes sits below 1 because it's intra-only: it spends enormous bitrates on a picture H.264 holds for a fraction. An unknown codec assumes 1.0 — if I assumed anything more efficient I'd inflate my estimate of the source's quality and over-compress it.&lt;/p&gt;

&lt;p&gt;But that conversion can ask for a &lt;em&gt;bigger&lt;/em&gt; file than the source. Holding an HEVC or AV1 picture in H.264 really does cost 45–60% more bits, so the conversion times a high retention can land above what the source actually spent. A button labelled "compress" handing back a larger file is indefensible no matter how good my image-quality argument is, so I cap it hard at &lt;code&gt;0.95 × source&lt;/code&gt;. When those two goals collide, compressing wins.&lt;/p&gt;

&lt;p&gt;Audio had the identical bug, and this is the one I'm least proud of: a 684 kbps clip with a 48 kbps audio track came back &lt;strong&gt;10% larger&lt;/strong&gt; on my lightest setting, because I re-encoded the audio at 128 kbps. I re-encode audio rather than copy it, so asking for more bits than the original carried invents nothing — it just makes the track bigger than the one it replaces. On a thin video track, that alone flips the whole file. Now I treat the source's audio bitrate as a ceiling, not a target.&lt;/p&gt;

&lt;h2&gt;
  
  
  Browsers lie to you as well
&lt;/h2&gt;

&lt;p&gt;Three of these, quickly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Codec support answers are optimistic.&lt;/strong&gt; I asked Chrome whether AAC could handle 16–24 kbps mono. It said yes — an &lt;code&gt;isConfigSupported&lt;/code&gt;-style answer, not a real encode attempt. Then it threw &lt;code&gt;OperationError: Encoding error&lt;/code&gt; on the very first sample. I reproduced it at 16000, 24000 and 25969 bps alike, on a freshly created worker's first encode, so it isn't a warm-up artifact either. I stopped asking and hardcoded the split: Opus below 64 kbps, AAC at or above. Opus is the better codec down there anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Firefox ships an AVC encoder and no AAC encoder.&lt;/strong&gt; The tempting read is that Firefox needs my ffmpeg path. It doesn't — MP4 carries Opus perfectly well, and pulling 32 MB of wasm over someone's connection because of an audio track would be ridiculous. So my engine decision checks exactly one thing, on purpose: &lt;code&gt;canEncodeVideo('avc')&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hardware encoder sessions are a shared, limited resource&lt;/strong&gt;, and closing one doesn't always free it right away. Compress twice back to back in the same tab and the second can fail with that same generic &lt;code&gt;OperationError&lt;/code&gt; — and so can every attempt after it, until you reload the page. I now treat that specific &lt;code&gt;DOMException&lt;/code&gt; as recoverable and drop to &lt;code&gt;ffmpeg.wasm&lt;/code&gt; for the rest of the run, because software encoding never touches the session I couldn't get.&lt;/p&gt;

&lt;h2&gt;
  
  
  A constant bitrate request is a ceiling, not a promise
&lt;/h2&gt;

&lt;p&gt;In target-size mode I check the output and re-encode when I miss. Correcting an overshoot is mandatory — "at most 25 MB" is a promise I made.&lt;/p&gt;

&lt;p&gt;Undershoot is the interesting direction. It's almost never because I asked for too few bytes; I asked for the target. It's because the encoder declined to spend them. Most WebCodecs implementations treat a constant-bitrate request as a ceiling, and easy content simply costs less than the budget allows.&lt;/p&gt;

&lt;p&gt;Which means the right answer to an undershoot is to ask for &lt;em&gt;more&lt;/em&gt; than the target. I used to clamp the corrected ask to &lt;code&gt;desiredBytes * 0.97&lt;/code&gt;, so correcting a 28 MB result against a 60 MB target handed back an ask of 58.2 MB — smaller than the ask that had just undershot. My next pass faithfully reproduced 28 MB and gave up.&lt;/p&gt;

&lt;p&gt;I raise it once, though, not repeatedly. An encoder undershooting because the content is cheap will undershoot at any budget, and every extra pass is a full re-encode someone sits and waits through.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line that looked like a no-op
&lt;/h2&gt;

&lt;p&gt;Passing &lt;code&gt;frameRate&lt;/code&gt; into the conversion when it already equals the source's rate is not a no-op. Mediabunny quantises every sample onto a rigid &lt;code&gt;1/frameRate&lt;/code&gt; grid and drops or duplicates whichever sample lands in a bucket that's already full. Real footage is rarely perfectly constant-frame-rate — auto-exposure and capture jitter alone push samples off any fixed grid, and a VFR source has no single rate to quantise to at all. I forced that grid when I didn't need to, and it produced real judder. Now I pass it only when I deliberately changed the rate, and the same goes for ffmpeg's &lt;code&gt;-r&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One more, free: round output dimensions &lt;strong&gt;down&lt;/strong&gt; to even, never to nearest. H.264 wants even dimensions for 4:2:0 chroma, and real captures and crops do produce odd heights. Rounding 1081 up to 1082 means encoding a frame larger than the source — interpolating pixels that were never there, for a softer picture at more bits.&lt;/p&gt;

&lt;h2&gt;
  
  
  What they all had in common
&lt;/h2&gt;

&lt;p&gt;Every one of these came from taking a number at face value:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a bitrate I computed from file size and called a video bitrate&lt;/li&gt;
&lt;li&gt;a quality table describing first-generation encodes, which I applied to footage that had been through Messenger&lt;/li&gt;
&lt;li&gt;a codec-support API answering about a configuration it had never tried&lt;/li&gt;
&lt;li&gt;a bitrate request the encoder treated as optional&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The encoders were fine. WebCodecs and ffmpeg.wasm both did exactly what I asked. Every bug was in my asking.&lt;/p&gt;

&lt;p&gt;If you're building something in this shape, here's the rule that would have saved me most of it: &lt;strong&gt;judge the source against itself, not against a table.&lt;/strong&gt; An absolute standard, applied to a file that never met it, will always conclude the file has to be destroyed for its own good.&lt;/p&gt;




&lt;p&gt;The compressor all this came out of is at &lt;a href="https://squishyfile.com" rel="noopener noreferrer"&gt;squishyfile.com&lt;/a&gt; — runs in your browser, nothing uploaded. Ask me anything in the comments, I'll go as deep as you want.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>webassembly</category>
    </item>
    <item>
      <title>Convert MP4 to Transcript</title>
      <dc:creator>Max/Wang</dc:creator>
      <pubDate>Thu, 17 Sep 2026 10:14:50 +0000</pubDate>
      <link>https://dev.to/maxslashwang/convert-mp4-to-transcript-3hp9</link>
      <guid>https://dev.to/maxslashwang/convert-mp4-to-transcript-3hp9</guid>
      <description>&lt;p&gt;Running speech recognition locally is possible, but the real challenge isn't just making the model run in a browser.&lt;/p&gt;

&lt;p&gt;You have to balance model size, processing speed, memory usage, language support and transcription quality — all on hardware you don't control.&lt;/p&gt;

&lt;p&gt;The result is a different trade-off from cloud transcription: no upload or server processing, but a smaller model and more limited language coverage.&lt;/p&gt;

&lt;p&gt;I built an MP4 → text tool around that approach. It currently supports English, Spanish, Japanese, Chinese and Korean, with TXT, SRT and VTT export.&lt;/p&gt;

&lt;p&gt;Try it here: &lt;a href="https://squishyfile.com/mp4-to-transcript" rel="noopener noreferrer"&gt;https://squishyfile.com/mp4-to-transcript&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>javascript</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I Built My Own Free Background Remover</title>
      <dc:creator>Max/Wang</dc:creator>
      <pubDate>Wed, 16 Sep 2026 14:15:48 +0000</pubDate>
      <link>https://dev.to/maxslashwang/i-built-my-own-free-background-remover-2lk9</link>
      <guid>https://dev.to/maxslashwang/i-built-my-own-free-background-remover-2lk9</guid>
      <description>&lt;p&gt;Recently, remove.bg started moving its background removal experience to Canva.&lt;/p&gt;

&lt;p&gt;So I started looking around for alternatives.&lt;/p&gt;

&lt;p&gt;There are quite a few good ones. Adobe has a pretty decent background remover, for example. The actual results are surprisingly good.&lt;/p&gt;

&lt;p&gt;But then I hit the usual problems.&lt;/p&gt;

&lt;p&gt;Limits.&lt;/p&gt;

&lt;p&gt;Login requirements.&lt;/p&gt;

&lt;p&gt;And sometimes you can process an image for free, but you need to sign in before you can actually download the result.&lt;/p&gt;

&lt;p&gt;I also came across &lt;code&gt;rembg&lt;/code&gt;, which is completely free and works really well. But I wanted something that could handle &lt;strong&gt;HEIC and HEIF&lt;/strong&gt; images too, since a lot of photos these days come directly from iPhones.&lt;/p&gt;

&lt;p&gt;So, naturally, I ended up coding my own.&lt;/p&gt;

&lt;h2&gt;
  
  
  A background remover that runs in the browser
&lt;/h2&gt;

&lt;p&gt;I wanted to keep the whole thing simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Free&lt;/li&gt;
&lt;li&gt;No account&lt;/li&gt;
&lt;li&gt;No download limits&lt;/li&gt;
&lt;li&gt;Supports JPG, PNG, WebP, &lt;strong&gt;HEIC and HEIF&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Background removal happens locally in the browser&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the model, I went with &lt;strong&gt;BiRefNet-lite&lt;/strong&gt; and converted it to ONNX so I could run inference with ONNX Runtime Web.&lt;/p&gt;

&lt;p&gt;The interesting part is that this doesn't need a backend GPU.&lt;/p&gt;

&lt;p&gt;It uses &lt;strong&gt;WebGPU&lt;/strong&gt; when available, so the model can run directly on the user's machine.&lt;/p&gt;

&lt;p&gt;I've tested it on a few different images and, honestly, the results are pretty good. Hair, people, products, objects, etc. are generally handled quite nicely.&lt;/p&gt;

&lt;p&gt;It's not magic, of course. Background removal is one of those tasks where there will always be difficult images — messy hair, low contrast, objects blending into the background, and so on.&lt;/p&gt;

&lt;p&gt;But for a lightweight browser tool, I'm pretty happy with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why BiRefNet-lite?
&lt;/h2&gt;

&lt;p&gt;I also looked at other models, including RMBG-1.4.&lt;/p&gt;

&lt;p&gt;RMBG-1.4 can be very fast in my testing, and the output quality is in the same general range.&lt;/p&gt;

&lt;p&gt;However, licensing matters when you're building a public commercial website, so I ended up using BiRefNet-lite for this project. The model is released under the MIT license, which makes things much simpler for this kind of use.&lt;/p&gt;

&lt;p&gt;The trade-off is that inference isn't necessarily as fast as RMBG-1.4 on every setup.&lt;/p&gt;

&lt;p&gt;That's something I'm still interested in optimizing.&lt;/p&gt;

&lt;h2&gt;
  
  
  One important limitation
&lt;/h2&gt;

&lt;p&gt;There is one fairly big requirement:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your browser needs WebGPU.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If your device or browser doesn't support WebGPU, this tool won't work.&lt;/p&gt;

&lt;p&gt;That's the trade-off for running a relatively capable AI model directly on the user's machine instead of sending images to a server.&lt;/p&gt;

&lt;p&gt;For newer laptops, desktops, and phones, WebGPU support is becoming much more common, though.&lt;/p&gt;

&lt;h2&gt;
  
  
  HEIC/HEIF was actually one of the reasons I built it
&lt;/h2&gt;

&lt;p&gt;This was probably the biggest practical motivation.&lt;/p&gt;

&lt;p&gt;If you're using an iPhone, there's a good chance you've encountered HEIC images.&lt;/p&gt;

&lt;p&gt;I wanted to be able to drop one of those images into the tool, remove the background, and download the result without having to convert it somewhere else first.&lt;/p&gt;

&lt;p&gt;So now it does exactly that.&lt;/p&gt;

&lt;p&gt;Anyway, this started as one of those "why doesn't this simple thing work the way I want?" projects and turned into another little tool for SquishyFile.&lt;/p&gt;

&lt;p&gt;You can try it here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://squishyfile.com/image/transparent-background-creator" rel="noopener noreferrer"&gt;https://squishyfile.com/image/transparent-background-creator&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Hopefully some of you find it useful. 🙂&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>javascript</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
