diff --git a/crates/effects/src/warp_stab.rs b/crates/effects/src/warp_stab.rs
--- 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<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();
diff --git a/crates/engine/src/analysis/warp.rs b/crates/engine/src/analysis/warp.rs
--- 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);
