Filehinge
Support

· by the Filehinge team

How Filehinge processes files 100% in your browser

WebAssembly, WebCodecs, WebGPU, OPFS, and a manifest format that lets anyone verify the WASM they are running.

The problem with cloud converters

If you have ever searched for "heic to jpg" you have seen the landscape. Dozens of sites offer to convert your file in exchange for an upload. The model is: you hand over the bytes, they run ImageMagick or libheif on their server, they hand you back a result. Sometimes the result is watermarked. Sometimes it is behind an email capture. Often the terms of service reserve a perpetual, irrevocable license to use your file for "service improvement."

None of this is new; none of it is malicious on its face; and none of it was really necessary in the first place. Every browser shipped since about 2019 has had the codecs, the compute primitives, and the storage APIs needed to do the same job in the tab. Filehinge is the version of this built with that assumption.

Browser capabilities in 2026

The picture is better than most developers realize. Four recent additions, taken together, make a real converter possible:

  • WebAssembly — C and C++ libraries (libheif, libvpx, x264, pdf-lib, lamejs’s predecessor LAME) run at near-native speed inside a memory-isolated sandbox. WASM SIMD is now universal; WASM threads are common.
  • WebCodecs — direct access to the browser’s hardware-accelerated audio and video decoders. Available in Chrome 94+, Firefox 130+, Safari 18+. No codec to ship; the OS already has one.
  • WebGPU — general-purpose GPU compute in a cross-platform API. Compute shaders for resize, color conversion, DCT. An order of magnitude faster than OffscreenCanvas draws on images past ~24 megapixels.
  • Origin Private File System (OPFS) — a real filesystem per origin, scoped to the browser, no user picker involved. Useful for staging files larger than memory.

Two more matter when a converter has to hold state across reloads: the File System Access API gives the user control over where results land, and Service Workers cache every static asset so the app runs without a network after the first visit.

Our architecture

There is one primary constraint: the user’s file never leaves the browser. Every other decision followed from that. Here is the flow end to end:

Filehinge data flowYour browser tabYour file(input)Result(in memory)Converter, in this tabdecode → process (WASM / WebGPU)→ encode → hand back the resultNo code here sends your fileover the network.Filehinge CDN (Cloudflare)HTML, JavaScript, CSS, fonts,WASM (SHA-256 pinned), manifests;OCR models from jsDelivrdownload only, no upload endpointfilesAny serverThere is no upload step andno code path that sendsyour file anywhere.Other scripts: blocked by CSPnever

A request comes in for /heic-to-jpg/. Next.js serves a static HTML shell. React hydrates. The ConverterScreen component mounts. When the user hovers or drags, we preload the converter’s manifest and WASM (the hover intent hint). On drop, a dedicated Web Worker is spawned, the WASM is verified against the manifest SHA-256, and the converter runs. The result is a Blob. The user saves it.

There is no fetch(POST) anywhere in this path. If you open DevTools and watch Network while a conversion runs, you see requests for static JS and WASM files going out, and nothing coming back except those assets. There is no backend.

The Converter Manifest Format

Every converter ships with a JSON manifest at /public/manifests/[id].json. Here is heic-to-jpg:

{
  "id": "heic-to-jpg",
  "version": "1.0.0",
  "name": "HEIC to JPG",
  "inputFormats": ["image/heic", "image/heif"],
  "outputFormats": ["image/jpeg"],
  "wasmModules": [],
  "avgMemoryMB": 256,
  "license": "MIT",
  "sourceUrl": "https://github.com/alexcorvi/heic2any",
  "runtime": "main"
}

The interesting fields are wasmModules and runtime. A converter that uses real WASM (like jpg-to-avif, which pulls in jSquash’s AVIF encoder, or the mp4-to-mp3 fallback that uses ffmpeg-core.wasm) lists every module by URL and SHA-256. The worker fetches each module, hashes it with Web Crypto, and refuses to run if the hash is off by a single byte.

async function fetchAndVerify(ref: WasmModuleRef): Promise<ArrayBuffer> {
  const res = await fetch(ref.url);
  const bytes = await res.arrayBuffer();
  const actual = await sha256Hex(bytes);
  if (actual !== ref.sha256) {
    throw new IntegrityError({ url: ref.url, expected: ref.sha256, actual });
  }
  return bytes;
}

