M13.32: web main-thread stall: Warp Stabilizer plans solved in job workers

warp.status solved the stabilization plan (Subspace Warp eigen-decompositions and mesh fits)
on the page the first time it saw an analysis: ~4.9 s blocking the browser's event loop in
the smoke test. The analysis job now sends the plan summary with its result, and a settings
change has a WarpPlan job solve it (status reports stabilizing meanwhile). Desktop unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-04 03:40:46 -04:00
1 parent 402b4cb52f
commit a006ed5765
7 files changed
+295 -18

No files matched your search

+10 -6
View File
@@ -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" };
}
+107 -2
View File
@@ -203,10 +203,14 @@ fn analysis_cached(json: &str) -> Option<(u64, Arc<WarpAnalysis>)> {
Some((key, a))
}
fn plan_cached(akey: u64, a: &WarpAnalysis, s: &StabSettings) -> Arc<Plan> {
fn plans() -> &'static PlanCache {
static C: OnceLock<PlanCache> = OnceLock::new();
C.get_or_init(Default::default)
}
fn plan_cached(akey: u64, a: &WarpAnalysis, s: &StabSettings) -> Arc<Plan> {
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<WarpAnalysis>,
(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<HashMap<(u64, u64), Arc<PlanSummary>>>;
fn summaries() -> &'static SummaryCache {
static C: OnceLock<SummaryCache> = 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<WarpAnalysis>, 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<PlanSummary>) {
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<PlanSummary>)> {
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<PlanSummary>)> {
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<PlanSummary>)> {
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 {
+25 -5
View File
@@ -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<CommandSpec> {
@@ -101,10 +102,12 @@ fn warp_status(s: &mut Session, p: &Value) -> Result<Value> {
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(&params);
out["layer"] = json!(lid.0);
@@ -115,13 +118,30 @@ fn warp_status(s: &mut Session, p: &Value) -> Result<Value> {
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(&params, 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(&params, 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(&params, 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);
+4
View File
@@ -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<String> = self.jobs().into_iter().map(|j| j.id).collect();
+61 -3
View File
@@ -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<PropGroup>,
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<effectcraft_effects::warp_stab::PlanSummary>,
},
/// Roto Brush segmentations computed so far (propagation), not sent before.
Segs {
segs: Vec<SegData>,
@@ -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<WorkerReply> {
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(&params)?;
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 {
+74
View File
@@ -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<Vec<(crate::offload::WorkerRequest, std::sync::Arc<crate::offload::Inbox>)>>);
impl crate::offload::Offload for Deferred {
fn start(&self, req: crate::offload::WorkerRequest, inbox: std::sync::Arc<crate::offload::Inbox>) -> Result<(), String> {
self.0.lock().unwrap().push((req, inbox));
Ok(())
}
fn cancel(&self, _: u64) {}
}
impl Deferred {
fn kinds(&self) -> Vec<crate::offload::JobKind> {
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<dyn effectcraft_render::FootageSource>) {
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"]);
}
+14 -2
View File
@@ -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`,