diff --git a/crates/engine/src/cmd/animation.rs b/crates/engine/src/cmd/animation.rs
--- a/crates/engine/src/cmd/animation.rs
+++ b/crates/engine/src/cmd/animation.rs
@@ -10,11 +10,16 @@ use effectcraft_project::{GroupKind, Layer, LayerId, LayerSource, Node, Project,
 use effectcraft_time::Tick;
 use serde_json::{Value, json};
 
+use super::effect::{effect_time_error, retime_effect};
 use super::{CommandSpec, bad, frontend, has_comp, has_keys, has_layers, layer_mut, layers_p, match_path_of, resolve_effect_path, selected_leaf_props, str_p};
 use crate::{EngineError, KeyRef, Result, Session, cmd};
 
 /// Preset file format version tag (`*.ecpreset`, JSON).
 pub const PRESET_TAG: &str = "effectcraftAnimationPreset";
+/// The preset format version that `anim.savePreset` writes.
+pub const PRESET_VERSION: u64 = 2;
+/// The first preset version that stores effect keys relative to the earliest key (#1971).
+const RELATIVE_EFFECT_KEYS: u64 = 2;
 
 fn has_selected_props(s: &Session) -> std::result::Result<(), String> {
     has_comp(s)?;
@@ -45,13 +50,26 @@ fn selected_effects(s: &Session) -> Vec<(LayerId, PropGroup)> {
         .collect()
 }
 
+/// The earliest key anywhere in `groups`, nested controls included.
+fn earliest_key<'a>(groups: impl IntoIterator<Item = &'a PropGroup>) -> Option<Tick> {
+    let mut first: Option<Tick> = None;
+    for g in groups {
+        g.walk("", &mut |_, pr| {
+            if let Some(t) = pr.keys.iter().map(|k| k.time).min() {
+                first = Some(first.map_or(t, |f| f.min(t)));
+            }
+        });
+    }
+    first
+}
+
 // ---------------------------------------------------------------- presets
 
 fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
     let path = str_p(p, "path").ok_or_else(|| bad("anim.savePreset", "missing `path`"))?.to_string();
     let comp = s.active_comp().ok_or(EngineError::NoComp)?;
     let t = s.time();
-    let effects: Vec<(LayerId, PropGroup)> = selected_effects(s);
+    let mut effects: Vec<(LayerId, PropGroup)> = selected_effects(s);
     let effect_uids: Vec<Uid> = effects.iter().map(|(_, g)| g.uid).collect();
     let mut props = vec![];
     for (lid, uid) in selected_leaf_props(s) {
@@ -67,7 +85,11 @@ fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
         return Err(bad("anim.savePreset", "select properties or effects to save"));
     }
     // Key times are stored relative to the earliest key so the preset applies at the CTI.
-    let first = props.iter().flat_map(|(_, _, pr)| pr.keys.iter().map(|k| k.time)).min().unwrap_or(Tick::ZERO);
+    let first =
+        props.iter().flat_map(|(_, _, pr)| pr.keys.iter().map(|k| k.time)).chain(earliest_key(effects.iter().map(|(_, g)| g))).min().unwrap_or(Tick::ZERO);
+    for (_, g) in &mut effects {
+        retime_effect(g, 1.0, "anim.savePreset", |t| Ok(Tick(t.0.checked_sub(first.0).ok_or_else(|| effect_time_error("anim.savePreset"))?)))?;
+    }
     let props_json: Vec<Value> = props
         .iter()
         .map(|(_, mp, pr)| {
@@ -82,7 +104,7 @@ fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
     let name = str_p(p, "name")
         .map(str::to_string)
         .unwrap_or_else(|| std::path::Path::new(&path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default());
-    let doc = json!({PRESET_TAG: 1, "name": name, "properties": props_json, "effects": fx_json});
+    let doc = json!({PRESET_TAG: PRESET_VERSION, "name": name, "properties": props_json, "effects": fx_json});
     let text = serde_json::to_string_pretty(&doc).unwrap_or_default();
     s.services.write_file(&path, text.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
     Ok(json!({"path": path, "properties": props.len(), "effects": effects.len()}))
@@ -110,8 +132,16 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
         s.save_prefs();
         s.prefs_revision += 1;
     }
-    let effects: Vec<PropGroup> =
+    let mut effects: Vec<PropGroup> =
         doc["effects"].as_array().map(|a| a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()).unwrap_or_default();
+    // Older presets stored effect keys at the source layer's times.
+    if doc[PRESET_TAG].as_u64().unwrap_or(1) < RELATIVE_EFFECT_KEYS
+        && let Some(first) = earliest_key(&effects)
+    {
+        for g in &mut effects {
+            retime_effect(g, 1.0, "anim.applyPreset", |t| Ok(Tick(t.0.checked_sub(first.0).ok_or_else(|| effect_time_error("anim.applyPreset"))?)))?;
+        }
+    }
     let props: Vec<(String, Property)> = doc["properties"]
         .as_array()
         .map(|a| {
@@ -140,6 +170,7 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
             if let Some(fx) = l.props.sub_mut("effects") {
                 for g in &effects {
                     let mut g = g.clone();
+                    retime_effect(&mut g, 1.0, "anim.applyPreset", |t| Ok(Tick(t.0.checked_add(lt.0).ok_or_else(|| effect_time_error("anim.applyPreset"))?)))?;
                     g.reassign_uids(&mut next);
                     next += 1;
                     fx.children.push(Node::Group(g));
diff --git a/crates/engine/src/cmd/effect.rs b/crates/engine/src/cmd/effect.rs
--- a/crates/engine/src/cmd/effect.rs
+++ b/crates/engine/src/cmd/effect.rs
@@ -415,7 +415,7 @@ fn effects_p(s: &Session, p: &Value, cmd: &str) -> Result<Vec<(LayerId, Uid)>> {
     Ok(sel)
 }
 
-fn effect_time_error(cmd: &str) -> EngineError {
+pub(crate) fn effect_time_error(cmd: &str) -> EngineError {
     bad(cmd, "an effect keyframe converts outside the supported time range")
 }
 
@@ -451,7 +451,7 @@ fn effect_time(layer: &Layer, time: Tick, to_comp: bool, cmd: &str) -> Result<Ti
 
 /// Change the key time coordinate system throughout an effect, including nested controls.
 /// Ease speeds are per second in that coordinate system; reversed time swaps the key sides.
-fn retime_effect(group: &mut PropGroup, scale: f64, cmd: &str, mut time: impl FnMut(Tick) -> Result<Tick>) -> Result<()> {
+pub(crate) fn retime_effect(group: &mut PropGroup, scale: f64, cmd: &str, mut time: impl FnMut(Tick) -> Result<Tick>) -> Result<()> {
     let mut result = Ok(());
     group.walk_mut(&mut |pr| {
         if result.is_err() {
diff --git a/crates/engine/src/tests/effects.rs b/crates/engine/src/tests/effects.rs
--- a/crates/engine/src/tests/effects.rs
+++ b/crates/engine/src/tests/effects.rs
@@ -772,6 +772,101 @@ fn preset_round_trips_effect_properties_by_match_name() {
     }
 }
 
+/// Save the selected effects and properties as a preset, then apply it to `target` at `at`.
+fn save_and_apply_preset(s: &mut Session, name: &str, selected: Vec<(LayerId, u64)>, target: u64, at: f64) -> serde_json::Value {
+    let path = std::env::temp_dir().join(format!("ec-1971-{name}-{}.ecpreset", std::process::id())).to_string_lossy().to_string();
+    s.state.selected_props = selected;
+    s.execute("anim.savePreset", json!({"path": path})).unwrap();
+    s.execute("time.set", json!({"time": at})).unwrap();
+    let r = s.execute("anim.applyPreset", json!({"path": path, "layers": [target]}));
+    let _ = std::fs::remove_file(&path);
+    r.unwrap()
+}
+
+/// The comp times of the keys at `path` on `lid`'s `n`th effect (0-based), or on the layer when
+/// `n` is `None`.
+fn key_times(s: &Session, lid: u64, n: Option<usize>, path: &str) -> Vec<f64> {
+    let l = layer(s, lid);
+    let group = match n {
+        Some(n) => l.effects().unwrap().groups().nth(n).unwrap().clone(),
+        None => l.props.clone(),
+    };
+    group.prop(path).unwrap().keys.iter().map(|k| l.comp_time(k.time).seconds()).collect()
+}
+
+/// #1971: a saved effect's keys start at the playhead of the layer it's applied to, as a saved
+/// property's do, nested controls included, an effect without keys still applies, and the whole
+/// apply is one undo step.
+#[test]
+fn preset_effect_keys_start_at_the_playhead() {
+    let (mut s, a, b) = comp_with_two_solids();
+    let blur = apply(&mut s, a, "Gaussian Blur");
+    let fractal = apply(&mut s, a, "Fractal");
+    let slider = apply(&mut s, a, "Slider Control");
+    animate_effect(&mut s, a, blur, "blurriness", &[(5.0, 10.0), (5.5, 30.0), (6.0, 10.0)]);
+    animate_effect(&mut s, a, fractal, "mandelbrot/mandelbrotX", &[(5.5, 1.25), (7.0, 2.5)]);
+    let before = layer(&s, b);
+    let undo = s.history.undo.len();
+    let r = save_and_apply_preset(&mut s, "effects", vec![(LayerId(a), blur), (LayerId(a), fractal), (LayerId(a), slider)], b, 0.5);
+    assert_eq!(r["applied"], 3, "{r}");
+    assert_eq!(key_times(&s, b, Some(0), "blurriness"), vec![0.5, 1.0, 1.5]);
+    assert_eq!(key_times(&s, b, Some(1), "mandelbrot/mandelbrotX"), vec![1.0, 2.5]);
+    assert_eq!(s.history.undo.len(), undo + 1, "the whole apply is one undo step");
+    s.execute("edit.undo", json!({})).unwrap();
+    assert_eq!(layer(&s, b), before);
+}
+
+/// #1971: effects and properties saved together keep their relative timing: the earliest key of
+/// either starts at the playhead.
+#[test]
+fn preset_effects_and_properties_share_one_anchor() {
+    let (mut s, a, b) = comp_with_two_solids();
+    let blur = apply(&mut s, a, "Gaussian Blur");
+    animate_effect(&mut s, a, blur, "blurriness", &[(5.0, 10.0), (6.0, 30.0)]);
+    for (time, value) in [(4.0, -12.0), (7.0, 12.0)] {
+        s.execute("prop.addKey", json!({"layer": a, "path": "transform/rotation", "time": time, "timeBase": "comp", "value": value})).unwrap();
+    }
+    let rotation = layer(&s, a).props.prop("transform/rotation").unwrap().uid;
+    save_and_apply_preset(&mut s, "mixed", vec![(LayerId(a), blur), (LayerId(a), rotation)], b, 1.0);
+    assert_eq!(key_times(&s, b, None, "transform/rotation"), vec![1.0, 4.0]);
+    assert_eq!(key_times(&s, b, Some(0), "blurriness"), vec![2.0, 3.0]);
+}
+
+/// #1971: on a stretched layer a saved effect's keys land where a saved property's keys do.
+#[test]
+fn preset_effect_keys_follow_a_stretched_layer_like_properties() {
+    let (mut s, a, b) = comp_with_two_solids();
+    let blur = apply(&mut s, a, "Gaussian Blur");
+    animate_effect(&mut s, a, blur, "blurriness", &[(5.0, 10.0), (6.0, 30.0)]);
+    for (time, value) in [(5.0, -12.0), (6.0, 12.0)] {
+        s.execute("prop.addKey", json!({"layer": a, "path": "transform/rotation", "time": time, "timeBase": "comp", "value": value})).unwrap();
+    }
+    let rotation = layer(&s, a).props.prop("transform/rotation").unwrap().uid;
+    set_layer_clock(&mut s, b, 1.0, 200.0);
+    save_and_apply_preset(&mut s, "stretched", vec![(LayerId(a), blur), (LayerId(a), rotation)], b, 2.0);
+    let property = key_times(&s, b, None, "transform/rotation");
+    assert_eq!(property.first(), Some(&2.0), "the property's keys start at the playhead");
+    assert_eq!(key_times(&s, b, Some(0), "blurriness"), property, "and the effect's keys land with them");
+}
+
+/// #1971: a preset saved before effect keys were stored relative to the earliest key (version 1)
+/// has its effects' keys at the source layer's times; they also start at the playhead.
+#[test]
+fn old_preset_effect_keys_start_at_the_playhead() {
+    let (mut s, a, b) = comp_with_two_solids();
+    let blur = apply(&mut s, a, "Gaussian Blur");
+    animate_effect(&mut s, a, blur, "blurriness", &[(5.0, 10.0), (6.0, 30.0)]);
+    let old = json!({
+        "effectcraftAnimationPreset": 1,
+        "name": "shake",
+        "properties": [],
+        "effects": [serde_json::to_value(effect_group(&s, a, blur)).unwrap()],
+    });
+    s.execute("time.set", json!({"time": 0.5})).unwrap();
+    s.execute("anim.applyPreset", json!({"preset": old, "layers": [b]})).unwrap();
+    assert_eq!(key_times(&s, b, Some(0), "blurriness"), vec![0.5, 1.5]);
+}
+
 /// #2064: `prop.addKey` accepts the `effects/<match name>[#n]/<property>` path that
 /// `essential.list` reports.
 #[test]
