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 <target>/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 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-01 00:01:18 -04:00
1 parent 17057f7bbc
commit 2d2bc2ad8f
16 files changed
+885 -27

No files matched your search

Generated
+20
View File
@@ -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]]
+13
View File
@@ -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"
+6 -1
View File
@@ -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 🚧 |
<br>
@@ -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: <kbd>G</kbd> grid · <kbd>D</kbd> detail · <kbd>E</kbd> edit · <kbd>C</kbd> crop · <kbd>M</kbd> masking ·
<kbd>Shift</kbd>+<kbd>P</kbd> presets · <kbd>\\</kbd> original · <kbd>Y</kbd> before/after · <kbd>Z</kbd> zoom ·
<kbd>J</kbd> clipping · <kbd>⌘Z</kbd> undo · <kbd>⌘/</kbd> all shortcuts.
+44
View File
@@ -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",
] }
+33
View File
@@ -0,0 +1,33 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LightCraft</title>
<link rel="icon" href="data:,">
<meta name="description" content="LightCraft: open-source photo library and raw developer, running in your browser (WASM).">
<style>
:root { color-scheme: dark; }
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #1c1c1c; }
canvas#lightcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#lightcraft_loading {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
color: #b8b8b8; font: 15px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif; pointer-events: none;
}
#lightcraft_status { position: absolute; left: 0; bottom: 0; width: 1px; height: 1px; overflow: hidden; }
</style>
</head>
<body>
<canvas id="lightcraft_canvas"></canvas>
<div id="lightcraft_loading">Loading LightCraft…</div>
<div id="lightcraft_status" aria-live="polite"></div>
<script type="module">
import init from "./lightcraft_web.js";
init().catch((e) => {
console.error(e);
const el = document.getElementById("lightcraft_loading");
if (el) el.textContent = "LightCraft failed to load: " + e;
});
</script>
</body>
</html>
+143
View File
@@ -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<f64>,
thumbs_done_ms: Option<f64>,
open_ms: f64,
drafts: Vec<f64>,
jobs: Vec<f64>,
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<u64> {
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<Value> {
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::<f64>() / 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::<Vec<_>>(),
"slider_draft_median_ms": r(median),
"slider_draft_mean_ms": r(mean),
"slider_job_ms": self.jobs.iter().copied().map(r).collect::<Vec<_>>(),
"release_full_ms": r(self.final_ms),
"loupe_px": tex,
"frames": self.frames,
})
}
}
+20
View File
@@ -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;
+144
View File
@@ -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/<n>/<name>`), 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<String, Arc<[u8]>>,
/// Paths added since the last [`MemStore::take_pending`] (to import on the next frame).
pending: Vec<String>,
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<Mutex<Inner>>);
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<u8>) -> 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<Arc<[u8]>> {
self.lock().files.get(path).cloned()
}
/// Paths added since the last call.
pub fn take_pending(&self) -> Vec<String> {
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<u8> {
let bytes: Vec<u8> = (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");
}
}
+202
View File
@@ -0,0 +1,202 @@
//! Browser host: starts eframe on `<canvas id="lightcraft_canvas">`, 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::<web_sys::HtmlInputElement>()) else { return };
input.set_type("file");
input.set_multiple(true);
input.set_accept(ACCEPT);
let inp = input.clone();
let on_change = Closure::<dyn FnMut()>::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<Bench>,
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::<web_sys::HtmlCanvasElement>().ok())
else {
log::error!("missing <canvas id=\"lightcraft_canvas\">");
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:?}"));
}
}
});
}
+4 -4
View File
@@ -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<std::time::Instant>| {
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);
+1
View File
@@ -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 }
+3 -9
View File
@@ -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.
+3 -10
View File
@@ -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;
+77
View File
@@ -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 <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:
```sh
cargo xtask web # → <target>/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)
```
`<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://`. 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…* (<kbd>⌘⇧I</kbd>) 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/<n>/<name>`) 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…* (<kbd>⌘⇧E</kbd>) 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.
+9 -3
View File
@@ -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 <command>
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 <target>/web/;
--serve serves it on http://127.0.0.1:<port> (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<Vec<String>, String> {
let crates = layers::from_metadata(&metadata()?)?;
Ok(crates
@@ -119,6 +124,7 @@ fn wasm_set() -> Result<Vec<String>, String> {
_ => false,
})
.map(|c| c.name)
.chain(std::iter::once("lightcraft-web".to_string()))
.collect())
}
+163
View File
@@ -0,0 +1,163 @@
//! `cargo xtask web`: build `apps/lightcraft-web` for wasm32 and bundle it with `wasm-bindgen`
//! into `<target>/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<String, String> {
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::<u16>().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");
}
}