This is stricter than the normal <script integrity="sha-..."> attribute. Subresource Integrity (SRI) on a script tag tells the browser what to check; the manifest makes that check visible to the user. Our /verify page lists the full set of hashes, and the Prove it button on any converter page will re-fetch and recompute live, so the hash appears in a terminal-style log aria-live’d for screen readers.

The cryptographic verification layer

A common question: what stops us from swapping in a modified WASM after users trust the page? The short answer is nothing, if you only look at one visit. The longer answer is that three layers stack, and all three would have to fail silently:

  • The CSP header limits scripts to this site, plus Plausible for analytics and jsDelivr for the OCR engines. A compromised CDN could replace a WASM file we serve; its SHA-256 would then fail to match the manifest and the converter would refuse to run it.
  • The manifests ship with the site and list the hash of every module. The runtime checks each module before it starts, and the verify button repeats that check in your own browser.
  • The /verify page lists every manifest with its hash and a link to the module's upstream source, so anyone can fetch the same release and compare. If they disagree, that is evidence.

None of this is bulletproof. A state-level attacker can beat any of the three. The point is that silent compromise is expensive, and loud compromise is detectable by ordinary users. That is a much better posture than an opaque upload.

WebGPU results on a real laptop

We shipped a bilinear resize compute shader in Week 3 and wired it into jpg-to-avif and png-to-webp. The pipeline is: createImageBitmap decodes the JPG, copyExternalImageTo Texture uploads it to the GPU, a single-kernel WGSL shader samples and writes to an output texture, the result comes back via copyTextureToBuffer with the 256-aligned row padding stripped, then jSquash encodes AVIF.

Wall-clock numbers for the resize step on a 24 MP source (6000x4000) down to 1920px long edge:

  • OffscreenCanvas drawImage on an M2 MacBook Air: 180-220 ms
  • WebGPU compute shader on the same machine: 8-15 ms
  • Same shader on a mid-range Windows laptop with integrated Iris Xe: 12-25 ms

That is a real 10-20x for the resize step alone. The encode itself is still CPU-bound through jSquash; moving that to a GPU encoder is on the roadmap (WebGPU does not yet have a universal image encode path, so this likely means a custom DCT + quantize pipeline).

A caveat: the first WebGPU dispatch on a cold page includes pipeline compilation, which adds 10-40 ms. On the second conversion it disappears. We preload the pipeline when the dropzone sees a hover, so in practice the user never pays that cold-start cost.

What we got wrong

A few painful hours, since you asked:

  • Source maps and Cloudflare Pages. We left source maps on for the first deploy. Wrangler’s batched upload API choked on the .map files and threw cryptic errors in a loop. We now gate sourceMaps behind a manual build.
  • ffmpeg-core.wasm is too big for Cloudflare Pages. The file is 30.7 MB. Pages enforces 25 MiB per file. We strip the file from production builds and have R2-backed WASM serving on the roadmap. The practical effect today is that browsers without WebCodecs see a clear error for mp4-to-mp3, instead of silently failing.
  • Dark mode default. We shipped prefers-color-scheme auto-apply, reverted it a day later. Light default with a manual toggle reads better for a utility site.
  • Multi-page PDF output filename. The pdf-to-jpg converter emits a ZIP when given a multi-page PDF. The old code used the manifest’s default output type (image/jpeg) to name the file, so users got sample.jpg when they actually downloaded a ZIP. Fixed by letting the actual blob MIME win.
  • Turbopack + Serwist. @serwist/next does not support Turbopack yet. We use Turbopack in dev and --webpack in build, which is an annoying split to document.

What’s next

Week 4 brought us to 20 converters, a technical blog post (this one), launch documentation, and a pricing page with a real money-back guarantee. The next set of things we are working on:

  • PDF editing and merge. images-to-pdf (many-to-one), pdf merge, and signature placement. pdf-lib does the heavy lifting; the hard part is a UI that does not feel like Adobe.
  • UDF to PDF/DOCX/ODT. UDF is the format used by UYAP, the Turkish national court system. There is no public spec. Reverse-engineering it is a sprint unto itself and would be a genuine first for the open web.
  • 3D model and CAD converters. STL, OBJ, GLTF. Then DWG and DXF. Then, maybe, STEP.
  • AI-enhanced OCR via Transformers.js. ONNX models for text recognition fit in under 50 MB and can run on WebGPU. A PDF OCR converter that keeps the layout is within reach.

All of it, in the browser. None of it, uploaded. Questions, bug reports, and format requests go to [email protected].

Discuss on Hacker News or r/webdev.