From 2d2bc2ad8f0ce17a6f300107afd8aa0cd1e9355b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 00:01:18 -0400 Subject: [PATCH] M0.11: web build (WASM) apps/lightcraft-web runs the egui UI in the browser (eframe web, glow/WebGL2): procedural demo library on load; in-memory imports via file picker (Cmd+Shift+I) and drag-and-drop (MemStore + bytes-based engine hooks); export goes through app.export and downloads in the browser; `?bench` logs first-frame and slider latency. `cargo xtask web [--serve [port]] [--dev]` builds with the matching wasm-bindgen CLI into /web/; the web app is part of the CI wasm check. Wasm fixes: lazy profiling Instant in pipeline, web-time clocks in ui-egui (render timings were 0 on wasm). Docs: docs/web.md. No persistence on web yet. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 20 +++ Cargo.toml | 13 ++ README.md | 7 +- apps/lightcraft-web/Cargo.toml | 44 +++++++ apps/lightcraft-web/index.html | 33 +++++ apps/lightcraft-web/src/bench.rs | 143 ++++++++++++++++++++++ apps/lightcraft-web/src/lib.rs | 20 +++ apps/lightcraft-web/src/store.rs | 144 ++++++++++++++++++++++ apps/lightcraft-web/src/web.rs | 202 +++++++++++++++++++++++++++++++ crates/pipeline/src/lib.rs | 8 +- crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/lib.rs | 12 +- crates/ui-egui/src/render.rs | 13 +- docs/web.md | 77 ++++++++++++ xtask/src/main.rs | 12 +- xtask/src/web.rs | 163 +++++++++++++++++++++++++ 16 files changed, 885 insertions(+), 27 deletions(-) create mode 100644 apps/lightcraft-web/Cargo.toml create mode 100644 apps/lightcraft-web/index.html create mode 100644 apps/lightcraft-web/src/bench.rs create mode 100644 apps/lightcraft-web/src/lib.rs create mode 100644 apps/lightcraft-web/src/store.rs create mode 100644 apps/lightcraft-web/src/web.rs create mode 100644 docs/web.md create mode 100644 xtask/src/web.rs 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 ``, wires the in-memory store to +//! the file picker and drag-and-drop, and turns exports into downloads. + +use lightcraft_engine::Session; +use lightcraft_ui_egui::{LightcraftApp, Services}; +use serde_json::json; +use wasm_bindgen::JsCast; +use wasm_bindgen::prelude::*; + +use crate::bench::Bench; +use crate::store::{MemStore, download_name}; + +const ACCEPT: &str = ".jpg,.jpeg,.png,.tif,.tiff,.webp,.dng,.cr2,.nef,.arw,.psd,.jxl,.gif,.bmp"; + +fn window() -> web_sys::Window { + web_sys::window().expect("no window") +} + +/// Milliseconds since navigation start. +fn perf_now() -> f64 { + window().performance().map(|p| p.now()).unwrap_or(0.0) +} + +fn set_status(text: &str) { + if let Some(el) = window().document().and_then(|d| d.get_element_by_id("lightcraft_status")) { + el.set_text_content(Some(text)); + } +} + +/// Read a browser `File` into the store. +async fn read_file(store: MemStore, file: web_sys::File, ctx: egui::Context) { + match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await { + Ok(buf) => { + store.add(&file.name(), js_sys::Uint8Array::new(&buf).to_vec()); + ctx.request_repaint(); + } + Err(e) => log::warn!("reading {}: {e:?}", file.name()), + } +} + +/// Show the browser's open dialog; picked files land in the store asynchronously. +fn open_picker(store: MemStore, ctx: egui::Context) { + let Some(doc) = window().document() else { return }; + let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::()) else { return }; + input.set_type("file"); + input.set_multiple(true); + input.set_accept(ACCEPT); + let inp = input.clone(); + let on_change = Closure::::new(move || { + if let Some(files) = inp.files() { + for i in 0..files.length() { + if let Some(f) = files.get(i) { + wasm_bindgen_futures::spawn_local(read_file(store.clone(), f, ctx.clone())); + } + } + } + }); + input.set_onchange(Some(on_change.as_ref().unchecked_ref())); + on_change.forget(); + input.click(); +} + +/// Offer `bytes` as a download named after `path`. +fn download(path: &str, bytes: &[u8]) -> Result<(), String> { + let e = |e: JsValue| format!("download failed: {e:?}"); + let arr = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes)); + let opts = web_sys::BlobPropertyBag::new(); + let name = download_name(path); + opts.set_type(crate::store::mime_for(name)); + let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&arr, &opts).map_err(e)?; + let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(e)?; + let doc = window().document().ok_or("no document")?; + let a: web_sys::HtmlAnchorElement = doc.create_element("a").map_err(e)?.unchecked_into(); + a.set_href(&url); + a.set_download(name); + a.click(); + // revoke once the click has been handled + let revoke = Closure::once_into_js(move || { + let _ = web_sys::Url::revoke_object_url(&url); + }); + let _ = window().set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 5000); + log::info!("exported {name} ({} bytes)", bytes.len()); + Ok(()) +} + +fn services(store: MemStore, ctx: egui::Context) -> Services { + Services { + pick_files: Some(Box::new(move || { + open_picker(store.clone(), ctx.clone()); + Vec::new() // files arrive asynchronously and are imported on a later frame + })), + write: Some(Box::new(download)), + png: Some(Box::new(|img: &lightcraft_raster::Rgba8| { + lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default() + })), + } +} + +struct WebApp { + app: LightcraftApp, + store: MemStore, + bench: Option, + first_frame_logged: bool, +} + +impl WebApp { + fn new(cc: &eframe::CreationContext<'_>, bench: bool) -> Self { + let store = MemStore::default(); + let t = perf_now(); + let mut session = Session::with_demo(); + log::info!("lightcraft: wasm started at {:.0} ms; demo library built in {:.0} ms", t, perf_now() - t); + store.install(&mut session); + // previews are large (≤ 2560 px, f32): keep few in a 32-bit address space + session.media.preview_capacity = 3; + let mut app = LightcraftApp::new(session, services(store.clone(), cc.egui_ctx.clone())); + app.ui = app.ui.sanitized(); + let origin = lightcraft_ui_egui::now_ms() - perf_now(); + WebApp { app, store, bench: bench.then(|| Bench::new(origin)), first_frame_logged: false } + } + + fn import_dropped(&mut self, ctx: &egui::Context) { + let dropped = ctx.input(|i| i.raw.dropped_files.clone()); + for f in dropped { + let (store, ctx) = (self.store.clone(), ctx.clone()); + wasm_bindgen_futures::spawn_local(async move { + let name = f.path().to_string_lossy().to_string(); + match f.bytes_async().await { + Ok(bytes) => { + store.add(&name, bytes); + ctx.request_repaint(); + } + Err(e) => log::warn!("reading dropped {name}: {e}"), + } + }); + } + let paths = self.store.take_pending(); + if !paths.is_empty() { + let n = paths.len(); + match self.app.run("library.import", json!({"paths": paths})) { + Ok(_) => self.app.toast(ctx, format!("Added {n} photo{}", if n == 1 { "" } else { "s" })), + Err(e) => log::warn!("import: {e}"), + } + } + } +} + +impl eframe::App for WebApp { + fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.import_dropped(ctx); + self.app.logic(ctx); + if let Some(b) = self.bench.as_mut() + && let Some(report) = b.step(&mut self.app) + { + log::info!("lightcraft-bench {report}"); + set_status(&format!("bench: {report}")); + self.bench = None; + } + if self.bench.is_some() { + ctx.request_repaint(); + } + } + + fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { + self.app.raw_input_hook(raw); + } + + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + self.app.ui(ui); + if !self.first_frame_logged && !self.app.widgets.is_empty() { + self.first_frame_logged = true; + log::info!("lightcraft first UI frame at {:.0} ms after navigation start", perf_now()); + } + } +} + +#[wasm_bindgen(start)] +pub fn start() { + eframe::WebLogger::init(log::LevelFilter::Info).ok(); + log::info!("lightcraft: wasm instantiated at {:.0} ms", perf_now()); + let bench = window().location().search().is_ok_and(|q| q.contains("bench")); + wasm_bindgen_futures::spawn_local(async move { + let Some(canvas) = + window().document().and_then(|d| d.get_element_by_id("lightcraft_canvas")).and_then(|e| e.dyn_into::().ok()) + else { + log::error!("missing "); + return; + }; + let runner = eframe::WebRunner::new(); + let r = runner.start(canvas, eframe::WebOptions::default(), Box::new(move |cc| Ok(Box::new(WebApp::new(cc, bench))))).await; + match r { + Ok(()) => { + if let Some(el) = window().document().and_then(|d| d.get_element_by_id("lightcraft_loading")) { + el.remove(); + } + } + Err(e) => { + log::error!("LightCraft failed to start: {e:?}"); + set_status(&format!("LightCraft failed to start: {e:?}")); + } + } + }); +} diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 65341aea..c0ddcbbe 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -98,14 +98,14 @@ pub fn output_size(src_w: usize, src_h: usize, s: &DevelopSettings, req: &Render /// Render `src` with settings `s`. pub fn render(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest) -> Rendered { let prof = std::env::var_os("LIGHTCRAFT_PROFILE").is_some(); - let t0 = std::time::Instant::now(); - let lap = |what: &str, t: &mut std::time::Instant| { - if prof { + // `Instant::now()` panics on wasm32-unknown-unknown: only read the clock when profiling. + let lap = |what: &str, t: &mut Option| { + if let Some(t) = t { eprintln!(" {what}: {:.1} ms", t.elapsed().as_secs_f64() * 1e3); *t = std::time::Instant::now(); } }; - let mut t = t0; + let mut t = prof.then(std::time::Instant::now); let s = &*profiles::effective(s); let frame = geometry::Frame::new(src.width, src.height, s, req.apply_crop); let (w, h) = frame.fit(req.max_w, req.max_h); diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index f574fb28..7c38bd3b 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -15,6 +15,7 @@ lightcraft-raster = { workspace = true } lightcraft-develop = { workspace = true } lightcraft-pipeline = { workspace = true } lightcraft-catalog = { workspace = true } +web-time = { workspace = true } lightcraft-engine = { workspace = true } egui = { workspace = true } serde = { workspace = true } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c83e7c5f..f470f843 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -266,16 +266,10 @@ impl LightcraftApp { } } +/// Wall-clock milliseconds since the Unix epoch (`web-time` maps to `Date.now()` on the web). pub fn now_ms() -> f64 { - #[cfg(not(target_arch = "wasm32"))] - { - use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0) - } - #[cfg(target_arch = "wasm32")] - { - 0.0 - } + use web_time::{SystemTime, UNIX_EPOCH}; + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0) } /// Whether the settings render in black & white. diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 5babdeb0..90df55e6 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -61,15 +61,7 @@ impl Default for Renderer { } fn now() -> f64 { - #[cfg(not(target_arch = "wasm32"))] - { - use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0) - } - #[cfg(target_arch = "wasm32")] - { - 0.0 - } + crate::now_ms() } impl Renderer { @@ -142,8 +134,9 @@ impl Renderer { best.map(|b| g.remove(b)) }; if let Some((slot, _, job)) = job { + let t0 = now(); let r = job.run(); - let _ = self.results_tx.send((slot, r, 0.0)); + let _ = self.results_tx.send((slot, r, now() - t0)); } } let mut changed = false; diff --git a/docs/web.md b/docs/web.md new file mode 100644 index 00000000..e60263ff --- /dev/null +++ b/docs/web.md @@ -0,0 +1,77 @@ +# 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, 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://`. If `wasm-opt` +(binaryen) is on `PATH`, the release build also runs it over the module. + +The release build uses the `web` Cargo profile (release + thin LTO, no debug info). + +## What works + +- **Demo library on load.** The procedural scenes from `lightcraft-scenes` are loaded at + start, so the page always has something in it. +- **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 kept in memory + (`store::MemStore`) under synthetic paths (`mem//`) and decoded by the same engine code + as the desktop app (`lightcraft_engine::files::{probe_bytes, load_bytes}`). That covers + JPEG/PNG/TIFF/WebP and the supported raw formats. +- **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.export` path as + the desktop app (`lightcraft_engine::export`: JPEG/PNG/TIFF/WebP, sizing, naming). The host's + `write` service hands each file to the browser as a download instead of writing it to disk. + +## Not yet + +- **Persistence.** Nothing is saved across reloads: the catalog, edits, imported files and UI + prefs all start fresh. The planned route is IndexedDB/OPFS for the op log, and file handles + where the browser supports them. +- **Worker threads.** Renders run on the main thread, one job per frame. On a cold load the grid + fills in over a few seconds, and the UI can stall briefly while a thumbnail is generated. The fix + is Web Workers, which need wasm threads (`+atomics`, SharedArrayBuffer, COOP/COEP headers). +- **Bundle size.** The module is about 12 MB uncompressed, because it includes every codec and + raw decoder. Serving it gzip/brotli-compressed or running `wasm-opt` shrinks it a lot. +- **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: + +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: + +``` +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 the `develop.set` command 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. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 91457462..0218d4a9 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -6,6 +6,7 @@ mod assets; mod layers; mod stats; +mod web; use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode}; @@ -16,7 +17,10 @@ usage: cargo xtask commands: assets every image/icon/font/media file is attributed in assets/ATTRIBUTION.md; no Adobe assets layers enforce the crate dependency layering (plan/architecture.md §3) - wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates + wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (+ the web app) + web [--serve [port]] [--dev] + build the browser app (apps/lightcraft-web) into /web/; + --serve serves it on http://127.0.0.1: (default 8080) ci fmt --check, clippy -D warnings, test, layers, assets, wasm (stops at first failure) corpus [--download] show where test corpora live; --download fetches PngSuite into corpus/pngsuite @@ -30,6 +34,7 @@ fn main() -> ExitCode { Some("layers") => cmd_layers(), Some("assets") => assets::run(&root()), Some("wasm") => cmd_wasm(), + Some("web") => web::run(&rest), Some("ci") => cmd_ci(), Some("corpus") => cmd_corpus(rest.contains(&"--download")), Some("stats") => stats::run(&root(), rest.contains(&"--exact")), @@ -59,7 +64,7 @@ pub fn cargo() -> Command { c } -fn run(mut cmd: Command, what: &str) -> Result<(), String> { +pub fn run(mut cmd: Command, what: &str) -> Result<(), String> { eprintln!("$ {what}"); let status = cmd.status().map_err(|e| format!("{what}: failed to spawn: {e}"))?; if status.success() { Ok(()) } else { Err(format!("{what}: exited with {status}")) } @@ -108,7 +113,7 @@ fn cmd_layers() -> Result<(), String> { } /// Workspace packages that must build for wasm32: all L0–L5 crates plus -/// the egui shell. +/// the egui shell and the web app. fn wasm_set() -> Result, String> { let crates = layers::from_metadata(&metadata()?)?; Ok(crates @@ -119,6 +124,7 @@ fn wasm_set() -> Result, String> { _ => false, }) .map(|c| c.name) + .chain(std::iter::once("lightcraft-web".to_string())) .collect()) } diff --git a/xtask/src/web.rs b/xtask/src/web.rs new file mode 100644 index 00000000..18218f44 --- /dev/null +++ b/xtask/src/web.rs @@ -0,0 +1,163 @@ +//! `cargo xtask web`: build `apps/lightcraft-web` for wasm32 and bundle it with `wasm-bindgen` +//! into `/web/` (index.html + lightcraft_web.js + lightcraft_web_bg.wasm). +//! `--serve [port]` then serves that folder with a tiny static HTTP server (std only). + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::{cargo, metadata, root, run as step}; + +const TARGET: &str = "wasm32-unknown-unknown"; + +/// The `wasm-bindgen` version pinned in Cargo.lock (the CLI must match it exactly). +fn locked_bindgen_version() -> Result { + let lock = std::fs::read_to_string(root().join("Cargo.lock")).map_err(|e| format!("Cargo.lock: {e}"))?; + let mut lines = lock.lines(); + while let Some(l) = lines.next() { + if l.trim() == "name = \"wasm-bindgen\"" + && let Some(v) = lines.next().and_then(|v| v.trim().strip_prefix("version = \"")) + { + return Ok(v.trim_end_matches('"').to_string()); + } + } + Err("wasm-bindgen not found in Cargo.lock".into()) +} + +fn check_bindgen(want: &str) -> Result<(), String> { + let hint = format!("install it with:\n cargo install wasm-bindgen-cli --version {want} --locked"); + let out = Command::new("wasm-bindgen").arg("--version").output().map_err(|_| format!("`wasm-bindgen` CLI not found; {hint}"))?; + let have = String::from_utf8_lossy(&out.stdout); + let have = have.split_whitespace().nth(1).unwrap_or("?"); + if have != want { + return Err(format!("wasm-bindgen CLI is {have} but Cargo.lock has {want}; {hint}")); + } + Ok(()) +} + +pub fn run(args: &[&str]) -> Result<(), String> { + let dev = args.contains(&"--dev"); + let serve = args.iter().position(|a| *a == "--serve").map(|i| args.get(i + 1).and_then(|p| p.parse::().ok()).unwrap_or(8080)); + + let want = locked_bindgen_version()?; + check_bindgen(&want)?; + let meta = metadata()?; + let target_dir = PathBuf::from(meta["target_directory"].as_str().ok_or("cargo metadata: no target_directory")?); + let profile = if dev { "dev" } else { "web" }; + + let mut c = cargo(); + c.args(["build", "-p", "lightcraft-web", "--lib", "--target", TARGET, "--profile", profile]); + step(c, &format!("cargo build -p lightcraft-web --target {TARGET} --profile {profile}"))?; + + let wasm = target_dir.join(TARGET).join(if dev { "debug" } else { profile }).join("lightcraft_web.wasm"); + let out = target_dir.join("web"); + std::fs::create_dir_all(&out).map_err(|e| format!("{}: {e}", out.display()))?; + let mut b = Command::new("wasm-bindgen"); + b.arg(&wasm).args(["--target", "web", "--no-typescript", "--out-name", "lightcraft_web", "--out-dir"]).arg(&out); + if dev { + b.arg("--debug"); + } + step(b, &format!("wasm-bindgen {} → {}", wasm.display(), out.display()))?; + + // optional size pass when binaryen is installed + let bg = out.join("lightcraft_web_bg.wasm"); + if !dev && Command::new("wasm-opt").arg("--version").output().is_ok() { + let mut o = Command::new("wasm-opt"); + o.args(["-O2", "--enable-bulk-memory", "--enable-nontrapping-float-to-int", "--enable-sign-ext", "--enable-mutable-globals"]) + .arg(&bg) + .arg("-o") + .arg(&bg); + if step(o, "wasm-opt -O2").is_err() { + eprintln!("(wasm-opt failed; keeping the unoptimized module)"); + } + } + let index = root().join("apps/lightcraft-web/index.html"); + std::fs::copy(&index, out.join("index.html")).map_err(|e| format!("copy index.html: {e}"))?; + let size = std::fs::metadata(&bg).map(|m| m.len()).unwrap_or(0); + println!("\nweb build ready: {} ({:.1} MB wasm)", out.display(), size as f64 / 1e6); + + match serve { + Some(port) => serve_dir(&out, port), + None => { + println!("serve it with `cargo xtask web --serve` (or any static HTTP server) and open http://127.0.0.1:8080/"); + Ok(()) + } + } +} + +fn mime(path: &Path) -> &'static str { + match path.extension().and_then(|e| e.to_str()).unwrap_or("") { + "html" => "text/html; charset=utf-8", + "js" => "text/javascript; charset=utf-8", + "wasm" => "application/wasm", + "css" => "text/css; charset=utf-8", + "json" => "application/json", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "svg" => "image/svg+xml", + _ => "application/octet-stream", + } +} + +/// Serve `dir` on 127.0.0.1:`port` (GET/HEAD only, no directory listing). Blocks. +fn serve_dir(dir: &Path, port: u16) -> Result<(), String> { + let listener = TcpListener::bind(("127.0.0.1", port)).map_err(|e| format!("bind 127.0.0.1:{port}: {e}"))?; + println!("serving {} at http://127.0.0.1:{port}/ (Ctrl+C to stop)", dir.display()); + for stream in listener.incoming().flatten() { + if let Err(e) = handle(stream, dir) { + eprintln!("http: {e}"); + } + } + Ok(()) +} + +fn handle(mut stream: TcpStream, dir: &Path) -> std::io::Result<()> { + let mut reader = BufReader::new(stream.try_clone()?); + let mut line = String::new(); + reader.read_line(&mut line)?; + // drain headers + let mut h = String::new(); + while reader.read_line(&mut h)? > 2 { + h.clear(); + } + let mut parts = line.split_whitespace(); + let method = parts.next().unwrap_or(""); + let target = parts.next().unwrap_or("/"); + let path = target.split(['?', '#']).next().unwrap_or("/"); + let rel = path.trim_start_matches('/'); + let rel = if rel.is_empty() { "index.html" } else { rel }; + let file = dir.join(rel); + let safe = !rel.split('/').any(|c| c == ".." || c.contains('\\')); + let (status, body, ctype) = match (method, safe, std::fs::read(&file)) { + ("GET" | "HEAD", true, Ok(b)) => ("200 OK", b, mime(&file)), + ("GET" | "HEAD", _, _) => ("404 Not Found", b"not found".to_vec(), "text/plain"), + _ => ("405 Method Not Allowed", Vec::new(), "text/plain"), + }; + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n", + body.len() + )?; + if method != "HEAD" { + stream.write_all(&body)?; + } + stream.flush() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bindgen_version_is_locked() { + let v = locked_bindgen_version().unwrap(); + assert!(v.starts_with("0.2."), "{v}"); + } + + #[test] + fn wasm_mime() { + assert_eq!(mime(Path::new("a/lightcraft_web_bg.wasm")), "application/wasm"); + assert_eq!(mime(Path::new("index.html")), "text/html; charset=utf-8"); + } +}