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, <input type=file> 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 <noreply@anthropic.com>
This commit is contained in:
parent
c925ac8ae8
commit
31f192e2a9
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
] }
|
||||
|
|
@ -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<Value>, js_sys::Function, js_sys::Function);
|
||||
|
||||
thread_local! {
|
||||
static TX: RefCell<Option<Sender<ControlRequest>>> = const { RefCell::new(None) };
|
||||
static PENDING: RefCell<Vec<Pending>> = const { RefCell::new(Vec::new()) };
|
||||
static INFO: RefCell<Value> = const { RefCell::new(Value::Null) };
|
||||
}
|
||||
|
||||
pub(crate) fn set_sender(tx: Sender<ControlRequest>) {
|
||||
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 `/<name>` 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
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
//! 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).
|
||||
//!
|
||||
//! 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`]).
|
||||
|
||||
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<Option<HashMap<String, Arc<[u8]>>>> = Mutex::new(None);
|
||||
/// Render outputs written since the last [`flush_downloads`].
|
||||
static OUTPUTS: 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.
|
||||
pub fn put(path: &str, data: Arc<[u8]>) {
|
||||
with_files(|m| m.insert(path.to_string(), data));
|
||||
}
|
||||
|
||||
pub fn get(path: &str) -> Option<Arc<[u8]>> {
|
||||
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<Vec<u8>> {
|
||||
get(path)
|
||||
.map(|d| d.to_vec())
|
||||
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, format!("{path}: open it with File ▸ Open (or drop it on the page)")))
|
||||
}
|
||||
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
put(path, data.into());
|
||||
download(file_name(path), data).map_err(|e| std::io::Error::other(format!("download failed: {e:?}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Media import from the table (the bytes are also handed to the media pool, which decodes them).
|
||||
pub struct WebImporter {
|
||||
pub pool: Arc<effectcraft_media::MediaPool>,
|
||||
}
|
||||
|
||||
impl effectcraft_engine::Importer for WebImporter {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
let bytes = get(path).ok_or_else(|| format!("{path}: not loaded (import it with File ▸ Import or drop it on the page)"))?;
|
||||
self.pool.add_bytes(path, bytes.clone());
|
||||
effectcraft_media::probe_bytes(path, bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Render Queue output: files land in the table and are downloaded after the render
|
||||
/// ([`flush_downloads`]; several files, e.g. an image sequence, as one `.zip`).
|
||||
pub fn export_sink() -> Arc<effectcraft_export::Sink> {
|
||||
Arc::new(|path: &str, data: Vec<u8>| {
|
||||
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<u8> {
|
||||
fn crc32(d: &[u8]) -> u32 {
|
||||
let mut c = !0u32;
|
||||
for &b in d {
|
||||
c ^= b as u32;
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
|
||||
}
|
||||
}
|
||||
!c
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut central = Vec::new();
|
||||
for (name, data) in entries {
|
||||
let (crc, n, off) = (crc32(data), data.len() as u32, out.len() as u32);
|
||||
let common = |v: &mut Vec<u8>| {
|
||||
v.extend_from_slice(&20u16.to_le_bytes()); // version needed
|
||||
v.extend_from_slice(&0x0800u16.to_le_bytes()); // flags: UTF-8 names
|
||||
v.extend_from_slice(&0u16.to_le_bytes()); // stored
|
||||
v.extend_from_slice(&0u16.to_le_bytes()); // time
|
||||
v.extend_from_slice(&0x21u16.to_le_bytes()); // date: 1980-01-01
|
||||
v.extend_from_slice(&crc.to_le_bytes());
|
||||
v.extend_from_slice(&n.to_le_bytes());
|
||||
v.extend_from_slice(&n.to_le_bytes());
|
||||
v.extend_from_slice(&(name.len() as u16).to_le_bytes());
|
||||
v.extend_from_slice(&0u16.to_le_bytes()); // extra
|
||||
};
|
||||
out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
|
||||
common(&mut out);
|
||||
out.extend_from_slice(name.as_bytes());
|
||||
out.extend_from_slice(data);
|
||||
central.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
|
||||
central.extend_from_slice(&20u16.to_le_bytes()); // version made by
|
||||
common(&mut central);
|
||||
central.extend_from_slice(&[0; 6]); // comment len, disk, internal attrs
|
||||
central.extend_from_slice(&0u32.to_le_bytes()); // external attrs
|
||||
central.extend_from_slice(&off.to_le_bytes());
|
||||
central.extend_from_slice(name.as_bytes());
|
||||
}
|
||||
let (cd_off, cd_len, count) = (out.len() as u32, central.len() as u32, entries.len() as u16);
|
||||
out.extend_from_slice(¢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<String>) + 'static) -> Result<(), JsValue> {
|
||||
let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
|
||||
let input: web_sys::HtmlInputElement = doc.create_element("input")?.dyn_into()?;
|
||||
input.set_type("file");
|
||||
input.set_accept(accept);
|
||||
input.set_multiple(multiple);
|
||||
let inp = input.clone();
|
||||
let on_change = Closure::once_into_js(move || {
|
||||
let Some(list) = inp.files() else { return };
|
||||
read_files((0..list.length()).filter_map(|i| list.get(i)).collect(), then);
|
||||
});
|
||||
input.add_event_listener_with_callback("change", on_change.unchecked_ref())?;
|
||||
input.click();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read browser files into the table (asynchronously); `then` gets their table paths.
|
||||
pub fn read_files(picked: Vec<web_sys::File>, then: impl FnOnce(Vec<String>) + 'static) {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let mut paths = Vec::new();
|
||||
for f in picked {
|
||||
match wasm_bindgen_futures::JsFuture::from(f.array_buffer()).await {
|
||||
Ok(buf) => {
|
||||
let path = path_for(&f.name());
|
||||
put(&path, js_sys::Uint8Array::new(&buf).to_vec().into());
|
||||
paths.push(path);
|
||||
}
|
||||
Err(e) => log::warn!("cannot read {}: {e:?}", f.name()),
|
||||
}
|
||||
}
|
||||
then(paths);
|
||||
});
|
||||
}
|
||||
|
|
@ -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 | `<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) |
|
||||
//! | 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<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, 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::<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(())
|
||||
}
|
||||
|
|
@ -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 <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.
|
||||
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);
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<!-- EffectCraft mark (original work): the header logo of crates/ui-egui/src/header.rs as SVG:
|
||||
a navy rounded square split blue/violet with a white keyframe diamond. -->
|
||||
<rect x="0" y="0" width="64" height="64" rx="12" fill="#1d2b5e"/>
|
||||
<path d="M14 4 H32 V60 H14 A10 10 0 0 1 4 50 V14 A10 10 0 0 1 14 4 Z" fill="#2d8ceb"/>
|
||||
<path d="M32 4 H50 A10 10 0 0 1 60 14 V50 A10 10 0 0 1 50 60 H32 Z" fill="#8e6bff"/>
|
||||
<path d="M32 15.4 L48.6 32 L32 48.6 L15.4 32 Z" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 558 B |
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>EffectCraft</title>
|
||||
<link rel="icon" href="favicon.svg" type="image/svg+xml">
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; overflow: hidden; background: #1b1c1f; }
|
||||
canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; outline: none; }
|
||||
#loading {
|
||||
position: absolute; inset: 0; display: flex; flex-direction: column; gap: 14px;
|
||||
align-items: center; justify-content: center; color: #c9c9cc;
|
||||
font: 14px/1.4 system-ui, sans-serif; pointer-events: none;
|
||||
}
|
||||
#loading img { width: 56px; height: 56px; }
|
||||
#loading .err { color: #ff8a7a; white-space: pre-wrap; max-width: 70ch; pointer-events: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="effectcraft_canvas"></canvas>
|
||||
<div id="loading"><img src="favicon.svg" alt=""><div id="status">Loading EffectCraft…</div></div>
|
||||
<script type="module">
|
||||
import init, * as ec from "./effectcraft_web.js";
|
||||
const t0 = performance.now();
|
||||
window.effectcraftLoad = {};
|
||||
// The agent / test API: the desktop control channel as promises (docs/web.md).
|
||||
window.effectcraft = {
|
||||
request: (method, params) => ec.request(method, params ?? {}),
|
||||
execute: (command, params) => ec.request("engine.execute", { command, params: params ?? {} }),
|
||||
commands: () => ec.request("engine.commands", {}),
|
||||
inspect: () => ec.request("ui.inspect", {}),
|
||||
screenshot: (params) => ec.request("ui.screenshot", params ?? {}),
|
||||
renderFrame: (params) => ec.request("render.frame", { base64: true, ...(params ?? {}) }),
|
||||
files: () => ec.files(),
|
||||
readFile: (path) => ec.readFile(path),
|
||||
addFile: async (fileOrUrl, name) => {
|
||||
let bytes;
|
||||
if (typeof fileOrUrl === "string") {
|
||||
bytes = new Uint8Array(await (await fetch(fileOrUrl)).arrayBuffer());
|
||||
name = name ?? fileOrUrl.split("/").pop();
|
||||
} else {
|
||||
bytes = new Uint8Array(await fileOrUrl.arrayBuffer());
|
||||
name = name ?? fileOrUrl.name;
|
||||
}
|
||||
return ec.addFile(name, bytes);
|
||||
},
|
||||
info: () => ({ ...ec.info(), crossOriginIsolated: self.crossOriginIsolated, load: window.effectcraftLoad }),
|
||||
};
|
||||
try {
|
||||
await init();
|
||||
window.effectcraftLoad.wasmMs = performance.now() - t0;
|
||||
await ec.start("effectcraft_canvas");
|
||||
window.effectcraftLoad.readyMs = performance.now() - t0;
|
||||
} catch (e) {
|
||||
const s = document.getElementById("status");
|
||||
if (s) { s.className = "err"; s.textContent = "EffectCraft failed to start:\n" + (e && e.stack || e); }
|
||||
window.effectcraftLoad.error = String(e);
|
||||
throw e;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -24,3 +24,4 @@ serde = { workspace = true }
|
|||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -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<Vec<u64>, String> {
|
||||
let wait = wait || cfg!(target_arch = "wasm32");
|
||||
if self.is_rendering() {
|
||||
return Err("a render is already in progress".into());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -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<f32>; 2] {
|
|||
|
||||
// ---------------------------------------------------------------- the movie
|
||||
|
||||
type Writer = Mp4Writer<BufWriter<std::fs::File>>;
|
||||
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<Report> {
|
||||
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 })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Re
|
|||
.into_par_iter()
|
||||
.map(|k| {
|
||||
let path = sequence_path(job.path, f0 + k as i64);
|
||||
if job.settings.skip_existing && Path::new(&path).exists() {
|
||||
if job.settings.skip_existing && out::exists(job.sink, &path) {
|
||||
return Ok(0);
|
||||
}
|
||||
let img = render_frame(job, comp, k);
|
||||
write_still(&path, fmt, &img, comp, channels, w, h, job.output.quality)?;
|
||||
Ok(std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0))
|
||||
let mut f = out::create(job.sink, &path)?;
|
||||
write_still(&mut f, fmt, &img, comp, channels, w, h, job.output.quality)?;
|
||||
f.finish()
|
||||
})
|
||||
.collect();
|
||||
for r in written {
|
||||
|
|
@ -236,9 +237,8 @@ fn sequence(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<Re
|
|||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn write_still(path: &str, fmt: OutputFormat, img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32, quality: u8) -> 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<u8> = 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 })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<u8>) + Send + Sync;
|
||||
|
||||
/// One output file being written.
|
||||
pub(crate) enum Out<'a> {
|
||||
File(BufWriter<std::fs::File>),
|
||||
Mem { path: String, cur: Cursor<Vec<u8>>, 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<Out<'a>> {
|
||||
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<u64> {
|
||||
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<usize> {
|
||||
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<u64> {
|
||||
match self {
|
||||
Out::File(w) => w.seek(pos),
|
||||
Out::Mem { cur, .. } => cur.seek(pos),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<Arc<effectcraft_export::Sink>>,
|
||||
}
|
||||
|
||||
impl Exporter for FileExporter {
|
||||
fn formats(&self) -> Vec<OutputFormat> {
|
||||
|
|
@ -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<Mutex<Vec<(String, Vec<u8>)>>> = 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<u8>| 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");
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@
|
|||
//! Frames render on a dedicated thread pool from immutable `Arc<Project>` 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<Mutex<HashSet<FrameKey>>>,
|
||||
/// Pending jobs, picked by priority when a pool thread frees up (the viewer's frame first).
|
||||
queue: Arc<Mutex<Queue>>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pool: rayon::ThreadPool,
|
||||
ctx: Option<egui::Context>,
|
||||
/// 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<Mutex<Queue>>,
|
||||
cache: Arc<Mutex<Cache>>,
|
||||
inflight: Arc<Mutex<HashSet<FrameKey>>>,
|
||||
ctx: Option<egui::Context>,
|
||||
last: Arc<Mutex<f64>>,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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 → <target>/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/
|
||||
```
|
||||
|
||||
`<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
|
||||
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 | `<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) |
|
||||
|
||||
### 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.
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
//! Workspace automation: `cargo xtask <layers|assets|wasm|ci>`.
|
||||
//! Workspace automation: `cargo xtask <layers|assets|wasm|web|ci>`.
|
||||
//!
|
||||
//! - `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
|
||||
//! `<target>/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
|
||||
//! `<file>.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 `<target>/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::<u16>().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 <layers|assets|wasm|ci>".into()),
|
||||
"web" => web(&std::env::args().skip(2).collect::<Vec<_>>()),
|
||||
_ => Err("usage: cargo xtask <layers|assets|wasm|web [--dev] [--serve PORT]|ci>".into()),
|
||||
};
|
||||
match r {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
|
|
|
|||
Loading…
Reference in New Issue