M13.30: web depth and polish

- GPU in browser job workers: each job worker opens its own WebGPU device
  (deferred readbacks); the Render Queue export and the analyses' frame loops
  are futures (render::passes, offload::run_request_async) whose frames
  render in passes, awaiting the device between them. GPU particles and
  Advanced 3D read back under keys too. workers.jobs reports passes and
  readbacks; the smoke test measures them.
- Layer buffers in the browser's OPFS disk cache: frame workers back their
  layer caches with a PrefetchStore; the page plans what to fetch before a
  request from recent misses (LayerPrefetch over the shared DiskIndex).
- Templates embed footage (default on, size-capped with a warning) and
  creating from a template extracts it next to the new project / into
  browser storage.
- ICC LUT profiles (lut8/lut16/lutAtoB/lutBtoA, from the public ICC spec) in
  Simulate Output > My Custom RGB, baked into a 3D LUT for the viewer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-04 02:57:17 -04:00
co-authored by Claude Opus 5.5
parent d39c0e818c
commit f3715aac32
46 changed files with 3304 additions and 387 deletions
+72 -17
View File
@@ -13,13 +13,18 @@
//! - **Reads** are asynchronous: a frame whose key is indexed is read and decoded here instead
//! of rendering it (`crate::frames`); a damaged or missing file is a miss and is forgotten.
//!
//! Only frames are cached on disk in the browser (the desktop also keeps slow layer buffers):
//! a layer lookup happens in the middle of a synchronous render, where the browser's file
//! system can't be read.
//! - **Layer buffers** too (`layers/<32 hex>.ecc`): a layer lookup happens in the middle of a
//! synchronous render, where the browser's file system can't be read, so the frame workers
//! back their layer caches with a `PrefetchStore`. They report each frame's layer-cache
//! misses; before the next request the page names the recent misses that are on disk
//! ([`layer_prefetch`], `disk_cache::LayerPrefetch`), and the worker reads those entries
//! into memory before rendering, so the lookups find them. Slow buffers are written by the
//! worker after its frame and indexed here like frames (one least-recently-used order for
//! both kinds).
use std::cell::RefCell;
use effectcraft_engine::render::disk_cache::{self, DiskIndex, Kind};
use effectcraft_engine::render::disk_cache::{self, DiskIndex, Kind, LayerPrefetch};
use serde_json::{Value, json};
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
@@ -29,7 +34,7 @@ extern "C" {
#[wasm_bindgen(js_name = cacheList)]
fn cache_list() -> js_sys::Promise;
#[wasm_bindgen(js_name = cacheRead)]
fn cache_read(name: &str) -> js_sys::Promise;
pub(crate) fn cache_read(name: &str) -> js_sys::Promise;
#[wasm_bindgen(js_name = cacheWrite)]
pub(crate) fn cache_write(name: &str, bytes: js_sys::Uint8Array) -> js_sys::Promise;
#[wasm_bindgen(js_name = cacheDelete)]
@@ -53,6 +58,10 @@ struct State {
writes: u64,
evictions: u64,
error: Option<String>,
/// Recent layer-cache misses of the frame workers (what to prefetch).
prefetch: LayerPrefetch,
layer_hits: u64,
layer_writes: u64,
}
thread_local! {
@@ -68,19 +77,19 @@ pub fn init() {
wasm_bindgen_futures::spawn_local(async {
match JsFuture::from(cache_list()).await {
Ok(list) => {
let mut entries: Vec<(f64, u128, u64)> = js_sys::Array::from(&list)
let mut entries: Vec<(f64, Kind, u128, u64)> = js_sys::Array::from(&list)
.iter()
.filter_map(|e| {
let get = |k: &str| js_sys::Reflect::get(&e, &k.into()).ok();
let key = disk_cache::parse_entry_name(&get("name")?.as_string()?)?;
Some((get("modified")?.as_f64().unwrap_or(0.0), key, get("size")?.as_f64().unwrap_or(0.0) as u64))
let (kind, key) = parse_name(&get("name")?.as_string()?)?;
Some((get("modified")?.as_f64().unwrap_or(0.0), kind, key, get("size")?.as_f64().unwrap_or(0.0) as u64))
})
.collect();
entries.sort_by(|a, b| a.0.total_cmp(&b.0));
ST.with(|s| {
let mut s = s.borrow_mut();
for (_, key, bytes) in entries {
s.index.insert(Kind::Frame, key, bytes);
for (_, kind, key, bytes) in entries {
s.index.insert(kind, key, bytes);
}
s.loaded = true;
s.available = true;
@@ -96,6 +105,46 @@ pub fn init() {
});
}
/// An entry's name in `js/host.js`'s cache folders (`layers/` for layer buffers).
pub(crate) fn name_of(kind: Kind, key: u128) -> String {
match kind {
Kind::Frame => disk_cache::entry_name(key),
Kind::Layer => format!("layers/{}", disk_cache::entry_name(key)),
}
}
fn parse_name(name: &str) -> Option<(Kind, u128)> {
match name.strip_prefix("layers/") {
Some(n) => Some((Kind::Layer, disk_cache::parse_entry_name(n)?)),
None => Some((Kind::Frame, disk_cache::parse_entry_name(name)?)),
}
}
/// Layer entries (32 hex digits) a frame worker should read before its next frame: recent
/// misses that are on disk.
pub fn layer_prefetch(max: usize) -> Vec<String> {
if !active() {
return vec![];
}
ST.with(|s| {
let s = s.borrow();
s.prefetch.plan(&s.index, max).into_iter().map(|k| format!("{k:032x}")).collect()
})
}
/// A frame worker's layer-cache misses and the buffers it served from what it fetched.
pub fn note_layers(misses: &[String], hits: &[String]) {
let parse = |v: &[String]| v.iter().filter_map(|k| u128::from_str_radix(k, 16).ok()).collect::<Vec<u128>>();
ST.with(|s| {
let mut s = s.borrow_mut();
s.prefetch.note_misses(parse(misses));
for k in parse(hits) {
s.index.touch(Kind::Layer, k);
s.layer_hits += 1;
}
});
}
/// Apply the settings: on/off and the limit (`max_gb`, at most half the origin's quota when it
/// is known).
pub fn configure(enabled: bool, max_gb: u32, quota: Option<u64>) {
@@ -155,12 +204,15 @@ pub async fn read(key: u128) -> Option<disk_cache::Frame8> {
frame
}
/// A worker stored the frame of `key` (`bytes` on disk).
pub fn stored(key: u128, bytes: u64) {
/// A worker stored the frame (or, with `layer`, the layer buffer) of `key` (`bytes` on disk).
pub fn stored(key: u128, bytes: u64, layer: bool) {
ST.with(|s| {
let mut s = s.borrow_mut();
s.index.insert(Kind::Frame, key, bytes);
s.index.insert(if layer { Kind::Layer } else { Kind::Frame }, key, bytes);
s.writes += 1;
if layer {
s.layer_writes += 1;
}
});
evict();
}
@@ -174,7 +226,7 @@ fn evict() {
v
});
if !victims.is_empty() {
delete(victims.into_iter().map(|(_, k)| disk_cache::entry_name(k)).collect());
delete(victims.into_iter().map(|(kind, k)| name_of(kind, k)).collect());
}
}
@@ -194,6 +246,7 @@ pub fn clear() -> (u64, u64) {
let mut s = s.borrow_mut();
let r = (s.index.len() as u64, s.index.total());
s.index.clear();
s.prefetch.clear();
r
});
wasm_bindgen_futures::spawn_local(async {
@@ -212,10 +265,12 @@ pub fn stats() -> Value {
"enabled": s.enabled && s.available,
"available": s.available,
"loaded": s.loaded,
"folder": "Origin Private File System: effectcraft-cache/v1/frames",
"folder": "Origin Private File System: effectcraft-cache/v1 (frames, layers)",
"entries": s.index.len(),
"frames": s.index.len(),
"layers": 0,
"frames": s.index.keys(Kind::Frame).len(),
"layers": s.index.keys(Kind::Layer).len(),
"layerHits": s.layer_hits,
"layerWrites": s.layer_writes,
"bytes": s.index.total(),
"maxBytes": s.max_bytes,
"hits": s.hits,