Image Converter Web App
A browser-native local-first image converter built with React, TypeScript, Web Workers, and a Rust/WebAssembly npm engine.
Web App Link
Web App: https://image-converter-web-app-project.leonardwalujan.eu.org/
Repository Links
GitHub: https://github.com/walujanle/image-converter-web-app
GitLab: https://gitlab.com/walujanle/image-converter-web-app
Download Links
Build File: https://links.leonardwalujan.eu.org/lw/image-converter-web-app-latest-build-file
Build File Checksum (SHA-256): https://links.leonardwalujan.eu.org/lw/image-converter-web-app-latest-build-file-checksum
Source Code: https://links.leonardwalujan.eu.org/lw/image-converter-web-app-latest-source-code
Screenshots
-
Image Converter Web App Screenshot 1 -
Image Converter Web App Screenshot 2 -
Image Converter Web App Screenshot 3 -
Image Converter Web App Screenshot 4
Short Explanation
Image Converter Web App is a batch image converter that runs inside the browser tab. You add files, decide how they should come out, press start, and get the results back through ordinary browser downloads. The images are never sent anywhere, and that is a structural fact rather than a promise: the repository contains no upload API to send them to.
The conversion work itself is not written here. It comes from my own npm package, @walujanle/image-converter-wasm, a Rust engine compiled to WebAssembly. This repository is the host around that engine — the React interface, two working modes, the queue, the worker pool, saved settings, presets, downloads, ZIP packaging, and the production asset cache.
Input can be JPEG, PNG, WebP, AVIF, HEIC, or HEIF. Output can be JPEG, PNG, WebP, or AVIF. HEIC is read-only, because the engine deliberately does not encode it.
Why I Built This Project
I wanted the whole conversion to happen on the visitor’s own machine, and I wanted that to be verifiable rather than advertised. Most online converters ask you to hand over your photos first and explain their privacy policy second. This one has nothing to hand anything over to.
The more interesting reason is that this app is the first real consumer of my own WASM package. A published API looks fine in a README. It looks different when fifty files are queued, the user cancels at image forty-eight, the browser has a fixed memory ceiling, and a conversion that was already in flight resolves after the run it belonged to has ended. Building the app is what turned the package from something I had written into something I had actually used.
The third reason was control over the result. Output format, quality, encode intent, WebP lossless mode, PNG compression, crop, resize, metadata, filename rules, auto suffixes, batch size, concurrency, presets, dataset logs — all of it belongs to the person converting the image. The goal was never to build a server-side converter service with a queue somebody else owns.
Tech Stack Used
- React 19 for the interface.
- TypeScript 6 in strict mode. The 7.x line is deliberately not used yet; 6.x is the current stable target here.
- Node 24 LTS as the development runtime.
- Vite 8 for the dev server and production builds.
- Bun for scripts and package management.
- Zustand for state, split per mode.
- Tailwind CSS 4 for styling.
- Web Workers so conversion never runs on the UI thread.
@walujanle/image-converter-wasmfor decode, crop, resize, encode, metadata handling, and output dimensions.- JSZip, loaded lazily and only when a ZIP is actually requested.
- A service worker generated at build time for the production asset cache.
- Biome for linting and formatting.
- Vitest for tests.
Dependencies are pinned to exact versions with bun.lock committed, so a checkout months from now resolves the same tree the release was verified against.
The repository is licensed as AGPL-3.0-only, matching the WASM package that ships to the browser with it.
Features
Local-first batch conversion
Images are added to a queue and converted in batches. There is no upload step and no server round trip; files come from the browser’s own file APIs and leave as Blob downloads.
Supported formats
Input covers JPEG, PNG, WebP, AVIF, HEIC, and HEIF. Output covers JPEG, PNG, WebP, and AVIF. GIF, BMP, TIFF, and RAW are not part of the engine’s contract and are not accepted.
Simple and Advanced modes
Simple mode keeps to what most conversions actually need: output format, quality, PNG optimization, the queue, start, cancel, and download. PNG has no quality panel at all, because it encodes losslessly and a slider there would be decoration.
Advanced mode opens the full panel — resize, crop, filename transforms, auto suffixes, dataset log generation, batch size, and the preset manager. The concurrency limit sits in app settings, reachable from either mode.
Independent mode state
The two modes do not share anything. Separate queues, separate settings, separate storage keys. Switching modes mid-session never drags one mode’s configuration into the other.
Convert again
A finished image can be sent back to the queue and converted a second time at a different format or quality without being removed and dropped in again. There is a button per image and a batch button in both modes, and the batch button says how many images it will reset. Cancelled images carry their own badge and are recovered the same way.
Worker-based conversion
Conversion runs in a pool of dedicated Web Workers, each one calling the WASM engine. The pool creates workers up to the configured limit, queues tasks FIFO, and recycles a worker after 100 tasks. Concurrency defaults to the machine’s own hardware thread count, capped at 32.
Background engine warmup
After the page loads, the app warms one worker so the WebAssembly module is compiled before the first conversion instead of during it. A small toast reports the state. If warmup times out, the worker restarts and conversion still initializes on demand — the warmup is an optimization, not a dependency.
Production asset cache
Production builds emit a service worker that precaches the hashed JavaScript, CSS, worker, and WASM assets. The cache name is derived from a hash of the precache list, so a build that changes filenames also changes the cache name and the old caches are dropped on activation. Nothing has to be versioned by hand. In development the same module clears stale caches instead of registering anything.
Memory guards
Browsers running WebAssembly work in a 32-bit address space, and one conversion holds the encoded input, the decoded frame, and the output at the same time. The app models that peak explicitly and refuses work it cannot finish. The check runs when a file is added, so an oversized file is rejected at the point you drop it with the real limit for its format in the message, and again before the worker call, which is the actual guarantee.
Encode intent
Instead of asking everyone to reason about what quality 85 means across four different codecs, the conversion panel exposes an intent. Balanced uses the quality you set. Archive spends encoding time on fidelity. Social favours upload size and asks the engine to strip private metadata before encoding. Social also caps quality at 90, and the UI says so rather than letting the engine apply the cap silently behind a slider that reads 100.
Filename and dataset output
Output names can be sanitized, prefixed, find-and-replaced, given a new extension, and made collision-free. An optional auto suffix appends the output dimensions and quality. Dataset log generation writes dataset_log.txt alongside the images; when it is on, non-individual downloads are packaged as ZIP so the log always travels with the files it describes.
Presets
Conversion settings can be saved, applied, exported, and imported. Imports are size-checked and validated before anything reaches the settings, because a preset file is untrusted input like any other file.
Metadata preservation
Metadata preservation is off by default and has to be turned on deliberately. When it is on, the request is forwarded to the engine, and how much survives depends on the output container: JPEG, PNG, and WebP carry metadata broadly, AVIF carries less.
How It Works
On page load the app creates the worker pool and sends a warmup request. One worker initializes the WASM engine, and in production the service worker is registered for the asset cache. A toast reports whether the engine is ready, cached, or will load on demand.
When files are selected, each one is validated before it reaches the queue: size against the per-format memory budget, declared MIME against an accepted list, magic bytes against the real container, RIFF structure for WebP, and ISO BMFF brands for AVIF and HEIC. A filename extension and a browser-provided File.type are both trivially wrong, so neither is trusted on its own. Accepted files then have their dimensions read, four at a time, since reading a file for dimensions means reading all of it.
Starting a batch takes a snapshot: the active mode, its settings, the item IDs in play, and a run token. Output names are built from the naming rules, memory is estimated, and jobs go through a concurrency queue into the pool. Each worker maps the UI settings onto the engine’s public option shape and calls convertImageWithInfo(...), which handles decode, optional crop, optional resize, encode, metadata, and the final dimensions. The result comes back as a Blob with its dimensions and duration attached.
That run token is what makes the async side safe. A conversion can resolve long after its batch ended, and writing then would resurrect an item the user has already moved past. Every callback that touches state has to pass three tests first: a batch is active, the callback carries that batch’s token, and the item belonged to that batch. Callbacks from an earlier run hold an older token and are dropped without a sound.
Downloads follow from what is finished. A single artifact downloads directly, several are bundled into a ZIP, and enabling the dataset log routes everything through ZIP so the log is included. Cancellation is handled defensively: queued tasks are aborted before they start, in-flight results are discarded rather than trusted, and workers are restarted instead of being asked politely to stop a long-running image operation.
Privacy and Security Notes
Local processing removes the largest privacy problem, but not every one of them. Malformed or oversized files can still cause trouble locally, so validation runs on MIME types, content signatures, ISO BMFF brands, file size, crop and resize values, batch size, and preset import size before any work is accepted.
Metadata is treated as sensitive, because EXIF, XMP, IPTC, and ICC data routinely carry GPS coordinates, camera and lens serial numbers, author names, timestamps, and editing history. Preservation is opt-in for that reason. The Social intent is the middle path: metadata is kept, but the engine removes the location and device-identity fields before encoding.
Production builds generate a _headers file with a Content-Security-Policy, HSTS, Referrer-Policy, Permissions-Policy, nosniff, frame denial, COOP, COEP, CORP, immutable asset caching, and no-store for the service worker. The same builder renders the headers used by bun run preview, so a policy that would break production breaks locally first. The policy was verified by running a full conversion under it with zero violation events.
The directive worth naming is connect-src 'self'. That is what turns “your files are never uploaded” from something you have to take my word for into something the browser enforces. The app never needs anything wider, since it has no upload API and contacts no third party.
One caveat is important enough to state plainly: the _headers format is host-specific and is ignored silently by hosts that do not support it. There is no build error and no runtime warning — the app simply ships without protection. GitHub Pages cannot serve these headers at all, which also means the cross-origin isolation the engine wants. Some platforms override individual headers from their own dashboards, so the live response is the source of truth, not the file in the build output.
Build and Development Notes
This repository is an application, not a package. It consumes @walujanle/image-converter-wasm from npm as a runtime dependency.
The commands used day to day:
bun install
bun run dev
bun run lint
bun run check
bun run typecheck
bun run test
bun run test:coverage
bun run audit
bun run build
bun run lint runs the linter; bun run check also verifies formatting and import order. Every import inside src/ goes through one @/ alias that resolves to src/, so a path reads the same from any file in the tree.
The current release is 1.1.2 - Stable Version, and the app is feature-complete. The recorded validation baseline is 21 test files and 321 tests passing, with lint, formatting, typecheck, dependency audit, and production build all passing alongside it. Coverage sits at roughly 45% of statements measured across all of src/ — the pure layers that carry validation and worker logic are above 95%, and React components are largely untested, which is a limit I would rather disclose than hide behind a flattering number measured over a smaller denominator.
Limit Problems
- Conversion is bound by browser memory. The practical input limit is about 10.7 MB for JPEG and WebP and about 7.1 MB for PNG, AVIF, and HEIC. Those numbers are derived from the peak-memory model, not chosen, which is why they are lower than people expect.
- Images above 8192 × 8192 pixels are rejected. A larger decoded frame does not fit in a 32-bit WebAssembly heap, so this is a hard ceiling rather than a tuning value.
- There is no separate chunked streaming path. One conversion pipeline is easier to cancel, test, and reason about than two that behave differently.
- HEIC and HEIF are input-only. There is no HEIC output.
- GIF, BMP, TIFF, and RAW are not accepted as input.
- AVIF output preserves less metadata than JPEG, PNG, and WebP.
- Resize lives in Advanced mode only, with an optional aspect ratio lock, and the maximum resize input is 7680 px.
- Dataset numbering applies to the generated log file, not to the output filenames themselves, and it is only available when log generation is on.
- The security headers depend on the host serving them. On a host that ignores
_headers, the app ships with none. - Full WCAG conformance still needs manual assistive technology testing and automated accessibility checks beyond the unit suite. The keyboard, focus, and screen-reader behavior is built in; certifying it is a different exercise.
- Legal and compliance certification depends on deployment and operational controls, not on source code.
License
Image Converter Web App is released under AGPL-3.0-only.
The app distributes and runs @walujanle/image-converter-wasm in the browser, and that engine is AGPL-3.0-only itself, so the licences have to match. Making this repository permissive while the AGPL engine is part of the browser runtime would not be a simplification, it would just be wrong.
Latest Projects
Image Converter WASM
A Rust WebAssembly image conversion package for browser and Node.js runtimes, published as an npm package.
Image Converter Windows App
A local-first Windows image converter built with Rust for private, offline batch conversion.
Test
Test