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>
This commit is contained in:
Brandon Thomas
2026-10-02 23:42:00 -04:00
co-authored by Claude Opus 5.5
parent c406b71cba
commit 0bee1ab2da
41 changed files with 3230 additions and 319 deletions
+10 -5
View File
@@ -1,6 +1,6 @@
[package]
name = "effectcraft-web"
description = "EffectCraft in the browser (wasm32): eframe web runner, in-memory files, browser file pickers and downloads, window.effectcraft API"
description = "EffectCraft in the browser (wasm32): eframe web runner, persistent browser storage (OPFS / IndexedDB), Web Audio, Web Worker renders, PWA, window.effectcraft API"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -12,10 +12,14 @@ workspace = true
[lib]
crate-type = ["cdylib", "rlib"]
# Everything is wasm32-only: on other targets this crate is empty (so `cargo test --workspace`
# and clippy stay cheap). Build with `cargo xtask web` (see docs/web.md).
[target.'cfg(target_arch = "wasm32")'.dependencies]
# The browser glue is wasm32-only; on other targets the crate holds just the portable storage
# model (`store`), unit-tested natively. Build the app with `cargo xtask web` (see docs/web.md).
[dependencies]
effectcraft-engine = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
effectcraft-host = { workspace = true }
effectcraft-media = { workspace = true }
effectcraft-export = { workspace = true }
@@ -23,8 +27,8 @@ effectcraft-expr = { workspace = true }
effectcraft-ui-egui = { workspace = true }
egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu"] }
serde_json = { workspace = true }
log = { workspace = true }
web-time = { workspace = true }
# The wasm-bindgen CLI used by `cargo xtask web` must be exactly this version.
wasm-bindgen = "=0.2.129"
wasm-bindgen-futures = "0.4"
@@ -35,6 +39,7 @@ web-sys = { version = "0.3", features = [
"CssStyleDeclaration",
"Document",
"Element",
"DedicatedWorkerGlobalScope",
"Event",
"EventTarget",
"File",
+335
View File
@@ -0,0 +1,335 @@
// Browser services for the EffectCraft web app (imported by the wasm module through
// wasm-bindgen; see src/persist.rs, src/audio.rs and src/worker.rs):
//
// - storage: the Origin Private File System, or IndexedDB where OPFS can't write;
// - audio: a Web Audio output (AudioWorklet, ScriptProcessorNode fallback) fed stereo blocks;
// - workers: a pool of Web Workers, each running its own instance of the engine.
//
// Plain functions only: the Rust side owns the logic.
// ------------------------------------------------------------------ storage
const DIR = "effectcraft";
const DB = "effectcraft";
const STORE = "entries";
let backend = null;
// OPFS file names can't hold "/": keys are percent-encoded.
const enc = (k) => encodeURIComponent(k);
const dec = (n) => decodeURIComponent(n);
async function opfs() {
if (!navigator.storage || !navigator.storage.getDirectory) throw new Error("no OPFS");
const root = await navigator.storage.getDirectory();
const dir = await root.getDirectoryHandle(DIR, { create: true });
// Probe a write: some browsers expose OPFS but only write it from workers.
const probe = await dir.getFileHandle(".probe", { create: true });
if (!probe.createWritable) throw new Error("OPFS is read-only on this thread");
const w = await probe.createWritable();
await w.write(new Uint8Array([1]));
await w.close();
await dir.removeEntry(".probe");
return {
name: "opfs",
async loadAll() {
const out = [];
for await (const [name, h] of dir.entries()) {
if (h.kind !== "file" || name.startsWith(".") || name.endsWith(".crswap")) continue;
const f = await h.getFile();
out.push({ key: dec(name), data: new Uint8Array(await f.arrayBuffer()), modified: f.lastModified });
}
return out;
},
async put(key, data) {
// createWritable writes a swap file and replaces the entry on close: atomic.
const h = await dir.getFileHandle(enc(key), { create: true });
const w = await h.createWritable();
await w.write(data);
await w.close();
},
async del(key) {
try { await dir.removeEntry(enc(key)); } catch (e) { if (e.name !== "NotFoundError") throw e; }
},
};
}
function req(r) {
return new Promise((res, rej) => { r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); });
}
async function idb() {
if (!self.indexedDB) throw new Error("no IndexedDB");
const open = indexedDB.open(DB, 1);
open.onupgradeneeded = () => open.result.createObjectStore(STORE, { keyPath: "key" });
const db = await req(open);
const tx = (mode) => db.transaction(STORE, mode).objectStore(STORE);
return {
name: "indexeddb",
async loadAll() {
const all = await req(tx("readonly").getAll());
return all.map((r) => ({ key: r.key, data: new Uint8Array(r.data), modified: r.modified }));
},
async put(key, data, modified) {
await req(tx("readwrite").put({ key, data: data.slice().buffer, modified }));
},
async del(key) {
await req(tx("readwrite").delete(key));
},
};
}
function memory() {
const m = new Map();
return {
name: "memory",
async loadAll() { return [...m.values()]; },
async put(key, data, modified) { m.set(key, { key, data: data.slice(), modified }); },
async del(key) { m.delete(key); },
};
}
/// Open the store: OPFS, else IndexedDB, else memory (nothing persists). Resolves with the
/// backend's name. `prefer` ("opfs" | "indexeddb" | "memory") forces one (tests).
export async function storeOpen(prefer) {
const order = { opfs: [opfs, idb, memory], indexeddb: [idb, memory], memory: [memory] }[prefer] ?? [opfs, idb, memory];
for (const make of order) {
try { backend = await make(); break; } catch (e) { console.info(`storage: ${make.name}: ${e.message ?? e}`); }
}
// Ask the browser not to evict the store under storage pressure (granted for installed apps).
try { if (navigator.storage && navigator.storage.persist) await navigator.storage.persist(); } catch {}
return backend.name;
}
/// Every stored entry: [{key, data: Uint8Array, modified}].
export async function storeLoadAll() { return backend.loadAll(); }
export async function storePut(key, data, modified) { return backend.put(key, data, modified); }
export async function storeDelete(key) { return backend.del(key); }
/// {usage, quota, persisted, backend}.
export async function storeEstimate() {
const out = { backend: backend ? backend.name : null };
try {
const e = await navigator.storage.estimate();
out.usage = e.usage; out.quota = e.quota;
out.persisted = await navigator.storage.persisted();
} catch {}
return out;
}
// ------------------------------------------------------------------ audio
// One AudioContext for the page. Browsers start it suspended until the user interacts with the
// page, so the first pointer/key event resumes it ("unlock").
let ctx = null;
let node = null;
let kind = null;
// Sample frames handed to the output since `audioStart`, and the context time it started at.
let posted = 0;
let startTime = 0;
let underruns = 0;
// ScriptProcessor fallback: the queue lives on this thread.
let spQueue = [];
let spOffset = 0;
// Blocks pushed while the output node is being created.
let starting = false;
let pendingPush = [];
// Bumped by every start/stop: a start superseded while it awaited the worklet gives up.
let generation = 0;
function context() {
if (!ctx) {
const AC = self.AudioContext || self.webkitAudioContext;
if (!AC) return null;
ctx = new AC({ latencyHint: "interactive" });
}
return ctx;
}
function unlock() {
const c = context();
if (c && c.state !== "running") c.resume().catch(() => {});
}
/// Install the user-gesture unlock (called once at startup).
export function audioInstallUnlock() {
for (const ev of ["pointerdown", "keydown", "touchend"]) {
self.addEventListener(ev, unlock, { capture: true, passive: true });
}
}
/// {state, sampleRate, backend, posted, played, underruns, baseLatency, outputLatency}.
export function audioState() {
const c = ctx;
return {
state: c ? c.state : "none",
sampleRate: c ? c.sampleRate : 0,
backend: kind,
posted,
played: audioPlayed(),
underruns,
baseLatency: c ? c.baseLatency || 0 : 0,
outputLatency: c ? c.outputLatency || 0 : 0,
};
}
/// The output's sample rate (creates the context; 0 without Web Audio).
export function audioSampleRate() {
const c = context();
return c ? c.sampleRate : 0;
}
/// Start an output node; resolves when it is ready. Samples pushed before then queue up.
export async function audioStart(workletUrl) {
audioStop();
const c = context();
if (!c) throw new Error("Web Audio is not available");
unlock();
posted = 0;
underruns = 0;
spQueue = [];
spOffset = 0;
starting = true;
pendingPush = [];
const gen = ++generation;
if (c.audioWorklet && workletUrl) {
try {
if (!c.__ecWorklet) {
await c.audioWorklet.addModule(workletUrl);
c.__ecWorklet = true;
}
if (gen !== generation) return;
node = new AudioWorkletNode(c, "effectcraft-output", { numberOfInputs: 0, outputChannelCount: [2] });
node.port.onmessage = (e) => { if (e.data && e.data.underrun) underruns += e.data.underrun; };
kind = "worklet";
} catch (e) {
console.warn("audio: AudioWorklet unavailable, using ScriptProcessorNode", e);
node = null;
}
}
if (!node) {
node = c.createScriptProcessor(2048, 0, 2);
node.onaudioprocess = (e) => {
const l = e.outputBuffer.getChannelData(0);
const r = e.outputBuffer.getChannelData(1);
let i = 0;
while (i < l.length && spQueue.length) {
const b = spQueue[0];
const n = Math.min(l.length - i, (b.length - spOffset) >> 1);
for (let k = 0; k < n; k++) { l[i + k] = b[spOffset + 2 * k]; r[i + k] = b[spOffset + 2 * k + 1]; }
i += n; spOffset += 2 * n;
if (spOffset >= b.length) { spQueue.shift(); spOffset = 0; }
}
if (i < l.length) { underruns += l.length - i; l.fill(0, i); r.fill(0, i); }
};
kind = "scriptProcessor";
}
starting = false;
if (!node) return;
node.connect(c.destination);
startTime = c.currentTime;
const p = pendingPush;
pendingPush = [];
for (const b of p) send(b);
}
function send(samples) {
if (kind === "worklet") node.port.postMessage(samples, [samples.buffer]);
else spQueue.push(samples);
}
/// Queue interleaved stereo samples (Float32Array) for output.
export function audioPush(samples) {
posted += samples.length >> 1;
if (node && !starting) send(samples);
else if (starting) pendingPush.push(samples);
}
/// Sample frames played since `audioStart`, by the context clock (AudioContext.currentTime).
export function audioPlayed() {
if (!ctx || !node || starting) return 0;
return Math.max(0, Math.round((ctx.currentTime - startTime) * ctx.sampleRate));
}
/// Output latency in sample frames (what's handed over but not audible yet comes on top).
export function audioOutputLatency() {
if (!ctx) return 0;
return Math.round(((ctx.outputLatency || 0) + (ctx.baseLatency || 0)) * ctx.sampleRate);
}
export function audioStop() {
generation++;
starting = false;
pendingPush = [];
if (node) {
try { node.disconnect(); } catch {}
if (kind === "worklet") node.port.postMessage("stop");
}
node = null;
spQueue = [];
}
// ------------------------------------------------------------------ workers
// Idle workers are reused; each keeps the files it was sent (path → size) so a file goes to a
// worker once.
const idle = [];
const running = new Map(); // job id → {worker, done}
function spawn(base) {
const worker = new Worker(new URL("worker.js", base), { type: "module", name: "effectcraft-worker" });
const w = { worker, files: new Map(), ready: null };
w.ready = new Promise((res, rej) => {
worker.onmessage = (e) => { if (e.data && e.data.type === "ready") res(); };
worker.onerror = (e) => rej(new Error(e.message || "worker failed to start"));
});
worker.postMessage({ type: "init", module: self.effectcraftModule ?? null, base });
return w;
}
/// Run a job in a worker. `files`: [[path, Uint8Array]] the job reads; `onMessage(type, json,
/// path, bytes)` receives "reply" (json), "file" (path, bytes) and "error" (json = message).
export function workerRun(id, json, files, base, onMessage) {
const w = idle.pop() ?? spawn(base);
running.set(id, w);
w.ready.then(() => {
w.worker.onmessage = (e) => {
const m = e.data;
if (m.type === "reply") {
onMessage("reply", m.json, null, null);
if (m.done) {
running.delete(id);
idle.push(w);
}
} else if (m.type === "file") {
onMessage("file", null, m.path, new Uint8Array(m.bytes));
}
};
w.worker.onerror = (e) => {
running.delete(id);
onMessage("error", String(e.message || "worker error"), null, null);
w.worker.terminate();
};
for (const [path, bytes] of files) {
if (w.files.get(path) === bytes.length) continue;
w.files.set(path, bytes.length);
w.worker.postMessage({ type: "file", path, bytes });
}
w.worker.postMessage({ type: "job", json });
}, (e) => {
running.delete(id);
onMessage("error", String(e.message || e), null, null);
});
}
/// Stop a job: its worker is terminated (it can't be interrupted mid-job).
export function workerCancel(id) {
const w = running.get(id);
if (!w) return;
running.delete(id);
w.worker.terminate();
}
/// {running, idle}.
export function workerStats() {
return { running: running.size, idle: idle.length };
}
+59
View File
@@ -92,9 +92,68 @@ pub fn info() -> JsValue {
}
v["version"] = json!(env!("CARGO_PKG_VERSION"));
v["threads"] = json!(false);
v["workers"] = from_js(&crate::worker::stats());
v["audio"] = from_js(&crate::audio::state());
if let Some(e) = crate::persist::last_error() {
v["storage"]["error"] = json!(e);
}
to_js(&v)
}
/// Save the project to browser storage without downloading it: `path` (default: its current
/// path, or `/<project name>.ecproj`). Resolves with `{path, bytes}`.
#[wasm_bindgen(js_name = saveToBrowser)]
pub fn save_to_browser(path: Option<String>) -> js_sys::Promise {
js_sys::Promise::new(&mut |resolve, reject| {
let path = path.clone();
crate::post(move |app| {
let p = path.or_else(|| app.session.path.clone()).unwrap_or_else(|| format!("/{}.ecproj", app.session.project_name()));
let p = if p.starts_with('/') { p } else { format!("/{p}") };
crate::files::save_quietly(&p);
match app.session.execute("file.saveAs", json!({"path": p})) {
Ok(v) => {
let _ = resolve.call1(&JsValue::NULL, &to_js(&v));
}
Err(e) => {
let _ = reject.call1(&JsValue::NULL, &JsValue::from_str(&e.to_string()));
}
}
});
})
}
/// What browser storage holds: `{backend, usage, quota, persisted, files: [{path, size,
/// modified}], config: [name]}` (render outputs, which are not stored, are left out).
#[wasm_bindgen(js_name = listStored)]
pub async fn list_stored() -> JsValue {
let est = wasm_bindgen_futures::JsFuture::from(crate::persist::store_estimate()).await.map(|v| from_js(&v)).unwrap_or(json!({}));
let files: Vec<Value> = crate::files::STORE
.files()
.into_iter()
.filter(|f| f.3)
.map(|(path, size, modified, _)| json!({"path": path, "size": size, "modified": modified}))
.collect();
let config: Vec<Value> =
crate::files::STORE.lock().list(crate::store::CONFIG).into_iter().map(|(k, ..)| json!(k.trim_start_matches(crate::store::CONFIG))).collect();
let mut v = est;
v["files"] = json!(files);
v["config"] = json!(config);
v["pending"] = json!(crate::files::STORE.lock().has_pending());
to_js(&v)
}
/// Remove a stored file (media, project, auto-save). Resolves with whether it existed.
#[wasm_bindgen(js_name = removeStored)]
pub fn remove_stored(path: String) -> bool {
crate::files::STORE.remove_file(&path)
}
/// Resolves once every change is written to browser storage.
#[wasm_bindgen]
pub fn flush() -> js_sys::Promise {
crate::persist::flushed()
}
/// Settle the promises whose replies arrived (called every frame).
pub(crate) fn poll_replies() {
PENDING.with(|p| {
+223
View File
@@ -0,0 +1,223 @@
//! The app: startup (storage, session restore, hooks), the eframe app and the page glue.
use std::cell::RefCell;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use effectcraft_engine::Session;
use crate::{api, audio, files, persist, worker};
use effectcraft_ui_egui::EffectcraftApp;
use serde_json::json;
use wasm_bindgen::prelude::*;
/// Work for the UI thread with access to the app (posted by async tasks: picked files…).
type Action = Box<dyn FnOnce(&mut EffectcraftApp)>;
static WORKER: AtomicBool = AtomicBool::new(false);
/// Running inside a Web Worker (no page, no downloads).
pub fn is_worker() -> bool {
WORKER.load(Ordering::Relaxed)
}
pub(crate) fn set_worker() {
WORKER.store(true, Ordering::Relaxed);
}
/// `rel` resolved against the page's URL (`worker.js`, `audio-worklet.js`); `""` = the page's
/// folder.
pub fn page_url(rel: &str) -> String {
let href = web_sys::window().and_then(|w| w.location().href().ok()).unwrap_or_default();
web_sys::Url::new_with_base(if rel.is_empty() { "./" } else { rel }, &href).map(|u| u.href()).unwrap_or_else(|_| rel.to_string())
}
thread_local! {
static ACTIONS: RefCell<Vec<Action>> = const { RefCell::new(Vec::new()) };
static CTX: RefCell<Option<egui::Context>> = const { RefCell::new(None) };
}
/// Run `f` on the UI thread at the next frame.
pub fn post(f: impl FnOnce(&mut EffectcraftApp) + 'static) {
ACTIONS.with(|a| a.borrow_mut().push(Box::new(f)));
repaint();
}
/// Ask for a UI frame.
pub fn repaint() {
CTX.with(|c| {
if let Some(c) = c.borrow().as_ref() {
c.request_repaint();
}
});
}
/// Open a project or import media from table paths (picked or dropped files).
pub(crate) fn open_or_import(app: &mut EffectcraftApp, paths: Vec<String>) {
let (projects, media): (Vec<String>, Vec<String>) = paths.into_iter().partition(|p| p.to_ascii_lowercase().ends_with(".ecproj"));
if let Some(p) = projects.last()
&& let Err(e) = app.session.execute("file.open", json!({"path": p}))
{
app.ui.status = e.to_string();
}
if !media.is_empty() {
match app.session.execute("file.import", json!({"paths": media})) {
Ok(r) => {
if let Some(errs) = r["errors"].as_array().filter(|e| !e.is_empty()) {
app.ui.status = errs.iter().filter_map(|e| e.as_str()).collect::<Vec<_>>().join("; ");
}
}
Err(e) => app.ui.status = e.to_string(),
}
}
}
/// The eframe app: the shared EffectCraft UI plus the browser host's per-frame duties.
pub struct WebApp {
pub app: EffectcraftApp,
/// Keep the session snapshot (off with `?empty`).
pub persist: bool,
}
impl eframe::App for WebApp {
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
self.app.raw_input_hook(ctx, raw_input);
}
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
let actions = ACTIONS.with(|a| std::mem::take(&mut *a.borrow_mut()));
for f in actions {
f(&mut self.app);
}
// Files dropped on the page: read them, then open/import.
let dropped: Vec<web_sys::File> = ctx.input(|i| i.raw.dropped_files.iter().filter_map(|f| f.web_file().cloned()).collect());
if !dropped.is_empty() {
files::read_files(dropped, |paths| post(move |app| open_or_import(app, paths)));
}
self.app.logic(ctx, frame);
api::poll_replies();
// Render Queue outputs written this frame (here or by a worker) → downloads.
files::flush_downloads();
// What a reload comes back to.
if self.persist {
persist::snapshot(&self.app.session, ctx.input(|i| i.time), 1.0);
}
api::set_info("gpu", json!({"compositor": self.app.gpu_adapter(), "viewerOnGpu": self.app.viewer_on_gpu()}));
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
self.app.ui(ui, frame);
}
}
fn query() -> Option<web_sys::UrlSearchParams> {
web_sys::window().and_then(|w| w.location().search().ok()).and_then(|s| web_sys::UrlSearchParams::new_with_str(&s).ok())
}
fn query_flag(name: &str) -> bool {
query().is_some_and(|p| p.has(name))
}
fn query_value(name: &str) -> Option<String> {
query().and_then(|p| p.get(name))
}
/// A session wired for the browser: media decoding from memory, expressions, scripting, Render
/// Queue export to downloads, settings in browser storage, renders and analyses in workers.
pub fn session() -> Session {
let pool = Arc::new(effectcraft_media::MediaPool::new());
persist::register_media(&pool);
Session {
services: Arc::new(files::WebServices),
footage: pool.clone(),
importer: Some(Arc::new(files::WebImporter { pool })),
expr: Some(Arc::new(effectcraft_expr::Expressions)),
expr_check: Some(effectcraft_expr::check_syntax),
script: Some(effectcraft_host::script::runner),
exporter: Some(Arc::new(effectcraft_host::FileExporter { sink: Some(files::export_sink()) })),
config: Some(Arc::new(persist::config())),
offload: (!query_flag("noworkers")).then(|| Arc::new(worker::WorkerOffload) as Arc<dyn effectcraft_engine::offload::Offload>),
..Default::default()
}
}
/// Browser file pickers in place of the desktop's file dialogs.
fn install_hooks(app: &mut EffectcraftApp) {
app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
let accept = exts.iter().map(|e| format!(".{e}")).collect::<Vec<_>>().join(",");
if let Err(e) = files::pick(&accept, true, |paths| post(move |app| open_or_import(app, paths))) {
log::warn!("file picker: {e:?}");
}
// the import happens when the browser hands over the files
Vec::new()
}));
app.hooks.pick_open_project = Some(Box::new(|| {
if let Err(e) = files::pick(".ecproj", false, |paths| post(move |app| open_or_import(app, paths))) {
log::warn!("file picker: {e:?}");
}
None
}));
// Saving downloads the file: the chosen name is all a "save dialog" needs.
app.hooks.pick_save = Some(Box::new(|name: &str| Some(files::path_for(if name.is_empty() { "Untitled Project.ecproj" } else { name }))));
app.hooks.audio_device = Some(Box::new(audio::open));
app.hooks.audio_devices = Some(Box::new(|| vec!["Browser default output".into()]));
}
/// Entry point (called by the page's bootstrap script once the wasm module is instantiated).
#[wasm_bindgen]
pub async fn start(canvas_id: String) -> Result<(), JsValue> {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
let canvas: web_sys::HtmlCanvasElement = doc.get_element_by_id(&canvas_id).ok_or("no canvas")?.dyn_into()?;
let empty = query_flag("empty");
let home = query_flag("home");
// Browser storage: settings, recent projects, imported media, saved projects, auto-saves and
// the last session come back.
let t0 = js_sys::Date::now();
let stored = match persist::load(&query_value("storage").unwrap_or_default()).await {
Ok(n) => n,
Err(e) => {
log::warn!("browser storage unavailable: {e}");
0
}
};
api::set_info("storage", json!({"backend": persist::backend(), "entries": stored, "loadMs": js_sys::Date::now() - t0}));
audio::install_unlock();
let runner = eframe::WebRunner::new();
runner
.start(
canvas,
eframe::WebOptions::default(),
Box::new(move |cc| {
CTX.with(|c| *c.borrow_mut() = Some(cc.egui_ctx.clone()));
let mut session = session();
session.load_settings();
let recovery = session.begin_recovery();
// `?empty`: a blank project; `?demo`: the demo project; otherwise the last visit's
// project (the demo on a first visit).
let restored = !empty && !query_flag("demo") && persist::restore(&mut session);
if !restored && !empty {
let _ = session.execute("file.openDemoProject", json!({}));
}
api::set_info("restored", json!(restored));
let mut app = EffectcraftApp::new(session);
app.ui.start_screen = home;
if let Some(r) = recovery.filter(|r| !restored && r.autosave.is_some()) {
app.offer_recovery(r);
}
install_hooks(&mut app);
let (tx, rx) = std::sync::mpsc::channel();
api::set_sender(tx);
app = app.with_control(rx);
let backend = cc.wgpu_render_state.as_ref().map(|rs| format!("{:?}", rs.adapter.get_info().backend));
api::set_info("backend", json!(backend));
Ok(Box::new(WebApp { app, persist: !empty }))
}),
)
.await?;
// Hide the loading message once the app runs.
if let Some(el) = doc.get_element_by_id("loading") {
el.remove();
}
Ok(())
}
+109
View File
@@ -0,0 +1,109 @@
//! Audio preview through Web Audio (`js/host.js`): an AudioWorklet (ScriptProcessorNode where
//! worklets are unavailable) plays the blocks the UI mixes ahead
//! ([`effectcraft_ui_egui::audio::AudioPlayback::pump`]).
//!
//! Every UI frame [`WebAudioOut::pump`] moves samples from the feed to the output so it stays
//! ~150 ms ahead of what has played. A/V sync follows `AudioContext.currentTime`: frames played
//! = (currentTime − start) × rate, so the device latency the playback clock subtracts is
//! "handed over but not played yet" plus the context's output latency.
//!
//! Browsers keep the AudioContext suspended until the user interacts with the page; the host
//! resumes it on the first pointer or key event.
use std::sync::Arc;
use effectcraft_ui_egui::audio::{AudioDevice, AudioFeed};
use wasm_bindgen::prelude::*;
#[wasm_bindgen(module = "/js/host.js")]
extern "C" {
#[wasm_bindgen(js_name = audioInstallUnlock)]
pub fn install_unlock();
#[wasm_bindgen(js_name = audioState)]
pub fn state() -> JsValue;
#[wasm_bindgen(js_name = audioSampleRate)]
fn sample_rate() -> f64;
#[wasm_bindgen(js_name = audioStart)]
fn audio_start(worklet_url: &str) -> js_sys::Promise;
#[wasm_bindgen(js_name = audioPush)]
fn audio_push(samples: js_sys::Float32Array);
#[wasm_bindgen(js_name = audioPlayed)]
fn audio_played() -> f64;
#[wasm_bindgen(js_name = audioOutputLatency)]
fn audio_output_latency() -> f64;
#[wasm_bindgen(js_name = audioStop)]
fn audio_stop();
}
/// How far ahead of the playhead the output is kept fed.
const LEAD_SECONDS: f64 = 0.15;
pub struct WebAudioOut {
rate: u32,
feed: Option<Arc<AudioFeed>>,
/// Sample frames handed to the output since start.
posted: u64,
buf: Vec<f32>,
}
/// The Settings ▸ Audio factory: Web Audio's default output (`None` without Web Audio).
pub fn open(_: &effectcraft_ui_egui::audio::AudioOutput) -> Option<Box<dyn AudioDevice>> {
let rate = sample_rate();
(rate > 0.0).then(|| Box::new(WebAudioOut { rate: rate as u32, feed: None, posted: 0, buf: Vec::new() }) as Box<dyn AudioDevice>)
}
fn worklet_url() -> String {
crate::page_url("audio-worklet.js")
}
impl AudioDevice for WebAudioOut {
fn sample_rate(&self) -> u32 {
self.rate
}
fn start(&mut self, feed: Arc<AudioFeed>) -> Result<(), String> {
self.feed = Some(feed);
self.posted = 0;
let p = audio_start(&worklet_url());
wasm_bindgen_futures::spawn_local(async move {
if let Err(e) = wasm_bindgen_futures::JsFuture::from(p).await {
log::warn!("audio output: {e:?}");
}
});
Ok(())
}
fn stop(&mut self) {
self.feed = None;
audio_stop();
}
fn latency_frames(&self) -> u64 {
let played = audio_played() as u64;
self.posted.saturating_sub(played) + audio_output_latency() as u64
}
fn pump(&mut self) {
let Some(feed) = &self.feed else { return };
let played = audio_played() as u64;
let lead = (LEAD_SECONDS * self.rate as f64) as u64;
let want = (played + lead).saturating_sub(self.posted) as usize;
// Only real samples: the output plays silence by itself when it runs dry.
let n = want.min(feed.queued_frames());
if n == 0 {
return;
}
self.buf.resize(n * 2, 0.0);
feed.pull(&mut self.buf);
audio_push(js_sys::Float32Array::from(&self.buf[..]));
self.posted += n as u64;
}
}
impl Drop for WebAudioOut {
fn drop(&mut self) {
if self.feed.is_some() {
audio_stop();
}
}
}
+43 -20
View File
@@ -1,39 +1,55 @@
//! Files in the browser: an in-memory file table in place of the file system, `<input type=file>`
//! pickers for Open/Import, and downloads for everything the app writes (projects, renders).
//! 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::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::sync::{Arc, LazyLock, Mutex};
use effectcraft_engine::Services;
use effectcraft_engine::project::Footage;
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
static FILES: Mutex<Option<HashMap<String, Arc<[u8]>>>> = Mutex::new(None);
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());
fn with_files<R>(f: impl FnOnce(&mut HashMap<String, Arc<[u8]>>) -> R) -> R {
f(FILES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_default())
}
/// Store a file in the table.
/// Store a file in the table (persisted).
pub fn put(path: &str, data: Arc<[u8]>) {
with_files(|m| m.insert(path.to_string(), data));
STORE.put_file(path, data, true);
}
pub fn get(path: &str) -> Option<Arc<[u8]>> {
with_files(|m| m.get(path).cloned())
STORE.file(path)
}
/// `(path, size)` of every file in the table.
pub fn list() -> Vec<(String, usize)> {
let mut v: Vec<(String, usize)> = with_files(|m| m.iter().map(|(k, d)| (k.clone(), d.len())).collect());
v.sort();
v
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.
@@ -85,7 +101,8 @@ pub fn download(name: &str, data: &[u8]) -> Result<(), JsValue> {
Ok(())
}
/// Engine file access (projects): reads from the table; writes go to the table and download.
/// 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 {
@@ -96,6 +113,15 @@ impl Services for WebServices {
}
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:?}")))
}
}
@@ -116,10 +142,7 @@ impl effectcraft_engine::Importer for WebImporter {
/// 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>| {
put(path, data.into());
OUTPUTS.lock().unwrap_or_else(|e| e.into_inner()).push(path.to_string());
})
Arc::new(|path: &str, data: Vec<u8>| add_output(path, data.into()))
}
/// Download the render outputs written since the last call.
+19 -163
View File
@@ -6,175 +6,31 @@
//!
//! | Desktop | Web |
//! |---|---|
//! | file system (`FsServices`, media reads, export writes) | in-memory file table ([`files`]) |
//! | file system (`FsServices`, media reads, export writes) | a virtual file table ([`files`]) persisted to the Origin Private File System / IndexedDB ([`persist`], [`store`]) |
//! | config directory (settings, shortcuts, recent projects, auto-saves) | the same store ([`store::WebConfig`]) |
//! | rfd file dialogs | `<input type=file>` pickers; drop files on the page |
//! | written files (Save, Render Queue) | browser downloads (several render files as one `.zip`) |
//! | frame render threads | `Frames::pump` on the UI thread between egui frames |
//! | Render Queue thread | the render runs inline (the page waits until it is done) |
//! | Render Queue / analysis threads | Web Workers running their own engine instance ([`worker`]) |
//! | cpal audio output | Web Audio: an AudioWorklet fed from the preview mixdown ([`audio`]) |
//! | TCP control channel / MCP | `window.effectcraft` JavaScript API ([`api`]) |
//!
//! Everything here is `wasm32`-only; on other targets the crate is empty.
#![cfg(target_arch = "wasm32")]
//! [`store`] is portable (unit-tested natively); everything else is `wasm32`-only.
pub mod store;
#[cfg(target_arch = "wasm32")]
pub mod api;
#[cfg(target_arch = "wasm32")]
mod app;
#[cfg(target_arch = "wasm32")]
pub mod audio;
#[cfg(target_arch = "wasm32")]
pub mod files;
#[cfg(target_arch = "wasm32")]
pub mod persist;
#[cfg(target_arch = "wasm32")]
pub mod worker;
use std::cell::RefCell;
use std::sync::Arc;
use effectcraft_engine::Session;
use effectcraft_ui_egui::EffectcraftApp;
use serde_json::json;
use wasm_bindgen::prelude::*;
/// Work for the UI thread with access to the app (posted by async tasks: picked files…).
type Action = Box<dyn FnOnce(&mut EffectcraftApp)>;
thread_local! {
static ACTIONS: RefCell<Vec<Action>> = const { RefCell::new(Vec::new()) };
static CTX: RefCell<Option<egui::Context>> = const { RefCell::new(None) };
}
/// Run `f` on the UI thread at the next frame.
pub fn post(f: impl FnOnce(&mut EffectcraftApp) + 'static) {
ACTIONS.with(|a| a.borrow_mut().push(Box::new(f)));
repaint();
}
/// Ask for a UI frame.
pub fn repaint() {
CTX.with(|c| {
if let Some(c) = c.borrow().as_ref() {
c.request_repaint();
}
});
}
/// Open a project or import media from table paths (picked or dropped files).
fn open_or_import(app: &mut EffectcraftApp, paths: Vec<String>) {
let (projects, media): (Vec<String>, Vec<String>) = paths.into_iter().partition(|p| p.to_ascii_lowercase().ends_with(".ecproj"));
if let Some(p) = projects.last()
&& let Err(e) = app.session.execute("file.open", json!({"path": p}))
{
app.ui.status = e.to_string();
}
if !media.is_empty() {
match app.session.execute("file.import", json!({"paths": media})) {
Ok(r) => {
if let Some(errs) = r["errors"].as_array().filter(|e| !e.is_empty()) {
app.ui.status = errs.iter().filter_map(|e| e.as_str()).collect::<Vec<_>>().join("; ");
}
}
Err(e) => app.ui.status = e.to_string(),
}
}
}
/// The eframe app: the shared EffectCraft UI plus the browser host's per-frame duties.
pub struct WebApp {
pub app: EffectcraftApp,
}
impl eframe::App for WebApp {
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
self.app.raw_input_hook(ctx, raw_input);
}
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
let actions = ACTIONS.with(|a| std::mem::take(&mut *a.borrow_mut()));
for f in actions {
f(&mut self.app);
}
// Files dropped on the page: read them, then open/import.
let dropped: Vec<web_sys::File> = ctx.input(|i| i.raw.dropped_files.iter().filter_map(|f| f.web_file().cloned()).collect());
if !dropped.is_empty() {
files::read_files(dropped, |paths| post(move |app| open_or_import(app, paths)));
}
self.app.logic(ctx, frame);
api::poll_replies();
// Render Queue outputs written this frame → downloads.
files::flush_downloads();
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
self.app.ui(ui, frame);
}
}
fn query_flag(name: &str) -> bool {
web_sys::window().and_then(|w| w.location().search().ok()).and_then(|s| web_sys::UrlSearchParams::new_with_str(&s).ok()).is_some_and(|p| p.has(name))
}
/// A session wired for the browser: media decoding from memory, expressions, scripting, Render
/// Queue export to downloads.
pub fn session() -> Session {
let pool = Arc::new(effectcraft_media::MediaPool::new());
Session {
services: Arc::new(files::WebServices),
footage: pool.clone(),
importer: Some(Arc::new(files::WebImporter { pool })),
expr: Some(Arc::new(effectcraft_expr::Expressions)),
expr_check: Some(effectcraft_expr::check_syntax),
script: Some(effectcraft_host::script::runner),
exporter: Some(Arc::new(effectcraft_host::FileExporter { sink: Some(files::export_sink()) })),
..Default::default()
}
}
/// Browser file pickers in place of the desktop's file dialogs.
fn install_hooks(app: &mut EffectcraftApp) {
app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
let accept = exts.iter().map(|e| format!(".{e}")).collect::<Vec<_>>().join(",");
if let Err(e) = files::pick(&accept, true, |paths| post(move |app| open_or_import(app, paths))) {
log::warn!("file picker: {e:?}");
}
// the import happens when the browser hands over the files
Vec::new()
}));
app.hooks.pick_open_project = Some(Box::new(|| {
if let Err(e) = files::pick(".ecproj", false, |paths| post(move |app| open_or_import(app, paths))) {
log::warn!("file picker: {e:?}");
}
None
}));
// Saving downloads the file: the chosen name is all a "save dialog" needs.
app.hooks.pick_save = Some(Box::new(|name: &str| Some(files::path_for(if name.is_empty() { "Untitled Project.ecproj" } else { name }))));
}
/// Entry point (called by the page's bootstrap script once the wasm module is instantiated).
#[wasm_bindgen]
pub async fn start(canvas_id: String) -> Result<(), JsValue> {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
let canvas: web_sys::HtmlCanvasElement = doc.get_element_by_id(&canvas_id).ok_or("no canvas")?.dyn_into()?;
let demo = !query_flag("empty");
let home = query_flag("home");
let runner = eframe::WebRunner::new();
runner
.start(
canvas,
eframe::WebOptions::default(),
Box::new(move |cc| {
CTX.with(|c| *c.borrow_mut() = Some(cc.egui_ctx.clone()));
let mut session = session();
if demo {
let _ = session.execute("file.openDemoProject", json!({}));
}
let mut app = EffectcraftApp::new(session);
app.ui.start_screen = home;
install_hooks(&mut app);
let (tx, rx) = std::sync::mpsc::channel();
api::set_sender(tx);
app = app.with_control(rx);
let backend = cc.wgpu_render_state.as_ref().map(|rs| format!("{:?}", rs.adapter.get_info().backend));
api::set_info("backend", json!(backend));
Ok(Box::new(WebApp { app }))
}),
)
.await?;
// Hide the loading message once the app runs.
if let Some(el) = doc.get_element_by_id("loading") {
el.remove();
}
Ok(())
}
#[cfg(target_arch = "wasm32")]
pub use app::*;
+190
View File
@@ -0,0 +1,190 @@
//! Persistence: the [`crate::store::Store`] mirrored to the browser's storage (the Origin
//! Private File System, IndexedDB where OPFS can't write, memory as a last resort; `js/host.js`).
//!
//! - [`load`] (before the app starts) opens the storage and reads every entry into the mirror.
//! - Changes flush in the background ([`schedule_flush`], wired as the store's change hook):
//! one write per changed key, in order, with nothing written twice concurrently.
//! - The session snapshot ([`snapshot`]) records the open project and editor state every second
//! or so while it changes, so a reload (or a crash) comes back to where the user was
//! ([`restore`]).
use std::cell::Cell;
use effectcraft_engine::Session;
use effectcraft_engine::config::ConfigStore;
use effectcraft_engine::project::Project;
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
use crate::files::STORE;
use crate::store::{SNAPSHOT, SNAPSHOT_META, SnapshotMeta, WebConfig};
#[wasm_bindgen(module = "/js/host.js")]
extern "C" {
#[wasm_bindgen(js_name = storeOpen)]
fn store_open(prefer: &str) -> js_sys::Promise;
#[wasm_bindgen(js_name = storeLoadAll)]
fn store_load_all() -> js_sys::Promise;
#[wasm_bindgen(js_name = storePut)]
fn store_put(key: &str, data: js_sys::Uint8Array, modified: f64) -> js_sys::Promise;
#[wasm_bindgen(js_name = storeDelete)]
fn store_delete(key: &str) -> js_sys::Promise;
#[wasm_bindgen(js_name = storeEstimate)]
pub(crate) fn store_estimate() -> js_sys::Promise;
}
thread_local! {
static FLUSHING: Cell<bool> = const { Cell::new(false) };
static BACKEND: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
static LAST_ERROR: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
static FLUSH_WAITERS: std::cell::RefCell<Vec<js_sys::Function>> = const { std::cell::RefCell::new(Vec::new()) };
static SNAP: Cell<(u64, f64)> = const { Cell::new((u64::MAX, 0.0)) };
static LAST_META: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
}
/// The storage in use: `opfs`, `indexeddb` or `memory`.
pub fn backend() -> String {
BACKEND.with(|b| b.borrow().clone())
}
/// The last storage error (quota exceeded…), if any.
pub fn last_error() -> Option<String> {
LAST_ERROR.with(|e| e.borrow().clone())
}
fn js_err(e: JsValue) -> String {
e.as_string().or_else(|| js_sys::Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_else(|| format!("{e:?}"))
}
/// Open the browser storage and read everything into the store. `prefer` forces a backend
/// (`?storage=indexeddb` / `memory`; empty = OPFS, then IndexedDB). Returns the entry count.
pub async fn load(prefer: &str) -> Result<usize, String> {
let name = JsFuture::from(store_open(prefer)).await.map_err(js_err)?;
BACKEND.with(|b| *b.borrow_mut() = name.as_string().unwrap_or_default());
let all = JsFuture::from(store_load_all()).await.map_err(js_err)?;
let all: js_sys::Array = all.dyn_into().map_err(js_err)?;
let n = all.length() as usize;
{
let mut m = STORE.lock();
for e in all.iter() {
let get = |k: &str| js_sys::Reflect::get(&e, &k.into()).unwrap_or(JsValue::UNDEFINED);
let Some(key) = get("key").as_string() else { continue };
let data: js_sys::Uint8Array = match get("data").dyn_into() {
Ok(d) => d,
Err(_) => continue,
};
m.load(&key, data.to_vec().into(), get("modified").as_f64().unwrap_or(0.0));
}
}
let _ = STORE.on_change.set(schedule_flush);
Ok(n)
}
/// Write pending changes to the browser storage (in the background; one flush at a time).
pub fn schedule_flush() {
if FLUSHING.with(|f| f.replace(true)) {
return; // the running flush picks the new changes up
}
wasm_bindgen_futures::spawn_local(async {
loop {
let pending = STORE.lock().take_pending();
if pending.is_empty() {
break;
}
for p in pending {
let r = match &p.data {
Some(d) => JsFuture::from(store_put(&p.key, js_sys::Uint8Array::from(&d[..]), p.modified)).await,
None => JsFuture::from(store_delete(&p.key)).await,
};
if let Err(e) = r {
let msg = format!("saving {} to browser storage failed: {}", p.key, js_err(e));
log::warn!("{msg}");
LAST_ERROR.with(|l| *l.borrow_mut() = Some(msg));
}
}
}
FLUSHING.with(|f| f.set(false));
for w in FLUSH_WAITERS.with(|w| std::mem::take(&mut *w.borrow_mut())) {
let _ = w.call0(&JsValue::NULL);
}
});
}
/// A promise that resolves once every change so far is in the browser storage.
pub fn flushed() -> js_sys::Promise {
js_sys::Promise::new(&mut |resolve, _| {
if !FLUSHING.with(|f| f.get()) && !STORE.lock().has_pending() {
let _ = resolve.call0(&JsValue::NULL);
} else {
FLUSH_WAITERS.with(|w| w.borrow_mut().push(resolve));
schedule_flush();
}
})
}
/// The config store for the session.
pub fn config() -> WebConfig {
WebConfig::new(STORE.clone())
}
/// Record the open project and editor state when they changed (checked at most every
/// `min_secs`).
pub fn snapshot(s: &Session, now: f64, min_secs: f64) {
let (rev, at) = SNAP.with(|c| c.get());
if now - at < min_secs {
return;
}
SNAP.with(|c| c.set((rev, now)));
let meta = SnapshotMeta { path: s.path.clone(), dirty: s.is_dirty(), state: serde_json::to_value(&s.state).ok(), saved: 0.0 };
let key = serde_json::to_string(&meta).unwrap_or_default();
if rev == s.revision && LAST_META.with(|m| *m.borrow() == key) {
return;
}
let c = config();
if rev != s.revision {
let _ = c.write(SNAPSHOT, &s.project.to_json());
}
let _ = c.write(SNAPSHOT_META, &serde_json::to_string(&SnapshotMeta { saved: js_sys::Date::now(), ..meta }).unwrap_or_default());
SNAP.with(|c| c.set((s.revision, now)));
LAST_META.with(|m| *m.borrow_mut() = key);
}
/// Reopen the last visit's project and editor state. Returns whether there was one.
pub fn restore(s: &mut Session) -> bool {
let c = config();
let (Some(json), Some(meta)) = (c.read(SNAPSHOT), c.read(SNAPSHOT_META)) else { return false };
let meta: SnapshotMeta = serde_json::from_str(&meta).unwrap_or_default();
let p = match Project::from_json(&json) {
Ok(p) => p,
Err(e) => {
log::warn!("restoring the last session: {e}");
return false;
}
};
s.replace_project(p, meta.path.clone());
if let Some(st) = meta.state.and_then(|v| serde_json::from_value(v).ok()) {
s.state = st;
}
if meta.dirty {
// Unsaved changes stay unsaved.
s.bump();
}
SNAP.with(|c| c.set((s.revision, 0.0)));
log::info!("restored the last session ({})", meta.path.as_deref().unwrap_or("untitled"));
true
}
/// Register persisted media with the media pool (so restored projects find their footage).
pub fn register_media(pool: &effectcraft_media::MediaPool) -> usize {
let mut n = 0;
for (path, _, _, persisted) in STORE.files() {
if persisted
&& !path.to_ascii_lowercase().ends_with(".ecproj")
&& let Some(d) = STORE.file(&path)
{
pool.add_bytes(&path, d);
n += 1;
}
}
n
}
+399
View File
@@ -0,0 +1,399 @@
//! 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()
}
}
/// 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 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 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());
}
}
+186
View File
@@ -0,0 +1,186 @@
//! Renders and analyses in Web Workers ([`effectcraft_engine::offload`]).
//!
//! **Page side** — [`WorkerOffload`], the session's [`Offload`]: serializes the job, ships the
//! footage files it reads and the request to a worker from the host's pool (`js/host.js`
//! `workerRun`), and turns the worker's messages back into [`WorkerReply`]s in the job's
//! [`Inbox`]. Rendered files come back as their bytes and download like any render.
//!
//! **Worker side** — `web/worker.js` instantiates the same wasm module (the page's compiled
//! `WebAssembly.Module`, so nothing is compiled twice) and calls [`worker_init`], then
//! [`worker_file`] per footage file and [`worker_job`] per job: a plain engine session (footage
//! from memory, expressions, the exporter) runs it blocking and posts replies as it goes. Each
//! worker has its own memory: no shared-memory threads, so this works on stable Rust.
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::Arc;
use effectcraft_engine::Session;
use effectcraft_engine::offload::{Inbox, Offload, WorkerReply, WorkerRequest};
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
#[wasm_bindgen(module = "/js/host.js")]
extern "C" {
#[wasm_bindgen(js_name = workerRun)]
fn worker_run(id: f64, json: &str, files: js_sys::Array, base: &str, on_message: &js_sys::Function);
#[wasm_bindgen(js_name = workerCancel)]
fn worker_cancel(id: f64);
#[wasm_bindgen(js_name = workerStats)]
pub fn stats() -> JsValue;
}
type OnMessage = Closure<dyn FnMut(String, JsValue, JsValue, JsValue)>;
thread_local! {
/// Message handlers of running jobs (dropped when the job ends).
static HANDLERS: RefCell<HashMap<u64, OnMessage>> = RefCell::new(HashMap::new());
/// The worker's session and its media pool.
static SESSION: RefCell<Option<Session>> = const { RefCell::new(None) };
static POOL: RefCell<Option<Arc<effectcraft_media::MediaPool>>> = const { RefCell::new(None) };
}
/// The page's offload: one worker per running job (idle workers are reused).
pub struct WorkerOffload;
impl Offload for WorkerOffload {
fn start(&self, req: WorkerRequest, inbox: Arc<Inbox>) -> Result<(), String> {
let id = req.id;
let files = js_sys::Array::new();
for p in &req.files {
if let Some(d) = crate::files::get(p) {
files.push(&js_sys::Array::of2(&JsValue::from_str(p), &js_sys::Uint8Array::from(&d[..])));
}
}
let json = serde_json::to_string(&req).map_err(|e| e.to_string())?;
let on: OnMessage = Closure::new(move |kind: String, json: JsValue, path: JsValue, bytes: JsValue| {
match kind.as_str() {
"reply" => {
let Some(t) = json.as_string() else { return };
match serde_json::from_str::<WorkerReply>(&t) {
Ok(r) => {
let done = matches!(r, WorkerReply::Done);
inbox.push(r);
if done {
end(id);
}
}
Err(e) => log::warn!("worker reply: {e}"),
}
}
"file" => {
if let (Some(p), Ok(b)) = (path.as_string(), bytes.dyn_into::<js_sys::Uint8Array>()) {
crate::files::add_output(&p, b.to_vec().into());
}
}
_ => {
inbox.push(WorkerReply::Failed { error: json.as_string().unwrap_or_else(|| "worker failed".into()) });
inbox.push(WorkerReply::Done);
end(id);
}
}
crate::repaint();
});
worker_run(id as f64, &json, files, &crate::page_url(""), on.as_ref().unchecked_ref());
HANDLERS.with(|h| h.borrow_mut().insert(id, on));
Ok(())
}
fn cancel(&self, id: u64) {
worker_cancel(id as f64);
end(id);
}
}
/// Drop a job's handler (after the current call returns).
fn end(id: u64) {
wasm_bindgen_futures::spawn_local(async move {
HANDLERS.with(|h| h.borrow_mut().remove(&id));
});
}
// ---------------------------------------------------------------- worker side
fn post(msg: &JsValue, transfer: Option<&js_sys::Array>) {
let scope: web_sys::DedicatedWorkerGlobalScope = js_sys::global().unchecked_into();
let r = match transfer {
Some(t) => scope.post_message_with_transfer(msg, t),
None => scope.post_message(msg),
};
if let Err(e) = r {
log::warn!("worker: postMessage: {e:?}");
}
}
fn obj(pairs: &[(&str, JsValue)]) -> JsValue {
let o = js_sys::Object::new();
for (k, v) in pairs {
let _ = js_sys::Reflect::set(&o, &JsValue::from_str(k), v);
}
o.into()
}
/// Set up the worker's engine session (called once by `web/worker.js`).
#[wasm_bindgen(js_name = workerInit)]
pub fn worker_init() {
crate::set_worker();
eframe::WebLogger::init(log::LevelFilter::Info).ok();
let pool = Arc::new(effectcraft_media::MediaPool::new());
POOL.with(|p| *p.borrow_mut() = Some(pool.clone()));
// Rendered files go back to the page (transferred, not copied).
let sink: Arc<effectcraft_export::Sink> = Arc::new(|path: &str, data: Vec<u8>| {
let bytes = js_sys::Uint8Array::from(&data[..]);
let buf = bytes.buffer();
post(&obj(&[("type", "file".into()), ("path", path.into()), ("bytes", buf.clone().into())]), Some(&js_sys::Array::of1(&buf)));
});
let s = Session {
services: Arc::new(crate::files::WebServices),
footage: pool.clone(),
importer: Some(Arc::new(crate::files::WebImporter { pool })),
expr: Some(Arc::new(effectcraft_expr::Expressions)),
expr_check: Some(effectcraft_expr::check_syntax),
exporter: Some(Arc::new(effectcraft_host::FileExporter { sink: Some(sink) })),
..Default::default()
};
SESSION.with(|c| *c.borrow_mut() = Some(s));
}
/// A footage file for the jobs that follow.
#[wasm_bindgen(js_name = workerFile)]
pub fn worker_file(path: String, bytes: js_sys::Uint8Array) {
let data: Arc<[u8]> = bytes.to_vec().into();
crate::files::STORE.put_file(&path, data.clone(), false);
POOL.with(|p| {
if let Some(p) = p.borrow().as_ref() {
p.add_bytes(&path, data);
}
});
}
/// Run one job (a JSON [`WorkerRequest`]) and post its replies.
#[wasm_bindgen(js_name = workerJob)]
pub fn worker_job(json: String) {
let send: effectcraft_engine::offload::Post = Rc::new(|r: WorkerReply| {
let done = matches!(r, WorkerReply::Done);
let t = serde_json::to_string(&r).unwrap_or_default();
post(&obj(&[("type", "reply".into()), ("json", t.into()), ("done", done.into())]), None);
});
let req: WorkerRequest = match serde_json::from_str(&json) {
Ok(r) => r,
Err(e) => {
send(WorkerReply::Failed { error: format!("bad request: {e}") });
send(WorkerReply::Done);
return;
}
};
SESSION.with(|c| {
let mut c = c.borrow_mut();
let Some(s) = c.as_mut() else {
send(WorkerReply::Failed { error: "worker not initialised".into() });
send(WorkerReply::Done);
return;
};
effectcraft_engine::offload::run_request(s, req, &send);
});
}
+153 -7
View File
@@ -4,10 +4,15 @@
// cargo xtask web --serve 8765 & # build + serve <target>/web/dist
// node apps/effectcraft-web/tests/smoke.mjs --url http://127.0.0.1:8765/ --out /tmp/ec-web
//
// Steps: load (timing), demo comp in the viewer (screenshot, viewer pixels), `render.frame`
// through `window.effectcraft`, Render Queue GIF and PNG-sequence (.zip) exports (downloads), File ▸ Save As (download),
// re-opening the saved project through `effectcraft.addFile`. Prints a JSON report; exits non-zero
// on failure. `--headed` shows the browser.
// Steps: load (timing), the GPU path (WebGPU → GPU compositor, viewer frames on the GPU), demo
// comp in the viewer (screenshot, viewer pixels), `render.frame` through `window.effectcraft`,
// Render Queue GIF and PNG-sequence (.zip) exports (downloads), a background Render Queue job in
// a Web Worker while the page stays responsive (event-loop gaps measured), audio (the
// AudioContext starts on a user gesture; preview playback feeds it), File ▸ Save As (download),
// re-opening the saved project through `effectcraft.addFile`, persistence across a reload (the
// session, imported media, settings, a project saved to browser storage, Open Recent) and an
// offline reload through the service worker. Prints a JSON report; exits non-zero on failure.
// `--headed` shows the browser.
import { spawn } from "node:child_process";
import { mkdirSync, writeFileSync, readdirSync, statSync, mkdtempSync } from "node:fs";
import { join } from "node:path";
@@ -85,6 +90,22 @@ async function js(expr, timeout = 300000) {
return r.result.value;
}
const check = (cond, msg) => {
if (!cond) throw new Error(`check failed: ${msg}`);
};
// Wait until the app runs (after a navigation or reload).
async function ready(ms = 180000) {
await until("!!(window.effectcraftLoad && (window.effectcraftLoad.readyMs || window.effectcraftLoad.error))", ms);
const load = await js("window.effectcraftLoad");
if (load.error) throw new Error(load.error);
return load;
}
async function idle() {
await until(`effectcraft.execute("renderQueue.list", {}).then(r => !r.rendering)`, 300000, 200);
}
async function shot(name) {
const r = await send("Page.captureScreenshot", { format: "png" });
const p = join(out, `${name}.png`);
@@ -121,11 +142,11 @@ try {
await send("Emulation.setDeviceMetricsOverride", { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
const t0 = Date.now();
await send("Page.navigate", { url });
await until("!!(window.effectcraftLoad && (window.effectcraftLoad.readyMs || window.effectcraftLoad.error))", 180000);
report.load = await js("window.effectcraftLoad");
report.load = await ready();
report.load.wallMs = Date.now() - t0;
if (report.load.error) throw new Error(report.load.error);
report.info = await js("effectcraft.info()");
// Storage: OPFS where the browser writes it (Chrome does), else IndexedDB.
check(["opfs", "indexeddb"].includes(report.info.storage?.backend), `storage backend ${JSON.stringify(report.info.storage)}`);
// The demo comp in the viewer: wait for the first viewer frame (renderMs > 0).
const ms = await until("effectcraft.inspect().then(i => i.renderMs > 0)", 120000, 500);
@@ -133,6 +154,15 @@ try {
const insp = await js("effectcraft.inspect()");
report.steps.viewer = { waitMs: ms, renderMs: insp.renderMs, activeComp: insp.activeComp, window: insp.window, screenshot: await shot("01-demo") };
// GPU: with WebGPU, eframe runs on it and the compositor renders viewer frames on the GPU
// (no readback); without it, WebGL2 / CPU.
const info = await js("effectcraft.info()");
report.steps.gpu = { webgpu: info.webgpu, backend: info.backend, ...info.gpu };
if (info.webgpu && info.backend === "BrowserWebGpu") {
check(info.gpu && info.gpu.compositor, "GPU compositor on WebGPU");
check(info.gpu.viewerOnGpu, "viewer frames on the GPU");
}
// Comp pixels through the API.
const f = await js("effectcraft.renderFrame({max_side: 480}).then(r => ({w: r.width, h: r.height, pngBytes: atob(r.png).length}))");
report.steps.renderFrame = f;
@@ -145,12 +175,74 @@ try {
await js(`effectcraft.execute("renderQueue.render", {})`);
const gif = await waitDownload((n) => n.endsWith(".gif"));
report.steps.gifExport = { ...gif, ms: Date.now() - rqT };
await idle();
// An image sequence arrives as one .zip.
await js(`effectcraft.execute("renderQueue.setRender", {index: 1, render: false})`);
await js(`effectcraft.request("ui.menu.invoke", {id: "renderQueue.add", params: {format: "png", output: "seq_[####].png", resolution: 0.125, timeSpan: "custom", start: 0, end: 0.2}})`);
await js(`effectcraft.execute("renderQueue.render", {})`);
report.steps.pngSequence = await waitDownload((n) => n.endsWith(".zip"));
await idle();
// A background render (`wait: false`, what the Render button does) runs in a Web Worker: the
// page keeps its event loop. Measure the longest gap between 10 ms timers while it renders.
await js(`effectcraft.execute("renderQueue.setRender", {index: 2, render: false})`);
await js(`effectcraft.request("ui.menu.invoke", {id: "renderQueue.add", params: {format: "gif", output: "bg_[compName].gif", resolution: 0.5, timeSpan: "custom", start: 0, end: 4}})`);
report.steps.backgroundRender = await js(`(async () => {
const t0 = performance.now();
const r = await effectcraft.execute("renderQueue.render", {wait: false});
const callMs = performance.now() - t0;
let maxGapMs = 0, last = performance.now(), ticks = 0, progress = 0;
for (;;) {
await new Promise((res) => setTimeout(res, 10));
const now = performance.now();
maxGapMs = Math.max(maxGapMs, now - last);
last = now;
if (++ticks % 5) continue;
const l = await effectcraft.execute("renderQueue.list", {});
if (l.progress) progress = Math.max(progress, l.progress.done);
if (!l.rendering) break;
}
return {startedRendering: r.rendering, callMs, renderMs: performance.now() - t0, maxGapMs, ticks, progressFrames: progress, workers: effectcraft.info().workers};
})()`);
const bg = report.steps.backgroundRender;
bg.download = await waitDownload((n) => n.startsWith("bg_") && n.endsWith(".gif"));
check(bg.startedRendering, "render started in the background");
check(bg.maxGapMs < 400, `event loop blocked ${bg.maxGapMs} ms during the render`);
check(bg.progressFrames > 0, "progress reported while rendering");
// Audio: the AudioContext starts on a user gesture.
report.steps.audio = { before: (await js("effectcraft.info()")).audio };
check(report.steps.audio.before.state !== "running", "audio context not running before a gesture");
// (a click in the window's bottom-right corner, the status bar: modifier keys alone don't
// count as user activation)
await send("Input.dispatchMouseEvent", { type: "mousePressed", x: 1590, y: 995, button: "left", clickCount: 1 });
await send("Input.dispatchMouseEvent", { type: "mouseReleased", x: 1590, y: 995, button: "left", clickCount: 1 });
await until("effectcraft.info().audio.state === 'running'", 10000);
report.steps.audio.afterGesture = (await js("effectcraft.info()")).audio;
// A 2 s tone in the comp; preview playback feeds the output and the meters.
await js(`(async () => {
const rate = 48000, n = 2 * rate, b = new ArrayBuffer(44 + n * 4), v = new DataView(b);
const w = (o, t) => [...t].forEach((c, i) => v.setUint8(o + i, c.charCodeAt(0)));
w(0, "RIFF"); v.setUint32(4, 36 + n * 4, true); w(8, "WAVE"); w(12, "fmt "); v.setUint32(16, 16, true);
v.setUint16(20, 1, true); v.setUint16(22, 2, true); v.setUint32(24, rate, true); v.setUint32(28, rate * 4, true);
v.setUint16(32, 4, true); v.setUint16(34, 16, true); w(36, "data"); v.setUint32(40, n * 4, true);
for (let i = 0; i < n; i++) { const x = Math.round(Math.sin(2 * Math.PI * 440 * i / rate) * 0.5 * 32767); v.setInt16(44 + 4 * i, x, true); v.setInt16(46 + 4 * i, x, true); }
return effectcraft.addFile(new File([b], "tone.wav"));
})()`);
await until(`effectcraft.execute("layer.addItem", {item: "tone.wav", time: 0}).then(() => true, () => false)`, 20000, 300);
await js(`effectcraft.request("ui.playback", {action: "stop"})`);
await js(`effectcraft.execute("time.set", {time: 0})`);
await js(`effectcraft.request("ui.playback", {action: "play"})`);
await sleep(1500);
const pb = await js(`effectcraft.request("ui.playback", {action: "status"})`);
const au = (await js("effectcraft.info()")).audio;
await js(`effectcraft.request("ui.playback", {action: "stop"})`);
report.steps.audio.playing = { playback: pb, output: au };
check(pb.audio, "audio preview running");
check(au.posted > 0.5 * au.sampleRate, `audio fed (${au.posted} frames)`);
check(au.played > 0, "audio clock advancing");
check(Math.max(...(pb.levelsDb || [-99])) > -20, `meters (${JSON.stringify(pb.levelsDb)})`);
// Save As → .ecproj download; reopen it.
await js(`effectcraft.execute("file.saveAs", {path: "/smoke.ecproj"})`);
@@ -162,6 +254,60 @@ try {
if (!comps) throw new Error("reopened project has no active comp");
report.steps.reopen = { ...opened, activeComp: comps };
report.steps.final = { screenshot: await shot("02-after") };
// Persistence: the session, imported media, settings and a project saved to browser storage
// survive a reload; File ▸ Open Recent reopens the project.
await js(`effectcraft.execute("layer.newSolid", {name: "Persisted Solid", color: "#22aa66"})`);
await js(`effectcraft.execute("prefs.set", {key: "general.recentItems", value: 7})`);
const saved = await js(`effectcraft.saveToBrowser("/persist-test.ecproj")`);
await js(`effectcraft.execute("layer.newSolid", {name: "Unsaved Solid"})`);
await sleep(1500); // the session snapshot is taken at most every second
await js("effectcraft.flush()");
const before = await js("effectcraft.listStored()");
const tr = Date.now();
await send("Page.reload", {});
await sleep(500);
const load2 = await ready();
const after = await js("effectcraft.listStored()");
const info2 = await js("effectcraft.info()");
const layers = await js(`effectcraft.execute("comp.info", {}).then(c => c.layers.map(l => l.name))`);
const recentItems = await js(`effectcraft.execute("prefs.get", {key: "general.recentItems"})`);
const rec = await js(`effectcraft.execute("file.recoveryInfo", {})`);
const paths = after.files.map((f) => f.path);
report.steps.persistence = { saved, reloadMs: Date.now() - tr, load: load2, restored: info2.restored, storage: info2.storage, layers, recentItems, recent: rec.recentProjects, stored: paths, usage: after.usage, filesBefore: before.files.length };
check(info2.restored, "session restored after reload");
check(layers.includes("Persisted Solid") && layers.includes("Unsaved Solid"), `layers after reload: ${layers}`);
check(paths.includes("/tone.wav") && paths.includes("/persist-test.ecproj"), `stored files: ${paths}`);
check(recentItems === 7, `setting after reload: ${recentItems}`);
check(rec.recentProjects[0] === "/persist-test.ecproj", `recent projects: ${rec.recentProjects}`);
await js(`effectcraft.execute("file.openRecent", {index: 0})`);
const reopened = await js(`effectcraft.execute("comp.info", {}).then(c => c.layers.map(l => l.name))`);
check(reopened.includes("Persisted Solid") && !reopened.includes("Unsaved Solid"), `Open Recent: ${reopened}`);
// The imported tone is still in the project and decodes from storage after the reload.
const tone = await js(`effectcraft.execute("layer.addItem", {item: "tone.wav", time: 0}).then(() => "ok", (e) => String(e))`);
check(tone === "ok", `tone.wav after reload: ${tone}`);
report.steps.persistence.openRecent = reopened;
// PWA: manifest, service worker in control, and an offline reload.
const pwa = await js(`(async () => {
const m = await (await fetch("manifest.webmanifest")).json();
const reg = await navigator.serviceWorker.getRegistration();
const keys = await caches.keys();
const cached = keys.length ? (await (await caches.open(keys[0])).keys()).length : 0;
return {manifest: m.name, icons: m.icons.length, display: m.display, sw: !!(reg && reg.active), controlled: !!navigator.serviceWorker.controller, caches: keys, cached};
})()`);
check(pwa.sw && pwa.cached > 4, `service worker: ${JSON.stringify(pwa)}`);
await send("Network.enable", {});
await send("Network.emulateNetworkConditions", { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
await send("Page.reload", {});
await sleep(500);
const offline = await ready();
const info3 = await js("effectcraft.info()");
await send("Network.emulateNetworkConditions", { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
report.steps.pwa = { ...pwa, offlineLoad: offline, offlineControlled: info3.serviceWorker, crossOriginIsolated: info3.crossOriginIsolated };
check(info3.serviceWorker, "offline reload served by the service worker");
check(info3.crossOriginIsolated, "cross-origin isolated under the service worker");
report.steps.final2 = { screenshot: await shot("03-offline") };
} catch (e) {
failed = true;
report.error = String(e && e.stack || e);
+44
View File
@@ -0,0 +1,44 @@
// EffectCraft audio output (AudioWorklet): plays interleaved stereo blocks posted by the page
// (src/audio.rs feeds them from the preview mixdown). Running dry plays silence, reported back
// as underrun frames.
class EffectcraftOutput extends AudioWorkletProcessor {
constructor() {
super();
this.queue = [];
this.offset = 0;
this.stopped = false;
this.port.onmessage = (e) => {
if (e.data === "stop") this.stopped = true;
else this.queue.push(e.data);
};
}
process(_inputs, outputs) {
const out = outputs[0];
const l = out[0];
const r = out[1] ?? out[0];
let i = 0;
while (i < l.length && this.queue.length) {
const b = this.queue[0];
const n = Math.min(l.length - i, (b.length - this.offset) >> 1);
for (let k = 0; k < n; k++) {
l[i + k] = b[this.offset + 2 * k];
r[i + k] = b[this.offset + 2 * k + 1];
}
i += n;
this.offset += 2 * n;
if (this.offset >= b.length) {
this.queue.shift();
this.offset = 0;
}
}
if (i < l.length) {
l.fill(0, i);
r.fill(0, i);
if (!this.stopped) this.port.postMessage({ underrun: l.length - i });
}
return !this.stopped;
}
}
registerProcessor("effectcraft-output", EffectcraftOutput);
+30 -2
View File
@@ -4,8 +4,12 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#1b1c1f">
<meta name="description" content="EffectCraft: motion graphics and visual effects in the browser">
<title>EffectCraft</title>
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="manifest" href="manifest.webmanifest">
<link rel="apple-touch-icon" href="icon-256.png">
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #1b1c1f; }
canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; outline: none; }
@@ -46,10 +50,34 @@
}
return ec.addFile(name, bytes);
},
info: () => ({ ...ec.info(), crossOriginIsolated: self.crossOriginIsolated, load: window.effectcraftLoad }),
info: () => ({
...ec.info(),
crossOriginIsolated: self.crossOriginIsolated,
webgpu: !!navigator.gpu,
serviceWorker: !!(navigator.serviceWorker && navigator.serviceWorker.controller),
load: window.effectcraftLoad,
}),
// Browser storage (OPFS / IndexedDB): save without downloading, list, remove, wait for writes.
saveToBrowser: (path) => ec.saveToBrowser(path ?? undefined),
listStored: () => ec.listStored(),
removeStored: (path) => ec.removeStored(path),
flush: () => ec.flush(),
};
// Offline start and install (PWA): the service worker caches the app's files.
if ("serviceWorker" in navigator && !new URLSearchParams(location.search).has("nosw")) {
navigator.serviceWorker.register("sw.js").catch((e) => console.warn("service worker:", e));
}
try {
await init();
// Compile once: the page and its render workers instantiate the same module.
const wasmUrl = new URL("effectcraft_web_bg.wasm", location.href);
let module;
try {
module = await WebAssembly.compileStreaming(fetch(wasmUrl));
} catch {
module = await WebAssembly.compile(await (await fetch(wasmUrl)).arrayBuffer());
}
window.effectcraftModule = module;
await init({ module_or_path: module });
window.effectcraftLoad.wasmMs = performance.now() - t0;
await ec.start("effectcraft_canvas");
window.effectcraftLoad.readyMs = performance.now() - t0;
@@ -0,0 +1,17 @@
{
"name": "EffectCraft",
"short_name": "EffectCraft",
"description": "Motion graphics and visual effects compositor, in the browser",
"id": "./",
"start_url": "./",
"scope": "./",
"display": "standalone",
"background_color": "#1b1c1f",
"theme_color": "#1b1c1f",
"categories": ["graphics", "photo", "productivity"],
"icons": [
{ "src": "favicon.svg", "sizes": "any", "type": "image/svg+xml" },
{ "src": "icon-256.png", "sizes": "256x256", "type": "image/png" },
{ "src": "icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
+45
View File
@@ -0,0 +1,45 @@
// EffectCraft service worker: the app works offline once loaded, and installs as an app.
//
// `cargo xtask web` fills in the build's file list and version. Every file is precached into a
// cache named after the version, served cache-first (responses keep the server's headers, so
// the page stays cross-origin isolated: COOP/COEP), and old versions are dropped once the new
// one activates. Anything else same-origin is fetched from the network and cached for next time.
const VERSION = "__EC_VERSION__";
const FILES = __EC_FILES__;
const CACHE = `effectcraft-${VERSION}`;
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(CACHE)
.then((c) => c.addAll(FILES.map((f) => new Request(f, { cache: "reload" }))))
.then(() => self.skipWaiting()),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((k) => k.startsWith("effectcraft-") && k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim()),
);
});
self.addEventListener("fetch", (event) => {
const req = event.request;
const url = new URL(req.url);
if (req.method !== "GET" || url.origin !== self.location.origin) return;
event.respondWith(
(async () => {
const cache = await caches.open(CACHE);
// The page itself regardless of its query (?empty, ?demo…).
const key = req.mode === "navigate" ? new URL("./", self.registration.scope).href : req;
const hit = await cache.match(key, { ignoreSearch: req.mode === "navigate" });
if (hit) return hit;
const res = await fetch(req);
if (res.ok && res.type === "basic") cache.put(req, res.clone());
return res;
})(),
);
});
+19
View File
@@ -0,0 +1,19 @@
// An EffectCraft engine instance in a Web Worker: renders the Render Queue and runs analyses
// off the page's thread (src/worker.rs, crates/engine/src/offload.rs). The page sends the
// compiled module, then footage files and jobs; the worker replies with JSON messages and
// rendered files.
import init, * as ec from "./effectcraft_web.js";
let ready = null;
self.onmessage = async (e) => {
const m = e.data;
if (m.type === "init") {
ready = init({ module_or_path: m.module ?? new URL("effectcraft_web_bg.wasm", m.base) }).then(() => ec.workerInit());
await ready;
self.postMessage({ type: "ready" });
return;
}
await ready;
if (m.type === "file") ec.workerFile(m.path, m.bytes);
else if (m.type === "job") ec.workerJob(m.json);
};