# 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: ```sh rustup target add wasm32-unknown-unknown cargo install wasm-bindgen-cli --version --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: ```sh cargo xtask web # → /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) ``` `` 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 Japanese text comes from [craft-fonts](https://github.com/storytold/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 only BIZ UDPGothic Regular (UI, and the watermark fallback), so the module stays well under Cloudflare's 25 MiB per-file limit: measured 2026-10-06, 17.1 MB without craft-fonts and 21.8 MB with it (brotli 3.8 MB / 6.3 MB). Without it the web build works, but Japanese text has no 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 - **A persistent library.** The library lives in the browser's storage for the page's origin: [OPFS](https://developer.mozilla.org/docs/Web/API/File_System_API/Origin_private_file_system) 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`), plus `presets.json`, `view.json`, `prefs.json` and `ui.json` (panel layout). The catalog `Store` is 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). - Known limitation: the browser storage has no file locks, so two tabs of the same origin open the same library and the last one to write a snapshot wins (the desktop app and the CLI lock a library folder: `catalog.lock`, issue #99). Use one tab at a time; a guard through the Web Locks API (`navigator.locks`) is still to do. - `originals/`: the bytes of every imported photo. The catalog refers to them as `web//`; the main thread keeps recently used originals in memory (≤ 768 MB) and loads the rest on demand (the active photo is prefetched). - `thumbs/.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. URL options: `?store=idb` forces IndexedDB, `?store=memory` keeps nothing, `?reset` deletes the stored library first (after a confirmation: it deletes every imported photo too). - **Keeping the library safe** (experimental: the library lives only in this browser): - The app asks for persistent storage (`navigator.storage.persist()`); when the browser doesn't grant it, a notice says the library may be evicted under storage pressure. - **File ▸ Back Up Library…** downloads a zip of the library files and every stored original (`originals//`, so an unzipped backup is browsable; entries are stored, not compressed; at most 4 GB). **File ▸ Restore Library from Backup…** reads such a zip into a new library folder (`library-restored-