How VisualRefiner converts HEIC, compresses images, and transcodes video with no server, no upload, and no account — using the Canvas API, WebCodecs, and a little WebAssembly.
Most "free online converters" work like this: you pick a file, it's uploaded to a server, converted there, and sent back. That's fine for a meme. It's less fine for a folder of personal photos, a passport scan, or client footage under NDA.
I built VisualRefiner to do the same jobs without the upload. Every conversion runs in the browser tab on your own machine. There is no backend to send files to — the whole site is a static export — so "your files never leave your device" isn't a privacy promise, it's just how the architecture works. Here's the interesting part of how it's put together.
The core: <canvas> is a format converter
The humble 2D canvas is already an image transcoder. Decode any image the browser understands into an ImageBitmap, draw it, and re-encode with toBlob() — the MIME type you pass decides the output format:
async function convert(file, type = "image/jpeg", quality = 0.82) {
const bitmap = await createImageBitmap(file);
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
canvas.getContext("2d", { alpha: true }).drawImage(bitmap, 0, 0);
bitmap.close();
return new Promise((resolve, reject) =>
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error("encode failed"))),
type, // "image/jpeg" | "image/png" | "image/webp"
quality, // ignored for PNG (lossless)
),
);
}
That single function is PNG↔JPG↔WebP conversion and JPG/WebP compression (the quality argument) all at once. No dependency, no upload, works offline. The result is a Blob; wrap it in URL.createObjectURL() for a preview or a download link.
Two details that bite you if you skip them:
-
Revoke your object URLs. Each
createObjectURLpins the blob in memory until youURL.revokeObjectURL()it. In a tool people use repeatedly, forgetting this is a slow leak. Revoke the previous URL every time you produce a new one. -
PNG ignores
quality. A "quality slider" on a PNG export does nothing — PNG is lossless. Hide the control or convert to WebP if the user wants a smaller file with a quality knob.
HEIC: the format the canvas can't read
Then there's HEIC — the format iPhones save by default. Most browsers won't decode it in a <canvas> pipeline, so createImageBitmap() throws. The fix is to decode it ourselves first, with libheif compiled to WebAssembly (via heic2any):
async function normalize(file, quality) {
const isHeic = /heic|heif/i.test(file.type) || /\.hei[cf]$/i.test(file.name);
if (!isHeic) return file;
// Only pulled in when someone actually hands us a HEIC file.
const { default: heic2any } = await import("heic2any");
const out = await heic2any({ blob: file, toType: "image/jpeg", quality });
return Array.isArray(out) ? out[0] : out;
}
Now HEIC flows into the same canvas function as everything else. The WASM decoder is the heaviest thing on the site (~2 MB), which brings up the next point.
Load the heavy parts lazily
If you bundle libheif, a video engine, and a GIF encoder into your main chunk, your homepage pays for tools the visitor may never touch. The trick is a dynamic import() at the moment of use — note the await import(...) inside both functions above and below. The bundler splits each into its own chunk, and the network tab confirms the 2 MB HEIC decoder only downloads when someone converts a HEIC file. The homepage ships a small bundle; the weight arrives on demand.
High-quality resizing is the same story. Canvas drawImage downscaling aliases badly (jagged edges, moiré). pica does proper Lanczos-style resampling, so it's loaded only on the resize path:
if (needsResize) {
const { default: pica } = await import("pica");
await pica().resize(sourceCanvas, targetCanvas);
}
Video, without FFmpeg-in-WASM
Video is where people reach for ffmpeg.wasm, but it's a big download and slow. Modern browsers expose WebCodecs — hardware-accelerated encode/decode built into the browser. VisualRefiner drives it through mediabunny to transcode clips to MP4 or WebM, extract frames as PNG, or build a GIF (with gifenc) — all in the tab.
The honest caveat: WebCodecs support and the available codecs depend on the browser. So conversion isn't guaranteed for every exotic input the way the canvas image path is. The tool checks and tells you when a track can't be decoded, rather than pretending. That trade — depend on the platform, degrade honestly — beats shipping a 25 MB WASM FFmpeg to every visitor.
Why static export makes the privacy claim trivially true
The site is a Next.js app exported to static files (output: "export") and served as plain assets from a CDN. There is no API route, no server that receives a file, nothing to log. You don't have to trust a privacy policy — open DevTools → Network, convert something, and watch: zero upload requests. The bytes stay in the tab.
That also means the "server cost" of a converter that could handle thousands of files is… a static host. All the compute is the user's.
Takeaways
-
<canvas>+toBlob()is a complete image converter/compressor with zero deps. - For formats the browser can't decode (HEIC), a WASM decoder bridges the gap — load it lazily so everyone else doesn't pay for it.
- Prefer WebCodecs over WASM FFmpeg for video when you can accept browser-dependent codec support.
- Dynamic
import()per feature keeps the initial bundle small. - A static export turns "we don't upload your files" from a promise into an architectural fact.
If you want to see it in action, the tools are at visualrefiner.com — HEIC → JPG, image compressor, video to GIF — and the code is on GitHub.
Top comments (0)