diff --git a/Cargo.lock b/Cargo.lock
index 0ffd4eea..a5a2fde5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -934,6 +934,7 @@ dependencies = [
"egui-wgpu",
"egui-winit",
"egui_glow",
+ "glow",
"glutin",
"glutin-winit",
"image",
@@ -2182,6 +2183,25 @@ dependencies = [
"log",
"serde",
"serde_json",
+ "web-time",
+]
+
+[[package]]
+name = "lightcraft-web"
+version = "0.1.0"
+dependencies = [
+ "eframe",
+ "egui",
+ "js-sys",
+ "lightcraft-codecs",
+ "lightcraft-engine",
+ "lightcraft-raster",
+ "lightcraft-ui-egui",
+ "log",
+ "serde_json",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 0cf6a740..7f66c02c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -58,6 +58,13 @@ egui_extras = { version = "0.36", default-features = false }
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
rfd = "0.17"
+web-time = "1"
+
+# web (apps/lightcraft-web)
+wasm-bindgen = "0.2.129"
+wasm-bindgen-futures = "0.4"
+js-sys = "0.3"
+web-sys = "0.3"
[workspace.lints.rust]
unsafe_code = "deny"
@@ -84,3 +91,9 @@ inherits = "release"
lto = "thin"
codegen-units = 1
debug = false
+
+# `cargo xtask web`: the browser build (no debug info; size matters more than build time)
+[profile.web]
+inherits = "release"
+debug = false
+lto = "thin"
diff --git a/README.md b/README.md
index 392d1b3e..9fdb5c17 100644
--- a/README.md
+++ b/README.md
@@ -226,7 +226,8 @@ LightCraft is young and moving fast — see the [roadmap](ROADMAP.md) for estima
| Remove / Heal / Clone spots (auto source) | ✅ · content-aware fill (PatchMatch), Red Eye 🚧 |
| Export: JPEG / PNG / TIFF / WebP / AVIF, sizing, file-size limit, output sharpening, naming, batch | ✅ · metadata policy, watermark, DNG ⬜ |
| MCP server, CLI, control channel | ✅ |
-| Optics, Geometry/Upright, GPU pipeline, web build, persistent library | 🚧 |
+| Optics, Geometry/Upright, GPU pipeline, persistent library | 🚧 |
+| Web build (same UI in the browser via WASM; in-memory imports, export downloads) | ✅ · persistence, workers 🚧 |
@@ -237,10 +238,14 @@ git clone https://github.com/storytold/lightcraft && cd lightcraft
cargo run --release -p lightcraft # opens with a procedurally generated demo library
cargo run --release -p lightcraft -- ~/Pictures/trip # or point it at your photos (folders are scanned)
cargo run --release -p lightcraft -- --control 7980 # with the automation channel
+cargo xtask web --serve # the same app in the browser: http://127.0.0.1:8080/
cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light.exposure=0.5
cargo xtask ci # fmt, clippy, tests, layering, wasm checks
```
+The web build needs the `wasm32-unknown-unknown` target and the matching `wasm-bindgen` CLI
+(`cargo xtask web` prints the exact install command); see [docs/web.md](docs/web.md).
+
Keyboard: G grid · D detail · E edit · C crop · M masking ·
Shift+P presets · \\ original · Y before/after · Z zoom ·
J clipping · ⌘Z undo · ⌘/ all shortcuts.
diff --git a/apps/lightcraft-web/Cargo.toml b/apps/lightcraft-web/Cargo.toml
new file mode 100644
index 00000000..b9c7bdf2
--- /dev/null
+++ b/apps/lightcraft-web/Cargo.toml
@@ -0,0 +1,44 @@
+[package]
+name = "lightcraft-web"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+publish = false
+description = "LightCraft in the browser: the egui UI compiled to WASM (build with `cargo xtask web`)"
+
+[lints]
+workspace = true
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+
+[dependencies]
+lightcraft-engine = { workspace = true }
+lightcraft-ui-egui = { workspace = true }
+lightcraft-codecs = { workspace = true }
+lightcraft-raster = { workspace = true }
+serde_json = { workspace = true }
+log = { workspace = true }
+
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+eframe = { workspace = true, features = ["default_fonts", "glow"] }
+egui = { workspace = true }
+wasm-bindgen = { workspace = true }
+wasm-bindgen-futures = { workspace = true }
+js-sys = { workspace = true }
+web-sys = { workspace = true, features = [
+ "Blob",
+ "BlobPropertyBag",
+ "Document",
+ "Element",
+ "File",
+ "FileList",
+ "HtmlAnchorElement",
+ "HtmlCanvasElement",
+ "HtmlElement",
+ "HtmlInputElement",
+ "Location",
+ "Performance",
+ "Url",
+ "Window",
+] }
diff --git a/apps/lightcraft-web/index.html b/apps/lightcraft-web/index.html
new file mode 100644
index 00000000..ac1ed167
--- /dev/null
+++ b/apps/lightcraft-web/index.html
@@ -0,0 +1,33 @@
+
+
+
+
+
+ LightCraft
+
+
+
+
+
+
+ Loading LightCraft…
+
+
+
+
diff --git a/apps/lightcraft-web/src/bench.rs b/apps/lightcraft-web/src/bench.rs
new file mode 100644
index 00000000..7ede4a7a
--- /dev/null
+++ b/apps/lightcraft-web/src/bench.rs
@@ -0,0 +1,143 @@
+//! Scripted in-app measurement (`?bench`): open the first photo in Detail, then drive the Exposure
+//! control exactly like a slider drag (begin interaction → N `develop.set` → end) and time each
+//! step from the command to the moment the new loupe texture is ready. Results go to the log
+//! (the browser console on the web) as one `lightcraft-bench {…json…}` line.
+
+use lightcraft_ui_egui::LightcraftApp;
+use lightcraft_ui_egui::render::Slot;
+use serde_json::{Value, json};
+
+const STEPS: usize = 8;
+
+#[derive(Debug)]
+enum Phase {
+ /// Wait for the grid's thumbnails to finish.
+ Idle,
+ /// Detail view requested; wait for the loupe.
+ Opening {
+ t0: f64,
+ },
+ /// A step was issued at `t0`; wait for a Main texture with a new key.
+ Step {
+ i: usize,
+ t0: f64,
+ key: u64,
+ },
+ Done,
+}
+
+pub struct Bench {
+ phase: Phase,
+ frames: u32,
+ start_ms: f64,
+ first_frame_ms: Option,
+ thumbs_done_ms: Option,
+ open_ms: f64,
+ drafts: Vec,
+ jobs: Vec,
+ final_ms: f64,
+}
+
+impl Bench {
+ /// `start_ms`: the page's time origin in the same clock as [`lightcraft_ui_egui::now_ms`].
+ pub fn new(start_ms: f64) -> Self {
+ Bench {
+ phase: Phase::Idle,
+ frames: 0,
+ start_ms,
+ first_frame_ms: None,
+ thumbs_done_ms: None,
+ open_ms: 0.0,
+ drafts: vec![],
+ jobs: vec![],
+ final_ms: 0.0,
+ }
+ }
+
+ fn main_key(app: &LightcraftApp) -> Option {
+ app.renderer.textures.get(&Slot::Main).map(|t| t.key)
+ }
+
+ /// Call once per frame after `LightcraftApp::logic`. Returns the report when finished.
+ pub fn step(&mut self, app: &mut LightcraftApp) -> Option {
+ let now = lightcraft_ui_egui::now_ms();
+ self.frames += 1;
+ if self.first_frame_ms.is_none() && self.frames > 1 {
+ self.first_frame_ms = Some(now - self.start_ms);
+ }
+ let idle = app.renderer.queued() == 0;
+ match self.phase {
+ Phase::Idle => {
+ let thumbs = app.renderer.textures.keys().filter(|s| matches!(s, Slot::Thumb(_))).count();
+ if idle && thumbs > 0 && self.frames > 3 {
+ self.thumbs_done_ms = Some(now - self.start_ms);
+ let first = app.session.visible().first().copied();
+ if let Some(id) = first {
+ let _ = app.run("library.select", json!({"ids": [id.0]}));
+ }
+ let _ = app.run("view.detail", json!({}));
+ self.phase = Phase::Opening { t0: now };
+ }
+ }
+ Phase::Opening { t0 } => {
+ if idle && Self::main_key(app).is_some() && self.frames > 3 {
+ self.open_ms = now - t0;
+ let _ = app.run("develop.beginInteraction", json!({"label": "Exposure"}));
+ self.issue(app, 0, now);
+ }
+ }
+ Phase::Step { i, t0, key } => {
+ if idle && Self::main_key(app).is_some_and(|k| k != key) {
+ let dt = now - t0;
+ if i < STEPS {
+ self.drafts.push(dt);
+ self.jobs.push(app.renderer.last_main_ms);
+ if i + 1 < STEPS {
+ self.issue(app, i + 1, now);
+ } else {
+ // release: the full-resolution render after the drag
+ let key = Self::main_key(app).unwrap_or(0);
+ let _ = app.run("develop.endInteraction", json!({}));
+ let _ = app.run("develop.set", json!({"control": "light.exposure", "value": 0.0}));
+ self.phase = Phase::Step { i: STEPS, t0: now, key };
+ }
+ } else {
+ self.final_ms = dt;
+ self.phase = Phase::Done;
+ return Some(self.report(app));
+ }
+ }
+ }
+ Phase::Done => {}
+ }
+ None
+ }
+
+ fn issue(&mut self, app: &mut LightcraftApp, i: usize, now: f64) {
+ let key = Self::main_key(app).unwrap_or(0);
+ let v = 0.15 * (i as f64 + 1.0);
+ let _ = app.run("develop.set", json!({"control": "light.exposure", "value": v}));
+ self.phase = Phase::Step { i, t0: now, key };
+ }
+
+ fn report(&self, app: &LightcraftApp) -> Value {
+ let mut d = self.drafts.clone();
+ d.sort_by(f64::total_cmp);
+ let median = d.get(d.len() / 2).copied().unwrap_or(0.0);
+ let mean = if d.is_empty() { 0.0 } else { d.iter().sum::() / d.len() as f64 };
+ let tex = app.renderer.textures.get(&Slot::Main).map(|t| t.size).unwrap_or([0, 0]);
+ let r = |x: f64| (x * 10.0).round() / 10.0;
+ json!({
+ "first_frame_ms": self.first_frame_ms.map(r),
+ "thumbs_done_ms": self.thumbs_done_ms.map(r),
+ "open_detail_ms": r(self.open_ms),
+ "slider_draft_ms": d.iter().copied().map(r).collect::>(),
+ "slider_draft_median_ms": r(median),
+ "slider_draft_mean_ms": r(mean),
+ "slider_job_ms": self.jobs.iter().copied().map(r).collect::>(),
+ "release_full_ms": r(self.final_ms),
+ "loupe_px": tex,
+ "frames": self.frames,
+ })
+ }
+}
diff --git a/apps/lightcraft-web/src/lib.rs b/apps/lightcraft-web/src/lib.rs
new file mode 100644
index 00000000..0ab06bc9
--- /dev/null
+++ b/apps/lightcraft-web/src/lib.rs
@@ -0,0 +1,20 @@
+//! LightCraft in the browser: the same egui UI as the desktop app, compiled to WASM.
+//!
+//! Build with `cargo xtask web` (→ `target/web/`), then serve that folder over HTTP.
+//! Differences from the desktop host:
+//! - no filesystem: photos picked (File ▸ Add Photos…) or dropped onto the page are held in
+//! memory ([`store::MemStore`]) and decoded from bytes;
+//! - export downloads the file through the browser;
+//! - renders run inline on the main thread (one job per frame) — no worker threads yet;
+//! - no persistence yet (UI prefs and the catalog reset on reload);
+//! - the procedural demo library is loaded at start so the page is never empty.
+//!
+//! `?bench` in the URL runs a scripted first-paint / slider-latency measurement and logs it to the
+//! console (see [`bench`]).
+#![forbid(unsafe_code)]
+
+pub mod bench;
+pub mod store;
+
+#[cfg(target_arch = "wasm32")]
+mod web;
diff --git a/apps/lightcraft-web/src/store.rs b/apps/lightcraft-web/src/store.rs
new file mode 100644
index 00000000..acac328e
--- /dev/null
+++ b/apps/lightcraft-web/src/store.rs
@@ -0,0 +1,144 @@
+//! In-memory "filesystem" for the web build: files picked or dropped in the browser are kept as
+//! bytes under synthetic paths (`mem//`), and the engine reads them through the same
+//! [`FileLoader`]/[`FileProbe`] hooks the desktop app backs with `std::fs`.
+
+use std::collections::HashMap;
+use std::sync::{Arc, Mutex};
+
+use lightcraft_engine::files::{load_bytes, probe_bytes};
+use lightcraft_engine::media::{FileLoader, FileProbe};
+
+#[derive(Default)]
+struct Inner {
+ files: HashMap>,
+ /// Paths added since the last [`MemStore::take_pending`] (to import on the next frame).
+ pending: Vec,
+ next: u64,
+}
+
+/// Shared, cloneable byte store. File reads in the browser are async, so additions arrive from
+/// promise callbacks and are picked up by the app on its next frame.
+#[derive(Clone, Default)]
+pub struct MemStore(Arc>);
+
+impl MemStore {
+ fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
+ self.0.lock().unwrap_or_else(|e| e.into_inner())
+ }
+
+ /// Store a file's bytes; returns its synthetic path and queues it for import.
+ pub fn add(&self, name: &str, bytes: Vec) -> String {
+ let mut g = self.lock();
+ g.next += 1;
+ let name = name.rsplit(['/', '\\']).next().filter(|n| !n.is_empty()).unwrap_or("photo");
+ let path = format!("mem/{}/{name}", g.next);
+ g.files.insert(path.clone(), bytes.into());
+ g.pending.push(path.clone());
+ path
+ }
+
+ pub fn get(&self, path: &str) -> Option> {
+ self.lock().files.get(path).cloned()
+ }
+
+ /// Paths added since the last call.
+ pub fn take_pending(&self) -> Vec {
+ std::mem::take(&mut self.lock().pending)
+ }
+
+ pub fn len(&self) -> usize {
+ self.lock().files.len()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.len() == 0
+ }
+
+ /// Engine hooks that decode/probe from this store.
+ pub fn hooks(&self) -> (FileLoader, FileProbe) {
+ let s = self.clone();
+ let loader: FileLoader = Arc::new(move |path: &str, max_edge: usize| {
+ let bytes = s.get(path).ok_or_else(|| format!("{path}: not in memory"))?;
+ load_bytes(&bytes, max_edge)
+ });
+ let s = self.clone();
+ let probe: FileProbe = Arc::new(move |path: &str| {
+ let bytes = s.get(path).ok_or_else(|| format!("{path}: not in memory"))?;
+ probe_bytes(path, &bytes)
+ });
+ (loader, probe)
+ }
+
+ /// Install the hooks into a session.
+ pub fn install(&self, session: &mut lightcraft_engine::Session) {
+ let (l, p) = self.hooks();
+ session.media.file_loader = Some(l);
+ session.media.file_probe = Some(p);
+ }
+}
+
+/// File name part of an export path (the browser download name).
+pub fn download_name(path: &str) -> &str {
+ path.rsplit(['/', '\\']).next().filter(|n| !n.is_empty()).unwrap_or("export.png")
+}
+
+/// MIME type for a download, by extension.
+pub fn mime_for(name: &str) -> &'static str {
+ match name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).as_deref() {
+ Some("jpg" | "jpeg") => "image/jpeg",
+ Some("png") => "image/png",
+ Some("tif" | "tiff") => "image/tiff",
+ Some("webp") => "image/webp",
+ Some("avif") => "image/avif",
+ _ => "application/octet-stream",
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::json;
+
+ fn png_bytes(w: usize, h: usize) -> Vec {
+ let bytes: Vec = (0..w * h).flat_map(|i| [(i * 7) as u8, 128, 200, 255]).collect();
+ let img = lightcraft_raster::Rgba8::from_bytes(w, h, &bytes).unwrap();
+ lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap()
+ }
+
+ #[test]
+ fn add_take_get() {
+ let s = MemStore::default();
+ let a = s.add("C:\\x\\a.png", vec![1, 2]);
+ let b = s.add("a.png", vec![3]);
+ assert_ne!(a, b, "same name twice gets distinct paths");
+ assert!(a.ends_with("/a.png"));
+ assert_eq!(s.take_pending(), vec![a.clone(), b]);
+ assert!(s.take_pending().is_empty());
+ assert_eq!(&*s.get(&a).unwrap(), &[1, 2]);
+ assert_eq!(s.len(), 2);
+ }
+
+ #[test]
+ fn import_and_render_from_memory() {
+ let store = MemStore::default();
+ let mut session = lightcraft_engine::Session::new();
+ store.install(&mut session);
+ let path = store.add("tiny.png", png_bytes(40, 30));
+ let r = session.execute("library.import", &json!({"paths": [path]})).unwrap();
+ let id = lightcraft_engine::catalog::PhotoId(r["imported"][0].as_u64().unwrap());
+ let p = session.catalog.photo(id).unwrap();
+ assert_eq!((p.width, p.height), (40, 30));
+ assert_eq!(p.file_name, "tiny.png");
+ let out = session.render_now(id, 20, 20).unwrap();
+ assert_eq!(out.image.width, 20);
+ }
+
+ #[test]
+ fn download_names() {
+ assert_eq!(download_name("dir/x-lightcraft.png"), "x-lightcraft.png");
+ assert_eq!(download_name("y.png"), "y.png");
+ assert_eq!(mime_for("a.JPG"), "image/jpeg");
+ assert_eq!(mime_for("a"), "application/octet-stream");
+ assert_eq!(download_name(""), "export.png");
+ }
+}
diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs
new file mode 100644
index 00000000..21817cda
--- /dev/null
+++ b/apps/lightcraft-web/src/web.rs
@@ -0,0 +1,202 @@
+//! Browser host: starts eframe on `