M15.2: web app depth (WEB-1): browser storage, Web Audio, worker renders, WebGPU viewer, PWA

- Persistence: settings, shortcuts, recent projects, imported media, saved projects and
  auto-saves live in OPFS (IndexedDB fallback) through an in-memory mirror flushed in the
  background (apps/effectcraft-web/src/store.rs, persist.rs); ConfigStore gains FileOps so
  auto-save / recovery work in the browser store; the session snapshot restores the last
  project + editor state on reload; File > Open Recent works from storage.
- Audio: Web Audio output (AudioWorklet, ScriptProcessor fallback) fed by AudioPlayback::pump
  on wasm32; A/V sync from AudioContext.currentTime; meters; unlock on first gesture.
- Background jobs: engine::offload (WorkerRequest/WorkerReply protocol, Offload trait,
  run_request); Render Queue, tracker, mask tracker, Warp Stabilizer and Roto Freeze run in Web
  Workers (a second engine instance on the shared compiled module), progress streamed back;
  cancel terminates the worker.
- GPU: WebGPU viewer frames drawn straight from the compositor's texture (gpu_display on
  wasm32 when the viewer draws plainly); async readback (Gpu::read_display_async) for pixels.
- PWA: manifest, service worker precaching the build (xtask fills list/version), icons copied
  from assets/app-icon; COOP/COEP kept from cached responses; offline reload works.
- window.effectcraft: saveToBrowser, listStored, removeStored, flush; info() reports storage,
  audio, gpu, workers.
- Tests: native store + protocol + offload (render, track, cancel) tests; smoke.mjs extended
  (GPU path, worker render responsiveness, audio gesture + playback, reload persistence,
  Open Recent, offline reload).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 23:42:00 -04:00
co-authored by Claude Opus 5.5
parent c406b71cba
commit 0bee1ab2da
41 changed files with 3230 additions and 319 deletions
+59
View File
@@ -92,9 +92,68 @@ pub fn info() -> JsValue {
}
v["version"] = json!(env!("CARGO_PKG_VERSION"));
v["threads"] = json!(false);
v["workers"] = from_js(&crate::worker::stats());
v["audio"] = from_js(&crate::audio::state());
if let Some(e) = crate::persist::last_error() {
v["storage"]["error"] = json!(e);
}
to_js(&v)
}
/// Save the project to browser storage without downloading it: `path` (default: its current
/// path, or `/<project name>.ecproj`). Resolves with `{path, bytes}`.
#[wasm_bindgen(js_name = saveToBrowser)]
pub fn save_to_browser(path: Option<String>) -> js_sys::Promise {
js_sys::Promise::new(&mut |resolve, reject| {
let path = path.clone();
crate::post(move |app| {
let p = path.or_else(|| app.session.path.clone()).unwrap_or_else(|| format!("/{}.ecproj", app.session.project_name()));
let p = if p.starts_with('/') { p } else { format!("/{p}") };
crate::files::save_quietly(&p);
match app.session.execute("file.saveAs", json!({"path": p})) {
Ok(v) => {
let _ = resolve.call1(&JsValue::NULL, &to_js(&v));
}
Err(e) => {
let _ = reject.call1(&JsValue::NULL, &JsValue::from_str(&e.to_string()));
}
}
});
})
}
/// What browser storage holds: `{backend, usage, quota, persisted, files: [{path, size,
/// modified}], config: [name]}` (render outputs, which are not stored, are left out).
#[wasm_bindgen(js_name = listStored)]
pub async fn list_stored() -> JsValue {
let est = wasm_bindgen_futures::JsFuture::from(crate::persist::store_estimate()).await.map(|v| from_js(&v)).unwrap_or(json!({}));
let files: Vec<Value> = crate::files::STORE
.files()
.into_iter()
.filter(|f| f.3)
.map(|(path, size, modified, _)| json!({"path": path, "size": size, "modified": modified}))
.collect();
let config: Vec<Value> =
crate::files::STORE.lock().list(crate::store::CONFIG).into_iter().map(|(k, ..)| json!(k.trim_start_matches(crate::store::CONFIG))).collect();
let mut v = est;
v["files"] = json!(files);
v["config"] = json!(config);
v["pending"] = json!(crate::files::STORE.lock().has_pending());
to_js(&v)
}
/// Remove a stored file (media, project, auto-save). Resolves with whether it existed.
#[wasm_bindgen(js_name = removeStored)]
pub fn remove_stored(path: String) -> bool {
crate::files::STORE.remove_file(&path)
}
/// Resolves once every change is written to browser storage.
#[wasm_bindgen]
pub fn flush() -> js_sys::Promise {
crate::persist::flushed()
}
/// Settle the promises whose replies arrived (called every frame).
pub(crate) fn poll_replies() {
PENDING.with(|p| {