mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
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:
1 parent
402b4cb52f
commit
a006ed5765
7 files changed
+295
-18
No files matched your search
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(¶ms);
|
||||
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(¶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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(¶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 {
|
||||
|
||||
@@ -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
@@ -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`,
|
||||
|
||||
Reference in new issue
Block a user