diff --git a/apps/effectcraft-web/tests/smoke.mjs b/apps/effectcraft-web/tests/smoke.mjs index 8a89be83..ee272a67 100644 --- a/apps/effectcraft-web/tests/smoke.mjs +++ b/apps/effectcraft-web/tests/smoke.mjs @@ -322,7 +322,8 @@ try { let maxGapMs = 0, last = performance.now(); const timer = setInterval(() => { const now = performance.now(); maxGapMs = Math.max(maxGapMs, now - last); last = now; }, 10); const gaps = {}; - const phase = (n) => { gaps[n] = maxGapMs; maxGapMs = 0; }; + // (a phase ends once the timer has run: a stall at its end counts in it, not the next) + const phase = async (n) => { await new Promise((res) => setTimeout(res, 30)); gaps[n] = maxGapMs; maxGapMs = 0; }; await effectcraft.execute("layer.newSolid", {name: "GPU Job Solid", color: "#3366cc", width: 320, height: 180}); await effectcraft.execute("effect.apply", {effect: "Fractal Noise"}); await effectcraft.execute("prop.addKey", {path: "effects/#1/transform/offset", time: 0, value: [0.5, 0.5]}); @@ -335,7 +336,7 @@ try { const t0 = performance.now(); const r = await effectcraft.execute("renderQueue.render", {}); const renderMs = performance.now() - t0; - phase("render"); + await phase("render"); // Applying Warp Stabilizer starts its analysis in a job worker (as in After Effects). await effectcraft.execute("effect.apply", {effect: "Warp Stabilizer"}); const t1 = performance.now(); @@ -345,21 +346,23 @@ try { warp = await effectcraft.execute("warp.status", {}); } while (!warp.analyzed && performance.now() - t1 < 180000); const warpMs = performance.now() - t1; - phase("warp"); + await phase("warp"); // Content-Aware Fill (a job worker too): its input is the layer's source, so the noise and // blur go into a precomp (rendered on the worker's GPU); cut a hole, fill half a second. - // (Editing on the page — pre-composing, the mask — is not part of the measured jobs.) + // Editing on the page after the analysis (M13.32: the first warp.status with the + // analysis used to solve the stabilization plan on the page, ≈ 5 s; now the job sends + // it): every command stays a few UI frames. await effectcraft.execute("effect.remove", {effect: "Warp Stabilizer"}); await effectcraft.execute("layer.precompose", {name: "GPU Fill Source", mode: "move", open: false}); await effectcraft.execute("layer.addMask", {rect: [140, 70, 40, 40], mode: "subtract"}); const wa = await effectcraft.execute("comp.info", {}).then((c) => c.workArea).catch(() => null); await effectcraft.execute("comp.workArea", {start: 0, end: 0.5}); - phase("prepareFill"); + await phase("prepareFill"); const t2 = performance.now(); const fill = await effectcraft.execute("contentFill.generate", {method: "edgeBlend", wait: true}).catch((e) => ({error: String(e)})); const fillMs = performance.now() - t2; const fillLayers = await effectcraft.execute("comp.info", {}).then((c) => c.layers.map((l) => l.name).filter((n) => n.startsWith("Fill "))); - phase("fill"); + await phase("fill"); clearInterval(timer); maxGapMs = Math.max(gaps.render, gaps.warp, gaps.fill); // Back to the project as it was. @@ -384,6 +387,7 @@ try { check(!gj.fill.error && gj.fillLayers.length > 0, `Content-Aware Fill in a job worker: ${JSON.stringify(gj.fill)} ${JSON.stringify(gj.fillLayers)}`); check(fj && fj.gpu && fj.readbacks > 0, `Content-Aware Fill frames on the job worker's GPU: ${JSON.stringify(fj)}`); check(gj.maxGapMs < 400, `event loop blocked ${gj.maxGapMs} ms during the GPU jobs: ${JSON.stringify(gj.gaps)}`); + check(gj.gaps.prepareFill < 50, `event loop blocked ${gj.gaps.prepareFill} ms while editing after the analysis (pre-compose, mask): ${JSON.stringify(gj.gaps)}`); } else { report.steps.gpuJobs = { note: "no WebGPU in workers here: job workers render on their CPU" }; } diff --git a/crates/effects/src/warp_stab.rs b/crates/effects/src/warp_stab.rs index ff535c41..595cb520 100644 --- a/crates/effects/src/warp_stab.rs +++ b/crates/effects/src/warp_stab.rs @@ -203,10 +203,14 @@ fn analysis_cached(json: &str) -> Option<(u64, Arc)> { Some((key, a)) } -fn plan_cached(akey: u64, a: &WarpAnalysis, s: &StabSettings) -> Arc { +fn plans() -> &'static PlanCache { static C: OnceLock = OnceLock::new(); + C.get_or_init(Default::default) +} + +fn plan_cached(akey: u64, a: &WarpAnalysis, s: &StabSettings) -> Arc { let key = (akey, settings_key(s)); - let c = C.get_or_init(Default::default); + let c = plans(); if let Some(p) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&key) { return p.clone(); } @@ -241,6 +245,107 @@ pub fn plan_at(params: &Params, time: f64) -> Option<(usize, Arc, (k < pl.warps.len()).then_some((k, a, pl)) } +/// What `warp.status` reports of a stabilization plan (no meshes): small, so another engine +/// instance (a browser job worker) can compute it and send it over. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlanSummary { + /// [`Plan::warps`] (row-major 3 × 3 per frame). + pub warps: Vec<[[f64; 3]; 3]>, + pub auto_scale: f64, + pub crop: Option<[f64; 4]>, + pub valid_fraction: f64, +} + +impl PlanSummary { + fn of(p: &Plan) -> PlanSummary { + PlanSummary { warps: p.warps.iter().map(|h| h.0).collect(), auto_scale: p.auto_scale, crop: p.crop, valid_fraction: p.valid_fraction } + } +} + +type SummaryCache = Mutex>>; + +fn summaries() -> &'static SummaryCache { + static C: OnceLock = OnceLock::new(); + C.get_or_init(Default::default) +} + +/// The key of an instance's plan (analysis, settings), with the analysis (`None` when not +/// analysed). Cheap once the analysis is parsed: no plan is computed. +fn plan_key(params: &Params) -> Option<((u64, u64), Arc, StabSettings)> { + let j = s(params, ANALYSIS); + if j.is_empty() { + return None; + } + let (akey, a) = analysis_cached(j)?; + let fps = if a.frame_duration > 0.0 { 1.0 / a.frame_duration } else { 30.0 }; + let st = settings(params, fps); + Some(((akey, settings_key(&st)), a, st)) +} + +/// The key a [`PlanSummary`] of this instance is stored under ([`store_summary`]). +pub fn summary_key(params: &Params) -> Option<[u64; 2]> { + plan_key(params).map(|(k, _, _)| [k.0, k.1]) +} + +/// Keep a summary computed elsewhere (a job worker), so [`cached_summary_at`] finds it. +pub fn store_summary(key: [u64; 2], summary: PlanSummary) { + keep_summary((key[0], key[1]), Arc::new(summary)); +} + +fn keep_summary(key: (u64, u64), summary: Arc) { + let mut m = summaries().lock().unwrap_or_else(|e| e.into_inner()); + if m.len() > 32 { + m.clear(); + } + m.insert(key, summary); +} + +/// The plan summary of an instance (its key and the summary), computing the plan when it isn't +/// cached (seconds for a long Subspace Warp clip). `None` when not analysed. +pub fn summary(params: &Params) -> Option<([u64; 2], Arc)> { + let (key, a, st) = plan_key(params)?; + let cached = summaries().lock().unwrap_or_else(|e| e.into_inner()).get(&key).cloned(); + let sum = match cached { + Some(s) => s, + None => { + let s = Arc::new(PlanSummary::of(&plan_cached(key.0, &a, &st))); + keep_summary(key, s.clone()); + s + } + }; + Some(([key.0, key.1], sum)) +} + +/// The plan summary of an instance at layer time `time`: (frame index, summary). Computes the +/// plan when it isn't cached ([`summary`]). +pub fn summary_at(params: &Params, time: f64) -> Option<(usize, Arc)> { + if let Some(r) = cached_summary_at(params, time) { + return Some(r); + } + let (_, sum) = summary(params)?; + let k = analysis(params)?.frame_at(time)?; + (k < sum.warps.len()).then_some((k, sum)) +} + +/// [`summary_at`] without computing anything: from a stored summary or a cached plan; `None` +/// when neither exists yet (or the instance is not analysed). +pub fn cached_summary_at(params: &Params, time: f64) -> Option<(usize, Arc)> { + let (key, a, _) = plan_key(params)?; + let k = a.frame_at(time)?; + let sum = summaries().lock().unwrap_or_else(|e| e.into_inner()).get(&key).cloned(); + let sum = match sum { + Some(s) => s, + None => { + let p = plans().lock().unwrap_or_else(|e| e.into_inner()).get(&key).cloned()?; + let s = Arc::new(PlanSummary::of(&p)); + keep_summary(key, s.clone()); + s + } + }; + (k < sum.warps.len()).then_some((k, sum)) +} + /// Motion from frame `k` to frame `j` (scene points of frame k → frame j), from the analysed /// frame-to-frame homographies. pub fn motion_between(a: &WarpAnalysis, k: usize, j: usize) -> Homography { diff --git a/crates/engine/src/commands/warp_cmds.rs b/crates/engine/src/commands/warp_cmds.rs index 14f0ee4b..16639c4b 100644 --- a/crates/engine/src/commands/warp_cmds.rs +++ b/crates/engine/src/commands/warp_cmds.rs @@ -5,6 +5,7 @@ use effectcraft_project::{GroupKind, ItemId, Layer, LayerId, PropGroup, Uid}; use serde_json::{Value, json}; use super::{CommandSpec, b_p, bad, has_comp, layer_p}; +use crate::offload::{JobKind, WorkerJob}; use crate::{EngineError, Result, Session, cmd, query}; pub fn specs() -> Vec { @@ -101,10 +102,12 @@ fn warp_status(s: &mut Session, p: &Value) -> Result { s.poll_warp(false); let mut out = json!({"running": s.is_warp_analyzing(), "progress": s.warp_progress(), "banner": s.warp_progress().map(|p| p.banner())}); let Ok((cid, lid, uid)) = warp_p(s, p, "warp.status") else { return Ok(out) }; - let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + // (the project's `Arc`: the borrows outlive starting a job below) + let project = s.project.clone(); + let comp = project.comp(cid).ok_or(EngineError::NoComp)?; let layer = comp.layer(lid).ok_or(EngineError::NoComp)?; let g = layer.props.find_group(uid).ok_or(EngineError::NoComp)?; - let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, s.time_of(cid)); + let ctx = effectcraft_render::EvalCtx::new(&project, cid, comp, s.time_of(cid)); let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)); let analysis = effectcraft_effects::warp_stab::analysis(¶ms); out["layer"] = json!(lid.0); @@ -115,13 +118,30 @@ fn warp_status(s: &mut Session, p: &Value) -> Result { out["frames"] = json!(a.frames.len()); out["size"] = json!(a.size); let lt = layer.layer_time(s.time_of(cid)).seconds(); - if let Some((k, _, plan)) = effectcraft_effects::warp_stab::plan_at(¶ms, lt) { - let m = plan.warps[k].0; + // Solving the plan takes seconds for a long Subspace Warp clip: where jobs run + // elsewhere (the browser) it is never solved here but in a job worker, and the status + // says "stabilizing" until the summary arrives (`WorkerReply::WarpPlan`). + let plan = if s.offloads() { + let cached = effectcraft_effects::warp_stab::cached_summary_at(¶ms, lt); + if cached.is_none() { + out["stabilizing"] = json!(true); + if s.offloaded(JobKind::Warp).is_none() && s.offloaded(JobKind::WarpPlan).is_none() { + let t = s.time_of(cid); + if let Err(e) = s.offload_analysis(WorkerJob::WarpPlan { comp: cid, layer: lid, effect: uid, time: t }) { + log::warn!("warp.status: {e}"); + } + } + } + cached + } else { + effectcraft_effects::warp_stab::summary_at(¶ms, lt) + }; + if let Some((k, plan)) = plan { out["frame"] = json!(k); out["autoScale"] = json!(plan.auto_scale * 100.0); out["crop"] = json!(plan.crop); out["validFraction"] = json!(plan.valid_fraction); - out["warp"] = json!(m); + out["warp"] = json!(plan.warps[k]); let pts = &a.frames[k]; out["features"] = json!(pts.features); out["inliers"] = json!(pts.inliers); diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index b0ef2be5..f022a312 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -262,6 +262,9 @@ impl Session { { out.push(info("roto", "roto", "Roto Brush propagation".into(), p.done, p.total, true, String::new())); } + if let Some(j) = self.offloaded(crate::offload::JobKind::WarpPlan) { + out.push(info("warpPlan", "warp", "Warp Stabilizer: Stabilizing".into(), j.progress.done, j.progress.total, true, String::new())); + } if let Some(j) = self.offloaded(crate::offload::JobKind::ContentFill) { out.push(info("contentFill", "contentFill", "Content-Aware Fill".into(), j.progress.done, j.progress.total, true, String::new())); } @@ -281,6 +284,7 @@ impl Session { "warp" => self.stop_warp(), "camera" => self.stop_camera(), "roto" => self.stop_roto(), + "warpPlan" => self.cancel_offloaded(crate::offload::JobKind::WarpPlan), "contentFill" => self.cancel_offloaded(crate::offload::JobKind::ContentFill), "all" => { let ids: Vec = self.jobs().into_iter().map(|j| j.id).collect(); diff --git a/crates/engine/src/offload.rs b/crates/engine/src/offload.rs index 809d323c..c410ce86 100644 --- a/crates/engine/src/offload.rs +++ b/crates/engine/src/offload.rs @@ -14,7 +14,8 @@ //! | 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) | +//! | Warp Stabilizer analysis | [`Session::start_warp`], then the stabilization plan | the effect's property group (one undo step); the plan's summary ([`WorkerReply::WarpPlan`]) | +//! | Warp Stabilizer stabilization (settings changed after the analysis) | the plan ([`effectcraft_effects::warp_stab::summary_at`]) | its summary, for `warp.status` | //! | 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 | @@ -73,6 +74,15 @@ pub enum WorkerJob { layer: LayerId, effect: Uid, }, + /// The stabilization plan of an analysed Warp Stabilizer at comp time `time` (its settings + /// there): seconds of solving for a long Subspace Warp clip, so not on the page. The + /// summary comes back as [`WorkerReply::WarpPlan`]. + WarpPlan { + comp: ItemId, + layer: LayerId, + effect: Uid, + time: Tick, + }, Camera { comp: ItemId, layer: LayerId, @@ -120,6 +130,7 @@ impl WorkerJob { match self { WorkerJob::Render { .. } => JobKind::Render, WorkerJob::Warp { .. } => JobKind::Warp, + WorkerJob::WarpPlan { .. } => JobKind::WarpPlan, WorkerJob::Camera { .. } => JobKind::Camera, WorkerJob::RotoFreeze { .. } => JobKind::RotoFreeze, WorkerJob::RotoPropagate { .. } => JobKind::RotoPropagate, @@ -134,6 +145,7 @@ impl WorkerJob { match *self { WorkerJob::Render { .. } => None, WorkerJob::Warp { comp, layer, effect } + | WorkerJob::WarpPlan { comp, layer, effect, .. } | WorkerJob::Camera { comp, layer, effect } | WorkerJob::RotoFreeze { comp, layer, effect } | WorkerJob::RotoPropagate { comp, layer, effect, .. } => Some((comp, layer, effect)), @@ -149,6 +161,7 @@ impl WorkerJob { pub enum JobKind { Render, Warp, + WarpPlan, Camera, RotoFreeze, RotoPropagate, @@ -163,6 +176,7 @@ impl JobKind { match self { JobKind::Render => "Render", JobKind::Warp => "Warp Stabilizer Analysis", + JobKind::WarpPlan => "Warp Stabilizer: Stabilizing", JobKind::Camera => "3D Camera Tracker Analysis", JobKind::RotoFreeze => "Freeze", JobKind::RotoPropagate => "Roto Brush Propagation", @@ -197,6 +211,12 @@ pub enum WorkerReply { group: Box, message: String, }, + /// A Warp Stabilizer plan's summary ([`effectcraft_effects::warp_stab::summary_key`]): the + /// page keeps it for `warp.status` instead of solving the plan itself. + WarpPlan { + key: [u64; 2], + summary: Box, + }, /// Roto Brush segmentations computed so far (propagation), not sent before. Segs { segs: Vec, @@ -456,6 +476,11 @@ async fn run_job(s: &mut Session, job: WorkerJob, post: Post) -> Result<(), Stri Ok(()) } WorkerJob::Warp { comp, layer, effect } => analysis(s, kind, target, post, |s| s.start_warp(comp, layer, effect, true).map(|_| ())).await, + WorkerJob::WarpPlan { comp, layer, effect, time } => { + let r = warp_plan(s, comp, layer, effect, time).ok_or("the Warp Stabilizer is not analysed")?; + post(r); + Ok(()) + } WorkerJob::Camera { comp, layer, effect } => analysis(s, kind, target, post, |s| s.start_camera(comp, layer, effect, true).map(|_| ())).await, WorkerJob::RotoFreeze { comp, layer, effect } => { analysis(s, kind, target, post, |s| { @@ -612,10 +637,29 @@ async fn analysis( 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")?; + // The stabilization plan too (before the group, so the page has it once it shows the + // analysis): solving it would otherwise stall the page at its first `warp.status`. + if kind == JobKind::Warp + && let Some(r) = warp_plan(s, comp, layer, uid, s.time_of(comp)) + { + post(r); + } post(WorkerReply::Group { comp, layer, group: Box::new(group), message }); Ok(()) } +/// The Warp Stabilizer plan summary of (comp, layer, effect) with its settings at comp time `t` +/// (computing the plan), as a reply. +fn warp_plan(s: &Session, comp: ItemId, layer: LayerId, effect: Uid, t: Tick) -> Option { + let c = s.project.comp(comp)?; + let l = c.layer(layer)?; + let g = l.props.find_group(effect)?; + let ctx = effectcraft_render::EvalCtx::new(&s.project, comp, c, t); + let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(l, pr)); + let (key, summary) = effectcraft_effects::warp_stab::summary(¶ms)?; + Some(WorkerReply::WarpPlan { key, summary: Box::new((*summary).clone()) }) +} + // ---------------------------------------------------------------- UI side /// Analysis progress of an offloaded job. @@ -735,6 +779,7 @@ impl Session { } WorkerReply::Group { comp, layer, group, message } => results.push((j.kind, comp, layer, group, message)), WorkerReply::Fill { plan, files } => fills.push((plan, files)), + WorkerReply::WarpPlan { key, summary } => effectcraft_effects::warp_stab::store_summary(key, *summary), WorkerReply::Segs { segs } => { for sd in &segs { sd.store(); @@ -792,8 +837,11 @@ impl Session { } else if kind == JobKind::RotoPropagate { self.events.push(Event::Toast { message: format!("Roto Brush: propagated {} frame(s) in {:.1} s", segs, p.elapsed), error: false }); } - // Rendered frames depend on the result (an analysis' group, the segmentation cache). - self.bump(); + // Rendered frames depend on the result (an analysis' group, the segmentation cache); + // not on a plan summary (`warp.status` only). + if kind != JobKind::WarpPlan { + self.bump(); + } } changed } @@ -852,6 +900,7 @@ mod tests { let reqs = [ WorkerJob::Render { items: vec![vec![(item.clone(), "/out.gif".into())]] }, WorkerJob::Warp { comp, layer: LayerId(3), effect: 9 }, + WorkerJob::WarpPlan { comp, layer: LayerId(3), effect: 9, time: Tick::from_seconds_f64(0.5) }, WorkerJob::RotoFreeze { comp, layer: LayerId(3), effect: 9 }, WorkerJob::Camera { comp, layer: LayerId(3), effect: 9 }, WorkerJob::RotoPropagate { comp, layer: LayerId(3), effect: 9, direction: crate::roto::Direction::Forward }, @@ -908,6 +957,15 @@ mod tests { 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::WarpPlan { + key: [1, u64::MAX], + summary: Box::new(effectcraft_effects::warp_stab::PlanSummary { + warps: vec![[[1.0, 0.0, 2.5], [0.0, 1.0, -1.0], [0.0, 0.0, 1.0]]], + auto_scale: 1.08, + crop: Some([1.0, 2.0, 300.0, 200.0]), + valid_fraction: 0.9, + }), + }, WorkerReply::Group { comp, layer: LayerId(1), group: Box::new(g), message: "ok".into() }, WorkerReply::Segs { segs: vec![SegData::new(5, &effectcraft_track::roto::FrameSeg::empty(4, 3))] }, WorkerReply::Fill { diff --git a/crates/engine/src/tests_mask_warp.rs b/crates/engine/src/tests_mask_warp.rs index 7f915e42..e08ce2e6 100644 --- a/crates/engine/src/tests_mask_warp.rs +++ b/crates/engine/src/tests_mask_warp.rs @@ -382,3 +382,77 @@ fn warp_analysis_invalidates_and_cancels() { let _ = FPS; let _ = Tick::ZERO; } + +/// Jobs recorded by the session (the browser's job workers), run on demand in a second session +/// through JSON. +#[derive(Default)] +struct Deferred(std::sync::Mutex)>>); + +impl crate::offload::Offload for Deferred { + fn start(&self, req: crate::offload::WorkerRequest, inbox: std::sync::Arc) -> Result<(), String> { + self.0.lock().unwrap().push((req, inbox)); + Ok(()) + } + fn cancel(&self, _: u64) {} +} + +impl Deferred { + fn kinds(&self) -> Vec { + self.0.lock().unwrap().iter().map(|(r, _)| r.job.kind()).collect() + } + /// Run the recorded jobs in a worker session (sharing `footage`). + fn run(&self, footage: std::sync::Arc) { + let jobs = std::mem::take(&mut *self.0.lock().unwrap()); + for (req, inbox) in jobs { + let req: crate::offload::WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap(); + let mut w = Session { footage: footage.clone(), ..Default::default() }; + let post: crate::offload::Post = std::rc::Rc::new(move |r| inbox.push(serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap())); + crate::offload::run_request(&mut w, req, &post); + } + } +} + +/// M13.32: where jobs run elsewhere (the browser), `warp.status` never solves the +/// stabilization plan on the page's thread (seconds for a long Subspace Warp clip): the +/// analysis job sends the plan's summary with its result, and a settings change has the plan +/// solved by a job ("stabilizing" meanwhile). +#[test] +fn warp_plan_is_solved_in_the_job_not_on_the_page() { + let (mut s, clip, solid) = setup(shaky); + s.execute("edit.clear", json!({"layers": [solid.0]})).unwrap(); + s.execute("layer.select", json!({"layers": [clip.0]})).unwrap(); + s.execute("effect.apply", json!({"effect": "Warp Stabilizer"})).unwrap(); + // Settings no other test uses: the plans of this test are solved nowhere else. + set_param(&mut s, clip, "stabilization/smoothness", json!(41.25)); + let off = std::sync::Arc::new(Deferred::default()); + s.offload = Some(off.clone()); + s.execute_checked("warp.analyze", json!({"layer": clip.0})).unwrap(); + assert_eq!(off.kinds(), [crate::offload::JobKind::Warp]); + off.run(s.footage.clone()); + assert!(s.poll_offload()); + let st = s.execute("warp.status", json!({})).unwrap(); + assert_eq!(st["analyzed"], true, "{st}"); + assert!(st.get("stabilizing").is_none() && st["autoScale"].as_f64().is_some_and(|a| a > 100.0), "the plan came with the analysis: {st}"); + assert!(off.kinds().is_empty(), "nothing more to solve"); + + // A settings change: the page doesn't solve the new plan, a job does. + set_param(&mut s, clip, "stabilization/smoothness", json!(23.75)); + let st = s.execute("warp.status", json!({})).unwrap(); + assert_eq!(st["stabilizing"], true, "{st}"); + assert!(st.get("autoScale").is_none() && st["analyzed"] == true, "{st}"); + s.execute("warp.status", json!({})).unwrap(); + assert_eq!(off.kinds(), [crate::offload::JobKind::WarpPlan], "one job, however often the status is asked"); + assert!(s.jobs().iter().any(|j| j.id == "warpPlan")); + let rev = s.revision; + off.run(s.footage.clone()); + assert!(s.poll_offload()); + assert_eq!(s.revision, rev, "a plan summary changes no frames"); + let st = s.execute("warp.status", json!({})).unwrap(); + assert!(st.get("stabilizing").is_none(), "{st}"); + let w: [[f64; 3]; 3] = serde_json::from_value(st["warp"].clone()).unwrap_or_else(|_| panic!("{st}")); + // The same plan as solved here (without jobs). + s.offload = None; + let here = s.execute("warp.status", json!({})).unwrap(); + assert_eq!(here["warp"], json!(w)); + assert_eq!(here["autoScale"], st["autoScale"]); +} diff --git a/docs/web.md b/docs/web.md index 52e18c82..6414108f 100644 --- a/docs/web.md +++ b/docs/web.md @@ -188,6 +188,16 @@ the same futures with `passes::block_on`; they never wait there). `effectcraft.info().workers` reports `{running, idle, gpu: {adapter} | {cpu: why}, jobs: [{kind, gpu, passes, readbacks, ms}]}` (the last jobs). +**Warp Stabilizer plans off the page (M13.32).** Solving a stabilization plan (Subspace Warp: +an eigen-decomposition per frame, then a mesh fit) takes seconds for a long clip, and +`warp.status` used to solve it on the page the first time it saw the analysis (a ≈ 5 s +stall). Now the analysis job solves the plan for the analysed settings and sends its summary +(`WorkerReply::WarpPlan`: per-frame warps, auto-scale, crop, valid fraction) with the result. +When the settings change later, `warp.status` reports `stabilizing: true` and starts a +`WorkerJob::WarpPlan` job (`jobs.list` id `warpPlan`) instead of solving it; the next status +after it ends has the plan. Frames with the effect render in the frame workers, which solve +their own plans. The desktop solves the plan in place, as before. + **Content-Aware Fill in a job worker (M13.30).** `contentFill.generate` (the panel's Generate Fill Layer) sends its plan (`content_fill::FillPlan`: layer, frames, method, folder) to a job worker (`WorkerJob::ContentFill`, `jobs.list` id `contentFill`): the worker renders the @@ -411,13 +421,15 @@ System, served from it after a reload without rendering, and cleared with `stora the M13.30 ones: a Render Queue job and a Warp Stabilizer analysis on the job worker's own WebGPU device (`workers.jobs`: GPU passes and readbacks per job) while the page stays responsive, and layer buffers written to the disk cache and served from it after a reload and a -purge (`layerHits`). +purge (`layerHits`); M13.32: editing after the Warp Stabilizer analysis (removing it, +pre-composing, a mask) keeps the page's event-loop gaps under 50 ms. Native unit tests cover the storage model (`apps/effectcraft-web/src/store.rs`: write coalescing, file table, `ConfigStore` / `FileOps`, auto-save and crash recovery through the browser store) and the worker protocol (`crates/engine/src/offload.rs`: serde round trips of every request and reply, a render through an in-process offload; `tests_roto.rs`: Roto Brush -propagation through an offload streaming segmentations), the frame-worker protocol +propagation through an offload streaming segmentations; `tests_mask_warp.rs`: Warp Stabilizer +plans solved in the job, never by `warp.status` on the page), the frame-worker protocol (`crates/engine/src/remote.rs`: diff / patch, a replica following edits renders the same pixels; `crates/ui-egui/tests/ui_web_offload.rs`: the frame cache through a remote renderer, `wait: true` deferred until the offloaded job ends) and the Media Browser's virtual tree (`media_browser.rs`,