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

225 lines
9.2 KiB
Rust

//! FilmCraft 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 on WebGPU (WebGL2 fallback). What the desktop shell gets from the OS,
//! this crate gets from the browser:
//!
//! | Desktop | Web ([module]) |
//! |---|---|
//! | file system ([`filmcraft_engine::FsServices`]) | virtual file table of `File`/`Blob`s with chunked range reads, downloads ([`fs`]) |
//! | file dialogs (rfd) | `<input type=file>` / File System Access pickers, drag-and-drop ([`import`]) |
//! | auto-save + recovery journal (data dir) | OPFS snapshot + media copies ([`recovery`], [`opfs`]) |
//! | cpal audio output | WebAudio `AudioWorklet`, played-frame clock ([`audio`]) |
//! | VideoToolbox… | WebCodecs `VideoDecoder` ([`webcodecs`]) |
//! | frame worker threads | cooperative frame queue pumped between UI frames (`FrameServer::pump`) |
//! | TCP control channel / MCP | `window.filmcraft` JavaScript API ([`api`]) |
//!
//! Everything here is `wasm32`-only; on other targets the crate is empty.
#![cfg(target_arch = "wasm32")]
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
pub mod api;
pub mod audio;
pub mod fs;
pub mod import;
pub mod opfs;
pub mod recovery;
pub mod recovery_policy;
pub mod webcodecs;
use std::cell::RefCell;
use std::sync::Arc;
use filmcraft_engine::Session;
use filmcraft_ui_egui::FilmcraftApp;
use serde_json::json;
use wasm_bindgen::prelude::*;
/// Work for the UI thread with access to the app (posted by async tasks and the JS API).
pub type Action = Box<dyn FnOnce(&mut WebApp, &egui::Context)>;
thread_local! {
static ACTIONS: RefCell<Vec<Action>> = const { RefCell::new(Vec::new()) };
static CTX: RefCell<Option<egui::Context>> = const { RefCell::new(None) };
static INFO: RefCell<serde_json::Value> = const { RefCell::new(serde_json::Value::Null) };
}
/// Run `f` on the UI thread at the next frame.
pub fn post(f: impl FnOnce(&mut WebApp, &egui::Context) + '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();
}
});
}
/// Give a host that builds its own [`WebApp`] the egui context that [`post`] and [`repaint`] wake.
/// [`start`] calls it; an embedding app that runs `WebApp` in its own eframe runner calls it once
/// from its app creator, or posted actions and JS API requests wait for the next input event.
pub fn set_context(ctx: egui::Context) {
CTX.with(|c| *c.borrow_mut() = Some(ctx));
}
/// Environment facts reported by `filmcraft.info()` (backend, isolation, codecs…).
pub fn set_info(key: &str, v: serde_json::Value) {
INFO.with(|i| {
let mut i = i.borrow_mut();
if !i.is_object() {
*i = json!({});
}
i[key] = v;
});
}
pub fn info() -> serde_json::Value {
INFO.with(|i| i.borrow().clone())
}
/// The eframe app: the shared FilmCraft UI plus the web host's per-frame duties.
pub struct WebApp {
pub app: FilmcraftApp,
pub audio: Option<audio::Handle>,
pub autosave: recovery::Autosave,
}
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(self, ctx);
}
self.app.logic(ctx, frame);
api::poll_replies();
// exports advance between frames (no threads)
if self.app.session.pump_jobs(std::time::Duration::from_millis(30)) {
ctx.request_repaint();
}
if let Some(a) = &self.audio {
a.pump();
}
self.autosave.tick(&self.app.session, ctx);
}
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))
}
/// 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 t0 = web_time::Instant::now();
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 isolated = js_sys::Reflect::get(&js_sys::global(), &"crossOriginIsolated".into()).ok().and_then(|v| v.as_bool()).unwrap_or(false);
// Frame rendering runs cooperatively on this thread. wasm threads need a cross-origin
// isolated page *and* a build with atomics (nightly `build-std`); this build has none.
set_info("crossOriginIsolated", json!(isolated));
set_info("threads", json!(false));
set_info("frameWorkers", json!("cooperative"));
set_info("opfs", json!(opfs::available()));
// Media kept in OPFS by earlier sessions come back under their paths (recovery).
let restored = if opfs::available() && !query_flag("fresh") { recovery::restore_media().await } else { 0 };
let recovered = if query_flag("fresh") || query_flag("norecover") { None } else { recovery::load_snapshot().await };
webcodecs::probe().await;
api::install()?;
import::install_drop_handlers()?;
let demo = !query_flag("empty");
let mut web_options = eframe::WebOptions::default();
// `?webgl`: WebGL2 only. The page reloads with it when starting on WebGPU fails (an adapter
// whose device can't be created, a driver the browser rejects…); see `index.html`.
if query_flag("webgl")
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(c) = &mut web_options.wgpu_options.wgpu_setup
{
c.instance_descriptor.backends = eframe::wgpu::Backends::GL;
}
let runner = eframe::WebRunner::new();
runner
.start(
canvas,
web_options,
Box::new(move |cc| {
set_context(cc.egui_ctx.clone());
let ctx = cc.egui_ctx.clone();
fs::set_on_data(move || ctx.request_repaint());
fs::set_on_write(|path, data| {
if let Err(e) = fs::download(path, data) {
log::warn!("download {path}: {e:?}");
}
});
webcodecs::install();
let mut session = Session::new(Arc::new(fs::WebServices));
let mut toast = None;
if let Some((name, bytes)) = recovered {
let path = format!("/recovered/{name}");
fs::put(&path, bytes);
match session.execute("file.open", json!({"path": path})) {
Ok(_) => {
// the recovered changes are unsaved
session.saved_revision = 0;
toast = Some(format!("Recovered unsaved changes to {name}"));
}
Err(e) => log::warn!("recovery failed: {e}"),
}
} else if demo {
let _ = session.execute("file.openDemoProject", json!({}));
}
let mut app = FilmcraftApp::new(session);
import::install_hooks(&mut app);
let (tx, rx) = std::sync::mpsc::channel();
api::set_sender(tx);
app = app.with_control(rx);
if let Some(t) = toast {
app.status(t);
}
let backend = cc.wgpu_render_state.as_ref().map(|rs| rs.adapter.get_info().backend);
set_info("backend", json!(backend.map(|b| format!("{b:?}"))));
// The GPU compositor needs float render targets everywhere: WebGPU only (WebGL2
// composites on the CPU).
if let Some(rs) = cc.wgpu_render_state.clone()
&& backend == Some(eframe::wgpu::Backend::BrowserWebGpu)
&& !query_flag("cpu")
{
app.set_wgpu(rs);
set_info("compositor", json!("gpu"));
} else {
set_info("compositor", json!("cpu"));
}
let audio = audio::Handle::new();
if let Some(a) = &audio {
app.audio = Some(Box::new(a.clone()));
}
set_info("audio", json!(audio.is_some()));
set_info("restoredMedia", json!(restored));
Ok(Box::new(WebApp { app, audio, autosave: recovery::Autosave::default() }))
}),
)
.await?;
set_info("startupMs", json!(t0.elapsed().as_secs_f64() * 1000.0));
// keep the runner alive for the page's lifetime
std::mem::forget(runner);
if let Some(el) = doc.get_element_by_id("loading") {
el.remove();
}
Ok(())
}