Image Converter WASM
A Rust WebAssembly image conversion package for browser and Node.js runtimes, published as an npm package.
Repository Links
GitHub: https://github.com/walujanle/image-converter-wasm
GitLab: https://gitlab.com/walujanle/image-converter-wasm
Package Link
npm: https://www.npmjs.com/package/@walujanle/image-converter-wasm
Download Links
Package: https://links.leonardwalujan.eu.org/lw/image-converter-wasm-latest-package
Package Checksum (SHA-256): https://links.leonardwalujan.eu.org/lw/image-converter-wasm-latest-package-checksum
Source Code: https://links.leonardwalujan.eu.org/lw/image-converter-wasm-latest-source-code
Short Explanation
Image Converter WASM is an image conversion engine written in Rust, compiled to WebAssembly, and packaged for JavaScript. It is published on npm as @walujanle/image-converter-wasm.
The contract is small on purpose: you pass image bytes in as a Uint8Array, the conversion happens in memory, and you get encoded bytes back as a Uint8Array. The package never opens a file picker, never renders a download button, never uploads anything, and never writes to a folder. All of that belongs to the application using it. That split is what makes the package usable inside a frontend app, a web worker, or a Node.js script without ever arguing over who owns the file.
Output formats are JPEG, PNG, WebP, and AVIF. Input can be jpg, jpeg, png, webp, avif, heic, or heif. HEIC and HEIF are read-only here. You can convert away from them, but the package will not encode HEIC, and it refuses with a clear error rather than quietly handing back a different format.
Why I Built This Project
I built this because I wanted to walk the whole path myself: Rust source code, to WebAssembly, to something a frontend can install from npm and call like any other dependency. Reading about that path is not the same as shipping it.
The second reason was the image work itself. I wanted to sit closer to the engine than a normal converter UI lets you: output format, quality, PNG compression, WebP lossless mode, resize, crop, aspect ratio handling, metadata, progress reporting. Not a generic online converter service, but a conversion engine a site can call while the frontend keeps full control over how files are picked, previewed, downloaded, cached, or uploaded.
The third reason was the runtime split. The same engine had to work in a browser and in Node.js, which forces you to be honest about what belongs in the WASM module and what belongs in the wrapper around it.
So the project ended up being a practical bridge between three things I wanted to understand properly: image processing in Rust, WebAssembly packaging, and frontend integration through npm.
Tech Stack Used
- Rust for the conversion engine, compiled to
wasm32-unknown-unknown. wasm-bindgenfor the JavaScript boundary.- ESM and CommonJS wrapper entries, both built on one shared logic file so the two cannot drift apart.
- TypeScript declarations for the public JavaScript API.
- npm for distribution, targeting Node.js 18 or newer.
imagefor JPEG, PNG, and WebP decoding.heicwith itsav1feature for HEIC, HEIF, and AVIF decoding.jpeg-encoderfor JPEG output.pngfor PNG output.zenwebpfor WebP output.raviffor AVIF output.fast_image_resizefor resize processing.img-parts,kamadak-exif, andlittle_exiffor container editing and metadata parsing.
Decoding is pure Rust throughout. There are no C libraries in the dependency tree and no JavaScript codec fallbacks, which is the only way the HEIC and AVIF input paths can survive a WebAssembly target at all.
The package is licensed as AGPL-3.0-only. That is not a stylistic choice: the shipped WASM binaries include dependencies whose licence path is AGPL or commercial, and the package has to match them.
Package Model
The package is not one large WebAssembly file. The build produces a separate WASM module per output target:
- JPEG module
- PNG module
- WebP module
- AVIF module
The JavaScript wrapper loads only the module matching the output format you asked for, and only when you ask for it. Worth being precise about what this saves: every module still carries the full decoder set, because any output format may receive any supported input. The split trims encoder weight, not decoder weight.
The generated npm package ships both module styles:
index.jsfor ESM imports.index.cjsfor CommonJSrequire.index.d.tsfor TypeScript users.shared.jsfor the option mapping and call sequence both entries use.formats/<format>/esm/index_bg.wasmfor browser and bundler ESM usage.formats/<format>/cjs/index_bg.wasmfor CommonJS usage.
In Node.js, the wrapper reads the packaged WASM bytes straight from disk. In browser builds, it resolves the generated WASM asset URL, unless the host application passes explicit wasmSources — which is the escape hatch for frameworks that will not serve WASM files out of node_modules.
Public API
The public JavaScript API is deliberately small. Six functions cover everything.
init(options?)
Sets up wrapper configuration and can preload chosen output modules so the first conversion is not the one paying for module startup. It also accepts custom WASM sources, either per format or as a single source for all of them, for frameworks that need the WASM files served from a public or static directory.
convertImage(fileBytes, ext, options)
Converts one input buffer and returns the encoded bytes.
convertImageWithInfo(fileBytes, ext, options, onProgress?)
Same conversion, but it also returns the final width and height, and it accepts a progress callback. Progress arrives as a value from 0 to 1.
getImageDimensions(fileBytes, ext, format?)
Reads image dimensions without running a full conversion.
extractMetadata(fileBytes, ext, format?)
Reads whatever EXIF, XMP, IPTC, and ICC data is present in an in-memory buffer.
getProjectVersion(format?)
Returns the project version embedded into the loaded WASM module.
Conversion Options
The options cover what a browser or Node.js caller actually needs:
format: target output format, one ofJpeg,Png,WebP, orAvif.quality: applies to JPEG, lossy WebP, and AVIF. Defaults to75and is clamped to the1–100range.pngCompressed: higher PNG compression.lossless: WebP lossless mode.resize: enables resize processing.targetWidthandtargetHeight: target dimensions.resizeLockAspectRatio: keeps the original aspect ratio when resizing. On by default.crop: enables percent-based crop.cropTop,cropBottom,cropLeft, andcropRight: crop percentages from0to100. Negative, out-of-range,NaN, and infinite values are rejected instead of being coerced to zero.keepMetadata: preserve supported metadata in the output.intent: the quality policy, one ofBalanced,Archive, orSocial.
Only format is required. Everything else falls back to an engine default, so a partial options object is a normal way to call this, not a special case.
intent is the option I like most, because it moves a decision out of the caller and into the engine. Instead of every host application inventing its own idea of what “quality 85” means for four different codecs, the intent names the goal and the engine picks the chroma, encoder speed, and metadata policy to match:
| Intent | Quality | JPEG chroma | AVIF speed | WebP | PNG | Metadata |
|---|---|---|---|---|---|---|
Balanced | as given | 4:4:4 from 85 up | 4 | lossy tuning | as asked | untouched |
Archive | as given | always 4:4:4 | 2 | near-lossless from 95 up | always maximum | untouched |
Social | capped at 90 | always subsampled | 6 | lossy tuning | as asked | private fields removed |
Balanced is the default and behaves the way you would expect. Archive spends encoding time to keep fidelity. Social goes the other way: it caps quality, subsamples chroma, encodes fast, and strips the metadata you would not want to publish along with a photo.
Quality is passed through to the encoders as given. There is no hidden second curve remapping the number you supplied, so the value you set is the value the codec sees.
How It Works
The flow starts in the JavaScript wrapper. It normalizes the requested output format, normalizes the input extension, maps the JavaScript option names onto the JSON shape Rust expects, and loads the right WASM module. That is all it does. Every real conversion decision lives in Rust, so the two wrapper entries can never disagree about behaviour.
Inside Rust, the extension is turned into a normalized value once, at the boundary, and every later routing decision reads that one value. The pipeline then rejects HEIC output, starts a conversion deadline, and validates the input: file size, extension, and magic bytes, before anything is decoded. That same gate runs on every entry point that accepts caller bytes, including metadata extraction and dimension reads, not just conversion.
Next comes the pixel budget check, then decoding. JPEG, PNG, and WebP go through the image crate. HEIC, HEIF, and AVIF go through the pure Rust heic decoder. AVIF input specifically has to take that route, because the common AVIF decoder depends on a C library that cannot be built for WebAssembly at all.
EXIF orientation is applied after decode, so a photo saved rotated by the camera comes out upright. The three ISOBMFF inputs are skipped here, because their decoder already corrected the orientation and applying it twice would rotate the image into the wrong position.
When metadata preservation is requested, metadata is extracted before any transformation. ICC colour profiles are read on a separate track, so colour fidelity can survive even when metadata copying is off. If the intent is Social, private fields are stripped at this point, before encoding rather than after, so the engine’s own metadata integrity check compares against the stripped payload and never mistakes a deliberate removal for accidental data loss.
Then the engine applies the optional crop, the optional resize, re-checks the pixel budget against the final dimensions, and encodes. The result is written into memory and handed back to JavaScript. There is no file path at this layer, and there never was one.
The limits are fixed and documented:
| Limit | Value |
|---|---|
| Max file size | 256 MB |
| Max dimension per side | 16384 |
| Max pixels per frame | 8192 × 8192 |
| Conversion timeout | 300 s |
The pixel budget is the one that usually applies first, and it exists for a specific reason. WebAssembly runs in a 32-bit address space that browsers cut well below the theoretical limit, and the pipeline holds more than one frame at a time. An oversized image needs to come back as an error the host can catch and show, not as a module abort the host can do nothing about.
Progress reporting is stage-based. It tells you which part of the pipeline is running, which is what a progress bar needs. It is not a byte or scanline counter from inside the codec, and the docs say so rather than implying more precision than exists.
Metadata Behavior
Extraction and preservation are two different things. extractMetadata(...) reports what is in the source bytes. keepMetadata: true asks the pipeline to write supported metadata into the output.
What each output container can carry:
| Output Format | EXIF | XMP | IPTC | ICC |
|---|---|---|---|---|
| JPEG | Yes | Yes | Yes | Yes |
| PNG | Yes | Yes | Yes | Yes |
| WebP | Yes | Yes | No | Yes |
| AVIF | Yes | Yes | No | No |
JPEG writes EXIF and XMP through APP1 segments, IPTC through Photoshop APP13, and ICC through APP2. PNG writes metadata into PNG chunks and additionally projects selected fields into text chunks, so file properties in the operating system show something useful. WebP writes EXIF, XMP, and ICC chunks into the RIFF container; IPTC has no native WebP chunk in this package. AVIF keeps EXIF and XMP, with XMP injected by editing ISOBMFF boxes directly, and does not promise ICC embedding on the output side.
For AVIF and HEIC input, metadata is read from ISOBMFF structures: item metadata for EXIF and XMP, plus ICC from colr boxes carrying a prof or rICC profile type. Being able to read ICC from a source is not the same as being able to write it into every output, and the table above is the honest version.
The Social intent is where metadata turns into a privacy feature. It removes GPS coordinates along with altitude, timestamp, map datum, and the direction, speed, and track telemetry that usually rides with them; camera and lens serial numbers, including the vendor-specific XMP namespaces that quietly duplicate them; the camera owner’s name; the image unique ID; and the IPTC location datasets for city, sublocation, state or province, country code, and country. Authorship, copyright, description, and exposure data are kept, because those are the fields a photographer usually wants to travel with the image. ICC profiles are left alone; a colour profile says nothing about where you were or which body you shot on.
Every one of those fields is declared once, in a single registry that the EXIF, XMP, IPTC, and PNG text paths all read from. That is the part I would defend in review: a privacy promise split across several hand-maintained lists is a promise that eventually stops being true in one of them.
Example Usage
import { convertImage, init } from "@walujanle/image-converter-wasm";
await init({ preload: ["Jpeg"] });
const input = new Uint8Array(await file.arrayBuffer());
const output = await convertImage(input, ".png", {
format: "Jpeg",
quality: 82,
resize: true,
targetWidth: 1600,
resizeLockAspectRatio: true,
keepMetadata: true,
intent: "Social",
});
const blob = new Blob([output], { type: "image/jpeg" });
Extensions are normalized, so png, .png, and PNG are all accepted as long as the bytes really are that format.
When you want dimensions and a progress bar as well:
const result = await convertImageWithInfo(
input,
"heic",
{ format: "Avif", quality: 70, intent: "Archive" },
(progress) => setPercent(Math.round(progress * 100)),
);
console.log(result.width, result.height);
In browser applications, this belongs in a client-side path: a client component, a browser-only hook, or a web worker. Server-side rendering code should not assume the browser file APIs exist.
Build and Release Notes
The build is a Node script, so Linux, macOS, and Windows run identical steps. build_wasm.sh and build_wasm.bat are thin shims over scripts/build.mjs, and all three accept the same arguments. It compiles one output module at a time, runs wasm-bindgen, assembles the npm package under pkg/, and copies the npm README into place.
The first build takes your package details:
./build_wasm.sh --author "Your Name" --scope yourscope
After that they are remembered in a local gitignored file, so every later build is just ./build_wasm.sh. You can also build a single module by name, force an unscoped package name, or drop the saved values again. In CI the same values can come from environment variables, and values from the environment are never written to disk. If nothing is configured and no terminal is attached, the build fails with a clear message instead of hanging on a prompt nobody can answer.
Two other commands carry the release. node scripts/verify.mjs runs the entire source checklist — formatting, clippy with warnings treated as errors, the test suite, a separate check per output format, and a WebAssembly release check — as one command. node test-npm.js then smoke-tests the package that was actually built, through both the ESM and CommonJS entries, across every format and every intent.
The repository keeps two README files on purpose:
README.mdfor maintainers reading the source repository.js-wrapper/README.npm.mdfor people installing the package from npm.
They serve genuinely different readers. Maintainers need build and architecture detail; package users need installation, API, runtime, and bundler notes. Merging them would make both worse.
There is also an optional threaded build path. It is only worth enabling when the host environment is ready for threaded WebAssembly, which means SharedArrayBuffer plus the COOP and COEP headers.
Limitations
- HEIC and HEIF are decode-only. There is no HEIC output, and there will not be one until a pure Rust encoder exists that fits the licence audit.
- TIFF is not a public input or output format here. The internal TIFF parsing exists to read EXIF payloads, not to be a TIFF codec.
- Conversion is in-memory, so large images need real memory. The pixel budget of 8192 × 8192 is the practical ceiling, and it is lower than the per-side limit suggests.
- File selection, previews, saving, and downloads are the host application’s job.
- Progress is stage-based, not codec-internal.
- AVIF output does not embed ICC profiles.
- WebP and AVIF output do not carry IPTC, because those containers have no native IPTC path in this package.
- Threaded WASM builds need cooperation from the host:
SharedArrayBuffer, COOP, and COEP. - Edge runtimes are not a documented target.
License
Image Converter WASM is released under AGPL-3.0-only.
You can study, modify, and redistribute it under the AGPL terms. Applications that distribute the package or expose it over a network need to meet the AGPL obligations or arrange a separate compatible licence. The reason the licence cannot simply be MIT is concrete: the shipped WASM binaries include heic and zenwebp, both offered as AGPL or commercial, and heic is the decoder behind every HEIC, HEIF, and AVIF input, so that obligation reaches every module in the package.
Latest Projects
Image Converter Web App
A browser-native local-first image converter built with React, TypeScript, Web Workers, and a Rust/WebAssembly npm engine.
Image Converter Windows App
A local-first Windows image converter built with Rust for private, offline batch conversion.
Test
Test