From 31f192e2a90823857a6cefe3f150cb452b5ae89e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 00:19:35 -0400 Subject: [PATCH] M15.1: web build (eframe wasm) + xtask web - apps/effectcraft-web (L6): eframe web runner booting the shared EffectcraftApp with a browser session: in-memory file table, pickers and drag-and-drop for open/import, downloads for Save and Render Queue outputs (several files as one stored .zip), and the window.effectcraft promise API over the control channel. Demo project on start. - wasm32 runtime fixes: web-time for Instant/SystemTime (render, engine, export, ui-egui); Frames renders queued jobs on the UI thread (Frames::pump) instead of a thread pool; Render Queue runs inline; relative outputs resolve under / ; control PNGs inline. - effectcraft-export: per-job output Sink (files to memory instead of disk); host FileExporter carries it; test that sink exports touch no file. - xtask web [--dev] [--serve PORT] (wasm-bindgen 0.2.129, optional wasm-opt, COOP/COEP server); effectcraft-web in WEB_CRATES. docs/web.md, headless Chrome smoke test, favicon (+attribution). Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.md | 1 + Cargo.lock | 24 ++ Cargo.toml | 2 + README.md | 5 +- ROADMAP.md | 2 +- apps/effectcraft-web/Cargo.toml | 50 ++++ apps/effectcraft-web/src/api.rs | 118 +++++++++ apps/effectcraft-web/src/files.rs | 233 ++++++++++++++++++ apps/effectcraft-web/src/lib.rs | 179 ++++++++++++++ apps/effectcraft-web/tests/smoke.mjs | 175 +++++++++++++ apps/effectcraft-web/web/favicon.svg | 8 + .../web/favicon.svg.attribution | 9 + apps/effectcraft-web/web/index.html | 64 +++++ crates/engine/Cargo.toml | 1 + crates/engine/src/render_queue.rs | 11 +- crates/export/Cargo.toml | 1 + crates/export/src/encode.rs | 13 +- crates/export/src/lib.rs | 37 ++- crates/export/src/out.rs | 77 ++++++ crates/export/tests/roundtrip.rs | 6 +- crates/host/src/lib.rs | 42 +++- crates/render/Cargo.toml | 1 + crates/render/src/lib.rs | 6 +- crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/control.rs | 11 +- crates/ui-egui/src/frames.rs | 135 ++++++---- crates/ui-egui/src/lib.rs | 8 + docs/architecture.md | 5 +- docs/web.md | 103 ++++++++ xtask/src/main.rs | 125 +++++++++- 30 files changed, 1361 insertions(+), 92 deletions(-) create mode 100644 apps/effectcraft-web/Cargo.toml create mode 100644 apps/effectcraft-web/src/api.rs create mode 100644 apps/effectcraft-web/src/files.rs create mode 100644 apps/effectcraft-web/src/lib.rs create mode 100644 apps/effectcraft-web/tests/smoke.mjs create mode 100644 apps/effectcraft-web/web/favicon.svg create mode 100644 apps/effectcraft-web/web/favicon.svg.attribution create mode 100644 apps/effectcraft-web/web/index.html create mode 100644 crates/export/src/out.rs create mode 100644 docs/web.md diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 97598349..58394d2b 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -16,6 +16,7 @@ iconography, images or other Adobe assets; open licences only; every asset attri | `assets/fonts/Inter-Italic.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | | `assets/fonts/NotoSerif-Regular.ttf` | The Noto Project Authors | https://github.com/notofonts/latin-greek-cyrillic | OFL-1.1 (`assets/fonts/OFL-NotoSerif.txt`) | | `assets/fonts/JetBrainsMono-Regular.ttf` | The JetBrains Mono Project Authors | https://github.com/JetBrains/JetBrainsMono | OFL-1.1 (`assets/fonts/OFL-JetBrainsMono.txt`) | +| `apps/effectcraft-web/web/favicon.svg` | EffectCraft contributors | Original work: web app icon, the code-drawn EffectCraft mark (split rounded square, keyframe diamond) as SVG | MIT OR Apache-2.0 | ## Screenshots diff --git a/Cargo.lock b/Cargo.lock index 9d3c76d3..b24259a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1126,6 +1126,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "web-time", ] [[package]] @@ -1147,6 +1148,7 @@ dependencies = [ "log", "rayon", "thiserror 2.0.21", + "web-time", ] [[package]] @@ -1266,6 +1268,7 @@ dependencies = [ "log", "rayon", "serde", + "web-time", ] [[package]] @@ -1312,6 +1315,27 @@ dependencies = [ "rayon", "serde", "serde_json", + "web-time", +] + +[[package]] +name = "effectcraft-web" +version = "0.1.0" +dependencies = [ + "effectcraft-engine", + "effectcraft-export", + "effectcraft-expr", + "effectcraft-host", + "effectcraft-media", + "effectcraft-ui-egui", + "eframe", + "egui", + "js-sys", + "log", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index bd1d4b20..95986671 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,8 @@ serde = { version = "1", features = ["derive", "rc"] } serde_json = { version = "1", features = ["float_roundtrip"] } log = "0.4" rayon = "1" +# std::time::Instant/SystemTime on native; the browser clock on wasm32 (std's panic there). +web-time = "1" gif = "0.14" proptest = "1" diff --git a/README.md b/README.md index f1f9d711..f40c884a 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,7 @@ git clone https://github.com/storytold/effectcraft cd effectcraft cargo run --release -p effectcraft # the app, with the demo project open cargo run --release -p effectcraft-cli -- render --out intro.mp4 # render the demo headless +cargo xtask web --serve 8765 # the browser build on http://127.0.0.1:8765/ (docs/web.md) ``` To work on it: `cargo test --workspace`, and `cargo xtask ci` before every commit (format, lints, @@ -176,8 +177,8 @@ tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING EffectCraft is young. The core of After Effects is in place and gets deeper every week: what is done and what comes next is in the [ROADMAP](ROADMAP.md). Not there yet: layer styles, time and -audio effects, audio playback, motion tracking, puppet and paint tools, the web build and GPU -compositing. Projects are saved as `.ecproj`, readable versioned JSON; After Effects `.aep` +audio effects, audio playback, motion tracking, puppet and paint tools, and GPU compositing. The +web build runs the full app in the browser, single-threaded for now. Projects are saved as `.ecproj`, readable versioned JSON; After Effects `.aep` files cannot be opened. ## How it's made diff --git a/ROADMAP.md b/ROADMAP.md index 39c0f0e7..06a99d59 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,7 +6,7 @@ once. | | Milestone | State | |---|---|---| -| M0 | Skeleton: crates, compositor, the After Effects style shell, control channel, `cargo xtask` | Done (web build pending) | +| M0 | Skeleton: crates, compositor, the After Effects style shell, control channel, `cargo xtask` | Done (web build: [docs/web.md](docs/web.md)) | | M1 | Keyframes: temporal and spatial interpolation, Easy Ease, roving, velocity | Done | | M2 | Compositing: 38 blend modes, track mattes, parenting, adjustment layers | Done | | M3 | Project operations: settings dialogs, layer commands, `.ecproj`, undo, After Effects menu bar | Mostly done | diff --git a/apps/effectcraft-web/Cargo.toml b/apps/effectcraft-web/Cargo.toml new file mode 100644 index 00000000..d3f8aebb --- /dev/null +++ b/apps/effectcraft-web/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "effectcraft-web" +description = "EffectCraft in the browser (wasm32): eframe web runner, in-memory files, browser file pickers and downloads, window.effectcraft API" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[lints] +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] +effectcraft-engine = { workspace = true } +effectcraft-host = { workspace = true } +effectcraft-media = { workspace = true } +effectcraft-export = { workspace = true } +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 } +# The wasm-bindgen CLI used by `cargo xtask web` must be exactly this version. +wasm-bindgen = "=0.2.129" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +web-sys = { version = "0.3", features = [ + "Blob", + "BlobPropertyBag", + "CssStyleDeclaration", + "Document", + "Element", + "Event", + "EventTarget", + "File", + "FileList", + "HtmlAnchorElement", + "HtmlCanvasElement", + "HtmlElement", + "HtmlInputElement", + "Location", + "Url", + "UrlSearchParams", + "Window", +] } diff --git a/apps/effectcraft-web/src/api.rs b/apps/effectcraft-web/src/api.rs new file mode 100644 index 00000000..5579d111 --- /dev/null +++ b/apps/effectcraft-web/src/api.rs @@ -0,0 +1,118 @@ +//! `window.effectcraft`: the desktop control channel (`docs/control-protocol.md`) as promises, +//! for agents and browser tests. `web/index.html` wraps the exported [`request`] as +//! `effectcraft.request(method, params)`, `effectcraft.execute(command, params)` and friends. + +use std::cell::RefCell; +use std::sync::mpsc::{Receiver, Sender}; + +use effectcraft_ui_egui::ControlRequest; +use serde_json::{Value, json}; +use wasm_bindgen::prelude::*; + +type Pending = (Receiver, js_sys::Function, js_sys::Function); + +thread_local! { + static TX: RefCell>> = const { RefCell::new(None) }; + static PENDING: RefCell> = const { RefCell::new(Vec::new()) }; + static INFO: RefCell = const { RefCell::new(Value::Null) }; +} + +pub(crate) fn set_sender(tx: Sender) { + TX.with(|t| *t.borrow_mut() = Some(tx)); +} + +/// Environment facts reported by `effectcraft.info()`. +pub fn set_info(key: &str, v: Value) { + INFO.with(|i| { + let mut i = i.borrow_mut(); + if !i.is_object() { + *i = json!({}); + } + i[key] = v; + }); +} + +fn to_js(v: &Value) -> JsValue { + js_sys::JSON::parse(&v.to_string()).unwrap_or(JsValue::NULL) +} + +fn from_js(v: &JsValue) -> Value { + if v.is_undefined() || v.is_null() { + return json!({}); + } + js_sys::JSON::stringify(v).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({})) +} + +/// Send a control request to the app; the promise resolves with its `result` or rejects with its +/// `error`. +#[wasm_bindgen] +pub fn request(method: String, params: JsValue) -> js_sys::Promise { + let params = from_js(¶ms); + js_sys::Promise::new(&mut |resolve, reject| { + let (req, rx) = ControlRequest::new(method.clone(), params.clone()); + let sent = TX.with(|t| t.borrow().as_ref().is_some_and(|tx| tx.send(req).is_ok())); + if !sent { + let _ = reject.call1(&JsValue::NULL, &JsValue::from_str("EffectCraft is not running yet")); + return; + } + PENDING.with(|p| p.borrow_mut().push((rx, resolve, reject))); + crate::repaint(); + }) +} + +/// Files in the in-memory file table: `[{path, size}]` (picked, dropped, saved and rendered files). +#[wasm_bindgen] +pub fn files() -> JsValue { + to_js(&Value::Array(crate::files::list().into_iter().map(|(path, size)| json!({"path": path, "size": size})).collect())) +} + +/// A file's bytes from the table (e.g. a render), or `undefined`. +#[wasm_bindgen(js_name = readFile)] +pub fn read_file(path: String) -> JsValue { + crate::files::get(&path).map(|d| js_sys::Uint8Array::from(&d[..]).into()).unwrap_or(JsValue::UNDEFINED) +} + +/// Put bytes into the file table under `/` and open (`.ecproj`) or import them. Resolves +/// with the table path. +#[wasm_bindgen(js_name = addFile)] +pub fn add_file(name: String, data: js_sys::Uint8Array) -> String { + let path = crate::files::path_for(&name); + crate::files::put(&path, data.to_vec().into()); + let p = path.clone(); + crate::post(move |app| crate::open_or_import(app, vec![p])); + path +} + +/// Backend and build facts. +#[wasm_bindgen] +pub fn info() -> JsValue { + let mut v = INFO.with(|i| i.borrow().clone()); + if !v.is_object() { + v = json!({}); + } + v["version"] = json!(env!("CARGO_PKG_VERSION")); + v["threads"] = json!(false); + to_js(&v) +} + +/// Settle the promises whose replies arrived (called every frame). +pub(crate) fn poll_replies() { + PENDING.with(|p| { + p.borrow_mut().retain(|(rx, resolve, reject)| match rx.try_recv() { + Ok(v) => { + if v["ok"].as_bool() == Some(true) { + let _ = resolve.call1(&JsValue::NULL, &to_js(&v["result"])); + } else { + let msg = v["error"].as_str().map(str::to_string).unwrap_or_else(|| v.to_string()); + let _ = reject.call1(&JsValue::NULL, &JsValue::from_str(&msg)); + } + false + } + Err(std::sync::mpsc::TryRecvError::Empty) => true, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + let _ = reject.call1(&JsValue::NULL, &JsValue::from_str("request dropped")); + false + } + }); + }); +} diff --git a/apps/effectcraft-web/src/files.rs b/apps/effectcraft-web/src/files.rs new file mode 100644 index 00000000..853a92e6 --- /dev/null +++ b/apps/effectcraft-web/src/files.rs @@ -0,0 +1,233 @@ +//! Files in the browser: an in-memory file table in place of the file system, `` +//! pickers for Open/Import, and downloads for everything the app writes (projects, renders). +//! +//! Picked or dropped files are read into the table under `/`; the engine, the media pool and +//! the exporter read and write through it ([`WebServices`], [`WebImporter`], [`export_sink`]). + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use effectcraft_engine::Services; +use effectcraft_engine::project::Footage; +use wasm_bindgen::JsCast; +use wasm_bindgen::prelude::*; + +static FILES: Mutex>>> = Mutex::new(None); +/// Render outputs written since the last [`flush_downloads`]. +static OUTPUTS: Mutex> = Mutex::new(Vec::new()); + +fn with_files(f: impl FnOnce(&mut HashMap>) -> R) -> R { + f(FILES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_default()) +} + +/// Store a file in the table. +pub fn put(path: &str, data: Arc<[u8]>) { + with_files(|m| m.insert(path.to_string(), data)); +} + +pub fn get(path: &str) -> Option> { + with_files(|m| m.get(path).cloned()) +} + +/// `(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 +} + +/// The table path for a picked/dropped file name. +pub fn path_for(name: &str) -> String { + let base = name.rsplit(['/', '\\']).next().unwrap_or(name); + format!("/{base}") +} + +fn file_name(path: &str) -> &str { + path.rsplit('/').next().filter(|n| !n.is_empty()).unwrap_or("effectcraft-output") +} + +fn mime(name: &str) -> &'static str { + match name.rsplit('.').next().unwrap_or("").to_ascii_lowercase().as_str() { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "tif" | "tiff" => "image/tiff", + "mp4" => "video/mp4", + "mov" => "video/quicktime", + "zip" => "application/zip", + "ecproj" => "application/json", + _ => "application/octet-stream", + } +} + +/// Offer `data` as a download named `name`. +pub fn download(name: &str, data: &[u8]) -> Result<(), JsValue> { + let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?; + let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(data)); + let bag = web_sys::BlobPropertyBag::new(); + bag.set_type(mime(name)); + let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &bag)?; + let url = web_sys::Url::create_object_url_with_blob(&blob)?; + let a: web_sys::HtmlAnchorElement = doc.create_element("a")?.dyn_into()?; + a.set_href(&url); + a.set_download(name); + a.style().set_property("display", "none")?; + doc.body().ok_or("no body")?.append_child(&a)?; + a.click(); + a.remove(); + // Revoke once the browser has started the download. + let revoke = Closure::once_into_js(move || { + let _ = web_sys::Url::revoke_object_url(&url); + }); + if let Some(w) = web_sys::window() { + let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 60_000); + } + Ok(()) +} + +/// Engine file access (projects): reads from the table; writes go to the table and download. +pub struct WebServices; + +impl Services for WebServices { + fn read_file(&self, path: &str) -> std::io::Result> { + get(path) + .map(|d| d.to_vec()) + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, format!("{path}: open it with File ▸ Open (or drop it on the page)"))) + } + fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> { + put(path, data.into()); + download(file_name(path), data).map_err(|e| std::io::Error::other(format!("download failed: {e:?}"))) + } +} + +/// Media import from the table (the bytes are also handed to the media pool, which decodes them). +pub struct WebImporter { + pub pool: Arc, +} + +impl effectcraft_engine::Importer for WebImporter { + fn probe(&self, path: &str) -> Result { + let bytes = get(path).ok_or_else(|| format!("{path}: not loaded (import it with File ▸ Import or drop it on the page)"))?; + self.pool.add_bytes(path, bytes.clone()); + effectcraft_media::probe_bytes(path, bytes).map_err(|e| e.to_string()) + } +} + +/// Render Queue output: files land in the table and are downloaded after the render +/// ([`flush_downloads`]; several files, e.g. an image sequence, as one `.zip`). +pub fn export_sink() -> Arc { + Arc::new(|path: &str, data: Vec| { + put(path, data.into()); + OUTPUTS.lock().unwrap_or_else(|e| e.into_inner()).push(path.to_string()); + }) +} + +/// Download the render outputs written since the last call. +pub fn flush_downloads() { + let paths = std::mem::take(&mut *OUTPUTS.lock().unwrap_or_else(|e| e.into_inner())); + let mut out: Vec<(String, Arc<[u8]>)> = paths.into_iter().filter_map(|p| get(&p).map(|d| (file_name(&p).to_string(), d))).collect(); + out.sort_by(|a, b| a.0.cmp(&b.0)); + out.dedup_by(|a, b| a.0 == b.0); + let r = match out.len() { + 0 => return, + 1 => download(&out[0].0, &out[0].1), + n => { + let first = &out[0].0; + let stem = first.rsplit_once('.').map_or(first.as_str(), |(s, _)| s).trim_end_matches(|c: char| c.is_ascii_digit() || c == '_'); + let stem = if stem.is_empty() { "render" } else { stem }; + log::info!("render: {n} files → {stem}.zip"); + download(&format!("{stem}.zip"), &zip_store(&out)) + } + }; + if let Err(e) = r { + log::warn!("download failed: {e:?}"); + } +} + +/// A `.zip` with the files stored uncompressed (PKWARE APPNOTE 6.3: local headers, central +/// directory, end record; no data descriptors, no ZIP64). +pub fn zip_store(entries: &[(String, Arc<[u8]>)]) -> Vec { + fn crc32(d: &[u8]) -> u32 { + let mut c = !0u32; + for &b in d { + c ^= b as u32; + for _ in 0..8 { + c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }; + } + } + !c + } + let mut out = Vec::new(); + let mut central = Vec::new(); + for (name, data) in entries { + let (crc, n, off) = (crc32(data), data.len() as u32, out.len() as u32); + let common = |v: &mut Vec| { + v.extend_from_slice(&20u16.to_le_bytes()); // version needed + v.extend_from_slice(&0x0800u16.to_le_bytes()); // flags: UTF-8 names + v.extend_from_slice(&0u16.to_le_bytes()); // stored + v.extend_from_slice(&0u16.to_le_bytes()); // time + v.extend_from_slice(&0x21u16.to_le_bytes()); // date: 1980-01-01 + v.extend_from_slice(&crc.to_le_bytes()); + v.extend_from_slice(&n.to_le_bytes()); + v.extend_from_slice(&n.to_le_bytes()); + v.extend_from_slice(&(name.len() as u16).to_le_bytes()); + v.extend_from_slice(&0u16.to_le_bytes()); // extra + }; + out.extend_from_slice(&0x0403_4b50u32.to_le_bytes()); + common(&mut out); + out.extend_from_slice(name.as_bytes()); + out.extend_from_slice(data); + central.extend_from_slice(&0x0201_4b50u32.to_le_bytes()); + central.extend_from_slice(&20u16.to_le_bytes()); // version made by + common(&mut central); + central.extend_from_slice(&[0; 6]); // comment len, disk, internal attrs + central.extend_from_slice(&0u32.to_le_bytes()); // external attrs + central.extend_from_slice(&off.to_le_bytes()); + central.extend_from_slice(name.as_bytes()); + } + let (cd_off, cd_len, count) = (out.len() as u32, central.len() as u32, entries.len() as u16); + out.extend_from_slice(¢ral); + out.extend_from_slice(&0x0605_4b50u32.to_le_bytes()); + out.extend_from_slice(&[0; 4]); // disk numbers + out.extend_from_slice(&count.to_le_bytes()); + out.extend_from_slice(&count.to_le_bytes()); + out.extend_from_slice(&cd_len.to_le_bytes()); + out.extend_from_slice(&cd_off.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out +} + +/// Show the browser's file picker; `then` gets the picked files' table paths once they are read. +pub fn pick(accept: &str, multiple: bool, then: impl FnOnce(Vec) + 'static) -> Result<(), JsValue> { + let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?; + let input: web_sys::HtmlInputElement = doc.create_element("input")?.dyn_into()?; + input.set_type("file"); + input.set_accept(accept); + input.set_multiple(multiple); + let inp = input.clone(); + let on_change = Closure::once_into_js(move || { + let Some(list) = inp.files() else { return }; + read_files((0..list.length()).filter_map(|i| list.get(i)).collect(), then); + }); + input.add_event_listener_with_callback("change", on_change.unchecked_ref())?; + input.click(); + Ok(()) +} + +/// Read browser files into the table (asynchronously); `then` gets their table paths. +pub fn read_files(picked: Vec, then: impl FnOnce(Vec) + 'static) { + wasm_bindgen_futures::spawn_local(async move { + let mut paths = Vec::new(); + for f in picked { + match wasm_bindgen_futures::JsFuture::from(f.array_buffer()).await { + Ok(buf) => { + let path = path_for(&f.name()); + put(&path, js_sys::Uint8Array::new(&buf).to_vec().into()); + paths.push(path); + } + Err(e) => log::warn!("cannot read {}: {e:?}", f.name()), + } + } + then(paths); + }); +} diff --git a/apps/effectcraft-web/src/lib.rs b/apps/effectcraft-web/src/lib.rs new file mode 100644 index 00000000..7a765bfc --- /dev/null +++ b/apps/effectcraft-web/src/lib.rs @@ -0,0 +1,179 @@ +//! EffectCraft in the browser. +//! +//! The same engine and egui UI as the desktop app, compiled to `wasm32-unknown-unknown` and run +//! by eframe's web runner (WebGPU, WebGL2 fallback). What the desktop shell gets from the OS, this +//! crate gets from the browser: +//! +//! | Desktop | Web | +//! |---|---| +//! | file system (`FsServices`, media reads, export writes) | in-memory file table ([`files`]) | +//! | rfd file dialogs | `` pickers; drop files on the page | +//! | written files (Save, Render Queue) | browser downloads (several render files as one `.zip`) | +//! | frame render threads | `Frames::pump` on the UI thread between egui frames | +//! | Render Queue thread | the render runs inline (the page waits until it is done) | +//! | 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")] + +pub mod api; +pub mod files; + +use std::cell::RefCell; +use std::sync::Arc; + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use serde_json::json; +use wasm_bindgen::prelude::*; + +/// Work for the UI thread with access to the app (posted by async tasks: picked files…). +type Action = Box; + +thread_local! { + static ACTIONS: RefCell> = const { RefCell::new(Vec::new()) }; + static CTX: RefCell> = const { RefCell::new(None) }; +} + +/// Run `f` on the UI thread at the next frame. +pub fn post(f: impl FnOnce(&mut EffectcraftApp) + 'static) { + ACTIONS.with(|a| a.borrow_mut().push(Box::new(f))); + repaint(); +} + +/// Ask for a UI frame. +pub fn repaint() { + CTX.with(|c| { + if let Some(c) = c.borrow().as_ref() { + c.request_repaint(); + } + }); +} + +/// Open a project or import media from table paths (picked or dropped files). +fn open_or_import(app: &mut EffectcraftApp, paths: Vec) { + let (projects, media): (Vec, Vec) = paths.into_iter().partition(|p| p.to_ascii_lowercase().ends_with(".ecproj")); + if let Some(p) = projects.last() + && let Err(e) = app.session.execute("file.open", json!({"path": p})) + { + app.ui.status = e.to_string(); + } + if !media.is_empty() { + match app.session.execute("file.import", json!({"paths": media})) { + Ok(r) => { + if let Some(errs) = r["errors"].as_array().filter(|e| !e.is_empty()) { + app.ui.status = errs.iter().filter_map(|e| e.as_str()).collect::>().join("; "); + } + } + Err(e) => app.ui.status = e.to_string(), + } + } +} + +/// The eframe app: the shared EffectCraft UI plus the browser host's per-frame duties. +pub struct WebApp { + pub app: EffectcraftApp, +} + +impl eframe::App for WebApp { + fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { + self.app.raw_input_hook(ctx, raw_input); + } + + fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + let actions = ACTIONS.with(|a| std::mem::take(&mut *a.borrow_mut())); + for f in actions { + f(&mut self.app); + } + // Files dropped on the page: read them, then open/import. + let dropped: Vec = ctx.input(|i| i.raw.dropped_files.iter().filter_map(|f| f.web_file().cloned()).collect()); + if !dropped.is_empty() { + files::read_files(dropped, |paths| post(move |app| open_or_import(app, paths))); + } + self.app.logic(ctx, frame); + api::poll_replies(); + // Render Queue outputs written this frame → downloads. + files::flush_downloads(); + } + + fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + self.app.ui(ui, frame); + } +} + +fn query_flag(name: &str) -> bool { + web_sys::window().and_then(|w| w.location().search().ok()).and_then(|s| web_sys::UrlSearchParams::new_with_str(&s).ok()).is_some_and(|p| p.has(name)) +} + +/// A session wired for the browser: media decoding from memory, expressions, 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), + exporter: Some(Arc::new(effectcraft_host::FileExporter { sink: Some(files::export_sink()) })), + ..Default::default() + } +} + +/// Browser file pickers in place of the desktop's file dialogs. +fn install_hooks(app: &mut EffectcraftApp) { + app.hooks.pick_files = Some(Box::new(|exts: &[&str]| { + let accept = exts.iter().map(|e| format!(".{e}")).collect::>().join(","); + if let Err(e) = files::pick(&accept, true, |paths| post(move |app| open_or_import(app, paths))) { + log::warn!("file picker: {e:?}"); + } + // the import happens when the browser hands over the files + Vec::new() + })); + app.hooks.pick_open_project = Some(Box::new(|| { + if let Err(e) = files::pick(".ecproj", false, |paths| post(move |app| open_or_import(app, paths))) { + log::warn!("file picker: {e:?}"); + } + None + })); + // Saving downloads the file: the chosen name is all a "save dialog" needs. + app.hooks.pick_save = Some(Box::new(|name: &str| Some(files::path_for(if name.is_empty() { "Untitled Project.ecproj" } else { name })))); +} + +/// Entry point (called by the page's bootstrap script once the wasm module is instantiated). +#[wasm_bindgen] +pub async fn start(canvas_id: String) -> Result<(), JsValue> { + eframe::WebLogger::init(log::LevelFilter::Info).ok(); + let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?; + let canvas: web_sys::HtmlCanvasElement = doc.get_element_by_id(&canvas_id).ok_or("no canvas")?.dyn_into()?; + let demo = !query_flag("empty"); + let home = query_flag("home"); + let runner = eframe::WebRunner::new(); + runner + .start( + canvas, + eframe::WebOptions::default(), + Box::new(move |cc| { + CTX.with(|c| *c.borrow_mut() = Some(cc.egui_ctx.clone())); + let mut session = session(); + if demo { + let _ = session.execute("file.openDemoProject", json!({})); + } + let mut app = EffectcraftApp::new(session); + app.ui.start_screen = home; + install_hooks(&mut app); + let (tx, rx) = std::sync::mpsc::channel(); + api::set_sender(tx); + app = app.with_control(rx); + let backend = cc.wgpu_render_state.as_ref().map(|rs| format!("{:?}", rs.adapter.get_info().backend)); + api::set_info("backend", json!(backend)); + Ok(Box::new(WebApp { app })) + }), + ) + .await?; + // Hide the loading message once the app runs. + if let Some(el) = doc.get_element_by_id("loading") { + el.remove(); + } + Ok(()) +} diff --git a/apps/effectcraft-web/tests/smoke.mjs b/apps/effectcraft-web/tests/smoke.mjs new file mode 100644 index 00000000..de2f7f53 --- /dev/null +++ b/apps/effectcraft-web/tests/smoke.mjs @@ -0,0 +1,175 @@ +// Browser smoke test of the EffectCraft web app over the Chrome DevTools Protocol. +// No npm dependencies (Node ≥ 22: global WebSocket and fetch). +// +// cargo xtask web --serve 8765 & # build + serve /web/dist +// node apps/effectcraft-web/tests/smoke.mjs --url http://127.0.0.1:8765/ --out /tmp/ec-web +// +// Steps: load (timing), demo comp in the viewer (screenshot, viewer pixels), `render.frame` +// through `window.effectcraft`, Render Queue GIF and PNG-sequence (.zip) exports (downloads), File ▸ Save As (download), +// re-opening the saved project through `effectcraft.addFile`. Prints a JSON report; exits non-zero +// on failure. `--headed` shows the browser. +import { spawn } from "node:child_process"; +import { mkdirSync, writeFileSync, readdirSync, statSync, mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const arg = (k, d) => { + const i = process.argv.indexOf(`--${k}`); + return i > 0 ? process.argv[i + 1] : d; +}; +const url = arg("url", "http://127.0.0.1:8765/"); +const out = arg("out", join(tmpdir(), "effectcraft-web-smoke")); +const chrome = arg("chrome", process.platform === "darwin" ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" : "google-chrome"); +const port = Number(arg("port", "9334")); +const headless = !process.argv.includes("--headed"); +mkdirSync(out, { recursive: true }); +const downloads = join(out, "downloads"); +mkdirSync(downloads, { recursive: true }); + +const profile = mkdtempSync(join(tmpdir(), "ec-chrome-")); +const proc = spawn(chrome, [ + ...(headless ? ["--headless=new"] : []), + `--remote-debugging-port=${port}`, + `--user-data-dir=${profile}`, + "--no-first-run", + "--no-default-browser-check", + "--enable-unsafe-webgpu", + "--window-size=1600,1000", + "about:blank", +], { stdio: "ignore" }); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ws; +let nextId = 1; +const waiting = new Map(); +const logs = []; + +async function connect() { + for (let i = 0; i < 100; i++) { + try { + const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json(); + const page = list.find((t) => t.type === "page"); + if (page) { + ws = new WebSocket(page.webSocketDebuggerUrl); + await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); + ws.onmessage = (m) => { + const msg = JSON.parse(m.data); + if (msg.id && waiting.has(msg.id)) { + waiting.get(msg.id)(msg); + waiting.delete(msg.id); + } else if (msg.method === "Runtime.consoleAPICalled") { + logs.push(msg.params.args.map((a) => a.value ?? a.description ?? "").join(" ")); + } else if (msg.method === "Runtime.exceptionThrown") { + logs.push("EXCEPTION " + JSON.stringify(msg.params.exceptionDetails.exception?.description ?? msg.params.exceptionDetails.text)); + } + }; + return; + } + } catch {} + await sleep(100); + } + throw new Error("Chrome did not start"); +} + +function send(method, params = {}) { + const id = nextId++; + ws.send(JSON.stringify({ id, method, params })); + return new Promise((res, rej) => waiting.set(id, (m) => (m.error ? rej(new Error(`${method}: ${m.error.message}`)) : res(m.result)))); +} + +async function js(expr, timeout = 300000) { + // (promise rejections carry plain strings: wrap them so the message survives) + const wrapped = `(async () => { try { return await (${expr}); } catch (e) { throw new Error(String(e && e.message || e)); } })()`; + const r = await send("Runtime.evaluate", { expression: wrapped, awaitPromise: true, returnByValue: true, timeout }); + if (r.exceptionDetails) throw new Error(`${expr}: ${r.exceptionDetails.exception?.description ?? r.exceptionDetails.text}`); + return r.result.value; +} + +async function shot(name) { + const r = await send("Page.captureScreenshot", { format: "png" }); + const p = join(out, `${name}.png`); + writeFileSync(p, Buffer.from(r.data, "base64")); + return p; +} + +async function until(expr, ms = 60000, step = 200) { + const t0 = Date.now(); + while (Date.now() - t0 < ms) { + if (await js(expr)) return Date.now() - t0; + await sleep(step); + } + throw new Error(`timeout waiting for ${expr}`); +} + +async function waitDownload(pred, ms = 120000) { + const t0 = Date.now(); + while (Date.now() - t0 < ms) { + const f = readdirSync(downloads).find((n) => pred(n) && !n.endsWith(".crdownload")); + if (f) return { name: f, bytes: statSync(join(downloads, f)).size }; + await sleep(200); + } + throw new Error("no download"); +} + +const report = { url, steps: {} }; +let failed = false; +try { + await connect(); + await send("Page.enable"); + await send("Runtime.enable"); + await send("Browser.setDownloadBehavior", { behavior: "allow", downloadPath: downloads }).catch(() => {}); + 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.wallMs = Date.now() - t0; + if (report.load.error) throw new Error(report.load.error); + report.info = await js("effectcraft.info()"); + + // 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); + await sleep(1500); + 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") }; + + // 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; + + // Render Queue: a short, small GIF → download. + const rqT = Date.now(); + // (`ui.menu.invoke`: `engine.execute`'s strict parameter check does not know the spread + // Render Settings / Output Module keys) + await js(`effectcraft.request("ui.menu.invoke", {id: "renderQueue.add", params: {format: "gif", output: "smoke_[compName].gif", resolution: 0.25, timeSpan: "custom", start: 0, end: 1}})`); + await js(`effectcraft.execute("renderQueue.render", {})`); + const gif = await waitDownload((n) => n.endsWith(".gif")); + report.steps.gifExport = { ...gif, ms: Date.now() - rqT }; + + // 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")); + + // Save As → .ecproj download; reopen it. + await js(`effectcraft.execute("file.saveAs", {path: "/smoke.ecproj"})`); + const proj = await waitDownload((n) => n.endsWith(".ecproj")); + report.steps.save = proj; + await js(`effectcraft.execute("file.newProject", {})`); + const opened = await js(`(async () => { const d = effectcraft.readFile("/smoke.ecproj"); const p = await effectcraft.addFile(new File([d], "smoke-copy.ecproj")); await new Promise(r => setTimeout(r, 800)); return {path: p, files: effectcraft.files()}; })()`); + const comps = await js(`effectcraft.inspect().then(i => i.activeComp)`); + 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") }; +} catch (e) { + failed = true; + report.error = String(e && e.stack || e); + try { report.errorShot = await shot("error"); } catch {} +} +report.console = logs.slice(-40); +writeFileSync(join(out, "report.json"), JSON.stringify(report, null, 2)); +console.log(JSON.stringify(report, null, 2)); +try { ws.close(); } catch {} +proc.kill(); +process.exit(failed ? 1 : 0); diff --git a/apps/effectcraft-web/web/favicon.svg b/apps/effectcraft-web/web/favicon.svg new file mode 100644 index 00000000..0b4f3c8f --- /dev/null +++ b/apps/effectcraft-web/web/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + diff --git a/apps/effectcraft-web/web/favicon.svg.attribution b/apps/effectcraft-web/web/favicon.svg.attribution new file mode 100644 index 00000000..e5f8b025 --- /dev/null +++ b/apps/effectcraft-web/web/favicon.svg.attribution @@ -0,0 +1,9 @@ +asset: favicon.svg +title: EffectCraft web app icon (favicon) +author: EffectCraft contributors +source: original work +license: MIT OR Apache-2.0 +added: 2026-10-01 by EffectCraft contributors (Claude agent, M15 web app) +notes: Hand-written SVG of the code-drawn EffectCraft mark (`paint_logo` in + crates/ui-egui/src/header.rs): a navy rounded square split into blue and violet halves + with a white keyframe diamond, from basic shapes. Not derived from any other product's icon. diff --git a/apps/effectcraft-web/web/index.html b/apps/effectcraft-web/web/index.html new file mode 100644 index 00000000..3e6cbf06 --- /dev/null +++ b/apps/effectcraft-web/web/index.html @@ -0,0 +1,64 @@ + + + + + + + EffectCraft + + + + + +
Loading EffectCraft…
+ + + diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index ecc39243..6960e561 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -24,3 +24,4 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +web-time = { workspace = true } diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs index 49241e13..61bc0fab 100644 --- a/crates/engine/src/render_queue.rs +++ b/crates/engine/src/render_queue.rs @@ -125,11 +125,11 @@ struct Work { } fn unix_now() -> u64 { - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) + 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) { - let t0 = std::time::Instant::now(); + let t0 = web_time::Instant::now(); { let mut s = lock(&shared.state); s.items_total = w.items.len(); @@ -139,7 +139,7 @@ fn run_work(w: Work, shared: &JobShared) { lock(&shared.updates).push(ItemUpdate::Finished { id: item.id, status: RenderStatus::UserStopped, seconds: 0.0, output: None }); continue; } - let t1 = std::time::Instant::now(); + let t1 = web_time::Instant::now(); lock(&shared.updates).push(ItemUpdate::Started { id: item.id, unix: unix_now() }); { let mut s = lock(&shared.state); @@ -211,7 +211,8 @@ impl Session { return Some(p); } let base = self.path.as_deref().and_then(|s| std::path::Path::new(s).parent().map(|d| d.to_path_buf())).filter(|d| !d.as_os_str().is_empty()); - let base = base.or_else(|| std::env::current_dir().ok())?; + // (the web has no working directory: relative outputs land in its virtual root) + let base = base.or_else(|| std::env::current_dir().ok()).or_else(|| cfg!(target_arch = "wasm32").then(|| "/".into()))?; Some(base.join(path).to_string_lossy().to_string()) } @@ -227,7 +228,9 @@ 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. pub fn start_render(&mut self, wait: bool) -> Result, String> { + let wait = wait || cfg!(target_arch = "wasm32"); if self.is_rendering() { return Err("a render is already in progress".into()); } diff --git a/crates/export/Cargo.toml b/crates/export/Cargo.toml index 5534fd02..34c83aab 100644 --- a/crates/export/Cargo.toml +++ b/crates/export/Cargo.toml @@ -21,6 +21,7 @@ filmcraft-isobmff = { workspace = true } image = { workspace = true, features = ["png", "jpeg", "tiff", "exr"] } gif = { workspace = true } rayon = { workspace = true } +web-time = { workspace = true } thiserror = { workspace = true } log = { workspace = true } diff --git a/crates/export/src/encode.rs b/crates/export/src/encode.rs index e00bb93c..d6cf3150 100644 --- a/crates/export/src/encode.rs +++ b/crates/export/src/encode.rs @@ -1,7 +1,7 @@ //! Movie export: FilmCraft H.264 → MP4 (+ AAC) and ProRes → MOV (+ PCM), muxed by //! FilmCraft's ISO BMFF / QuickTime writer. -use std::io::{BufWriter, Write}; +use std::io::Write; use effectcraft_project::Comp; use effectcraft_project::render_queue::{Channels, OutputFormat, ProResProfile}; @@ -218,7 +218,7 @@ fn deinterleave(buf: &[f32]) -> [Vec; 2] { // ---------------------------------------------------------------- the movie -type Writer = Mp4Writer>; +type Writer<'f, 's> = Mp4Writer<&'f mut crate::out::Out<'s>>; pub(crate) fn movie(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result { let rate = job.settings.rate(comp); @@ -242,10 +242,10 @@ pub(crate) fn movie(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> R drop(first); st.advance(first_end)?; - let file = std::fs::File::create(job.path).map_err(mux_err)?; + let mut file = crate::out::create(job.sink, job.path)?; let opts = WriterOptions::new(brand); let movie_ts = opts.movie_timescale.max(1) as i128; - let mut mux: Writer = Mp4Writer::new(BufWriter::new(file), opts).map_err(mux_err)?; + let mut mux: Writer = Mp4Writer::new(&mut file, opts).map_err(mux_err)?; let mut vcfg = TrackConfig::new(venc.sample_entry(), rate.num as u32); if let Some(start) = venc.media_start() { // An explicit edit: the convenience `media_start` edit would subtract the B-frame delay @@ -333,10 +333,9 @@ pub(crate) fn movie(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> R mux.write_sample(*at, WriteSample { data: &au, duration: 1024, composition_offset: 0, is_sync: true }).map_err(mux_err)?; } } - let mut out = mux.finish().map_err(mux_err)?; + let out = mux.finish().map_err(mux_err)?; out.flush().map_err(mux_err)?; - drop(out); - let bytes = std::fs::metadata(job.path).map(|m| m.len()).unwrap_or(0); + let bytes = file.finish()?; Ok(Report { path: job.path.to_string(), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: with_audio }) } diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index d87e0858..1616926d 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -17,9 +17,9 @@ //! output is a single pixel wide/high). mod encode; +mod out; -use std::path::Path; -use std::time::Instant; +use web_time::Instant; use effectcraft_project::render_queue::{AudioOutput, Channels, OutputFormat, OutputModule, RenderQuality, RenderSettings, sequence_path}; use effectcraft_project::{Comp, ItemId, Project}; @@ -29,6 +29,7 @@ use effectcraft_time::Tick; use rayon::prelude::*; pub use effectcraft_project::render_queue; +pub use out::Sink; #[derive(Debug, thiserror::Error)] pub enum ExportError { @@ -61,6 +62,8 @@ pub struct Job<'a> { /// Output file. For image sequences a run of `#` (or `[#####]`) becomes the frame number; a /// path without one gets `_NNNNN` before the extension. pub path: &'a str, + /// Hand finished files to this instead of writing them to disk (web downloads, tests). + pub sink: Option<&'a Sink>, } /// Progress after a batch of frames. @@ -122,9 +125,6 @@ pub fn export(job: &Job, progress: &mut dyn FnMut(&Progress) -> bool) -> Result< let total = job.settings.frame_count(comp); let mut st = State { t0, total, done: 0, progress }; st.advance(0)?; - if let Some(dir) = Path::new(job.path).parent().filter(|d| !d.as_os_str().is_empty()) { - std::fs::create_dir_all(dir).map_err(io)?; - } let (w, h) = output_size(comp, job.settings, job.output); let mut report = match job.output.format { OutputFormat::H264 | OutputFormat::ProRes => encode::movie(job, comp, w, h, &mut st)?, @@ -218,12 +218,13 @@ fn sequence(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result Result Result<()> { +fn write_still(file: &mut out::Out, fmt: OutputFormat, img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32, quality: u8) -> Result<()> { use image::{ExtendedColorType, ImageEncoder, ImageFormat}; - let file = || std::fs::File::create(path).map(std::io::BufWriter::new).map_err(io); match fmt { OutputFormat::ExrSequence => { // Linear light, premultiplied (the EXR convention); RGB keeps alpha = 1 over the background. @@ -263,12 +263,12 @@ fn write_still(path: &str, fmt: OutputFormat, img: &Image, comp: &Comp, channels } } let buf = image::Rgba32FImage::from_raw(w, h, data).ok_or_else(|| ExportError::Encode("exr buffer".into()))?; - image::DynamicImage::ImageRgba32F(buf).save_with_format(path, ImageFormat::OpenExr).map_err(|e| ExportError::Encode(e.to_string())) + image::DynamicImage::ImageRgba32F(buf).write_to(file, ImageFormat::OpenExr).map_err(|e| ExportError::Encode(e.to_string())) } OutputFormat::JpegSequence => { let px = rgba8(img, comp, Channels::Rgb, w, h); let rgb: Vec = px.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect(); - image::codecs::jpeg::JpegEncoder::new_with_quality(file()?, quality.clamp(1, 100)) + image::codecs::jpeg::JpegEncoder::new_with_quality(file, quality.clamp(1, 100)) .write_image(&rgb, w, h, ExtendedColorType::Rgb8) .map_err(|e| ExportError::Encode(e.to_string())) } @@ -279,11 +279,10 @@ fn write_still(path: &str, fmt: OutputFormat, img: &Image, comp: &Comp, channels Channels::Rgb => (px.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect(), ExtendedColorType::Rgb8), }; if fmt == OutputFormat::PngSequence { - image::codecs::png::PngEncoder::new(file()?).write_image(&data, w, h, ct).map_err(|e| ExportError::Encode(e.to_string())) + image::codecs::png::PngEncoder::new(file).write_image(&data, w, h, ct).map_err(|e| ExportError::Encode(e.to_string())) } else { - // The TIFF encoder needs Seek. - let f = std::fs::File::create(path).map_err(io)?; - image::codecs::tiff::TiffEncoder::new(std::io::BufWriter::new(f)).write_image(&data, w, h, ct).map_err(|e| ExportError::Encode(e.to_string())) + // (the TIFF encoder needs Seek) + image::codecs::tiff::TiffEncoder::new(file).write_image(&data, w, h, ct).map_err(|e| ExportError::Encode(e.to_string())) } } f => Err(ExportError::Unsupported(format!("{} is not a still format", f.label()))), @@ -294,8 +293,8 @@ fn gif_export(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result< if w > u16::MAX as u32 || h > u16::MAX as u32 { return Err(ExportError::Unsupported("GIF frames are limited to 65535 px".into())); } - let file = std::fs::File::create(job.path).map_err(io)?; - let mut enc = gif::Encoder::new(std::io::BufWriter::new(file), w as u16, h as u16, &[]).map_err(|e| ExportError::Encode(e.to_string()))?; + let mut file = out::create(job.sink, job.path)?; + let mut enc = gif::Encoder::new(&mut file, w as u16, h as u16, &[]).map_err(|e| ExportError::Encode(e.to_string()))?; enc.set_repeat(if job.output.gif_loop { gif::Repeat::Infinite } else { gif::Repeat::Finite(0) }).map_err(|e| ExportError::Encode(e.to_string()))?; let rate = job.settings.rate(comp); // GIF delays are in 1/100 s: carry the rounding error so long animations keep their timing. @@ -328,7 +327,7 @@ fn gif_export(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result< i = end; } drop(enc); - let bytes = std::fs::metadata(job.path).map(|m| m.len()).unwrap_or(0); + let bytes = file.finish()?; Ok(Report { path: job.path.to_string(), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: false }) } diff --git a/crates/export/src/out.rs b/crates/export/src/out.rs new file mode 100644 index 00000000..c5104946 --- /dev/null +++ b/crates/export/src/out.rs @@ -0,0 +1,77 @@ +//! Output files: the file system, or an in-memory [`Sink`] chosen per job (the web app, which +//! offers every written file as a download; tests). + +use std::io::{BufWriter, Cursor, Seek, SeekFrom, Write}; + +use crate::{ExportError, Result, io}; + +/// Receives each finished output file (path as resolved, complete contents) instead of the file +/// system. Called once per file; image sequences call it from the render threads. +pub type Sink = dyn Fn(&str, Vec) + Send + Sync; + +/// One output file being written. +pub(crate) enum Out<'a> { + File(BufWriter), + Mem { path: String, cur: Cursor>, sink: &'a Sink }, +} + +/// Create `path` (parent directories included) or an in-memory file for `sink`. +pub(crate) fn create<'a>(sink: Option<&'a Sink>, path: &str) -> Result> { + match sink { + Some(sink) => Ok(Out::Mem { path: path.to_string(), cur: Cursor::new(Vec::new()), sink }), + None => { + if let Some(dir) = std::path::Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) { + std::fs::create_dir_all(dir).map_err(io)?; + } + Ok(Out::File(BufWriter::new(std::fs::File::create(path).map_err(io)?))) + } + } +} + +/// Whether `path` already exists (never for a sink: it has no memory of earlier jobs). +pub(crate) fn exists(sink: Option<&Sink>, path: &str) -> bool { + sink.is_none() && std::path::Path::new(path).exists() +} + +impl Out<'_> { + /// Flush and close; a sink receives the contents now. Returns the file size. + pub(crate) fn finish(self) -> Result { + match self { + Out::File(mut w) => { + w.flush().map_err(io)?; + let f = w.into_inner().map_err(|e| ExportError::Io(e.to_string()))?; + Ok(f.metadata().map(|m| m.len()).unwrap_or(0)) + } + Out::Mem { path, cur, sink } => { + let data = cur.into_inner(); + let n = data.len() as u64; + sink(&path, data); + Ok(n) + } + } + } +} + +impl Write for Out<'_> { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + match self { + Out::File(w) => w.write(buf), + Out::Mem { cur, .. } => cur.write(buf), + } + } + fn flush(&mut self) -> std::io::Result<()> { + match self { + Out::File(w) => w.flush(), + Out::Mem { .. } => Ok(()), + } + } +} + +impl Seek for Out<'_> { + fn seek(&mut self, pos: SeekFrom) -> std::io::Result { + match self { + Out::File(w) => w.seek(pos), + Out::Mem { cur, .. } => cur.seek(pos), + } + } +} diff --git a/crates/export/tests/roundtrip.rs b/crates/export/tests/roundtrip.rs index 4ee077d9..761b2c67 100644 --- a/crates/export/tests/roundtrip.rs +++ b/crates/export/tests/roundtrip.rs @@ -55,7 +55,7 @@ fn settings() -> RenderSettings { fn run(p: &Project, cid: ItemId, footage: &dyn FootageSource, om: &OutputModule, path: &Path) -> effectcraft_export::Report { let s = settings(); let path = path.to_string_lossy().to_string(); - let job = Job { project: p, footage, expr: None, comp: cid, settings: &s, output: om, path: &path }; + let job = Job { project: p, footage, expr: None, comp: cid, settings: &s, output: om, path: &path, sink: None }; let mut last = Progress::default(); let mut calls = 0; let r = export(&job, &mut |pr| { @@ -167,7 +167,7 @@ fn h264_odd_size_is_cropped_even() { let comp = p.comp(cid).expect("comp"); assert_eq!(s.output_size(comp).0 % 2, 1); let path = d.join("odd.mp4").to_string_lossy().to_string(); - let job = Job { project: &p, footage: &NoFootage, expr: None, comp: cid, settings: &s, output: &om, path: &path }; + let job = Job { project: &p, footage: &NoFootage, expr: None, comp: cid, settings: &s, output: &om, path: &path, sink: None }; let r = export(&job, &mut |_| true).expect("export"); assert_eq!((r.width % 2, r.height % 2), (0, 0)); } @@ -287,7 +287,7 @@ fn cancel_stops_early() { let om = OutputModule::for_format(OutputFormat::PngSequence); let s = settings(); let path = d.join("c_[#####].png").to_string_lossy().to_string(); - let job = Job { project: &p, footage: &NoFootage, expr: None, comp: cid, settings: &s, output: &om, path: &path }; + let job = Job { project: &p, footage: &NoFootage, expr: None, comp: cid, settings: &s, output: &om, path: &path, sink: None }; let r = export(&job, &mut |_| false); assert!(matches!(r, Err(effectcraft_export::ExportError::Cancelled))); let written = std::fs::read_dir(&d).map(|r| r.count()).unwrap_or(0); diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index c8ed03bc..df563fc7 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -16,8 +16,12 @@ impl Importer for MediaImporter { } } -/// Render Queue export via `effectcraft-export`. -pub struct FileExporter; +/// Render Queue export via `effectcraft-export`: to the file system, or to `sink` (the web app +/// turns written files into downloads). +#[derive(Default)] +pub struct FileExporter { + pub sink: Option>, +} impl Exporter for FileExporter { fn formats(&self) -> Vec { @@ -32,6 +36,7 @@ impl Exporter for FileExporter { settings: &job.item.settings, output: &job.item.output, path: job.path, + sink: self.sink.as_deref(), }; match effectcraft_export::export(&j, &mut |p| progress(p.done, p.total)) { Ok(r) => Ok(ExportResult { path: r.path, frames: r.frames, width: r.width, height: r.height, bytes: r.bytes, seconds: r.seconds, audio: r.audio }), @@ -44,7 +49,7 @@ impl Exporter for FileExporter { /// A new session with media, import, expressions and export enabled. pub fn session() -> Session { Session { - exporter: Some(Arc::new(FileExporter)), + exporter: Some(Arc::new(FileExporter::default())), footage: Arc::new(effectcraft_media::MediaPool::new()), importer: Some(Arc::new(MediaImporter)), expr: Some(Arc::new(effectcraft_expr::Expressions)), @@ -68,6 +73,37 @@ mod tests { assert!(img.data.iter().any(|p| p[3] > 0.5)); } + /// The web app's path: outputs go to a sink (downloads), never to the file system. + #[test] + fn render_queue_exports_to_a_sink() { + use std::sync::{Arc, Mutex}; + let got: Arc)>>> = Arc::default(); + let g = got.clone(); + let mut s = super::session(); + s.exporter = Some(Arc::new(super::FileExporter { sink: Some(Arc::new(move |p: &str, d: Vec| g.lock().unwrap().push((p.to_string(), d)))) })); + s.execute("file.openDemoProject", json!({})).unwrap(); + let dir = "/no-such-dir-effectcraft-sink-test"; + s.execute( + "renderQueue.add", + json!({"format": "gif", "output": format!("{dir}/a.gif"), "resolution": 0.0625, "timeSpan": "custom", "start": 0.0, "end": 0.2}), + ) + .unwrap(); + s.execute( + "renderQueue.add", + json!({"format": "png", "output": format!("{dir}/seq_[##].png"), "resolution": 0.0625, "timeSpan": "custom", "start": 0.0, "end": 0.1}), + ) + .unwrap(); + s.execute("renderQueue.render", json!({"wait": true})).unwrap(); + s.poll_render(); + let mut got = got.lock().unwrap().clone(); + got.sort_by(|a, b| a.0.cmp(&b.0)); + let names: Vec<&str> = got.iter().map(|(p, _)| p.as_str()).collect(); + assert_eq!(names, [format!("{dir}/a.gif"), format!("{dir}/seq_00.png"), format!("{dir}/seq_01.png"), format!("{dir}/seq_02.png")]); + assert!(got[0].1.starts_with(b"GIF89a")); + assert!(got[1].1.starts_with(b"\x89PNG")); + assert!(!std::path::Path::new(dir).exists()); + } + #[test] fn render_queue_exports_demo_comp() { let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/host-rq"); diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 53019406..4c8a9787 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -21,5 +21,6 @@ effectcraft-text = { workspace = true } effectcraft-effects = { workspace = true } kurbo = { workspace = true } rayon = { workspace = true } +web-time = { workspace = true } serde = { workspace = true } log = { workspace = true } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 02f61df6..a750993a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -208,7 +208,7 @@ impl<'a> Renderer<'a> { let Some(spec) = effectcraft_effects::find(effect) else { continue }; let params = self.effect_params(ctx, layer, g); let ectx = EffectCtx { params: ¶ms, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment, env }; - let t0 = std::time::Instant::now(); + let t0 = web_time::Instant::now(); buf = effectcraft_effects::apply(spec, &ectx, buf); if let Some(v) = timing.as_deref_mut() { v.push((spec.id.to_string(), t0.elapsed().as_secs_f64() * 1e3)); @@ -348,9 +348,9 @@ impl<'a> Renderer<'a> { return; }; let mut timing = LayerTiming { depth: self.depth, layer: layer.name.clone(), ..Default::default() }; - let t0 = std::time::Instant::now(); + let t0 = web_time::Instant::now(); let Some(buf) = self.layer_buf_timed(ctx, layer, Some(&mut timing)) else { return }; - let t1 = std::time::Instant::now(); + let t1 = web_time::Instant::now(); self.composite_layer(ctx, layer, &buf, canvas, opacity); timing.process_ms = (t1 - t0).as_secs_f64() * 1e3; timing.composite_ms = t1.elapsed().as_secs_f64() * 1e3; diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index d27ef79c..d7c5ea7e 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -25,6 +25,7 @@ serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } rayon = { workspace = true } +web-time = { workspace = true } kurbo = { workspace = true } [dev-dependencies] diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 5aa3c9a8..40332468 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -355,7 +355,9 @@ fn render_frame(app: &EffectcraftApp, p: &Value) -> Outcome { Err(e) => return err(e), }; let mut out = json!({"comp": cid.0, "time": t.seconds(), "width": w, "height": h}); - if p.get("base64").and_then(Value::as_bool) == Some(true) { + // (the web has no file system: inline unless a path is given) + let inline = p.get("base64").and_then(Value::as_bool).unwrap_or(cfg!(target_arch = "wasm32") && p.get("path").is_none()); + if inline { out["png"] = json!(base64(&png)); return ok(out); } @@ -427,6 +429,13 @@ pub fn save_screenshot(ctx: &egui::Context, image: &egui::ColorImage, path: Opti rgba.extend_from_slice(&[c.r(), c.g(), c.b(), 255]); } } + if path.is_none() && cfg!(target_arch = "wasm32") { + // no file system on the web: inline PNG + return match encode_png(&rgba, cw as u32, ch as u32) { + Ok(png) => json!({"ok": true, "result": {"png": base64(&png), "width": cw, "height": ch}}), + Err(e) => json!({"ok": false, "error": e}), + }; + } let path = path.map(str::to_string).unwrap_or_else(|| std::env::temp_dir().join("effectcraft-screenshot.png").to_string_lossy().to_string()); match encode_png(&rgba, cw as u32, ch as u32) { Ok(png) => match std::fs::write(&path, png) { diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 129d510d..190beb4e 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -3,6 +3,9 @@ //! Frames render on a dedicated thread pool from immutable `Arc` snapshots, so the UI //! never waits for the compositor. Results land in a memory-budgeted cache keyed by (project //! revision, comp, frame, scale); the timeline draws the cached range as the green cache bar. +//! +//! wasm32 has no threads: jobs wait in the same priority queue and [`Frames::pump`] renders them +//! on the UI thread between egui frames, within a time budget. use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::{Arc, Mutex}; @@ -61,6 +64,7 @@ pub struct Frames { inflight: Arc>>, /// Pending jobs, picked by priority when a pool thread frees up (the viewer's frame first). queue: Arc>, + #[cfg(not(target_arch = "wasm32"))] pool: rayon::ThreadPool, ctx: Option, /// Render time of the last viewer (urgent) frame in ms, for the Info panel / perf readout. @@ -69,11 +73,13 @@ pub struct Frames { impl Default for Frames { fn default() -> Self { + #[cfg(not(target_arch = "wasm32"))] let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 16); Frames { cache: Arc::new(Mutex::new(Cache { map: HashMap::new(), order: VecDeque::new(), bytes: 0, budget: 3 << 30 })), inflight: Arc::new(Mutex::new(HashSet::new())), queue: Arc::new(Mutex::new(Queue::default())), + #[cfg(not(target_arch = "wasm32"))] pool: rayon::ThreadPoolBuilder::new().num_threads(threads).thread_name(|i| format!("ec-frame-{i}")).build().expect("frame pool"), ctx: None, last_ms: Arc::new(Mutex::new(0.0)), @@ -176,52 +182,95 @@ impl Frames { let seq = q.seq; q.jobs.push(Job { key, src: src.clone(), comp, t, opts, urgent, seq }); drop(q); - let queue = self.queue.clone(); - let cache = self.cache.clone(); - let inflight = self.inflight.clone(); - let ctx = self.ctx.clone(); - let last = self.last_ms.clone(); - // One spawn per job; each spawn renders whichever queued job matters most right now. - self.pool.spawn(move || { - let job = { - let Ok(mut q) = queue.lock() else { return }; - // Urgent first (newest), then prefetch in request order. - let best = q.jobs.iter().enumerate().max_by_key(|(_, j)| (j.urgent, if j.urgent { j.seq as i64 } else { -(j.seq as i64) })).map(|(i, _)| i); - let Some(i) = best else { return }; - let job = q.jobs.swap_remove(i); - q.running.insert(job.key); - if job.urgent { - q.urgent_running += 1; - } - job - }; - let t0 = std::time::Instant::now(); - let mut r = Renderer::new(&job.src.project, job.src.footage.as_ref(), job.opts); - r.expr = job.src.expr.as_deref(); - r.cache = Some(&job.src.layer_cache); - let img = r.comp_frame(job.comp, job.t); - let ci = Arc::new(to_color_image(&img)); - if job.urgent - && let Ok(mut l) = last.lock() - { - *l = t0.elapsed().as_secs_f64() * 1000.0; + #[cfg(not(target_arch = "wasm32"))] + { + let w = self.worker(); + // One spawn per job; each spawn renders whichever queued job matters most right now. + self.pool.spawn(move || { + w.run_next(); + }); + } + #[cfg(target_arch = "wasm32")] + if let Some(ctx) = &self.ctx { + // rendered by `pump` after this UI frame + ctx.request_repaint(); + } + } + + fn worker(&self) -> Worker { + Worker { queue: self.queue.clone(), cache: self.cache.clone(), inflight: self.inflight.clone(), ctx: self.ctx.clone(), last: self.last_ms.clone() } + } + + /// Render queued frames on this thread, most important first, until the queue is empty or + /// `budget` has passed (at least one frame). Returns whether frames are still queued. Only + /// wasm32 needs this (native frames render on the pool); elsewhere it does nothing. + pub fn pump(&self, budget: std::time::Duration) -> bool { + if cfg!(not(target_arch = "wasm32")) { + return false; + } + let t0 = web_time::Instant::now(); + let w = self.worker(); + while w.run_next() { + if t0.elapsed() >= budget { + break; } - if let Ok(mut c) = cache.lock() { - c.insert(job.key, ci); + } + self.queue.lock().map(|q| !q.jobs.is_empty()).unwrap_or(false) + } +} + +/// Renders queued jobs (on a pool thread, or the UI thread on wasm32). +struct Worker { + queue: Arc>, + cache: Arc>, + inflight: Arc>>, + ctx: Option, + last: Arc>, +} + +impl Worker { + /// Render the queued job that matters most right now. `false` when the queue was empty. + fn run_next(&self) -> bool { + let Worker { queue, cache, inflight, ctx, last } = self; + let job = { + let Ok(mut q) = queue.lock() else { return false }; + // Urgent first (newest), then prefetch in request order. + let best = q.jobs.iter().enumerate().max_by_key(|(_, j)| (j.urgent, if j.urgent { j.seq as i64 } else { -(j.seq as i64) })).map(|(i, _)| i); + let Some(i) = best else { return false }; + let job = q.jobs.swap_remove(i); + q.running.insert(job.key); + if job.urgent { + q.urgent_running += 1; } - if let Ok(mut inf) = inflight.lock() { - inf.remove(&job.key); + job + }; + let t0 = web_time::Instant::now(); + let mut r = Renderer::new(&job.src.project, job.src.footage.as_ref(), job.opts); + r.expr = job.src.expr.as_deref(); + r.cache = Some(&job.src.layer_cache); + let img = r.comp_frame(job.comp, job.t); + let ci = Arc::new(to_color_image(&img)); + if job.urgent + && let Ok(mut l) = last.lock() + { + *l = t0.elapsed().as_secs_f64() * 1000.0; + } + if let Ok(mut c) = cache.lock() { + c.insert(job.key, ci); + } + if let Ok(mut inf) = inflight.lock() { + inf.remove(&job.key); + } + if let Ok(mut q) = queue.lock() { + q.running.remove(&job.key); + if job.urgent { + q.urgent_running -= 1; } - if let Ok(mut q) = queue.lock() { - q.running.remove(&job.key); - if job.urgent { - q.urgent_running -= 1; - } - } - if let Some(ctx) = ctx { - ctx.request_repaint(); - } - }); + } + if let Some(ctx) = ctx { + ctx.request_repaint(); + } + true } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 60613605..8cf3ca76 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -327,6 +327,10 @@ impl EffectcraftApp { } fn frames_parallelism(&self) -> usize { + if cfg!(target_arch = "wasm32") { + // frames render one at a time on the UI thread (`Frames::pump`) + return 1; + } std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4) } @@ -625,6 +629,10 @@ impl eframe::App for EffectcraftApp { } self.frame(ui); let ctx = ui.ctx().clone(); + // wasm32: no frame threads; render queued frames now, between UI frames. + if cfg!(target_arch = "wasm32") && self.frames.pump(std::time::Duration::from_millis(if self.playback.playing { 24 } else { 40 })) { + ctx.request_repaint(); + } self.last_ui_time = ctx.input(|i| i.time); if !self.synthetic.is_empty() { ctx.request_repaint(); diff --git a/docs/architecture.md b/docs/architecture.md index 4c8646b3..e0e38757 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,7 +8,8 @@ and drive it through the same command registry. Dependencies only point downward (or along the few same-layer edges listed below). `cargo xtask layers` enforces this, and fails the build if a crate below L5 depends on egui, eframe, winit, rfd, -cpal or muda. Everything in L0 to L4 also builds for `wasm32-unknown-unknown` (`cargo xtask wasm`). +cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for +`wasm32-unknown-unknown` (`cargo xtask wasm`); see [web.md](web.md). | Layer | Crate (`effectcraft-…`) | Responsibility | |---|---|---| @@ -29,7 +30,7 @@ cpal or muda. Everything in L0 to L4 also builds for `wasm32-unknown-unknown` (` | L4 | `host` | A fully wired `Session` (media, expressions, exporter) for the frontends | | L5 | `ui-egui` | The desktop interface: docking, panels, viewer, timeline, graph editor, dialogs, control channel | | L5 | `automation` | The MCP server, headless or bridged to the running app | -| L6 | apps `effectcraft`, `effectcraft-cli` | Desktop app; command-line tool (render, exec, get/set, MCP) | +| L6 | apps `effectcraft`, `effectcraft-cli`, `effectcraft-web` | Desktop app; command-line tool (render, exec, get/set, MCP); the browser app (wasm32, [web.md](web.md)) | Allowed same-layer edges: `path → keyframe, raster`, `text → path`, `effects → project, text, path`, `media / expr / export → render`, `export → media`, `host → engine`. diff --git a/docs/web.md b/docs/web.md new file mode 100644 index 00000000..7c4ec31d --- /dev/null +++ b/docs/web.md @@ -0,0 +1,103 @@ +# EffectCraft on the web + +`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. + +## Build and run + +| Tool | Version | Install | +|---|---|---| +| Rust target | `wasm32-unknown-unknown` | `rustup target add wasm32-unknown-unknown` | +| wasm-bindgen CLI | exactly the `wasm-bindgen` crate version (0.2.129) | `cargo install wasm-bindgen-cli --version 0.2.129 --locked` | +| wasm-opt (optional) | any recent binaryen | `brew install binaryen`; used by `cargo xtask web` when found | + +```sh +cargo xtask web # release build → /web/dist (index.html, effectcraft_web.js, effectcraft_web_bg.wasm, favicon.svg) +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/ +``` + +`` 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 +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`. + +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 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`. + +URL flags: `?empty` (start without the demo project), `?home` (show the start screen). + +## 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 | +| `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` | +| 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 | `` 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) | + +### Threads + +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). + +## `window.effectcraft`: the agent / test API + +The control channel of the desktop app (`docs/control-protocol.md`) as promises. Results resolve +with the method's `result`, errors reject with the message. + +| Call | | +|---|---| +| `effectcraft.request(method, params)` | any control method: `ui.inspect`, `ui.click`, `ui.key`, `ui.playback`, `ui.menu.invoke`, `render.frame`… | +| `effectcraft.execute(command, params)` | `engine.execute`: an engine command (same ids and params as MCP / `effectcraft-cli`) | +| `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.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 | + +`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 +``` + +## 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`: + +```sh +cargo xtask web --serve 8765 & +node apps/effectcraft-web/tests/smoke.mjs --url http://127.0.0.1:8765/ --out target/web/smoke +``` + +## 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). +- `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. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 92d6d64b..81b61a88 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1,8 +1,11 @@ -//! Workspace automation: `cargo xtask `. +//! Workspace automation: `cargo xtask `. //! //! - `layers`: enforces the dependency layering of `docs/architecture.md` §1 (downward-only edges, //! listed same-layer edges, no UI/OS crates below L5). -//! - `wasm`: `cargo check --target wasm32-unknown-unknown` for every crate in L0–L4 and the egui UI. +//! - `wasm`: `cargo check --target wasm32-unknown-unknown` for every crate in L0–L4, the egui UI +//! and the web app. +//! - `web [--dev] [--serve PORT]`: build the web app (`apps/effectcraft-web`) into +//! `/web/dist` with `wasm-bindgen` (docs/web.md); `--serve` serves it on localhost. //! - `assets`: every asset file (image, icon, font, LUT, audio, video…) has a complete //! `.attribution` sidecar and an entry in `ATTRIBUTION.md` (AGENTS.md §1). //! - `ci`: fmt check, clippy -D warnings, tests, layers, assets, wasm. @@ -201,7 +204,7 @@ fn run(cmd: &mut Command) -> Result<(), String> { } /// Crates above L4 that must also build for the web. -const WEB_CRATES: &[&str] = &["effectcraft-ui-egui"]; +const WEB_CRATES: &[&str] = &["effectcraft-ui-egui", "effectcraft-web"]; fn wasm() -> Result<(), String> { let md = metadata()?; @@ -222,6 +225,119 @@ fn wasm() -> Result<(), String> { Ok(()) } +/// The wasm-bindgen CLI must match the `wasm-bindgen` crate version exactly. +const WASM_BINDGEN: &str = "0.2.129"; + +fn target_dir() -> std::path::PathBuf { + std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| std::path::PathBuf::from("target")) +} + +/// Build the web app into `/web/dist` (see docs/web.md). +fn web(args: &[String]) -> Result<(), String> { + let dev = args.iter().any(|a| a == "--dev"); + let serve = args.iter().position(|a| a == "--serve").map(|i| args.get(i + 1).and_then(|p| p.parse::().ok()).unwrap_or(8765)); + let profile = if dev { "dev" } else { "release" }; + let mut build = Command::new(env!("CARGO")); + build.args(["build", "--target", "wasm32-unknown-unknown", "-p", "effectcraft-web", "--profile", profile]); + run(&mut build)?; + let out = Command::new("wasm-bindgen") + .arg("--version") + .output() + .map_err(|_| format!("wasm-bindgen CLI not found: cargo install wasm-bindgen-cli --version {WASM_BINDGEN} --locked"))?; + let v = String::from_utf8_lossy(&out.stdout); + if !v.contains(WASM_BINDGEN) { + return Err(format!( + "wasm-bindgen CLI is {} but the crate is {WASM_BINDGEN}: cargo install wasm-bindgen-cli --version {WASM_BINDGEN} --locked", + v.trim() + )); + } + let dir = if dev { "debug" } else { "release" }; + let wasm = target_dir().join("wasm32-unknown-unknown").join(dir).join("effectcraft_web.wasm"); + let dist = target_dir().join("web").join("dist"); + let _ = std::fs::remove_dir_all(&dist); + std::fs::create_dir_all(&dist).map_err(|e| e.to_string())?; + run(Command::new("wasm-bindgen").args(["--target", "web", "--no-typescript", "--out-dir"]).arg(&dist).arg(&wasm))?; + let bg = dist.join("effectcraft_web_bg.wasm"); + if !dev && Command::new("wasm-opt").arg("--version").output().is_ok() { + // optional: smaller and faster (binaryen); skipped when not installed + let opt = dist.join("effectcraft_web_opt.wasm"); + run(Command::new("wasm-opt") + .args(["-O2", "--enable-bulk-memory", "--enable-nontrapping-float-to-int", "--enable-sign-ext", "--enable-mutable-globals"]) + .arg(&bg) + .arg("-o") + .arg(&opt))?; + std::fs::rename(&opt, &bg).map_err(|e| e.to_string())?; + } + let web = std::path::Path::new("apps/effectcraft-web/web"); + for e in std::fs::read_dir(web).map_err(|e| e.to_string())?.flatten() { + let name = e.file_name().to_string_lossy().to_string(); + if !name.ends_with(".attribution") { + std::fs::copy(e.path(), dist.join(&name)).map_err(|e| format!("{name}: {e}"))?; + } + } + 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(); + 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)?; + } + Ok(()) +} + +/// 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> { + use std::io::{BufRead, BufReader, Write}; + let listener = std::net::TcpListener::bind(("127.0.0.1", port)).map_err(|e| format!("127.0.0.1:{port}: {e}"))?; + println!("serving {} on http://127.0.0.1:{port}/", dir.display()); + for stream in listener.incoming().flatten() { + let dir = dir.to_path_buf(); + std::thread::spawn(move || { + let mut stream = stream; + let mut line = String::new(); + let mut reader = BufReader::new(match stream.try_clone() { + Ok(s) => s, + Err(_) => return, + }); + if reader.read_line(&mut line).is_err() { + return; + } + loop { + let mut h = String::new(); + if reader.read_line(&mut h).map(|n| n == 0).unwrap_or(true) || h.trim().is_empty() { + break; + } + } + let path = line.split_whitespace().nth(1).unwrap_or("/").split(['?', '#']).next().unwrap_or("/").to_string(); + let rel = if path == "/" { "index.html".to_string() } else { path.trim_start_matches('/').replace("..", "") }; + let file = dir.join(&rel); + let (status, body) = match std::fs::read(&file) { + Ok(b) => ("200 OK", b), + Err(_) => ("404 Not Found", b"not found".to_vec()), + }; + let mime = match file.extension().and_then(|e| e.to_str()).unwrap_or("") { + "html" => "text/html; charset=utf-8", + "js" => "text/javascript", + "wasm" => "application/wasm", + "svg" => "image/svg+xml", + "json" => "application/json", + "png" => "image/png", + "gif" => "image/gif", + "ecproj" => "application/json", + "mp4" => "video/mp4", + "webm" => "video/webm", + _ => "application/octet-stream", + }; + let head = format!( + "HTTP/1.1 {status}\r\nContent-Type: {mime}\r\nContent-Length: {}\r\nCross-Origin-Opener-Policy: same-origin\r\nCross-Origin-Embedder-Policy: require-corp\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = stream.write_all(head.as_bytes()).and_then(|_| stream.write_all(&body)); + }); + } + Ok(()) +} + fn ci() -> Result<(), String> { let cargo = env!("CARGO"); run(Command::new(cargo).args(["fmt", "--check"]))?; @@ -239,7 +355,8 @@ fn main() -> ExitCode { "wasm" => wasm(), "assets" => assets(), "ci" => ci(), - _ => Err("usage: cargo xtask ".into()), + "web" => web(&std::env::args().skip(2).collect::>()), + _ => Err("usage: cargo xtask ".into()), }; match r { Ok(()) => ExitCode::SUCCESS,