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
+30 -18
View File
@@ -13,7 +13,7 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::config::ConfigStore;
use crate::config::{ConfigStore, FileOps, StdFiles};
use crate::prefs::Prefs;
/// Folder created next to a project for its auto-saves.
@@ -68,16 +68,12 @@ pub fn folder(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>) ->
/// Existing auto-saves of `stem` in `dir`: (slot, path, modified).
pub fn existing(dir: &Path, stem: &str) -> Vec<(u32, PathBuf, std::time::SystemTime)> {
let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
let mut v: Vec<_> = rd
.flatten()
.filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string();
let n = slot_of(stem, &name)?;
let m = e.metadata().ok()?.modified().ok()?;
Some((n, e.path(), m))
})
.collect();
existing_in(&StdFiles, dir, stem)
}
/// [`existing`] through `fs`.
pub fn existing_in(fs: &dyn FileOps, dir: &Path, stem: &str) -> Vec<(u32, PathBuf, std::time::SystemTime)> {
let mut v: Vec<_> = fs.list(dir).into_iter().filter_map(|(name, m)| Some((slot_of(stem, &name)?, dir.join(&name), m))).collect();
v.sort_by_key(|x| x.0);
v
}
@@ -95,18 +91,29 @@ pub fn next_slot(existing: &[(u32, PathBuf, std::time::SystemTime)], last: Optio
/// Write one auto-save of `json` for the project at `project` and rotate. Returns its path.
pub fn write(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>, last_slot: Option<u32>, json: &str) -> std::io::Result<(PathBuf, u32)> {
write_in(&StdFiles, prefs, project, fallback, last_slot, json)
}
/// [`write`] through `fs`.
pub fn write_in(
fs: &dyn FileOps,
prefs: &Prefs,
project: Option<&str>,
fallback: Option<&Path>,
last_slot: Option<u32>,
json: &str,
) -> std::io::Result<(PathBuf, u32)> {
let dir = folder(prefs, project, fallback).ok_or_else(|| std::io::Error::other("no auto-save folder for an untitled project"))?;
std::fs::create_dir_all(&dir)?;
let stem = project_stem(project);
let max = prefs.auto_save.max_versions.max(1);
let have = existing(&dir, &stem);
let have = existing_in(fs, &dir, &stem);
let slot = next_slot(&have, last_slot, max);
let path = dir.join(file_name(&stem, slot));
crate::config::atomic_write(&path, json.as_bytes())?;
fs.write(&path, json.as_bytes())?;
// Max versions lowered: drop slots above it.
for (n, p, _) in have {
if n > max {
let _ = std::fs::remove_file(p);
let _ = fs.remove(&p);
}
}
Ok((path, slot))
@@ -114,8 +121,13 @@ pub fn write(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>, last
/// The newest auto-save of a project, if any.
pub fn latest(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>) -> Option<PathBuf> {
latest_in(&StdFiles, prefs, project, fallback)
}
/// [`latest`] through `fs`.
pub fn latest_in(fs: &dyn FileOps, prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>) -> Option<PathBuf> {
let dir = folder(prefs, project, fallback)?;
existing(&dir, &project_stem(project)).into_iter().max_by_key(|e| e.2).map(|e| e.1)
existing_in(fs, &dir, &project_stem(project)).into_iter().max_by_key(|e| e.2).map(|e| e.1)
}
/// What the crash-recovery sentinel records.
@@ -151,8 +163,8 @@ pub fn begin(store: &dyn ConfigStore, prefs: &Prefs) -> Option<Recovery> {
let autosave = s
.autosave
.clone()
.filter(|p| Path::new(p).is_file())
.or_else(|| latest(prefs, s.project.as_deref(), fallback.as_deref()).map(|p| p.to_string_lossy().to_string()));
.filter(|p| store.files().is_file(Path::new(p)))
.or_else(|| latest_in(store.files(), prefs, s.project.as_deref(), fallback.as_deref()).map(|p| p.to_string_lossy().to_string()));
Some(Recovery { project: s.project, autosave, dirty: s.dirty })
}
+1 -1
View File
@@ -69,7 +69,7 @@ fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
fn increment_save(s: &mut Session, _: &Value) -> Result<Value> {
let cur = s.path.clone().ok_or_else(|| bad("file.incrementAndSave", "save the project first"))?;
// After Effects: `Intro.aep` → `Intro 2.aep` → `Intro 3.aep`.
let next = crate::autosave::increment_path(&cur, |p| std::path::Path::new(p).exists());
let next = crate::autosave::increment_path(&cur, |p| s.file_ops().is_file(std::path::Path::new(p)));
save_to(s, &next)
}
+1 -1
View File
@@ -239,7 +239,7 @@ fn auto_save(s: &mut Session, _: &Value) -> Result<Value> {
fn recovery_info(s: &mut Session, _: &Value) -> Result<Value> {
let root = s.default_autosave_root();
let latest = crate::autosave::latest(&s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string());
let latest = crate::autosave::latest_in(s.file_ops(), &s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string());
Ok(json!({
"lastAutoSave": s.autosave.last_path,
"latestOnDisk": latest,
+49 -4
View File
@@ -1,7 +1,11 @@
//! Persistent configuration storage (Settings, keyboard shortcut presets, the crash-recovery
//! sentinel). The engine only sees the [`ConfigStore`] trait; frontends choose where it lives:
//! the desktop app uses a [`DirConfig`] in the platform config directory, the web app can back it
//! with `localStorage`, tests and headless runs use [`MemoryConfig`] (or none at all).
//! the desktop app uses a [`DirConfig`] in the platform config directory, the web app backs it
//! with the browser's Origin Private File System (IndexedDB fallback), tests and headless runs use
//! [`MemoryConfig`] (or none at all).
//!
//! Auto-saves are project files, not settings, but they live wherever the store says: the store's
//! [`FileOps`] (the file system by default; the browser's storage on the web).
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
@@ -12,11 +16,52 @@ pub trait ConfigStore: Send + Sync {
fn read(&self, name: &str) -> Option<String>;
fn write(&self, name: &str, data: &str) -> std::io::Result<()>;
fn remove(&self, name: &str) -> std::io::Result<()>;
/// A directory on disk next to the configuration (default Auto-Save folder for untitled
/// projects); `None` for stores that aren't on the file system.
/// The folder next to the configuration (default Auto-Save folder for untitled projects),
/// in the namespace of [`ConfigStore::files`]; `None` when there is none.
fn dir(&self) -> Option<PathBuf> {
None
}
/// Where auto-saves are listed, written and found (default: the file system).
fn files(&self) -> &dyn FileOps {
&StdFiles
}
}
/// The file operations auto-save and crash recovery need.
pub trait FileOps: Send + Sync {
/// Files directly in `dir`: (file name, modified time).
fn list(&self, dir: &Path) -> Vec<(String, std::time::SystemTime)>;
/// Write `data` to `path` atomically, creating its folder.
fn write(&self, path: &Path, data: &[u8]) -> std::io::Result<()>;
fn remove(&self, path: &Path) -> std::io::Result<()>;
fn is_file(&self, path: &Path) -> bool;
}
/// [`FileOps`] on the file system.
pub struct StdFiles;
impl FileOps for StdFiles {
fn list(&self, dir: &Path) -> Vec<(String, std::time::SystemTime)> {
let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
rd.flatten()
.filter_map(|e| {
let m = e.metadata().ok().filter(|m| m.is_file())?.modified().ok()?;
Some((e.file_name().to_string_lossy().to_string(), m))
})
.collect()
}
fn write(&self, path: &Path, data: &[u8]) -> std::io::Result<()> {
if let Some(d) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(d)?;
}
atomic_write(path, data)
}
fn remove(&self, path: &Path) -> std::io::Result<()> {
std::fs::remove_file(path)
}
fn is_file(&self, path: &Path) -> bool {
path.is_file()
}
}
/// In-memory store (tests, headless sessions).
+8
View File
@@ -15,6 +15,7 @@ pub mod demo;
pub mod links;
pub mod mask_track;
pub mod menus;
pub mod offload;
pub mod prefs;
pub mod render_queue;
pub mod roto;
@@ -297,6 +298,11 @@ pub struct Session {
/// The JavaScript scripting engine (set by the host that links `effectcraft-script`):
/// `script.run`, File ▸ Scripts ▸ Run Script File… (`.jsx`/`.js`) and the Script Console.
pub script: Option<ScriptRunner>,
/// Runs renders and analyses off the UI thread where there are no threads (the web app's
/// Web Workers, see [`offload`]); `None` = threads (desktop) or inline (wasm32).
pub offload: Option<Arc<dyn offload::Offload>>,
/// Analyses running in the [`Session::offload`] worker.
pub offloaded: Vec<offload::OffloadedJob>,
}
/// A script to run (see [`Session::script`]).
@@ -348,6 +354,8 @@ impl Default for Session {
autosave: autosave::AutoSaveState::default(),
snapshot: None,
script: None,
offload: None,
offloaded: vec![],
}
}
}
+10 -2
View File
@@ -195,6 +195,7 @@ fn run_work(w: MaskWork, shared: &MaskTrackShared) {
s.elapsed = t0.elapsed().as_secs_f64();
s.fps = if s.elapsed > 0.0 { k as f64 / s.elapsed } else { 0.0 };
s.time = w.times[k].seconds();
crate::offload::report(s.done, s.total);
}
let mut s = lock(&shared.state);
s.elapsed = t0.elapsed().as_secs_f64();
@@ -213,16 +214,19 @@ fn write_frames(p: &mut Project, job: &MaskTrackJob, frames: &[MaskFrame]) {
impl Session {
/// Whether a mask track is running.
pub fn is_mask_tracking(&self) -> bool {
self.mask_job.as_ref().is_some_and(|j| !j.is_finished())
self.mask_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(crate::offload::JobKind::MaskTrack).is_some()
}
/// Live progress of the running (or just finished) mask track.
pub fn mask_track_progress(&self) -> Option<TrackProgress> {
self.mask_job.as_ref().map(|j| j.progress())
self.mask_job.as_ref().map(|j| j.progress()).or_else(|| self.offloaded(crate::offload::JobKind::MaskTrack).map(|j| j.progress.track()))
}
/// Cancel the running mask track (frames tracked so far are kept).
pub fn stop_mask_track(&mut self) -> bool {
if self.cancel_offloaded(crate::offload::JobKind::MaskTrack) {
return true;
}
match &self.mask_job {
Some(j) if !j.is_finished() => {
j.shared.cancel.store(true, Ordering::Relaxed);
@@ -237,6 +241,10 @@ impl Session {
return Err("a track analysis is already running".into());
}
self.poll_mask_track();
if !wait && self.offloads() {
let MaskWork { comp, layer, path, method, times, .. } = work;
return self.offload_analysis(crate::offload::WorkerJob::MaskTrack { comp, layer, mask, direction, path, method, times });
}
let wait = wait || cfg!(target_arch = "wasm32");
self.history.undo.push(("Track Mask".into(), self.project.clone()));
self.history.redo.clear();
+741
View File
@@ -0,0 +1,741 @@
//! Background jobs where the platform has no threads (the browser).
//!
//! On the desktop, renders and analyses run on threads that share the session's project and
//! footage. A wasm build without atomics has one thread per instance, so the web app runs a
//! second engine instance in a Web Worker instead and talks to it with messages: an
//! [`Offload`] (set as [`Session::offload`]) ships a [`WorkerRequest`] — the serialized project,
//! the footage files it reads and the job — and feeds the worker's [`WorkerReply`]s back into an
//! [`Inbox`] that the session drains every frame ([`Session::poll_render`],
//! [`Session::poll_offload`]). The worker side is [`run_request`]: a plain session fed the
//! project runs the job blocking and reports through a callback.
//!
//! | Job | Worker runs | Reply applied on the UI thread |
//! |---|---|---|
//! | Render Queue | the export of every queued item | item status, progress; files arrive through the host |
//! | Warp Stabilizer analysis | [`Session::start_warp`] | the effect's property group (one undo step) |
//! | Track Motion / Stabilize Motion | the tracker analysis | the tracker group |
//! | Mask tracking | the mask track | the mask group |
//! | Roto Brush Freeze | the freeze | the effect group |
//!
//! Roto Brush propagation fills the session's segmentation cache rather than the project, so it
//! keeps running on the UI thread.
//!
//! Cancelling terminates the worker: what an analysis tracked so far is dropped and the Render
//! Queue item being rendered becomes "User Stopped".
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use effectcraft_keyframe::ShapePath;
use effectcraft_project::render_queue::{RenderQueueItem, RenderStatus};
use effectcraft_project::tracking::TrackerSettings;
use effectcraft_project::{ItemId, ItemKind, LayerId, Project, PropGroup, Uid};
use effectcraft_time::Tick;
use serde::{Deserialize, Serialize};
use crate::mask_track::MaskMethod;
use crate::render_queue::{ItemUpdate, JobShared, JobState};
use crate::tracking::PointValues;
use crate::{Event, Session};
/// One job for a worker.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkerRequest {
pub id: u64,
/// The project ([`Project::to_json`]).
pub project: String,
/// Footage files the job reads; the host ships their bytes before the request.
pub files: Vec<String>,
pub job: WorkerJob,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum WorkerJob {
/// Render Queue: per item, one (item with that output module, resolved output path) per
/// output module.
Render {
items: Vec<Vec<(RenderQueueItem, String)>>,
},
Warp {
comp: ItemId,
layer: LayerId,
effect: Uid,
},
RotoFreeze {
comp: ItemId,
layer: LayerId,
effect: Uid,
},
Track {
comp: ItemId,
layer: LayerId,
tracker: Uid,
direction: crate::tracking::Direction,
settings: TrackerSettings,
points: Vec<PointValues>,
times: Vec<Tick>,
},
MaskTrack {
comp: ItemId,
layer: LayerId,
mask: Uid,
direction: crate::tracking::Direction,
path: ShapePath,
method: MaskMethod,
times: Vec<Tick>,
},
}
impl WorkerJob {
pub fn kind(&self) -> JobKind {
match self {
WorkerJob::Render { .. } => JobKind::Render,
WorkerJob::Warp { .. } => JobKind::Warp,
WorkerJob::RotoFreeze { .. } => JobKind::RotoFreeze,
WorkerJob::Track { .. } => JobKind::Track,
WorkerJob::MaskTrack { .. } => JobKind::MaskTrack,
}
}
/// (comp, layer, property group uid) an analysis writes to.
pub fn target(&self) -> Option<(ItemId, LayerId, Uid)> {
match *self {
WorkerJob::Render { .. } => None,
WorkerJob::Warp { comp, layer, effect } | WorkerJob::RotoFreeze { comp, layer, effect } => Some((comp, layer, effect)),
WorkerJob::Track { comp, layer, tracker, .. } => Some((comp, layer, tracker)),
WorkerJob::MaskTrack { comp, layer, mask, .. } => Some((comp, layer, mask)),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum JobKind {
Render,
Warp,
RotoFreeze,
Track,
MaskTrack,
}
impl JobKind {
/// The undo step an analysis result becomes.
pub fn label(self) -> &'static str {
match self {
JobKind::Render => "Render",
JobKind::Warp => "Warp Stabilizer Analysis",
JobKind::RotoFreeze => "Freeze",
JobKind::Track => "Analyze Track",
JobKind::MaskTrack => "Track Mask",
}
}
}
/// What a worker reports.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum WorkerReply {
/// Render Queue progress.
Render {
state: JobState,
},
/// A Render Queue item started or finished.
Item {
update: ItemUpdate,
},
/// Analysis progress (frames).
Progress {
done: u64,
total: u64,
},
/// An analysis finished: its property group after the job, and the job's message.
Group {
comp: ItemId,
layer: LayerId,
group: Box<PropGroup>,
message: String,
},
Failed {
error: String,
},
/// The job ended (always the last reply).
Done,
}
/// Replies waiting for the UI thread.
#[derive(Default)]
pub struct Inbox(Mutex<Vec<WorkerReply>>);
impl Inbox {
pub fn push(&self, r: WorkerReply) {
self.0.lock().unwrap_or_else(|e| e.into_inner()).push(r);
}
pub fn take(&self) -> Vec<WorkerReply> {
std::mem::take(&mut *self.0.lock().unwrap_or_else(|e| e.into_inner()))
}
}
/// Runs jobs off the UI thread (the web app: a pool of Web Workers).
pub trait Offload: Send + Sync {
/// Start `req`; replies go to `inbox` (ending with [`WorkerReply::Done`]).
fn start(&self, req: WorkerRequest, inbox: Arc<Inbox>) -> Result<(), String>;
/// Stop job `id` now (the worker is terminated; no more replies arrive).
fn cancel(&self, id: u64);
}
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
pub fn next_id() -> u64 {
NEXT_ID.fetch_add(1, Ordering::Relaxed)
}
/// Footage files a project reads (paths, sequence frames).
pub fn footage_files(p: &Project) -> Vec<String> {
let mut v: Vec<String> = p
.items
.values()
.filter_map(|i| match &i.kind {
ItemKind::Footage(f) if !f.path.is_empty() => Some(std::iter::once(f.path.clone()).chain(f.sequence.iter().cloned()).collect::<Vec<_>>()),
_ => None,
})
.flatten()
.collect();
v.sort();
v.dedup();
v
}
// ---------------------------------------------------------------- progress hook (worker side)
thread_local! {
static HOOK: RefCell<Option<Box<dyn FnMut(u64, u64)>>> = const { RefCell::new(None) };
}
/// Analyses call this as frames complete; a worker forwards it (no-op elsewhere).
pub fn report(done: u64, total: u64) {
HOOK.with(|h| {
if let Ok(mut h) = h.try_borrow_mut()
&& let Some(f) = h.as_mut()
{
f(done, total);
}
});
}
fn with_hook<R>(hook: Box<dyn FnMut(u64, u64)>, f: impl FnOnce() -> R) -> R {
HOOK.with(|h| *h.borrow_mut() = Some(hook));
let r = f();
HOOK.with(|h| *h.borrow_mut() = None);
r
}
// ---------------------------------------------------------------- worker side
/// Where a worker sends its replies.
pub type Post = Rc<dyn Fn(WorkerReply)>;
/// Run `req` on `s` (a session with footage, expressions and an exporter, but no project yet),
/// blocking, reporting through `post`. Always ends with [`WorkerReply::Done`].
pub fn run_request(s: &mut Session, req: WorkerRequest, post: &Post) {
match Project::from_json(&req.project) {
Ok(p) => {
s.replace_project(p, None);
if let Err(e) = run_job(s, req.job, post.clone()) {
post(WorkerReply::Failed { error: e });
}
}
Err(e) => post(WorkerReply::Failed { error: format!("project: {e}") }),
}
post(WorkerReply::Done);
}
fn run_job(s: &mut Session, job: WorkerJob, post: Post) -> Result<(), String> {
let kind = job.kind();
let target = job.target();
match job {
WorkerJob::Render { items } => {
let exporter = s.exporter.clone().ok_or("export is not available in this build")?;
let shared = JobShared::default();
// Item updates and progress go out as they happen (progress at most every 100 ms).
let last = Cell::new(None::<web_time::Instant>);
let notify = |sh: &JobShared, force: bool| {
let ups = std::mem::take(&mut *crate::render_queue::lock(&sh.updates));
for u in ups {
post(WorkerReply::Item { update: u });
}
if force || last.get().is_none_or(|t| t.elapsed().as_millis() >= 100) {
last.set(Some(web_time::Instant::now()));
post(WorkerReply::Render { state: sh.snapshot() });
}
};
crate::render_queue::run_items(s, exporter, items, &shared, &mut |sh| notify(sh, false));
notify(&shared, true);
Ok(())
}
WorkerJob::Warp { comp, layer, effect } => analysis(s, kind, target, post, |s| s.start_warp(comp, layer, effect, true).map(|_| ())),
WorkerJob::RotoFreeze { comp, layer, effect } => analysis(s, kind, target, post, |s| {
s.start_roto(comp, layer, effect, crate::roto::RotoTask::Freeze, crate::roto::Direction::Both, true).map(|_| ())
}),
WorkerJob::Track { comp, layer, tracker, direction, settings, points, times } => analysis(s, kind, target, post, |s| {
let work = crate::tracking::Work {
project: s.project.clone(),
footage: s.footage.clone(),
expr: s.expr.clone(),
cache: s.layer_cache.clone(),
comp,
layer,
settings,
points,
times,
};
s.start_track(work, tracker, direction, true)
}),
WorkerJob::MaskTrack { comp, layer, mask, direction, path, method, times } => analysis(s, kind, target, post, |s| {
let work = crate::mask_track::MaskWork {
project: s.project.clone(),
footage: s.footage.clone(),
expr: s.expr.clone(),
cache: s.layer_cache.clone(),
comp,
layer,
path,
method,
times,
};
s.start_mask_track(work, mask, direction, true)
}),
}
}
fn analysis(
s: &mut Session,
kind: JobKind,
target: Option<(ItemId, LayerId, Uid)>,
post: Post,
run: impl FnOnce(&mut Session) -> Result<(), String>,
) -> Result<(), String> {
let (comp, layer, uid) = target.ok_or("no target")?;
let rev = s.revision;
// Progress at most every 100 ms.
let p = post.clone();
let mut last = None::<web_time::Instant>;
let hook = Box::new(move |done: u64, total: u64| {
if last.is_none_or(|t: web_time::Instant| t.elapsed().as_millis() >= 100) {
last = Some(web_time::Instant::now());
p(WorkerReply::Progress { done, total });
}
});
with_hook(hook, || run(s))?;
let message = s
.events
.iter()
.rev()
.find_map(|e| match e {
Event::Toast { message, .. } => Some(message.clone()),
_ => None,
})
.unwrap_or_default();
let failed = s.events.iter().any(|e| matches!(e, Event::Toast { error: true, .. }));
if failed {
return Err(message);
}
if s.revision == rev && kind != JobKind::Track && kind != JobKind::MaskTrack {
// Nothing written (e.g. the analysis was empty).
return Err(if message.is_empty() { "nothing was analysed".into() } else { message });
}
let group = s.project.comp(comp).and_then(|c| c.layer(layer)).and_then(|l| l.props.find_group(uid)).cloned().ok_or("the analysed property is gone")?;
post(WorkerReply::Group { comp, layer, group: Box::new(group), message });
Ok(())
}
// ---------------------------------------------------------------- UI side
/// Analysis progress of an offloaded job.
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OffloadProgress {
pub done: u64,
pub total: u64,
pub elapsed: f64,
pub finished: bool,
pub cancelled: bool,
pub error: Option<String>,
}
/// Frames per second of `done` frames in `elapsed` seconds.
pub(crate) fn rate(done: u64, elapsed: f64) -> f64 {
if elapsed > 0.0 { done as f64 / elapsed } else { 0.0 }
}
impl OffloadProgress {
pub(crate) fn track(&self) -> crate::tracking::TrackProgress {
crate::tracking::TrackProgress { done: self.done, total: self.total, elapsed: self.elapsed, fps: rate(self.done, self.elapsed), ..Default::default() }
}
}
/// An analysis running in a worker.
pub struct OffloadedJob {
pub id: u64,
pub kind: JobKind,
pub comp: ItemId,
pub layer: LayerId,
pub uid: Uid,
pub inbox: Arc<Inbox>,
pub progress: OffloadProgress,
started: web_time::Instant,
}
/// A Render Queue job running in a worker (see [`crate::render_queue::RenderJob`]).
pub struct RemoteRender {
pub id: u64,
pub inbox: Arc<Inbox>,
/// The queue items in the job.
pub ids: Vec<u64>,
}
impl Session {
/// Whether jobs started without `wait` go to the [`Offload`].
pub fn offloads(&self) -> bool {
self.offload.is_some()
}
/// The offloaded analysis of `kind`, if one runs.
pub fn offloaded(&self, kind: JobKind) -> Option<&OffloadedJob> {
self.offloaded.iter().find(|j| j.kind == kind && !j.progress.finished)
}
/// Send an analysis to the worker.
pub(crate) fn offload_analysis(&mut self, job: WorkerJob) -> Result<(), String> {
let off = self.offload.clone().ok_or("no background worker")?;
let kind = job.kind();
let (comp, layer, uid) = job.target().ok_or("not an analysis")?;
if self.offloaded(kind).is_some() {
return Err(format!("a {} job is already running", kind.label()));
}
let id = next_id();
let inbox = Arc::new(Inbox::default());
let req = WorkerRequest { id, project: self.project.to_json(), files: footage_files(&self.project), job };
off.start(req, inbox.clone())?;
self.offloaded.push(OffloadedJob { id, kind, comp, layer, uid, inbox, progress: OffloadProgress::default(), started: web_time::Instant::now() });
self.events.push(Event::ProjectChanged { revision: self.revision });
Ok(())
}
/// Cancel the offloaded analysis of `kind` (nothing is written).
pub fn cancel_offloaded(&mut self, kind: JobKind) -> bool {
let off = self.offload.clone();
let Some(j) = self.offloaded.iter_mut().find(|j| j.kind == kind && !j.progress.finished) else { return false };
if let Some(off) = off {
off.cancel(j.id);
}
j.progress.cancelled = true;
j.progress.finished = true;
true
}
/// Apply worker replies to offloaded analyses (results become one undo step each) and drop
/// finished jobs. Frontends call this every frame while [`Session::offloaded`] is not empty.
pub fn poll_offload(&mut self) -> bool {
if self.offloaded.is_empty() {
return false;
}
let mut changed = false;
let mut results = vec![];
for j in &mut self.offloaded {
if j.progress.cancelled {
continue;
}
for r in j.inbox.take() {
changed = true;
match r {
WorkerReply::Progress { done, total } => {
j.progress.done = done;
j.progress.total = total;
}
WorkerReply::Group { comp, layer, group, message } => results.push((j.kind, comp, layer, group, message)),
WorkerReply::Failed { error } => j.progress.error = Some(error),
WorkerReply::Done => j.progress.finished = true,
WorkerReply::Render { .. } | WorkerReply::Item { .. } => {}
}
}
j.progress.elapsed = j.started.elapsed().as_secs_f64();
}
for (kind, comp, layer, group, message) in results {
let uid = group.uid;
let r = self.edit(kind.label(), None, |p, _| {
let g = p
.comp_mut(comp)
.and_then(|c| c.layer_mut(layer))
.and_then(|l| l.props.find_group_mut(uid))
.ok_or_else(|| crate::EngineError::Other("the analysed property was removed during the analysis".into()))?;
*g = *group;
Ok(())
});
match r {
Ok(()) => self.events.push(Event::Toast { message, error: false }),
Err(e) => self.events.push(Event::Toast { message: format!("{}: {e}", kind.label()), error: true }),
}
}
let mut done = vec![];
self.offloaded.retain(|j| {
if j.progress.finished {
done.push((j.kind, j.progress.clone()));
}
!j.progress.finished
});
for (kind, p) in done {
changed = true;
if let Some(e) = p.error {
self.events.push(Event::Toast { message: format!("{}: {e}", kind.label()), error: true });
} else if p.cancelled {
self.events.push(Event::Toast { message: format!("{} stopped", kind.label()), error: false });
}
self.bump();
}
changed
}
/// Feed a remote render's replies into its [`JobShared`]; a cancelled one is wrapped up here
/// (the item being rendered and the ones after it become "User Stopped").
pub(crate) fn drain_remote_render(&mut self) {
let Some(job) = &self.render_job else { return };
let Some(remote) = &job.remote else { return };
let shared = job.shared.clone();
for r in remote.inbox.take() {
match r {
WorkerReply::Render { state } => {
let fin = crate::render_queue::lock(&shared.state).finished;
*crate::render_queue::lock(&shared.state) = JobState { finished: fin, ..state };
}
WorkerReply::Item { update } => crate::render_queue::lock(&shared.updates).push(update),
WorkerReply::Failed { error } => {
log::warn!("render worker: {error}");
let cur = crate::render_queue::lock(&shared.state).current;
if let Some(id) = cur {
crate::render_queue::lock(&shared.updates).push(ItemUpdate::Finished {
id,
status: RenderStatus::Failed(error),
seconds: 0.0,
output: None,
});
}
}
WorkerReply::Done => crate::render_queue::lock(&shared.state).finished = true,
_ => {}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn round<T: Serialize + for<'de> Deserialize<'de>>(v: &T) -> String {
let a = serde_json::to_string(v).unwrap();
let back: T = serde_json::from_str(&a).unwrap();
let b = serde_json::to_string(&back).unwrap();
assert_eq!(a, b);
a
}
#[test]
fn protocol_round_trips() {
let mut s = Session::default();
s.execute("file.openDemoProject", serde_json::json!({})).unwrap();
let comp = s.active_comp_id().unwrap();
s.execute("renderQueue.add", serde_json::json!({})).unwrap();
let item = s.project.render_queue[0].clone();
let reqs = [
WorkerJob::Render { items: vec![vec![(item.clone(), "/out.gif".into())]] },
WorkerJob::Warp { comp, layer: LayerId(3), effect: 9 },
WorkerJob::RotoFreeze { comp, layer: LayerId(3), effect: 9 },
WorkerJob::Track {
comp,
layer: LayerId(1),
tracker: 4,
direction: crate::tracking::Direction::Backward,
settings: TrackerSettings::default(),
points: vec![PointValues {
uid: 5,
spec: effectcraft_track::PointSpec { center: [1.0, 2.0], feature_size: [3.0, 4.0], search_offset: [0.5, 0.0], search_size: [9.0, 9.0] },
attach_offset: [0.25, -1.0],
}],
times: vec![Tick::ZERO, Tick::from_seconds_f64(0.5)],
},
WorkerJob::MaskTrack {
comp,
layer: LayerId(2),
mask: 7,
direction: crate::tracking::Direction::Forward,
path: ShapePath::ellipse([10.0, 20.0], 5.0, 6.0),
method: MaskMethod::Perspective,
times: vec![Tick::from_seconds_f64(1.0)],
},
];
for job in reqs {
let kind = job.kind();
let req = WorkerRequest { id: 42, project: s.project.to_json(), files: footage_files(&s.project), job };
let t = round(&req);
assert!(t.contains("\"kind\":"), "{kind:?}");
let back: WorkerRequest = serde_json::from_str(&t).unwrap();
assert_eq!(back.job.kind(), kind);
assert_eq!(Project::from_json(&back.project).unwrap(), *s.project);
}
let g = s.active_comp().unwrap().layers[0].props.clone();
for r in [
WorkerReply::Render { state: JobState { current: Some(3), done: 4, total: 10, items_total: 1, ..Default::default() } },
WorkerReply::Item { update: ItemUpdate::Started { id: 3, unix: 1_700_000_000 } },
WorkerReply::Item { update: ItemUpdate::Finished { id: 3, status: RenderStatus::Failed("x".into()), seconds: 1.5, output: Some("/a.gif".into()) } },
WorkerReply::Progress { done: 1, total: 2 },
WorkerReply::Group { comp, layer: LayerId(1), group: Box::new(g), message: "ok".into() },
WorkerReply::Failed { error: "boom".into() },
WorkerReply::Done,
] {
let t = round(&r);
assert!(t.starts_with("{\"type\":"), "{t}");
}
}
/// An [`Offload`] that runs the job right away in a second session, through JSON (what a
/// Web Worker does, minus the threads).
struct Inline;
impl Offload for Inline {
fn start(&self, req: WorkerRequest, inbox: Arc<Inbox>) -> Result<(), String> {
let req: WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
let mut w = Session { exporter: Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })), ..Default::default() };
let post: Post = Rc::new(move |r| inbox.push(serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap()));
run_request(&mut w, req, &post);
Ok(())
}
fn cancel(&self, _: u64) {}
}
#[test]
fn render_runs_through_the_offload() {
let mut s = Session {
exporter: Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })),
offload: Some(Arc::new(Inline)),
..Default::default()
};
s.execute("file.openDemoProject", serde_json::json!({})).unwrap();
s.execute("renderQueue.add", serde_json::json!({"output": "/x.gif"})).unwrap();
let ids = s.start_render(false).unwrap();
// (the inline offload has replied already; replies are applied when polled)
s.poll_render();
assert!(s.render_job.is_none());
let it = s.project.render_queue.iter().find(|i| i.id == ids[0]).unwrap();
assert_eq!(it.status, RenderStatus::Done, "{:?}", it.status);
assert!(it.last_output.as_deref().is_some_and(|o| o.ends_with("x.gif")));
// `wait` renders inline.
s.execute("renderQueue.add", serde_json::json!({"output": "/y.gif"})).unwrap();
s.start_render(true).unwrap();
assert!(s.render_job.is_none() || s.render_job.as_ref().unwrap().remote.is_none());
}
/// Runs jobs in a session sharing `footage` (the synthetic clip of the tracking tests).
struct InlineWith(Arc<dyn effectcraft_render::FootageSource>);
impl Offload for InlineWith {
fn start(&self, req: WorkerRequest, inbox: Arc<Inbox>) -> Result<(), String> {
let req: WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
let mut w = Session { footage: self.0.clone(), ..Default::default() };
let post: Post = Rc::new(move |r| inbox.push(serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap()));
run_request(&mut w, req, &post);
Ok(())
}
fn cancel(&self, _: u64) {}
}
/// Never replies (a job still running); records cancellations.
#[derive(Default)]
struct Hanging(Mutex<Vec<u64>>);
impl Offload for Hanging {
fn start(&self, _: WorkerRequest, _: Arc<Inbox>) -> Result<(), String> {
Ok(())
}
fn cancel(&self, id: u64) {
self.0.lock().unwrap().push(id);
}
}
fn pose(f: u32) -> ([f64; 2], f64, f64) {
([150.0 + 2.0 * f as f64, 110.0 + 1.0 * f as f64], 0.0, 1.0)
}
#[test]
fn track_analysis_runs_through_the_offload_as_one_undo_step() {
let (mut s, clip, _) = crate::tests_track::setup(crate::tests_track::patch_frames(pose));
s.offload = Some(Arc::new(InlineWith(s.footage.clone())));
s.execute("track.motion", serde_json::json!({})).unwrap();
s.execute("track.setPoint", serde_json::json!({"point": 1, "center": pose(0).0, "featureSize": [36, 36], "searchSize": [80, 80]})).unwrap();
let undo = s.history.undo.len();
s.execute("track.analyze", serde_json::json!({"direction": "forward"})).unwrap();
assert!(s.is_tracking() && s.track_job.is_none(), "offloaded, not on a thread");
assert!(s.track_progress().is_some());
assert!(s.poll_offload());
assert!(!s.is_tracking() && s.offloaded.is_empty());
let l = s.active_comp().unwrap().layer(clip).unwrap();
let tp = l.trackers().next().unwrap().0.track_points().next().unwrap().clone();
let fc = tp.get("featureCenter").unwrap();
assert_eq!(fc.keys.len(), 25);
let v = fc.value_at(crate::tests_track::frame_time(12)).as_vec2();
assert!((v[0] - pose(12).0[0]).abs() < 0.3 && (v[1] - pose(12).0[1]).abs() < 0.3, "{v:?}");
assert_eq!(s.history.undo.len(), undo + 1);
assert_eq!(s.history.undo.last().unwrap().0, "Analyze Track");
assert!(s.events.iter().any(|e| matches!(e, Event::Toast { message, error: false } if message.starts_with("Tracked"))));
assert!(s.undo());
let l = s.active_comp().unwrap().layer(clip).unwrap();
assert!(l.trackers().next().unwrap().0.track_points().next().unwrap().get("featureCenter").unwrap().keys.is_empty());
// `wait` runs inline.
s.redo();
s.execute("track.analyze", serde_json::json!({"direction": "forward", "wait": true})).ok();
assert!(s.offloaded.is_empty());
}
#[test]
fn cancelling_offloaded_jobs() {
let (mut s, _, _) = crate::tests_track::setup(crate::tests_track::patch_frames(pose));
let off = Arc::new(Hanging::default());
s.offload = Some(off.clone());
s.exporter = Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() }));
// Analysis: stopping drops the job, nothing is written.
s.execute("track.motion", serde_json::json!({})).unwrap();
let rev = s.revision;
s.execute("track.analyze", serde_json::json!({})).unwrap();
assert!(s.is_tracking());
assert!(s.execute("track.analyze", serde_json::json!({})).is_err(), "one at a time");
assert!(s.stop_track());
s.poll_offload();
assert!(!s.is_tracking());
assert_eq!(off.0.lock().unwrap().len(), 1);
assert!(s.events.iter().any(|e| matches!(e, Event::Toast { message, .. } if message.contains("stopped"))));
assert!(s.revision > rev);
// Render: the queued items become User Stopped.
s.execute("renderQueue.add", serde_json::json!({"output": "/a.gif"})).unwrap();
let ids = s.start_render(false).unwrap();
assert!(s.is_rendering());
assert!(s.stop_render());
s.poll_render();
assert!(!s.is_rendering());
assert_eq!(off.0.lock().unwrap().len(), 2);
let it = s.project.render_queue.iter().find(|i| i.id == ids[0]).unwrap();
assert_eq!(it.status, RenderStatus::UserStopped);
}
#[test]
fn progress_hook_is_scoped() {
report(1, 2); // no hook: nothing happens
let seen = Rc::new(RefCell::new(vec![]));
let s2 = seen.clone();
with_hook(Box::new(move |d, t| s2.borrow_mut().push((d, t))), || {
report(1, 3);
report(2, 3);
});
report(3, 3);
assert_eq!(*seen.borrow(), vec![(1, 3), (2, 3)]);
}
}
+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();
+29 -4
View File
@@ -10,6 +10,7 @@
//! of (the layer's source and timing, its masks and the effects above it) and drops frozen
//! mattes made from other frames.
use crate::offload::JobKind;
use std::collections::{BTreeMap, HashMap};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
@@ -62,7 +63,7 @@ impl Default for RotoOptions {
pub const VIEWS: [&str; 4] = ["alphaBoundary", "alpha", "alphaOverlay", "none"];
/// What a job does.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RotoTask {
Propagate,
@@ -378,6 +379,7 @@ fn run_work(w: Work, shared: &RotoShared) {
s.done = k as u64 + 1;
s.elapsed = t0.elapsed().as_secs_f64();
s.fps = if s.elapsed > 0.0 { s.done as f64 / s.elapsed } else { 0.0 };
crate::offload::report(s.done, s.total);
}
if w.task == RotoTask::Freeze {
*lock(&shared.frozen) = Some(frozen);
@@ -402,19 +404,37 @@ pub(crate) fn job_frames(d: &effectcraft_track::roto::RotoData, dir: Direction)
impl Session {
pub fn is_roto_running(&self) -> bool {
self.roto_job.as_ref().is_some_and(|j| !j.is_finished())
self.roto_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(JobKind::RotoFreeze).is_some()
}
pub fn roto_progress(&self) -> Option<RotoProgress> {
self.roto_job.as_ref().map(|j| j.progress())
self.roto_job.as_ref().map(|j| j.progress()).or_else(|| {
self.offloaded(JobKind::RotoFreeze).map(|j| {
let p = &j.progress;
RotoProgress {
task: Some(RotoTask::Freeze),
done: p.done,
total: p.total,
elapsed: p.elapsed,
fps: crate::offload::rate(p.done, p.elapsed),
..Default::default()
}
})
})
}
/// (comp, layer, effect uid, task) of the running job.
pub fn roto_target(&self) -> Option<(ItemId, LayerId, Uid, RotoTask)> {
self.roto_job.as_ref().map(|j| (j.comp, j.layer, j.effect, j.task))
self.roto_job
.as_ref()
.map(|j| (j.comp, j.layer, j.effect, j.task))
.or_else(|| self.offloaded(JobKind::RotoFreeze).map(|j| (j.comp, j.layer, j.uid, RotoTask::Freeze)))
}
pub fn stop_roto(&mut self) -> bool {
if self.cancel_offloaded(JobKind::RotoFreeze) {
return true;
}
match &self.roto_job {
Some(j) if !j.is_finished() => {
j.shared.cancel.store(true, Ordering::Relaxed);
@@ -471,6 +491,11 @@ impl Session {
task,
};
let strokes = str_of(g, fx::STROKES);
if !wait && task == RotoTask::Freeze && self.offloads() {
drop(work);
self.offload_analysis(crate::offload::WorkerJob::RotoFreeze { comp, layer, effect })?;
return Ok(n);
}
let wait = wait || cfg!(target_arch = "wasm32");
let shared = Arc::new(RotoShared::default());
let thread = if wait {
+2 -2
View File
@@ -16,8 +16,8 @@ fn demo() -> Session {
}
/// Records exports; fails items whose path contains "fail"; honours cancellation.
struct MockExporter {
log: Mutex<Vec<String>>,
pub(crate) struct MockExporter {
pub(crate) log: Mutex<Vec<String>>,
}
impl Exporter for MockExporter {
+9 -1
View File
@@ -89,11 +89,19 @@ impl Session {
self.config.as_ref().and_then(|c| c.dir())
}
/// Where auto-saves live: the config store's [`crate::config::FileOps`], else the file system.
pub fn file_ops(&self) -> &dyn crate::config::FileOps {
match &self.config {
Some(c) => c.files(),
None => &crate::config::StdFiles,
}
}
/// Write an auto-save now (whether or not the project is dirty). Returns its path.
pub fn autosave_now(&mut self) -> Result<String> {
let json = self.project.to_json();
let root = self.default_autosave_root();
let (path, slot) = autosave::write(&self.prefs, self.path.as_deref(), root.as_deref(), self.autosave.last_slot, &json)
let (path, slot) = autosave::write_in(self.file_ops(), &self.prefs, self.path.as_deref(), root.as_deref(), self.autosave.last_slot, &json)
.map_err(|e| EngineError::Other(format!("auto-save failed: {e}")))?;
let p = path.to_string_lossy().to_string();
self.autosave.last_slot = Some(slot);
+12 -3
View File
@@ -70,7 +70,8 @@ pub fn track_options(o: &TrackerOptions) -> trk::TrackOptions {
}
/// A track point's regions and attach offset read from its properties at a layer time.
#[derive(Clone, Copy, Debug, PartialEq)]
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PointValues {
pub uid: Uid,
pub spec: trk::PointSpec,
@@ -292,6 +293,7 @@ pub(crate) fn run_work(w: Work, shared: &TrackShared) {
s.elapsed = t0.elapsed().as_secs_f64();
s.fps = if s.elapsed > 0.0 { k as f64 / s.elapsed } else { 0.0 };
s.time = w.times[k].seconds();
crate::offload::report(s.done, s.total);
}
let mut s = lock(&shared.state);
s.elapsed = t0.elapsed().as_secs_f64();
@@ -322,16 +324,19 @@ fn write_frames(p: &mut Project, job: &TrackJob, frames: &[FrameResult]) {
impl Session {
/// Whether a track analysis is running.
pub fn is_tracking(&self) -> bool {
self.track_job.as_ref().is_some_and(|j| !j.is_finished())
self.track_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(crate::offload::JobKind::Track).is_some()
}
/// Live progress of the running (or just finished) analysis.
pub fn track_progress(&self) -> Option<TrackProgress> {
self.track_job.as_ref().map(|j| j.progress())
self.track_job.as_ref().map(|j| j.progress()).or_else(|| self.offloaded(crate::offload::JobKind::Track).map(|j| j.progress.track()))
}
/// Cancel the running analysis (results so far are kept).
pub fn stop_track(&mut self) -> bool {
if self.cancel_offloaded(crate::offload::JobKind::Track) {
return true;
}
match &self.track_job {
Some(j) if !j.is_finished() => {
j.cancel();
@@ -348,6 +353,10 @@ impl Session {
return Err("a track analysis is already running".into());
}
self.poll_track();
if !wait && self.offloads() {
let Work { comp, layer, settings, points, times, .. } = work;
return self.offload_analysis(crate::offload::WorkerJob::Track { comp, layer, tracker, direction, settings, points, times });
}
let wait = wait || cfg!(target_arch = "wasm32");
self.history.undo.push(("Analyze Track".into(), self.project.clone()));
self.history.redo.clear();
+19 -4
View File
@@ -11,6 +11,7 @@
//! for re-analysis, which frontends start with [`Session::poll_warp`] (After Effects re-analyses
//! automatically too).
use crate::offload::{JobKind, rate};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
@@ -232,6 +233,7 @@ fn run_work(w: WarpWork, shared: &WarpShared) {
s.done = k as u64 + 1;
s.elapsed = t0.elapsed().as_secs_f64();
s.fps = if s.elapsed > 0.0 { s.done as f64 / s.elapsed } else { 0.0 };
crate::offload::report(s.done, s.total);
}
lock(&shared.state).step = 2;
let lt0 = layer.layer_time(w.times[0]).seconds();
@@ -264,21 +266,29 @@ pub(crate) fn analysis_times(comp: &effectcraft_project::Comp, layer: &Layer) ->
impl Session {
pub fn is_warp_analyzing(&self) -> bool {
self.warp_job.as_ref().is_some_and(|j| !j.is_finished())
self.warp_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(JobKind::Warp).is_some()
}
/// Progress of the running (or just finished) analysis.
pub fn warp_progress(&self) -> Option<WarpProgress> {
self.warp_job.as_ref().map(|j| j.progress())
self.warp_job.as_ref().map(|j| j.progress()).or_else(|| {
self.offloaded(JobKind::Warp).map(|j| {
let p = &j.progress;
WarpProgress { step: 1, done: p.done, total: p.total, elapsed: p.elapsed, fps: rate(p.done, p.elapsed), ..Default::default() }
})
})
}
/// The effect being analysed: (comp, layer, effect uid).
pub fn warp_target(&self) -> Option<(ItemId, LayerId, Uid)> {
self.warp_job.as_ref().map(|j| (j.comp, j.layer, j.effect))
self.warp_job.as_ref().map(|j| (j.comp, j.layer, j.effect)).or_else(|| self.offloaded(JobKind::Warp).map(|j| (j.comp, j.layer, j.uid)))
}
/// Cancel the running analysis (nothing is written).
pub fn stop_warp(&mut self) -> bool {
if self.cancel_offloaded(JobKind::Warp) {
return true;
}
match &self.warp_job {
Some(j) if !j.is_finished() => {
j.shared.cancel.store(true, Ordering::Relaxed);
@@ -325,6 +335,11 @@ impl Session {
times,
};
let key = signature(&self.project, comp, l);
if !wait && self.offloads() {
drop(work);
self.offload_analysis(crate::offload::WorkerJob::Warp { comp, layer, effect })?;
return Ok(n);
}
let wait = wait || cfg!(target_arch = "wasm32");
let shared = Arc::new(WarpShared::default());
let thread = if wait {
@@ -391,7 +406,7 @@ impl Session {
/// re-analyses in the background, like After Effects.
pub fn poll_warp(&mut self, auto: bool) -> bool {
let changed = self.poll_warp_job();
if auto && self.warp_job.is_none() {
if auto && self.warp_job.is_none() && self.offloaded(JobKind::Warp).is_none() {
while let Some((c, l, e)) = self.warp_pending.first().copied() {
self.warp_pending.remove(0);
if self.start_warp(c, l, e, false).is_ok() {