mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-28 20:16:52 -06:00
M15.2: web app depth (WEB-1): browser storage, Web Audio, worker renders, WebGPU viewer, PWA
- Persistence: settings, shortcuts, recent projects, imported media, saved projects and auto-saves live in OPFS (IndexedDB fallback) through an in-memory mirror flushed in the background (apps/effectcraft-web/src/store.rs, persist.rs); ConfigStore gains FileOps so auto-save / recovery work in the browser store; the session snapshot restores the last project + editor state on reload; File > Open Recent works from storage. - Audio: Web Audio output (AudioWorklet, ScriptProcessor fallback) fed by AudioPlayback::pump on wasm32; A/V sync from AudioContext.currentTime; meters; unlock on first gesture. - Background jobs: engine::offload (WorkerRequest/WorkerReply protocol, Offload trait, run_request); Render Queue, tracker, mask tracker, Warp Stabilizer and Roto Freeze run in Web Workers (a second engine instance on the shared compiled module), progress streamed back; cancel terminates the worker. - GPU: WebGPU viewer frames drawn straight from the compositor's texture (gpu_display on wasm32 when the viewer draws plainly); async readback (Gpu::read_display_async) for pixels. - PWA: manifest, service worker precaching the build (xtask fills list/version), icons copied from assets/app-icon; COOP/COEP kept from cached responses; offline reload works. - window.effectcraft: saveToBrowser, listStored, removeStored, flush; info() reports storage, audio, gpu, workers. - Tests: native store + protocol + offload (render, track, cancel) tests; smoke.mjs extended (GPU path, worker render responsiveness, audio gesture + playback, reload persistence, Open Recent, offline reload). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c406b71cba
commit
0bee1ab2da
@@ -92,9 +92,68 @@ pub fn info() -> JsValue {
|
||||
}
|
||||
v["version"] = json!(env!("CARGO_PKG_VERSION"));
|
||||
v["threads"] = json!(false);
|
||||
v["workers"] = from_js(&crate::worker::stats());
|
||||
v["audio"] = from_js(&crate::audio::state());
|
||||
if let Some(e) = crate::persist::last_error() {
|
||||
v["storage"]["error"] = json!(e);
|
||||
}
|
||||
to_js(&v)
|
||||
}
|
||||
|
||||
/// Save the project to browser storage without downloading it: `path` (default: its current
|
||||
/// path, or `/<project name>.ecproj`). Resolves with `{path, bytes}`.
|
||||
#[wasm_bindgen(js_name = saveToBrowser)]
|
||||
pub fn save_to_browser(path: Option<String>) -> js_sys::Promise {
|
||||
js_sys::Promise::new(&mut |resolve, reject| {
|
||||
let path = path.clone();
|
||||
crate::post(move |app| {
|
||||
let p = path.or_else(|| app.session.path.clone()).unwrap_or_else(|| format!("/{}.ecproj", app.session.project_name()));
|
||||
let p = if p.starts_with('/') { p } else { format!("/{p}") };
|
||||
crate::files::save_quietly(&p);
|
||||
match app.session.execute("file.saveAs", json!({"path": p})) {
|
||||
Ok(v) => {
|
||||
let _ = resolve.call1(&JsValue::NULL, &to_js(&v));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = reject.call1(&JsValue::NULL, &JsValue::from_str(&e.to_string()));
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/// What browser storage holds: `{backend, usage, quota, persisted, files: [{path, size,
|
||||
/// modified}], config: [name]}` (render outputs, which are not stored, are left out).
|
||||
#[wasm_bindgen(js_name = listStored)]
|
||||
pub async fn list_stored() -> JsValue {
|
||||
let est = wasm_bindgen_futures::JsFuture::from(crate::persist::store_estimate()).await.map(|v| from_js(&v)).unwrap_or(json!({}));
|
||||
let files: Vec<Value> = crate::files::STORE
|
||||
.files()
|
||||
.into_iter()
|
||||
.filter(|f| f.3)
|
||||
.map(|(path, size, modified, _)| json!({"path": path, "size": size, "modified": modified}))
|
||||
.collect();
|
||||
let config: Vec<Value> =
|
||||
crate::files::STORE.lock().list(crate::store::CONFIG).into_iter().map(|(k, ..)| json!(k.trim_start_matches(crate::store::CONFIG))).collect();
|
||||
let mut v = est;
|
||||
v["files"] = json!(files);
|
||||
v["config"] = json!(config);
|
||||
v["pending"] = json!(crate::files::STORE.lock().has_pending());
|
||||
to_js(&v)
|
||||
}
|
||||
|
||||
/// Remove a stored file (media, project, auto-save). Resolves with whether it existed.
|
||||
#[wasm_bindgen(js_name = removeStored)]
|
||||
pub fn remove_stored(path: String) -> bool {
|
||||
crate::files::STORE.remove_file(&path)
|
||||
}
|
||||
|
||||
/// Resolves once every change is written to browser storage.
|
||||
#[wasm_bindgen]
|
||||
pub fn flush() -> js_sys::Promise {
|
||||
crate::persist::flushed()
|
||||
}
|
||||
|
||||
/// Settle the promises whose replies arrived (called every frame).
|
||||
pub(crate) fn poll_replies() {
|
||||
PENDING.with(|p| {
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
//! 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, files, persist, worker};
|
||||
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)>;
|
||||
|
||||
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()}));
|
||||
}
|
||||
|
||||
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(|| Arc::new(worker::WorkerOffload) as Arc<dyn effectcraft_engine::offload::Offload>),
|
||||
..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();
|
||||
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();
|
||||
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));
|
||||
let mut app = EffectcraftApp::new(session);
|
||||
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);
|
||||
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(())
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Audio preview through Web Audio (`js/host.js`): an AudioWorklet (ScriptProcessorNode where
|
||||
//! worklets are unavailable) plays the blocks the UI mixes ahead
|
||||
//! ([`effectcraft_ui_egui::audio::AudioPlayback::pump`]).
|
||||
//!
|
||||
//! Every UI frame [`WebAudioOut::pump`] moves samples from the feed to the output so it stays
|
||||
//! ~150 ms ahead of what has played. A/V sync follows `AudioContext.currentTime`: frames played
|
||||
//! = (currentTime − start) × rate, so the device latency the playback clock subtracts is
|
||||
//! "handed over but not played yet" plus the context's output latency.
|
||||
//!
|
||||
//! Browsers keep the AudioContext suspended until the user interacts with the page; the host
|
||||
//! resumes it on the first pointer or key event.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_ui_egui::audio::{AudioDevice, AudioFeed};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(module = "/js/host.js")]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = audioInstallUnlock)]
|
||||
pub fn install_unlock();
|
||||
#[wasm_bindgen(js_name = audioState)]
|
||||
pub fn state() -> JsValue;
|
||||
#[wasm_bindgen(js_name = audioSampleRate)]
|
||||
fn sample_rate() -> f64;
|
||||
#[wasm_bindgen(js_name = audioStart)]
|
||||
fn audio_start(worklet_url: &str) -> js_sys::Promise;
|
||||
#[wasm_bindgen(js_name = audioPush)]
|
||||
fn audio_push(samples: js_sys::Float32Array);
|
||||
#[wasm_bindgen(js_name = audioPlayed)]
|
||||
fn audio_played() -> f64;
|
||||
#[wasm_bindgen(js_name = audioOutputLatency)]
|
||||
fn audio_output_latency() -> f64;
|
||||
#[wasm_bindgen(js_name = audioStop)]
|
||||
fn audio_stop();
|
||||
}
|
||||
|
||||
/// How far ahead of the playhead the output is kept fed.
|
||||
const LEAD_SECONDS: f64 = 0.15;
|
||||
|
||||
pub struct WebAudioOut {
|
||||
rate: u32,
|
||||
feed: Option<Arc<AudioFeed>>,
|
||||
/// Sample frames handed to the output since start.
|
||||
posted: u64,
|
||||
buf: Vec<f32>,
|
||||
}
|
||||
|
||||
/// The Settings ▸ Audio factory: Web Audio's default output (`None` without Web Audio).
|
||||
pub fn open(_: &effectcraft_ui_egui::audio::AudioOutput) -> Option<Box<dyn AudioDevice>> {
|
||||
let rate = sample_rate();
|
||||
(rate > 0.0).then(|| Box::new(WebAudioOut { rate: rate as u32, feed: None, posted: 0, buf: Vec::new() }) as Box<dyn AudioDevice>)
|
||||
}
|
||||
|
||||
fn worklet_url() -> String {
|
||||
crate::page_url("audio-worklet.js")
|
||||
}
|
||||
|
||||
impl AudioDevice for WebAudioOut {
|
||||
fn sample_rate(&self) -> u32 {
|
||||
self.rate
|
||||
}
|
||||
|
||||
fn start(&mut self, feed: Arc<AudioFeed>) -> Result<(), String> {
|
||||
self.feed = Some(feed);
|
||||
self.posted = 0;
|
||||
let p = audio_start(&worklet_url());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
if let Err(e) = wasm_bindgen_futures::JsFuture::from(p).await {
|
||||
log::warn!("audio output: {e:?}");
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stop(&mut self) {
|
||||
self.feed = None;
|
||||
audio_stop();
|
||||
}
|
||||
|
||||
fn latency_frames(&self) -> u64 {
|
||||
let played = audio_played() as u64;
|
||||
self.posted.saturating_sub(played) + audio_output_latency() as u64
|
||||
}
|
||||
|
||||
fn pump(&mut self) {
|
||||
let Some(feed) = &self.feed else { return };
|
||||
let played = audio_played() as u64;
|
||||
let lead = (LEAD_SECONDS * self.rate as f64) as u64;
|
||||
let want = (played + lead).saturating_sub(self.posted) as usize;
|
||||
// Only real samples: the output plays silence by itself when it runs dry.
|
||||
let n = want.min(feed.queued_frames());
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
self.buf.resize(n * 2, 0.0);
|
||||
feed.pull(&mut self.buf);
|
||||
audio_push(js_sys::Float32Array::from(&self.buf[..]));
|
||||
self.posted += n as u64;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WebAudioOut {
|
||||
fn drop(&mut self) {
|
||||
if self.feed.is_some() {
|
||||
audio_stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +1,55 @@
|
||||
//! 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).
|
||||
//! Files in the browser: a virtual file table in place of the file system, `<input type=file>`
|
||||
//! pickers for Open/Import, and downloads for what the user saves and 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`]).
|
||||
//! The table is the persistent [`Store`]'s file namespace: imported media, saved projects and
|
||||
//! auto-saves survive reloads ([`crate::persist`]); render outputs don't (they download).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, LazyLock, 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);
|
||||
use crate::store::Store;
|
||||
|
||||
/// The page's store (each worker has its own, unpersisted).
|
||||
pub static STORE: LazyLock<Arc<Store>> = LazyLock::new(|| {
|
||||
let s = Arc::new(Store::default());
|
||||
let _ = s.clock.set(js_sys::Date::now);
|
||||
s
|
||||
});
|
||||
/// Render outputs written since the last [`flush_downloads`].
|
||||
static OUTPUTS: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
/// Saves that write to browser storage only (no download): `api.saveToBrowser`.
|
||||
static QUIET: 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.
|
||||
/// Store a file in the table (persisted).
|
||||
pub fn put(path: &str, data: Arc<[u8]>) {
|
||||
with_files(|m| m.insert(path.to_string(), data));
|
||||
STORE.put_file(path, data, true);
|
||||
}
|
||||
|
||||
pub fn get(path: &str) -> Option<Arc<[u8]>> {
|
||||
with_files(|m| m.get(path).cloned())
|
||||
STORE.file(path)
|
||||
}
|
||||
|
||||
/// `(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
|
||||
STORE.files().into_iter().map(|(p, n, _, _)| (p, n)).collect()
|
||||
}
|
||||
|
||||
/// The next write of `path` goes to browser storage only (no download).
|
||||
pub fn save_quietly(path: &str) {
|
||||
QUIET.lock().unwrap_or_else(|e| e.into_inner()).push(path.to_string());
|
||||
}
|
||||
|
||||
/// A rendered file (from the page's exporter or a worker): kept in the table unpersisted and
|
||||
/// downloaded at the next [`flush_downloads`].
|
||||
pub fn add_output(path: &str, data: Arc<[u8]>) {
|
||||
STORE.put_file(path, data, false);
|
||||
OUTPUTS.lock().unwrap_or_else(|e| e.into_inner()).push(path.to_string());
|
||||
}
|
||||
|
||||
/// The table path for a picked/dropped file name.
|
||||
@@ -85,7 +101,8 @@ pub fn download(name: &str, data: &[u8]) -> Result<(), JsValue> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Engine file access (projects): reads from the table; writes go to the table and download.
|
||||
/// Engine file access (projects): reads from the table; writes go to the table (persisted) and
|
||||
/// download, except [`save_quietly`] saves.
|
||||
pub struct WebServices;
|
||||
|
||||
impl Services for WebServices {
|
||||
@@ -96,6 +113,15 @@ impl Services for WebServices {
|
||||
}
|
||||
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
put(path, data.into());
|
||||
let quiet = {
|
||||
let mut q = QUIET.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let n = q.len();
|
||||
q.retain(|p| p != path);
|
||||
q.len() != n
|
||||
};
|
||||
if quiet || crate::is_worker() {
|
||||
return Ok(());
|
||||
}
|
||||
download(file_name(path), data).map_err(|e| std::io::Error::other(format!("download failed: {e:?}")))
|
||||
}
|
||||
}
|
||||
@@ -116,10 +142,7 @@ impl effectcraft_engine::Importer for WebImporter {
|
||||
/// 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());
|
||||
})
|
||||
Arc::new(|path: &str, data: Vec<u8>| add_output(path, data.into()))
|
||||
}
|
||||
|
||||
/// Download the render outputs written since the last call.
|
||||
|
||||
+19
-163
@@ -6,175 +6,31 @@
|
||||
//!
|
||||
//! | Desktop | Web |
|
||||
//! |---|---|
|
||||
//! | file system (`FsServices`, media reads, export writes) | in-memory file table ([`files`]) |
|
||||
//! | file system (`FsServices`, media reads, export writes) | a virtual file table ([`files`]) persisted to the Origin Private File System / IndexedDB ([`persist`], [`store`]) |
|
||||
//! | config directory (settings, shortcuts, recent projects, auto-saves) | the same store ([`store::WebConfig`]) |
|
||||
//! | 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) |
|
||||
//! | Render Queue / analysis threads | Web Workers running their own engine instance ([`worker`]) |
|
||||
//! | cpal audio output | Web Audio: an AudioWorklet fed from the preview mixdown ([`audio`]) |
|
||||
//! | 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")]
|
||||
//! [`store`] is portable (unit-tested natively); everything else is `wasm32`-only.
|
||||
|
||||
pub mod store;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod api;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod app;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod audio;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod files;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod persist;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod worker;
|
||||
|
||||
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, scripting, 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),
|
||||
script: Some(effectcraft_host::script::runner),
|
||||
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(())
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use app::*;
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Persistence: the [`crate::store::Store`] mirrored to the browser's storage (the Origin
|
||||
//! Private File System, IndexedDB where OPFS can't write, memory as a last resort; `js/host.js`).
|
||||
//!
|
||||
//! - [`load`] (before the app starts) opens the storage and reads every entry into the mirror.
|
||||
//! - Changes flush in the background ([`schedule_flush`], wired as the store's change hook):
|
||||
//! one write per changed key, in order, with nothing written twice concurrently.
|
||||
//! - The session snapshot ([`snapshot`]) records the open project and editor state every second
|
||||
//! or so while it changes, so a reload (or a crash) comes back to where the user was
|
||||
//! ([`restore`]).
|
||||
|
||||
use std::cell::Cell;
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::config::ConfigStore;
|
||||
use effectcraft_engine::project::Project;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
use crate::files::STORE;
|
||||
use crate::store::{SNAPSHOT, SNAPSHOT_META, SnapshotMeta, WebConfig};
|
||||
|
||||
#[wasm_bindgen(module = "/js/host.js")]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = storeOpen)]
|
||||
fn store_open(prefer: &str) -> js_sys::Promise;
|
||||
#[wasm_bindgen(js_name = storeLoadAll)]
|
||||
fn store_load_all() -> js_sys::Promise;
|
||||
#[wasm_bindgen(js_name = storePut)]
|
||||
fn store_put(key: &str, data: js_sys::Uint8Array, modified: f64) -> js_sys::Promise;
|
||||
#[wasm_bindgen(js_name = storeDelete)]
|
||||
fn store_delete(key: &str) -> js_sys::Promise;
|
||||
#[wasm_bindgen(js_name = storeEstimate)]
|
||||
pub(crate) fn store_estimate() -> js_sys::Promise;
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static FLUSHING: Cell<bool> = const { Cell::new(false) };
|
||||
static BACKEND: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
|
||||
static LAST_ERROR: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
|
||||
static FLUSH_WAITERS: std::cell::RefCell<Vec<js_sys::Function>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
static SNAP: Cell<(u64, f64)> = const { Cell::new((u64::MAX, 0.0)) };
|
||||
static LAST_META: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
|
||||
}
|
||||
|
||||
/// The storage in use: `opfs`, `indexeddb` or `memory`.
|
||||
pub fn backend() -> String {
|
||||
BACKEND.with(|b| b.borrow().clone())
|
||||
}
|
||||
|
||||
/// The last storage error (quota exceeded…), if any.
|
||||
pub fn last_error() -> Option<String> {
|
||||
LAST_ERROR.with(|e| e.borrow().clone())
|
||||
}
|
||||
|
||||
fn js_err(e: JsValue) -> String {
|
||||
e.as_string().or_else(|| js_sys::Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_else(|| format!("{e:?}"))
|
||||
}
|
||||
|
||||
/// Open the browser storage and read everything into the store. `prefer` forces a backend
|
||||
/// (`?storage=indexeddb` / `memory`; empty = OPFS, then IndexedDB). Returns the entry count.
|
||||
pub async fn load(prefer: &str) -> Result<usize, String> {
|
||||
let name = JsFuture::from(store_open(prefer)).await.map_err(js_err)?;
|
||||
BACKEND.with(|b| *b.borrow_mut() = name.as_string().unwrap_or_default());
|
||||
let all = JsFuture::from(store_load_all()).await.map_err(js_err)?;
|
||||
let all: js_sys::Array = all.dyn_into().map_err(js_err)?;
|
||||
let n = all.length() as usize;
|
||||
{
|
||||
let mut m = STORE.lock();
|
||||
for e in all.iter() {
|
||||
let get = |k: &str| js_sys::Reflect::get(&e, &k.into()).unwrap_or(JsValue::UNDEFINED);
|
||||
let Some(key) = get("key").as_string() else { continue };
|
||||
let data: js_sys::Uint8Array = match get("data").dyn_into() {
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
};
|
||||
m.load(&key, data.to_vec().into(), get("modified").as_f64().unwrap_or(0.0));
|
||||
}
|
||||
}
|
||||
let _ = STORE.on_change.set(schedule_flush);
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Write pending changes to the browser storage (in the background; one flush at a time).
|
||||
pub fn schedule_flush() {
|
||||
if FLUSHING.with(|f| f.replace(true)) {
|
||||
return; // the running flush picks the new changes up
|
||||
}
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
loop {
|
||||
let pending = STORE.lock().take_pending();
|
||||
if pending.is_empty() {
|
||||
break;
|
||||
}
|
||||
for p in pending {
|
||||
let r = match &p.data {
|
||||
Some(d) => JsFuture::from(store_put(&p.key, js_sys::Uint8Array::from(&d[..]), p.modified)).await,
|
||||
None => JsFuture::from(store_delete(&p.key)).await,
|
||||
};
|
||||
if let Err(e) = r {
|
||||
let msg = format!("saving {} to browser storage failed: {}", p.key, js_err(e));
|
||||
log::warn!("{msg}");
|
||||
LAST_ERROR.with(|l| *l.borrow_mut() = Some(msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
FLUSHING.with(|f| f.set(false));
|
||||
for w in FLUSH_WAITERS.with(|w| std::mem::take(&mut *w.borrow_mut())) {
|
||||
let _ = w.call0(&JsValue::NULL);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A promise that resolves once every change so far is in the browser storage.
|
||||
pub fn flushed() -> js_sys::Promise {
|
||||
js_sys::Promise::new(&mut |resolve, _| {
|
||||
if !FLUSHING.with(|f| f.get()) && !STORE.lock().has_pending() {
|
||||
let _ = resolve.call0(&JsValue::NULL);
|
||||
} else {
|
||||
FLUSH_WAITERS.with(|w| w.borrow_mut().push(resolve));
|
||||
schedule_flush();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The config store for the session.
|
||||
pub fn config() -> WebConfig {
|
||||
WebConfig::new(STORE.clone())
|
||||
}
|
||||
|
||||
/// Record the open project and editor state when they changed (checked at most every
|
||||
/// `min_secs`).
|
||||
pub fn snapshot(s: &Session, now: f64, min_secs: f64) {
|
||||
let (rev, at) = SNAP.with(|c| c.get());
|
||||
if now - at < min_secs {
|
||||
return;
|
||||
}
|
||||
SNAP.with(|c| c.set((rev, now)));
|
||||
let meta = SnapshotMeta { path: s.path.clone(), dirty: s.is_dirty(), state: serde_json::to_value(&s.state).ok(), saved: 0.0 };
|
||||
let key = serde_json::to_string(&meta).unwrap_or_default();
|
||||
if rev == s.revision && LAST_META.with(|m| *m.borrow() == key) {
|
||||
return;
|
||||
}
|
||||
let c = config();
|
||||
if rev != s.revision {
|
||||
let _ = c.write(SNAPSHOT, &s.project.to_json());
|
||||
}
|
||||
let _ = c.write(SNAPSHOT_META, &serde_json::to_string(&SnapshotMeta { saved: js_sys::Date::now(), ..meta }).unwrap_or_default());
|
||||
SNAP.with(|c| c.set((s.revision, now)));
|
||||
LAST_META.with(|m| *m.borrow_mut() = key);
|
||||
}
|
||||
|
||||
/// Reopen the last visit's project and editor state. Returns whether there was one.
|
||||
pub fn restore(s: &mut Session) -> bool {
|
||||
let c = config();
|
||||
let (Some(json), Some(meta)) = (c.read(SNAPSHOT), c.read(SNAPSHOT_META)) else { return false };
|
||||
let meta: SnapshotMeta = serde_json::from_str(&meta).unwrap_or_default();
|
||||
let p = match Project::from_json(&json) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
log::warn!("restoring the last session: {e}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
s.replace_project(p, meta.path.clone());
|
||||
if let Some(st) = meta.state.and_then(|v| serde_json::from_value(v).ok()) {
|
||||
s.state = st;
|
||||
}
|
||||
if meta.dirty {
|
||||
// Unsaved changes stay unsaved.
|
||||
s.bump();
|
||||
}
|
||||
SNAP.with(|c| c.set((s.revision, 0.0)));
|
||||
log::info!("restored the last session ({})", meta.path.as_deref().unwrap_or("untitled"));
|
||||
true
|
||||
}
|
||||
|
||||
/// Register persisted media with the media pool (so restored projects find their footage).
|
||||
pub fn register_media(pool: &effectcraft_media::MediaPool) -> usize {
|
||||
let mut n = 0;
|
||||
for (path, _, _, persisted) in STORE.files() {
|
||||
if persisted
|
||||
&& !path.to_ascii_lowercase().ends_with(".ecproj")
|
||||
&& let Some(d) = STORE.file(&path)
|
||||
{
|
||||
pool.add_bytes(&path, d);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
//! What the browser keeps between visits: the in-memory side of the persistent store.
|
||||
//!
|
||||
//! The engine's storage traits are synchronous ([`ConfigStore`], [`FileOps`],
|
||||
//! [`effectcraft_engine::Services`]); the browser's storage (the Origin Private File System, or
|
||||
//! IndexedDB where OPFS can't write) is asynchronous. So the app works on a [`Mirror`]: every
|
||||
//! stored entry is loaded into memory before the app starts, reads and writes hit the mirror
|
||||
//! synchronously, and writes queue up as pending operations that the web host flushes to the
|
||||
//! browser in the background (coalesced per key, in order).
|
||||
//!
|
||||
//! Keys are namespaced:
|
||||
//!
|
||||
//! | Key | What |
|
||||
//! |---|---|
|
||||
//! | `config/<name>` | settings (`prefs.json`), shortcut presets, the recovery sentinel, the session snapshot |
|
||||
//! | `files<path>` | the virtual file table: imported media, saved projects, auto-saves (`/EffectCraft Auto-Save/…`) |
|
||||
//!
|
||||
//! Render outputs go into the file table unpersisted (they download instead).
|
||||
//!
|
||||
//! This module is plain Rust (no browser APIs) so it is unit-tested natively.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use effectcraft_engine::config::{ConfigStore, FileOps};
|
||||
|
||||
/// Prefix of configuration entries.
|
||||
pub const CONFIG: &str = "config/";
|
||||
/// Prefix of file-table entries (followed by the absolute path, `files/clip.mp4`).
|
||||
pub const FILES: &str = "files";
|
||||
/// The session snapshot (project + editor state), written while the app runs and restored
|
||||
/// on the next visit.
|
||||
pub const SNAPSHOT: &str = "session.ecproj";
|
||||
/// Its metadata ([`SnapshotMeta`]).
|
||||
pub const SNAPSHOT_META: &str = "session.json";
|
||||
|
||||
/// One stored blob.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Entry {
|
||||
pub data: Arc<[u8]>,
|
||||
/// Milliseconds since the Unix epoch.
|
||||
pub modified: f64,
|
||||
/// Written to the browser's storage (render outputs are not).
|
||||
pub persist: bool,
|
||||
}
|
||||
|
||||
/// A queued write for the browser storage: `data` = `None` deletes the key.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Pending {
|
||||
pub key: String,
|
||||
pub data: Option<Arc<[u8]>>,
|
||||
pub modified: f64,
|
||||
}
|
||||
|
||||
/// The in-memory copy of the store plus the writes not yet flushed.
|
||||
#[derive(Default)]
|
||||
pub struct Mirror {
|
||||
entries: BTreeMap<String, Entry>,
|
||||
/// Keys changed since the last [`Mirror::take_pending`], in first-change order.
|
||||
dirty: Vec<String>,
|
||||
}
|
||||
|
||||
impl Mirror {
|
||||
/// An entry read from the browser's storage at startup (nothing to flush).
|
||||
pub fn load(&mut self, key: &str, data: Arc<[u8]>, modified: f64) {
|
||||
self.entries.insert(key.to_string(), Entry { data, modified, persist: true });
|
||||
}
|
||||
|
||||
pub fn put(&mut self, key: &str, data: Arc<[u8]>, modified: f64, persist: bool) {
|
||||
let was_persisted = self.entries.get(key).is_some_and(|e| e.persist);
|
||||
self.entries.insert(key.to_string(), Entry { data, modified, persist });
|
||||
if persist || was_persisted {
|
||||
self.touch(key);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, key: &str) -> bool {
|
||||
match self.entries.remove(key) {
|
||||
Some(e) => {
|
||||
if e.persist {
|
||||
self.touch(key);
|
||||
}
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn touch(&mut self, key: &str) {
|
||||
if !self.dirty.iter().any(|k| k == key) {
|
||||
self.dirty.push(key.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<&Entry> {
|
||||
self.entries.get(key)
|
||||
}
|
||||
|
||||
/// Entries under `prefix`: (key, size, modified, persisted).
|
||||
pub fn list(&self, prefix: &str) -> Vec<(String, usize, f64, bool)> {
|
||||
self.entries
|
||||
.range(prefix.to_string()..)
|
||||
.take_while(|(k, _)| k.starts_with(prefix))
|
||||
.map(|(k, e)| (k.clone(), e.data.len(), e.modified, e.persist))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The writes to flush, one per changed key with its latest state (a put, or a delete when
|
||||
/// the key is gone or no longer persisted).
|
||||
pub fn take_pending(&mut self) -> Vec<Pending> {
|
||||
std::mem::take(&mut self.dirty)
|
||||
.into_iter()
|
||||
.map(|key| match self.entries.get(&key) {
|
||||
Some(e) if e.persist => Pending { data: Some(e.data.clone()), modified: e.modified, key },
|
||||
_ => Pending { key, data: None, modified: 0.0 },
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn has_pending(&self) -> bool {
|
||||
!self.dirty.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// The key of a file-table path (`/a/b.mov` → `files/a/b.mov`).
|
||||
pub fn file_key(path: &str) -> String {
|
||||
if path.starts_with('/') { format!("{FILES}{path}") } else { format!("{FILES}/{path}") }
|
||||
}
|
||||
|
||||
/// The path of a file-table key.
|
||||
pub fn key_path(key: &str) -> Option<&str> {
|
||||
key.strip_prefix(FILES).filter(|p| p.starts_with('/'))
|
||||
}
|
||||
|
||||
/// The persistent store: a [`Mirror`] and a hook that wakes the flusher after changes.
|
||||
#[derive(Default)]
|
||||
pub struct Store {
|
||||
pub mirror: Mutex<Mirror>,
|
||||
/// Called after every change that needs flushing (the web host schedules a flush).
|
||||
pub on_change: OnceLock<fn()>,
|
||||
/// Clock in milliseconds since the Unix epoch (tests pin it).
|
||||
pub clock: OnceLock<fn() -> f64>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn lock(&self) -> std::sync::MutexGuard<'_, Mirror> {
|
||||
self.mirror.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn now(&self) -> f64 {
|
||||
self.clock.get().map_or(0.0, |f| f())
|
||||
}
|
||||
|
||||
fn changed(&self) {
|
||||
if let Some(f) = self.on_change.get() {
|
||||
f();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- file table
|
||||
|
||||
/// Store a file under `path` (persisted unless `persist` is false: render outputs).
|
||||
pub fn put_file(&self, path: &str, data: Arc<[u8]>, persist: bool) {
|
||||
let now = self.now();
|
||||
self.lock().put(&file_key(path), data, now, persist);
|
||||
if persist {
|
||||
self.changed();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn file(&self, path: &str) -> Option<Arc<[u8]>> {
|
||||
self.lock().get(&file_key(path)).map(|e| e.data.clone())
|
||||
}
|
||||
|
||||
pub fn remove_file(&self, path: &str) -> bool {
|
||||
let r = self.lock().remove(&file_key(path));
|
||||
self.changed();
|
||||
r
|
||||
}
|
||||
|
||||
/// Every file: (path, size, modified ms, persisted).
|
||||
pub fn files(&self) -> Vec<(String, usize, f64, bool)> {
|
||||
self.lock().list(FILES).into_iter().filter_map(|(k, n, m, p)| Some((key_path(&k)?.to_string(), n, m, p))).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings and friends ([`ConfigStore`]) in the store; auto-saves ([`FileOps`]) in its file
|
||||
/// table, under `/` (untitled projects auto-save to `/EffectCraft Auto-Save/`).
|
||||
pub struct WebConfig {
|
||||
store: Arc<Store>,
|
||||
files: WebFiles,
|
||||
}
|
||||
|
||||
impl WebConfig {
|
||||
pub fn new(store: Arc<Store>) -> WebConfig {
|
||||
WebConfig { files: WebFiles(store.clone()), store }
|
||||
}
|
||||
}
|
||||
|
||||
/// [`FileOps`] on the store's file table.
|
||||
pub struct WebFiles(pub Arc<Store>);
|
||||
|
||||
impl ConfigStore for WebConfig {
|
||||
fn read(&self, name: &str) -> Option<String> {
|
||||
let m = self.store.lock();
|
||||
m.get(&format!("{CONFIG}{name}")).map(|e| String::from_utf8_lossy(&e.data).into_owned())
|
||||
}
|
||||
fn write(&self, name: &str, data: &str) -> std::io::Result<()> {
|
||||
let now = self.store.now();
|
||||
self.store.lock().put(&format!("{CONFIG}{name}"), data.as_bytes().into(), now, true);
|
||||
self.store.changed();
|
||||
Ok(())
|
||||
}
|
||||
fn remove(&self, name: &str) -> std::io::Result<()> {
|
||||
self.store.lock().remove(&format!("{CONFIG}{name}"));
|
||||
self.store.changed();
|
||||
Ok(())
|
||||
}
|
||||
fn dir(&self) -> Option<PathBuf> {
|
||||
Some(PathBuf::from("/"))
|
||||
}
|
||||
fn files(&self) -> &dyn FileOps {
|
||||
&self.files
|
||||
}
|
||||
}
|
||||
|
||||
fn path_str(p: &Path) -> String {
|
||||
p.to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
|
||||
impl FileOps for WebFiles {
|
||||
fn list(&self, dir: &Path) -> Vec<(String, std::time::SystemTime)> {
|
||||
let dir = path_str(dir);
|
||||
let dir = dir.trim_end_matches('/');
|
||||
self.0
|
||||
.files()
|
||||
.into_iter()
|
||||
.filter_map(|(p, _, m, _)| {
|
||||
let (parent, name) = p.rsplit_once('/')?;
|
||||
(parent == dir).then(|| (name.to_string(), std::time::UNIX_EPOCH + std::time::Duration::from_secs_f64(m.max(0.0) / 1000.0)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn write(&self, path: &Path, data: &[u8]) -> std::io::Result<()> {
|
||||
self.0.put_file(&path_str(path), data.into(), true);
|
||||
Ok(())
|
||||
}
|
||||
fn remove(&self, path: &Path) -> std::io::Result<()> {
|
||||
if self.0.remove_file(&path_str(path)) { Ok(()) } else { Err(std::io::Error::new(std::io::ErrorKind::NotFound, path_str(path))) }
|
||||
}
|
||||
fn is_file(&self, path: &Path) -> bool {
|
||||
self.0.file(&path_str(path)).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// What the session snapshot records besides the project.
|
||||
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct SnapshotMeta {
|
||||
/// The project's file (None = untitled).
|
||||
pub path: Option<String>,
|
||||
/// Unsaved changes when the snapshot was taken.
|
||||
pub dirty: bool,
|
||||
/// Viewer, timeline and selection state.
|
||||
pub state: Option<serde_json::Value>,
|
||||
/// Milliseconds since the Unix epoch.
|
||||
pub saved: f64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn store() -> Arc<Store> {
|
||||
let s = Arc::new(Store::default());
|
||||
let _ = s.clock.set(|| 1_000.0);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_coalesce_per_key_in_order() {
|
||||
let mut m = Mirror::default();
|
||||
m.put("config/a", Arc::from(&b"1"[..]), 1.0, true);
|
||||
m.put("files/x.png", Arc::from(&b"px"[..]), 2.0, true);
|
||||
m.put("config/a", Arc::from(&b"2"[..]), 3.0, true);
|
||||
let p = m.take_pending();
|
||||
assert_eq!(p.len(), 2);
|
||||
assert_eq!((p[0].key.as_str(), p[0].data.as_deref()), ("config/a", Some(&b"2"[..])));
|
||||
assert_eq!(p[0].modified, 3.0);
|
||||
assert_eq!(p[1].key, "files/x.png");
|
||||
assert!(!m.has_pending() && m.take_pending().is_empty());
|
||||
// Delete after put: one delete.
|
||||
m.put("files/y", Arc::from(&b"y"[..]), 4.0, true);
|
||||
m.remove("files/y");
|
||||
assert_eq!(m.take_pending(), vec![Pending { key: "files/y".into(), data: None, modified: 0.0 }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpersisted_entries_never_flush_and_loaded_ones_are_clean() {
|
||||
let mut m = Mirror::default();
|
||||
m.load("files/old.mov", Arc::from(&b"m"[..]), 5.0);
|
||||
m.put("files/render.gif", Arc::from(&b"g"[..]), 6.0, false);
|
||||
assert!(!m.has_pending());
|
||||
assert!(!m.remove("files/none"));
|
||||
m.remove("files/render.gif");
|
||||
assert!(!m.has_pending());
|
||||
// Overwriting a persisted file with an unpersisted one deletes the stored copy.
|
||||
m.put("files/old.mov", Arc::from(&b"n"[..]), 7.0, false);
|
||||
assert_eq!(m.take_pending()[0].data, None);
|
||||
assert_eq!(m.list("files").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_and_paths_round_trip() {
|
||||
assert_eq!(file_key("/a b/c.mov"), "files/a b/c.mov");
|
||||
assert_eq!(file_key("c.mov"), "files/c.mov");
|
||||
assert_eq!(key_path("files/a b/c.mov"), Some("/a b/c.mov"));
|
||||
assert_eq!(key_path("config/prefs.json"), None);
|
||||
let s = store();
|
||||
s.put_file("/clip.mp4", Arc::from(&b"abc"[..]), true);
|
||||
s.put_file("/out.gif", Arc::from(&b"g"[..]), false);
|
||||
let f = s.files();
|
||||
assert_eq!(f, vec![("/clip.mp4".to_string(), 3, 1000.0, true), ("/out.gif".to_string(), 1, 1000.0, false)]);
|
||||
assert_eq!(s.file("/clip.mp4").as_deref(), Some(&b"abc"[..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_store_and_file_ops() {
|
||||
let s = store();
|
||||
let c = WebConfig::new(s.clone());
|
||||
assert_eq!(c.read("prefs.json"), None);
|
||||
c.write("prefs.json", "{\"a\":1}").unwrap();
|
||||
assert_eq!(c.read("prefs.json").as_deref(), Some("{\"a\":1}"));
|
||||
c.remove("prefs.json").unwrap();
|
||||
assert_eq!(c.read("prefs.json"), None);
|
||||
// Config entries are not files.
|
||||
c.write("session.lock", "{}").unwrap();
|
||||
assert!(s.files().is_empty());
|
||||
let fs = c.files();
|
||||
fs.write(Path::new("/EffectCraft Auto-Save/P auto-save 1.ecproj"), b"{}").unwrap();
|
||||
fs.write(Path::new("/EffectCraft Auto-Save/P auto-save 2.ecproj"), b"{}").unwrap();
|
||||
fs.write(Path::new("/other.ecproj"), b"{}").unwrap();
|
||||
let mut names: Vec<String> = fs.list(Path::new("/EffectCraft Auto-Save")).into_iter().map(|e| e.0).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["P auto-save 1.ecproj", "P auto-save 2.ecproj"]);
|
||||
assert!(fs.is_file(Path::new("/other.ecproj")));
|
||||
fs.remove(Path::new("/other.ecproj")).unwrap();
|
||||
assert!(!fs.is_file(Path::new("/other.ecproj")));
|
||||
assert!(fs.remove(Path::new("/other.ecproj")).is_err());
|
||||
let pending = s.lock().take_pending();
|
||||
let keys: Vec<&str> = pending.iter().map(|p| p.key.as_str()).collect();
|
||||
assert_eq!(
|
||||
keys,
|
||||
[
|
||||
"config/prefs.json",
|
||||
"config/session.lock",
|
||||
"files/EffectCraft Auto-Save/P auto-save 1.ecproj",
|
||||
"files/EffectCraft Auto-Save/P auto-save 2.ecproj",
|
||||
"files/other.ecproj"
|
||||
]
|
||||
);
|
||||
assert_eq!(pending[0].data, None, "removed");
|
||||
assert_eq!(pending[4].data, None, "removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autosave_and_recovery_run_in_the_browser_store() {
|
||||
use effectcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
let s = store();
|
||||
let mut a = Session { config: Some(Arc::new(WebConfig::new(s.clone()))), ..Default::default() };
|
||||
a.load_settings();
|
||||
assert!(a.begin_recovery().is_none());
|
||||
a.execute("file.newProject", json!({})).unwrap();
|
||||
a.execute("comp.new", json!({"name": "C"})).unwrap();
|
||||
// Untitled: the auto-save lands in the store's file table.
|
||||
let p = a.autosave_now().unwrap();
|
||||
assert_eq!(p, "/EffectCraft Auto-Save/Untitled Project auto-save 1.ecproj");
|
||||
assert!(s.file(&p).is_some());
|
||||
// The tab closes without a clean exit: the next visit offers the auto-save.
|
||||
let mut b = Session { config: Some(Arc::new(WebConfig::new(s.clone()))), ..Default::default() };
|
||||
b.load_settings();
|
||||
let r = b.begin_recovery().expect("recovery");
|
||||
assert!(r.autosave.is_some());
|
||||
// Increment and Save sees the store's files.
|
||||
let fs = WebConfig::new(s.clone());
|
||||
fs.files().write(Path::new("/A.ecproj"), b"{}").unwrap();
|
||||
let next = effectcraft_engine::autosave::increment_path("/A.ecproj", |p| fs.files().is_file(Path::new(p)));
|
||||
assert_eq!(next, "/A 2.ecproj");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_meta_round_trips() {
|
||||
let m = SnapshotMeta { path: Some("/p.ecproj".into()), dirty: true, state: Some(serde_json::json!({"activeComp": 3})), saved: 12.5 };
|
||||
let t = serde_json::to_string(&m).unwrap();
|
||||
assert_eq!(serde_json::from_str::<SnapshotMeta>(&t).unwrap(), m);
|
||||
assert_eq!(serde_json::from_str::<SnapshotMeta>("{}").unwrap(), SnapshotMeta::default());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Renders and analyses in Web Workers ([`effectcraft_engine::offload`]).
|
||||
//!
|
||||
//! **Page side** — [`WorkerOffload`], the session's [`Offload`]: serializes the job, ships the
|
||||
//! footage files it reads and the request to a worker from the host's pool (`js/host.js`
|
||||
//! `workerRun`), and turns the worker's messages back into [`WorkerReply`]s in the job's
|
||||
//! [`Inbox`]. Rendered files come back as their bytes and download like any render.
|
||||
//!
|
||||
//! **Worker side** — `web/worker.js` instantiates the same wasm module (the page's compiled
|
||||
//! `WebAssembly.Module`, so nothing is compiled twice) and calls [`worker_init`], then
|
||||
//! [`worker_file`] per footage file and [`worker_job`] per job: a plain engine session (footage
|
||||
//! from memory, expressions, the exporter) runs it blocking and posts replies as it goes. Each
|
||||
//! worker has its own memory: no shared-memory threads, so this works on stable Rust.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::offload::{Inbox, Offload, WorkerReply, WorkerRequest};
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(module = "/js/host.js")]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = workerRun)]
|
||||
fn worker_run(id: f64, json: &str, files: js_sys::Array, base: &str, on_message: &js_sys::Function);
|
||||
#[wasm_bindgen(js_name = workerCancel)]
|
||||
fn worker_cancel(id: f64);
|
||||
#[wasm_bindgen(js_name = workerStats)]
|
||||
pub fn stats() -> JsValue;
|
||||
}
|
||||
|
||||
type OnMessage = Closure<dyn FnMut(String, JsValue, JsValue, JsValue)>;
|
||||
|
||||
thread_local! {
|
||||
/// Message handlers of running jobs (dropped when the job ends).
|
||||
static HANDLERS: RefCell<HashMap<u64, OnMessage>> = RefCell::new(HashMap::new());
|
||||
/// The worker's session and its media pool.
|
||||
static SESSION: RefCell<Option<Session>> = const { RefCell::new(None) };
|
||||
static POOL: RefCell<Option<Arc<effectcraft_media::MediaPool>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// The page's offload: one worker per running job (idle workers are reused).
|
||||
pub struct WorkerOffload;
|
||||
|
||||
impl Offload for WorkerOffload {
|
||||
fn start(&self, req: WorkerRequest, inbox: Arc<Inbox>) -> Result<(), String> {
|
||||
let id = req.id;
|
||||
let files = js_sys::Array::new();
|
||||
for p in &req.files {
|
||||
if let Some(d) = crate::files::get(p) {
|
||||
files.push(&js_sys::Array::of2(&JsValue::from_str(p), &js_sys::Uint8Array::from(&d[..])));
|
||||
}
|
||||
}
|
||||
let json = serde_json::to_string(&req).map_err(|e| e.to_string())?;
|
||||
let on: OnMessage = Closure::new(move |kind: String, json: JsValue, path: JsValue, bytes: JsValue| {
|
||||
match kind.as_str() {
|
||||
"reply" => {
|
||||
let Some(t) = json.as_string() else { return };
|
||||
match serde_json::from_str::<WorkerReply>(&t) {
|
||||
Ok(r) => {
|
||||
let done = matches!(r, WorkerReply::Done);
|
||||
inbox.push(r);
|
||||
if done {
|
||||
end(id);
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("worker reply: {e}"),
|
||||
}
|
||||
}
|
||||
"file" => {
|
||||
if let (Some(p), Ok(b)) = (path.as_string(), bytes.dyn_into::<js_sys::Uint8Array>()) {
|
||||
crate::files::add_output(&p, b.to_vec().into());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
inbox.push(WorkerReply::Failed { error: json.as_string().unwrap_or_else(|| "worker failed".into()) });
|
||||
inbox.push(WorkerReply::Done);
|
||||
end(id);
|
||||
}
|
||||
}
|
||||
crate::repaint();
|
||||
});
|
||||
worker_run(id as f64, &json, files, &crate::page_url(""), on.as_ref().unchecked_ref());
|
||||
HANDLERS.with(|h| h.borrow_mut().insert(id, on));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cancel(&self, id: u64) {
|
||||
worker_cancel(id as f64);
|
||||
end(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop a job's handler (after the current call returns).
|
||||
fn end(id: u64) {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
HANDLERS.with(|h| h.borrow_mut().remove(&id));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- worker side
|
||||
|
||||
fn post(msg: &JsValue, transfer: Option<&js_sys::Array>) {
|
||||
let scope: web_sys::DedicatedWorkerGlobalScope = js_sys::global().unchecked_into();
|
||||
let r = match transfer {
|
||||
Some(t) => scope.post_message_with_transfer(msg, t),
|
||||
None => scope.post_message(msg),
|
||||
};
|
||||
if let Err(e) = r {
|
||||
log::warn!("worker: postMessage: {e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn obj(pairs: &[(&str, JsValue)]) -> JsValue {
|
||||
let o = js_sys::Object::new();
|
||||
for (k, v) in pairs {
|
||||
let _ = js_sys::Reflect::set(&o, &JsValue::from_str(k), v);
|
||||
}
|
||||
o.into()
|
||||
}
|
||||
|
||||
/// Set up the worker's engine session (called once by `web/worker.js`).
|
||||
#[wasm_bindgen(js_name = workerInit)]
|
||||
pub fn worker_init() {
|
||||
crate::set_worker();
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
let pool = Arc::new(effectcraft_media::MediaPool::new());
|
||||
POOL.with(|p| *p.borrow_mut() = Some(pool.clone()));
|
||||
// Rendered files go back to the page (transferred, not copied).
|
||||
let sink: Arc<effectcraft_export::Sink> = Arc::new(|path: &str, data: Vec<u8>| {
|
||||
let bytes = js_sys::Uint8Array::from(&data[..]);
|
||||
let buf = bytes.buffer();
|
||||
post(&obj(&[("type", "file".into()), ("path", path.into()), ("bytes", buf.clone().into())]), Some(&js_sys::Array::of1(&buf)));
|
||||
});
|
||||
let s = Session {
|
||||
services: Arc::new(crate::files::WebServices),
|
||||
footage: pool.clone(),
|
||||
importer: Some(Arc::new(crate::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(sink) })),
|
||||
..Default::default()
|
||||
};
|
||||
SESSION.with(|c| *c.borrow_mut() = Some(s));
|
||||
}
|
||||
|
||||
/// A footage file for the jobs that follow.
|
||||
#[wasm_bindgen(js_name = workerFile)]
|
||||
pub fn worker_file(path: String, bytes: js_sys::Uint8Array) {
|
||||
let data: Arc<[u8]> = bytes.to_vec().into();
|
||||
crate::files::STORE.put_file(&path, data.clone(), false);
|
||||
POOL.with(|p| {
|
||||
if let Some(p) = p.borrow().as_ref() {
|
||||
p.add_bytes(&path, data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Run one job (a JSON [`WorkerRequest`]) and post its replies.
|
||||
#[wasm_bindgen(js_name = workerJob)]
|
||||
pub fn worker_job(json: String) {
|
||||
let send: effectcraft_engine::offload::Post = Rc::new(|r: WorkerReply| {
|
||||
let done = matches!(r, WorkerReply::Done);
|
||||
let t = serde_json::to_string(&r).unwrap_or_default();
|
||||
post(&obj(&[("type", "reply".into()), ("json", t.into()), ("done", done.into())]), None);
|
||||
});
|
||||
let req: WorkerRequest = match serde_json::from_str(&json) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
send(WorkerReply::Failed { error: format!("bad request: {e}") });
|
||||
send(WorkerReply::Done);
|
||||
return;
|
||||
}
|
||||
};
|
||||
SESSION.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
let Some(s) = c.as_mut() else {
|
||||
send(WorkerReply::Failed { error: "worker not initialised".into() });
|
||||
send(WorkerReply::Done);
|
||||
return;
|
||||
};
|
||||
effectcraft_engine::offload::run_request(s, req, &send);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user