effectcraft/apps/effectcraft-web/src/lib.rs

180 lines
7.1 KiB
Rust

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