<?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: x3-team</title>
    <description>The latest articles on DEV Community by x3-team (@x3team).</description>
    <link>https://dev.to/x3team</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%2F4198891%2F0b97066e-adc4-4942-8731-0b527202e7be.png</url>
      <title>DEV Community: x3-team</title>
      <link>https://dev.to/x3team</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/x3team"/>
    <language>en</language>
    <item>
      <title>How I built 60+ file tools that never upload your files</title>
      <dc:creator>x3-team</dc:creator>
      <pubDate>Wed, 21 Oct 2026 17:20:45 +0000</pubDate>
      <link>https://dev.to/x3team/how-i-built-60-file-tools-that-never-upload-your-files-40ng</link>
      <guid>https://dev.to/x3team/how-i-built-60-file-tools-that-never-upload-your-files-40ng</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure up front: I'm Vitalii, and I built &lt;a href="https://nibbo.org" rel="noopener noreferrer"&gt;Nibbo&lt;/a&gt;, the project this post is about. I'm a solo developer. What follows is how it works, including the parts that still don't work well.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most "free online converters" follow the same pattern: you pick a file, it gets uploaded to their server, a job runs there, and you download the result. That's fine for a meme GIF. It's less fine for a passport scan, a signed contract, or a private video.&lt;/p&gt;

&lt;p&gt;So I set myself one rule: &lt;strong&gt;the site has no server that could receive a file.&lt;/strong&gt; Not "we delete it after an hour", but no endpoint for it at all. Today that rule covers 67 tools: video compression to an exact size, photo compression to a target KB, PDF merge/split/compress/sign, background removal, subtitles from speech, HEIC→JPG, MP4→MP3 and so on.&lt;/p&gt;

&lt;p&gt;This post covers the architecture, the memory problems you hit with 3 GB inputs, offline support, and how you can check the "no upload" claim yourself in about 30 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack: a static site with heavy islands
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Astro&lt;/strong&gt; builds a fully static site, with &lt;strong&gt;Preact&lt;/strong&gt; islands for the interactive tools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Pages&lt;/strong&gt; hosts it. The only server-side code is two small Pages Functions: a language redirect on &lt;code&gt;/&lt;/code&gt; and an endpoint that returns the visitor's country (for a flag in the UI). Neither one accepts a request body.&lt;/li&gt;
&lt;li&gt;All processing happens in the browser, using:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WebCodecs&lt;/strong&gt; + &lt;strong&gt;mp4box.js&lt;/strong&gt; (demux) + &lt;strong&gt;mp4-muxer&lt;/strong&gt; for hardware-accelerated video&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ffmpeg.wasm&lt;/strong&gt; (single-threaded core) as the fallback for containers WebCodecs can't handle&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;jSquash&lt;/strong&gt; (AVIF/WebP/JPEG XL) and &lt;strong&gt;libheif-js&lt;/strong&gt; (HEIC) for images&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pdf-lib&lt;/strong&gt;, &lt;strong&gt;qpdf&lt;/strong&gt; compiled to wasm, and &lt;strong&gt;pdf.js&lt;/strong&gt; for PDFs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tesseract.js&lt;/strong&gt; for OCR&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ONNX Runtime Web / transformers.js&lt;/strong&gt; for on-device models (Whisper for subtitles, BiRefNet-lite and MODNet for background removal), running on WebGPU with a WebAssembly fallback&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One constraint shapes everything else. Each of those libraries weighs megabytes, and plenty of my visitors are on slow, expensive mobile data. So the build &lt;strong&gt;fails&lt;/strong&gt; if any page's first load (HTML + CSS + statically imported JS, gzipped) goes over 200 KB. Every wasm module sits behind a dynamic &lt;code&gt;import()&lt;/code&gt; and is only downloaded when you actually start a tool that needs it.&lt;/p&gt;

&lt;h2&gt;
  
  
  WebCodecs first, ffmpeg.wasm second
&lt;/h2&gt;

&lt;p&gt;ffmpeg.wasm is the obvious way to do video in a browser, and it's great. It's also slow for encoding: x264 on a single wasm thread is no match for the hardware encoder your GPU already has. So video compression uses a hybrid engine:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Probe.&lt;/strong&gt; MP4/MOV/M4V/3GP (anything ISO-BMFF) is demuxed with mp4box.js. Everything else (MKV, AVI, WMV, FLV, TS…) goes to the fallback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encode&lt;/strong&gt; with &lt;code&gt;VideoEncoder&lt;/code&gt; (H.264, hardware where available). Some Chromium builds ship without an H.264 encoder, so those get VP9-in-MP4 instead, which is still far faster than software x264.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fall back&lt;/strong&gt; to x264 in ffmpeg.wasm only if WebCodecs is missing, can't decode the input, or fails. The ~32 MB FFmpeg core is downloaded only in that case.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I deliberately use the &lt;strong&gt;single-threaded&lt;/strong&gt; FFmpeg core. The multi-threaded one needs &lt;code&gt;SharedArrayBuffer&lt;/code&gt;, which means cross-origin isolation (COOP/COEP headers), and that breaks a lot of ordinary embedding and third-party behavior across the whole site. Being slower on rare formats is the better trade.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hitting an exact size
&lt;/h3&gt;

