Files
effectcraft/apps/effectcraft-web/src/files.rs
T
Brandon ThomasandClaude Opus 5.5 0bee1ab2da M15.2: web app depth (WEB-1): browser storage, Web Audio, worker renders, WebGPU viewer, PWA
- Persistence: settings, shortcuts, recent projects, imported media, saved projects and
  auto-saves live in OPFS (IndexedDB fallback) through an in-memory mirror flushed in the
  background (apps/effectcraft-web/src/store.rs, persist.rs); ConfigStore gains FileOps so
  auto-save / recovery work in the browser store; the session snapshot restores the last
  project + editor state on reload; File > Open Recent works from storage.
- Audio: Web Audio output (AudioWorklet, ScriptProcessor fallback) fed by AudioPlayback::pump
  on wasm32; A/V sync from AudioContext.currentTime; meters; unlock on first gesture.
- Background jobs: engine::offload (WorkerRequest/WorkerReply protocol, Offload trait,
  run_request); Render Queue, tracker, mask tracker, Warp Stabilizer and Roto Freeze run in Web
  Workers (a second engine instance on the shared compiled module), progress streamed back;
  cancel terminates the worker.
- GPU: WebGPU viewer frames drawn straight from the compositor's texture (gpu_display on
  wasm32 when the viewer draws plainly); async readback (Gpu::read_display_async) for pixels.
- PWA: manifest, service worker precaching the build (xtask fills list/version), icons copied
  from assets/app-icon; COOP/COEP kept from cached responses; offline reload works.
- window.effectcraft: saveToBrowser, listStored, removeStored, flush; info() reports storage,
  audio, gpu, workers.
- Tests: native store + protocol + offload (render, track, cancel) tests; smoke.mjs extended
  (GPU path, worker render responsiveness, audio gesture + playback, reload persistence,
  Open Recent, offline reload).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:42:00 -04:00

257 lines
10 KiB
Rust

