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
parent c406b71cba
commit 0bee1ab2da
41 changed files with 3230 additions and 319 deletions

2
Cargo.lock generated
View File

@ -1511,10 +1511,12 @@ dependencies = [
"egui",
"js-sys",
"log",
"serde",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"web-time",
]
[[package]]

View File

@ -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

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",

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 };
}

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| {

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(())
}

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();
}
}
}

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.

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::*;

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
}

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());
}
}

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);
});
}

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);

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);

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;

View File

@ -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" }
]
}

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;
})(),
);
});

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);
};

View File

@ -13,7 +13,7 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::config::ConfigStore;
use crate::config::{ConfigStore, FileOps, StdFiles};
use crate::prefs::Prefs;
/// Folder created next to a project for its auto-saves.
@ -68,16 +68,12 @@ pub fn folder(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>) ->
/// Existing auto-saves of `stem` in `dir`: (slot, path, modified).
pub fn existing(dir: &Path, stem: &str) -> Vec<(u32, PathBuf, std::time::SystemTime)> {
let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
let mut v: Vec<_> = rd
.flatten()
.filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string();
let n = slot_of(stem, &name)?;
let m = e.metadata().ok()?.modified().ok()?;
Some((n, e.path(), m))
})
.collect();
existing_in(&StdFiles, dir, stem)
}
/// [`existing`] through `fs`.
pub fn existing_in(fs: &dyn FileOps, dir: &Path, stem: &str) -> Vec<(u32, PathBuf, std::time::SystemTime)> {
let mut v: Vec<_> = fs.list(dir).into_iter().filter_map(|(name, m)| Some((slot_of(stem, &name)?, dir.join(&name), m))).collect();
v.sort_by_key(|x| x.0);
v
}
@ -95,18 +91,29 @@ pub fn next_slot(existing: &[(u32, PathBuf, std::time::SystemTime)], last: Optio
/// Write one auto-save of `json` for the project at `project` and rotate. Returns its path.
pub fn write(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>, last_slot: Option<u32>, json: &str) -> std::io::Result<(PathBuf, u32)> {
write_in(&StdFiles, prefs, project, fallback, last_slot, json)
}
/// [`write`] through `fs`.
pub fn write_in(
fs: &dyn FileOps,
prefs: &Prefs,
project: Option<&str>,
fallback: Option<&Path>,
last_slot: Option<u32>,
json: &str,
) -> std::io::Result<(PathBuf, u32)> {
let dir = folder(prefs, project, fallback).ok_or_else(|| std::io::Error::other("no auto-save folder for an untitled project"))?;
std::fs::create_dir_all(&dir)?;
let stem = project_stem(project);
let max = prefs.auto_save.max_versions.max(1);
let have = existing(&dir, &stem);
let have = existing_in(fs, &dir, &stem);
let slot = next_slot(&have, last_slot, max);
let path = dir.join(file_name(&stem, slot));
crate::config::atomic_write(&path, json.as_bytes())?;
fs.write(&path, json.as_bytes())?;
// Max versions lowered: drop slots above it.
for (n, p, _) in have {
if n > max {
let _ = std::fs::remove_file(p);
let _ = fs.remove(&p);
}
}
Ok((path, slot))
@ -114,8 +121,13 @@ pub fn write(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>, last
/// The newest auto-save of a project, if any.
pub fn latest(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>) -> Option<PathBuf> {
latest_in(&StdFiles, prefs, project, fallback)
}
/// [`latest`] through `fs`.
pub fn latest_in(fs: &dyn FileOps, prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>) -> Option<PathBuf> {
let dir = folder(prefs, project, fallback)?;
existing(&dir, &project_stem(project)).into_iter().max_by_key(|e| e.2).map(|e| e.1)
existing_in(fs, &dir, &project_stem(project)).into_iter().max_by_key(|e| e.2).map(|e| e.1)
}
/// What the crash-recovery sentinel records.
@ -151,8 +163,8 @@ pub fn begin(store: &dyn ConfigStore, prefs: &Prefs) -> Option<Recovery> {
let autosave = s
.autosave
.clone()
.filter(|p| Path::new(p).is_file())
.or_else(|| latest(prefs, s.project.as_deref(), fallback.as_deref()).map(|p| p.to_string_lossy().to_string()));
.filter(|p| store.files().is_file(Path::new(p)))
.or_else(|| latest_in(store.files(), prefs, s.project.as_deref(), fallback.as_deref()).map(|p| p.to_string_lossy().to_string()));
Some(Recovery { project: s.project, autosave, dirty: s.dirty })
}

View File

@ -69,7 +69,7 @@ fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
fn increment_save(s: &mut Session, _: &Value) -> Result<Value> {
let cur = s.path.clone().ok_or_else(|| bad("file.incrementAndSave", "save the project first"))?;
// After Effects: `Intro.aep` → `Intro 2.aep` → `Intro 3.aep`.
let next = crate::autosave::increment_path(&cur, |p| std::path::Path::new(p).exists());
let next = crate::autosave::increment_path(&cur, |p| s.file_ops().is_file(std::path::Path::new(p)));
save_to(s, &next)
}

View File

@ -239,7 +239,7 @@ fn auto_save(s: &mut Session, _: &Value) -> Result<Value> {
fn recovery_info(s: &mut Session, _: &Value) -> Result<Value> {
let root = s.default_autosave_root();
let latest = crate::autosave::latest(&s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string());
let latest = crate::autosave::latest_in(s.file_ops(), &s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string());
Ok(json!({
"lastAutoSave": s.autosave.last_path,
"latestOnDisk": latest,

View File

@ -1,7 +1,11 @@
//! Persistent configuration storage (Settings, keyboard shortcut presets, the crash-recovery
//! sentinel). The engine only sees the [`ConfigStore`] trait; frontends choose where it lives:
//! the desktop app uses a [`DirConfig`] in the platform config directory, the web app can back it
//! with `localStorage`, tests and headless runs use [`MemoryConfig`] (or none at all).
//! the desktop app uses a [`DirConfig`] in the platform config directory, the web app backs it
//! with the browser's Origin Private File System (IndexedDB fallback), tests and headless runs use
//! [`MemoryConfig`] (or none at all).
//!
//! Auto-saves are project files, not settings, but they live wherever the store says: the store's
//! [`FileOps`] (the file system by default; the browser's storage on the web).
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
@ -12,11 +16,52 @@ pub trait ConfigStore: Send + Sync {
fn read(&self, name: &str) -> Option<String>;
fn write(&self, name: &str, data: &str) -> std::io::Result<()>;
fn remove(&self, name: &str) -> std::io::Result<()>;
/// A directory on disk next to the configuration (default Auto-Save folder for untitled
/// projects); `None` for stores that aren't on the file system.
/// The folder next to the configuration (default Auto-Save folder for untitled projects),
/// in the namespace of [`ConfigStore::files`]; `None` when there is none.
fn dir(&self) -> Option<PathBuf> {
None
}
/// Where auto-saves are listed, written and found (default: the file system).
fn files(&self) -> &dyn FileOps {
&StdFiles
}
}
/// The file operations auto-save and crash recovery need.
pub trait FileOps: Send + Sync {
/// Files directly in `dir`: (file name, modified time).
fn list(&self, dir: &Path) -> Vec<(String, std::time::SystemTime)>;
/// Write `data` to `path` atomically, creating its folder.
fn write(&self, path: &Path, data: &[u8]) -> std::io::Result<()>;
fn remove(&self, path: &Path) -> std::io::Result<()>;
fn is_file(&self, path: &Path) -> bool;
}
/// [`FileOps`] on the file system.
pub struct StdFiles;
impl FileOps for StdFiles {
fn list(&self, dir: &Path) -> Vec<(String, std::time::SystemTime)> {
let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
rd.flatten()
.filter_map(|e| {
let m = e.metadata().ok().filter(|m| m.is_file())?.modified().ok()?;
Some((e.file_name().to_string_lossy().to_string(), m))
})
.collect()
}
fn write(&self, path: &Path, data: &[u8]) -> std::io::Result<()> {
if let Some(d) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(d)?;
}
atomic_write(path, data)
}
fn remove(&self, path: &Path) -> std::io::Result<()> {
std::fs::remove_file(path)
}
fn is_file(&self, path: &Path) -> bool {
path.is_file()
}
}
/// In-memory store (tests, headless sessions).

View File

@ -15,6 +15,7 @@ pub mod demo;
pub mod links;
pub mod mask_track;
pub mod menus;
pub mod offload;
pub mod prefs;
pub mod render_queue;
pub mod roto;
@ -297,6 +298,11 @@ pub struct Session {
/// The JavaScript scripting engine (set by the host that links `effectcraft-script`):
/// `script.run`, File ▸ Scripts ▸ Run Script File… (`.jsx`/`.js`) and the Script Console.
pub script: Option<ScriptRunner>,
/// Runs renders and analyses off the UI thread where there are no threads (the web app's
/// Web Workers, see [`offload`]); `None` = threads (desktop) or inline (wasm32).
pub offload: Option<Arc<dyn offload::Offload>>,
/// Analyses running in the [`Session::offload`] worker.
pub offloaded: Vec<offload::OffloadedJob>,
}
/// A script to run (see [`Session::script`]).
@ -348,6 +354,8 @@ impl Default for Session {
autosave: autosave::AutoSaveState::default(),
snapshot: None,
script: None,
offload: None,
offloaded: vec![],
}
}
}

View File

@ -195,6 +195,7 @@ fn run_work(w: MaskWork, shared: &MaskTrackShared) {
s.elapsed = t0.elapsed().as_secs_f64();
s.fps = if s.elapsed > 0.0 { k as f64 / s.elapsed } else { 0.0 };
s.time = w.times[k].seconds();
crate::offload::report(s.done, s.total);
}
let mut s = lock(&shared.state);
s.elapsed = t0.elapsed().as_secs_f64();
@ -213,16 +214,19 @@ fn write_frames(p: &mut Project, job: &MaskTrackJob, frames: &[MaskFrame]) {
impl Session {
/// Whether a mask track is running.
pub fn is_mask_tracking(&self) -> bool {
self.mask_job.as_ref().is_some_and(|j| !j.is_finished())
self.mask_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(crate::offload::JobKind::MaskTrack).is_some()
}
/// Live progress of the running (or just finished) mask track.
pub fn mask_track_progress(&self) -> Option<TrackProgress> {
self.mask_job.as_ref().map(|j| j.progress())
self.mask_job.as_ref().map(|j| j.progress()).or_else(|| self.offloaded(crate::offload::JobKind::MaskTrack).map(|j| j.progress.track()))
}
/// Cancel the running mask track (frames tracked so far are kept).
pub fn stop_mask_track(&mut self) -> bool {
if self.cancel_offloaded(crate::offload::JobKind::MaskTrack) {
return true;
}
match &self.mask_job {
Some(j) if !j.is_finished() => {
j.shared.cancel.store(true, Ordering::Relaxed);
@ -237,6 +241,10 @@ impl Session {
return Err("a track analysis is already running".into());
}
self.poll_mask_track();
if !wait && self.offloads() {
let MaskWork { comp, layer, path, method, times, .. } = work;
return self.offload_analysis(crate::offload::WorkerJob::MaskTrack { comp, layer, mask, direction, path, method, times });
}
let wait = wait || cfg!(target_arch = "wasm32");
self.history.undo.push(("Track Mask".into(), self.project.clone()));
self.history.redo.clear();

View File

@ -0,0 +1,741 @@
//! Background jobs where the platform has no threads (the browser).
//!
//! On the desktop, renders and analyses run on threads that share the session's project and
//! footage. A wasm build without atomics has one thread per instance, so the web app runs a
//! second engine instance in a Web Worker instead and talks to it with messages: an
//! [`Offload`] (set as [`Session::offload`]) ships a [`WorkerRequest`] — the serialized project,
//! the footage files it reads and the job — and feeds the worker's [`WorkerReply`]s back into an
//! [`Inbox`] that the session drains every frame ([`Session::poll_render`],
//! [`Session::poll_offload`]). The worker side is [`run_request`]: a plain session fed the
//! project runs the job blocking and reports through a callback.
//!
//! | Job | Worker runs | Reply applied on the UI thread |
//! |---|---|---|
//! | Render Queue | the export of every queued item | item status, progress; files arrive through the host |
//! | Warp Stabilizer analysis | [`Session::start_warp`] | the effect's property group (one undo step) |
//! | Track Motion / Stabilize Motion | the tracker analysis | the tracker group |
//! | Mask tracking | the mask track | the mask group |
//! | Roto Brush Freeze | the freeze | the effect group |
//!
//! Roto Brush propagation fills the session's segmentation cache rather than the project, so it
//! keeps running on the UI thread.
//!
//! Cancelling terminates the worker: what an analysis tracked so far is dropped and the Render
//! Queue item being rendered becomes "User Stopped".
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use effectcraft_keyframe::ShapePath;
use effectcraft_project::render_queue::{RenderQueueItem, RenderStatus};
use effectcraft_project::tracking::TrackerSettings;
use effectcraft_project::{ItemId, ItemKind, LayerId, Project, PropGroup, Uid};
use effectcraft_time::Tick;
use serde::{Deserialize, Serialize};
use crate::mask_track::MaskMethod;
use crate::render_queue::{ItemUpdate, JobShared, JobState};
use crate::tracking::PointValues;
use crate::{Event, Session};
/// One job for a worker.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkerRequest {
pub id: u64,
/// The project ([`Project::to_json`]).
pub project: String,
/// Footage files the job reads; the host ships their bytes before the request.
pub files: Vec<String>,
pub job: WorkerJob,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum WorkerJob {
/// Render Queue: per item, one (item with that output module, resolved output path) per
/// output module.
Render {
items: Vec<Vec<(RenderQueueItem, String)>>,
},
Warp {
comp: ItemId,
layer: LayerId,
effect: Uid,
},
RotoFreeze {
comp: ItemId,
layer: LayerId,
effect: Uid,
},
Track {
comp: ItemId,
layer: LayerId,
tracker: Uid,
direction: crate::tracking::Direction,
settings: TrackerSettings,
points: Vec<PointValues>,
times: Vec<Tick>,
},
MaskTrack {
comp: ItemId,
layer: LayerId,
mask: Uid,
direction: crate::tracking::Direction,
path: ShapePath,
method: MaskMethod,
times: Vec<Tick>,
},
}
impl WorkerJob {
pub fn kind(&self) -> JobKind {
match self {
WorkerJob::Render { .. } => JobKind::Render,
WorkerJob::Warp { .. } => JobKind::Warp,
WorkerJob::RotoFreeze { .. } => JobKind::RotoFreeze,
WorkerJob::Track { .. } => JobKind::Track,
WorkerJob::MaskTrack { .. } => JobKind::MaskTrack,
}
}
/// (comp, layer, property group uid) an analysis writes to.
pub fn target(&self) -> Option<(ItemId, LayerId, Uid)> {
match *self {
WorkerJob::Render { .. } => None,
WorkerJob::Warp { comp, layer, effect } | WorkerJob::RotoFreeze { comp, layer, effect } => Some((comp, layer, effect)),
WorkerJob::Track { comp, layer, tracker, .. } => Some((comp, layer, tracker)),
WorkerJob::MaskTrack { comp, layer, mask, .. } => Some((comp, layer, mask)),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum JobKind {
Render,
Warp,
RotoFreeze,
Track,
MaskTrack,
}
impl JobKind {
/// The undo step an analysis result becomes.
pub fn label(self) -> &'static str {
match self {
JobKind::Render => "Render",
JobKind::Warp => "Warp Stabilizer Analysis",
JobKind::RotoFreeze => "Freeze",
JobKind::Track => "Analyze Track",
JobKind::MaskTrack => "Track Mask",
}
}
}
/// What a worker reports.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum WorkerReply {
/// Render Queue progress.
Render {
state: JobState,
},
/// A Render Queue item started or finished.
Item {
update: ItemUpdate,
},
/// Analysis progress (frames).
Progress {
done: u64,
total: u64,
},
/// An analysis finished: its property group after the job, and the job's message.
Group {
comp: ItemId,
layer: LayerId,
group: Box<PropGroup>,
message: String,
},
Failed {
error: String,
},
/// The job ended (always the last reply).
Done,
}
/// Replies waiting for the UI thread.
#[derive(Default)]
pub struct Inbox(Mutex<Vec<WorkerReply>>);
impl Inbox {
pub fn push(&self, r: WorkerReply) {
self.0.lock().unwrap_or_else(|e| e.into_inner()).push(r);
}
pub fn take(&self) -> Vec<WorkerReply> {
std::mem::take(&mut *self.0.lock().unwrap_or_else(|e| e.into_inner()))
}
}
/// Runs jobs off the UI thread (the web app: a pool of Web Workers).
pub trait Offload: Send + Sync {
/// Start `req`; replies go to `inbox` (ending with [`WorkerReply::Done`]).
fn start(&self, req: WorkerRequest, inbox: Arc<Inbox>) -> Result<(), String>;
/// Stop job `id` now (the worker is terminated; no more replies arrive).
fn cancel(&self, id: u64);
}
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
pub fn next_id() -> u64 {
NEXT_ID.fetch_add(1, Ordering::Relaxed)
}
/// Footage files a project reads (paths, sequence frames).
pub fn footage_files(p: &Project) -> Vec<String> {
let mut v: Vec<String> = p
.items
.values()
.filter_map(|i| match &i.kind {
ItemKind::Footage(f) if !f.path.is_empty() => Some(std::iter::once(f.path.clone()).chain(f.sequence.iter().cloned()).collect::<Vec<_>>()),
_ => None,
})
.flatten()
.collect();
v.sort();
v.dedup();
v
}
// ---------------------------------------------------------------- progress hook (worker side)
thread_local! {
static HOOK: RefCell<Option<Box<dyn FnMut(u64, u64)>>> = const { RefCell::new(None) };
}
/// Analyses call this as frames complete; a worker forwards it (no-op elsewhere).
pub fn report(done: u64, total: u64) {
HOOK.with(|h| {
if let Ok(mut h) = h.try_borrow_mut()
&& let Some(f) = h.as_mut()
{
f(done, total);
}
});
}
fn with_hook<R>(hook: Box<dyn FnMut(u64, u64)>, f: impl FnOnce() -> R) -> R {
HOOK.with(|h| *h.borrow_mut() = Some(hook));
let r = f();
HOOK.with(|h| *h.borrow_mut() = None);
r
}
// ---------------------------------------------------------------- worker side
/// Where a worker sends its replies.
pub type Post = Rc<dyn Fn(WorkerReply)>;
/// Run `req` on `s` (a session with footage, expressions and an exporter, but no project yet),
/// blocking, reporting through `post`. Always ends with [`WorkerReply::Done`].
pub fn run_request(s: &mut Session, req: WorkerRequest, post: &Post) {
match Project::from_json(&req.project) {
Ok(p) => {
s.replace_project(p, None);
if let Err(e) = run_job(s, req.job, post.clone()) {
post(WorkerReply::Failed { error: e });
}
}
Err(e) => post(WorkerReply::Failed { error: format!("project: {e}") }),
}
post(WorkerReply::Done);
}
fn run_job(s: &mut Session, job: WorkerJob, post: Post) -> Result<(), String> {
let kind = job.kind();
let target = job.target();
match job {
WorkerJob::Render { items } => {
let exporter = s.exporter.clone().ok_or("export is not available in this build")?;
let shared = JobShared::default();
// Item updates and progress go out as they happen (progress at most every 100 ms).
let last = Cell::new(None::<web_time::Instant>);
let notify = |sh: &JobShared, force: bool| {
let ups = std::mem::take(&mut *crate::render_queue::lock(&sh.updates));
for u in ups {
post(WorkerReply::Item { update: u });
}
if force || last.get().is_none_or(|t| t.elapsed().as_millis() >= 100) {
last.set(Some(web_time::Instant::now()));
post(WorkerReply::Render { state: sh.snapshot() });
}
};
crate::render_queue::run_items(s, exporter, items, &shared, &mut |sh| notify(sh, false));
notify(&shared, true);
Ok(())
}
WorkerJob::Warp { comp, layer, effect } => analysis(s, kind, target, post, |s| s.start_warp(comp, layer, effect, true).map(|_| ())),
WorkerJob::RotoFreeze { comp, layer, effect } => analysis(s, kind, target, post, |s| {
s.start_roto(comp, layer, effect, crate::roto::RotoTask::Freeze, crate::roto::Direction::Both, true).map(|_| ())
}),
WorkerJob::Track { comp, layer, tracker, direction, settings, points, times } => analysis(s, kind, target, post, |s| {
let work = crate::tracking::Work {
project: s.project.clone(),
footage: s.footage.clone(),
expr: s.expr.clone(),
cache: s.layer_cache.clone(),
comp,
layer,
settings,
points,
times,
};
s.start_track(work, tracker, direction, true)
}),
WorkerJob::MaskTrack { comp, layer, mask, direction, path, method, times } => analysis(s, kind, target, post, |s| {
let work = crate::mask_track::MaskWork {
project: s.project.clone(),
footage: s.footage.clone(),
expr: s.expr.clone(),
cache: s.layer_cache.clone(),
comp,
layer,
path,
method,
times,
};
s.start_mask_track(work, mask, direction, true)
}),
}
}
fn analysis(
s: &mut Session,
kind: JobKind,
target: Option<(ItemId, LayerId, Uid)>,
post: Post,
run: impl FnOnce(&mut Session) -> Result<(), String>,
) -> Result<(), String> {
let (comp, layer, uid) = target.ok_or("no target")?;
let rev = s.revision;
// Progress at most every 100 ms.
let p = post.clone();
let mut last = None::<web_time::Instant>;
let hook = Box::new(move |done: u64, total: u64| {
if last.is_none_or(|t: web_time::Instant| t.elapsed().as_millis() >= 100) {
last = Some(web_time::Instant::now());
p(WorkerReply::Progress { done, total });
}
});
with_hook(hook, || run(s))?;
let message = s
.events
.iter()
.rev()
.find_map(|e| match e {
Event::Toast { message, .. } => Some(message.clone()),
_ => None,
})
.unwrap_or_default();
let failed = s.events.iter().any(|e| matches!(e, Event::Toast { error: true, .. }));
if failed {
return Err(message);
}
if s.revision == rev && kind != JobKind::Track && kind != JobKind::MaskTrack {
// Nothing written (e.g. the analysis was empty).
return Err(if message.is_empty() { "nothing was analysed".into() } else { message });
}
let group = s.project.comp(comp).and_then(|c| c.layer(layer)).and_then(|l| l.props.find_group(uid)).cloned().ok_or("the analysed property is gone")?;
post(WorkerReply::Group { comp, layer, group: Box::new(group), message });
Ok(())
}
// ---------------------------------------------------------------- UI side
/// Analysis progress of an offloaded job.
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OffloadProgress {
pub done: u64,
pub total: u64,
pub elapsed: f64,
pub finished: bool,
pub cancelled: bool,
pub error: Option<String>,
}
/// Frames per second of `done` frames in `elapsed` seconds.
pub(crate) fn rate(done: u64, elapsed: f64) -> f64 {
if elapsed > 0.0 { done as f64 / elapsed } else { 0.0 }
}
impl OffloadProgress {
pub(crate) fn track(&self) -> crate::tracking::TrackProgress {
crate::tracking::TrackProgress { done: self.done, total: self.total, elapsed: self.elapsed, fps: rate(self.done, self.elapsed), ..Default::default() }
}
}
/// An analysis running in a worker.
pub struct OffloadedJob {
pub id: u64,
pub kind: JobKind,
pub comp: ItemId,
pub layer: LayerId,
pub uid: Uid,
pub inbox: Arc<Inbox>,
pub progress: OffloadProgress,
started: web_time::Instant,
}
/// A Render Queue job running in a worker (see [`crate::render_queue::RenderJob`]).
pub struct RemoteRender {
pub id: u64,
pub inbox: Arc<Inbox>,
/// The queue items in the job.
pub ids: Vec<u64>,
}
impl Session {
/// Whether jobs started without `wait` go to the [`Offload`].
pub fn offloads(&self) -> bool {
self.offload.is_some()
}
/// The offloaded analysis of `kind`, if one runs.
pub fn offloaded(&self, kind: JobKind) -> Option<&OffloadedJob> {
self.offloaded.iter().find(|j| j.kind == kind && !j.progress.finished)
}
/// Send an analysis to the worker.
pub(crate) fn offload_analysis(&mut self, job: WorkerJob) -> Result<(), String> {
let off = self.offload.clone().ok_or("no background worker")?;
let kind = job.kind();
let (comp, layer, uid) = job.target().ok_or("not an analysis")?;
if self.offloaded(kind).is_some() {
return Err(format!("a {} job is already running", kind.label()));
}
let id = next_id();
let inbox = Arc::new(Inbox::default());
let req = WorkerRequest { id, project: self.project.to_json(), files: footage_files(&self.project), job };
off.start(req, inbox.clone())?;
self.offloaded.push(OffloadedJob { id, kind, comp, layer, uid, inbox, progress: OffloadProgress::default(), started: web_time::Instant::now() });
self.events.push(Event::ProjectChanged { revision: self.revision });
Ok(())
}
/// Cancel the offloaded analysis of `kind` (nothing is written).
pub fn cancel_offloaded(&mut self, kind: JobKind) -> bool {
let off = self.offload.clone();
let Some(j) = self.offloaded.iter_mut().find(|j| j.kind == kind && !j.progress.finished) else { return false };
if let Some(off) = off {
off.cancel(j.id);
}
j.progress.cancelled = true;
j.progress.finished = true;
true
}
/// Apply worker replies to offloaded analyses (results become one undo step each) and drop
/// finished jobs. Frontends call this every frame while [`Session::offloaded`] is not empty.
pub fn poll_offload(&mut self) -> bool {
if self.offloaded.is_empty() {
return false;
}
let mut changed = false;
let mut results = vec![];
for j in &mut self.offloaded {
if j.progress.cancelled {
continue;
}
for r in j.inbox.take() {
changed = true;
match r {
WorkerReply::Progress { done, total } => {
j.progress.done = done;
j.progress.total = total;
}
WorkerReply::Group { comp, layer, group, message } => results.push((j.kind, comp, layer, group, message)),
WorkerReply::Failed { error } => j.progress.error = Some(error),
WorkerReply::Done => j.progress.finished = true,
WorkerReply::Render { .. } | WorkerReply::Item { .. } => {}
}
}
j.progress.elapsed = j.started.elapsed().as_secs_f64();
}
for (kind, comp, layer, group, message) in results {
let uid = group.uid;
let r = self.edit(kind.label(), None, |p, _| {
let g = p
.comp_mut(comp)
.and_then(|c| c.layer_mut(layer))
.and_then(|l| l.props.find_group_mut(uid))
.ok_or_else(|| crate::EngineError::Other("the analysed property was removed during the analysis".into()))?;
*g = *group;
Ok(())
});
match r {
Ok(()) => self.events.push(Event::Toast { message, error: false }),
Err(e) => self.events.push(Event::Toast { message: format!("{}: {e}", kind.label()), error: true }),
}
}
let mut done = vec![];
self.offloaded.retain(|j| {
if j.progress.finished {
done.push((j.kind, j.progress.clone()));
}
!j.progress.finished
});
for (kind, p) in done {
changed = true;
if let Some(e) = p.error {
self.events.push(Event::Toast { message: format!("{}: {e}", kind.label()), error: true });
} else if p.cancelled {
self.events.push(Event::Toast { message: format!("{} stopped", kind.label()), error: false });
}
self.bump();
}
changed
}
/// Feed a remote render's replies into its [`JobShared`]; a cancelled one is wrapped up here
/// (the item being rendered and the ones after it become "User Stopped").
pub(crate) fn drain_remote_render(&mut self) {
let Some(job) = &self.render_job else { return };
let Some(remote) = &job.remote else { return };
let shared = job.shared.clone();
for r in remote.inbox.take() {
match r {
WorkerReply::Render { state } => {
let fin = crate::render_queue::lock(&shared.state).finished;
*crate::render_queue::lock(&shared.state) = JobState { finished: fin, ..state };
}
WorkerReply::Item { update } => crate::render_queue::lock(&shared.updates).push(update),
WorkerReply::Failed { error } => {
log::warn!("render worker: {error}");
let cur = crate::render_queue::lock(&shared.state).current;
if let Some(id) = cur {
crate::render_queue::lock(&shared.updates).push(ItemUpdate::Finished {
id,
status: RenderStatus::Failed(error),
seconds: 0.0,
output: None,
});
}
}
WorkerReply::Done => crate::render_queue::lock(&shared.state).finished = true,
_ => {}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn round<T: Serialize + for<'de> Deserialize<'de>>(v: &T) -> String {
let a = serde_json::to_string(v).unwrap();
let back: T = serde_json::from_str(&a).unwrap();
let b = serde_json::to_string(&back).unwrap();
assert_eq!(a, b);
a
}
#[test]
fn protocol_round_trips() {
let mut s = Session::default();
s.execute("file.openDemoProject", serde_json::json!({})).unwrap();
let comp = s.active_comp_id().unwrap();
s.execute("renderQueue.add", serde_json::json!({})).unwrap();
let item = s.project.render_queue[0].clone();
let reqs = [
WorkerJob::Render { items: vec![vec![(item.clone(), "/out.gif".into())]] },
WorkerJob::Warp { comp, layer: LayerId(3), effect: 9 },
WorkerJob::RotoFreeze { comp, layer: LayerId(3), effect: 9 },
WorkerJob::Track {
comp,
layer: LayerId(1),
tracker: 4,
direction: crate::tracking::Direction::Backward,
settings: TrackerSettings::default(),
points: vec![PointValues {
uid: 5,
spec: effectcraft_track::PointSpec { center: [1.0, 2.0], feature_size: [3.0, 4.0], search_offset: [0.5, 0.0], search_size: [9.0, 9.0] },
attach_offset: [0.25, -1.0],
}],
times: vec![Tick::ZERO, Tick::from_seconds_f64(0.5)],
},
WorkerJob::MaskTrack {
comp,
layer: LayerId(2),
mask: 7,
direction: crate::tracking::Direction::Forward,
path: ShapePath::ellipse([10.0, 20.0], 5.0, 6.0),
method: MaskMethod::Perspective,
times: vec![Tick::from_seconds_f64(1.0)],
},
];
for job in reqs {
let kind = job.kind();
let req = WorkerRequest { id: 42, project: s.project.to_json(), files: footage_files(&s.project), job };
let t = round(&req);
assert!(t.contains("\"kind\":"), "{kind:?}");
let back: WorkerRequest = serde_json::from_str(&t).unwrap();
assert_eq!(back.job.kind(), kind);
assert_eq!(Project::from_json(&back.project).unwrap(), *s.project);
}
let g = s.active_comp().unwrap().layers[0].props.clone();
for r in [
WorkerReply::Render { state: JobState { current: Some(3), done: 4, total: 10, items_total: 1, ..Default::default() } },
WorkerReply::Item { update: ItemUpdate::Started { id: 3, unix: 1_700_000_000 } },
WorkerReply::Item { update: ItemUpdate::Finished { id: 3, status: RenderStatus::Failed("x".into()), seconds: 1.5, output: Some("/a.gif".into()) } },
WorkerReply::Progress { done: 1, total: 2 },
WorkerReply::Group { comp, layer: LayerId(1), group: Box::new(g), message: "ok".into() },
WorkerReply::Failed { error: "boom".into() },
WorkerReply::Done,
] {
let t = round(&r);
assert!(t.starts_with("{\"type\":"), "{t}");
}
}
/// An [`Offload`] that runs the job right away in a second session, through JSON (what a
/// Web Worker does, minus the threads).
struct Inline;
impl Offload for Inline {
fn start(&self, req: WorkerRequest, inbox: Arc<Inbox>) -> Result<(), String> {
let req: WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
let mut w = Session { exporter: Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })), ..Default::default() };
let post: Post = Rc::new(move |r| inbox.push(serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap()));
run_request(&mut w, req, &post);
Ok(())
}
fn cancel(&self, _: u64) {}
}
#[test]
fn render_runs_through_the_offload() {
let mut s = Session {
exporter: Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })),
offload: Some(Arc::new(Inline)),
..Default::default()
};
s.execute("file.openDemoProject", serde_json::json!({})).unwrap();
s.execute("renderQueue.add", serde_json::json!({"output": "/x.gif"})).unwrap();
let ids = s.start_render(false).unwrap();
// (the inline offload has replied already; replies are applied when polled)
s.poll_render();
assert!(s.render_job.is_none());
let it = s.project.render_queue.iter().find(|i| i.id == ids[0]).unwrap();
assert_eq!(it.status, RenderStatus::Done, "{:?}", it.status);
assert!(it.last_output.as_deref().is_some_and(|o| o.ends_with("x.gif")));
// `wait` renders inline.
s.execute("renderQueue.add", serde_json::json!({"output": "/y.gif"})).unwrap();
s.start_render(true).unwrap();
assert!(s.render_job.is_none() || s.render_job.as_ref().unwrap().remote.is_none());
}
/// Runs jobs in a session sharing `footage` (the synthetic clip of the tracking tests).
struct InlineWith(Arc<dyn effectcraft_render::FootageSource>);
impl Offload for InlineWith {
fn start(&self, req: WorkerRequest, inbox: Arc<Inbox>) -> Result<(), String> {
let req: WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
let mut w = Session { footage: self.0.clone(), ..Default::default() };
let post: Post = Rc::new(move |r| inbox.push(serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap()));
run_request(&mut w, req, &post);
Ok(())
}
fn cancel(&self, _: u64) {}
}
/// Never replies (a job still running); records cancellations.
#[derive(Default)]
struct Hanging(Mutex<Vec<u64>>);
impl Offload for Hanging {
fn start(&self, _: WorkerRequest, _: Arc<Inbox>) -> Result<(), String> {
Ok(())
}
fn cancel(&self, id: u64) {
self.0.lock().unwrap().push(id);
}
}
fn pose(f: u32) -> ([f64; 2], f64, f64) {
([150.0 + 2.0 * f as f64, 110.0 + 1.0 * f as f64], 0.0, 1.0)
}
#[test]
fn track_analysis_runs_through_the_offload_as_one_undo_step() {
let (mut s, clip, _) = crate::tests_track::setup(crate::tests_track::patch_frames(pose));
s.offload = Some(Arc::new(InlineWith(s.footage.clone())));
s.execute("track.motion", serde_json::json!({})).unwrap();
s.execute("track.setPoint", serde_json::json!({"point": 1, "center": pose(0).0, "featureSize": [36, 36], "searchSize": [80, 80]})).unwrap();
let undo = s.history.undo.len();
s.execute("track.analyze", serde_json::json!({"direction": "forward"})).unwrap();
assert!(s.is_tracking() && s.track_job.is_none(), "offloaded, not on a thread");
assert!(s.track_progress().is_some());
assert!(s.poll_offload());
assert!(!s.is_tracking() && s.offloaded.is_empty());
let l = s.active_comp().unwrap().layer(clip).unwrap();
let tp = l.trackers().next().unwrap().0.track_points().next().unwrap().clone();
let fc = tp.get("featureCenter").unwrap();
assert_eq!(fc.keys.len(), 25);
let v = fc.value_at(crate::tests_track::frame_time(12)).as_vec2();
assert!((v[0] - pose(12).0[0]).abs() < 0.3 && (v[1] - pose(12).0[1]).abs() < 0.3, "{v:?}");
assert_eq!(s.history.undo.len(), undo + 1);
assert_eq!(s.history.undo.last().unwrap().0, "Analyze Track");
assert!(s.events.iter().any(|e| matches!(e, Event::Toast { message, error: false } if message.starts_with("Tracked"))));
assert!(s.undo());
let l = s.active_comp().unwrap().layer(clip).unwrap();
assert!(l.trackers().next().unwrap().0.track_points().next().unwrap().get("featureCenter").unwrap().keys.is_empty());
// `wait` runs inline.
s.redo();
s.execute("track.analyze", serde_json::json!({"direction": "forward", "wait": true})).ok();
assert!(s.offloaded.is_empty());
}
#[test]
fn cancelling_offloaded_jobs() {
let (mut s, _, _) = crate::tests_track::setup(crate::tests_track::patch_frames(pose));
let off = Arc::new(Hanging::default());
s.offload = Some(off.clone());
s.exporter = Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() }));
// Analysis: stopping drops the job, nothing is written.
s.execute("track.motion", serde_json::json!({})).unwrap();
let rev = s.revision;
s.execute("track.analyze", serde_json::json!({})).unwrap();
assert!(s.is_tracking());
assert!(s.execute("track.analyze", serde_json::json!({})).is_err(), "one at a time");
assert!(s.stop_track());
s.poll_offload();
assert!(!s.is_tracking());
assert_eq!(off.0.lock().unwrap().len(), 1);
assert!(s.events.iter().any(|e| matches!(e, Event::Toast { message, .. } if message.contains("stopped"))));
assert!(s.revision > rev);
// Render: the queued items become User Stopped.
s.execute("renderQueue.add", serde_json::json!({"output": "/a.gif"})).unwrap();
let ids = s.start_render(false).unwrap();
assert!(s.is_rendering());
assert!(s.stop_render());
s.poll_render();
assert!(!s.is_rendering());
assert_eq!(off.0.lock().unwrap().len(), 2);
let it = s.project.render_queue.iter().find(|i| i.id == ids[0]).unwrap();
assert_eq!(it.status, RenderStatus::UserStopped);
}
#[test]
fn progress_hook_is_scoped() {
report(1, 2); // no hook: nothing happens
let seen = Rc::new(RefCell::new(vec![]));
let s2 = seen.clone();
with_hook(Box::new(move |d, t| s2.borrow_mut().push((d, t))), || {
report(1, 3);
report(2, 3);
});
report(3, 3);
assert_eq!(*seen.borrow(), vec![(1, 3), (2, 3)]);
}
}

View File

@ -13,7 +13,7 @@ use std::sync::{Arc, Mutex};
use effectcraft_project::render_queue::{OutputFormat, PostRenderAction, RenderQueueItem, RenderStatus, TemplateVars, expand_template};
use effectcraft_project::{ItemId, ItemKind, LayerSource, Project};
use effectcraft_render::{ExprHost, FootageSource};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use crate::{Event, Session};
@ -54,7 +54,7 @@ pub trait Exporter: Send + Sync {
pub const CANCELLED: &str = "cancelled";
/// Live progress of a render job (serde for agents / the control channel).
#[derive(Clone, Debug, Default, Serialize)]
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct JobState {
/// Item being rendered.
pub current: Option<u64>,
@ -73,7 +73,8 @@ pub struct JobState {
}
/// Per-item status changes from the job thread, applied to the project by `poll_render`.
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "camelCase")]
pub enum ItemUpdate {
Started { id: u64, unix: u64 },
Finished { id: u64, status: RenderStatus, seconds: f64, output: Option<String> },
@ -86,7 +87,7 @@ pub struct JobShared {
pub updates: Mutex<Vec<ItemUpdate>>,
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
pub(crate) fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
@ -100,6 +101,8 @@ impl JobShared {
pub struct RenderJob {
pub shared: Arc<JobShared>,
thread: Option<std::thread::JoinHandle<()>>,
/// Rendering in a worker ([`crate::offload`]).
pub remote: Option<crate::offload::RemoteRender>,
}
impl RenderJob {
@ -132,7 +135,19 @@ fn unix_now() -> u64 {
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
fn run_work(w: Work, shared: &JobShared) {
/// Render `items` with the session's footage, expressions and GPU (a worker's render).
pub(crate) fn run_items(
s: &Session,
exporter: Arc<dyn Exporter>,
items: Vec<Vec<(RenderQueueItem, String)>>,
shared: &JobShared,
notify: &mut dyn FnMut(&JobShared),
) {
let work = Work { project: s.project.clone(), footage: s.footage.clone(), expr: s.expr.clone(), accel: s.accel.clone(), exporter, items };
run_work(work, shared, notify);
}
fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared)) {
let t0 = web_time::Instant::now();
{
let mut s = lock(&shared.state);
@ -154,18 +169,22 @@ fn run_work(w: Work, shared: &JobShared) {
s.items_done = k;
s.remaining = None;
}
notify(shared);
// Every output module encodes the same frames (Composition ▸ Add Output Module); the
// first module's file is the item's output.
let mut r: Result<ExportResult, String> = Err("no output module".into());
for (mi, (mitem, path)) in modules.iter().enumerate() {
let job = ExportJob { project: &w.project, footage: w.footage.as_ref(), expr: w.expr.as_deref(), accel: w.accel.as_deref(), item: mitem, path };
let rr = w.exporter.export(&job, &mut |done, total| {
{
let mut s = lock(&shared.state);
s.done = done;
s.total = total;
s.elapsed = t0.elapsed().as_secs_f64();
s.item_elapsed = t1.elapsed().as_secs_f64();
s.remaining = (done > 0).then(|| s.item_elapsed / done as f64 * total.saturating_sub(done) as f64);
}
notify(shared);
!shared.cancel.load(Ordering::Relaxed)
});
match rr {
@ -187,6 +206,7 @@ fn run_work(w: Work, shared: &JobShared) {
}
};
lock(&shared.updates).push(up);
notify(shared);
}
let mut s = lock(&shared.state);
s.items_done = w.items.len();
@ -245,10 +265,12 @@ impl Session {
}
/// Start rendering every queued item. `wait`: block until done (otherwise a background thread
/// renders; call [`Session::poll_render`] regularly). Returns the ids being rendered.
/// wasm32 has no threads: the render always runs to completion before this returns.
/// renders, or the [`Session::offload`] worker; call [`Session::poll_render`] regularly).
/// Returns the ids being rendered. Without threads or an offload (wasm32) the render always
/// runs to completion before this returns.
pub fn start_render(&mut self, wait: bool) -> Result<Vec<u64>, String> {
let wait = wait || cfg!(target_arch = "wasm32");
let offload = self.offload.clone().filter(|_| !wait);
let wait = wait || (cfg!(target_arch = "wasm32") && offload.is_none());
if self.is_rendering() {
return Err("a render is already in progress".into());
}
@ -291,15 +313,31 @@ impl Session {
return Err("nothing is queued: add a composition (Composition ▸ Add to Render Queue) and tick Render".into());
}
let ids: Vec<u64> = items.iter().filter_map(|m| m.first().map(|(i, _)| i.id)).collect();
if let Some(off) = offload {
let id = crate::offload::next_id();
let inbox = Arc::new(crate::offload::Inbox::default());
let req = crate::offload::WorkerRequest {
id,
project: self.project.to_json(),
files: crate::offload::footage_files(&self.project),
job: crate::offload::WorkerJob::Render { items },
};
off.start(req, inbox.clone())?;
let shared = Arc::new(JobShared::default());
lock(&shared.state).items_total = ids.len();
self.render_job = Some(RenderJob { shared, thread: None, remote: Some(crate::offload::RemoteRender { id, inbox, ids: ids.clone() }) });
self.poll_render();
return Ok(ids);
}
let work = Work { project: self.project.clone(), footage: self.footage.clone(), expr: self.expr.clone(), accel: self.accel.clone(), exporter, items };
let shared = Arc::new(JobShared::default());
if wait {
run_work(work, &shared);
self.render_job = Some(RenderJob { shared, thread: None });
run_work(work, &shared, &mut |_| {});
self.render_job = Some(RenderJob { shared, thread: None, remote: None });
} else {
let sh = shared.clone();
let thread = std::thread::Builder::new().name("render-queue".into()).spawn(move || run_work(work, &sh)).map_err(|e| e.to_string())?;
self.render_job = Some(RenderJob { shared, thread: Some(thread) });
let thread = std::thread::Builder::new().name("render-queue".into()).spawn(move || run_work(work, &sh, &mut |_| {})).map_err(|e| e.to_string())?;
self.render_job = Some(RenderJob { shared, thread: Some(thread), remote: None });
}
self.poll_render();
Ok(ids)
@ -310,6 +348,25 @@ impl Session {
match &self.render_job {
Some(j) if !j.is_finished() => {
j.cancel();
if let Some(r) = &j.remote {
// A worker can't be interrupted mid-frame: terminate it and stop what's left.
if let Some(off) = &self.offload {
off.cancel(r.id);
}
self.drain_remote_render();
let j = self.render_job.as_ref().expect("job");
let r = j.remote.as_ref().expect("remote");
let finished: Vec<u64> =
lock(&j.shared.updates).iter().filter_map(|u| if let ItemUpdate::Finished { id, .. } = u { Some(*id) } else { None }).collect();
for id in &r.ids {
let done = finished.contains(id)
|| self.project.render_queue.iter().any(|i| i.id == *id && matches!(i.status, RenderStatus::Done | RenderStatus::Failed(_)));
if !done {
lock(&j.shared.updates).push(ItemUpdate::Finished { id: *id, status: RenderStatus::UserStopped, seconds: 0.0, output: None });
}
}
lock(&j.shared.state).finished = true;
}
true
}
_ => false,
@ -319,6 +376,7 @@ impl Session {
/// Apply job updates to the queue items; drop the job once it finished. Returns whether
/// anything changed. Frontends call this every frame while rendering.
pub fn poll_render(&mut self) -> bool {
self.drain_remote_render();
let Some(job) = &self.render_job else { return false };
let updates = std::mem::take(&mut *lock(&job.shared.updates));
let finished = job.is_finished();

View File

@ -10,6 +10,7 @@
//! of (the layer's source and timing, its masks and the effects above it) and drops frozen
//! mattes made from other frames.
use crate::offload::JobKind;
use std::collections::{BTreeMap, HashMap};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
@ -62,7 +63,7 @@ impl Default for RotoOptions {
pub const VIEWS: [&str; 4] = ["alphaBoundary", "alpha", "alphaOverlay", "none"];
/// What a job does.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RotoTask {
Propagate,
@ -378,6 +379,7 @@ fn run_work(w: Work, shared: &RotoShared) {
s.done = k as u64 + 1;
s.elapsed = t0.elapsed().as_secs_f64();
s.fps = if s.elapsed > 0.0 { s.done as f64 / s.elapsed } else { 0.0 };
crate::offload::report(s.done, s.total);
}
if w.task == RotoTask::Freeze {
*lock(&shared.frozen) = Some(frozen);
@ -402,19 +404,37 @@ pub(crate) fn job_frames(d: &effectcraft_track::roto::RotoData, dir: Direction)
impl Session {
pub fn is_roto_running(&self) -> bool {
self.roto_job.as_ref().is_some_and(|j| !j.is_finished())
self.roto_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(JobKind::RotoFreeze).is_some()
}
pub fn roto_progress(&self) -> Option<RotoProgress> {
self.roto_job.as_ref().map(|j| j.progress())
self.roto_job.as_ref().map(|j| j.progress()).or_else(|| {
self.offloaded(JobKind::RotoFreeze).map(|j| {
let p = &j.progress;
RotoProgress {
task: Some(RotoTask::Freeze),
done: p.done,
total: p.total,
elapsed: p.elapsed,
fps: crate::offload::rate(p.done, p.elapsed),
..Default::default()
}
})
})
}
/// (comp, layer, effect uid, task) of the running job.
pub fn roto_target(&self) -> Option<(ItemId, LayerId, Uid, RotoTask)> {
self.roto_job.as_ref().map(|j| (j.comp, j.layer, j.effect, j.task))
self.roto_job
.as_ref()
.map(|j| (j.comp, j.layer, j.effect, j.task))
.or_else(|| self.offloaded(JobKind::RotoFreeze).map(|j| (j.comp, j.layer, j.uid, RotoTask::Freeze)))
}
pub fn stop_roto(&mut self) -> bool {
if self.cancel_offloaded(JobKind::RotoFreeze) {
return true;
}
match &self.roto_job {
Some(j) if !j.is_finished() => {
j.shared.cancel.store(true, Ordering::Relaxed);
@ -471,6 +491,11 @@ impl Session {
task,
};
let strokes = str_of(g, fx::STROKES);
if !wait && task == RotoTask::Freeze && self.offloads() {
drop(work);
self.offload_analysis(crate::offload::WorkerJob::RotoFreeze { comp, layer, effect })?;
return Ok(n);
}
let wait = wait || cfg!(target_arch = "wasm32");
let shared = Arc::new(RotoShared::default());
let thread = if wait {

View File

@ -16,8 +16,8 @@ fn demo() -> Session {
}
/// Records exports; fails items whose path contains "fail"; honours cancellation.
struct MockExporter {
log: Mutex<Vec<String>>,
pub(crate) struct MockExporter {
pub(crate) log: Mutex<Vec<String>>,
}
impl Exporter for MockExporter {

View File

@ -89,11 +89,19 @@ impl Session {
self.config.as_ref().and_then(|c| c.dir())
}
/// Where auto-saves live: the config store's [`crate::config::FileOps`], else the file system.
pub fn file_ops(&self) -> &dyn crate::config::FileOps {
match &self.config {
Some(c) => c.files(),
None => &crate::config::StdFiles,
}
}
/// Write an auto-save now (whether or not the project is dirty). Returns its path.
pub fn autosave_now(&mut self) -> Result<String> {
let json = self.project.to_json();
let root = self.default_autosave_root();
let (path, slot) = autosave::write(&self.prefs, self.path.as_deref(), root.as_deref(), self.autosave.last_slot, &json)
let (path, slot) = autosave::write_in(self.file_ops(), &self.prefs, self.path.as_deref(), root.as_deref(), self.autosave.last_slot, &json)
.map_err(|e| EngineError::Other(format!("auto-save failed: {e}")))?;
let p = path.to_string_lossy().to_string();
self.autosave.last_slot = Some(slot);

View File

@ -70,7 +70,8 @@ pub fn track_options(o: &TrackerOptions) -> trk::TrackOptions {
}
/// A track point's regions and attach offset read from its properties at a layer time.
#[derive(Clone, Copy, Debug, PartialEq)]
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PointValues {
pub uid: Uid,
pub spec: trk::PointSpec,
@ -292,6 +293,7 @@ pub(crate) fn run_work(w: Work, shared: &TrackShared) {
s.elapsed = t0.elapsed().as_secs_f64();
s.fps = if s.elapsed > 0.0 { k as f64 / s.elapsed } else { 0.0 };
s.time = w.times[k].seconds();
crate::offload::report(s.done, s.total);
}
let mut s = lock(&shared.state);
s.elapsed = t0.elapsed().as_secs_f64();
@ -322,16 +324,19 @@ fn write_frames(p: &mut Project, job: &TrackJob, frames: &[FrameResult]) {
impl Session {
/// Whether a track analysis is running.
pub fn is_tracking(&self) -> bool {
self.track_job.as_ref().is_some_and(|j| !j.is_finished())
self.track_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(crate::offload::JobKind::Track).is_some()
}
/// Live progress of the running (or just finished) analysis.
pub fn track_progress(&self) -> Option<TrackProgress> {
self.track_job.as_ref().map(|j| j.progress())
self.track_job.as_ref().map(|j| j.progress()).or_else(|| self.offloaded(crate::offload::JobKind::Track).map(|j| j.progress.track()))
}
/// Cancel the running analysis (results so far are kept).
pub fn stop_track(&mut self) -> bool {
if self.cancel_offloaded(crate::offload::JobKind::Track) {
return true;
}
match &self.track_job {
Some(j) if !j.is_finished() => {
j.cancel();
@ -348,6 +353,10 @@ impl Session {
return Err("a track analysis is already running".into());
}
self.poll_track();
if !wait && self.offloads() {
let Work { comp, layer, settings, points, times, .. } = work;
return self.offload_analysis(crate::offload::WorkerJob::Track { comp, layer, tracker, direction, settings, points, times });
}
let wait = wait || cfg!(target_arch = "wasm32");
self.history.undo.push(("Analyze Track".into(), self.project.clone()));
self.history.redo.clear();

View File

@ -11,6 +11,7 @@
//! for re-analysis, which frontends start with [`Session::poll_warp`] (After Effects re-analyses
//! automatically too).
use crate::offload::{JobKind, rate};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
@ -232,6 +233,7 @@ fn run_work(w: WarpWork, shared: &WarpShared) {
s.done = k as u64 + 1;
s.elapsed = t0.elapsed().as_secs_f64();
s.fps = if s.elapsed > 0.0 { s.done as f64 / s.elapsed } else { 0.0 };
crate::offload::report(s.done, s.total);
}
lock(&shared.state).step = 2;
let lt0 = layer.layer_time(w.times[0]).seconds();
@ -264,21 +266,29 @@ pub(crate) fn analysis_times(comp: &effectcraft_project::Comp, layer: &Layer) ->
impl Session {
pub fn is_warp_analyzing(&self) -> bool {
self.warp_job.as_ref().is_some_and(|j| !j.is_finished())
self.warp_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(JobKind::Warp).is_some()
}
/// Progress of the running (or just finished) analysis.
pub fn warp_progress(&self) -> Option<WarpProgress> {
self.warp_job.as_ref().map(|j| j.progress())
self.warp_job.as_ref().map(|j| j.progress()).or_else(|| {
self.offloaded(JobKind::Warp).map(|j| {
let p = &j.progress;
WarpProgress { step: 1, done: p.done, total: p.total, elapsed: p.elapsed, fps: rate(p.done, p.elapsed), ..Default::default() }
})
})
}
/// The effect being analysed: (comp, layer, effect uid).
pub fn warp_target(&self) -> Option<(ItemId, LayerId, Uid)> {
self.warp_job.as_ref().map(|j| (j.comp, j.layer, j.effect))
self.warp_job.as_ref().map(|j| (j.comp, j.layer, j.effect)).or_else(|| self.offloaded(JobKind::Warp).map(|j| (j.comp, j.layer, j.uid)))
}
/// Cancel the running analysis (nothing is written).
pub fn stop_warp(&mut self) -> bool {
if self.cancel_offloaded(JobKind::Warp) {
return true;
}
match &self.warp_job {
Some(j) if !j.is_finished() => {
j.shared.cancel.store(true, Ordering::Relaxed);
@ -325,6 +335,11 @@ impl Session {
times,
};
let key = signature(&self.project, comp, l);
if !wait && self.offloads() {
drop(work);
self.offload_analysis(crate::offload::WorkerJob::Warp { comp, layer, effect })?;
return Ok(n);
}
let wait = wait || cfg!(target_arch = "wasm32");
let shared = Arc::new(WarpShared::default());
let thread = if wait {
@ -391,7 +406,7 @@ impl Session {
/// re-analyses in the background, like After Effects.
pub fn poll_warp(&mut self, auto: bool) -> bool {
let changed = self.poll_warp_job();
if auto && self.warp_job.is_none() {
if auto && self.warp_job.is_none() && self.offloaded(JobKind::Warp).is_none() {
while let Some((c, l, e)) = self.warp_pending.first().copied() {
self.warp_pending.remove(0);
if self.start_warp(c, l, e, false).is_ok() {

View File

@ -484,6 +484,41 @@ impl<'g> Enc<'g> {
Some(out)
}
/// [`Enc::read_texture`] without waiting for the GPU (the browser's main thread can't): `done`
/// gets the bytes once the copy is mapped (from the browser's event loop on the web; from
/// a later device poll natively).
pub fn read_texture_async(&mut self, texture: &wgpu::Texture, w: u32, h: u32, bpp: u32, done: impl FnOnce(Option<Vec<u8>>) + wgpu::WasmNotSend + 'static) {
let row = w * bpp;
let padded = row.div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let buf = self.g.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback-async"),
size: padded as u64 * h as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
self.encoder().copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo { texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
wgpu::TexelCopyBufferInfo { buffer: &buf, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(padded), rows_per_image: Some(h) } },
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
self.submit();
let b = buf.clone();
buf.map_async(wgpu::MapMode::Read, .., move |r| {
let out = r.ok().and_then(|_| {
let view = b.get_mapped_range(..).ok()?;
let mut out = Vec::with_capacity((row * h) as usize);
for y in 0..h as usize {
out.extend_from_slice(&view[y * padded as usize..y * padded as usize + row as usize]);
}
Some(out)
});
b.unmap();
done(out);
});
#[cfg(not(target_arch = "wasm32"))]
let _ = self.g.device.poll(wgpu::PollType::Poll);
}
/// A data buffer for kernels that read tables (curves).
pub fn data(&self, v: &[f32]) -> wgpu::Buffer {
let mut bytes = Vec::with_capacity(v.len().max(4) * 4);

View File

@ -93,6 +93,12 @@ impl Gpu {
Enc::new(&self.ctx).read_texture(&f.texture, f.width, f.height, 4)
}
/// [`Gpu::read_display`] without blocking (the browser's main thread): `done` gets the bytes
/// once the GPU has them.
pub fn read_display_async(&self, f: &DisplayFrame, done: impl FnOnce(Option<Vec<u8>>) + wgpu::WasmNotSend + 'static) {
Enc::new(&self.ctx).read_texture_async(&f.texture, f.width, f.height, 4, done);
}
/// Render a frame on the GPU and read it back (`None` = not handled).
pub fn render(&self, r: &Renderer, comp: ItemId, t: Tick) -> Option<Image> {
if !self.ctx.can_readback() {

View File

@ -1,8 +1,9 @@
//! Real-time audio preview.
//!
//! The frontend owns no audio API: the native app supplies an [`AudioDevice`] (cpal) through
//! [`crate::Hooks::audio_device`]. During preview playback an [`AudioPlayback`] runs a feeder
//! thread that mixes the composition ahead of the device with the same mixdown export uses
//! [`crate::Hooks::audio_device`], the web app one on Web Audio. During preview playback an
//! [`AudioPlayback`] runs a feeder thread (wasm32: [`AudioPlayback::pump`] every UI frame) that
//! mixes the composition ahead of the device with the same mixdown export uses
//! (`effectcraft_render::audio::mix_comp`: Audio switch, solo, Audio Levels, audio effects) and
//! pushes it into an [`AudioFeed`]; the device callback pulls from the feed.
//!
@ -35,6 +36,9 @@ pub trait AudioDevice: Send {
fn latency_frames(&self) -> u64 {
0
}
/// Called every UI frame while playing: devices without an audio thread of their own (Web
/// Audio) move queued samples to the output here.
fn pump(&mut self) {}
}
/// Opens the default output device (`None` when there is none).
@ -160,10 +164,18 @@ pub struct AudioPlayback {
device: Box<dyn AudioDevice>,
stop: Arc<AtomicBool>,
thread: Option<JoinHandle<()>>,
/// wasm32 (no threads): what the feeder thread would own, run by [`AudioPlayback::pump`].
feeder: Option<(RenderSource, ItemId, i64)>,
pub rate: u32,
pub span: PlaySpan,
}
/// Audio kept queued ahead of the device: ~200 ms (a feeder thread tops it up continuously);
/// wasm32 tops it up once per UI frame, so it keeps ~350 ms to ride out slow frames.
fn queue_target(rate: u32) -> usize {
if cfg!(target_arch = "wasm32") { rate as usize * 7 / 20 } else { rate as usize / 5 }
}
impl AudioPlayback {
/// Start mixing `comp` over `span` into `device`.
pub fn start(
@ -185,13 +197,18 @@ impl AudioPlayback {
for _ in 0..prime {
cursor = feed_block(&src, comp, &feed, &span, cursor, rate);
}
if cfg!(target_arch = "wasm32") {
let mut p = AudioPlayback { feed: feed.clone(), device, stop, thread: None, feeder: Some((src, comp, cursor)), rate, span };
p.device.start(feed)?;
p.pump();
return Ok(p);
}
let thread = {
let (feed, stop) = (feed.clone(), stop.clone());
std::thread::Builder::new()
.name("ec-audio-feed".into())
.spawn(move || {
// Keep ~200 ms queued ahead of the device.
let target = rate as usize / 5;
let target = queue_target(rate);
while !stop.load(Ordering::Relaxed) {
if feed.queued_frames() < target && !feed.finished.load(Ordering::Relaxed) {
cursor = feed_block(&src, comp, &feed, &span, cursor, rate);
@ -203,7 +220,19 @@ impl AudioPlayback {
.map_err(|e| e.to_string())?
};
device.start(feed.clone())?;
Ok(AudioPlayback { feed, device, stop, thread: Some(thread), rate, span })
Ok(AudioPlayback { feed, device, stop, thread: Some(thread), feeder: None, rate, span })
}
/// Every UI frame while playing: on wasm32 mix ahead into the feed (what the feeder thread
/// does elsewhere), then let the device take what it needs.
pub fn pump(&mut self) {
if let Some((src, comp, cursor)) = &mut self.feeder {
let target = queue_target(self.rate);
while self.feed.queued_frames() < target && !self.feed.finished.load(Ordering::Relaxed) {
*cursor = feed_block(src, *comp, &self.feed, &self.span, *cursor, self.rate);
}
}
self.device.pump();
}
/// The comp sample index audible now.

View File

@ -118,9 +118,11 @@ pub struct RenderSource {
pub layer_cache: Arc<LayerCache>,
/// GPU compositor on the viewer's device (Mercury GPU Acceleration), if available.
pub gpu: Option<Gpu>,
/// Keep GPU frames on the GPU for the viewer (native egui-wgpu textures). Off by default:
/// Show Channel / exposure / region-of-interest drawing read the CPU frame texture, so GPU
/// frames are read back (the compositing still runs on the GPU).
/// Keep GPU frames on the GPU for the viewer (native egui-wgpu textures). Off on the
/// desktop: Show Channel / exposure / region-of-interest drawing read the CPU frame texture,
/// so GPU frames are read back (the compositing still runs on the GPU). On in the browser
/// while the viewer draws plainly, because there frames can't be read back synchronously
/// (`EffectcraftApp::gpu_display`).
pub gpu_display: bool,
}
@ -175,6 +177,11 @@ impl Frames {
}
}
/// The egui context (to request repaints from callbacks).
pub fn context(&self) -> Option<egui::Context> {
self.ctx.clone()
}
pub fn get(&self, k: &FrameKey) -> Option<FrameImage> {
self.cache.lock().ok()?.map.get(k).cloned()
}

View File

@ -138,6 +138,8 @@ pub struct EffectcraftApp {
/// Last displayed image (Info panel colour, eyedroppers, histograms). GPU frames fill it on
/// demand ([`EffectcraftApp::viewer_pixels`]).
pub(crate) viewer_image: Option<Arc<egui::ColorImage>>,
/// wasm32: an asynchronous readback of a GPU viewer frame (key asked for, result).
viewer_readback: (Option<FrameKey>, Arc<std::sync::Mutex<Option<(FrameKey, Arc<egui::ColorImage>)>>>),
/// egui-wgpu's device, once the first frame arrives (desktop and WebGPU).
pub(crate) wgpu: Option<eframe::egui_wgpu::RenderState>,
/// The GPU compositor on that device (None: no usable adapter → CPU only).
@ -198,6 +200,7 @@ impl EffectcraftApp {
viewer_native: None,
viewer_shown: None,
viewer_image: None,
viewer_readback: Default::default(),
wgpu: None,
gpu: None,
gpu_checked: false,
@ -323,10 +326,22 @@ impl EffectcraftApp {
expr: self.session.expr.clone(),
layer_cache: self.session.layer_cache.clone(),
gpu: self.gpu.clone(),
gpu_display: false,
gpu_display: self.gpu_display(),
}
}
/// Keep GPU-composited viewer frames on the GPU (drawn from their texture). The browser can't
/// read frames back (its thread can't wait for the GPU), so there the viewer shows GPU frames
/// directly whenever it draws them plainly (RGB, no exposure, no region of interest); the
/// desktop reads them back (see [`frames::RenderSource::gpu_display`]).
fn gpu_display(&self) -> bool {
let v = &self.session.state.viewer;
cfg!(target_arch = "wasm32")
&& v.channel == effectcraft_engine::viewer::Channel::Rgb
&& v.exposure == 0.0
&& self.session.state.region_of_interest.is_none()
}
/// Set up the GPU compositor on egui-wgpu's device (once). Without an adapter that runs
/// compute shaders (WebGL2, software GL) everything stays on the CPU.
pub(crate) fn init_gpu(&mut self, frame: &eframe::Frame) {
@ -358,14 +373,38 @@ impl EffectcraftApp {
/// The viewer's current pixels as 8-bit premultiplied RGBA, reading a GPU frame back the
/// first time something asks for it.
///
/// The browser can't wait for the GPU: there the first call starts an asynchronous readback
/// and returns `None`; a later call (the next frame) has the pixels.
pub fn viewer_pixels(&mut self) -> Option<Arc<egui::ColorImage>> {
if self.viewer_image.is_none()
&& let (Some((_, _, f)), Some(g)) = (&self.viewer_native, &self.gpu)
&& self.viewer_shown.as_ref().map(|s| s.1) == self.viewer_native.as_ref().map(|n| n.1)
&& let Some(bytes) = g.read_display(f)
{
let to_image = |bytes: Vec<u8>, w: u32, h: u32| {
let px = bytes.chunks_exact(4).map(|c| egui::Color32::from_rgba_premultiplied(c[0], c[1], c[2], c[3])).collect();
self.viewer_image = Some(Arc::new(egui::ColorImage::new([f.width as usize, f.height as usize], px)));
Arc::new(egui::ColorImage::new([w as usize, h as usize], px))
};
if self.viewer_image.is_none()
&& let (Some((_, key, f)), Some(g)) = (&self.viewer_native, &self.gpu)
&& self.viewer_shown.as_ref().map(|s| s.1) == Some(*key)
{
if cfg!(target_arch = "wasm32") {
let ready = self.viewer_readback.1.lock().ok().and_then(|mut r| r.take_if(|r| r.0 == *key));
if let Some((_, img)) = ready {
self.viewer_image = Some(img);
} else if self.viewer_readback.0 != Some(*key) {
self.viewer_readback.0 = Some(*key);
let (slot, k, (w, h)) = (self.viewer_readback.1.clone(), *key, (f.width, f.height));
let ctx = self.frames.context();
g.read_display_async(f, move |bytes| {
if let (Some(b), Ok(mut s)) = (bytes, slot.lock()) {
*s = Some((k, to_image(b, w, h)));
}
if let Some(c) = ctx {
c.request_repaint();
}
});
}
} else if let Some(bytes) = g.read_display(f) {
self.viewer_image = Some(to_image(bytes, f.width, f.height));
}
}
self.viewer_image.clone()
}
@ -532,7 +571,8 @@ impl EffectcraftApp {
queued += 1;
}
}
if let Some(a) = &self.audio {
if let Some(a) = &mut self.audio {
a.pump();
self.meter.update(a.feed.take_peaks(), now);
} else if self.meter.active() {
self.meter.update([0.0; 2], now);
@ -738,6 +778,11 @@ impl EffectcraftApp {
self.session.poll_mask_track();
ctx.request_repaint_after(std::time::Duration::from_millis(50));
}
// Analyses running in a worker (the web app).
if !self.session.offloaded.is_empty() {
self.session.poll_offload();
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
// Warp Stabilizer: finish analyses and start queued (re-)analyses in the background.
if self.session.warp_job.is_some() || !self.session.warp_pending.is_empty() {
self.session.poll_warp(true);

View File

@ -361,6 +361,20 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
painter.text(comp_rect.left_top() + vec2(6.0, 6.0), Align2::LEFT_TOP, "Snapshot", Tokens::ui(11.0), SNAP_COLOR);
return;
}
// A GPU frame (the web viewer): drawn straight from its texture; Show Channel / exposure
// read it back first (asynchronously in the browser: from the next frame on).
let gpu = matches!((&app.viewer_shown, &app.viewer_native), (Some(s), Some(n)) if s.0 == n.0 && s.1 == n.1 && n.1.comp == cid.0);
let rect = if gpu {
let id = app.viewer_shown.map(|s| s.0).expect("shown");
if plain || app.viewer_pixels().is_none() {
if !plain {
ctx.request_repaint();
}
painter.image(id, comp_rect, uv, Color32::WHITE);
return;
}
comp_rect
} else {
let Some((tex, k)) = app.viewer_tex.clone() else { return };
if k.comp != cid.0 {
return;
@ -374,6 +388,8 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
painter.image(tex.id(), rect, uv, Color32::WHITE);
return;
}
rect
};
let Some(src) = app.viewer_image.clone() else { return };
let key = {
use std::hash::{Hash, Hasher};

View File

@ -154,7 +154,8 @@ chain with one upload and one readback. Tests render scenes on both paths and co
(≤ 1/255 at 8 bpc, ≤ 1e-3 at 32 bpc); they skip without an adapter. The desktop viewer builds the
`Gpu` on egui-wgpu's device and shows frames from GPU textures without reading them back
(`ui-egui::frames`); headless renders, the CLI (unless `--gpu`) and CI use the CPU. On the web
(WebGPU) the GPU composites viewer frames; steps that need a readback fall back to the CPU.
(WebGPU) the GPU composites viewer frames; steps that need a readback fall back to the CPU (the
Info panel's pixel readout reads GPU frames back asynchronously).
**Colour and bit depth** (`crates/render/src/color.rs`, `crates/color/src/space.rs`). Pixels are
`f32`, but 8 and 16 bpc projects clamp and quantise each layer after its source and masks and
@ -259,6 +260,14 @@ inspect and click the interface. See [agents.md](agents.md) and
Settings (`engine::prefs`), keyboard shortcut presets (`engine::shortcuts`) and auto-save /
crash recovery (`engine::autosave`) live in the engine too; they persist through a
`ConfigStore` the frontend provides (a directory on the desktop, `localStorage` on the web).
`ConfigStore` the frontend provides (a directory on the desktop; the Origin Private File System,
or IndexedDB, on the web). Auto-saves go through the store's `FileOps` (the file system by
default, the browser storage on the web).
Renders and analyses run on background threads on the desktop. Where there are no threads (the
browser), `engine::offload` sends them to a second engine instance instead: the session's
`Offload` ships the serialized project, the footage it reads and the job (`WorkerRequest`) to a
Web Worker, and the worker's replies (progress, item status, the analysed property group) are
applied on the UI thread.
The shortcut dispatcher and the menus read the active preset. See
[preferences.md](preferences.md).

View File

@ -93,7 +93,7 @@ Timeline column/search depth, the Home screen and every After Effects workspace
| Compositions | 72% | 6.0 | Essential Graphics panel (the marker dialog and Composition Flowchart landed) |
| Animation | 65% | 10.4 | puppet, Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8) |
| Text | ≈ 86% | 1.3 | vertical type is basic (upright characters, no tate-chu-yoko), no OpenType feature panel, no extruded strokes (extruded, bevelled text landed in M7.6); per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API landed (M9.9–M9.10) |
| Web | 60% | 2.5 | threads, browser storage, audio |
| Web | 85% | 1.0 | viewer frames and Roto Brush propagation still on the page's thread; GPU effects in the browser (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2) |
| 3D | 72% | 12.0 | 3D camera tracker, multi-view layouts, Advanced 3D motion blur and blend modes, cameras/lights from models; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) landed in M7.4–M7.6 |
| Effects | ≈ 75% | 5.0 | GPU versions of more effects and the 3D Camera Tracker (every other After Effects effect exists since M9.11 and M12.5) |
| Interface | 70% | 6.0 | Timeline horizontal scrolling of many columns, a richer Learn area (native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) |

View File

@ -2,9 +2,10 @@
`apps/effectcraft-web` runs the same engine and egui UI as the desktop app in the browser: the
workspace compiled to `wasm32-unknown-unknown`, started by eframe's web runner on WebGPU (WebGL2
fallback). It is a static site: one `.wasm`, its `wasm-bindgen` JavaScript glue, `index.html` and an
icon. Nothing is uploaded anywhere; projects, media and renders stay in the page and on the user's
machine.
fallback). It is a static site: one `.wasm`, its `wasm-bindgen` JavaScript glue, `index.html`, a
few small scripts (worker, audio worklet, service worker), a web manifest and icons. Nothing is
uploaded anywhere: projects, media, settings and renders stay in the browser (its private storage)
and on the user's machine.
## Build and run
@ -15,48 +16,136 @@ machine.
| wasm-opt (optional) | any recent binaryen | `brew install binaryen`; used by `cargo xtask web` when found |
```sh
cargo xtask web # release build → <target>/web/dist (index.html, effectcraft_web.js, effectcraft_web_bg.wasm, favicon.svg)
cargo xtask web # release build → <target>/web/dist
cargo xtask web --dev # unoptimised build (faster to compile, slow to run)
cargo xtask web --serve 8765 # build, then serve dist on http://127.0.0.1:8765/
```
`<target>` is `$CARGO_TARGET_DIR` or `target`. `--serve` is a tiny localhost-only static server that
sends `Cross-Origin-Opener-Policy` / `Cross-Origin-Embedder-Policy`, so the page is cross-origin
`<target>` is `$CARGO_TARGET_DIR` or `target`. `dist` holds `index.html`, `effectcraft_web.js` +
`effectcraft_web_bg.wasm` (+ `snippets/`, the browser glue `js/host.js`), `worker.js`,
`audio-worklet.js`, `sw.js` (its precache list and version filled in by the build),
`manifest.webmanifest`, `favicon.svg` and `icon-256.png` / `icon-512.png` (copied from
`assets/app-icon`). `--serve` is a tiny localhost-only static server that sends
`Cross-Origin-Opener-Policy` / `Cross-Origin-Embedder-Policy`, so the page is cross-origin
isolated like a production deployment should be. Any static server works
(`python3 -m http.server -d target/web/dist 8765`); it must serve `.wasm` as `application/wasm`.
Service workers need a secure context: `https://`, or `http://localhost` / `127.0.0.1`.
The release `.wasm` is about 31 MB before `wasm-opt` (20 MB code: the effects, codecs, the
expression engine and the UI; 8.5 MB data, mostly the bundled fonts; 2 MB function names for
readable panics). Served compressed it is a fraction of that.
The release `.wasm` is about 38 MB before `wasm-opt` (code: the effects, codecs, the expression
engine and the UI; data, mostly the bundled fonts; function names for readable panics). Served
compressed it is a fraction of that, and the service worker caches it after the first visit.
The web crate is empty on non-wasm targets, so `cargo test --workspace` and clippy are unaffected;
`cargo xtask wasm` (part of `cargo xtask ci`) checks it, with every L0–L4 crate and
`effectcraft-ui-egui`, for `wasm32-unknown-unknown`.
On other targets the web crate holds only its portable storage model (`store.rs`, unit-tested by
`cargo test --workspace`); `cargo xtask wasm` (part of `cargo xtask ci`) checks the whole crate,
with every L0–L4 crate and `effectcraft-ui-egui`, for `wasm32-unknown-unknown`.
URL flags: `?empty` (start without the demo project), `?home` (show the start screen).
URL flags: `?empty` (a blank project; the session is not restored or recorded), `?demo` (the demo
project instead of the last session), `?home` (show the start screen), `?storage=indexeddb` /
`?storage=memory` (force a storage backend), `?noworkers` (render and analyse on the page's
thread), `?nosw` (don't register the service worker).
## How the desktop pieces map to the browser
| Desktop | Web |
|---|---|
| frame render thread pool (`ui-egui/src/frames.rs`) | `Frames::pump`: queued frames render on the UI thread after each egui frame, most important first (the viewer's frame, then prefetch), within 40 ms (24 ms while playing) |
| Render Queue on a background thread | `Session::start_render` always runs inline on wasm32: the page waits until the render is done |
| Render Queue on a background thread | a Web Worker running a second engine instance (below); progress streams back, the page never waits |
| tracker / mask tracker / Warp Stabilizer / Roto Brush Freeze threads | the same workers; the analysed property group comes back as one undo step |
| `rayon` parallel loops (raster, effects, export batches) | rayon's global pool falls back to the calling thread when threads are unavailable: same code, serial |
| `std::time::Instant` / `SystemTime` (panic on wasm32) | `web-time` (re-exports `std::time` on native) |
| `std::fs` project reads/writes (`FsServices`) | `files::WebServices`: an in-memory file table; saving downloads the `.ecproj` |
| `std::fs` project reads/writes (`FsServices`) | `files::WebServices`: the virtual file table, persisted (below); saving also downloads the `.ecproj` |
| config directory: settings, shortcut presets, recent projects, the recovery sentinel | `store::WebConfig` in browser storage |
| auto-save folder | `/EffectCraft Auto-Save/` in browser storage (`ConfigStore::files`) |
| media reads (`MediaPool`, `probe`) | `files::WebImporter`: bytes from the file table, handed to `MediaPool::add_bytes` / `probe_bytes` |
| export writes (`effectcraft-export`) | the job's `sink` (`effectcraft_host::FileExporter { sink }`): files land in the table and download after the render; several files (an image sequence) download as one stored `.zip` |
| rfd file dialogs | `<input type=file>` for File ▸ Open / Import; drop files anywhere on the page (`.ecproj` opens, everything else imports); "Save As" / "Output To" pick a download name |
| system fonts | not scanned; the bundled fonts (Inter, Noto Serif, JetBrains Mono) are always there |
| TCP control channel / MCP | `window.effectcraft` (below) |
| audio output | none yet (if `cpal` lands for desktop playback it must stay behind a cfg/feature off wasm32; a WebAudio backend would replace it) |
| cpal audio output | Web Audio (below) |
| GPU compositor on the desktop's wgpu device | the same compositor on eframe's WebGPU device (below) |
### Threads
### Persistence
wasm threads need a cross-origin isolated page **and** a wasm build with atomics (a nightly
`build-std` toolchain), so this build is single-threaded: frames, effects and exports run on the UI
thread. A long Render Queue job freezes the page until it finishes; keep web renders short or
small (resolution, time span).
Everything that lives in files on the desktop lives in the browser's **Origin Private File
System** (OPFS); where OPFS can't be written from the page (older Safari), **IndexedDB**; with
neither (some private windows), memory only. `effectcraft.info().storage.backend` says which.
The page asks for persistent storage (`navigator.storage.persist()`), which browsers grant to
installed apps, so the store isn't evicted under storage pressure.
The engine's storage traits are synchronous and the browser's are not, so the app works on an
in-memory mirror (`store.rs`): every stored entry is read before the app starts, reads and writes
hit the mirror, and changes flush in the background (one write per changed key, in order; OPFS
writes are atomic: a swap file replaced on close). Keys:
| Key | What |
|---|---|
| `config/prefs.json`, `config/shortcuts.json` | Settings (with File ▸ Open Recent's list) and keyboard shortcut presets |
| `config/session.lock` | the crash-recovery sentinel |
| `config/session.ecproj`, `config/session.json` | the session snapshot: the open project and editor state (active comp, time, selection), written at most once a second while they change |
| `files/<path>` | the file table: imported media, projects saved with File ▸ Save / Save As or `saveToBrowser`, auto-saves |
On the next visit the app reopens the snapshot (unsaved changes stay unsaved: the project is
dirty), registers every stored media file with the media pool so its footage decodes, and File ▸
Open Recent reopens saved projects from storage. Without a snapshot (first visit) it opens the
demo project. Render outputs are not stored: they download.
### Background jobs: Web Workers
wasm threads need shared memory, which needs a nightly `build-std` toolchain, so this build is
single-threaded per instance. Long jobs run in **Web Workers** instead, each with its own instance
of the same module (the page compiles it once and posts the `WebAssembly.Module`, so nothing is
compiled twice) and its own memory (`crates/engine/src/offload.rs`, `src/worker.rs`):
1. The session's `Offload` serializes a `WorkerRequest`: the project JSON, the footage paths it
reads, and the job (`render` with the resolved queue items and output paths; `warp`, `track`,
`maskTrack`, `rotoFreeze` with the target and analysis parameters).
2. `js/host.js` takes an idle worker from its pool (or starts one), sends it the footage bytes it
doesn't have yet, then the request.
3. The worker runs the job on a plain engine session, blocking, and posts `WorkerReply`s: render
progress (at most every 100 ms), item started/finished, rendered files (transferred), analysis
progress, the analysed property group, `failed`, `done`.
4. The page applies them every frame: `Session::poll_render` updates the queue items and the
progress bar, `Session::poll_offload` writes an analysis result as one undo step (the effect /
tracker / mask group is replaced; other edits made meanwhile are kept) and the downloads start.
Stopping a job terminates its worker (a worker can't be interrupted mid-frame): the item being
rendered becomes "User Stopped", an analysis writes nothing. `renderQueue.render {wait: true}` (the
default for agents) and analysis commands with `wait: true` still run on the page's thread and
return when done. Roto Brush propagation fills the session's segmentation cache rather than the
project, so it runs on the page's thread too, as do viewer frames (`Frames::pump`).
### Audio
Audio preview goes through **Web Audio**: an AudioWorklet (`audio-worklet.js`; a
ScriptProcessorNode where worklets are unavailable) plays interleaved stereo blocks. The mixdown
is the desktop's (`AudioPlayback`, `mix_comp`): without a feeder thread, `AudioPlayback::pump`
mixes ahead on every UI frame (≈ 350 ms queued) and the output device takes what keeps it
≈ 150 ms ahead of the playhead. A/V sync follows `AudioContext.currentTime`: the playback clock
is the frames played by the context clock minus its output latency, and the viewer shows the
frame under the audible sample, as on the desktop. The Audio panel's meters read the same feed.
Browsers start an AudioContext suspended until the user interacts with the page; the app resumes
it on the first pointer or key event, so audio plays from the first preview after any click or
key press.
### GPU
eframe starts on WebGPU where the browser has it (WebGL2 otherwise), and the GPU compositor
(`effectcraft-gpu`) runs on the same device: viewer frames are composited by compute shaders and
drawn straight from their texture, with no readback. Steps that need the CPU mid-render (3D runs,
adjustment layers, CPU-only effects) fall back to the CPU renderer for that frame, because the
page's thread can't wait for a GPU readback. The Info panel's pixel readout and the eyedroppers
read GPU frames back asynchronously (`Gpu::read_display_async`: the pixels arrive a frame
later). On WebGL2, or without a usable adapter, everything renders on the CPU.
`effectcraft.info().gpu` reports `{compositor, viewerOnGpu}`.
### Offline and install (PWA)
`manifest.webmanifest` (name, icons, standalone display) makes the app installable; `sw.js`
precaches every file of the build into a cache named after the build's hash, serves them
cache-first (the cached responses keep the server's COOP/COEP headers, so the page stays
cross-origin isolated offline), and drops older caches when a new build activates. After the
first visit the app loads without a network.
## `window.effectcraft`: the agent / test API
@ -70,34 +159,52 @@ with the method's `result`, errors reject with the message.
| `effectcraft.commands()` / `effectcraft.inspect()` | `engine.commands` / `ui.inspect` |
| `effectcraft.renderFrame(params)` | `render.frame` with `base64: true`: `{comp, time, width, height, png}` |
| `effectcraft.screenshot(params)` | `ui.screenshot`; without `path` the PNG comes back inline as `png` (base64) |
| `effectcraft.addFile(fileOrUrl, name?)` | put a `File`/`Blob` (or fetched URL) into the file table and open (`.ecproj`) or import it; resolves with its path |
| `effectcraft.addFile(fileOrUrl, name?)` | put a `File`/`Blob` (or fetched URL) into the file table (stored) and open (`.ecproj`) or import it; resolves with its path |
| `effectcraft.files()` / `effectcraft.readFile(path)` | the file table `[{path, size}]` / a file's bytes (`Uint8Array`), e.g. a render |
| `effectcraft.info()` | graphics backend, version, `crossOriginIsolated`, load timings |
| `effectcraft.saveToBrowser(path?)` | save the project to browser storage without downloading it (default: its path, or `/<name>.ecproj`); resolves with `{path, bytes}` |
| `effectcraft.listStored()` | `{backend, usage, quota, persisted, pending, files: [{path, size, modified}], config: [name]}` |
| `effectcraft.removeStored(path)` | delete a stored file |
| `effectcraft.flush()` | resolves once every change is written to browser storage |
| `effectcraft.info()` | graphics backend, `gpu`, `storage`, `audio` (`{state, sampleRate, backend, posted, played, underruns}`), `workers`, `restored`, `webgpu`, `serviceWorker`, version, `crossOriginIsolated`, load timings |
`window.effectcraftLoad` holds `{wasmMs, readyMs}` (module fetch + compile, and until the app runs).
```js
await effectcraft.execute("layer.newSolid", {color: "#ff8800"});
await effectcraft.request("ui.menu.invoke", {id: "renderQueue.add", params: {format: "gif", resolution: 0.25}});
await effectcraft.execute("renderQueue.render", {}); // the GIF downloads
await effectcraft.execute("renderQueue.render", {wait: false}); // renders in a worker; the GIF downloads
await effectcraft.saveToBrowser("/my-project.ecproj"); // File ▸ Open Recent has it after a reload
```
## Browser test
`apps/effectcraft-web/tests/smoke.mjs` drives headless Chrome over the DevTools protocol (Node ≥ 22,
no npm packages): load, the demo comp in the viewer, `render.frame`, Render Queue GIF and PNG
sequence (`.zip`) downloads, Save As and re-opening the saved project. It writes screenshots and
`report.json`:
no npm packages): load, the GPU path (WebGPU → GPU compositor, viewer on the GPU), the demo comp
in the viewer, `render.frame`, Render Queue GIF and PNG sequence (`.zip`) downloads, a background
render in a worker while measuring the page's event-loop gaps (must stay under 400 ms) and its
progress, the AudioContext starting on a (simulated) key press and a tone playing through it
with the meters moving, Save As and re-opening the saved project, persistence across a reload
(session snapshot, a stored project, imported media, a setting, Open Recent) and an offline
reload through the service worker. It writes screenshots and `report.json`:
```sh
cargo xtask web --serve 8765 &
node apps/effectcraft-web/tests/smoke.mjs --url http://127.0.0.1:8765/ --out target/web/smoke
```
Native unit tests cover the storage model (`apps/effectcraft-web/src/store.rs`: write
coalescing, file table, `ConfigStore` / `FileOps`, auto-save and crash recovery through the
browser store) and the worker protocol (`crates/engine/src/offload.rs`: serde round trips of
every request and reply, a render through an in-process offload).
## Gaps
- Single-threaded (above); Render Queue renders block the page.
- No audio playback.
- Imported media live in memory for the session only (nothing is persisted; reload = fresh start).
- Viewer frames, RAM preview and Roto Brush propagation still render on the page's thread (one
frame at a time between UI frames).
- GPU effects run only in the desktop GPU path: in the browser, effects run on the CPU (inside
workers for renders) because the page's thread can't wait for mid-render readbacks.
- Cancelling an analysis drops its partial result (the worker is terminated).
- Stored media count against the origin's storage quota; `effectcraft.listStored()` shows usage,
`removeStored` frees it (there is no storage manager in the UI yet).
- `engine.execute` rejects the spread Render Settings / Output Module keys of `renderQueue.add`
(`resolution`, `timeSpan`…) through its strict parameter check; use `ui.menu.invoke` as above.

View File

@ -285,8 +285,14 @@ fn web(args: &[String]) -> Result<(), String> {
std::fs::copy(e.path(), dist.join(&name)).map_err(|e| format!("{name}: {e}"))?;
}
}
// PWA icons: the app icon (assets/app-icon, attributed there).
for n in [256, 512] {
let src = format!("assets/app-icon/hicolor/{n}x{n}/apps/ai.storyteller.effectcraft.png");
std::fs::copy(&src, dist.join(format!("icon-{n}.png"))).map_err(|e| format!("{src}: {e}"))?;
}
service_worker(&dist)?;
let size = |p: &std::path::Path| std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
let total: u64 = std::fs::read_dir(&dist).map_err(|e| e.to_string())?.flatten().map(|e| size(&e.path())).sum();
let total: u64 = dist_files(&dist).iter().map(|f| size(&dist.join(f))).sum();
println!("web: {} ({:.1} MB wasm, {:.1} MB total)", dist.display(), size(&bg) as f64 / 1e6, total as f64 / 1e6);
if let Some(port) = serve {
serve_dir(&dist, port)?;
@ -294,6 +300,42 @@ fn web(args: &[String]) -> Result<(), String> {
Ok(())
}
/// Every file under `dist`, as `/`-separated relative paths, sorted.
fn dist_files(dist: &std::path::Path) -> Vec<String> {
fn walk(dir: &std::path::Path, rel: &str, out: &mut Vec<String>) {
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let name = e.file_name().to_string_lossy().to_string();
let r = if rel.is_empty() { name } else { format!("{rel}/{name}") };
if e.path().is_dir() {
walk(&e.path(), &r, out);
} else {
out.push(r);
}
}
}
let mut v = vec![];
walk(dist, "", &mut v);
v.sort();
v
}
/// Fill the service worker's precache list and version (a hash of the build's files).
fn service_worker(dist: &std::path::Path) -> Result<(), String> {
let sw = dist.join("sw.js");
let text = std::fs::read_to_string(&sw).map_err(|e| format!("sw.js: {e}"))?;
let files: Vec<String> = dist_files(dist).into_iter().filter(|f| f != "sw.js").collect();
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for f in &files {
for b in f.bytes().chain(std::fs::read(dist.join(f)).map_err(|e| format!("{f}: {e}"))?) {
h ^= b as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
}
}
let list: Vec<String> = std::iter::once("./".to_string()).chain(files.iter().map(|f| format!("./{f}"))).map(|f| format!("{f:?}")).collect();
let text = text.replace("__EC_VERSION__", &format!("{h:016x}")).replace("__EC_FILES__", &format!("[{}]", list.join(", ")));
std::fs::write(&sw, text).map_err(|e| format!("sw.js: {e}"))
}
/// A tiny static file server for the web build (localhost only). Sends the cross-origin
/// isolation headers so `crossOriginIsolated` is true, like a production deployment would.
fn serve_dir(dir: &std::path::Path, port: u16) -> Result<(), String> {
@ -330,7 +372,7 @@ fn serve_dir(dir: &std::path::Path, port: u16) -> Result<(), String> {
"js" => "text/javascript",
"wasm" => "application/wasm",
"svg" => "image/svg+xml",
"json" => "application/json",
"json" | "webmanifest" => "application/json",
"png" => "image/png",
"gif" => "image/gif",
"ecproj" => "application/json",