diff --git a/crates/effects/src/warp_stab.rs b/crates/effects/src/warp_stab.rs index 226654a04..b49765c28 100644 --- a/crates/effects/src/warp_stab.rs +++ b/crates/effects/src/warp_stab.rs @@ -223,6 +223,19 @@ fn plan_cached(akey: u64, a: &WarpAnalysis, s: &StabSettings) -> Arc { p } +/// Validate and cache the default stabilization plan after analysis. Use the serialized +/// analysis so the cached plan has exactly the same inputs as the first render, including +/// any floating-point round trip through JSON. Other settings still get their own plan. +pub fn prepare_default_plan(json: &str) -> bool { + let Some((key, a)) = analysis_cached(json) else { return false }; + if a.is_empty() { + return false; + } + let fps = if a.frame_duration > 0.0 { 1.0 / a.frame_duration } else { 30.0 }; + let p = plan_cached(key, &a, &StabSettings { fps, ..Default::default() }); + p.warps.len() == a.frames.len() && p.warps.iter().all(|h| h.0.iter().flatten().all(|v| v.is_finite())) +} + /// The stored analysis of an instance (`None` when not analysed). pub fn analysis(params: &Params) -> Option> { let j = s(params, ANALYSIS); @@ -601,6 +614,42 @@ mod tests { assert_eq!(render(&ctx, Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 }).img.data, img.data); } + #[test] + fn prepared_default_plan_preserves_first_render_and_is_reused() { + let json = analysis_json(25, 64.0, 48.0, |k| [((k * 7) % 5) as f64 - 2.0, k as f64 * 0.1]); + let mut a = WarpAnalysis::from_json(&json).unwrap(); + a.start = 0.012345678901234567; + a.tracks = (0..24) + .map(|id| effectcraft_track::camtrack::Track2D { + id, + start: 0, + pts: (0..25).map(|k| [8.0 + (id % 6) as f32 * 8.0 + ((k * 7) % 5) as f32, 8.0 + (id / 6) as f32 * 8.0 + k as f32 * 0.1]).collect(), + }) + .collect(); + let json = a.to_json(); + let ps = params(&json, 2, 0); + let ctx = EffectCtx { params: &ps, time: a.start, layer_size: a.size, seed: 1, adjustment: false, env: EffectEnv::default() }; + let input = Buf { img: frame([30.0, 22.0]), offset: [0.0; 2], scale: 1.0 }; + // Original flow: validate, discard the plan, then render from the stored analysis. + let _discarded = plan(&a, &StabSettings { fps: 25.0, ..Default::default() }); + let before = render(&ctx, input.clone()); + let key = (fnv(json.as_bytes()), settings_key(&settings(&ps, 25.0))); + plans().lock().unwrap().remove(&key); + assert!(prepare_default_plan(&json)); + let prepared = plans().lock().unwrap().get(&key).unwrap().clone(); + assert!(!prepared.meshes.is_empty(), "exercise the Subspace Warp plan"); + let after = render(&ctx, input); + let (_, _, rendered) = plan_at(&ps, a.start).unwrap(); + assert!(Arc::ptr_eq(&prepared, &rendered)); + assert_eq!(before.offset, after.offset); + assert_eq!(before.scale.to_bits(), after.scale.to_bits()); + for (a, b) in before.img.data.iter().flatten().zip(after.img.data.iter().flatten()) { + assert_eq!(a.to_bits(), b.to_bits()); + } + assert!(!prepare_default_plan("invalid")); + assert!(!prepare_default_plan(&WarpAnalysis::default().to_json())); + } + #[test] fn nested_param_lookup() { let mut p = Params::default(); diff --git a/crates/engine/src/analysis/warp.rs b/crates/engine/src/analysis/warp.rs index 5472d4e8c..71ee45ee6 100644 --- a/crates/engine/src/analysis/warp.rs +++ b/crates/engine/src/analysis/warp.rs @@ -4,7 +4,7 @@ //! the Warp Stabilizer, `Renderer::layer_input`) for every frame between the layer's In and Out //! points on a background thread, and feeds the frames to `effectcraft_track::stabilize`'s //! analyzer (step 1 of 2, "Analyzing in background"); step 2 ("Stabilizing") derives the -//! stabilization plan once to validate the result. When the job finishes, the analysis is +//! stabilization plan once to validate and cache the result. When the job finishes, the analysis is //! written into the effect's hidden Analysis parameter as one undo step, together with a key of //! what it was made from (the layer's source, In/Out, start, stretch and Time Remap). Edits that //! change those clear the analysis (`invalidate`, run after every edit) and queue the effect @@ -21,7 +21,7 @@ use effectcraft_project::{GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSour use effectcraft_raster::Image; use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, Renderer}; use effectcraft_time::Tick; -use effectcraft_track::stabilize::{AnalyzeOpts, Analyzer, StabSettings, WarpAnalysis}; +use effectcraft_track::stabilize::{AnalyzeOpts, Analyzer, WarpAnalysis}; use serde::Serialize; use crate::tracking::lock; @@ -245,9 +245,8 @@ async fn run_work(w: WarpWork, shared: &WarpShared) { let lt0 = layer.layer_time(w.times[0]).seconds(); let fd = if w.times.len() > 1 { layer.layer_time(w.times[1]).seconds() - lt0 } else { comp.frame_duration().seconds() }; let a = an.finish(lt0, fd); - // Step 2: derive a plan once (validates the motion and warms nothing else up). - let plan = effectcraft_track::stabilize::plan(&a, &StabSettings { fps: if fd > 0.0 { 1.0 / fd } else { 30.0 }, ..Default::default() }); - if plan.warps.len() != a.frames.len() || plan.warps.iter().any(|h| h.0.iter().flatten().any(|v| !v.is_finite())) { + // Plan from the stored JSON, exactly as rendering does, and retain it for the first render. + if !warp_stab::prepare_default_plan(&a.to_json()) { return fail("the stabilization could not be computed"); } *lock(&shared.result) = Some(a);