NC Imago · Docs

Imago in the browser

apps/imago-web runs the same egui UI as the desktop app (crates/ui-egui) in the browser, compiled to WebAssembly and drawn with WebGL2 (eframe's glow backend).

Build and run locally

One-time setup:

rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli --version <the wasm-bindgen version in Cargo.lock> --locked

cargo xtask web checks the CLI against Cargo.lock and prints the exact install command if it's missing or a different version.

Build, then serve:

cargo xtask web            # → <target>/web-site/{index.html, worker.js, imago_web.js, imago_web_bg.wasm}
cargo xtask web --serve    # build, then serve on http://127.0.0.1:8080/ (or `--serve 9000`)
cargo xtask web --dev      # unoptimized build with debug info (faster to compile, slow to run)

<target> is target/ unless CARGO_TARGET_DIR is set. Any static HTTP server works, as long as it serves .wasm as application/wasm. The bundle can't be opened from file:// (module workers and OPFS need an HTTP(S) origin; localhost/127.0.0.1 count as secure). If wasm-opt (binaryen) is on PATH, the release build also runs it over the module.

The release build uses the web Cargo profile (see Bundle size below) and writes gzip (-9) and brotli (-11) precompressed copies next to each file (*.gz, *.br, pure Rust, printed as a size table). --serve sends them with Content-Encoding when the browser accepts them; configure a production server the same way (e.g. nginx gzip_static/brotli_static).

Bundle size

Measured on the full bundle (every codec and raw decoder is in the module), without wasm-opt:

web profile .wasm bytes gzip -9 brotli -11 slider job*
before: release, thin LTO 15 742 091 5 251 664 3 473 726 3.8 ms
fat LTO, 1 CGU, strip, panic=abort, opt 3 12 924 849 4 779 686 3 210 246 3.9 ms
same, opt-level "s" 13 056 715 4 362 536 2 959 126 6.1 ms
same, opt-level "z" 12 648 958 4 152 661 2 860 173 9.0 ms
current: "s", per-pixel crates at opt 3 13 533 126 4 583 465 3 093 780 4.0 ms

* median Exposure draft render of the ?bench loupe in a worker (stage-cached), headless Chrome. Size-optimizing the pipeline crates costs 50–130 % render time, so Cargo.toml keeps them (pipeline, raster, color, develop, geom, raw, codecs, scenes, preview and the JPEG/PNG codecs) at opt-level 3 and the rest (egui, eframe, serde, glue) at "s". What goes over the wire is the brotli column: 3.1 MB, 11 % less than the old build's brotli size and 41 % less than its gzip size (5.25 MB). wasm-opt -Oz, when installed, shrinks it further.

Fonts. The browser has no system fonts to fall back on, so CJK text comes from craft-fonts, the optional CRAFT_FONTS_DIR build input (CRAFT_FONTS_DIR=../craft-fonts cargo xtask web; release builds always set it). On wasm32 crates/engine/build.rs embeds BIZ UDPGothic Regular (UI and watermark fallback) and Noto Sans Thai Regular (~214 KiB, for Thai file and folder names in every UI language). The web bundle also ships Noto Sans CJK SC Regular as imago_zh_hans.otf and loads it before the UI starts. Keeping the 16 MB Chinese face separate leaves the module under Cloudflare's 25 MiB per-file limit (the older Japanese-only module measured 21.8 MB). Without CRAFT_FONTS_DIR, the web build still works, but Chinese and Japanese text lack glyphs.

Deploying: headers

cargo xtask web --serve sends these on every response. A production server may send them too; today they are optional:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: same-origin

COOP + COEP make the page cross-origin isolated (crossOriginIsolated === true). That will be required for SharedArrayBuffer, i.e. for a future wasm-threads build (+atomics, nightly build-std), and gives full-resolution performance.now(). The current render workers (below) don't share memory, so the app runs without them; with COEP on, every subresource must be same-origin or send CORP/CORS headers (the bundle has no third-party resources). The file names are the same in every version, so serve them with Cache-Control: no-cache (not immutable); packaging/web/README.md (shipped as HOSTING.md) has the hosting details.

What works

Not yet

Measuring

Open http://127.0.0.1:8080/?bench to run a scripted measurement:

  1. Wait for the grid thumbnails.
  2. Open the first photo in Detail.
  3. Drag Exposure through 8 steps, exactly as a slider drag does (begin interaction → develop.set ×8 → end).

The page then logs one console line:

imago-bench {"first_frame_ms":…,"thumbs_done_ms":…,"slider_draft_ms":[…],"slider_draft_median_ms":…,"slider_job_ms":[…],"release_full_ms":…}