&lt;p&gt;"Compress to under 20 MB for Discord" is a bitrate budget problem:&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;// target bytes → total bitrate, keeping ~6% headroom for encoder overshoot&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;totalBitrateBudget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;targetBytes&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;durationSec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;overheadBytes&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;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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;targetBytes&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;safety&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;overheadBytes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;payload&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Audio gets a small tiered slice, and the rest goes to video. Then the planner walks a resolution ladder (2160 → 240) and picks the highest rung where bits-per-pixel stays above a minimum that depends on motion. Motion is estimated by measuring spatial and temporal complexity (SI/TI) on a few sampled GOPs. It's a cheap stand-in for a proper quality metric, but it beats shipping a 1080p blur fest. If the output still overshoots, the bitrate is scaled by &lt;code&gt;target / actual × 0.97&lt;/code&gt; and the job runs again, up to 3 attempts. If the target can't work at all, the tool says so up front and tells you the minimum size that would.&lt;/p&gt;

&lt;p&gt;Images use the same idea with a cheaper loop: binary search on encoder quality, and only if the quality floor still doesn't fit, shrink the pixel dimensions and search again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 3 GB problem: never hold the file
&lt;/h2&gt;

&lt;p&gt;The free limit is 3 GB per file. The first naive version did &lt;code&gt;await file.arrayBuffer()&lt;/code&gt;, and that's how you crash a tab with a big video.&lt;/p&gt;

&lt;p&gt;What helped, roughly in order of impact:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Read only the index.&lt;/strong&gt; In most MP4s the &lt;code&gt;moov&lt;/code&gt; box (the sample table) sits after the giant &lt;code&gt;mdat&lt;/code&gt; payload. mp4box.js can be fed slices and tells you which offset it needs next, so it jumps over &lt;code&gt;mdat&lt;/code&gt; entirely. Opening a multi-GB file reads only box headers and &lt;code&gt;moov&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Stream payloads in windows.&lt;/strong&gt; Sample data is read on demand, grouping nearby samples into one 8 MB &lt;code&gt;Blob.slice()&lt;/code&gt; read:&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;readSamples&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;Blob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;Sample&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;windowBytes&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="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;winStart&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;win&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;samples&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;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;win&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;winStart&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;winStart&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;win&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byteLength&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;winStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;win&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;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;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;windowBytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;win&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;winStart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;winStart&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;&lt;strong&gt;3. Backpressure on the codecs.&lt;/strong&gt; &lt;code&gt;VideoDecoder&lt;/code&gt; and &lt;code&gt;VideoEncoder&lt;/code&gt; happily accept thousands of queued chunks, which turns into thousands of decoded frames in GPU/CPU memory. I wait until &lt;code&gt;decodeQueueSize&lt;/code&gt; / &lt;code&gt;encodeQueueSize&lt;/code&gt; drops below 24 before feeding more.&lt;/p&gt;

