Reuse the validated Warp Stabilizer plan on first render

Prepare the default plan through the effects cache after analysis instead of discarding it. Plan from the serialized analysis so the first rendered frame retains identical floating-point inputs and output. Test bitwise render equality and cache reuse.

Part of #2322
This commit is contained in:
vangel76
2026-10-25 00:14:57 +02:00
parent 991904888a
commit 3f41f5c026
2 changed files with 53 additions and 5 deletions
+49
View File
@@ -223,6 +223,19 @@ fn plan_cached(akey: u64, a: &WarpAnalysis, s: &StabSettings) -> Arc<Plan> {
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<Arc<WarpAnalysis>> {
let j = s(params, ANALYSIS);
@@ -604,6 +617,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();
+4 -5
View File
@@ -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);