Files
effectcraft/apps/effectcraft-web/src/store.rs
T
Brandon ThomasandClaude Opus 5.5 1b726349ab M13.24: web app depth (WEB-1) — GPU effects in frame workers, OPFS disk cache, storage manager
- 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>
2026-10-03 18:35:03 -04:00

502 lines
19 KiB
Rust

//! What the browser keeps between visits: the in-memory side of the persistent store.
//!
//! The engine's storage traits are synchronous ([`ConfigStore`], [`FileOps`],
//! [`effectcraft_engine::Services`]); the browser's storage (the Origin Private File System, or
//! IndexedDB where OPFS can't write) is asynchronous. So the app works on a [`Mirror`]: every
//! stored entry is loaded into memory before the app starts, reads and writes hit the mirror
//! synchronously, and writes queue up as pending operations that the web host flushes to the
//! browser in the background (coalesced per key, in order).
//!
//! Keys are namespaced:
//!
//! | Key | What |
//! |---|---|
//! | `config/<name>` | settings (`prefs.json`), shortcut presets, the recovery sentinel, the session snapshot |
//! | `files<path>` | the virtual file table: imported media, saved projects, auto-saves (`/EffectCraft Auto-Save/…`) |
//!
//! Render outputs go into the file table unpersisted (they download instead).
//!
//! This module is plain Rust (no browser APIs) so it is unit-tested natively.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use effectcraft_engine::config::{ConfigStore, FileOps};
/// Prefix of configuration entries.
pub const CONFIG: &str = "config/";
/// Prefix of file-table entries (followed by the absolute path, `files/clip.mp4`).
pub const FILES: &str = "files";
/// The session snapshot (project + editor state), written while the app runs and restored
/// on the next visit.
pub const SNAPSHOT: &str = "session.ecproj";
/// Its metadata ([`SnapshotMeta`]).
pub const SNAPSHOT_META: &str = "session.json";
/// One stored blob.
#[derive(Clone, Debug)]
pub struct Entry {
pub data: Arc<[u8]>,
/// Milliseconds since the Unix epoch.
pub modified: f64,
/// Written to the browser's storage (render outputs are not).
pub persist: bool,
}
/// A queued write for the browser storage: `data` = `None` deletes the key.
#[derive(Clone, Debug, PartialEq)]
pub struct Pending {
pub key: String,
pub data: Option<Arc<[u8]>>,
pub modified: f64,
}
/// The in-memory copy of the store plus the writes not yet flushed.
#[derive(Default)]
pub struct Mirror {
entries: BTreeMap<String, Entry>,
/// Keys changed since the last [`Mirror::take_pending`], in first-change order.
dirty: Vec<String>,
}
impl Mirror {
/// An entry read from the browser's storage at startup (nothing to flush).
pub fn load(&mut self, key: &str, data: Arc<[u8]>, modified: f64) {
self.entries.insert(key.to_string(), Entry { data, modified, persist: true });
}
pub fn put(&mut self, key: &str, data: Arc<[u8]>, modified: f64, persist: bool) {
let was_persisted = self.entries.get(key).is_some_and(|e| e.persist);
self.entries.insert(key.to_string(), Entry { data, modified, persist });
if persist || was_persisted {
self.touch(key);
}
}
pub fn remove(&mut self, key: &str) -> bool {
match self.entries.remove(key) {
Some(e) => {
if e.persist {
self.touch(key);
}
true
}
None => false,
}
}
fn touch(&mut self, key: &str) {
if !self.dirty.iter().any(|k| k == key) {
self.dirty.push(key.to_string());
}
}
pub fn get(&self, key: &str) -> Option<&Entry> {
self.entries.get(key)
}
/// Entries under `prefix`: (key, size, modified, persisted).
pub fn list(&self, prefix: &str) -> Vec<(String, usize, f64, bool)> {
self.entries
.range(prefix.to_string()..)
.take_while(|(k, _)| k.starts_with(prefix))
.map(|(k, e)| (k.clone(), e.data.len(), e.modified, e.persist))
.collect()
}
/// The writes to flush, one per changed key with its latest state (a put, or a delete when
/// the key is gone or no longer persisted).
pub fn take_pending(&mut self) -> Vec<Pending> {
std::mem::take(&mut self.dirty)
.into_iter()
.map(|key| match self.entries.get(&key) {
Some(e) if e.persist => Pending { data: Some(e.data.clone()), modified: e.modified, key },
_ => Pending { key, data: None, modified: 0.0 },
})
.collect()
}
pub fn has_pending(&self) -> bool {
!self.dirty.is_empty()
}
}
/// The key of a file-table path (`/a/b.mov` → `files/a/b.mov`).
pub fn file_key(path: &str) -> String {
if path.starts_with('/') { format!("{FILES}{path}") } else { format!("{FILES}/{path}") }
}
/// The path of a file-table key.
pub fn key_path(key: &str) -> Option<&str> {
key.strip_prefix(FILES).filter(|p| p.starts_with('/'))
}
/// The persistent store: a [`Mirror`] and a hook that wakes the flusher after changes.
#[derive(Default)]
pub struct Store {
pub mirror: Mutex<Mirror>,
/// Called after every change that needs flushing (the web host schedules a flush).
pub on_change: OnceLock<fn()>,
/// Clock in milliseconds since the Unix epoch (tests pin it).
pub clock: OnceLock<fn() -> f64>,
}
impl Store {
pub fn lock(&self) -> std::sync::MutexGuard<'_, Mirror> {
self.mirror.lock().unwrap_or_else(|e| e.into_inner())
}
fn now(&self) -> f64 {
self.clock.get().map_or(0.0, |f| f())
}
fn changed(&self) {
if let Some(f) = self.on_change.get() {
f();
}
}
// ---- file table
/// Store a file under `path` (persisted unless `persist` is false: render outputs).
pub fn put_file(&self, path: &str, data: Arc<[u8]>, persist: bool) {
let now = self.now();
self.lock().put(&file_key(path), data, now, persist);
if persist {
self.changed();
}
}
pub fn file(&self, path: &str) -> Option<Arc<[u8]>> {
self.lock().get(&file_key(path)).map(|e| e.data.clone())
}
pub fn remove_file(&self, path: &str) -> bool {
let r = self.lock().remove(&file_key(path));
self.changed();
r
}
/// Every file: (path, size, modified ms, persisted).
pub fn files(&self) -> Vec<(String, usize, f64, bool)> {
self.lock().list(FILES).into_iter().filter_map(|(k, n, m, p)| Some((key_path(&k)?.to_string(), n, m, p))).collect()
}
/// Stored (persisted) files by kind: `{projects, media, autoSaves}`, each `{count, bytes}`
/// (the storage manager, `storage.info`).
pub fn usage(&self) -> serde_json::Value {
let mut out = serde_json::Map::new();
for k in [FileKind::Project, FileKind::Media, FileKind::AutoSave] {
let (mut count, mut bytes) = (0u64, 0u64);
for (path, n, _, persisted) in self.files() {
if persisted && FileKind::of(&path) == k {
count += 1;
bytes += n as u64;
}
}
out.insert(k.name().into(), serde_json::json!({"count": count, "bytes": bytes}));
}
out.into()
}
/// Delete every stored file of a kind; returns (files, bytes) removed.
pub fn clear_kind(&self, kind: FileKind) -> (u64, u64) {
let (mut count, mut bytes) = (0u64, 0u64);
for (path, n, _, persisted) in self.files() {
if persisted && FileKind::of(&path) == kind {
self.lock().remove(&file_key(&path));
count += 1;
bytes += n as u64;
}
}
if count > 0 {
self.changed();
}
(count, bytes)
}
}
/// What a stored file is (Settings ▸ Disk ▸ Browser Storage).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FileKind {
/// A saved project (`.ecproj` outside the auto-save folder).
Project,
/// Imported footage and everything else.
Media,
/// Auto-saves (`/EffectCraft Auto-Save/…`, or `… auto-save N.ecproj`).
AutoSave,
}
impl FileKind {
pub fn of(path: &str) -> FileKind {
let lower = path.to_ascii_lowercase();
if !lower.ends_with(".ecproj") {
return FileKind::Media;
}
let name = lower.rsplit('/').next().unwrap_or(&lower);
if lower.contains("/effectcraft auto-save/") || name.contains(" auto-save ") { FileKind::AutoSave } else { FileKind::Project }
}
/// `storage.clear` / `storage.info` name.
pub fn name(self) -> &'static str {
match self {
FileKind::Project => "projects",
FileKind::Media => "media",
FileKind::AutoSave => "autoSaves",
}
}
pub fn from_name(n: &str) -> Option<FileKind> {
[FileKind::Project, FileKind::Media, FileKind::AutoSave].into_iter().find(|k| k.name() == n)
}
}
/// Settings and friends ([`ConfigStore`]) in the store; auto-saves ([`FileOps`]) in its file
/// table, under `/` (untitled projects auto-save to `/EffectCraft Auto-Save/`).
pub struct WebConfig {
store: Arc<Store>,
files: WebFiles,
}
impl WebConfig {
pub fn new(store: Arc<Store>) -> WebConfig {
WebConfig { files: WebFiles(store.clone()), store }
}
}
/// [`FileOps`] on the store's file table.
pub struct WebFiles(pub Arc<Store>);
impl ConfigStore for WebConfig {
fn read(&self, name: &str) -> Option<String> {
let m = self.store.lock();
m.get(&format!("{CONFIG}{name}")).map(|e| String::from_utf8_lossy(&e.data).into_owned())
}
fn write(&self, name: &str, data: &str) -> std::io::Result<()> {
let now = self.store.now();
self.store.lock().put(&format!("{CONFIG}{name}"), data.as_bytes().into(), now, true);
self.store.changed();
Ok(())
}
fn remove(&self, name: &str) -> std::io::Result<()> {
self.store.lock().remove(&format!("{CONFIG}{name}"));
self.store.changed();
Ok(())
}
fn list(&self, dir: &str) -> Vec<String> {
let pre = format!("{CONFIG}{}/", dir.trim_end_matches('/'));
let mut v: Vec<String> = self
.store
.lock()
.list(&pre)
.into_iter()
.filter_map(|(k, ..)| k.strip_prefix(&pre).filter(|n| !n.is_empty() && !n.contains('/')).map(str::to_string))
.collect();
v.sort();
v
}
fn dir(&self) -> Option<PathBuf> {
Some(PathBuf::from("/"))
}
fn files(&self) -> &dyn FileOps {
&self.files
}
}
fn path_str(p: &Path) -> String {
p.to_string_lossy().replace('\\', "/")
}
impl FileOps for WebFiles {
fn list(&self, dir: &Path) -> Vec<(String, std::time::SystemTime)> {
let dir = path_str(dir);
let dir = dir.trim_end_matches('/');
self.0
.files()
.into_iter()
.filter_map(|(p, _, m, _)| {
let (parent, name) = p.rsplit_once('/')?;
(parent == dir).then(|| (name.to_string(), std::time::UNIX_EPOCH + std::time::Duration::from_secs_f64(m.max(0.0) / 1000.0)))
})
.collect()
}
fn write(&self, path: &Path, data: &[u8]) -> std::io::Result<()> {
self.0.put_file(&path_str(path), data.into(), true);
Ok(())
}
fn remove(&self, path: &Path) -> std::io::Result<()> {
if self.0.remove_file(&path_str(path)) { Ok(()) } else { Err(std::io::Error::new(std::io::ErrorKind::NotFound, path_str(path))) }
}
fn is_file(&self, path: &Path) -> bool {
self.0.file(&path_str(path)).is_some()
}
}
/// What the session snapshot records besides the project.
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct SnapshotMeta {
/// The project's file (None = untitled).
pub path: Option<String>,
/// Unsaved changes when the snapshot was taken.
pub dirty: bool,
/// Viewer, timeline and selection state.
pub state: Option<serde_json::Value>,
/// Milliseconds since the Unix epoch.
pub saved: f64,
}
#[cfg(test)]
mod tests {
use super::*;
fn store() -> Arc<Store> {
let s = Arc::new(Store::default());
let _ = s.clock.set(|| 1_000.0);
s
}
#[test]
fn usage_by_kind_and_clearing() {
let s = store();
s.put_file("/a.ecproj", Arc::from(&b"proj"[..]), true);
s.put_file("/clip.mp4", Arc::from(&b"0123456789"[..]), true);
s.put_file("/still.png", Arc::from(&b"px"[..]), true);
s.put_file("/EffectCraft Auto-Save/a auto-save 1.ecproj", Arc::from(&b"auto"[..]), true);
s.put_file("/render.gif", Arc::from(&b"out"[..]), false);
let u = s.usage();
assert_eq!(u["projects"], serde_json::json!({"count": 1, "bytes": 4}));
assert_eq!(u["media"], serde_json::json!({"count": 2, "bytes": 12}), "render outputs are not stored");
assert_eq!(u["autoSaves"], serde_json::json!({"count": 1, "bytes": 4}));
assert_eq!(FileKind::from_name("autoSaves"), Some(FileKind::AutoSave));
assert_eq!(FileKind::of("/x/My auto-save 3.ecproj"), FileKind::AutoSave);
s.lock().take_pending();
assert_eq!(s.clear_kind(FileKind::Media), (2, 12));
assert!(s.file("/clip.mp4").is_none() && s.file("/a.ecproj").is_some() && s.file("/render.gif").is_some());
let deleted: Vec<String> = s.lock().take_pending().into_iter().filter(|p| p.data.is_none()).map(|p| p.key).collect();
assert_eq!(deleted.len(), 2, "the deletions are flushed to browser storage");
assert_eq!(s.usage()["media"]["count"], 0);
}
#[test]
fn writes_coalesce_per_key_in_order() {
let mut m = Mirror::default();
m.put("config/a", Arc::from(&b"1"[..]), 1.0, true);
m.put("files/x.png", Arc::from(&b"px"[..]), 2.0, true);
m.put("config/a", Arc::from(&b"2"[..]), 3.0, true);
let p = m.take_pending();
assert_eq!(p.len(), 2);
assert_eq!((p[0].key.as_str(), p[0].data.as_deref()), ("config/a", Some(&b"2"[..])));
assert_eq!(p[0].modified, 3.0);
assert_eq!(p[1].key, "files/x.png");
assert!(!m.has_pending() && m.take_pending().is_empty());
// Delete after put: one delete.
m.put("files/y", Arc::from(&b"y"[..]), 4.0, true);
m.remove("files/y");
assert_eq!(m.take_pending(), vec![Pending { key: "files/y".into(), data: None, modified: 0.0 }]);
}
#[test]
fn unpersisted_entries_never_flush_and_loaded_ones_are_clean() {
let mut m = Mirror::default();
m.load("files/old.mov", Arc::from(&b"m"[..]), 5.0);
m.put("files/render.gif", Arc::from(&b"g"[..]), 6.0, false);
assert!(!m.has_pending());
assert!(!m.remove("files/none"));
m.remove("files/render.gif");
assert!(!m.has_pending());
// Overwriting a persisted file with an unpersisted one deletes the stored copy.
m.put("files/old.mov", Arc::from(&b"n"[..]), 7.0, false);
assert_eq!(m.take_pending()[0].data, None);
assert_eq!(m.list("files").len(), 1);
}
#[test]
fn keys_and_paths_round_trip() {
assert_eq!(file_key("/a b/c.mov"), "files/a b/c.mov");
assert_eq!(file_key("c.mov"), "files/c.mov");
assert_eq!(key_path("files/a b/c.mov"), Some("/a b/c.mov"));
assert_eq!(key_path("config/prefs.json"), None);
let s = store();
s.put_file("/clip.mp4", Arc::from(&b"abc"[..]), true);
s.put_file("/out.gif", Arc::from(&b"g"[..]), false);
let f = s.files();
assert_eq!(f, vec![("/clip.mp4".to_string(), 3, 1000.0, true), ("/out.gif".to_string(), 1, 1000.0, false)]);
assert_eq!(s.file("/clip.mp4").as_deref(), Some(&b"abc"[..]));
}
#[test]
fn config_store_and_file_ops() {
let s = store();
let c = WebConfig::new(s.clone());
assert_eq!(c.read("prefs.json"), None);
c.write("prefs.json", "{\"a\":1}").unwrap();
assert_eq!(c.read("prefs.json").as_deref(), Some("{\"a\":1}"));
c.remove("prefs.json").unwrap();
assert_eq!(c.read("prefs.json"), None);
// Config entries are not files.
c.write("session.lock", "{}").unwrap();
assert!(s.files().is_empty());
let fs = c.files();
fs.write(Path::new("/EffectCraft Auto-Save/P auto-save 1.ecproj"), b"{}").unwrap();
fs.write(Path::new("/EffectCraft Auto-Save/P auto-save 2.ecproj"), b"{}").unwrap();
fs.write(Path::new("/other.ecproj"), b"{}").unwrap();
let mut names: Vec<String> = fs.list(Path::new("/EffectCraft Auto-Save")).into_iter().map(|e| e.0).collect();
names.sort();
assert_eq!(names, ["P auto-save 1.ecproj", "P auto-save 2.ecproj"]);
assert!(fs.is_file(Path::new("/other.ecproj")));
fs.remove(Path::new("/other.ecproj")).unwrap();
assert!(!fs.is_file(Path::new("/other.ecproj")));
assert!(fs.remove(Path::new("/other.ecproj")).is_err());
let pending = s.lock().take_pending();
let keys: Vec<&str> = pending.iter().map(|p| p.key.as_str()).collect();
assert_eq!(
keys,
[
"config/prefs.json",
"config/session.lock",
"files/EffectCraft Auto-Save/P auto-save 1.ecproj",
"files/EffectCraft Auto-Save/P auto-save 2.ecproj",
"files/other.ecproj"
]
);
assert_eq!(pending[0].data, None, "removed");
assert_eq!(pending[4].data, None, "removed");
}
#[test]
fn autosave_and_recovery_run_in_the_browser_store() {
use effectcraft_engine::Session;
use serde_json::json;
let s = store();
let mut a = Session { config: Some(Arc::new(WebConfig::new(s.clone()))), ..Default::default() };
a.load_settings();
assert!(a.begin_recovery().is_none());
a.execute("file.newProject", json!({})).unwrap();
a.execute("comp.new", json!({"name": "C"})).unwrap();
// Untitled: the auto-save lands in the store's file table.
let p = a.autosave_now().unwrap();
assert_eq!(p, "/EffectCraft Auto-Save/Untitled Project auto-save 1.ecproj");
assert!(s.file(&p).is_some());
// The tab closes without a clean exit: the next visit offers the auto-save.
let mut b = Session { config: Some(Arc::new(WebConfig::new(s.clone()))), ..Default::default() };
b.load_settings();
let r = b.begin_recovery().expect("recovery");
assert!(r.autosave.is_some());
// Increment and Save sees the store's files.
let fs = WebConfig::new(s.clone());
fs.files().write(Path::new("/A.ecproj"), b"{}").unwrap();
let next = effectcraft_engine::autosave::increment_path("/A.ecproj", |p| fs.files().is_file(Path::new(p)));
assert_eq!(next, "/A 2.ecproj");
}
#[test]
fn snapshot_meta_round_trips() {
let m = SnapshotMeta { path: Some("/p.ecproj".into()), dirty: true, state: Some(serde_json::json!({"activeComp": 3})), saved: 12.5 };
let t = serde_json::to_string(&m).unwrap();
assert_eq!(serde_json::from_str::<SnapshotMeta>(&t).unwrap(), m);
assert_eq!(serde_json::from_str::<SnapshotMeta>("{}").unwrap(), SnapshotMeta::default());
}
}