M15.1: web build (eframe wasm) + xtask web

- apps/effectcraft-web (L6): eframe web runner booting the shared EffectcraftApp with a browser
  session: in-memory file table, <input type=file> pickers and drag-and-drop for open/import,
  downloads for Save and Render Queue outputs (several files as one stored .zip), and the
  window.effectcraft promise API over the control channel. Demo project on start.
- wasm32 runtime fixes: web-time for Instant/SystemTime (render, engine, export, ui-egui);
  Frames renders queued jobs on the UI thread (Frames::pump) instead of a thread pool;
  Render Queue runs inline; relative outputs resolve under / ; control PNGs inline.
- effectcraft-export: per-job output Sink (files to memory instead of disk); host FileExporter
  carries it; test that sink exports touch no file.
- xtask web [--dev] [--serve PORT] (wasm-bindgen 0.2.129, optional wasm-opt, COOP/COEP server);
  effectcraft-web in WEB_CRATES. docs/web.md, headless Chrome smoke test, favicon (+attribution).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 00:19:35 -04:00
co-authored by Claude Opus 5.5
parent c925ac8ae8
commit 31f192e2a9
30 changed files with 1361 additions and 92 deletions
+7 -4
View File
@@ -125,11 +125,11 @@ struct Work {
}
fn unix_now() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
fn run_work(w: Work, shared: &JobShared) {
let t0 = std::time::Instant::now();
let t0 = web_time::Instant::now();
{
let mut s = lock(&shared.state);
s.items_total = w.items.len();
@@ -139,7 +139,7 @@ fn run_work(w: Work, shared: &JobShared) {
lock(&shared.updates).push(ItemUpdate::Finished { id: item.id, status: RenderStatus::UserStopped, seconds: 0.0, output: None });
continue;
}
let t1 = std::time::Instant::now();
let t1 = web_time::Instant::now();
lock(&shared.updates).push(ItemUpdate::Started { id: item.id, unix: unix_now() });
{
let mut s = lock(&shared.state);
@@ -211,7 +211,8 @@ impl Session {
return Some(p);
}
let base = self.path.as_deref().and_then(|s| std::path::Path::new(s).parent().map(|d| d.to_path_buf())).filter(|d| !d.as_os_str().is_empty());
let base = base.or_else(|| std::env::current_dir().ok())?;
// (the web has no working directory: relative outputs land in its virtual root)
let base = base.or_else(|| std::env::current_dir().ok()).or_else(|| cfg!(target_arch = "wasm32").then(|| "/".into()))?;
Some(base.join(path).to_string_lossy().to_string())
}
@@ -227,7 +228,9 @@ impl Session {
/// Start rendering every queued item. `wait`: block until done (otherwise a background thread
/// renders; call [`Session::poll_render`] regularly). Returns the ids being rendered.
/// wasm32 has no threads: the render always runs to completion before this returns.
pub fn start_render(&mut self, wait: bool) -> Result<Vec<u64>, String> {
let wait = wait || cfg!(target_arch = "wasm32");
if self.is_rendering() {
return Err("a render is already in progress".into());
}