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:
Brandon Thomas
2026-10-02 23:42:00 -04:00
co-authored by Claude Opus 5.5
parent c406b71cba
commit 0bee1ab2da
41 changed files with 3230 additions and 319 deletions
+76 -18
View File
@@ -13,7 +13,7 @@ use std::sync::{Arc, Mutex};
use effectcraft_project::render_queue::{OutputFormat, PostRenderAction, RenderQueueItem, RenderStatus, TemplateVars, expand_template};
use effectcraft_project::{ItemId, ItemKind, LayerSource, Project};
use effectcraft_render::{ExprHost, FootageSource};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use crate::{Event, Session};
@@ -54,7 +54,7 @@ pub trait Exporter: Send + Sync {
pub const CANCELLED: &str = "cancelled";
/// Live progress of a render job (serde for agents / the control channel).
#[derive(Clone, Debug, Default, Serialize)]
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct JobState {
/// Item being rendered.
pub current: Option<u64>,
@@ -73,7 +73,8 @@ pub struct JobState {
}
/// Per-item status changes from the job thread, applied to the project by `poll_render`.
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "camelCase")]
pub enum ItemUpdate {
Started { id: u64, unix: u64 },
Finished { id: u64, status: RenderStatus, seconds: f64, output: Option<String> },
@@ -86,7 +87,7 @@ pub struct JobShared {
pub updates: Mutex<Vec<ItemUpdate>>,
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
pub(crate) fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
@@ -100,6 +101,8 @@ impl JobShared {
pub struct RenderJob {
pub shared: Arc<JobShared>,
thread: Option<std::thread::JoinHandle<()>>,
/// Rendering in a worker ([`crate::offload`]).
pub remote: Option<crate::offload::RemoteRender>,
}
impl RenderJob {
@@ -132,7 +135,19 @@ fn unix_now() -> u64 {
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
fn run_work(w: Work, shared: &JobShared) {
/// Render `items` with the session's footage, expressions and GPU (a worker's render).
pub(crate) fn run_items(
s: &Session,
exporter: Arc<dyn Exporter>,
items: Vec<Vec<(RenderQueueItem, String)>>,
shared: &JobShared,
notify: &mut dyn FnMut(&JobShared),
) {
let work = Work { project: s.project.clone(), footage: s.footage.clone(), expr: s.expr.clone(), accel: s.accel.clone(), exporter, items };
run_work(work, shared, notify);
}
fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared)) {
let t0 = web_time::Instant::now();
{
let mut s = lock(&shared.state);
@@ -154,18 +169,22 @@ fn run_work(w: Work, shared: &JobShared) {
s.items_done = k;
s.remaining = None;
}
notify(shared);
// Every output module encodes the same frames (Composition ▸ Add Output Module); the
// first module's file is the item's output.
let mut r: Result<ExportResult, String> = Err("no output module".into());
for (mi, (mitem, path)) in modules.iter().enumerate() {
let job = ExportJob { project: &w.project, footage: w.footage.as_ref(), expr: w.expr.as_deref(), accel: w.accel.as_deref(), item: mitem, path };
let rr = w.exporter.export(&job, &mut |done, total| {
let mut s = lock(&shared.state);
s.done = done;
s.total = total;
s.elapsed = t0.elapsed().as_secs_f64();
s.item_elapsed = t1.elapsed().as_secs_f64();
s.remaining = (done > 0).then(|| s.item_elapsed / done as f64 * total.saturating_sub(done) as f64);
{
let mut s = lock(&shared.state);
s.done = done;
s.total = total;
s.elapsed = t0.elapsed().as_secs_f64();
s.item_elapsed = t1.elapsed().as_secs_f64();
s.remaining = (done > 0).then(|| s.item_elapsed / done as f64 * total.saturating_sub(done) as f64);
}
notify(shared);
!shared.cancel.load(Ordering::Relaxed)
});
match rr {
@@ -187,6 +206,7 @@ fn run_work(w: Work, shared: &JobShared) {
}
};
lock(&shared.updates).push(up);
notify(shared);
}
let mut s = lock(&shared.state);
s.items_done = w.items.len();
@@ -245,10 +265,12 @@ impl Session {
}
/// Start rendering every queued item. `wait`: block until done (otherwise a background thread
/// renders; call [`Session::poll_render`] regularly). Returns the ids being rendered.
/// wasm32 has no threads: the render always runs to completion before this returns.
/// renders, or the [`Session::offload`] worker; call [`Session::poll_render`] regularly).
/// Returns the ids being rendered. Without threads or an offload (wasm32) the render always
/// runs to completion before this returns.
pub fn start_render(&mut self, wait: bool) -> Result<Vec<u64>, String> {
let wait = wait || cfg!(target_arch = "wasm32");
let offload = self.offload.clone().filter(|_| !wait);
let wait = wait || (cfg!(target_arch = "wasm32") && offload.is_none());
if self.is_rendering() {
return Err("a render is already in progress".into());
}
@@ -291,15 +313,31 @@ impl Session {
return Err("nothing is queued: add a composition (Composition ▸ Add to Render Queue) and tick Render".into());
}
let ids: Vec<u64> = items.iter().filter_map(|m| m.first().map(|(i, _)| i.id)).collect();
if let Some(off) = offload {
let id = crate::offload::next_id();
let inbox = Arc::new(crate::offload::Inbox::default());
let req = crate::offload::WorkerRequest {
id,
project: self.project.to_json(),
files: crate::offload::footage_files(&self.project),
job: crate::offload::WorkerJob::Render { items },
};
off.start(req, inbox.clone())?;
let shared = Arc::new(JobShared::default());
lock(&shared.state).items_total = ids.len();
self.render_job = Some(RenderJob { shared, thread: None, remote: Some(crate::offload::RemoteRender { id, inbox, ids: ids.clone() }) });
self.poll_render();
return Ok(ids);
}
let work = Work { project: self.project.clone(), footage: self.footage.clone(), expr: self.expr.clone(), accel: self.accel.clone(), exporter, items };
let shared = Arc::new(JobShared::default());
if wait {
run_work(work, &shared);
self.render_job = Some(RenderJob { shared, thread: None });
run_work(work, &shared, &mut |_| {});
self.render_job = Some(RenderJob { shared, thread: None, remote: None });
} else {
let sh = shared.clone();
let thread = std::thread::Builder::new().name("render-queue".into()).spawn(move || run_work(work, &sh)).map_err(|e| e.to_string())?;
self.render_job = Some(RenderJob { shared, thread: Some(thread) });
let thread = std::thread::Builder::new().name("render-queue".into()).spawn(move || run_work(work, &sh, &mut |_| {})).map_err(|e| e.to_string())?;
self.render_job = Some(RenderJob { shared, thread: Some(thread), remote: None });
}
self.poll_render();
Ok(ids)
@@ -310,6 +348,25 @@ impl Session {
match &self.render_job {
Some(j) if !j.is_finished() => {
j.cancel();
if let Some(r) = &j.remote {
// A worker can't be interrupted mid-frame: terminate it and stop what's left.
if let Some(off) = &self.offload {
off.cancel(r.id);
}
self.drain_remote_render();
let j = self.render_job.as_ref().expect("job");
let r = j.remote.as_ref().expect("remote");
let finished: Vec<u64> =
lock(&j.shared.updates).iter().filter_map(|u| if let ItemUpdate::Finished { id, .. } = u { Some(*id) } else { None }).collect();
for id in &r.ids {
let done = finished.contains(id)
|| self.project.render_queue.iter().any(|i| i.id == *id && matches!(i.status, RenderStatus::Done | RenderStatus::Failed(_)));
if !done {
lock(&j.shared.updates).push(ItemUpdate::Finished { id: *id, status: RenderStatus::UserStopped, seconds: 0.0, output: None });
}
}
lock(&j.shared.state).finished = true;
}
true
}
_ => false,
@@ -319,6 +376,7 @@ impl Session {
/// Apply job updates to the queue items; drop the job once it finished. Returns whether
/// anything changed. Frontends call this every frame while rendering.
pub fn poll_render(&mut self) -> bool {
self.drain_remote_render();
let Some(job) = &self.render_job else { return false };
let updates = std::mem::take(&mut *lock(&job.shared.updates));
let finished = job.is_finished();