web profile: fat LTO, 1 codegen unit, strip, panic=abort, opt-level "s", with the per-pixel crates (pipeline, raster, color, develop, geom, raw, codecs, scenes, preview, JPEG/PNG codecs) kept at opt-level 3: "s"/"z" there cost +50 %/+130 % render time. wasm 15.74 MB → 13.53 MB; brotli 3.47 MB → 3.09 MB (gzip 5.25 → 4.58 MB); worker slider job 3.8 → 4.0 ms (noise). cargo xtask web writes .gz (-9) and .br (-11) copies next to each bundle file (pure Rust: flate2, brotli) and prints a size table; --serve negotiates Accept-Encoding and serves them with Content-Encoding. Measurements in docs/web.md → Bundle size. Co-Authored-By: Claude Opus 5.5 <[email protected]>
8.4 KiB
LightCraft in the browser
apps/lightcraft-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/{index.html, worker.js, lightcraft_web.js, lightcraft_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.
Deploying: headers
cargo xtask web --serve sends these on every response, and a production server should too:
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 is
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 also runs without them; with COEP on, every subresource must be
same-origin or send CORP/CORS headers (the bundle has no third-party resources).
What works
-
A persistent library. The library lives in the browser's storage for the page's origin: OPFS when the main thread can write it (
FileSystemFileHandle.createWritable: Chrome, Edge, Firefox, recent Safari), otherwise IndexedDB. Both hold the same layout:library/catalog.snap,library/catalog.log: the same crash-safe journal as the desktop app (lightcraft-catalog), pluspresets.json,view.json,prefs.jsonandui.json(panel layout). The catalogStoreis a memory mirror loaded at start-up; every change is flushed in the background within a frame or two, each file replaced atomically, in modification order (apps/lightcraft-web/src/files.rs). View state and UI prefs are saved every second when they change (a tab can close without notice).originals/<content hash>: the bytes of every imported photo. The catalog refers to them asweb/<hash>/<file name>; the main thread keeps recently used originals in memory (≤ 768 MB) and loads the rest on demand (the active photo is prefetched).thumbs/<key>.jpg+thumbs/index.json: the rendered-thumbnail cache, with the desktop's budget (2 GB, least recently used pruned to 80 %).
A new library starts with the procedural demo photos. The app asks for persistent storage (
navigator.storage.persist()); without it the browser may evict the data under storage pressure. URL options:?store=idbforces IndexedDB,?store=memorykeeps nothing,?resetdeletes the stored library first. -
Importing photos with no filesystem. File ▸ Add Photos… (⌘⇧I) opens the browser's file picker. You can also drop files anywhere on the page. The bytes are written to storage, then imported and decoded by the same engine code as the desktop app (
lightcraft_engine::files::{probe_bytes, load_bytes}): JPEG/PNG/TIFF/WebP and the supported raw formats. "Copy into library" is the same as "Add" here. -
Rendering in Web Workers. Renders don't run on the main thread: up to four dedicated workers (
hardwareConcurrency − 1,?workers=Nto override,?workers=0for the old inline path) each run a second instance of the same wasm module.index.htmlcompiles the module once and posts the compiledWebAssembly.Moduletoworker.js. A job crosses as JSON (the photo's source, develop settings, request); the worker reads the original or the cached thumbnail from storage itself, keeps decoded sources and the loupe's stage cache, renders, and transfers the RGBA bytes back. Jobs for a photo go back to the worker that decoded it. This needs no wasm threads orSharedArrayBuffer. If no worker starts, rendering falls back to the main thread. -
Every develop control (sliders, curves, mixer, grading, masking, crop…) works as it does on the desktop, since it's the same crate.
-
Export downloads the file. Export… (⌘⇧E) runs the same
app.exportpath as the desktop app (lightcraft_engine::export: JPEG/PNG/TIFF/WebP, sizing, naming). The host'swriteservice hands each file to the browser as a download instead of writing it to disk. -
Automation.
await lightcraft.command("library.info", "{}")runs any engine or UI command by id on the next frame and resolves to the JSON result (web.statsreports storage, workers and the render queue). This is how the headless-Chrome checks drive the page.
Not yet
- Exports and auto-adjustments run on the main thread (they need the original's pixels there). Right after a reload, the first such command on a photo whose original isn't in memory yet fails with "still loading" and works a moment later.
- Durability is "a frame later", not fsync-before-return: a crash or tab kill in the few milliseconds between a command and its flush loses that command.
- Preset files (import/export
.json) aren't wired to browser pickers yet. - Control channel / MCP. These are desktop-only, because they need a TCP socket.
- AVIF export is untested in the browser; it's the one encoder that may not be wasm-safe.
Measuring
Open http://127.0.0.1:8080/?bench to run a scripted measurement:
- Wait for the grid thumbnails.
- Open the first photo in Detail.
- Drag Exposure through 8 steps, exactly as a slider drag does (begin interaction →
develop.set×8 → end).
The page then logs one console line:
lightcraft-bench {"first_frame_ms":…,"thumbs_done_ms":…,"slider_draft_ms":[…],"slider_draft_median_ms":…,"slider_job_ms":[…],"release_full_ms":…}
slider_draft_ms: time from thedevelop.setcommand until the new loupe texture is ready.slider_job_ms: the pipeline's share of that time.release_full_ms: the full-quality render after the drag ends.