&lt;p&gt;The non-obvious part: my first version polled with &lt;code&gt;setTimeout&lt;/code&gt;. In a hidden tab Chrome throttles timers to once per second (and much more after a few minutes), so compression slowed to a crawl whenever the user switched tabs. Listening for the codec's &lt;code&gt;dequeue&lt;/code&gt; event fixed it. The timer stays only as a safety net.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Don't copy into the wasm heap.&lt;/strong&gt; For the FFmpeg path, &lt;code&gt;ff.writeFile()&lt;/code&gt; would copy the whole input into wasm memory, which is 32-bit (4 GiB at most) and shared with everything else FFmpeg needs. Mounting the &lt;code&gt;File&lt;/code&gt; read-only via &lt;strong&gt;WORKERFS&lt;/strong&gt; lets FFmpeg read it in place:&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;await&lt;/span&gt; &lt;span class="nx"&gt;ff&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="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;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;ff&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="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;file&lt;/span&gt;&lt;span class="p"&gt;]&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;ff&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="s2"&gt;`/input/&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&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;ff&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="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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the streaming demux, my notes show a 1 GB 1080p VP9 test file going from about 3.0 GB peak tab memory to about 1.9 GB. The honest limits right now: the &lt;strong&gt;encoded output is still assembled in memory&lt;/strong&gt; (about the size of the target, so it's fine for a "make it 25 MB" job), and &lt;strong&gt;fragmented MP4s are still loaded whole&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Offline: a boring service worker, on purpose
&lt;/h2&gt;

&lt;p&gt;Once you've opened a page, it should work with Wi-Fi off. The service worker uses three plain strategies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Navigations: network-first&lt;/strong&gt;, falling back to cache, so deploys show up right away when you're online.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hashed build assets and vendored wasm (&lt;code&gt;/_astro/&lt;/code&gt;, &lt;code&gt;/vendor/&lt;/code&gt;): cache-first.&lt;/strong&gt; They're immutable by URL.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Everything else from the site: stale-while-revalidate.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two things made this harder than the tutorials suggest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Pages has a 25 MiB per-file limit.&lt;/strong&gt; The FFmpeg wasm (~32 MB) and ONNX Runtime don't fit, so they come from jsDelivr at a pinned version, and the browser &lt;strong&gt;refuses them unless the SHA-256 matches&lt;/strong&gt; the copy from npm baked in at build time. Model weights come from Hugging Face at pinned commits, are checked the same way, and are then kept in Cache Storage, so the second run (and offline runs) skip the network.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The service worker only handles same-origin requests.&lt;/strong&gt; Third-party files rely on the cache I manage myself (models) or on the browser's HTTP cache (the FFmpeg core). Pre-warming those is still on my roadmap. Caveat noted.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The PWA manifest also registers &lt;code&gt;file_handlers&lt;/code&gt; and a &lt;code&gt;share_target&lt;/code&gt;. Files shared from your phone's share sheet go into the site's own IndexedDB on the device, not to a server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't trust me, check it
&lt;/h2&gt;

&lt;p&gt;"We don't upload your files" is a claim. Here's how to test it on any site, including mine:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open DevTools → &lt;strong&gt;Network&lt;/strong&gt;, tick &lt;strong&gt;Preserve log&lt;/strong&gt;, and clear the list.&lt;/li&gt;
&lt;li&gt;Drop in a large file and run the tool.&lt;/li&gt;
&lt;li&gt;Look for requests with a request body about as big as your file (POST/PUT, or chunked uploads to &lt;code&gt;/upload&lt;/code&gt;, &lt;code&gt;/api/…&lt;/code&gt;, S3, etc.). Sorting by size and checking the &lt;strong&gt;Payload&lt;/strong&gt; tab makes this quick.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;On Nibbo you'll see GET requests for code and, the first time, for wasm or model weights. If you accepted the cookie banner, you'll also see analytics hits (GA4 / Yandex Metrica), which carry events like "tool started, mp4, ~120 MB". They never include file names or contents. If you declined, those aren't loaded at all.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Even simpler: load the tool page, &lt;strong&gt;turn on airplane mode&lt;/strong&gt;, then process a file. If it works, nothing was sent.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;On top of that, there's a &lt;code&gt;Content-Security-Policy&lt;/code&gt; with an explicit &lt;code&gt;connect-src&lt;/code&gt; allowlist (the site itself, jsDelivr, Hugging Face, and the two analytics hosts). Even a buggy or compromised script on the page can't open a connection to an arbitrary server. The browser enforces it, so it isn't just a promise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Feature-detect and fall back in layers.&lt;/strong&gt; WebCodecs H.264 → WebCodecs VP9 → ffmpeg.wasm covers the browsers I've tested. Each layer needs its own error path, because "supported" in &lt;code&gt;isConfigSupported&lt;/code&gt; doesn't always mean it works on a given file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory is the real limit, not CPU.&lt;/strong&gt; Design for &lt;code&gt;Blob.slice()&lt;/code&gt; from day one. Retrofitting streaming onto code that assumed an &lt;code&gt;ArrayBuffer&lt;/code&gt; was the most painful refactor I did.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hidden tabs are hostile.&lt;/strong&gt; Anything long-running that depends on timers will stall. Use events.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single-threaded wasm is a valid choice&lt;/strong&gt; if threads would force cross-origin isolation on your whole site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put a page-weight budget in CI early.&lt;/strong&gt; Seven wasm-heavy libraries will creep into your landing page bundle otherwise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin and hash everything you load from a CDN.&lt;/strong&gt; It's a few lines of &lt;code&gt;crypto.subtle.digest&lt;/code&gt; and it closes a whole class of supply-chain problems.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Nibbo is free, needs no account, adds no watermark, and is available in 16 languages. The code isn't public yet. If you want to poke at it, &lt;a href="https://nibbo.org" rel="noopener noreferrer"&gt;nibbo.org&lt;/a&gt; is the place. I'm most interested in files that break it and in how fast the video encoder runs on your hardware. Comments here work, and so do bug reports with a sample file.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>privacy</category>
      <category>webassembly</category>
    </item>
  </channel>
</rss>
