diff --git a/Cargo.lock b/Cargo.lock index 0d3ae06b..019ecb4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1511,10 +1511,12 @@ dependencies = [ "egui", "js-sys", "log", + "serde", "serde_json", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "web-time", ] [[package]] diff --git a/ROADMAP.md b/ROADMAP.md index 55b05822..f50e2146 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -20,7 +20,7 @@ once. | M11 | Animation tools: audio playback, meters and waveforms, Lottie import and export (done); presets, Motion Sketch, Wiggler | In progress | | M12 | Performance: layer cache and parallel compositing (done), GPU compositing, disk cache, motion tracking | In progress | | M13+ | Puppet, paint, roto, motion tracking, a plugin API | Planned | -| M15 | The web app (WebAssembly, WebGPU) | Done, single-threaded | +| M15 | The web app (WebAssembly, WebGPU) | Done: browser storage, Web Audio, renders and analyses in Web Workers, offline install ([docs/web.md](docs/web.md)) | | M14 | Built for agents: MCP server, command-line tool, control channel; Settings, keyboard shortcut editor, auto-save and crash recovery ([docs/preferences.md](docs/preferences.md)) | Done | ## How far from full parity diff --git a/apps/effectcraft-web/Cargo.toml b/apps/effectcraft-web/Cargo.toml index d3f8aebb..03663089 100644 --- a/apps/effectcraft-web/Cargo.toml +++ b/apps/effectcraft-web/Cargo.toml @@ -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", diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js new file mode 100644 index 00000000..7b6200eb --- /dev/null +++ b/apps/effectcraft-web/js/host.js @@ -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 }; +} diff --git a/apps/effectcraft-web/src/api.rs b/apps/effectcraft-web/src/api.rs index 5579d111..57cfd98d 100644 --- a/apps/effectcraft-web/src/api.rs +++ b/apps/effectcraft-web/src/api.rs @@ -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 `/.ecproj`). Resolves with `{path, bytes}`. +#[wasm_bindgen(js_name = saveToBrowser)] +pub fn save_to_browser(path: Option) -> 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 = 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 = + 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| { diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs new file mode 100644 index 00000000..0076cd4f --- /dev/null +++ b/apps/effectcraft-web/src/app.rs @@ -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; + +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> = const { RefCell::new(Vec::new()) }; + static CTX: RefCell> = 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) { + let (projects, media): (Vec, Vec) = 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::>().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 = 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::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 { + 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), + ..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::>().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(()) +} diff --git a/apps/effectcraft-web/src/audio.rs b/apps/effectcraft-web/src/audio.rs new file mode 100644 index 00000000..c16f3092 --- /dev/null +++ b/apps/effectcraft-web/src/audio.rs @@ -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>, + /// Sample frames handed to the output since start. + posted: u64, + buf: Vec, +} + +/// The Settings ▸ Audio factory: Web Audio's default output (`None` without Web Audio). +pub fn open(_: &effectcraft_ui_egui::audio::AudioOutput) -> Option> { + let rate = sample_rate(); + (rate > 0.0).then(|| Box::new(WebAudioOut { rate: rate as u32, feed: None, posted: 0, buf: Vec::new() }) as Box) +} + +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) -> 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(); + } + } +} diff --git a/apps/effectcraft-web/src/files.rs b/apps/effectcraft-web/src/files.rs index 853a92e6..78700e28 100644 --- a/apps/effectcraft-web/src/files.rs +++ b/apps/effectcraft-web/src/files.rs @@ -1,39 +1,55 @@ -//! Files in the browser: an in-memory file table in place of the file system, `` -//! 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, `` +//! pickers for Open/Import, and downloads for what the user saves and renders. //! //! Picked or dropped files are read into the table under `/`; 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>>> = Mutex::new(None); +use crate::store::Store; + +/// The page's store (each worker has its own, unpersisted). +pub static STORE: LazyLock> = 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> = Mutex::new(Vec::new()); +/// Saves that write to browser storage only (no download): `api.saveToBrowser`. +static QUIET: Mutex> = Mutex::new(Vec::new()); -fn with_files(f: impl FnOnce(&mut HashMap>) -> 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> { - 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 { - Arc::new(|path: &str, data: Vec| { - put(path, data.into()); - OUTPUTS.lock().unwrap_or_else(|e| e.into_inner()).push(path.to_string()); - }) + Arc::new(|path: &str, data: Vec| add_output(path, data.into())) } /// Download the render outputs written since the last call. diff --git a/apps/effectcraft-web/src/lib.rs b/apps/effectcraft-web/src/lib.rs index edd90e31..59de4433 100644 --- a/apps/effectcraft-web/src/lib.rs +++ b/apps/effectcraft-web/src/lib.rs @@ -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 | `` 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; - -thread_local! { - static ACTIONS: RefCell> = const { RefCell::new(Vec::new()) }; - static CTX: RefCell> = 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) { - let (projects, media): (Vec, Vec) = 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::>().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 = 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::>().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::*; diff --git a/apps/effectcraft-web/src/persist.rs b/apps/effectcraft-web/src/persist.rs new file mode 100644 index 00000000..6735d066 --- /dev/null +++ b/apps/effectcraft-web/src/persist.rs @@ -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 = const { Cell::new(false) }; + static BACKEND: std::cell::RefCell = const { std::cell::RefCell::new(String::new()) }; + static LAST_ERROR: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; + static FLUSH_WAITERS: std::cell::RefCell> = 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 = 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 { + 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 { + 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 +} diff --git a/apps/effectcraft-web/src/store.rs b/apps/effectcraft-web/src/store.rs new file mode 100644 index 00000000..0e4ea029 --- /dev/null +++ b/apps/effectcraft-web/src/store.rs @@ -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/` | settings (`prefs.json`), shortcut presets, the recovery sentinel, the session snapshot | +//! | `files` | 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>, + pub modified: f64, +} + +/// The in-memory copy of the store plus the writes not yet flushed. +#[derive(Default)] +pub struct Mirror { + entries: BTreeMap, + /// Keys changed since the last [`Mirror::take_pending`], in first-change order. + dirty: Vec, +} + +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 { + 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, + /// Called after every change that needs flushing (the web host schedules a flush). + pub on_change: OnceLock, + /// Clock in milliseconds since the Unix epoch (tests pin it). + pub clock: OnceLock 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> { + 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, + files: WebFiles, +} + +impl WebConfig { + pub fn new(store: Arc) -> WebConfig { + WebConfig { files: WebFiles(store.clone()), store } + } +} + +/// [`FileOps`] on the store's file table. +pub struct WebFiles(pub Arc); + +impl ConfigStore for WebConfig { + fn read(&self, name: &str) -> Option { + 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 { + 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, + /// Unsaved changes when the snapshot was taken. + pub dirty: bool, + /// Viewer, timeline and selection state. + pub state: Option, + /// Milliseconds since the Unix epoch. + pub saved: f64, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn store() -> Arc { + 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 = 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::(&t).unwrap(), m); + assert_eq!(serde_json::from_str::("{}").unwrap(), SnapshotMeta::default()); + } +} diff --git a/apps/effectcraft-web/src/worker.rs b/apps/effectcraft-web/src/worker.rs new file mode 100644 index 00000000..2d96f641 --- /dev/null +++ b/apps/effectcraft-web/src/worker.rs @@ -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; + +thread_local! { + /// Message handlers of running jobs (dropped when the job ends). + static HANDLERS: RefCell> = RefCell::new(HashMap::new()); + /// The worker's session and its media pool. + static SESSION: RefCell> = const { RefCell::new(None) }; + static POOL: RefCell>> = 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) -> 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::(&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::()) { + 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 = Arc::new(|path: &str, data: Vec| { + 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); + }); +} diff --git a/apps/effectcraft-web/tests/smoke.mjs b/apps/effectcraft-web/tests/smoke.mjs index de2f7f53..3ba19343 100644 --- a/apps/effectcraft-web/tests/smoke.mjs +++ b/apps/effectcraft-web/tests/smoke.mjs @@ -4,10 +4,15 @@ // cargo xtask web --serve 8765 & # build + serve /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); diff --git a/apps/effectcraft-web/web/audio-worklet.js b/apps/effectcraft-web/web/audio-worklet.js new file mode 100644 index 00000000..97756c52 --- /dev/null +++ b/apps/effectcraft-web/web/audio-worklet.js @@ -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); diff --git a/apps/effectcraft-web/web/index.html b/apps/effectcraft-web/web/index.html index 3e6cbf06..7c6c6c3c 100644 --- a/apps/effectcraft-web/web/index.html +++ b/apps/effectcraft-web/web/index.html @@ -4,8 +4,12 @@ + + EffectCraft + +