Key browser preview caches on a file generation, and hash bytes only when that key misses.

Replacing a virtual file bumps a counter the UI lookup reads. The content hash runs on that miss, from a callback, so the frame path does not stat every footage item or hash on every store write.
This commit is contained in:
Vikram Bhaskaran
2026-10-19 13:04:16 +11:00
parent ef74d82902
commit d3fd2c58d7
6 changed files with 175 additions and 28 deletions
+1
View File
@@ -257,6 +257,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
if n > 0 {
app.frames.set_remote(Some(Arc::new(frames::WebFrames::start(n, !query_flag("nogpuworkers")))));
}
app.frames.set_virtual_media(files::STORE.media_epoch(), Arc::new(|path| files::STORE.file_identity(path)));
let (tx, rx) = std::sync::mpsc::channel();
api::set_sender(tx);
app = app.with_control(rx);
+1
View File
@@ -416,6 +416,7 @@ fn post_reply(r: &FrameReply, px: Option<&[u8]>) {
fn new_server() -> FrameServer {
let mut s = FrameServer::new(crate::worker::pool(), Some(Arc::new(effectcraft_expr::Expressions)));
s.set_layer_store(Some(Arc::new(effectcraft_engine::render::PrefetchStore::default())));
s.set_virtual_id(Some(Arc::new(|path| crate::files::STORE.file_identity(path))));
s
}
+57 -4
View File
@@ -20,6 +20,7 @@
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use effectcraft_engine::config::{ConfigStore, FileOps};
@@ -45,6 +46,8 @@ pub struct Entry {
/// Which write this is: new with every write of the key, so a file replaced by another of
/// the same size still reads as changed (workers are sent it again, #218).
pub version: u64,
/// FNV-1a of `data`, filled the first time a disk-cache key asks for it.
hash: Option<u64>,
}
/// A queued write for the browser storage: `data` = `None` deletes the key.
@@ -69,13 +72,13 @@ 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) {
let version = self.next_version();
self.entries.insert(key.to_string(), Entry { data, modified, persist: true, version });
self.entries.insert(key.to_string(), Entry { data, modified, persist: true, version, hash: None });
}
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);
let version = self.next_version();
self.entries.insert(key.to_string(), Entry { data, modified, persist, version });
self.entries.insert(key.to_string(), Entry { data, modified, persist, version, hash: None });
if persist || was_persisted {
self.touch(key);
}
@@ -149,14 +152,31 @@ pub fn key_path(key: &str) -> Option<&str> {
key.strip_prefix(FILES).filter(|p| p.starts_with('/'))
}
/// FNV-1a 64. Computed when a disk-cache key first needs the file, not on every write.
fn content_hash(data: &[u8]) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for &b in data {
h ^= u64::from(b);
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
}
/// 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>,
/// Bumped when a virtual file is written. Preview cache lookups key on it; they do not hash.
media_epoch: Arc<AtomicU64>,
}
impl Default for Store {
fn default() -> Self {
Self { mirror: Mutex::default(), on_change: OnceLock::new(), clock: OnceLock::new(), media_epoch: Arc::new(AtomicU64::new(0)) }
}
}
impl Store {
@@ -179,12 +199,40 @@ impl Store {
/// 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);
let key = file_key(path);
let replaced = self.lock().get(&key).is_none_or(|e| !Arc::ptr_eq(&e.data, &data));
self.lock().put(&key, data, now, persist);
if replaced {
self.media_epoch.fetch_add(1, Ordering::Relaxed);
}
if persist {
self.changed();
}
}
/// The generation preview cache lookups key on. It changes when [`Self::put_file`] writes
/// a different blob, and it is not a content hash.
pub fn media_epoch(&self) -> Arc<AtomicU64> {
self.media_epoch.clone()
}
/// Size and content hash of the file under `path`. The hash is stored on the entry so a
/// later key does not walk the bytes again.
pub fn file_identity(&self, path: &str) -> Option<(u64, u64)> {
let key = file_key(path);
let mut mirror = self.lock();
let entry = mirror.entries.get(&key)?;
if let Some(hash) = entry.hash {
return Some((entry.data.len() as u64, hash));
}
let hash = content_hash(&entry.data);
let len = entry.data.len() as u64;
if let Some(entry) = mirror.entries.get_mut(&key) {
entry.hash = Some(hash);
}
Some((len, hash))
}
pub fn file(&self, path: &str) -> Option<Arc<[u8]>> {
self.lock().get(&file_key(path)).map(|e| e.data.clone())
}
@@ -446,6 +494,11 @@ mod tests {
assert_ne!(a, b);
assert_eq!(&data[..], b"blue");
assert_eq!(s.file_versioned("/collision.png").map(|f| f.1), Some(b), "reading doesn't change it");
let epoch = s.media_epoch().load(Ordering::Relaxed);
let id = s.file_identity("/collision.png").expect("identity");
assert_eq!(id, (4, content_hash(b"blue")));
assert_eq!(s.file_identity("/collision.png"), Some(id), "the hash is kept on the entry");
assert!(epoch >= 2, "each new blob bumps the generation the preview keys on");
}
/// A write that failed is flushed again with the key's latest state (#215).