Features are ordered by implementation difficulty, easiest first. Each entry includes a difficulty rating, pros/cons, architectural considerations, and a broad todo list.
| Rating | Meaning |
|---|---|
| 1/5 | Trivial — a few hours, minimal risk |
| 2/5 | Easy — a focused day or two |
| 3/5 | Moderate — multi-day, some coordination between Rust and TS |
| 4/5 | Hard — significant effort, architectural decisions required |
| 5/5 | Very Hard — major undertaking, weeks of work |
Estimated sizes for the existing production build (5 formats: PNG, JPEG, WebP, GIF, BMP).
| Asset | Uncompressed | Gzipped | Notes |
|---|---|---|---|
| WASM binary | ~1.5–2 MB | ~550–750 KB | image crate with 5 codecs; wasm-opt disabled |
| JavaScript (main + worker) | ~80–120 KB | ~25–40 KB | App code + PostHog SDK (~30 KB gzipped) |
| CSS (Tailwind purged) | ~15–25 KB | ~5–8 KB | Only used utility classes |
HTML (index.html) |
~15–20 KB | ~5–7 KB | Static content + JSON-LD structured data |
| Total | ~1.6–2.2 MB | ~585–805 KB | Over-the-wire first load |
All delta estimates below are gzipped (over-the-wire cost) unless stated otherwise. Uncompressed sizes are noted separately where relevant.
- Dark Mode — 1/5
- Paste from Clipboard — 1.5/5
- JPEG Quality Slider + Format Quality Controls — 2/5
- Tier 2 Format Support — TIFF, ICO, TGA, QOI — 2/5
- Simple Image Transforms — Flip, Rotate, Grayscale, Invert, Dither — 2/5
- Format-Specific Landing Pages (SEO) — 2.5/5
- SVG Rasterization — 2.5/5
- Image Metadata + EXIF Display — 2.5/5
- Compression Benchmark — Format Size Comparison — 2.5/5
- Parameterized Image Processing — Resize, Crop, Blur, Brighten, Contrast, Hue, Unsharpen — 3/5
- Side-by-Side Before/After Comparison — 3/5
- Color Palette Extraction — 3/5
- PWA Support — Offline Usage — 3/5
- Image Watermarking — 3.5/5
- Real Progress Reporting from Rust — 3.5/5
- React Frontend Migration — 4/5
- Batch Processing — 4/5
- Raise File Size and Memory Limits — 4.5/5
- Worker Pool for Parallel Batch Conversion — 4.5/5
Difficulty: 1/5
Add a light/dark theme toggle with persistence via localStorage. Tailwind CSS v4 has native dark mode support via the dark: variant and media query or class strategy.
- High user expectation — dark mode is standard on any modern tool
- Near-zero risk: purely CSS/TS, no Rust changes
- Tailwind v4 makes it trivial: add
dark:prefix variants to existing classes - Improves UX for users working in low-light environments
- Requires auditing every color in
styles.cssandindex.htmlfor dark variants - Need to decide:
prefers-color-schemeonly, or a manual toggle (manual toggle is better UX) - Minor testing burden across both themes
- Use Tailwind's class-based dark mode strategy (
darkMode: 'class'in config — Tailwind v4 uses:darkselector on<html>) rather than media-query-only, so user preference persists independently of system setting - Store preference in
localStorageunder a key likecolor-scheme - Toggle adds/removes
.darkon<html>element - No Worker or Rust changes needed
| Asset | Delta (uncompressed) | Delta (gzipped) | Notes |
|---|---|---|---|
| WASM binary | 0 | 0 | No Rust changes |
| JavaScript | +~1–2 KB | +~0.5–1 KB | Toggle logic, localStorage read/write |
| CSS | +~8–15 KB | +~3–6 KB | dark: variants for all existing Tailwind classes |
| Total | +~9–17 KB | +~3.5–7 KB | Smallest footprint of any feature in this roadmap |
CSS is the main contributor. Tailwind v4 purges unused classes, so cost is proportional to how many
dark:variants you add. Using CSS custom properties (--color-bgetc.) instead of per-elementdark:variants can halve this cost.
- Configure Tailwind v4 dark mode strategy (class-based)
- Add dark mode color tokens for background, text, borders, and interactive elements
- Audit all
index.htmlTailwind classes and adddark:variants - Add a theme toggle button (sun/moon icon) in the header
- Implement toggle logic in
ui.ts— read fromlocalStorageon load, write on toggle - Apply OS preference on first visit (
window.matchMedia('(prefers-color-scheme: dark)')) - Test both themes visually across all UI states (idle, uploading, converting, error, preview)
- The progress bar, drop zone, and preview card are the visually complex elements to handle
- SVG icons for sun/moon can be inline in HTML to avoid extra network requests
- Consider using CSS custom properties (
--color-bg,--color-text) to centralize theme values if many variants exist
Difficulty: 1.5/5
Allow users to paste an image directly from the clipboard using Ctrl+V / Cmd+V (or a dedicated "Paste" button). Most operating systems copy screenshots and UI elements to the clipboard as PNG or JPEG image data.
- Huge workflow improvement — pasting a screenshot is faster than saving + selecting a file
- Common user expectation for image tools
- Pure frontend change — no Rust or Worker changes
- Clipboard API is well-supported in modern browsers
- Requires user permission prompt (the Clipboard API gate) in some browsers
- Clipboard data is always returned as a
Blob; need to handle MIME type detection (theimagecrate's format detection from bytes already handles this) - Some browsers (especially Firefox) have inconsistent Clipboard API behavior
- Cannot paste a file path — only actual image data
- Hook into
document.addEventListener('paste', ...)to intercept clipboard paste events globally - Also add a "Paste image" button that calls
navigator.clipboard.read()for explicit trigger - Convert clipboard
Blob→ArrayBuffer→Uint8Arrayand feed into the existing file processing pipeline (handleFile()inui.ts) — no special casing needed - The existing format detection (
detect_format) will correctly identify PNG/JPEG/WebP data from clipboard bytes
| Asset | Delta (uncompressed) | Delta (gzipped) | Notes |
|---|---|---|---|
| WASM binary | 0 | 0 | No Rust changes |
| JavaScript | +~1–2 KB | +~0.5–1 KB | Paste event handler + clipboard API glue |
| CSS | 0 | 0 | No new UI elements beyond existing button styling |
| Total | +~1–2 KB | +~0.5–1 KB | Negligible — the cheapest feature in this roadmap |
The
pasteevent listener andBlob → Uint8Arrayconversion are a handful of lines that compress to near nothing. No new dependencies.
- Add global
pasteevent listener inui.tsondocument - Extract
ImageItemfromClipboardEvent.clipboardData.itemsfiltering forimage/* - Convert clipboard
BlobtoUint8Arrayand pass to existing file handling pipeline - Add optional "Paste image" button in the UI (calls
navigator.clipboard.read()) - Handle permission denial gracefully with a user-friendly error
- Handle multi-item clipboard (take the first image item)
- Track
input_method: "clipboard_paste"in existing PostHogimage_selectedanalytics event - Test on Chrome, Firefox, Safari (paste event behavior differs slightly)
- The
pasteevent'sclipboardDatadoes not require theclipboard-readpermission — it fires naturally on paste and is always granted navigator.clipboard.read()(for the explicit button) does require theclipboard-readpermission in Chromium browsers- Generate a synthetic filename like
pasted-image-{timestamp}.pngfor the download filename
Difficulty: 2/5
Expose output quality as a user-configurable parameter for lossy formats. JPEG is the primary target (quality 1–100), but the design should accommodate future lossy formats like WebP lossy or AVIF.
- High user value — power users always want quality control
- Enables the "find optimal file size" use case
- Relatively small Rust change: the
imagecrate's JPEG encoder already accepts a quality parameter - The UI is a familiar HTML range slider
- Must update the WASM API to accept an optional quality parameter, changing the function signature
- Need to update the Worker message protocol (
worker-types.ts) to carry quality - Quality only applies to JPEG (and future lossy formats); the UI must show/hide the slider contextually
- No quality setting for lossless formats (PNG, BMP, GIF) — this distinction needs clear UI communication
- Extend
convert_image()WASM export to accept an optionalquality: Option<u8>(or a separateconvert_image_with_options()function to avoid breaking existing calls) - In Rust: use
image::codecs::jpeg::JpegEncoder::new_with_quality()instead of the default encoder - Worker message
ConvertImageRequestgains an optionalquality?: numberfield - The slider should only appear when the selected target format is JPEG (or another lossy format)
- Display a live "estimated file size" readout as the slider moves — this is an approximation since actual size depends on image content
| Asset | Delta (uncompressed) | Delta (gzipped) | Notes |
|---|---|---|---|
| WASM binary | +~3–8 KB | +~1–3 KB | JpegEncoder::new_with_quality() code path; PngEncoder quality optional |
| JavaScript | +~1–2 KB | +~0.5–1 KB | Slider UI, Worker message update, conditional display logic |
| CSS | +~1–2 KB | +~0.5 KB | Range input styling |
| Total | +~5–12 KB | +~2–5 KB | Very low cost for high user value |
The WASM delta is minimal because
JpegEncoder::new_with_quality()is already in theimagecrate — it's just a different constructor call. No new monomorphizations or codec logic needed.
Rust (WASM):
- Add
quality: Option<u8>parameter toconvert_image()(or addconvert_image_with_options()) - Use
JpegEncoder::new_with_quality()when quality is specified, default to 80 when not - Return an error if quality is outside 1–100
- Add unit tests for quality boundaries (1, 50, 80, 100) and invalid values
Frontend:
- Add a quality slider (
<input type="range" min="1" max="100" value="80">) to the UI - Show the slider only when the target format is JPEG (or other lossy formats as added)
- Display current quality value next to the slider (e.g., "Quality: 80")
- Update
worker-types.tsConvertImageRequestto includequality?: number - Update
worker.tsto pass quality toconvert_image() - Update
ui.tsto read slider value and include it in the Worker message - Update PostHog
conversion_startedevent to includequalityproperty - Add "Quality" label with a tooltip explaining what quality affects
- JPEG quality 80 is a sensible default (good balance of size vs. visual quality)
- Consider also exposing PNG compression level (0–9) — lower = faster + larger file, higher = slower + smaller file. The
imagecrate supports this viaPngEncoder::new_with_quality() - A future "target file size" mode would use binary search over quality values to find the setting that produces a file near the target size — very useful for social media uploads with size caps
Difficulty: 2/5
Add the four Tier 2 formats from PLANNING.md. All are pure-Rust codecs in the image crate — no native dependencies required, making them straightforward WASM additions.
| Format | Decode | Encode | Notes |
|---|---|---|---|
| TIFF | Yes | Yes | Used in professional/print workflows |
| ICO | Yes | Yes | Windows icon format, multi-resolution |
| TGA | Yes | Yes | Legacy game/graphics format |
| QOI | Yes | Yes | Modern fast lossless format, simple spec |
- Near-zero Rust complexity — just add cargo features and update
formats.rs - Expands the target keyword surface for SEO (
tiff to png,ico converter, etc.) - QOI is an interesting modern format that techie users will appreciate
- TIFF is important for professional photo workflows (some cameras output TIFF)
- ICO is genuinely useful for favicon generation workflows
- ICO files can contain multiple embedded images at different sizes — the
imagecrate reads the first/largest; multi-resolution write support is limited - TIFF can have many variants (BigTIFF, TIFF with JPEG compression, etc.) — some may error
- TGA has limited use today — mostly legacy
- Each new format increases the WASM binary size slightly
- The UI format selector dropdown grows — may need visual grouping or a search/filter
- Add
tiff,ico,tga,qoifeatures toCargo.tomlfor theimagecrate - Extend
ImageFormatenum informats.rswith new variants to_image_format()mapping is straightforward — all four are first-class inimage::ImageFormatdetect_from_bytes()will work automatically viaimage::guess_format()for TIFF, TGA, QOI (they have distinct magic bytes); ICO's magic is[0x00, 0x00, 0x01, 0x00]- Frontend: add new options to the target format
<select>dropdown - Consider grouping formats visually: "Common" (PNG, JPEG, WebP, GIF, BMP) and "Other" (TIFF, ICO, TGA, QOI)
| Asset | Delta (uncompressed) | Delta (gzipped) | Notes |
|---|---|---|---|
| WASM binary — TIFF | +~180–260 KB | +~60–90 KB | tiff crate is the heaviest; handles many TIFF variants |
| WASM binary — ICO | +~50–80 KB | +~18–28 KB | Container format wrapping PNG/BMP internally |
| WASM binary — TGA | +~30–50 KB | +~10–18 KB | Simple run-length encoded format |
| WASM binary — QOI | +~15–25 KB | +~5–9 KB | Extremely simple spec — smallest codec of the four |
| WASM total (all four) | +~275–415 KB | +~93–145 KB | Adds ~15–25% to current WASM binary |
| JavaScript | +~1–2 KB | +~0.5–1 KB | New <option> elements, format name mappings |
| CSS | 0 | 0 | No new styles needed |
| Grand total | +~276–417 KB | +~93–146 KB | Dominated entirely by WASM codec additions |
Add formats incrementally rather than all at once — add QOI first (smallest, most interesting), then ICO (favicon workflow), then TIFF. Skip TGA unless there's user demand; it adds ~15 KB gzipped for a rarely-used legacy format.
Rust (WASM):
- Add
tiff,ico,tga,qoifeatures toCargo.toml - Add
Tiff,Ico,Tga,Qoivariants toImageFormatenum - Extend
from_name()to handle"tiff"/"tif","ico","tga","qoi"strings - Add
to_image_format()mappings for all four - Update
detect_from_bytes()to recognize new format magic bytes (where needed) - Add conversion matrix tests for all new format pairs
- Verify WASM binary size increase is acceptable
Frontend:
- Add new options to target format dropdown in
index.html - Update format display names and icon/badge mapping in
ui.tsif applicable - Update SEO content in
index.htmlto mention new formats (meta description, FAQ, supported formats section) - Update
sitemap.xmlto include new format pairs if format landing pages are implemented - Test download MIME types and file extensions for each new format
- QOI is worth highlighting as a "fast lossless" option — it decodes/encodes 3–10x faster than PNG for similar compression ratios. This is a differentiator
- ICO is useful for a "convert image to favicon" workflow — consider adding a dedicated "Favicon Generator" mode that creates a proper multi-resolution ICO from any input
- TIFF files are often very large (uncompressed RAW exports from cameras) — test against the 200 MB file size limit and add appropriate messaging
Difficulty: 2/5
Expose a set of one-click, zero-parameter image transforms from the image crate's imageops module. These can be applied before conversion, transforming the output rather than the input file directly.
| Operation | imageops function |
Parameters |
|---|---|---|
| Flip horizontal | flip_horizontal() |
None |
| Flip vertical | flip_vertical() |
None |
| Rotate 90° CW | rotate90() |
None |
| Rotate 180° | rotate180() |
None |
| Rotate 270° CW / 90° CCW | rotate270() |
None |
| Grayscale | grayscale() |
None |
| Invert colors | invert() |
None |
| Dither (quantize) | dither() |
Color map |
- Genuinely useful: fix phone camera auto-rotation, mirror screenshots, make grayscale copies
- No UI complexity — each is a simple toggle button or radio
- Clean Rust implementation — all functions are zero-parameter, one-liner wrappers
- Grayscale and invert are very commonly needed operations
- Adds UI surface area — need to decide on the interaction model (toolbar of buttons? checkboxes? applied in sequence?)
- Transforms are applied before conversion, so the preview must update after each toggle — requires a re-conversion on every toggle action, which may feel slow for large images
- Dithering is only meaningful when converting to palette-limited formats (GIF, 8-bit PNG) — context-sensitive display needed
- Need to think about transform ordering (does rotate then flip = flip then rotate?)
- Extend the Rust
convert_image()function to accept an optionalTransformOptionsstruct, or add a separatetransform_image()function that applies transforms to raw pixels before encoding - A clean approach: add a
pipelinestep inconvert.rsthat applies transforms to the decodedDynamicImagebefore encoding. Theimageopsfunctions all accept&mut DynamicImageor return a new image. - Worker message
ConvertImageRequestgains atransforms?: string[]field (ordered list of transform names, e.g.,["rotate90", "grayscale"]) - On the frontend, transforms are a state array that updates on each button click — re-run conversion on any change
- For large images, consider debouncing re-conversion by 300ms to avoid thrashing
| Asset | Delta (uncompressed) | Delta (gzipped) | Notes |
|---|---|---|---|
| WASM binary — flip H/V | +~5–10 KB | +~2–4 KB | Simple pixel reordering |
| WASM binary — rotate 90/180/270 | +~12–20 KB | +~4–7 KB | Rotation requires new allocation per call |
| WASM binary — grayscale | +~5–10 KB | +~2–4 KB | Channel averaging, already partially in image crate |
| WASM binary — invert | +~3–6 KB | +~1–2 KB | Simple bitwise NOT per channel |
| WASM binary — dither | +~20–35 KB | +~7–12 KB | Floyd-Steinberg or ordered dither algorithm |
| WASM total | +~45–81 KB | +~16–29 KB | Many functions are already compiled in but tree-shaken out |
| JavaScript | +~2–3 KB | +~1–1.5 KB | Transform toolbar, state array, debounce logic |
| CSS | +~2–4 KB | +~1–2 KB | Toolbar button styling |
| Grand total | +~49–88 KB | +~18–32 KB | Reasonable cost for 8 useful operations |
Rust's dead-code elimination removes unused
imageopsfunctions at compile time. Adding these features forces those functions to be compiled in. The actual delta depends on which functions are already present due to internalimagecrate usage (e.g.,grayscalemay already be compiled in for GIF palette operations).
Rust (WASM):
- Add
apply_transforms(img: DynamicImage, transforms: &[&str]) -> Result<DynamicImage, ConvertError>inconvert.rs - Implement each transform case using
imageops::*functions - Integrate
apply_transformsinto theconvert()pipeline (between decode and encode) - Export transforms as a
wasm_bindgenfunction or embed inconvert_image()via extended options - Add unit tests for each transform (verify dimensions after rotate90, pixel values after invert, etc.)
Frontend:
- Design a transforms toolbar in
index.html(icon buttons for flip H/V, rotate CW/CCW, grayscale toggle, invert toggle) - Manage transforms state as an ordered
string[]inui.ts - Wire each toolbar button to update state and trigger re-conversion
- Add debounce (300ms) on re-conversion when transforms change
- Show/hide dither option only when target format is GIF or indexed PNG
- Reset transforms state when a new image is loaded
- Include applied transforms in PostHog
conversion_startedevent
- Rotate 90° changes image dimensions (width↔height) — the progress estimate will need the post-rotation dimensions
- Grayscale before JPEG encoding saves file size — could add a tooltip explaining this
- Dithering is important when converting color images to GIF — the existing GIF warning could mention enabling dithering for better quality
- "Invert + grayscale" is a popular combination for dark UI mockup screenshots
Difficulty: 2.5/5
Create dedicated landing pages for high-value format conversion keyword pairs (e.g., /png-to-jpeg, /webp-to-png). Each page is the same tool but with format-specific title, meta tags, and pre-selected format dropdowns via URL parameters.
- Directly targets long-tail SEO keywords with high conversion intent ("convert webp to png online")
- Competitors (CloudConvert, Convertio, Zamzar) rank for hundreds of these pages — this is how they get organic traffic
- Low marginal effort per page once the URL parameter system is in place
- No backend needed — URL params read by JavaScript on load
- Requires careful URL routing in Vite (or a static site generator approach)
- Each page needs unique
<title>and<meta name="description">— can't just be injected by JS since search engines may not execute JS for crawling - If using a SPA approach, the static HTML must be pre-generated for each URL path (SSG)
- Maintaining N×M pages (every format pair) grows unwieldy as formats are added
- Approach A (URL params, JS-only): Read
?from=webp&to=pngfromwindow.location.searchand pre-select dropdowns. Fast to build, but Google may or may not fully crawl JS-rendered titles/meta tags. - Approach B (Static HTML per path, Vite): Use a script to generate separate HTML files for each format pair with hardcoded titles/meta. Each file is the same template but with different meta tags. Vite builds all of them. This is the recommended approach for SEO.
- Approach C (Netlify/Cloudflare redirects + edge functions): Route
/png-to-jpegtoindex.html?from=png&to=jpegat the CDN layer, and use an edge function to inject the correct title/description into the HTML head before serving. Complex but eliminates build-time generation. - Start with Approach B (static HTML generation) — a simple Node script can generate the files before build.
| Asset | Delta (uncompressed) | Delta (gzipped) | Notes |
|---|---|---|---|
| WASM binary | 0 | 0 | No Rust changes |
| JavaScript (shared) | +~0.5–1 KB | +~0.3–0.5 KB | URL param reading on load, pre-selection logic |
| HTML (per landing page) | +~12–18 KB | +~3–5 KB | Separate HTML document per format pair, served on demand |
| Sitemap | +~2–5 KB | +~0.5–1 KB | More entries; fetched separately |
| Initial bundle delta | +~0.5–1 KB | +~0.3–0.5 KB | Landing pages are separate HTTP requests, not part of the main bundle |
Landing pages do not increase the initial load. Each
/png-to-jpegURL is a separate HTML document fetched only when a user navigates to that URL. The WASM binary is shared across all pages and served from browser cache after the first visit. With 20 format pairs, total additional HTML is ~60–100 KB uncompressed across all pages — negligible.
- Decide on URL scheme:
/png-to-jpegvs/convert/png/to/jpeg - Create a template HTML file with placeholder tokens for title, description, source format, and target format
- Write a
generate-pages.jsNode script that produces one HTML file per format pair (N×M combinations) - Add the script to the build pipeline (
"prebuild": "node scripts/generate-pages.js") - Update
ui.tsto read URL path or query params and pre-select source/target formats on load - Add format-pair-specific FAQ content to each generated page
- Add individual
<link rel="canonical">to each page - Update
sitemap.xmlto list all landing page URLs - Add
hreflangif multi-language is planned (skip for now) - Verify Lighthouse SEO scores for generated pages
- Start with the 10 highest-value pairs:
webp-to-png,png-to-jpeg,jpeg-to-png,gif-to-png,bmp-to-png,png-to-gif,png-to-webp,jpeg-to-webp,gif-to-jpeg,tiff-to-png(after Tier 2 is implemented) - Use Google Search Console to monitor which format pairs are getting impressions and optimize those first
- Internal linking between related pages (e.g., "Also try: PNG to JPEG →") improves crawlability and SEO
Difficulty: 2.5/5
Accept SVG files as input and rasterize them to a bitmap format (PNG, JPEG, etc.). SVGs cannot be encoded back to SVG — this is a one-way conversion (SVG → raster). Requires the resvg crate, a pure-Rust SVG renderer.
- SVG → PNG is a genuinely useful and commonly searched operation
resvgis pure Rust, WASM-compatible, and actively maintained- High-quality rendering: supports most SVG 1.1 features
- No server required — maintains the privacy-first value proposition
resvgis a significant binary size addition (~1–3 MB increase in WASM output)- Font rendering requires bundling font data or accepting that SVGs using system fonts will fall back
- Very complex SVG files (filters, animations, CSS) may render incorrectly or slowly
- Output resolution must be specified by the user or guessed — SVGs are scalable, so the user needs to choose a target pixel size
- SVG cannot be encoded back to SVG (no vector export), which may confuse users
- Add
resvgandtiny-skia(its rendering backend) as dependencies; both are WASM-compatible - Add
Svgvariant toImageFormatenum, but only as a decode-only format (EncodeUnsupportedfor SVG output) detect_from_bytes()needs SVG detection: check for<?xmlor<svgat the start (SVGs don't have binary magic bytes)- Add a
rasterize_svg(input: &[u8], width: u32, height: u32) -> Result<Vec<u8>, JsError>function that usesresvgto render at the specified dimensions - Or: integrate into
convert_image()with a default resolution (e.g., 1024px wide preserving aspect ratio) - The frontend must show a resolution input when SVG is the source format
| Asset | Delta (uncompressed) | Delta (gzipped) | Notes |
|---|---|---|---|
WASM binary — resvg |
+~1.2–2 MB | +~400–700 KB | SVG tree parser (usvg) + renderer |
WASM binary — tiny-skia |
+~300–600 KB | +~100–200 KB | 2D rendering backend (path fill, stroke, blending) |
WASM binary — resvg font data |
+~100–400 KB | +~40–120 KB | Bundled font for SVG <text> elements (optional) |
| WASM total | +~1.6–3 MB | +~540 KB–1 MB | Roughly doubles the current WASM binary size |
| JavaScript | +~1–2 KB | +~0.5–1 KB | SVG-specific UI (resolution inputs, format restriction) |
| CSS | 0 | 0 | No new styles |
| Grand total | +~1.6–3 MB | +~540 KB–1 MB | Largest single WASM addition in this roadmap |
SVG rasterization is the most expensive feature by bundle size, bar none. The
resvg+tiny-skiadependency tree adds more bytes than the entire current WASM binary. Mitigation strategies: (1) lazy-load the SVG WASM module separately and only fetch it when the user uploads an SVG file; (2) skip bundled fonts and accept that SVG<text>with system fonts will use fallback rendering. Lazy-loading keeps the initial bundle unchanged and only incurs the cost when needed.
Rust (WASM):
- Add
resvgandtiny-skiatoCargo.toml - Add
SvgtoImageFormatenum as decode-only - Implement
detect_svg(input: &[u8]) -> boolusing XML/SVG signature detection - Implement SVG rasterization using
resvg::render()→tiny_skia::Pixmap - Convert
Pixmaptoimage::DynamicImage(copy RGBA bytes) for the existing encode pipeline - Add WASM export
rasterize_svg(input: &[u8], width: u32, height: u32) -> Result<Vec<u8>, JsError> - Add unit tests for SVG detection and rasterization
- Measure WASM binary size increase and optimize if needed
Frontend:
- Show "Output size" input fields (width × height) when source format is SVG
- Default to SVG viewBox dimensions parsed from the SVG text (or 1024×768 fallback)
- Update
worker.tsto callrasterize_svg()instead ofconvert_image()for SVG input - Update UI to clarify "SVG files can be converted to raster formats only"
- Add SVG to the supported formats section in
index.html
- Consider letting the user lock aspect ratio when specifying output dimensions
resvgdoes not support JavaScript in SVGs, CSS animations, or<use>elements referencing external URLs — document these limitations- Binary size concern: run
wasm-opt -Osand check final bundle size before shipping - SVG files with embedded fonts will render with a fallback if the font is not bundled — this is acceptable for most use cases
Difficulty: 2.5/5
Extract and display image metadata from uploaded files — EXIF data for JPEG/TIFF (camera model, focal length, ISO, GPS, date taken), PNG metadata chunks (description, creation time), and general file info (color space, bit depth, ICC profile presence).
- Useful for photographers who want to verify metadata before sharing
- Can also warn when GPS location data is present (privacy feature — "this image contains GPS coordinates")
- No server required — EXIF parsing is pure data reading from file bytes
- The
kamadak-exifcrate is pure Rust and WASM-compatible
kamadak-exifadds binary size (though it's small, ~100–200 KB)- EXIF data is present in JPEG and TIFF only; PNG, GIF, BMP, WebP have limited or no standard metadata
- Parsing and displaying metadata requires a UI panel — adds visual complexity
- EXIF can contain many fields (100+) — need to filter and show only the most useful ones
- The WASM boundary makes returning structured data from Rust to JS slightly complex (use
serde_jsonorJsValue)
- Add
kamadak-exiftoCargo.toml(or useexifcrate) - Add a new WASM export
get_metadata(input: &[u8]) -> Result<JsValue, JsError>that returns a JSON-serializable struct - Return a flat
HashMap<String, String>or a typed struct serialized viaserde-wasm-bindgen - Display only a curated set of tags: Make, Model, DateTime, ExposureTime, FNumber, ISO, GPSLatitude, GPSLongitude, Width, Height, ColorSpace, Orientation
- Show a GPS warning prominently if GPS tags are detected
- The metadata panel appears below or alongside the image preview — collapsible by default
| Asset | Delta (uncompressed) | Delta (gzipped) | Notes |
|---|---|---|---|
WASM binary — kamadak-exif |
+~80–130 KB | +~28–45 KB | EXIF tag parser; includes IFD traversal and tag table |
| WASM binary — glue code | +~5–10 KB | +~2–4 KB | get_metadata() export + serde serialization |
| JavaScript | +~2–3 KB | +~0.8–1.2 KB | Metadata panel DOM, GPS warning logic |
| CSS | +~1–2 KB | +~0.4–0.8 KB | Collapsible panel, table styling |
| Grand total | +~88–145 KB | +~31–51 KB | Modest cost; kamadak-exif is a focused, lean crate |
The Cons section noted
~100–200 KB— that estimate was for the uncompressed binary. Gzipped (the actual over-the-wire cost) is closer to 30–50 KB. Note thatserde+serde-wasm-bindgenare already inCargo.toml, so serializing the metadata struct to JS adds essentially zero new dependencies.
Rust (WASM):
- Add
kamadak-exif(orexif) toCargo.toml - Implement
get_metadata(input: &[u8]) -> Result<JsValue, JsError>inlib.rs - Parse EXIF fields and return a
HashMap<String, String>of formatted key-value pairs - Detect GPS presence and return a boolean
has_gpsflag - Return
Nonegracefully for formats with no EXIF support (PNG, GIF, BMP) - Add unit tests for EXIF parsing with real JPEG fixtures
Frontend:
- Call
get_metadata()in the Worker alongsideget_dimensions()when a file is loaded - Design a collapsible metadata panel in
index.html - Display key EXIF fields in a clean table format
- Show a prominent "GPS data detected" warning with a note about privacy
- Update
analytics.tsto includehas_exifandhas_gpsin theimage_selectedevent
- "Strip EXIF metadata" is a natural follow-on feature: re-encode the image without EXIF (JPEG → JPEG, stripping metadata) — this is a privacy tool that many users want
- EXIF orientation tag (1–8) determines if the image is stored rotated — the
imagecrate already handles this viaDynamicImage::into_img()when loading. Displaying the orientation value is useful debugging info.
Difficulty: 2.5/5
After the user uploads an image, automatically convert it to all supported formats in the background and show a size comparison table. Lets users find the smallest file format for their specific image without manual trial-and-error.
- Genuinely unique feature — no major competitor does this in-browser
- High utility: "which format gives me the smallest file?" is a common question
- Leverages the existing conversion pipeline with no Rust changes
- Makes the "privacy-first" angle even stronger (all done locally, instantly)
- Great for SEO: unique, useful tool that gets links and shares
- Running N conversions in sequence after upload increases time-to-interactive
- For large images (e.g., 12 MP), running 5+ conversions could take 2–5 seconds total
- Showing a table of results before the user requests a specific conversion changes the UX flow
- "Best" format depends on use case (JPEG is smaller for photos; PNG is better for screenshots with text)
- Run all benchmark conversions in the Web Worker sequentially, using the existing
convert_image()calls - Return results incrementally: as each format completes, post a
BenchmarkProgressmessage to the main thread so the table populates live - Add a new Worker message type
BenchmarkImagesthat accepts input bytes and target format list, returns results one-by-one viaBenchmarkResultmessages - The main thread renders a table with format name, file size, size change %, and a "Convert to this" button
- Use JPEG quality 80 as the default for JPEG benchmarks; PNG default compression
| Asset | Delta (uncompressed) | Delta (gzipped) | Notes |
|---|---|---|---|
| WASM binary | 0 | 0 | Uses existing convert_image() — no new Rust code |
| JavaScript | +~2–4 KB | +~0.8–1.5 KB | Benchmark orchestration loop, new Worker message types, results table UI |
| CSS | +~1–2 KB | +~0.4–0.8 KB | Results table, loading skeleton, highlight styling |
| Grand total | +~3–6 KB | +~1.2–2.3 KB | Near-zero bundle cost — feature is pure orchestration logic |
This is the best cost-to-value ratio of any feature in this roadmap. Zero new dependencies, zero WASM cost. The feature is entirely implemented by calling existing code in a loop and rendering results.
Worker:
- Add
BenchmarkImagesrequest type toworker-types.ts - Add
BenchmarkResultandBenchmarkCompleteresponse types - Implement benchmark loop in
worker.ts: iterate formats, convert, postBenchmarkResultper format - Allow early termination if a new file is loaded while benchmarking
Frontend:
- Add a results table to
index.html(hidden initially, shown after benchmark completes) - Handle incremental
BenchmarkResultmessages — populate table rows as they arrive - Add a "Convert to this" button per row that pre-selects the format and triggers download
- Highlight the smallest output format in the table
- Add a loading skeleton for rows that haven't completed yet
- Track benchmark completion in PostHog (
benchmark_viewedevent with format rankings)
- The benchmark should be opt-in (a "Compare all formats" button) rather than running automatically on every upload to avoid unnecessary computation for simple use cases
- Show a progress indicator (e.g., "Comparing 4/6 formats...") so users know it's working
- Only benchmark output formats (not the input format as output of itself), and skip SVG
- Consider using
Promise.allSettledin the Worker with sequential calls (not parallel — WASM is single-threaded)
Difficulty: 3/5
Implement the full set of parameterized image processing operations from the image crate's imageops module. Unlike the simple transforms in Feature 5, these require user-supplied parameters and a more complex UI.
| Operation | imageops function |
Parameters |
|---|---|---|
| Resize | resize() |
Width, height, filter type |
| Thumbnail | thumbnail() |
Max width, max height (preserves aspect ratio) |
| Crop | crop_imm() |
x, y, width, height |
| Blur (Gaussian) | blur() |
Sigma (float) |
| Fast blur | fast_blur() |
Sigma (float) |
| Unsharpen mask | unsharpen() |
Sigma, threshold |
| Brighten | brighten() |
Value (-255 to 255) |
| Contrast | contrast() |
Value (float, -100.0 to 100.0) |
| Hue rotate | huerotate() |
Degrees (0–360) |
| Tile | tile() |
Count or fill mode |
- Transforms this from a "format converter" to a lightweight "image editor" — much higher user retention and utility
- All operations are available in the
imagecrate with no additional dependencies - Resize is especially high-value: "resize image online" is a top search term
- Blur and sharpen are useful for social media preparation
- Significant UI work: sliders, input fields, crop selection UI (crop especially needs a canvas-based drag interface)
- Each operation needs live preview feedback — re-running conversion on every slider change is expensive for large images
- Crop requires an interactive overlay on the preview image — complex frontend logic
- Ordering matters: resize then crop ≠ crop then resize
- The Worker message protocol grows significantly
- Extend
convert.rsto accept aProcessingOptionsstruct (or vector of operation descriptors) applied in sequence between decode and encode - Use
serdeto pass options from JS through WASM boundary as JSON or viaJsValue - Operations struct in Rust:
pub enum Operation { Resize { width: u32, height: u32, filter: &'static str }, Crop { x: u32, y: u32, width: u32, height: u32 }, Blur { sigma: f32 }, Brighten { value: i32 }, Contrast { value: f32 }, HueRotate { degrees: i32 }, Unsharpen { sigma: f32, threshold: i32 }, Tile, }
- For live preview, use a "preview resolution" mode: before applying operations at full resolution, apply them to a downscaled thumbnail (e.g., 400px wide) and show that as preview. Only run full-resolution conversion on download.
- Crop UI requires a draggable selection overlay on the preview image — consider using a small library or building a simple canvas-based crop selector
| Asset | Delta (uncompressed) | Delta (gzipped) | Notes |
|---|---|---|---|
WASM binary — resize (all filter types) |
+~30–55 KB | +~10–18 KB | Lanczos3, CatmullRom, Triangle, Gaussian sampling kernels |
WASM binary — blur / fast_blur |
+~20–35 KB | +~7–12 KB | Gaussian convolution; fast_blur is a box approximation |
WASM binary — unsharpen |
+~8–15 KB | +~3–5 KB | Calls blur internally + subtraction |
WASM binary — crop_imm |
+~5–10 KB | +~2–3 KB | Sub-image view copy |
WASM binary — brighten / contrast / huerotate |
+~15–25 KB | +~5–8 KB | Per-pixel arithmetic on channel values |
WASM binary — tile |
+~8–12 KB | +~3–4 KB | Repeated overlay calls |
WASM binary — serde_json (options parsing) |
0 | 0 | serde already a dependency; serde_json adds ~40 KB uncompressed / ~15 KB gzipped if not already included |
WASM binary — processing.rs glue |
+~5–10 KB | +~2–3 KB | Dispatch enum, JSON deserialization, pipeline loop |
| WASM total | +~91–162 KB | +~32–53 KB | Largest Rust addition after SVG; resize filters dominate |
| JavaScript | +~5–8 KB | +~2–3 KB | Edit panel UI, sliders, crop overlay, debounce logic |
| CSS | +~3–6 KB | +~1–2 KB | Edit panel layout, slider styling, crop overlay |
| Grand total | +~99–176 KB | +~35–58 KB | Significant but reasonable for a full processing pipeline |
serde_jsonis the hidden cost — if it's not already inCargo.toml(currently onlyserde-wasm-bindgenis used), adding it for JSON options parsing adds ~15 KB gzipped. Consider passing operations as aJsValuearray instead of JSON strings to avoid this dependency entirely.
Rust (WASM):
- Define
ProcessingOperationenum andProcessingOptionsstruct in a newprocessing.rsmodule - Implement each operation using
imageopsfunctions - Integrate the processing pipeline into
convert()between decode and encode steps - Add
process_image(input: &[u8], operations_json: &str, target_format: &str)WASM export - Add resize filter options: Nearest, Triangle (bilinear), CatmullRom, Gaussian, Lanczos3
- Add unit tests for each operation
Frontend:
- Design an "Edit" panel below the file upload section with tabs or sections for each operation group
- Resize section: width/height inputs with aspect ratio lock toggle, filter selector
- Crop section: interactive crop overlay on the preview (or numeric x/y/w/h inputs as fallback)
- Adjustments section: sliders for brightness, contrast, hue rotation, blur sigma
- Implement debounced (300ms) preview updates using low-resolution preview mode
- Implement "Reset" per operation and "Reset All"
- Serialize active operations as ordered JSON array and pass to Worker
- Resize is the highest-priority operation here — treat it as a separate, simpler feature first (Feature 3.5, so to speak) before building the full processing pipeline
- Lanczos3 is the highest quality resize filter but slowest; Nearest is fastest but pixelated — expose the choice for power users
- The tile operation has limited use but is fun and creative (creates a pattern from the image)
filter3x3()(custom 3x3 kernel) is an advanced feature — skip for now unless there's clear user demand
Difficulty: 3/5
Show the original and converted images side by side with a draggable split slider, allowing users to visually compare quality differences (especially useful for lossy conversions like PNG → JPEG, or heavy blur/compression).
- High visual impact — makes the tool feel premium
- Directly addresses user question "how much quality did I lose?"
- Useful for demonstrating WebP savings vs JPEG, or JPEG quality tradeoffs
- Pure frontend feature — no Rust or Worker changes
- Requires a draggable slider overlay on two stacked images — non-trivial canvas or CSS implementation
- Must handle images with different dimensions (post-resize operations change size)
- On mobile, the drag interaction needs touch event handling
- Two images displayed simultaneously doubles GPU/memory use for preview rendering
- Implementation options:
- CSS clip-path approach: Stack two
<img>elements, clip one withclip-path: inset(0 0 0 X%)where X tracks the slider position. Simple but requires images to be the same display size. - Canvas approach: Draw both images onto a
<canvas>, clip at slider position. More flexible, handles different dimensions, but more code. - Library:
img-comparison-sliderweb component is a good zero-config option (4 KB, no dependencies)
- CSS clip-path approach: Stack two
- The split view should replace the single preview when a conversion completes (or be a toggle)
- Store both
originalBlobUrlandconvertedBlobUrlin UI state — currently onlyconvertedBlobUrlis stored
| Asset | Delta (uncompressed) | Delta (gzipped) | Notes |
|---|---|---|---|
| WASM binary | 0 | 0 | Pure frontend feature |
| JavaScript (custom CSS clip-path) | +~3–5 KB | +~1.2–2 KB | Drag event handling, clip-path calculation, touch events |
JavaScript (img-comparison-slider web component) |
+~8–12 KB | +~3–5 KB | If using the library instead of custom code |
| CSS | +~2–4 KB | +~0.8–1.5 KB | Split view layout, drag handle styling |
| Grand total (custom) | +~5–9 KB | +~2–3.5 KB | Recommended — no new dependency |
| Grand total (library) | +~10–16 KB | +~3.8–6.5 KB | Slightly larger but zero implementation risk |
The custom CSS clip-path approach is ~3 KB gzipped and has no runtime overhead beyond standard DOM events. The
img-comparison-sliderweb component is battle-tested and worth the extra ~2 KB if you don't want to maintain drag logic + touch handling yourself.
- Store
originalBlobUrlalongsideconvertedBlobUrlinui.tsstate - Add a toggle button "Split View / Single View" that appears after conversion
- Implement the split view component:
- Option A: Use
img-comparison-sliderweb component - Option B: Custom CSS clip-path implementation (preferred, no new dep)
- Option A: Use
- Add touch event support for mobile drag
- Show format labels above each panel ("Original: JPEG" / "Converted: PNG")
- Show file sizes under each panel
- Handle dimension mismatch (scale images to same display size)
- Ensure blob URLs for both images are revoked when user loads a new file
- The slider start position should default to 50% (center)
- For JPEG quality comparisons (after Feature 3), the split view becomes even more valuable — user can see quality vs. size tradeoff live
- Consider adding pixel zoom on hover/tap (magnifying glass effect) for comparing compression artifacts
Difficulty: 3/5
Analyze the uploaded image and extract its dominant color palette (5–10 colors). Display the palette as swatches with hex codes, which users can copy. Useful for designers who want to extract brand colors from an image.
- Unique feature that attracts designers, a high-value audience
- Genuinely useful: extracting brand colors from a logo or photo is a common design task
- Pure Rust computation — no new JS libraries needed
- Shareable/copyable hex codes add engagement (copy interaction)
- Color quantization (finding N representative colors from millions) requires an algorithm — no built-in in the
imagecrate - K-means clustering is the standard approach but has variable runtime depending on image complexity and K value
- The
imagecrate'sdither()uses a simple quantization; for quality palette extraction, a dedicated crate likecolor-thief(Rust port of ColorThief) or implementing median cut is needed - Running on full-resolution images in WASM can be slow for large images — downsample first
- Downsample image to ~100px before palette extraction to dramatically reduce computation (color accuracy is still good at this resolution)
- Use the
color-thiefRust crate if WASM-compatible, or implement median cut algorithm directly - Alternatively: use the
imagecrate's color quantization used for GIF encoding (NeuQuantquantizer) to extract representative colors - New WASM export:
extract_palette(input: &[u8], num_colors: u8) -> Result<JsValue, JsError>returnsVec<[u8; 3]>(RGB triplets) - Frontend renders swatches with copy-to-clipboard for hex codes
| Asset | Delta (uncompressed) | Delta (gzipped) | Notes |
|---|---|---|---|
| WASM binary — custom median cut | +~25–45 KB | +~9–16 KB | Hand-rolled median cut algorithm + downsampling step |
WASM binary — color-thief crate (if WASM-compatible) |
+~40–80 KB | +~14–28 KB | Includes k-means or median cut + Rust overhead |
WASM binary — using NeuQuant (already in image crate for GIF) |
0–5 KB | 0–2 KB | Reuse existing quantizer — nearly free if GIF feature is enabled |
| JavaScript | +~1.5–3 KB | +~0.6–1.2 KB | Swatch rendering, clipboard copy, "Copy all" button |
| CSS | +~1–2 KB | +~0.4–0.8 KB | Swatch grid layout |
| Grand total (NeuQuant reuse) | +~2.5–5 KB | +~1–3 KB | Near-free if you piggyback on the GIF quantizer |
| Grand total (custom median cut) | +~27.5–50 KB | +~10–18 KB | Good quality, no new dependencies |
| Grand total (color-thief) | +~42–85 KB | +~15–30 KB | Best quality, highest cost |
Recommended path: First try reusing
NeuQuant(already compiled in for GIF encoding) — it's already in the binary and produces decent palettes. If quality is insufficient, implement median cut as a ~200-line Rust function. Avoid addingcolor-thiefas a dependency unless it demonstrably outperforms the custom implementation.
Rust (WASM):
- Evaluate
color-thiefcrate for WASM compatibility; fall back to implementing median cut if needed - Implement
extract_palette()with input downsampling (resize to 100px width first) - Add WASM export returning array of RGB hex strings
- Add unit tests for palette extraction on known images
Frontend:
- Display palette swatches below the image preview after upload (or run on demand)
- Each swatch shows hex code; click copies to clipboard
- Show a "Copy all" button that copies the full palette as comma-separated hex codes
- Add a subtle loading state while palette is computed
- Track
palette_copiedevent in PostHog
- 6–8 colors is the sweet spot for palette size — too few misses nuance, too many becomes noise
- For images with few distinct colors (logos, icons), the palette result is very clean and useful
- The palette could be shown as CSS custom properties output (e.g.,
--color-primary: #FF6B6B) for developers - A potential companion to the "Side-by-Side Comparison" feature — show palette changing as you adjust hue/saturation
Difficulty: 3/5
Convert the app into a Progressive Web App (PWA) with a service worker that caches the WASM module and static assets. Enables offline usage and "Add to Home Screen" installation on mobile.
- "Works offline" is a genuine differentiator and a privacy-reinforcing message
- PWA installation improves repeat-visit engagement
- Service worker caching improves load performance for return visitors (WASM module cached after first visit)
- WASM modules are large (~1–3 MB); caching them dramatically reduces repeat-load time
- Service worker lifecycle is notoriously tricky: cache invalidation on deploys, update UX, error handling for cache misses
- WASM binary and JS bundle must be served with correct CORS headers (already the case for WASM, but service workers add a layer)
- The
COOP/COEPheaders required forSharedArrayBuffer(for future cancellation/threading) interact with service workers — need to be careful - Vite has limited first-class PWA support out of the box; use
vite-plugin-pwafor full support
- Generate a
manifest.json(app name, icons, theme color, display mode) - Write a service worker using Workbox (Google's SW library) or manually — Workbox handles cache strategies cleanly
- Cache strategy: Cache-first for WASM binary, Network-first for HTML (to pick up updates)
- Register the SW in
main.ts(navigator.serviceWorker.register('/sw.js')) - Vite: add a custom
sw.jsentry point or usevite-plugin-pwato include the SW file
| Asset | Delta (uncompressed) | Delta (gzipped) | Notes |
|---|---|---|---|
| WASM binary | 0 | 0 | No Rust changes |
| JavaScript — service worker (manual) | +~4–8 KB | +~2–3 KB | Fetch handler, cache strategy, update logic |
| JavaScript — Workbox runtime | +~60–100 KB | +~22–38 KB | If using Workbox; includes all strategy modules |
| JavaScript — SW registration | +~0.3–0.5 KB | +~0.2 KB | navigator.serviceWorker.register() call in main.ts |
manifest.json |
+~0.5–1 KB | +~0.3 KB | Separate HTTP request, fetched once |
| PWA icons (192×192 + 512×512 PNG) | +~25–60 KB | n/a | PNG icons; served separately, cached by SW |
| Grand total (manual SW) | +~30–70 KB | +~2.5–4 KB | Only SW registration JS is in the critical path; SW + icons cached |
| Grand total (Workbox) | +~86–162 KB | +~22–41 KB | Workbox runtime loads in the SW context, not main thread |
The service worker and icons are not part of the main JS bundle — they are separate resources fetched after the page loads. The critical-path cost is only the SW registration snippet (~200 bytes). Workbox's runtime executes inside the service worker process, not on the main thread. Choose Workbox for correctness; choose a manual SW if bundle minimalism matters.
- Create
web/public/manifest.jsonwith app metadata, icons, colors - Create icons at 192×192 and 512×512 (PNG) for PWA install
- Add
<link rel="manifest">and<meta name="theme-color">toindex.html - Write
sw.jsservice worker (use Workbox for simplicity):- Precache WASM binary, JS bundle, CSS, HTML
- Cache-first for assets, Network-first for HTML
- Register service worker in
main.ts - Handle service worker updates: show "Update available — reload" banner
- Test offline functionality: disconnect network, verify conversion still works
- Test
Add to Home Screenon Android Chrome and iOS Safari - Verify cache-busting works on deploy (Vite adds content hashes to filenames — SW should update automatically)
- The WASM module is the most important asset to cache — it's large and the app is completely non-functional without it
- iOS Safari has PWA quirks: no push notifications, limited service worker storage quota, splash screen requires specific icon sizes
- The "offline" badge or indicator ("Working offline") is a nice UX touch when the network is unavailable
- PWA installation prompt can be intercepted with
beforeinstallpromptevent to show a custom "Install App" button
Difficulty: 3.5/5
Allow users to add a text or image watermark to the output image before conversion. Common use cases: copyright text on exported photos, logos overlaid on product images.
- High practical value for photographers and content creators
- The
imageops::overlay()function handles image composition in Rust - Text rendering can be done via the
ab_glyphorrusttypecrates (pure Rust font rendering) - Differentiates from simple format converters
- Text watermarking requires a font renderer — adds significant binary size (font data + renderer)
- Font must be bundled (can't use system fonts in WASM) — even a minimal font is ~100–500 KB
- Image watermark (logo overlay) requires the user to upload a second file — complicates the UI
- Watermark positioning (corners, center, tiled) requires a coordinate system tied to the output dimensions
- The UI surface area grows: text input, font size, opacity, position, color
- For text watermarking: use
ab_glyphfor glyph rendering +imageproccrate'sdraw_text_mut()(or implement manually) - For image watermarking: accept a second
logo: &[u8]input, decode it, useimageops::overlay()at the specified position - Bundle a minimal font file (e.g., Roboto or Noto Sans subset) as a
staticbyte array compiled into the WASM binary - New WASM exports:
add_text_watermark(input: &[u8], text: &str, options_json: &str) -> Result<Vec<u8>, JsError>add_image_watermark(input: &[u8], watermark: &[u8], options_json: &str) -> Result<Vec<u8>, JsError>
- Options include: position (9 anchor points), opacity (0.0–1.0), padding, font size (for text)
| Asset | Delta (uncompressed) | Delta (gzipped) | Notes |
|---|---|---|---|
WASM binary — ab_glyph |
+~60–100 KB | +~22–35 KB | TTF/OTF glyph rasterizer |
WASM binary — imageproc (if used) |
+~80–150 KB | +~28–52 KB | General image processing; only draw_text_mut needed — consider inlining instead |
| WASM binary — bundled font (Latin subset) | +~40–80 KB | +~25–50 KB | Stored in WASM data segment as static [u8] |
| WASM binary — watermark logic | +~8–15 KB | +~3–5 KB | Position math, opacity blending, imageops::overlay() |
| WASM total | +~108–345 KB | +~50–142 KB | Range depends on whether imageproc is added or inlined |
| JavaScript | +~2–4 KB | +~0.8–1.5 KB | Watermark UI (text input, sliders, position picker) |
| CSS | +~1–2 KB | +~0.4–0.8 KB | Collapsible watermark section |
| Grand total | +~111–351 KB | +~51–144 KB | Font data and imageproc are the main unknowns |
Key decision: avoid adding all of
imageprocjust fordraw_text_mut. The function is ~200 lines in imageproc's source — copy it directly intolib.rsto avoid the 28–52 KBimageprocoverhead. Also consider serving the font file as a separate fetch (/fonts/watermark.woff2) rather than embedding it in the WASM binary, which keeps the initial WASM load fast and only incurs the font cost when the user enables watermarking.
Rust (WASM):
- Add
ab_glyphand a bundled font toCargo.toml - Add
imageprocor implementdraw_text_mut()directly - Implement
add_text_watermark()with position, size, color, opacity options - Implement
add_image_watermark()usingimageops::overlay()with alpha blending - Add unit tests for watermark placement (verify pixel values at expected positions)
Frontend:
- Add a "Watermark" section to the UI (collapsible, off by default)
- Text watermark: text input, font size slider, color picker, position selector (3×3 grid), opacity slider
- Image watermark: second file picker for logo, size (% of output width), position, opacity
- Show watermark preview in real-time (using debounced re-conversion of preview)
- Clearly label that watermark is applied to the converted output, not the original
- Start with text watermarking only (simpler) before tackling image watermarking
- Bundled font size concern: a subset of Roboto covering Latin characters is ~15–30 KB — very manageable
- Tiled watermark (repeated across the entire image) is a common pattern — add as a position option
- Opacity control is critical: a fully opaque watermark destroys the image; 20–40% is typical
Difficulty: 3.5/5
Replace the estimated progress bar with actual decode/encode progress reported from the Rust side via wasm-bindgen callbacks. Provides accurate progress for large images and removes the "90% stall" behavior.
- Accurate progress is a better UX than the estimated approximation
- Eliminates the "stuck at 90%" problem for images that take longer than estimated
- Showcases Rust↔JS interop capabilities
- The
imagecrate's decoders/encoders don't natively expose progress callbacks — this requires wrapping or instrumenting them, which is complex - Implementing progress requires calling back into JS mid-conversion, which involves
wasm-bindgenclosures — these have lifetime constraints that make them tricky to pass into deeply nested calls - Each callback crosses the WASM boundary, which has overhead — too many callbacks hurts performance
- If implemented naively, it changes the Rust function signatures significantly
- Strategy: use checkpoints rather than per-row progress. Report 4–6 progress events:
0%(start),25%(decoding started),50%(decoding complete),75%(encoding started),90%(encoding complete),100%(done) - In Rust: use a
Fn(f32)callback passed from JS viawasm-bindgenclosure. Call it at each checkpoint. wasm-bindgenclosures:js_sys::Functioncan be passed from JS and called in Rust viaapply()- Worker protocol: instead of a single
ConversionCompletemessage, send multipleConversionProgress { percent: f32 }messages, then a finalConversionComplete - Alternatively: use a
SharedArrayBuffer+ Atomics for a shared progress counter (simpler, no callbacks needed, but requires COOP/COEP security headers)
| Asset | Delta (uncompressed) | Delta (gzipped) | Notes |
|---|---|---|---|
WASM binary — js_sys::Function callback |
+~3–8 KB | +~1–3 KB | js_sys is already a transitive dependency of wasm-bindgen — minimal addition |
| JavaScript | +~1–2 KB | +~0.4–0.8 KB | ConversionProgress message type, updated progress bar handler |
| CSS | 0 | 0 | No visual changes — same progress bar |
| Grand total | +~4–10 KB | +~1.4–3.8 KB | Very low cost; js_sys is already in the dependency tree |
js_sysis already a transitive dependency ofwasm-bindgen, so callingjs_sys::Functionfrom Rust compiles in code that is likely already present. The actual binary delta depends on whichjs_sysAPIs are currently tree-shaken out.
Rust (WASM):
- Add
progress_callback: Option<js_sys::Function>parameter toconvert_image() - Call the callback at decode-start, decode-end, encode-start, encode-end checkpoints
- Handle
Nonecallback gracefully (existing callers without callback still work) - Add a typed wrapper struct for the progress function to avoid raw
js_sys::Functionuse
Worker (TS):
- Create a
progressCallbackfunction that postsConversionProgressmessages to main thread - Pass callback as a
js_sys::Function-compatible value when callingconvert_image() - Ensure callback is properly cleaned up after conversion completes
Frontend:
- Handle
ConversionProgressmessage type inmain.tsand forward toui.ts - Update progress bar in
ui.tsto use actual progress values instead of the estimated model - Keep the estimated model as a floor (progress bar never goes backwards)
- The
SharedArrayBufferapproach is cleaner if COOP/COEP headers are already configured — check deployment headers before choosing approach - For V1 of real progress, the 6-checkpoint approach is sufficient — per-row progress is overkill and expensive
- Real progress reporting is most valuable for large images (>5 MP) — for small images the conversion is near-instant anyway
Difficulty: 4/5
Refactor the frontend from vanilla TypeScript to React (or a lightweight alternative like Preact). The current vanilla TS works well for the MVP, but as more features are added (image processing pipeline, batch mode, side-by-side comparison, watermarking), managing DOM state manually becomes error-prone.
- React's component model is well-suited to the growing UI: each operation (resize, crop, watermark) becomes a self-contained component
- State management becomes explicit (
useState,useReducer) vs implicit DOM mutation - Easier to add complex interactions (drag-to-crop, before/after slider) with React component libraries
- Vastly better developer experience for UI iteration
- Large ecosystem for accessible UI primitives (Radix UI, shadcn/ui)
- Significant migration effort — every file in
web/src/needs rewriting - React adds bundle size (~40–50 KB gzipped for React + ReactDOM; ~4 KB for Preact)
- The current Vite setup works well with vanilla TS; React/Preact requires adding the appropriate Vite plugin (e.g.,
@vitejs/plugin-reactor@preact/preset-vite) - SEO: the current vanilla TS approach has all static content in
index.html, fully crawlable. React's client-side rendering may require SSR or static generation (Next.js) to preserve SEO — significant additional complexity - The existing
ImageConverterclass and Worker integration are clean and can be preserved as-is; the migration touches only the UI layer
- Preact over React: Preact is API-compatible with React but 1/10th the size (~3 KB). For a tool with no React ecosystem dependencies, Preact is the right choice.
- Bundler: Vite — better React/Preact DX, faster HMR, better PWA plugin support (see Feature 13), first-class WASM support
- SEO risk: The format-specific landing pages (Feature 6) depend on static HTML content. With a React SPA, this breaks unless using Next.js or Astro. Consider Astro as the frontend framework: static HTML generation + React/Preact islands for the interactive converter.
- Component structure:
App ├── ThemeProvider ├── DropZone ├── FormatSelector ├── ProcessingPanel │ ├── TransformControls │ ├── ResizeControls │ ├── AdjustmentSliders │ └── WatermarkControls ├── ProgressBar ├── ComparisonView │ ├── BeforePanel │ └── AfterPanel └── MetadataPanel - The
ImageConverterclass inmain.tscan be exposed via React Context instead ofwindow.__converter - Keep the Web Worker and Rust WASM layer completely unchanged
| Asset | Delta (uncompressed) | Delta (gzipped) | Notes |
|---|---|---|---|
| WASM binary | 0 | 0 | Worker + Rust layer untouched |
| JavaScript — Preact (recommended) | +~8–12 KB | +~3–5 KB | Preact core (~3 KB) + hooks (~1 KB); app code stays same size |
| JavaScript — React + ReactDOM | +~280–350 KB | +~100–130 KB | React 18 production build; app code stays same size |
| JavaScript — Astro island | +~15–25 KB | +~5–9 KB | Astro runtime for hydrating the converter island |
| CSS | 0 | 0 | Tailwind classes remain the same |
| Vite (Rollup-based bundling) | −5–15% | −5–15% | Vite's Rollup-based tree-shaking typically produces smaller bundles |
| Grand total (Preact) | +~8–12 KB | +~3–5 KB | Strongly recommended |
| Grand total (React) | +~280–350 KB | +~100–130 KB | Avoid unless ecosystem lock-in is required |
Preact is the clear choice. It adds ~3–5 KB gzipped over the current vanilla TS bundle — essentially free. React adds 100–130 KB, more than doubling the current JS payload. The Web Worker + WASM layer is completely unaffected by this migration either way, since it runs in a separate thread context.
- Evaluate and decide: React vs. Preact, SPA vs. Astro
- Set up new build toolchain (Vite + Preact recommended)
- Create project structure with component directories
- Migrate
ImageConverterclass to a React Context + custom hooks (useConverter,useWorker) - Migrate
ui.tsstate to React state (useState/useReducer) - Rewrite each UI section as a React component (one PR per component):
-
DropZonecomponent -
FormatSelectorcomponent -
ProgressBarcomponent -
ImagePreview/ComparisonViewcomponent -
MetadataPanelcomponent -
ErrorDisplaycomponent
-
- Migrate
analytics.tsto work as a React hook (useAnalytics) - Re-implement dark mode using React context (or CSS-only toggle)
- Update Playwright E2E tests to work with new component structure
- Verify Core Web Vitals are not degraded (LCP, CLS, INP)
- If using Vite, update
package.jsonscripts accordingly
- Recommendation: If the app is going to get more than 3–4 of the features in this roadmap, migrate to Preact + Vite sooner rather than later. The ROI increases with every feature added.
- Astro is worth considering: it renders the page shell (H1, FAQ, supported formats) as static HTML at build time, while the converter tool is a React/Preact island. Best of both worlds for SEO + component ergonomics.
- The migration can be done incrementally: start with Vite + Preact, port the core converter first, then port each UI section. The Worker and WASM layer don't need to change at all.
Difficulty: 4/5
Allow users to upload and convert multiple images at once. Files are processed sequentially in the Web Worker and results are available for individual or bulk download.
- Batch processing is listed as the top stretch goal for good reason — it dramatically increases utility for power users
- A user converting 50 holiday photos to WebP needs batch mode
- The underlying Worker and WASM code requires no changes — just sequential calls
- A "Download all as ZIP" capability makes the feature complete
- The UI shifts from a single-file flow to a multi-file queue — significant UI redesign
- ZIP creation in the browser requires a library (
fflateorJSZip) — adds dependency - Memory management becomes critical: holding all converted images in memory simultaneously is dangerous for large batches
- Error handling per file (some succeed, some fail) needs clear UX
- The current Worker architecture processes one file at a time — batch mode means managing a queue of requests
- UI model: a file list/queue with per-file status (pending, converting, done, error) and a progress indicator per file
- Processing model: sequential (one conversion at a time in the Worker) — simpler and avoids memory buildup
- Memory model: stream results out as they complete rather than buffering all. Create and immediately revoke blob URLs for individual downloads; only hold all bytes in memory when the user clicks "Download all as ZIP"
- ZIP generation: use
fflate(fast, WASM-based) orJSZipin the main thread to create a ZIP from collected byte arrays - ZIP streaming: for very large batches, use
fflate's streaming ZIP API to pipe converted bytes directly into the ZIP without holding all files in memory simultaneously - New Worker message types:
BatchConvertStart,BatchConvertProgress { index, total, result },BatchConvertComplete - The format selection and processing options (from Feature 10) apply uniformly to all files in the batch (same target format, same quality, etc.)
| Asset | Delta (uncompressed) | Delta (gzipped) | Notes |
|---|---|---|---|
| WASM binary | 0 | 0 | Batch uses existing convert_image() sequentially |
| JavaScript — batch queue logic | +~3–5 KB | +~1.2–2 KB | Queue manager, status tracking, progress messages |
JavaScript — fflate ZIP library |
+~25–35 KB | +~9–12 KB | Fast ZIP creation; streams output to avoid memory buildup |
JavaScript — JSZip (alternative) |
+~80–100 KB | +~30–38 KB | Heavier alternative; avoid in favour of fflate |
| CSS | +~2–4 KB | +~0.8–1.5 KB | File queue list, status badges, progress per file |
Grand total (fflate) |
+~30–44 KB | +~11–15.5 KB | Reasonable cost; fflate dominates |
Grand total (JSZip) |
+~85–109 KB | +~31–41.5 KB | Avoid |
fflateis the right choice — it's ~3× smaller thanJSZipand has a streaming API that avoids holding all converted files in memory simultaneously. For batches that don't need ZIP (individual downloads only), thefflatecost can be deferred until the user first clicks "Download all as ZIP" via dynamicimport().
Worker (TS):
- Add
BatchConvertRequestandBatchConvertProgressmessage types toworker-types.ts - Implement batch processing loop in
worker.ts: iterate files, convert each, post progress after each - Support cancellation of in-progress batch (check a cancellation flag between files)
Frontend:
- Redesign file input area to support multi-file selection (
multipleattribute on<input type="file">) - Build a file queue UI component: list of filenames, status badges, individual download buttons
- Show overall batch progress (e.g., "Converting 3 of 12...")
- Per-file progress/status indicator (queued, converting, done ✓, error ✗)
- Add "Download all as ZIP" button (enabled after at least one file is converted)
- Implement ZIP creation using
fflatestreaming API - Allow removing individual files from the queue before or during processing
- Handle mixed-format input batches (each file auto-detected, all converted to the selected output format)
- Update PostHog events for batch:
batch_started { file_count },batch_completed { success_count, error_count, total_ms }
- The "Download all as ZIP" button is the most user-valuable part of batch mode — implement it before optimizing the queue UI
- Individual file download buttons should appear as each file finishes (don't wait for the full batch)
- For large batches (>20 files), show a "Pause" button to let users stop and download completed files
- Batch processing combined with the image processing pipeline (Feature 10) — e.g., "resize all images to 1920px wide and convert to WebP" — is a killer workflow
Difficulty: 4.5/5
Increase the current 200 MB file size limit and 100 MP pixel limit to accommodate professional workflows (RAW photo editing, high-resolution scans, print production files).
- Enables professional use cases currently blocked (200 MB RAW TIFF exports, 200 MP medium format scans)
- Keeps the tool competitive with desktop applications
- No new Rust logic — just adjusted thresholds and better failure handling
- Mobile reliability degrades significantly above 200 MB (iOS Jetsam OOM kills are silent and unrecoverable)
- WASM 4 GB linear memory ceiling is a hard architectural limit on wasm32
- Large allocations increase time-to-convert (decoding a 500 MB file takes several seconds)
- Chunked/tiled processing (the real solution) is a significant research and implementation effort
- Memory64 (wasm64) raises the ceiling to 16 GB but has no Safari support and a 10–100% performance penalty
- Tier approach: Raise desktop limits to 500 MB / 375 MP; keep mobile limits at 100 MB / 50 MP
- Mobile detection: Use
navigator.userAgentData.mobileorscreen.width+ device pixel ratio to determine mobile and apply lower limits - Tiled processing (long-term): Process image in horizontal strips — decode one strip at a time, encode it, free it, process next strip. Requires format-specific strip decoders (the
imagecrate doesn't support this natively — would need custom codec wrappers for JPEG/PNG at minimum). This removes the WASM memory bottleneck entirely. - Streaming reads via
Blob.slice(): Read the file in chunks from JS usingBlob.slice()to avoid loading the full file into WASM memory at once. Useful for very large sequential formats. - Memory64 (future): Track WASM GC and memory64 proposal status; adopt when Safari support lands.
| Asset | Delta (uncompressed) | Delta (gzipped) | Notes |
|---|---|---|---|
| WASM binary | 0 | 0 | No new logic — just changed threshold constants |
| JavaScript | +~0.3–0.5 KB | +~0.1–0.2 KB | Mobile detection, updated limit constants, new warning messages |
| CSS | 0 | 0 | Existing warning UI reused |
| Grand total (near-term) | +~0.3–0.5 KB | +~0.1–0.2 KB | Essentially free |
| Grand total (tiled processing) | +~80–200 KB WASM | +~28–70 KB | Custom strip decoders for JPEG/PNG are significant new Rust code |
The threshold changes themselves are free. The hard work — tiled/chunked processing — requires new codec wrappers in Rust that add meaningful binary size. A custom JPEG strip decoder (
mozjpegbindings) would add ~50–150 KB WASM gzipped on its own. This is a research-heavy feature where actual size impact depends on the chosen approach.
Near-term (raise limits without tiling):
- Research actual tested limits on desktop Chrome, Firefox, Safari (16+ GB RAM machines)
- Detect mobile vs desktop in
ui.tsand apply different limits - Raise desktop limits to 500 MB file size / 375 MP pixels
- Add progressive warnings: "This is a large file — conversion may take 10–30 seconds"
- Improve OOM error messages: detect
RangeErrorfrom WASMmemory.grow()and show "Image too large for your device's memory" instead of a generic error
Long-term (tiled processing):
- Research which image formats the
imagecrate supports for strip/tile decoding (TIFF has native strip support) - Prototype tiled JPEG decoding using
mozjpegor a custom JPEG scan parser - Implement tiled PNG encoding using
pngcrate's streaming encoder - Design the cross-strip state management in Rust
- Benchmark memory usage at 500 MP using tiled mode vs current in-memory mode
- The 100 MP / 200 MB limits are conservative — real-world testing shows desktop Chrome handles 300–400 MP reliably on 16 GB machines
- The mobile limit is the real constraint — iOS is aggressive about killing processes. A 50 MB file size / 30 MP pixel limit is safer for mobile
- The user experience for "too large" should be empathetic: explain why it failed and suggest alternatives (use a lower quality setting, resize first, use the desktop version)
Difficulty: 4.5/5
Run multiple Web Workers in parallel, each with their own WASM module instance, to convert multiple images simultaneously and fully utilize multi-core CPUs for batch processing.
- Dramatically faster batch conversion on multi-core machines (4–8 cores = 4–8x throughput)
- Modern CPUs have abundant cores — single-threaded batch is leaving performance on the table
- Each Worker is independent — no shared state, no concurrency primitives needed
- Each Worker loads a full copy of the WASM module (~1–3 MB) — 4 workers = 4–12 MB memory overhead
- Coordinating a work queue across workers requires a scheduler in the main thread
- If all workers decode large images simultaneously, memory usage multiplies by worker count
- Workers must be dynamically created and destroyed (or pooled) — lifecycle management is complex
- Error handling becomes harder when failures occur across multiple parallel workers
- Pool size: Auto-detect from
navigator.hardwareConcurrency— useMath.min(hardwareConcurrency, 4)as the pool size (cap at 4 to avoid excessive memory use) - Work queue: Main thread maintains a queue of pending conversion tasks. When a Worker completes and becomes free, the main thread assigns the next task via
postMessage. This is a classic thread pool pattern. - Worker reuse: Workers stay alive across tasks (don't terminate and recreate) since WASM initialization is expensive (~100–200 ms)
- WASM module sharing:
WebAssembly.Moduleobjects are transferable and cloneable. Compile once and pass the compiled module to each Worker viapostMessage([module], [module])instead of re-compiling from bytes. Reduces initialization time for subsequent Workers. - Memory cap: Enforce a per-worker concurrency limit based on available memory. If a 24 MP image uses ~300 MB, limit concurrent large-image conversions to 2 even if the pool has 4 workers.
| Asset | Delta (uncompressed) | Delta (gzipped) | Notes |
|---|---|---|---|
| WASM binary | 0 | 0 | Compiled module is shared — not duplicated in the bundle |
JavaScript — WorkerPool class |
+~3–5 KB | +~1.2–2 KB | Pool manager, task queue, Worker lifecycle |
JavaScript — modified worker.ts |
+~0.5–1 KB | +~0.2–0.4 KB | Accept pre-compiled module via init(module) |
| CSS | 0 | 0 | No new UI (pool is transparent to the user) |
| Grand total (bundle) | +~3.5–6 KB | +~1.4–2.4 KB | Negligible on-disk cost |
| Runtime memory impact | N/A | N/A | +~1.5–2 MB RAM per additional Worker instance at runtime |
The bundle size impact is trivial — the
WorkerPoolclass is pure orchestration logic. The real cost is runtime memory: each Worker loads a full WASM linear memory space (~1.5–2 MB baseline, growing with image size). A 4-Worker pool processing four 12 MP images concurrently could use 1–2 GB RAM.WebAssembly.Modulesharing (viapostMessagetransfer) eliminates re-compilation overhead but does not reduce per-Worker memory consumption since each Worker gets its own heap.
Architecture:
- Design a
WorkerPoolclass inmain.ts:- Creates N Worker instances on initialization
- Maintains a task queue and tracks which Workers are busy
- Dispatches tasks to free Workers; queues tasks when all Workers are busy
- Pass compiled
WebAssembly.Moduleto each Worker to avoid re-compilation overhead - Design
WorkerPool.convert(data, format, options)API that returns a Promise, resolving when the Worker completes - Handle Worker errors — remove crashed Workers from the pool, recreate
Worker (TS):
- Modify
worker.tsto accept a pre-compiled WASM module viainit(compiledModule)instead of always fetching and compiling from URL
Frontend:
- Wire batch processing (Feature 17) to use
WorkerPoolinstead of the single Worker - Show per-worker utilization if useful for debugging (or as a fun "x cores working" indicator)
- A worker pool is only meaningful with batch processing (Feature 17) — implement batch first
- The pool size auto-detection should account for mobile: cap at 2 Workers on mobile to avoid memory pressure
WebAssembly.Modulesharing via transfer is a key optimization — without it, each Worker takes 200ms+ to initialize, negating the parallel benefit for small batches- This is the closest we can get to true parallelism in WASM without
SharedArrayBuffer+ WASM threads (which requires COOP/COEP headers and is not universally supported)
Difficulty: 2.5/5 · Impact: Medium · Effort: Medium
When a user loads a new image while a transform conversion is in-flight, the stale WASM conversion currently runs to completion and its result is discarded via a generation counter. For large images on mobile (up to 200 MB / 100 MP), this wastes 15-25 seconds of CPU and contributes to memory pressure.
- Immediately frees CPU and WASM memory when loading a new image
- Eliminates wasted computation on mobile for large images
- Clean WASM state after termination (no lingering allocations from previous image)
- WASM re-initialization adds latency to loading the new image (blocks
detectFormat/getDimensions) - More complex lifecycle management in
ImageConverterclass - All pending Worker requests must be rejected on terminate
- Add a
terminateAndRecreate()method toImageConverterthat callsworker.terminate(), creates a new Worker, and replaces thereadypromise - Reject all entries in
pendingRequestswith a "Worker terminated" error on terminate - Call from
handleFileinuseConverter.tsinstead of (or in addition to) the generation counter increment - Consider gating behind a file size / megapixel threshold — only terminate for images above a certain size where the wasted work is meaningful
- Benchmark WASM re-init cost on mobile to determine the right threshold
No bundle size impact — this is a runtime behavior change only.
See decisions/20260318-generation-counter-over-worker-termination.md for why the generation counter was chosen as the initial fix.
Difficulty: 2/5 · Impact: High · Effort: Low
Phone photos often store pixels in a rotated orientation with an EXIF tag instructing viewers to rotate on display. The browser's <img> tag respects this tag, so images appear correct in the UI. However, image::load_from_memory() in Rust decodes raw pixels without applying EXIF orientation. When the image is re-encoded after conversion or transforms, the EXIF tag is lost and the output appears rotated.
This affects all conversions of phone photos with EXIF orientation, not just transforms — but it's most noticeable when transforms are applied because the user is actively looking at the image in the TransformModal.
- Fixes incorrect rotation on all phone photos
- Consistent behavior between the preview (
<img>tag) and the conversion output - Small, isolated change in the Rust decoding path
- Adds a dependency for EXIF parsing (e.g.,
kamadak-exif) unless theimagecrate's built-in support is sufficient - Slight decoding overhead to read and apply orientation
- Apply EXIF orientation immediately after
image::load_from_memory()inconvert(),decode_rgba(), anddecode_rgba_with_transforms()— before any transforms or encoding - Options for reading EXIF orientation:
- Use the
imagecrate'sImageReaderwith orientation support (check if available in current version) - Use
kamadak-exifcrate to read the orientation tag, then manually apply the corresponding rotation/flip
- Use the
- The orientation correction is a one-time operation per decode, so performance impact is minimal
- Must set
default-features = falseon any new EXIF crate to avoid pulling in features that break WASM
| Component | Delta (gzipped) |
|---|---|
| WASM | +~2–8 KB |
| JS/CSS | 0 |
Bundle size deltas are gzipped (over-the-wire). WASM and JS deltas are broken out separately since WASM is fetched once and cached, while JS is part of the critical render path.
| # | Feature | Difficulty | Impact | Effort | WASM Δ (gz) | JS/CSS Δ (gz) | Total Δ (gz) | Done |
|---|---|---|---|---|---|---|---|---|
| 1 | Dark Mode | 1/5 | Medium | Low | 0 | +~4–7 KB | +~4–7 KB | [ ] |
| 2 | Paste from Clipboard | 1.5/5 | High | Low | 0 | +~0.5–1 KB | +~0.5–1 KB | [x] |
| 3 | JPEG Quality Slider | 2/5 | High | Low | +~1–3 KB | +~1–1.5 KB | +~2–5 KB | [x] |
| 4 | Tier 2 Formats (TIFF, ICO, TGA, QOI) | 2/5 | Medium | Low | +~93–145 KB | +~0.5–1 KB | +~94–146 KB | [x] |
| 5 | Simple Transforms (Flip, Rotate, Grayscale, Invert) | 2/5 | High | Medium | +~16–29 KB | +~2–3.5 KB | +~18–32 KB | [x] |
| 6 | Format Landing Pages (SEO) | 2.5/5 | High | Medium | 0 | +~0.3–0.5 KB | +~0.3–0.5 KB | [x] |
| 7 | SVG Rasterization | 2.5/5 | Medium | Medium | +~540 KB–1 MB | +~0.5–1 KB | +~541 KB–1 MB | [ ] |
| 8 | Image Metadata + EXIF Display | 2.5/5 | Medium | Medium | +~28–45 KB | +~1.2–2 KB | +~29–47 KB | [x] |
| 9 | Compression Benchmark | 2.5/5 | High | Medium | 0 | +~1.2–2.3 KB | +~1.2–2.3 KB | [x] |
| 10 | Parameterized Processing (Resize, Crop, Blur, etc.) | 3/5 | Very High | High | +~32–53 KB | +~3–5 KB | +~35–58 KB | [ ] |
| 11 | Side-by-Side Comparison | 3/5 | High | Medium | 0 | +~2–3.5 KB | +~2–3.5 KB | [ ] |
| 12 | Color Palette Extraction | 3/5 | Medium | Medium | +~0–28 KB | +~1–2 KB | +~1–30 KB | [ ] |
| 13 | PWA / Offline Support | 3/5 | Medium | High | 0 | +~2.5–4 KB† | +~2.5–4 KB† | [ ] |
| 14 | Image Watermarking | 3.5/5 | Medium | High | +~50–142 KB | +~1.2–2.3 KB | +~51–144 KB | [ ] |
| 15 | Real Progress Reporting | 3.5/5 | Low | High | +~1–3 KB | +~0.4–0.8 KB | +~1.4–3.8 KB | [ ] |
| 16 | React Migration (Preact) | 4/5 | Medium | Very High | 0 | +~3–5 KB | +~3–5 KB | [x] |
| 16 | React Migration (React) | 4/5 | Medium | Very High | 0 | +~100–130 KB | +~100–130 KB | [ ] |
| 17 | Batch Processing | 4/5 | Very High | Very High | 0 | +~11–15.5 KB | +~11–15.5 KB | [ ] |
| 18 | Raise File Size Limits | 4.5/5 | Low | Very High | 0‡ | +~0.1–0.2 KB | +~0.1–0.2 KB | [ ] |
| 19 | Worker Pool (Parallel Batch) | 4.5/5 | High | Very High | 0 | +~1.4–2.4 KB | +~1.4–2.4 KB | [ ] |
| 20 | Worker Termination (Stale Conversion Cancel) | 2.5/5 | Medium | Medium | 0 | 0 | 0 | [ ] |
| 21 | EXIF Orientation Handling | 2/5 | High | Low | +~2–8 KB | 0 | +~2–8 KB | [ ] |
† PWA service worker and icons load separately and are not in the critical JS bundle. ‡ Near-zero for threshold changes; tiled processing would add +28–70 KB WASM gzipped.
Features that deserve careful size scrutiny before shipping:
| Feature | Risk | Mitigation |
|---|---|---|
| SVG Rasterization (#7) | Doubles WASM binary | Lazy-load a separate svg-converter.wasm only when user uploads SVG |
| Tier 2 Formats — TIFF (#4) | +60–90 KB WASM | Add TIFF last; evaluate actual size delta after wasm-opt |
| Parameterized Processing (#10) | +32–53 KB WASM; serde_json hidden cost |
Pass ops as JsValue array instead of JSON string to skip serde_json |
| Image Watermarking (#14) | Wide range due to font + imageproc |
Inline draw_text_mut, serve font as separate fetch |
| React Migration (#16) | +100 KB JS if React chosen | Use Preact — same API, 1/30th the size |
| Batch Processing (#17) | fflate adds ~10 KB |
Dynamic import('fflate') on first ZIP click — deferred cost |
If time is limited, these four features deliver the most user value per hour of effort:
- Paste from Clipboard (1.5/5) — Instant workflow improvement, ~4 hours
- JPEG Quality Slider (2/5) — Top user request for any image tool, ~1 day
- Compression Benchmark (2.5/5) — Unique feature, drives engagement, ~2 days
- Simple Image Transforms (2/5) — Flip/rotate alone covers 80% of use cases, ~1.5 days