//! Files in the browser: a virtual file table in place of the file system, `<input type=file>`
//! pickers for Open/Import, and downloads for what the user saves and renders.
//!
//! Picked or dropped files are read into the table under `/<name>`; the engine, the media pool and
//! the exporter read and write through it ([`WebServices`], [`WebImporter`], [`export_sink`]).
//! The table is the persistent [`Store`]'s file namespace: imported media, saved projects and
//! auto-saves survive reloads ([`crate::persist`]); render outputs don't (they download).
use std::sync::{Arc, LazyLock, Mutex};
use effectcraft_engine::Services;
use effectcraft_engine::project::Footage;
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use crate::store::Store;
/// The page's store (each worker has its own, unpersisted).
pub static STORE: LazyLock<Arc<Store>> = LazyLock::new(|| {
let s = Arc::new(Store::default());
let _ = s.clock.set(js_sys::Date::now);
s
});
/// Render outputs written since the last [`flush_downloads`].
static OUTPUTS: Mutex<Vec<String>> = Mutex::new(Vec::new());
/// Saves that write to browser storage only (no download): `api.saveToBrowser`.
static QUIET: Mutex<Vec<String>> = Mutex::new(Vec::new());
/// Store a file in the table (persisted).
pub fn put(path: &str, data: Arc<[u8]>) {
STORE.put_file(path, data, true);
}
pub fn get(path: &str) -> Option<Arc<[u8]>> {
STORE.file(path)
}
/// `(path, size)` of every file in the table.
pub fn list() -> Vec<(String, usize)> {
STORE.files().into_iter().map(|(p, n, _, _)| (p, n)).collect()
}
/// The next write of `path` goes to browser storage only (no download).
pub fn save_quietly(path: &str) {
QUIET.lock().unwrap_or_else(|e| e.into_inner()).push(path.to_string());
}
/// A rendered file (from the page's exporter or a worker): kept in the table unpersisted and
/// downloaded at the next [`flush_downloads`].
pub fn add_output(path: &str, data: Arc<[u8]>) {
STORE.put_file(path, data, false);
OUTPUTS.lock().unwrap_or_else(|e| e.into_inner()).push(path.to_string());
}
/// The table path for a picked/dropped file name.
pub fn path_for(name: &str) -> String {
let base = name.rsplit(['/', '\\']).next().unwrap_or(name);
format!("/{base}")
}
fn file_name(path: &str) -> &str {
path.rsplit('/').next().filter(|n| !n.is_empty()).unwrap_or("effectcraft-output")
}
fn mime(name: &str) -> &'static str {
match name.rsplit('.').next().unwrap_or("").to_ascii_lowercase().as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"tif" | "tiff" => "image/tiff",
"mp4" => "video/mp4",
"mov" => "video/quicktime",
"zip" => "application/zip",
"ecproj" => "application/json",
_ => "application/octet-stream",
}
}
/// Offer `data` as a download named `name`.
pub fn download(name: &str, data: &[u8]) -> Result<(), JsValue> {
let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(data));
let bag = web_sys::BlobPropertyBag::new();
bag.set_type(mime(name));
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &bag)?;
let url = web_sys::Url::create_object_url_with_blob(&blob)?;
let a: web_sys::HtmlAnchorElement = doc.create_element("a")?.dyn_into()?;
a.set_href(&url);
a.set_download(name);
a.style().set_property("display", "none")?;
doc.body().ok_or("no body")?.append_child(&a)?;
a.click();
a.remove();
// Revoke once the browser has started the download.
let revoke = Closure::once_into_js(move || {
let _ = web_sys::Url::revoke_object_url(&url);
});
if let Some(w) = web_sys::window() {
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 60_000);
}
Ok(())
}
/// Engine file access (projects): reads from the table; writes go to the table (persisted) and
/// download, except [`save_quietly`] saves.
pub struct WebServices;
impl Services for WebServices {
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
get(path)
.map(|d| d.to_vec())
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, format!("{path}: open it with File ▸ Open (or drop it on the page)")))
}
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
put(path, data.into());
let quiet = {
let mut q = QUIET.lock().unwrap_or_else(|e| e.into_inner());
let n = q.len();
q.retain(|p| p != path);
q.len() != n
};
if quiet || crate::is_worker() {
return Ok(());
}
download(file_name(path), data).map_err(|e| std::io::Error::other(format!("download failed: {e:?}")))
}
}
/// Media import from the table (the bytes are also handed to the media pool, which decodes them).
pub struct WebImporter {
pub pool: Arc<effectcraft_media::MediaPool>,
}
impl effectcraft_engine::Importer for WebImporter {
fn probe(&self, path: &str) -> Result<Footage, String> {
let bytes = get(path).ok_or_else(|| format!("{path}: not loaded (import it with File ▸ Import or drop it on the page)"))?;
self.pool.add_bytes(path, bytes.clone());
effectcraft_media::probe_bytes(path, bytes).map_err(|e| e.to_string())
}
}
/// Render Queue output: files land in the table and are downloaded after the render
/// ([`flush_downloads`]; several files, e.g. an image sequence, as one `.zip`).
pub fn export_sink() -> Arc<effectcraft_export::Sink> {
Arc::new(|path: &str, data: Vec<u8>| add_output(path, data.into()))
}
/// Download the render outputs written since the last call.
pub fn flush_downloads() {
let paths = std::mem::take(&mut *OUTPUTS.lock().unwrap_or_else(|e| e.into_inner()));
let mut out: Vec<(String, Arc<[u8]>)> = paths.into_iter().filter_map(|p| get(&p).map(|d| (file_name(&p).to_string(), d))).collect();
out.sort_by(|a, b| a.0.cmp(&b.0));
out.dedup_by(|a, b| a.0 == b.0);
let r = match out.len() {
0 => return,
1 => download(&out[0].0, &out[0].1),
n => {
let first = &out[0].0;
let stem = first.rsplit_once('.').map_or(first.as_str(), |(s, _)| s).trim_end_matches(|c: char| c.is_ascii_digit() || c == '_');
let stem = if stem.is_empty() { "render" } else { stem };
log::info!("render: {n} files → {stem}.zip");
download(&format!("{stem}.zip"), &zip_store(&out))
}
};
if let Err(e) = r {
log::warn!("download failed: {e:?}");
}
}
/// A `.zip` with the files stored uncompressed (PKWARE APPNOTE 6.3: local headers, central
/// directory, end record; no data descriptors, no ZIP64).
pub fn zip_store(entries: &[(String, Arc<[u8]>)]) -> Vec<u8> {
fn crc32(d: &[u8]) -> u32 {
let mut c = !0u32;
for &b in d {
c ^= b as u32;
for _ in 0..8 {
c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
}
}
!c
}
let mut out = Vec::new();
let mut central = Vec::new();
for (name, data) in entries {
let (crc, n, off) = (crc32(data), data.len() as u32, out.len() as u32);
let common = |v: &mut Vec<u8>| {
v.extend_from_slice(&20u16.to_le_bytes()); // version needed
v.extend_from_slice(&0x0800u16.to_le_bytes()); // flags: UTF-8 names
v.extend_from_slice(&0u16.to_le_bytes()); // stored
v.extend_from_slice(&0u16.to_le_bytes()); // time
v.extend_from_slice(&0x21u16.to_le_bytes()); // date: 1980-01-01
v.extend_from_slice(&crc.to_le_bytes());
v.extend_from_slice(&n.to_le_bytes());
v.extend_from_slice(&n.to_le_bytes());
v.extend_from_slice(&(name.len() as u16).to_le_bytes());
v.extend_from_slice(&0u16.to_le_bytes()); // extra
};
out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
common(&mut out);
out.extend_from_slice(name.as_bytes());
out.extend_from_slice(data);
central.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
central.extend_from_slice(&20u16.to_le_bytes()); // version made by
common(&mut central);
central.extend_from_slice(&[0; 6]); // comment len, disk, internal attrs
central.extend_from_slice(&0u32.to_le_bytes()); // external attrs
central.extend_from_slice(&off.to_le_bytes());
central.extend_from_slice(name.as_bytes());
}
let (cd_off, cd_len, count) = (out.len() as u32, central.len() as u32, entries.len() as u16);
out.extend_from_slice(&central);
out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
out.extend_from_slice(&[0; 4]); // disk numbers
out.extend_from_slice(&count.to_le_bytes());
out.extend_from_slice(&count.to_le_bytes());
out.extend_from_slice(&cd_len.to_le_bytes());
out.extend_from_slice(&cd_off.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out
}
/// Show the browser's file picker; `then` gets the picked files' table paths once they are read.
pub fn pick(accept: &str, multiple: bool, then: impl FnOnce(Vec<String>) + 'static) -> Result<(), JsValue> {
let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
let input: web_sys::HtmlInputElement = doc.create_element("input")?.dyn_into()?;
input.set_type("file");
input.set_accept(accept);
input.set_multiple(multiple);
let inp = input.clone();
let on_change = Closure::once_into_js(move || {
let Some(list) = inp.files() else { return };
read_files((0..list.length()).filter_map(|i| list.get(i)).collect(), then);
});
input.add_event_listener_with_callback("change", on_change.unchecked_ref())?;
input.click();
Ok(())
}
/// Read browser files into the table (asynchronously); `then` gets their table paths.
pub fn read_files(picked: Vec<web_sys::File>, then: impl FnOnce(Vec<String>) + 'static) {
wasm_bindgen_futures::spawn_local(async move {
let mut paths = Vec::new();
for f in picked {
match wasm_bindgen_futures::JsFuture::from(f.array_buffer()).await {
Ok(buf) => {
let path = path_for(&f.name());
put(&path, js_sys::Uint8Array::new(&buf).to_vec().into());
paths.push(path);
}
Err(e) => log::warn!("cannot read {}: {e:?}", f.name()),
}
}
then(paths);
});
}