mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 02:27:00 -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>
134 lines
6.2 KiB
Rust
134 lines
6.2 KiB
Rust
//! Browser storage management: what the web app keeps in the browser (projects, imported media,
|
|
//! auto-saves and settings in the Origin Private File System or IndexedDB; the disk cache's
|
|
//! frames in the Origin Private File System), how much of the origin's quota that uses, whether
|
|
//! the browser keeps it under storage pressure, and clearing it.
|
|
//!
|
|
//! The browser's storage APIs are asynchronous and the engine's commands are not, so a
|
|
//! [`StorageHost`] answers from its latest snapshot (refreshing it in the background) and
|
|
//! starts requests that finish later; the snapshot reports their outcome.
|
|
//!
|
|
//! Commands (agents, Settings ▸ Disk ▸ Browser Storage): `storage.info`, `storage.persist`,
|
|
//! `storage.clear {what}`. Without a host (the desktop) `storage.info` reports
|
|
//! `{available: false}` and the others fail.
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::commands::{CommandSpec, str_p};
|
|
use crate::{Result, Session, cmd};
|
|
|
|
/// What can be cleared ([`StorageHost::clear`]).
|
|
pub const CLEARABLE: &[&str] = &["diskCache", "media", "projects", "autoSaves", "all"];
|
|
|
|
/// The browser's storage, as the web app manages it.
|
|
pub trait StorageHost: Send + Sync {
|
|
/// The latest snapshot: `{backend, usage, quota, persisted, files: {count, bytes, media,
|
|
/// projects, autoSaves}, diskCache: {enabled, entries, bytes, maxBytes, hits, misses, writes,
|
|
/// evictions}}` (`usage` / `quota` from `navigator.storage.estimate()`, in bytes). Asks for
|
|
/// a fresh one.
|
|
fn info(&self) -> Value;
|
|
/// Ask the browser to keep the storage under pressure (`navigator.storage.persist()`); the
|
|
/// answer arrives in a later [`StorageHost::info`] (`persisted`).
|
|
fn request_persist(&self) -> Value;
|
|
/// Delete stored data: `diskCache` (cached frames), `media` (imported footage), `projects`
|
|
/// (saved projects), `autoSaves`, or `all` of them (settings stay). Returns what was
|
|
/// removed: `{cleared, entries, bytes}`.
|
|
fn clear(&self, what: &str) -> std::result::Result<Value, String>;
|
|
}
|
|
|
|
fn host(s: &Session, cmd: &str) -> Result<std::sync::Arc<dyn StorageHost>> {
|
|
s.storage.clone().ok_or_else(|| crate::commands::bad(cmd, "browser storage is only managed in the web app"))
|
|
}
|
|
|
|
fn info(s: &mut Session, _: &Value) -> Result<Value> {
|
|
Ok(match &s.storage {
|
|
Some(h) => {
|
|
let mut v = h.info();
|
|
if let Some(o) = v.as_object_mut() {
|
|
o.insert("available".into(), json!(true));
|
|
}
|
|
v
|
|
}
|
|
None => json!({"available": false}),
|
|
})
|
|
}
|
|
|
|
fn persist(s: &mut Session, _: &Value) -> Result<Value> {
|
|
Ok(host(s, "storage.persist")?.request_persist())
|
|
}
|
|
|
|
fn clear(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let what = str_p(p, "what").unwrap_or("diskCache");
|
|
if !CLEARABLE.contains(&what) {
|
|
return Err(crate::commands::bad("storage.clear", format!("what: one of {}", CLEARABLE.join(", "))));
|
|
}
|
|
let h = host(s, "storage.clear")?;
|
|
let r = h.clear(what).map_err(|e| crate::commands::bad("storage.clear", e))?;
|
|
s.toast(format!("Cleared browser storage: {what}"));
|
|
Ok(r)
|
|
}
|
|
|
|
fn has_host(s: &Session) -> std::result::Result<(), String> {
|
|
if s.storage.is_some() { Ok(()) } else { Err("browser storage is only managed in the web app".into()) }
|
|
}
|
|
|
|
pub(crate) fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
crate::query!(
|
|
"storage.info",
|
|
"Browser Storage",
|
|
"{} → {available, backend, usage, quota, persisted, files: {count, bytes, media, projects, autoSaves}, diskCache: {enabled, entries, bytes, maxBytes, hits, misses, writes, evictions}}",
|
|
info
|
|
),
|
|
cmd!("storage.persist", "Request Persistent Storage", [], None, "{} → {requested, persisted}", has_host, persist),
|
|
cmd!("storage.clear", "Clear Browser Storage", [], None, "{what: diskCache|media|projects|autoSaves|all} → {cleared, entries, bytes}", has_host, clear),
|
|
]
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use super::*;
|
|
|
|
#[derive(Default)]
|
|
struct Mock {
|
|
cleared: Mutex<Vec<String>>,
|
|
persisted: Mutex<bool>,
|
|
}
|
|
|
|
impl StorageHost for Mock {
|
|
fn info(&self) -> Value {
|
|
json!({"backend": "opfs", "usage": 1000, "quota": 100000, "persisted": *self.persisted.lock().unwrap(),
|
|
"diskCache": {"enabled": true, "entries": 3, "bytes": 300, "maxBytes": 1 << 30}})
|
|
}
|
|
fn request_persist(&self) -> Value {
|
|
*self.persisted.lock().unwrap() = true;
|
|
json!({"requested": true})
|
|
}
|
|
fn clear(&self, what: &str) -> std::result::Result<Value, String> {
|
|
self.cleared.lock().unwrap().push(what.into());
|
|
Ok(json!({"cleared": what, "entries": 3, "bytes": 300}))
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn storage_commands_go_to_the_host() {
|
|
let mut s = Session::default();
|
|
assert_eq!(s.execute_checked("storage.info", json!({})).unwrap(), json!({"available": false}));
|
|
assert!(s.execute_checked("storage.clear", json!({"what": "diskCache"})).is_err());
|
|
let m = Arc::new(Mock::default());
|
|
s.storage = Some(m.clone());
|
|
let i = s.execute_checked("storage.info", json!({})).unwrap();
|
|
assert_eq!((i["available"].clone(), i["backend"].clone(), i["persisted"].clone()), (json!(true), json!("opfs"), json!(false)));
|
|
assert_eq!(s.execute_checked("storage.persist", json!({})).unwrap()["requested"], json!(true));
|
|
assert_eq!(s.execute_checked("storage.info", json!({})).unwrap()["persisted"], json!(true));
|
|
assert!(s.execute_checked("storage.clear", json!({"what": "everything"})).is_err());
|
|
assert_eq!(s.execute_checked("storage.clear", json!({"what": "media"})).unwrap()["cleared"], json!("media"));
|
|
// The disk cache's own commands use the browser's disk cache when there is no folder.
|
|
let st = s.execute_checked("cache.diskStats", json!({})).unwrap();
|
|
assert_eq!((st["enabled"].clone(), st["entries"].clone()), (json!(true), json!(3)));
|
|
assert_eq!(s.execute_checked("edit.purge", json!({"what": "disk"})).unwrap()["diskEntries"], json!(3));
|
|
assert_eq!(*m.cleared.lock().unwrap(), vec!["media".to_string(), "diskCache".to_string()]);
|
|
}
|
|
}
|