Files
effectcraft/crates/ui-egui/tests/ui_web_gpu_storage.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

238 lines
11 KiB
Rust

//! Headless checks for M13.24 (web app depth: GPU frame workers, the browser's disk cache, the
//! storage manager), run natively with stand-ins for the browser:
//!
//! - a frame worker with its own GPU device and deferred readbacks (the browser worker's
//! WebGPU path) renders frames with GPU effects in passes; the page's own GPU compositor
//! keeps frames without effects, and frames with effects go to the worker;
//! - frames the worker rendered are stored under their disk-cache keys (an in-memory stand-in
//! for the Origin Private File System) and served from there after a "reload" (a new frame
//! cache), without rendering; the blue cache bar sees them;
//! - Settings ▸ Disk shows the browser storage manager when the session has a storage host,
//! and its buttons run `storage.*` commands.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use effectcraft_engine::Session;
use effectcraft_engine::remote::{FrameMsg, FrameReply, FrameServer, Mirror};
use effectcraft_engine::render::RenderOpts;
use effectcraft_engine::render::disk_cache;
use effectcraft_engine::storage::StorageHost;
use effectcraft_gpu::Gpu;
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::control::{self, ControlRequest, Outcome};
use effectcraft_ui_egui::frames::{FrameImage, FrameKey, Frames, RemoteDone, RemoteFrame, RemoteFrames, RemoteJob, RenderSource};
use egui_kittest::Harness;
use serde_json::{Value, json};
fn rt<T: serde::Serialize + for<'de> serde::Deserialize<'de>>(v: &T) -> T {
serde_json::from_str(&serde_json::to_string(v).unwrap()).unwrap()
}
/// A GPU frame worker in this process, with a "disk" shared across reloads.
struct GpuWorker {
gpu: Gpu,
state: Mutex<(Mirror, FrameServer, u64)>,
disk: Arc<Mutex<HashMap<u128, Vec<u8>>>>,
rendered: Mutex<Vec<(u32, bool)>>,
}
impl GpuWorker {
fn new(s: &Session, gpu: Gpu, disk: Arc<Mutex<HashMap<u128, Vec<u8>>>>) -> GpuWorker {
let mut server = FrameServer::new(s.footage.clone(), s.expr.clone());
server.set_accel(Some(Arc::new(gpu.clone())));
GpuWorker { gpu, state: Mutex::new((Mirror::default(), server, 0)), disk, rendered: Mutex::default() }
}
}
impl RemoteFrames for GpuWorker {
fn slots(&self) -> usize {
1
}
fn gpu(&self) -> bool {
true
}
fn disk(&self) -> bool {
true
}
fn disk_contains(&self, key: u128) -> bool {
self.disk.lock().unwrap().contains_key(&key)
}
fn start(&self, job: RemoteJob, done: RemoteDone) {
// The page reads a hit from the disk cache instead of asking the worker.
if let Some(k) = job.disk_key
&& let Some(f) = self.disk.lock().unwrap().get(&k).and_then(|e| disk_cache::read_frame_entry(e))
{
return done(Ok(RemoteFrame { width: f.width, height: f.height, rgba: f.rgba, ms: 0.0 }));
}
let mut st = self.state.lock().unwrap();
let (mirror, server, next) = &mut *st;
if let Some(m) = mirror.sync(job.revision, &job.project) {
assert!(server.handle(rt(&m)).is_empty());
}
*next += 1;
let disk = job.disk_key.map(|k| format!("{k:032x}"));
let mut out = server.handle(rt(&FrameMsg::Render {
id: *next,
revision: job.revision,
comp: job.comp,
time: job.t,
opts: Box::new(job.opts),
disk: disk.clone(),
}));
// The worker awaits its device between passes (`Gpu::settled`); natively a poll.
while server.waiting() {
self.gpu.wait();
pollster::block_on(self.gpu.settled());
out = server.resume();
}
match out.into_iter().next() {
Some((FrameReply::Frame { width, height, ms, passes, gpu, .. }, Some(rgba))) => {
self.rendered.lock().unwrap().push((passes, gpu));
if let Some(k) = job.disk_key {
self.disk.lock().unwrap().insert(k, disk_cache::frame_entry(width, height, &rgba));
}
done(Ok(RemoteFrame { width, height, rgba, ms }))
}
other => done(Err(format!("{:?}", other.map(|o| o.0)))),
}
}
}
fn source(s: &Session, gpu: Option<Gpu>) -> RenderSource {
RenderSource {
project: s.project.clone(),
footage: s.footage.clone(),
expr: s.expr.clone(),
layer_cache: s.layer_cache.clone(),
gpu,
gpu_display: true,
disk: None,
}
}
fn cpu_pixels(f: &FrameImage) -> Vec<egui::Color32> {
match f {
FrameImage::Cpu(c) => c.pixels.clone(),
FrameImage::Gpu(_) => panic!("expected a CPU frame"),
}
}
#[test]
fn gpu_worker_renders_effect_frames_and_the_disk_cache_survives_a_reload() {
let (Some(page_gpu), Some(worker_gpu)) = (Gpu::headless(), Gpu::headless_deferred()) else {
eprintln!("no GPU adapter: skipped");
return;
};
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Fx", "width": 160, "height": 90, "frameRate": 30, "duration": 1})).unwrap();
let plain = s.active_comp_id().unwrap();
s.execute("layer.newSolid", json!({"color": "#3080ff", "width": 80, "height": 40})).unwrap();
s.execute("comp.new", json!({"name": "Blurred", "width": 160, "height": 90, "frameRate": 30, "duration": 1})).unwrap();
let fx = s.active_comp_id().unwrap();
s.execute("layer.newSolid", json!({"color": "#ff8030", "width": 80, "height": 40})).unwrap();
s.execute("effect.apply", json!({"layer": "#1", "effect": "Gaussian Blur"})).unwrap();
s.execute("prop.set", json!({"layer": "#1", "path": "effects/#1/blurriness", "value": 12})).unwrap();
let opts = RenderOpts { backend: effectcraft_engine::render::Backend::Gpu, ..Default::default() };
let t = effectcraft_engine::time::Tick::ZERO;
let key = |comp: effectcraft_engine::project::ItemId| FrameKey { revision: s.revision, comp: comp.0, frame: 0, scale: 1000, view: 0 };
let disk = Arc::new(Mutex::new(HashMap::new()));
let worker = Arc::new(GpuWorker::new(&s, worker_gpu.clone(), disk.clone()));
let mut frames = Frames::default();
frames.set_remote(Some(worker.clone()));
// No effects: the page's GPU compositor keeps the frame on the GPU.
frames.request_urgent(&source(&s, Some(page_gpu.clone())), key(plain), plain, t, opts);
frames.dispatch_remote();
assert!(matches!(frames.get(&key(plain)), Some(FrameImage::Gpu(_))));
assert!(worker.rendered.lock().unwrap().is_empty());
// GPU effects: the worker renders the frame on its GPU, in passes.
frames.request_urgent(&source(&s, Some(page_gpu.clone())), key(fx), fx, t, opts);
frames.dispatch_remote();
let got = cpu_pixels(&frames.get(&key(fx)).expect("rendered by the worker"));
let (passes, on_gpu) = worker.rendered.lock().unwrap()[0];
assert!(on_gpu && passes >= 2, "passes {passes}");
let want = effectcraft_ui_egui::frames::to_color_image(&s.render(fx, t, RenderOpts { backend: effectcraft_engine::render::Backend::Cpu, ..opts }));
let worst = got.iter().zip(&want.pixels).map(|(a, b)| (0..4).map(|c| (a[c] as i32 - b[c] as i32).abs()).max().unwrap()).max().unwrap();
assert!(worst <= 2, "GPU vs CPU: {worst}/255");
assert_eq!(disk.lock().unwrap().len(), 1, "stored under its disk key");
// "Reload": a new frame cache and worker; the frame comes from the disk cache.
let worker2 = Arc::new(GpuWorker::new(&s, worker_gpu, disk.clone()));
let mut frames2 = Frames::default();
frames2.set_remote(Some(worker2.clone()));
assert!(frames2.remote_disk());
let bar = frames2.disk_frames(&source(&s, None), s.revision, fx.0, 1000, 0, 30, &opts);
assert_eq!(bar, vec![0], "the blue cache bar shows the stored frame");
frames2.request_urgent(&source(&s, Some(page_gpu)), key(fx), fx, t, opts);
frames2.dispatch_remote();
assert_eq!(cpu_pixels(&frames2.get(&key(fx)).unwrap()), got);
assert!(worker2.rendered.lock().unwrap().is_empty(), "a hit is not rendered again");
}
#[derive(Default)]
struct Storage {
cleared: Mutex<Vec<String>>,
persist_asked: Mutex<bool>,
}
impl StorageHost for Storage {
fn info(&self) -> Value {
json!({"backend": "opfs", "usage": 52_000_000u64, "quota": 2_000_000_000u64, "persisted": false,
"files": {"projects": {"count": 2, "bytes": 40000}, "media": {"count": 3, "bytes": 50_000_000u64}, "autoSaves": {"count": 1, "bytes": 20000}},
"diskCache": {"enabled": true, "entries": 12, "bytes": 1_500_000, "maxBytes": 1_000_000_000u64, "hits": 4, "misses": 12}})
}
fn request_persist(&self) -> Value {
*self.persist_asked.lock().unwrap() = true;
json!({"requested": true})
}
fn clear(&self, what: &str) -> Result<Value, String> {
self.cleared.lock().unwrap().push(what.into());
Ok(json!({"cleared": what, "entries": 1, "bytes": 1}))
}
}
fn send(h: &mut Harness<'_, EffectcraftApp>, method: &str, params: Value) {
let ctx = h.ctx.clone();
let (req, _rx) = ControlRequest::new(method, params);
if let Outcome::Done(v) = control::handle(h.state_mut(), &ctx, &req) {
assert!(v["ok"] != false, "{method}: {v}");
}
// kittest doesn't call `raw_input_hook`: feed the synthetic click in.
for e in h.state_mut().take_synthetic_input() {
h.event(e);
}
h.run_steps(3);
}
#[test]
fn settings_disk_page_shows_the_browser_storage_manager() {
let mut s = Session::default();
let host = Arc::new(Storage::default());
s.storage = Some(host.clone());
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(2);
effectcraft_ui_egui::panels::settings::open(h.state_mut(), "disk");
h.run_steps(3);
for id in [
"settings.storage.backend",
"settings.storage.usage",
"settings.storage.diskCache",
"settings.storage.persist",
"settings.storage.clear.diskCache",
"settings.storage.clear.media",
] {
assert!(h.state().auto.find(id).is_some(), "{id} is registered");
}
let usage = h.state().auto.find("settings.storage.usage").unwrap().label.clone();
assert!(usage.contains("52.0 MB of 2.0 GB"), "{usage}");
send(&mut h, "ui.click", json!({"id": "settings.storage.clear.diskCache"}));
send(&mut h, "ui.click", json!({"id": "settings.storage.persist"}));
assert_eq!(*host.cleared.lock().unwrap(), vec!["diskCache".to_string()]);
assert!(*host.persist_asked.lock().unwrap());
// Without a host (the desktop) the section is not there.
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(Session::default()));
h.run_steps(2);
effectcraft_ui_egui::panels::settings::open(h.state_mut(), "disk");
h.run_steps(3);
assert!(h.state().auto.find("settings.storage.usage").is_none());
}