diff --git a/apps/lightcraft-web/src/backend.rs b/apps/lightcraft-web/src/backend.rs index 50a03135..4ccde59c 100644 --- a/apps/lightcraft-web/src/backend.rs +++ b/apps/lightcraft-web/src/backend.rs @@ -232,7 +232,6 @@ impl Backend { } } - #[allow(dead_code)] // thumbnail cache pruning (render workers) pub async fn remove(&self, path: &str) -> Result<(), String> { match self { Backend::Opfs(root) => { @@ -252,7 +251,6 @@ impl Backend { } /// Names of the files directly inside `dir`. - #[allow(dead_code)] // thumbnail cache pruning (render workers) pub async fn list(&self, dir: &str) -> Result, String> { match self { Backend::Opfs(root) => { diff --git a/apps/lightcraft-web/src/lib.rs b/apps/lightcraft-web/src/lib.rs index a2c5b589..3b19987f 100644 --- a/apps/lightcraft-web/src/lib.rs +++ b/apps/lightcraft-web/src/lib.rs @@ -6,19 +6,23 @@ //! main thread): catalog journal, presets, view state and prefs are mirrored in memory and //! flushed in the background ([`files`]); imported originals are stored by content hash //! ([`store`]); a new library starts with the procedural demo photos; -//! - renders run inline on the main thread (one job per frame) — no worker threads yet; +//! - renders run in Web Workers, each a second instance of this module ([`wire`], `workers`), and +//! fall back to the main thread (one job per frame) if workers can't start; //! - export downloads the file through the browser. //! //! `?bench` in the URL runs a scripted first-paint / slider-latency measurement and logs it to the //! console (see [`bench`]); `?store=idb` forces the IndexedDB backend, `?store=memory` disables -//! persistence and `?reset` clears the stored library. +//! persistence and `?workers=0` renders on the main thread. #![forbid(unsafe_code)] pub mod bench; pub mod files; pub mod store; +pub mod wire; #[cfg(target_arch = "wasm32")] mod backend; #[cfg(target_arch = "wasm32")] mod web; +#[cfg(target_arch = "wasm32")] +mod workers; diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index d11343b0..f669ec26 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -1,4 +1,4 @@ -//! Browser host: opens the library in browser storage, starts eframe on +//! Browser host: opens the library in browser storage, starts the render workers and eframe on //! ``, wires the file picker and drag-and-drop, and turns exports //! into downloads. @@ -18,13 +18,15 @@ use crate::backend::Backend; use crate::bench::Bench; use crate::files::{Files, FlushOp, LIBRARY_FILES}; use crate::store::{Originals, content_hash, download_name, storage_key}; +use crate::wire::ThumbIndex; +use crate::workers::{THUMB_INDEX, Workers}; const ACCEPT: &str = ".jpg,.jpeg,.png,.tif,.tiff,.webp,.dng,.cr2,.nef,.arw,.psd,.jxl,.gif,.bmp"; /// Where the library files live in browser storage. const LIBRARY_DIR: &str = "library"; -/// How often view state and UI prefs are saved (ms). +/// How often view state, UI prefs and the thumbnail index are saved (ms). const SAVE_EVERY_MS: f64 = 1000.0; fn window() -> web_sys::Window { @@ -46,11 +48,12 @@ fn library_key(name: &str) -> String { format!("{LIBRARY_DIR}/{name}") } -/// URL options (`?bench&store=idb&reset`). +/// URL options (`?bench&store=idb&workers=2&reset`). struct Options { bench: bool, /// "opfs" (default: OPFS, falling back to IndexedDB), "idb" or "memory". store: String, + workers: Option, reset: bool, } @@ -70,6 +73,7 @@ impl Options { Options { bench: get("bench").is_some(), store: get("store").filter(|s| s == "idb" || s == "memory").unwrap_or_else(|| "opfs".into()), + workers: get("workers").and_then(|v| v.parse().ok()), reset: get("reset").is_some(), } } @@ -184,6 +188,7 @@ struct Boot { opts: Options, backend: Option, files: Files, + index: ThumbIndex, } async fn boot(opts: Options) -> Boot { @@ -200,6 +205,7 @@ async fn boot(opts: Options) -> Boot { } }; let files = Files::default(); + let mut index = ThumbIndex::default(); if let Some(b) = &backend { if opts.reset { match b.clear().await { @@ -214,10 +220,38 @@ async fn boot(opts: Options) -> Boot { Err(e) => log::error!("reading {name}: {e}"), } } + if let Ok(Some(j)) = b.read(THUMB_INDEX).await { + index = ThumbIndex::from_json(&j); + } crate::backend::request_persistence(); + // thumbnails stored without an index entry (index saved late, or lost): delete them + let b2 = b.clone(); + let known: std::collections::HashSet = b + .list("thumbs") + .await + .unwrap_or_default() + .into_iter() + .filter(|n| n.ends_with(".jpg")) + .map(|n| n.trim_end_matches(".jpg").to_string()) + .collect(); + let orphans: Vec = known.into_iter().filter(|k| !index.contains(k)).collect(); + if !orphans.is_empty() { + wasm_bindgen_futures::spawn_local(async move { + for k in &orphans { + let _ = b2.remove(&crate::wire::thumb_storage_key(k)).await; + } + log::info!("thumbnail cache: removed {} unindexed files", orphans.len()); + }); + } } - log::info!("lightcraft: storage {} opened in {:.0} ms", backend.as_ref().map_or("memory", |b| b.kind()), perf_now() - t0); - Boot { opts, backend, files } + log::info!( + "lightcraft: storage {} opened in {:.0} ms ({} thumbnails indexed, {:.1} MB)", + backend.as_ref().map_or("memory", |b| b.kind()), + perf_now() - t0, + index.len(), + index.total() as f64 / 1e6 + ); + Boot { opts, backend, files, index } } struct WebApp { @@ -225,6 +259,7 @@ struct WebApp { originals: Originals, files: Files, backend: Option, + workers: Option, flushing: Rc>, bench: Option, first_frame_logged: bool, @@ -234,7 +269,7 @@ struct WebApp { impl WebApp { fn new(cc: &eframe::CreationContext<'_>, boot: Boot) -> Self { - let Boot { opts, backend, files } = boot; + let Boot { opts, backend, files, index } = boot; let originals = Originals::default(); let t = perf_now(); let mut session = Session::new(); @@ -268,6 +303,16 @@ impl WebApp { app.ui = ui; } app.ui = app.ui.sanitized(); + let n = opts.workers.unwrap_or_else(|| { + let cores = window().navigator().hardware_concurrency() as usize; + cores.saturating_sub(1).clamp(1, 4) + }); + let workers = + (n > 0).then(|| Workers::start(n, backend.as_ref().map_or("memory", |b| b.kind()), backend.clone(), index, cc.egui_ctx.clone())); + if let Some(w) = &workers { + app.renderer.set_offload(Box::new(w.clone())); + } + log::info!("lightcraft: {n} render workers"); CTX.with(|c| *c.borrow_mut() = Some(cc.egui_ctx.clone())); let origin = lightcraft_ui_egui::now_ms() - perf_now(); WebApp { @@ -275,6 +320,7 @@ impl WebApp { originals, files, backend, + workers, flushing: Rc::new(Cell::new(false)), bench: opts.bench.then(|| Bench::new(origin)), first_frame_logged: false, @@ -335,7 +381,7 @@ impl WebApp { } } - /// Save view state and UI prefs now and then; flush dirty files. + /// Save view state, UI prefs and the thumbnail index now and then; flush dirty files. fn save(&mut self) { let now = perf_now(); if now - self.last_save >= SAVE_EVERY_MS { @@ -347,6 +393,16 @@ impl WebApp { self.files.write("ui.json", &ui); self.ui_written = ui; } + if let (Some(w), Some(b)) = (&self.workers, &self.backend) + && let Some(index) = w.take_index_if_dirty() + { + let b = b.clone(); + wasm_bindgen_futures::spawn_local(async move { + if let Err(e) = b.write(THUMB_INDEX, &index).await { + log::warn!("saving the thumbnail index: {e}"); + } + }); + } } if let Some(b) = &self.backend && self.files.is_dirty() @@ -368,7 +424,7 @@ thread_local! { /// Run a command by id from JavaScript (automation, tests): `await command("library.info", "{}")` /// resolves to the result as JSON text, or rejects with the error. Besides every engine/UI -/// command, `web.stats` reports the browser host's state (storage, originals in memory). +/// command, `web.stats` reports the browser host's state (storage, workers, originals in memory). #[wasm_bindgen] pub fn command(id: String, params: String) -> js_sys::Promise { js_sys::Promise::new(&mut |resolve, reject| { @@ -379,11 +435,13 @@ pub fn command(id: String, params: String) -> js_sys::Promise { impl WebApp { fn web_stats(&self) -> serde_json::Value { + let (alive, ready, remote, inline) = self.workers.as_ref().map_or((0, 0, 0, 0), Workers::stats); let (orig_n, orig_bytes) = self.originals.usage(); json!({ "storage": self.backend.as_ref().map_or("memory", |b| b.kind()), "dirty": self.files.is_dirty(), "flushing": self.flushing.get(), + "workers": {"alive": alive, "ready": ready, "remoteJobs": remote, "inlineJobs": inline}, "originalsInMemory": orig_n, "originalBytesInMemory": orig_bytes, "renderQueue": self.app.renderer.queued(), @@ -416,6 +474,9 @@ impl eframe::App for WebApp { && let Some(report) = b.step(&mut self.app) { log::info!("lightcraft-bench {report}"); + if let Some((alive, ready, remote, inline)) = self.workers.as_ref().map(Workers::stats) { + log::info!("lightcraft-workers {{\"alive\":{alive},\"ready\":{ready},\"remote_jobs\":{remote},\"inline_jobs\":{inline}}}"); + } set_status(&format!("bench: {report}")); self.bench = None; } diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs new file mode 100644 index 00000000..95e75ffd --- /dev/null +++ b/apps/lightcraft-web/src/wire.rs @@ -0,0 +1,324 @@ +//! Render jobs across the Web Worker boundary. +//! +//! A worker is a second wasm instance with its own memory, so an engine [`RenderJob`] (which holds +//! `Arc`s into the main instance) can't be shared. The main thread sends a [`WireJob`] instead: +//! where the pixels come from (the catalog [`Source`]), the develop settings and the request. The +//! worker keeps its own decoded sources ([`WorkerCore`]), reads originals and cached thumbnails +//! from browser storage, renders, and transfers the RGBA bytes back. +//! +//! Thumbnail disk cache on the web: workers read/write `thumbs/.jpg` in browser storage; the +//! main thread keeps the index (sizes, recency) and prunes it to the native cache's budget +//! ([`ThumbIndex`]). + +use std::collections::HashMap; +use std::sync::Arc; + +use lightcraft_engine::catalog::Source; +use lightcraft_engine::develop::{DevelopSettings, EmbeddedLens}; +use lightcraft_engine::media::{MediaCache, RenderJob, SourceLevel, SourceRef}; +use lightcraft_engine::pipeline::{Quality, RenderRequest, Rendered, SourceInfo, StageCache}; +use lightcraft_preview::Lru; +use lightcraft_raster::Rgb32f; +use serde::{Deserialize, Serialize}; + +use crate::store::hash_of_path; + +/// One render request for a worker. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct WireJob { + pub level: SourceLevel, + pub origin: Source, + /// Decode the source at most this long. + pub max_edge: usize, + pub raw: bool, + pub as_shot_temp: f64, + pub as_shot_tint: f64, + pub lens: Option, + pub settings: Arc, + pub max_w: usize, + pub max_h: usize, + pub draft: bool, + pub apply_crop: bool, + /// Thumbnail cache key (hex): read the cached file first if `thumb_cached`, store the result. + pub thumb: Option, + pub thumb_cached: bool, + /// Reuse the worker's intermediate results for this view (the loupe), like the desktop's + /// per-slot stage cache: slider drags then only redo what changed. + pub stages: Option, +} + +impl WireJob { + /// `view`: the slot name, for jobs that keep a stage cache. + pub fn from_job(job: &RenderJob, thumb_cached: bool, view: &str) -> WireJob { + let max_edge = match &job.source { + SourceRef::Demo { max_edge, .. } | SourceRef::File { max_edge, .. } => *max_edge, + SourceRef::Loaded(img) => img.width.max(img.height), + }; + WireJob { + level: job.level, + origin: job.origin.clone(), + max_edge, + raw: job.info.raw, + as_shot_temp: job.info.as_shot_temp, + as_shot_tint: job.info.as_shot_tint, + lens: job.info.lens, + settings: job.settings.clone(), + max_w: job.request.max_w, + max_h: job.request.max_h, + draft: job.request.quality == Quality::Draft, + apply_crop: job.request.apply_crop, + thumb: job.cache.as_ref().map(|(_, k)| k.to_string()), + thumb_cached, + stages: job.stages.is_some().then(|| view.to_string()), + } + } + + pub fn info(&self) -> SourceInfo { + SourceInfo { raw: self.raw, as_shot_temp: self.as_shot_temp, as_shot_tint: self.as_shot_tint, lens: self.lens } + } + + pub fn request(&self) -> RenderRequest { + RenderRequest { + max_w: self.max_w, + max_h: self.max_h, + quality: if self.draft { Quality::Draft } else { Quality::Full }, + apply_crop: self.apply_crop, + } + } + + /// Identifies the decoded source this job needs. + fn source_key(&self) -> String { + match &self.origin { + Source::File { path } => format!("{}:{path}", self.max_edge), + Source::Demo { scene } => format!("{}:demo:{scene}", self.max_edge), + } + } + + /// Storage key of the cached thumbnail. + pub fn thumb_key(&self) -> Option { + self.thumb.as_ref().map(|h| thumb_storage_key(h)) + } +} + +pub fn thumb_storage_key(hex: &str) -> String { + format!("thumbs/{hex}.jpg") +} + +/// Decoded sources per worker. +const WORKER_SOURCE_BYTES: usize = 192 << 20; + +/// The synchronous part of a worker: decoded-source cache + rendering. +pub struct WorkerCore { + sources: Lru>, + media: MediaCache, + stages: HashMap>, +} + +impl Default for WorkerCore { + fn default() -> Self { + WorkerCore { sources: Lru::new(WORKER_SOURCE_BYTES), media: MediaCache::default(), stages: HashMap::new() } + } +} + +impl WorkerCore { + /// The content hash of the original to read from storage before [`WorkerCore::render`], if + /// the job needs one that isn't decoded yet. + pub fn needs_original(&mut self, job: &WireJob) -> Option { + if self.sources.get(&job.source_key()).is_some() { + return None; + } + match &job.origin { + Source::File { path } => hash_of_path(path).map(str::to_string), + Source::Demo { .. } => None, + } + } + + /// Render `job`; `original` holds the file's bytes when [`WorkerCore::needs_original`] asked. + pub fn render(&mut self, job: &WireJob, original: Option<&[u8]>) -> Result { + let key = job.source_key(); + let src = match self.sources.get(&key).cloned() { + Some(s) => s, + None => { + let src = match (&job.origin, original) { + (Source::File { .. }, Some(bytes)) => Arc::new(lightcraft_engine::files::load_bytes(bytes, job.max_edge)?.0), + (Source::File { path }, None) => return Err(format!("{path}: original not found in browser storage")), + (origin @ Source::Demo { .. }, _) => self.media.origin_ref(origin, job.max_edge).load()?, + }; + let cost = src.width * src.height * 12 + 64; + self.sources.insert(key, src.clone(), cost); + src + } + }; + Ok(match &job.stages { + Some(view) => { + let st = self.stages.entry(view.clone()).or_default().clone(); + lightcraft_engine::pipeline::render_cached(&src, &job.info(), &job.settings, &job.request(), &st) + } + None => lightcraft_engine::pipeline::render(&src, &job.info(), &job.settings, &job.request()), + }) + } +} + +/// The web thumbnail cache's index (main thread): size and last use of every stored thumbnail, +/// pruned like the native disk cache (least recently used first, down to 80 % of the budget). +#[derive(Default, Serialize, Deserialize)] +pub struct ThumbIndex { + /// hex key → (bytes, last use). + entries: HashMap, + clock: u64, + #[serde(skip)] + total: u64, + #[serde(skip)] + pub dirty: bool, +} + +impl ThumbIndex { + pub fn from_json(bytes: &[u8]) -> ThumbIndex { + let mut i: ThumbIndex = serde_json::from_slice(bytes).unwrap_or_default(); + i.total = i.entries.values().map(|e| e.0).sum(); + i + } + + pub fn to_json(&self) -> Vec { + serde_json::to_vec(self).unwrap_or_default() + } + + /// Is `hex` stored? Counts as a use. + pub fn touch(&mut self, hex: &str) -> bool { + self.clock += 1; + let clock = self.clock; + match self.entries.get_mut(hex) { + Some(e) => { + e.1 = clock; + self.dirty = true; + true + } + None => false, + } + } + + pub fn contains(&self, hex: &str) -> bool { + self.entries.contains_key(hex) + } + + pub fn insert(&mut self, hex: &str, bytes: u64) { + self.clock += 1; + if let Some(old) = self.entries.insert(hex.to_string(), (bytes, self.clock)) { + self.total -= old.0; + } + self.total += bytes; + self.dirty = true; + } + + pub fn remove(&mut self, hex: &str) { + if let Some(old) = self.entries.remove(hex) { + self.total -= old.0; + self.dirty = true; + } + } + + pub fn total(&self) -> u64 { + self.total + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Over `budget`: drop the least recently used entries down to 80 % of it. Returns the keys + /// whose files should be deleted. + pub fn prune(&mut self, budget: u64) -> Vec { + if self.total <= budget { + return Vec::new(); + } + let target = budget / 5 * 4; + let mut by_age: Vec<(String, u64, u64)> = self.entries.iter().map(|(k, v)| (k.clone(), v.0, v.1)).collect(); + by_age.sort_by_key(|e| e.2); + let mut out = Vec::new(); + for (k, _, _) in by_age { + if self.total <= target { + break; + } + self.remove(&k); + out.push(k); + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_engine::Session; + + #[test] + fn wire_job_renders_like_the_engine() { + let mut s = Session::with_demo(); + let id = s.visible_cloned()[0]; + s.execute("library.select", &serde_json::json!({"ids": [id.0]})).unwrap(); + s.execute("develop.set", &serde_json::json!({"control": "light.exposure", "value": 0.7})).unwrap(); + let job = s.thumb_job(id, 128).unwrap(); + let wire = WireJob::from_job(&job, false, "Thumb"); + // through JSON, as it crosses the worker boundary + let wire: WireJob = serde_json::from_str(&serde_json::to_string(&wire).unwrap()).unwrap(); + assert!(wire.thumb.is_some()); + let mut core = WorkerCore::default(); + assert_eq!(core.needs_original(&wire), None, "demo scenes are generated"); + let a = core.render(&wire, None).unwrap(); + let b = job.run().rendered.unwrap(); + assert_eq!((a.image.width, a.image.height), (b.image.width, b.image.height)); + assert_eq!(a.image.data, b.image.data); + assert_eq!(a.histogram, b.histogram); + } + + #[test] + fn worker_reads_originals_once() { + let bytes = crate::store::png_bytes(64, 48); + let hash = crate::store::content_hash(&bytes); + let origin = Source::File { path: crate::store::original_path(&hash, "a.png") }; + let job = WireJob { + level: SourceLevel::Thumb, + origin, + max_edge: 512, + raw: false, + as_shot_temp: 6500.0, + as_shot_tint: 0.0, + lens: None, + settings: Arc::new(DevelopSettings::default()), + max_w: 32, + max_h: 32, + draft: false, + apply_crop: true, + thumb: None, + thumb_cached: false, + stages: Some("Main".into()), + }; + let mut core = WorkerCore::default(); + assert_eq!(core.needs_original(&job).as_deref(), Some(hash.as_str())); + assert!(core.render(&job, None).is_err()); + let r = core.render(&job, Some(&bytes)).unwrap(); + assert_eq!(r.image.width, 32); + assert_eq!(core.needs_original(&job), None, "decoded source is kept"); + } + + #[test] + fn thumb_index_prunes_lru() { + let mut i = ThumbIndex::default(); + for k in ["a", "b", "c", "d"] { + i.insert(k, 100); + } + assert!(i.touch("a")); + assert!(!i.touch("zz")); + assert!(i.prune(1000).is_empty()); + let gone = i.prune(300); // 400 > 300 → down to 240 + assert_eq!(gone, vec!["b".to_string(), "c".to_string()]); + assert_eq!(i.total(), 200); + let j = ThumbIndex::from_json(&i.to_json()); + assert_eq!((j.len(), j.total()), (2, 200)); + assert!(j.contains("a") && j.contains("d")); + } +} diff --git a/apps/lightcraft-web/src/workers.rs b/apps/lightcraft-web/src/workers.rs new file mode 100644 index 00000000..27539f23 --- /dev/null +++ b/apps/lightcraft-web/src/workers.rs @@ -0,0 +1,392 @@ +//! Render workers: dedicated Web Workers, each running a second instance of this wasm module +//! (`worker.js` → [`worker_main`]). The main thread compiles the module once and posts the +//! compiled `WebAssembly.Module` to every worker, so a worker starts in a few milliseconds. +//! +//! This doesn't need wasm threads (shared memory, `+atomics`, a nightly `build-std`), only +//! module workers: the main thread posts a [`WireJob`] as JSON, the worker renders it and +//! transfers the RGBA bytes back (no copy across the boundary). +//! +//! Main side: [`Workers`] implements the UI's [`RenderOffload`]: one job per worker at a time, +//! with affinity (a photo goes back to the worker that already decoded it), the rendered- +//! thumbnail memory cache checked before dispatch, and the thumbnail storage index +//! ([`ThumbIndex`]) kept and pruned here. If no worker comes up, jobs run inline. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; +use std::sync::Arc; + +use js_sys::{Array, Object, Reflect, Uint8Array, Uint32Array}; +use lightcraft_engine::catalog::PhotoId; +use lightcraft_engine::media::{DISK_CACHE_BYTES, RenderJob, RenderResult, SourceLevel}; +use lightcraft_engine::pipeline::Rendered; +use lightcraft_preview::{Hash128, PreviewCache}; +use lightcraft_raster::{Histogram, Rgba8}; +use lightcraft_ui_egui::render::{RenderOffload, Slot}; +use wasm_bindgen::JsCast; +use wasm_bindgen::prelude::*; + +use crate::backend::Backend; +use crate::wire::{ThumbIndex, WireJob, WorkerCore, thumb_storage_key}; + +/// Storage key of the thumbnail index. +pub const THUMB_INDEX: &str = "thumbs/index.json"; + +fn set(o: &Object, k: &str, v: &JsValue) { + let _ = Reflect::set(o, &k.into(), v); +} + +fn get(o: &JsValue, k: &str) -> JsValue { + Reflect::get(o, &k.into()).unwrap_or(JsValue::UNDEFINED) +} + +fn now() -> f64 { + let perf = get(&js_sys::global(), "performance"); + let f = get(&perf, "now"); + f.dyn_ref::().and_then(|f| f.call0(&perf).ok()).and_then(|v| v.as_f64()).unwrap_or(0.0) +} + +// --------------------------------------------------------------------------------------------- +// worker side + +thread_local! { + static CORE: RefCell = RefCell::new(WorkerCore::default()); +} + +/// Entry point of a render worker (called by `worker.js` after instantiating the module). +/// `store` is the backend kind the page uses ("opfs", "idb" or "memory"). +#[wasm_bindgen] +pub fn worker_main(store: String) { + eframe::WebLogger::init(log::LevelFilter::Info).ok(); + let scope: web_sys::DedicatedWorkerGlobalScope = js_sys::global().unchecked_into(); + // The main thread sends jobs only after "ready", i.e. once storage is open, and one at a time. + wasm_bindgen_futures::spawn_local(async move { + let backend = match store.as_str() { + "memory" => None, + _ => Backend::open(store == "idb").await.map_err(|e| log::warn!("render worker: no storage ({e})")).ok(), + }; + let s = scope.clone(); + let onmessage = Closure::::new(move |e: web_sys::MessageEvent| { + let (s, b) = (s.clone(), backend.clone()); + wasm_bindgen_futures::spawn_local(async move { handle(&s, b, e.data()).await }); + }); + scope.set_onmessage(Some(onmessage.as_ref().unchecked_ref())); + onmessage.forget(); + let ready = Object::new(); + set(&ready, "ready", &true.into()); + let _ = scope.post_message(&ready); + }); +} + +/// Run one job message and post the result. +async fn handle(scope: &web_sys::DedicatedWorkerGlobalScope, backend: Option, msg: JsValue) { + let id = get(&msg, "id"); + let t0 = now(); + let out = Object::new(); + set(&out, "id", &id); + let job: Result = + get(&msg, "job").as_string().ok_or("no job".to_string()).and_then(|j| serde_json::from_str(&j).map_err(|e| e.to_string())); + let mut transfer = Array::new(); + let result: Result<(Rendered, u32), String> = async { + let job = job?; + let mut cache_miss = false; + // cached thumbnail? + if job.thumb_cached + && let (Some(b), Some(key)) = (&backend, job.thumb_key()) + { + match b.read(&key).await.ok().flatten().and_then(|bytes| lightcraft_preview::decode_jpeg(&bytes)) { + Some(image) => { + let histogram = Histogram::of_srgb8(&image); + return Ok((Rendered { image, histogram }, 0)); + } + None => cache_miss = true, + } + } + set(&out, "cacheMiss", &cache_miss.into()); + let original = match CORE.with(|c| c.borrow_mut().needs_original(&job)) { + Some(hash) => match &backend { + Some(b) => b.read(&crate::store::storage_key(&hash)).await?, + None => None, + }, + None => None, + }; + let rendered = CORE.with(|c| c.borrow_mut().render(&job, original.as_deref()))?; + let mut stored = 0; + if let (Some(b), Some(key)) = (&backend, job.thumb_key()) + && let Some(jpeg) = lightcraft_preview::encode_jpeg(&rendered.image) + { + match b.write(&key, &jpeg).await { + Ok(()) => stored = jpeg.len() as u32, + Err(e) => log::warn!("thumbnail cache write: {e}"), + } + } + Ok((rendered, stored)) + } + .await; + match result { + Ok((r, stored)) => { + let rgba = Uint8Array::from(r.image.as_bytes().as_ref()); + let h = &r.histogram; + let hist = Uint32Array::new_with_length(4 * 256 + 1); + for (i, ch) in [&h.r, &h.g, &h.b, &h.luma].into_iter().enumerate() { + hist.subarray((i * 256) as u32, ((i + 1) * 256) as u32).copy_from(ch); + } + hist.set_index(1024, h.total); + set(&out, "ok", &true.into()); + set(&out, "w", &(r.image.width as u32).into()); + set(&out, "h", &(r.image.height as u32).into()); + set(&out, "rgba", &rgba); + set(&out, "hist", &hist); + set(&out, "stored", &stored.into()); + transfer = Array::of2(&rgba.buffer(), &hist.buffer()); + } + Err(e) => { + set(&out, "ok", &false.into()); + set(&out, "err", &e.into()); + } + } + set(&out, "ms", &(now() - t0).into()); + if let Err(e) = scope.post_message_with_transfer(&out, &transfer) { + log::error!("render worker: post: {e:?}"); + } +} + +// --------------------------------------------------------------------------------------------- +// main side + +struct Busy { + id: u32, + slot: Slot, + photo: PhotoId, + level: SourceLevel, + key: u64, + cache: Option<(Arc, Hash128)>, +} + +struct W { + worker: web_sys::Worker, + ready: bool, + dead: bool, + busy: Option, +} + +struct Inner { + workers: Vec, + done: Vec<(Slot, RenderResult, f64)>, + next_id: u32, + /// (photo, level) → worker that last decoded it. + affinity: HashMap<(PhotoId, SourceLevel), usize>, + index: ThumbIndex, + backend: Option, + ctx: egui::Context, + /// Jobs finished by workers / inline (for diagnostics). + remote_done: u64, + inline_done: u64, + inline_this_frame: bool, +} + +/// The main thread's handle on the render workers. +#[derive(Clone)] +pub struct Workers(Rc>); + +impl Workers { + /// Start `n` workers (none: every job runs inline). `store` is the backend kind for workers. + pub fn start(n: usize, store: &str, backend: Option, index: ThumbIndex, ctx: egui::Context) -> Workers { + let w = Workers(Rc::new(RefCell::new(Inner { + workers: Vec::new(), + done: Vec::new(), + next_id: 0, + affinity: HashMap::new(), + index, + backend, + ctx, + remote_done: 0, + inline_done: 0, + inline_this_frame: false, + }))); + let module = get(&js_sys::global(), "lightcraftModule"); + if module.is_undefined() { + log::warn!("render workers: no compiled module on the page (window.lightcraftModule); rendering on the main thread"); + return w; + } + for i in 0..n { + let opts = web_sys::WorkerOptions::new(); + opts.set_type(web_sys::WorkerType::Module); + opts.set_name(&format!("lightcraft-render-{i}")); + let worker = match web_sys::Worker::new_with_options("./worker.js", &opts) { + Ok(x) => x, + Err(e) => { + log::warn!("render worker {i}: {e:?}"); + continue; + } + }; + let me = w.clone(); + let onmessage = Closure::::new(move |e: web_sys::MessageEvent| me.on_message(i, e.data())); + worker.set_onmessage(Some(onmessage.as_ref().unchecked_ref())); + onmessage.forget(); + let me = w.clone(); + let onerror = Closure::::new(move |e: JsValue| me.on_error(i, e)); + worker.set_onerror(Some(onerror.as_ref().unchecked_ref())); + onerror.forget(); + let init = Object::new(); + set(&init, "module", &module); + set(&init, "store", &store.into()); + if let Err(e) = worker.post_message(&init) { + log::warn!("render worker {i}: {e:?}"); + continue; + } + w.0.borrow_mut().workers.push(W { worker, ready: false, dead: false, busy: None }); + } + w + } + + fn on_error(&self, i: usize, e: JsValue) { + let msg = get(&e, "message").as_string().unwrap_or_else(|| format!("{e:?}")); + log::error!("render worker {i} failed: {msg}"); + let mut g = self.0.borrow_mut(); + if let Some(w) = g.workers.get_mut(i) { + w.dead = true; + w.worker.terminate(); + if let Some(b) = w.busy.take() { + let r = RenderResult { photo: b.photo, level: b.level, key: b.key, rendered: Err(msg), loaded: None }; + g.done.push((b.slot, r, 0.0)); + } + } + g.ctx.request_repaint(); + } + + fn on_message(&self, i: usize, data: JsValue) { + let mut g = self.0.borrow_mut(); + let g = &mut *g; + g.ctx.request_repaint(); + let Some(w) = g.workers.get_mut(i) else { return }; + if get(&data, "ready").is_truthy() { + w.ready = true; + return; + } + let id = get(&data, "id").as_f64().unwrap_or(-1.0) as u32; + let Some(b) = w.busy.take_if(|b| b.id == id) else { return }; + let ms = get(&data, "ms").as_f64().unwrap_or(0.0); + let rendered = if get(&data, "ok").is_truthy() { + let (wd, ht) = (get(&data, "w").as_f64().unwrap_or(0.0) as usize, get(&data, "h").as_f64().unwrap_or(0.0) as usize); + let rgba = Uint8Array::new(&get(&data, "rgba")).to_vec(); + let hist = Uint32Array::new(&get(&data, "hist")).to_vec(); + match Rgba8::from_bytes(wd, ht, &rgba) { + Some(image) if hist.len() == 1025 => { + let ch = |k: usize| hist[k * 256..(k + 1) * 256].to_vec(); + let histogram = Histogram { r: ch(0), g: ch(1), b: ch(2), luma: ch(3), total: hist[1024] }; + Ok(Rendered { image, histogram }) + } + _ => Err("render worker: bad image".to_string()), + } + } else { + Err(get(&data, "err").as_string().unwrap_or_else(|| "render worker failed".into())) + }; + if let Ok(r) = &rendered + && let Some((cache, key)) = &b.cache + { + cache.put(*key, Arc::new(r.image.clone())); + let hex = key.to_string(); + let stored = get(&data, "stored").as_f64().unwrap_or(0.0) as u64; + if get(&data, "cacheMiss").is_truthy() { + g.index.remove(&hex); + } + if stored > 0 { + g.index.insert(&hex, stored); + let gone = g.index.prune(DISK_CACHE_BYTES); + if !gone.is_empty() + && let Some(be) = g.backend.clone() + { + log::info!("thumbnail cache: pruning {} files", gone.len()); + wasm_bindgen_futures::spawn_local(async move { + for k in gone { + let _ = be.remove(&thumb_storage_key(&k)).await; + } + }); + } + } + } + g.affinity.insert((b.photo, b.level), i); + g.remote_done += 1; + g.done.push((b.slot, RenderResult { photo: b.photo, level: b.level, key: b.key, rendered, loaded: None }, ms)); + } + + /// The thumbnail index as JSON if it changed since the last call (the host saves it). + pub fn take_index_if_dirty(&self) -> Option> { + let mut g = self.0.borrow_mut(); + if !g.index.dirty { + return None; + } + g.index.dirty = false; + Some(g.index.to_json()) + } + + /// (workers alive, workers ready, jobs done remotely, jobs done inline). + pub fn stats(&self) -> (usize, usize, u64, u64) { + let g = self.0.borrow(); + let alive = g.workers.iter().filter(|w| !w.dead).count(); + let ready = g.workers.iter().filter(|w| w.ready && !w.dead).count(); + (alive, ready, g.remote_done, g.inline_done) + } +} + +impl RenderOffload for Workers { + fn try_start(&mut self, slot: Slot, job: RenderJob) -> Option { + let mut g = self.0.borrow_mut(); + let g = &mut *g; + // rendered thumbnail in memory: no worker needed + if let Some((cache, key)) = &job.cache + && let Some(img) = cache.get(*key) + { + let image = Arc::unwrap_or_clone(img); + let histogram = Histogram::of_srgb8(&image); + let r = RenderResult { photo: job.photo, level: job.level, key: job.key, rendered: Ok(Rendered { image, histogram }), loaded: None }; + g.done.push((slot, r, 0.0)); + return None; + } + if g.workers.iter().all(|w| w.dead) { + // no workers: render here, one job per frame (as without workers) + if g.inline_this_frame { + return Some(job); + } + g.inline_this_frame = true; + let t0 = now(); + let r = job.run(); + g.inline_done += 1; + g.done.push((slot, r, now() - t0)); + return None; + } + let free = |w: &W| w.ready && !w.dead && w.busy.is_none(); + let pick = match g.affinity.get(&(job.photo, job.level)) { + // a decoded preview-level source, or the loupe's stage cache, is worth waiting for; a + // thumbnail isn't + Some(&i) if g.workers.get(i).is_some_and(free) => Some(i), + Some(&i) if (job.level == SourceLevel::Preview || job.stages.is_some()) && g.workers.get(i).is_some_and(|w| !w.dead) => None, + _ => g.workers.iter().position(free), + }; + let Some(i) = pick else { return Some(job) }; + // decided here, so the next job for this photo follows even before this one finishes + g.affinity.insert((job.photo, job.level), i); + let thumb_cached = job.cache.as_ref().is_some_and(|(_, k)| g.index.touch(&k.to_string())); + let wire = WireJob::from_job(&job, thumb_cached, &format!("{slot:?}")); + let Ok(text) = serde_json::to_string(&wire) else { return Some(job) }; + g.next_id = g.next_id.wrapping_add(1); + let id = g.next_id; + let msg = Object::new(); + set(&msg, "id", &id.into()); + set(&msg, "job", &text.into()); + let w = &mut g.workers[i]; + if let Err(e) = w.worker.post_message(&msg) { + log::warn!("render worker {i}: {e:?}"); + return Some(job); + } + w.busy = Some(Busy { id, slot, photo: job.photo, level: job.level, key: job.key, cache: job.cache }); + None + } + + fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)> { + let mut g = self.0.borrow_mut(); + g.inline_this_frame = false; + std::mem::take(&mut g.done) + } +} diff --git a/apps/lightcraft-web/worker.js b/apps/lightcraft-web/worker.js new file mode 100644 index 00000000..3a02399a --- /dev/null +++ b/apps/lightcraft-web/worker.js @@ -0,0 +1,15 @@ +// LightCraft render worker: a second instance of the app's wasm module that renders jobs posted +// by the page (see apps/lightcraft-web/src/workers.rs). The first message carries the compiled +// WebAssembly.Module and the storage backend kind; the module then takes over `onmessage`. +import init, { worker_main } from "./lightcraft_web.js"; + +self.onmessage = async (e) => { + self.onmessage = null; + try { + await init({ module_or_path: e.data.module }); + worker_main(e.data.store); + } catch (err) { + // surfaces as the Worker's `error` event on the page, which falls back to inline rendering + setTimeout(() => { throw err; }); + } +}; diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index a827b2c6..fa3d72b1 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -167,7 +167,13 @@ impl MediaCache { (Source::Demo { .. }, SourceLevel::Full) => p.width.max(p.height).max(1) as usize, _ => usize::MAX, }); - match &p.source { + self.origin_ref(&p.source, max_edge) + } + + /// How to load `origin` at most `max_edge` pixels long, ignoring decoded sources in memory (a + /// render worker in another wasm instance builds its sources from this). + pub fn origin_ref(&mut self, origin: &Source, max_edge: usize) -> SourceRef { + match origin { Source::Demo { scene } => { if self.scenes.is_empty() { self.scenes = lightcraft_scenes::demo_library(); @@ -188,6 +194,9 @@ pub struct RenderJob { pub photo: PhotoId, pub level: SourceLevel, pub source: SourceRef, + /// Where the photo's pixels come from (for executors that can't share `source`, e.g. a web + /// worker). + pub origin: Source, pub info: SourceInfo, pub settings: Arc, pub request: RenderRequest, @@ -303,7 +312,7 @@ impl crate::Session { let k = Hasher128::new().str(&content_key(&p)).u64(settings.hash64()).u64(b as u64).u64(RENDER_CACHE_VERSION).finish(); (self.media.rendered.clone(), k) }); - Some(RenderJob { photo: id, level, source, info: source_info(&p), settings, request, key, cache, stages: None }) + Some(RenderJob { photo: id, level, source, origin: p.source.clone(), info: source_info(&p), settings, request, key, cache, stages: None }) } /// Accept a finished job's loaded source into the cache. diff --git a/crates/preview/src/disk.rs b/crates/preview/src/disk.rs index ccf0d480..995709a0 100644 --- a/crates/preview/src/disk.rs +++ b/crates/preview/src/disk.rs @@ -52,7 +52,7 @@ impl DiskCache { self.misses.fetch_add(1, Ordering::Relaxed); return None; }; - match decode(&bytes) { + match decode_jpeg(&bytes) { Some(img) => { self.hits.fetch_add(1, Ordering::Relaxed); // refresh recency for pruning @@ -71,7 +71,7 @@ impl DiskCache { } pub fn put(&self, key: Hash128, img: &Rgba8) { - let Some(bytes) = encode(img) else { return }; + let Some(bytes) = encode_jpeg(img) else { return }; let p = self.path(key); let Some(parent) = p.parent() else { return }; if std::fs::create_dir_all(parent).is_err() { @@ -144,7 +144,8 @@ impl DiskCache { } } -fn encode(img: &Rgba8) -> Option> { +/// Encode a thumbnail as the cache stores it (JPEG, RGB, 4:2:0). +pub fn encode_jpeg(img: &Rgba8) -> Option> { if img.width == 0 || img.height == 0 || img.width > u16::MAX as usize || img.height > u16::MAX as usize { return None; } @@ -159,7 +160,8 @@ fn encode(img: &Rgba8) -> Option> { Some(out) } -fn decode(bytes: &[u8]) -> Option { +/// Decode a cached thumbnail (see [`encode_jpeg`]). +pub fn decode_jpeg(bytes: &[u8]) -> Option { use zune_core::bytestream::ZCursor; use zune_core::colorspace::ColorSpace; use zune_core::options::DecoderOptions; diff --git a/crates/preview/src/lib.rs b/crates/preview/src/lib.rs index 478c784d..5fdcfc5e 100644 --- a/crates/preview/src/lib.rs +++ b/crates/preview/src/lib.rs @@ -19,7 +19,7 @@ pub mod pool; use std::path::Path; use std::sync::{Arc, Mutex}; -pub use disk::DiskCache; +pub use disk::{DiskCache, decode_jpeg, encode_jpeg}; pub use hash::{Hash128, Hasher128, hash_bytes}; use lightcraft_raster::Rgba8; pub use lru::Lru; diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 46c57afb..99815866 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -4,7 +4,9 @@ //! image…). Requests are deduplicated per slot (a newer request replaces a queued older one) and //! prioritised (loupe first, then on-screen thumbnails, then prefetch); thumbnails scrolled far //! out of view are dropped from the queue. Thumbnail jobs hit the engine's preview cache (memory + -//! disk) before rendering. On wasm the jobs run inline, one per frame. +//! disk) before rendering. On wasm the jobs run inline, one per frame, unless the host installs a +//! [`RenderOffload`] (the browser build's Web Workers): then queued jobs are handed to it as it has +//! room, and its results are collected every frame. use std::collections::HashMap; @@ -35,8 +37,29 @@ pub struct Tex { pub pixels: Option>, } +/// Runs render jobs outside this thread's job pool (the browser build: Web Workers, each with its +/// own wasm instance). The [`Renderer`] keeps the queue (priorities, per-slot de-duplication) and +/// hands jobs over one at a time. +pub trait RenderOffload { + /// Start `job` if an executor is free; otherwise give it back (`Some`: it stays queued). + fn try_start(&mut self, slot: Slot, job: RenderJob) -> Option; + /// Finished jobs since the last call: (slot, result, run time in ms). + fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)>; +} + +struct Queued { + slot: Slot, + priority: u32, + seq: u64, + job: RenderJob, +} + pub struct Renderer { pool: JobPool, + /// Jobs run elsewhere (see [`RenderOffload`]); `queue` holds the ones not started yet. + offload: Option>, + queue: Vec, + seq: u64, /// Slot → (key, priority) of the request in flight (queued or running). pending: HashMap, pub textures: HashMap, @@ -55,6 +78,9 @@ impl Default for Renderer { let threads = if cfg!(target_arch = "wasm32") { 0 } else { JobPool::::default_threads().min(6) }; Renderer { pool: JobPool::new(threads), + offload: None, + queue: Vec::new(), + seq: 0, pending: HashMap::new(), textures: HashMap::new(), last_main_ms: 0.0, @@ -66,6 +92,15 @@ impl Default for Renderer { } impl Renderer { + /// Run jobs through `offload` from now on (instead of the job pool / inline). + pub fn set_offload(&mut self, offload: Box) { + self.offload = Some(offload); + } + + fn is_queued(&self, slot: Slot) -> bool { + self.queue.iter().any(|q| q.slot == slot) || self.pool.is_queued(slot) + } + /// Is the slot's current texture (or pending request) already for `key`? pub fn is_current(&self, slot: Slot, key: u64) -> bool { self.textures.get(&slot).is_some_and(|t| t.key == key) || self.pending.get(&slot).is_some_and(|p| p.0 == key) @@ -78,18 +113,38 @@ impl Renderer { } if let Some(&(key, prio)) = self.pending.get(&slot) && key == job.key - && (prio == priority || !self.pool.is_queued(slot)) + && (prio == priority || !self.is_queued(slot)) { return; } self.pending.insert(slot, (job.key, priority)); - let key = job.key; + // (an offload keeps its own per-view stage caches; the flag tells it to) let job = if matches!(slot, Slot::Main | Slot::Before) { job.with_stages(self.stages.entry(slot).or_default().clone()) } else { job }; + if self.offload.is_some() { + self.seq += 1; + self.queue.retain(|q| q.slot != slot); + self.queue.push(Queued { slot, priority, seq: self.seq, job }); + self.dispatch(); // an idle worker starts now rather than next frame + return; + } + let key = job.key; self.pool.submit(slot, key, priority, Box::new(move || job.run())); } pub fn queued(&self) -> usize { - self.pool.queued() + self.pool.queued() + self.queue.len() + } + + /// Hand queued jobs to the offload (best first) while it has room. + fn dispatch(&mut self) { + let Some(off) = self.offload.as_mut() else { return }; + while let Some(i) = self.queue.iter().enumerate().max_by_key(|(_, q)| (q.priority, q.seq)).map(|(i, _)| i) { + let q = self.queue.swap_remove(i); + if let Some(job) = off.try_start(q.slot, q.job) { + self.queue.push(Queued { job, ..q }); + break; + } + } } /// Requests queued or running. @@ -113,9 +168,16 @@ impl Renderer { }; #[cfg(not(target_arch = "wasm32"))] let inline_ms = 0.0; - let mut changed = false; + let mut finished = Vec::new(); while let Some(done) = self.pool.try_recv() { - let (slot, r, ms) = (done.slot, done.result, if done.ms > 0.0 { done.ms } else { inline_ms }); + finished.push((done.slot, done.result, if done.ms > 0.0 { done.ms } else { inline_ms })); + } + if let Some(off) = self.offload.as_mut() { + finished.extend(off.finished()); + } + self.dispatch(); + let mut changed = false; + for (slot, r, ms) in finished { session.accept(&r); self.completed += 1; if self.pending.get(&slot).is_some_and(|p| p.0 == r.key) { @@ -172,6 +234,14 @@ impl Renderer { for s in dropped { self.pending.remove(&s); } + let pending = &mut self.pending; + self.queue.retain(|q| match q.slot { + Slot::Thumb(id) if !keep.contains(&id) && q.priority <= 10 => { + pending.remove(&q.slot); + false + } + _ => true, + }); let thumbs = self.textures.keys().filter(|s| matches!(s, Slot::Thumb(_))).count(); if thumbs <= max { return; diff --git a/docs/web.md b/docs/web.md index e60263ff..3f8be1d9 100644 --- a/docs/web.md +++ b/docs/web.md @@ -18,42 +18,86 @@ 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 # → /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://`. If `wasm-opt` +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 (release + thin LTO, no debug info). +## Deploying: headers + +`cargo xtask web --serve` sends these on every response, and a production server should too: + +``` +Cross-Origin-Opener-Policy: same-origin +Cross-Origin-Embedder-Policy: require-corp +Cross-Origin-Resource-Policy: same-origin +``` + +COOP + COEP make the page *cross-origin isolated* (`crossOriginIsolated === true`). That is +**required** for `SharedArrayBuffer`, i.e. for a future wasm-threads build (`+atomics`, nightly +`build-std`), and gives full-resolution `performance.now()`. The current render workers (below) +don't share memory, so the app also runs without them; with COEP on, every subresource must be +same-origin or send CORP/CORS headers (the bundle has no third-party resources). + ## What works -- **Demo library on load.** The procedural scenes from `lightcraft-scenes` are loaded at - start, so the page always has something in it. +- **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). + - `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. The app asks for persistent storage + (`navigator.storage.persist()`); without it the browser may evict the data under storage + pressure. URL options: `?store=idb` forces IndexedDB, `?store=memory` keeps nothing, `?reset` + deletes the stored library first. - **Importing photos with no filesystem.** *File ▸ Add Photos…* (⌘⇧I) opens the - browser's file picker. You can also drop files anywhere on the page. The bytes are 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. + browser's file picker. You can also drop files anywhere on the page. The bytes are written to + storage, then imported and decoded by the same engine code as the desktop app + (`lightcraft_engine::files::{probe_bytes, load_bytes}`): JPEG/PNG/TIFF/WebP and the supported + raw formats. "Copy into library" is the same as "Add" here. +- **Rendering in Web Workers.** Renders don't run on the main thread: up to four dedicated + workers (`hardwareConcurrency − 1`, `?workers=N` to override, `?workers=0` for the old inline + path) each run a second instance of the same wasm module. `index.html` compiles the module once + and posts the compiled `WebAssembly.Module` to `worker.js`. A job crosses as JSON (the photo's + source, develop settings, request); the worker reads the original or the cached thumbnail from + storage itself, keeps decoded sources and the loupe's stage cache, renders, and transfers the + RGBA bytes back. Jobs for a photo go back to the worker that decoded it. This needs no wasm + threads or `SharedArrayBuffer`. If no worker starts, rendering falls back to the main thread. - **Every develop control** (sliders, curves, mixer, grading, masking, crop…) works as it does on the desktop, since it's the same crate. - **Export downloads the file.** *Export…* (⌘⇧E) runs the same `app.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. +- **Automation.** `await lightcraft.command("library.info", "{}")` runs any engine or UI command + by id on the next frame and resolves to the JSON result (`web.stats` reports storage, workers + and the render queue). This is how the headless-Chrome checks drive the page. ## 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. +- **Exports and auto-adjustments run on the main thread** (they need the original's pixels + there). Right after a reload, the first such command on a photo whose original isn't in memory + yet fails with "still loading" and works a moment later. +- **Durability is "a frame later", not fsync-before-return:** a crash or tab kill in the few + milliseconds between a command and its flush loses that command. +- **Preset files** (import/export `.json`) aren't wired to browser pickers yet. - **Control channel / MCP.** These are desktop-only, because they need a TCP socket. - **AVIF export** is untested in the browser; it's the one encoder that may not be wasm-safe. diff --git a/xtask/src/web.rs b/xtask/src/web.rs index 18218f44..87abf6ce 100644 --- a/xtask/src/web.rs +++ b/xtask/src/web.rs @@ -1,6 +1,7 @@ //! `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). +//! into `/web/` (index.html + worker.js + lightcraft_web.js + lightcraft_web_bg.wasm). +//! `--serve [port]` then serves that folder with a tiny static HTTP server (std only) that sends +//! the cross-origin isolation headers (COOP/COEP). use std::io::{BufRead, BufReader, Write}; use std::net::{TcpListener, TcpStream}; @@ -11,6 +12,9 @@ use crate::{cargo, metadata, root, run as step}; const TARGET: &str = "wasm32-unknown-unknown"; +/// Files copied from `apps/lightcraft-web/` into the bundle as they are. +const STATIC_FILES: [&str; 2] = ["index.html", "worker.js"]; + /// 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}"))?; @@ -72,8 +76,9 @@ pub fn run(args: &[&str]) -> Result<(), String> { 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}"))?; + for f in STATIC_FILES { + std::fs::copy(root().join("apps/lightcraft-web").join(f), out.join(f)).map_err(|e| format!("copy {f}: {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); @@ -100,6 +105,17 @@ fn mime(path: &Path) -> &'static str { } } +/// Response headers every file gets. `Cross-Origin-Opener-Policy: same-origin` + +/// `Cross-Origin-Embedder-Policy: require-corp` make the page cross-origin isolated +/// (`crossOriginIsolated == true`): required for `SharedArrayBuffer` (a wasm-threads build) and +/// for full-resolution `performance.now()`. The current worker design (separate wasm instances) +/// works without them; they are sent so the dev server matches the recommended deployment. +pub const ISOLATION_HEADERS: [(&str, &str); 3] = [ + ("Cross-Origin-Opener-Policy", "same-origin"), + ("Cross-Origin-Embedder-Policy", "require-corp"), + ("Cross-Origin-Resource-Policy", "same-origin"), +]; + /// 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}"))?; @@ -134,11 +150,12 @@ fn handle(mut stream: TcpStream, dir: &Path) -> std::io::Result<()> { ("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() - )?; + let mut head = format!("HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nCache-Control: no-cache\r\n", body.len()); + for (k, v) in ISOLATION_HEADERS { + head.push_str(&format!("{k}: {v}\r\n")); + } + head.push_str("Connection: close\r\n\r\n"); + stream.write_all(head.as_bytes())?; if method != "HEAD" { stream.write_all(&body)?; }