Files
effectcraft/apps/effectcraft-web/src/app.rs
T
bflatasticandClaude Opus 5.5 9da33c157e Plug-ins: ScriptUI Panels folder scripts dock as panels and reopen, as in After Effects
EffectCraft's extension points compared with After Effects' (public
user guide and Scripting Guide) are now in docs/plugins.md, which
becomes "Plug-ins and extensions": scripts, ScriptUI panels and effect
plug-ins, with a short guide and an example panel for writing your own.

ScriptUI panels already came from `Scripts/ScriptUI Panels/` in the
settings folder, were listed at the bottom of the Window menu and
docked as tabs. What was missing: After Effects reopens the panels
that were open when it quit. Now the open panels are remembered in
`scriptui_panels.json` in the settings folder (a panel is added when
it opens and dropped when its tab or its script closes it; quitting
leaves the list), and `window.restoreScriptPanels` opens them again;
the desktop and web apps run it at start-up. A panel whose script is
gone or fails is reported and forgotten, and the others still open.
Where a panel was docked isn't kept, as workspace layouts aren't saved
between launches.

Gaps recorded in plugins.md and gaps.md G8: the Startup and Shutdown
script folders, keyboard shortcuts for scripts, ExtendScript's
`#include` / `#target` directives and `.jsxbin`.

Test: open_panels_open_again_at_the_next_launch (fails before: no
restore command), a_panel_dropped_in_the_folder_is_listed_and_opens_as_a_panel
(script), panels_open_at_quit_dock_again_at_launch (UI: the restored
panel docks with its controls; closing the tab forgets it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:03:12 +07:00

275 lines
12 KiB
Rust

//! 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<dyn FnOnce(&mut EffectcraftApp)>;
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<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).
pub(crate) 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,
/// 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<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 (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::UrlSearchParams> {
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<String> {
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<dyn effectcraft_engine::offload::Offload>
}),
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::<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 }))));
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(())
}