Merge branch 'worktree-agent-acb4c3cd3f5057c6a' (M15.1 web build)

Also: render uses web_time::Instant in layer styles; renderQueue.add documents
its render-settings/output-module keys so checked execution accepts them (+test).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-02 00:41:56 -04:00
commit 362d12269f
32 files changed
+1375 -94

No files matched your search

+50
View File
@@ -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",
] }
+118
View File
@@ -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(&params);
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
}
});
});
}
+233
View File
@@ -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(&central);
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);
});
}
+179
View File
@@ -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(())
}
+175
View File
@@ -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);
+8
View File
@@ -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.
+64
View File
@@ -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>