mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-28 12:07:48 -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
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user