M0.13: web render workers — pipeline renders off the main thread

Renders run in dedicated module Web Workers, each a second instance of the app's wasm module
(index.html compiles it once and posts the WebAssembly.Module to worker.js). A job crosses as
JSON (WireJob: source, settings, request); the worker reads originals and cached thumbnails from
browser storage itself, keeps decoded sources and per-view stage caches, and transfers RGBA back.
Jobs follow the worker that decoded the photo; the loupe waits for it. No wasm threads or
SharedArrayBuffer needed; falls back to inline rendering if no worker starts (?workers=0).

Thumbnail disk cache on the web: thumbs/<key>.jpg in OPFS/IndexedDB with an index on the main
thread, pruned to the desktop budget (2 GB → 80 %, LRU).
ui-egui: RenderOffload hook in the Renderer (queue/priorities/dedup stay in the UI).
engine: RenderJob.origin + MediaCache::origin_ref; preview: encode_jpeg/decode_jpeg public.
xtask web: copies worker.js; --serve sends COOP/COEP/CORP (documented in docs/web.md).

Headless Chrome, cold load + grid scroll, 24 demo photos: main-thread busy 12.4 s → 0.7–1.1 s,
long tasks 23 (max 1.1 s) → 0; grid filled in 6.3–8.4 s vs 13–16 s; slider job 4 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 08:36:15 -04:00
parent 961cf4afd2
commit 8f2058a124
12 changed files with 986 additions and 50 deletions

View File

@ -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<Vec<String>, String> {
match self {
Backend::Opfs(root) => {

View File

@ -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;

View File

@ -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
//! `<canvas id="lightcraft_canvas">`, 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<usize>,
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<Backend>,
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<String> = 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<String> = 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<Backend>,
workers: Option<Workers>,
flushing: Rc<Cell<bool>>,
bench: Option<Bench>,
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;
}

View File

@ -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/<key>.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<EmbeddedLens>,
pub settings: Arc<DevelopSettings>,
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<String>,
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<String>,
}
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<String> {
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<String, Arc<Rgb32f>>,
media: MediaCache,
stages: HashMap<String, Arc<StageCache>>,
}
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<String> {
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<Rendered, String> {
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<String, (u64, u64)>,
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<u8> {
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<String> {
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"));
}
}

View File

@ -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::<js_sys::Function>().and_then(|f| f.call0(&perf).ok()).and_then(|v| v.as_f64()).unwrap_or(0.0)
}
// ---------------------------------------------------------------------------------------------
// worker side
thread_local! {
static CORE: RefCell<WorkerCore> = 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::<dyn FnMut(web_sys::MessageEvent)>::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<Backend>, msg: JsValue) {
let id = get(&msg, "id");
let t0 = now();
let out = Object::new();
set(&out, "id", &id);
let job: Result<WireJob, String> =
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<PreviewCache>, Hash128)>,
}
struct W {
worker: web_sys::Worker,
ready: bool,
dead: bool,
busy: Option<Busy>,
}
struct Inner {
workers: Vec<W>,
done: Vec<(Slot, RenderResult, f64)>,
next_id: u32,
/// (photo, level) → worker that last decoded it.
affinity: HashMap<(PhotoId, SourceLevel), usize>,
index: ThumbIndex,
backend: Option<Backend>,
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<RefCell<Inner>>);
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<Backend>, 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::<dyn FnMut(web_sys::MessageEvent)>::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::<dyn FnMut(JsValue)>::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<Vec<u8>> {
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<RenderJob> {
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)
}
}

View File

@ -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; });
}
};

View File

@ -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<DevelopSettings>,
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.

View File

@ -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<Vec<u8>> {
/// Encode a thumbnail as the cache stores it (JPEG, RGB, 4:2:0).
pub fn encode_jpeg(img: &Rgba8) -> Option<Vec<u8>> {
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<Vec<u8>> {
Some(out)
}
fn decode(bytes: &[u8]) -> Option<Rgba8> {
/// Decode a cached thumbnail (see [`encode_jpeg`]).
pub fn decode_jpeg(bytes: &[u8]) -> Option<Rgba8> {
use zune_core::bytestream::ZCursor;
use zune_core::colorspace::ColorSpace;
use zune_core::options::DecoderOptions;

View File

@ -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;

View File

@ -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<std::sync::Arc<egui::ColorImage>>,
}
/// 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<RenderJob>;
/// 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<Slot, RenderResult>,
/// Jobs run elsewhere (see [`RenderOffload`]); `queue` holds the ones not started yet.
offload: Option<Box<dyn RenderOffload>>,
queue: Vec<Queued>,
seq: u64,
/// Slot → (key, priority) of the request in flight (queued or running).
pending: HashMap<Slot, (u64, u32)>,
pub textures: HashMap<Slot, Tex>,
@ -55,6 +78,9 @@ impl Default for Renderer {
let threads = if cfg!(target_arch = "wasm32") { 0 } else { JobPool::<Slot, RenderResult>::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<dyn RenderOffload>) {
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;

View File

@ -18,42 +18,86 @@ 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 # → <target>/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)
```
`<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`
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/<content hash>`: the bytes of every imported photo. The catalog refers to them as
`web/<hash>/<file name>`; the main thread keeps recently used originals in memory (≤ 768 MB)
and loads the rest on demand (the active photo is prefetched).
- `thumbs/<key>.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…* (<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.
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…* (<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.
- **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.

View File

@ -1,6 +1,7 @@
//! `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).
//! into `<target>/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<String, String> {
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)?;
}