mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 10:36:56 -06:00
- GPU in the browser: each frame worker opens its own WebGPU device (Gpu::request_deferred) and renders the GPU compositor and GPU effects with deferred readbacks (effectcraft-gpu deferred): readbacks keyed by content start with map_async and miss, the pass returns placeholders, the layer cache's gate drops inserts, and FrameServer::resume renders again once the device delivered. Backend Auto times whole frames per comp in the workers. Frames with effects go to GPU workers instead of the page. CPU fallback without WebGPU in workers (?nogpuworkers). - Fix: a WGSL constant (3.40282347e38) that Chrome rejects invalidated every GPU kernel in Chrome (the page's compositor too); a test checks every float literal fits f32. - Disk cache in the Origin Private File System: workers write sealed frame entries with sync access handles (temp file + move), the page keeps the DiskIndex (LRU under the settings' limit, at most half the quota) and reads hits instead of rendering; survives reloads; blue cache bar. - Storage manager: Settings ▸ Disk ▸ Browser Storage (usage/quota, persistent storage, Clear buttons, automation ids) and storage.info / storage.persist / storage.clear (StorageHost). - Docs: web.md (GPU workers, deferred passes, disk cache, storage manager, threading design: one engine instance per worker), architecture, preferences, parity (WEB-1), roadmap. - Tests: deferred passes vs CPU on a real device, placeholder gating, frame server passes and Auto, GPU worker routing + disk cache across a reload, storage commands and widgets, store usage by kind; smoke test: GPU effect frames in workers with pixel parity, disk hits after a reload, storage manager. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
125 lines
4.0 KiB
Rust
125 lines
4.0 KiB
Rust
//! The storage manager ([`StorageHost`], `storage.*` commands, Settings ▸ Disk ▸ Browser
|
|
//! Storage): the origin's usage and quota (`navigator.storage.estimate()`), persistent storage
|
|
//! (`navigator.storage.persist()`), what the store holds by kind ([`crate::store::FileKind`])
|
|
//! and the disk cache ([`crate::diskcache`]); clearing each.
|
|
//!
|
|
//! The browser answers asynchronously: [`StorageHost::info`] returns the latest estimate and
|
|
//! asks for a new one at most once a second.
|
|
|
|
use std::cell::RefCell;
|
|
|
|
use effectcraft_engine::storage::StorageHost;
|
|
use serde_json::{Value, json};
|
|
use wasm_bindgen::prelude::*;
|
|
use wasm_bindgen_futures::JsFuture;
|
|
|
|
use crate::store::FileKind;
|
|
|
|
#[wasm_bindgen(module = "/js/host.js")]
|
|
extern "C" {
|
|
#[wasm_bindgen(js_name = storePersist)]
|
|
fn store_persist() -> js_sys::Promise;
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct Estimate {
|
|
usage: u64,
|
|
quota: u64,
|
|
persisted: bool,
|
|
/// When the last refresh started (ms).
|
|
asked: f64,
|
|
busy: bool,
|
|
}
|
|
|
|
thread_local! {
|
|
static EST: RefCell<Estimate> = RefCell::new(Estimate::default());
|
|
}
|
|
|
|
/// Refresh the estimate in the background (at most once a second).
|
|
pub fn refresh() {
|
|
let now = js_sys::Date::now();
|
|
let go = EST.with(|e| {
|
|
let mut e = e.borrow_mut();
|
|
if e.busy || now - e.asked < 1000.0 {
|
|
return false;
|
|
}
|
|
e.busy = true;
|
|
e.asked = now;
|
|
true
|
|
});
|
|
if !go {
|
|
return;
|
|
}
|
|
wasm_bindgen_futures::spawn_local(async {
|
|
let v = JsFuture::from(crate::persist::store_estimate()).await.ok();
|
|
EST.with(|e| {
|
|
let mut e = e.borrow_mut();
|
|
e.busy = false;
|
|
if let Some(v) = v {
|
|
let get = |k: &str| js_sys::Reflect::get(&v, &k.into()).ok();
|
|
e.usage = get("usage").and_then(|x| x.as_f64()).unwrap_or(0.0) as u64;
|
|
e.quota = get("quota").and_then(|x| x.as_f64()).unwrap_or(0.0) as u64;
|
|
e.persisted = get("persisted").and_then(|x| x.as_bool()).unwrap_or(false);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/// The origin's quota in bytes, when known.
|
|
pub fn quota() -> Option<u64> {
|
|
EST.with(|e| Some(e.borrow().quota).filter(|q| *q > 0))
|
|
}
|
|
|
|
/// The web app's [`StorageHost`].
|
|
pub struct WebStorage;
|
|
|
|
impl StorageHost for WebStorage {
|
|
fn info(&self) -> Value {
|
|
refresh();
|
|
let (usage, quota, persisted) = EST.with(|e| {
|
|
let e = e.borrow();
|
|
(e.usage, e.quota, e.persisted)
|
|
});
|
|
json!({
|
|
"backend": crate::persist::backend(),
|
|
"usage": usage,
|
|
"quota": quota,
|
|
"persisted": persisted,
|
|
"files": crate::files::STORE.usage(),
|
|
"diskCache": crate::diskcache::stats(),
|
|
"error": crate::persist::last_error(),
|
|
})
|
|
}
|
|
|
|
fn request_persist(&self) -> Value {
|
|
wasm_bindgen_futures::spawn_local(async {
|
|
let granted = JsFuture::from(store_persist()).await.ok().and_then(|v| v.as_bool()).unwrap_or(false);
|
|
EST.with(|e| e.borrow_mut().persisted = granted);
|
|
crate::repaint();
|
|
});
|
|
json!({"requested": true, "persisted": EST.with(|e| e.borrow().persisted)})
|
|
}
|
|
|
|
fn clear(&self, what: &str) -> Result<Value, String> {
|
|
let kinds: Vec<FileKind> = match what {
|
|
"diskCache" => vec![],
|
|
"all" => vec![FileKind::Media, FileKind::Project, FileKind::AutoSave],
|
|
k => vec![FileKind::from_name(k).ok_or_else(|| format!("unknown storage kind {k}"))?],
|
|
};
|
|
let (mut entries, mut bytes) = (0u64, 0u64);
|
|
if matches!(what, "diskCache" | "all") {
|
|
let (n, b) = crate::diskcache::clear();
|
|
entries += n;
|
|
bytes += b;
|
|
}
|
|
for k in kinds {
|
|
let (n, b) = crate::files::STORE.clear_kind(k);
|
|
entries += n;
|
|
bytes += b;
|
|
}
|
|
// The estimate catches up on the next refresh.
|
|
EST.with(|e| e.borrow_mut().asked = 0.0);
|
|
Ok(json!({"cleared": what, "entries": entries, "bytes": bytes}))
|
|
}
|
|
}
|