//! The app: startup (storage, session restore, hooks), the eframe app and the page glue. use std::cell::RefCell; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use effectcraft_engine::Session; use crate::{api, audio, browse, diskcache, files, frames, persist, storage, worker}; use effectcraft_ui_egui::EffectcraftApp; use serde_json::json; use wasm_bindgen::prelude::*; #[wasm_bindgen(module = "/js/host.js")] extern "C" { #[wasm_bindgen(js_name = showDeviceLost)] fn show_device_lost(message: &str, kept: bool); } /// Work for the UI thread with access to the app (posted by async tasks: picked files…). type Action = Box; static WORKER: AtomicBool = AtomicBool::new(false); /// Running inside a Web Worker (no page, no downloads). pub fn is_worker() -> bool { WORKER.load(Ordering::Relaxed) } pub(crate) fn set_worker() { WORKER.store(true, Ordering::Relaxed); } /// `rel` resolved against the page's URL (`worker.js`, `audio-worklet.js`); `""` = the page's /// folder. pub fn page_url(rel: &str) -> String { let href = web_sys::window().and_then(|w| w.location().href().ok()).unwrap_or_default(); web_sys::Url::new_with_base(if rel.is_empty() { "./" } else { rel }, &href).map(|u| u.href()).unwrap_or_else(|_| rel.to_string()) } thread_local! { static ACTIONS: RefCell> = const { RefCell::new(Vec::new()) }; static CTX: RefCell> = const { RefCell::new(None) }; } /// Run `f` on the UI thread at the next frame. pub fn post(f: impl FnOnce(&mut EffectcraftApp) + 'static) { ACTIONS.with(|a| a.borrow_mut().push(Box::new(f))); repaint(); } /// Ask for a UI frame. pub fn repaint() { CTX.with(|c| { if let Some(c) = c.borrow().as_ref() { c.request_repaint(); } }); } /// Open a project or import media from table paths (picked or dropped files). pub(crate) fn open_or_import(app: &mut EffectcraftApp, paths: Vec) { let (projects, media): (Vec, Vec) = paths.into_iter().partition(|p| p.to_ascii_lowercase().ends_with(".ecproj")); if let Some(p) = projects.last() && let Err(e) = app.session.execute("file.open", json!({"path": p})) { app.ui.status = e.to_string(); } if !media.is_empty() { match app.session.execute("file.import", json!({"paths": media})) { Ok(r) => { if let Some(errs) = r["errors"].as_array().filter(|e| !e.is_empty()) { app.ui.status = errs.iter().filter_map(|e| e.as_str()).collect::>().join("; "); } } Err(e) => app.ui.status = e.to_string(), } } } /// The eframe app: the shared EffectCraft UI plus the browser host's per-frame duties. pub struct WebApp { pub app: EffectcraftApp, /// Keep the session snapshot (off with `?empty`). pub persist: bool, } impl eframe::App for WebApp { fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { self.app.raw_input_hook(ctx, raw_input); } fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { let actions = ACTIONS.with(|a| std::mem::take(&mut *a.borrow_mut())); for f in actions { f(&mut self.app); } // Files dropped on the page: read them, then open/import. let dropped: Vec = ctx.input(|i| i.raw.dropped_files.iter().filter_map(|f| f.web_file().cloned()).collect()); if !dropped.is_empty() { files::read_files(dropped, |paths| post(move |app| open_or_import(app, paths))); } self.app.logic(ctx, frame); api::poll_replies(); // Render Queue outputs written this frame (here or by a worker) → downloads. files::flush_downloads(); // What a reload comes back to. if self.persist { persist::snapshot(&self.app.session, ctx.input(|i| i.time), 1.0); } api::set_info("gpu", json!({"compositor": self.app.gpu_adapter(), "viewerOnGpu": self.app.viewer_on_gpu()})); api::set_info("frameWorkers", frames::stats()); // Settings ▸ Disk ▸ Disk Cache (frames in the Origin Private File System). let d = &self.app.session.prefs.disk; diskcache::configure(d.disk_cache_enabled, d.disk_cache_max_gb, storage::quota()); api::set_info("diskCache", diskcache::stats()); } fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { self.app.ui(ui, frame); } } fn query() -> Option { web_sys::window().and_then(|w| w.location().search().ok()).and_then(|s| web_sys::UrlSearchParams::new_with_str(&s).ok()) } fn query_flag(name: &str) -> bool { query().is_some_and(|p| p.has(name)) } fn query_value(name: &str) -> Option { query().and_then(|p| p.get(name)) } /// A session wired for the browser: media decoding from memory, expressions, scripting, Render /// Queue export to downloads, settings in browser storage, renders and analyses in workers. pub fn session() -> Session { let pool = Arc::new(effectcraft_media::MediaPool::new()); persist::register_media(&pool); Session { services: Arc::new(files::WebServices), footage: pool.clone(), importer: Some(Arc::new(files::WebImporter { pool })), expr: Some(Arc::new(effectcraft_expr::Expressions)), expr_check: Some(effectcraft_expr::check_syntax), script: Some(effectcraft_host::script::runner), exporter: Some(Arc::new(effectcraft_host::FileExporter { sink: Some(files::export_sink()) })), config: Some(Arc::new(persist::config())), offload: (!query_flag("noworkers")).then(|| { // Job workers render on a WebGPU device of their own (`?nogpuworkers`: their CPU). worker::set_job_worker_gpu(!query_flag("nogpuworkers")); Arc::new(worker::WorkerOffload) as Arc }), browser: Some(Arc::new(browse::WebBrowser)), storage: Some(Arc::new(storage::WebStorage)), ..Default::default() } } /// Browser file pickers in place of the desktop's file dialogs. fn install_hooks(app: &mut EffectcraftApp) { app.hooks.pick_files = Some(Box::new(|exts: &[&str]| { let accept = exts.iter().map(|e| format!(".{e}")).collect::>().join(","); if let Err(e) = files::pick(&accept, true, |paths| post(move |app| open_or_import(app, paths))) { log::warn!("file picker: {e:?}"); } // the import happens when the browser hands over the files Vec::new() })); app.hooks.pick_open_project = Some(Box::new(|| { if let Err(e) = files::pick(".ecproj", false, |paths| post(move |app| open_or_import(app, paths))) { log::warn!("file picker: {e:?}"); } None })); // Saving downloads the file: the chosen name is all a "save dialog" needs. app.hooks.pick_save = Some(Box::new(|name: &str| Some(files::path_for(if name.is_empty() { "Untitled Project.ecproj" } else { name })))); app.hooks.audio_device = Some(Box::new(audio::open)); app.hooks.audio_devices = Some(Box::new(|| vec!["Browser default output".into()])); } /// Entry point (called by the page's bootstrap script once the wasm module is instantiated). #[wasm_bindgen] pub async fn start(canvas_id: String) -> Result<(), JsValue> { eframe::WebLogger::init(log::LevelFilter::Info).ok(); let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?; let canvas: web_sys::HtmlCanvasElement = doc.get_element_by_id(&canvas_id).ok_or("no canvas")?.dyn_into()?; let empty = query_flag("empty"); let home = query_flag("home"); // Browser storage: settings, recent projects, imported media, saved projects, auto-saves and // the last session come back. let t0 = js_sys::Date::now(); let stored = match persist::load(&query_value("storage").unwrap_or_default()).await { Ok(n) => n, Err(e) => { log::warn!("browser storage unavailable: {e}"); 0 } }; api::set_info("storage", json!({"backend": persist::backend(), "entries": stored, "loadMs": js_sys::Date::now() - t0})); audio::install_unlock(); browse::restore(); diskcache::init(); storage::refresh(); 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())); // The page owns this shared presentation device; worker devices retain their // separate owned-device handlers. No browser error-scope future is blocked on. let gpu_failures = effectcraft_ui_egui::gpu_failure::GpuFailureBridge::new(&cc.egui_ctx); if let Some(rs) = &cc.wgpu_render_state { let errors = gpu_failures.clone(); rs.device.on_uncaptured_error(Arc::new(move |error| { let message = format!("uncaptured GPU error: {error}"); if errors.report(&message, false) { log::error!("{message}"); } })); // The canvas can't present any more (#225): the page says so over it and // offers a reload, which brings the session back when browser storage keeps it. let lost = gpu_failures.clone(); let kept = !empty && persist::backend() != "memory"; rs.device.set_device_lost_callback(move |reason, message| { let message = format!("GPU device lost ({reason:?}): {message}"); lost.report(&message, true); show_device_lost(&message, kept); }); } let mut session = session(); session.load_settings(); let recovery = session.begin_recovery(); // `?empty`: a blank project; `?demo`: the demo project; otherwise the last visit's // project (the demo on a first visit). let restored = !empty && !query_flag("demo") && persist::restore(&mut session); if !restored && !empty { let _ = session.execute("file.openDemoProject", json!({})); } api::set_info("restored", json!(restored)); // The ScriptUI panels open on the last visit open again. let _ = session.execute("window.restoreScriptPanels", json!({})); let mut app = EffectcraftApp::new(session); app.set_gpu_failure_bridge(gpu_failures); app.ui.start_screen = home; if let Some(r) = recovery.filter(|r| !restored && r.autosave.is_some()) { app.offer_recovery(r); } install_hooks(&mut app); // Viewer frames render in frame workers (`?frameworkers=N`, 0 or `?noworkers`: // on the page's thread), each on its own WebGPU device where the browser has // WebGPU in workers (`?nogpuworkers`: their CPU). let n = if query_flag("noworkers") { 0 } else { query_value("frameworkers").and_then(|v| v.parse().ok()).unwrap_or(2usize).min(8) }; if n > 0 { app.frames.set_remote(Some(Arc::new(frames::WebFrames::start(n, !query_flag("nogpuworkers"))))); } let (tx, rx) = std::sync::mpsc::channel(); api::set_sender(tx); app = app.with_control(rx); let backend = cc.wgpu_render_state.as_ref().map(|rs| format!("{:?}", rs.adapter.get_info().backend)); api::set_info("backend", json!(backend)); Ok(Box::new(WebApp { app, persist: !empty })) }), ) .await?; // Hide the loading message once the app runs. if let Some(el) = doc.get_element_by_id("loading") { el.remove(); } Ok(()) }