simple_editor\engine/
presets.rs

1//! Keyframe presets, motion presets and clip templates — capture from clips and apply back.
2//! Curve presets store keys normalised to 0..1 of the clip length (stretched to the target clip's
3//! duration when applied) or as absolute seconds (kept as saved).
4
5use crate::model::{
6    Animated, Asset, Clip, ClipKind, Ease, Effect, EffectKind, Id, Keyframe, NodeGraph, Project, MIN_CLIP,
7};
8use crate::settings::{CurvePreset, EffectPreset, MotionPreset, Template};
9use std::collections::HashMap;
10
11/// Snapshot a property's keys as a preset (normalised unless `absolute`). None if the property has no keys.
12pub fn capture_curve(name: &str, anim: &Animated, clip_duration: f64, absolute: bool) -> Option<CurvePreset> {
13    if anim.keys.is_empty() {
14        return None;
15    }
16    let d = clip_duration.max(MIN_CLIP);
17    let keys = anim.keys.iter().map(|k| Keyframe { t: if absolute { k.t } else { k.t / d }, ..*k }).collect();
18    Some(CurvePreset { name: name.into(), keys, absolute })
19}
20
21/// Apply a curve preset to a property: normalised times are stretched to `clip_duration`, absolute
22/// presets keep their seconds. Replaces existing keys.
23/// `scaled` is kept for the callers' "exact" button but is only meaningful for absolute presets, which
24/// ignore the duration anyway — placing a normalised preset's 0..1 times as seconds would crush the
25/// whole animation into the clip's first second. See `capture_curve`: nothing but tests saves absolute.
26pub fn apply_curve(preset: &CurvePreset, anim: &mut Animated, clip_duration: f64, _scaled: bool) {
27    anim.keys = scaled_keys(&preset.keys, preset.absolute, clip_duration);
28}
29
30/// `apply_curve`'s sibling: ADD the preset's keys to what the property already has, shifted to start at
31/// `offset` seconds, so two presets can be layered on one property without hand-editing. `span` is the
32/// length a normalised preset is stretched over (callers pass the clip's remaining time so the merged
33/// keys land inside it). A preset key falling on an existing key's time wins.
34pub fn merge_curve(preset: &CurvePreset, anim: &mut Animated, span: f64, offset: f64) {
35    for k in scaled_keys(&preset.keys, preset.absolute, span) {
36        let t = k.t + offset;
37        let k = Keyframe { t, ..k };
38        match anim.key_index_at(t) {
39            Some(i) => anim.keys[i] = k,
40            None => {
41                let i = anim.keys.partition_point(|o| o.t < t);
42                anim.keys.insert(i, k);
43            }
44        }
45    }
46}
47
48/// Convenience: keyframes of a preset scaled to `duration` (used by the curve editor preview).
49pub fn scaled_keys(keys: &[Keyframe], absolute: bool, duration: f64) -> Vec<Keyframe> {
50    keys.iter().map(|k| Keyframe { t: if absolute { k.t } else { k.t * duration }, ..*k }).collect()
51}
52
53fn motion(name: &str, props: &[(&str, &[(f64, f64, Ease)])]) -> MotionPreset {
54    MotionPreset {
55        name: name.into(),
56        props: props
57            .iter()
58            .map(|&(p, keys)| {
59                let keys = keys.iter().map(|&(t, v, ease)| Keyframe { t, v, ease }).collect();
60                (p.to_string(), CurvePreset { name: p.into(), keys, absolute: false })
61            })
62            .collect(),
63    }
64}
65
66/// Built-in motion presets (slide in/out from each side, zoom in/out "Ken Burns", pop, fade in/out, spin).
67pub fn builtin_motions() -> Vec<MotionPreset> {
68    // ponytail: slide offsets assume a ~1080p project — a wider clip just starts a bit on-screen.
69    const W: f64 = 1920.0;
70    const H: f64 = 1080.0;
71    let smooth = Ease::PRESETS[0].1;
72    let overshoot = Ease::PRESETS[4].1;
73    let lin = Ease::Linear;
74    vec![
75        motion("Slide In Left", &[("Position X", &[(0.0, -W, smooth), (0.25, 0.0, lin)])]),
76        motion("Slide In Right", &[("Position X", &[(0.0, W, smooth), (0.25, 0.0, lin)])]),
77        motion("Slide In Top", &[("Position Y", &[(0.0, -H, smooth), (0.25, 0.0, lin)])]),
78        motion("Slide In Bottom", &[("Position Y", &[(0.0, H, smooth), (0.25, 0.0, lin)])]),
79        motion("Slide Out Left", &[("Position X", &[(0.75, 0.0, smooth), (1.0, -W, lin)])]),
80        motion("Slide Out Right", &[("Position X", &[(0.75, 0.0, smooth), (1.0, W, lin)])]),
81        motion("Slide Out Top", &[("Position Y", &[(0.75, 0.0, smooth), (1.0, -H, lin)])]),
82        motion("Slide Out Bottom", &[("Position Y", &[(0.75, 0.0, smooth), (1.0, H, lin)])]),
83        motion("Zoom In (Ken Burns)", &[("Scale", &[(0.0, 1.0, lin), (1.0, 1.15, lin)])]),
84        motion("Zoom Out (Ken Burns)", &[("Scale", &[(0.0, 1.15, lin), (1.0, 1.0, lin)])]),
85        motion("Pop", &[("Scale", &[(0.0, 0.0, overshoot), (0.25, 1.0, lin)])]),
86        motion("Fade In", &[("Opacity", &[(0.0, 0.0, smooth), (0.25, 1.0, lin)])]),
87        motion("Fade Out", &[("Opacity", &[(0.75, 1.0, smooth), (1.0, 0.0, lin)])]),
88        motion("Spin", &[("Rotation", &[(0.0, 0.0, smooth), (1.0, 360.0, lin)])]),
89    ]
90}
91
92/// The fixed (label, property) pairs shared by capture and apply.
93const PROPS: [&str; 7] = ["Position X", "Position Y", "Scale", "Rotation", "Opacity", "Volume", "Pan"];
94
95fn prop_of<'a>(clip: &'a mut Clip, label: &str) -> Option<&'a mut Animated> {
96    match label {
97        "Position X" => Some(&mut clip.x),
98        "Position Y" => Some(&mut clip.y),
99        "Scale" => Some(&mut clip.scale),
100        "Rotation" => Some(&mut clip.rotation),
101        "Opacity" => Some(&mut clip.opacity),
102        "Volume" => Some(&mut clip.volume),
103        "Pan" => Some(&mut clip.pan),
104        _ => None,
105    }
106}
107
108/// Capture every animated property of `clip` (incl. effect params as "<Effect>: <Param>").
109pub fn capture_motion(name: &str, clip: &Clip) -> MotionPreset {
110    let mut props = Vec::new();
111    let fixed = [&clip.x, &clip.y, &clip.scale, &clip.rotation, &clip.opacity, &clip.volume, &clip.pan];
112    for (label, anim) in PROPS.iter().zip(fixed) {
113        if let Some(c) = capture_curve(label, anim, clip.duration, false) {
114            props.push((label.to_string(), c));
115        }
116    }
117    // ponytail: duplicate effects of the same kind share a label — the first instance wins on apply.
118    for e in &clip.effects {
119        for (i, spec) in e.specs().iter().enumerate() {
120            let Some(anim) = e.params.get(i) else { continue };
121            let label = format!("{}: {}", e.kind.name(), spec.name);
122            if let Some(c) = capture_curve(&label, anim, clip.duration, false) {
123                props.push((label, c));
124            }
125        }
126    }
127    MotionPreset { name: name.into(), props }
128}
129
130/// Resolve a motion preset label to the clip's property, creating the effect an "<Effect>: <Param>"
131/// label needs. None for labels this clip cannot hold.
132fn motion_prop<'a>(clip: &'a mut Clip, label: &str) -> Option<&'a mut Animated> {
133    if prop_of(clip, label).is_some() {
134        return prop_of(clip, label);
135    }
136    let (ename, pname) = label.split_once(": ")?;
137    let kind = EffectKind::ALL.into_iter().find(|k| k.name() == ename)?;
138    let pi = kind.params().iter().position(|p| p.name == pname)?;
139    if !clip.effects.iter().any(|e| e.kind == kind) {
140        clip.effects.push(Effect::new(kind));
141    }
142    clip.effects.iter_mut().find(|e| e.kind == kind).and_then(|e| e.params.get_mut(pi))
143}
144
145/// Apply a motion preset to a clip (properties it doesn't have are skipped; effect params create the
146/// effect when missing). `scaled` as in `apply_curve`.
147pub fn apply_motion(preset: &MotionPreset, clip: &mut Clip, scaled: bool) {
148    let dur = clip.duration;
149    for (label, curve) in &preset.props {
150        if let Some(anim) = motion_prop(clip, label) {
151            apply_curve(curve, anim, dur, scaled);
152        }
153    }
154}
155
156/// Layer a motion preset on top of the clip's existing keys, starting at clip-local `offset` and
157/// stretched over the time left in the clip (so "slide in left" then "slide in right" can be stacked).
158pub fn merge_motion(preset: &MotionPreset, clip: &mut Clip, offset: f64) {
159    let span = (clip.duration - offset).max(MIN_CLIP);
160    for (label, curve) in &preset.props {
161        if let Some(anim) = motion_prop(clip, label) {
162            merge_curve(curve, anim, span, offset);
163        }
164    }
165}
166
167/// Snapshot a clip's node graph if it has one, else its effect stack. The JSON's shape is what tells
168/// the two apart afterwards (`EffectPreset::is_graph`) — nothing else has to be stored.
169pub fn capture_effects(name: &str, clip: &Clip) -> EffectPreset {
170    let json = match clip.graph.as_ref().filter(|_| clip.uses_graph()) {
171        Some(g) => serde_json::to_string(g),
172        None => serde_json::to_string(&clip.effects),
173    };
174    EffectPreset { name: name.into(), json: json.unwrap_or_default() }
175}
176
177/// Apply a preset to `clip`: a graph replaces the clip's graph (with fresh node ids, so a later
178/// `add_node` cannot hand out one the preset already used), a stack replaces the effects *and* drops
179/// the graph, which would otherwise keep shadowing them. False when the JSON is not what it claims.
180pub fn apply_effects(preset: &EffectPreset, project: &mut Project, clip: Id) -> bool {
181    if preset.is_graph() {
182        let Ok(mut g) = serde_json::from_str::<NodeGraph>(&preset.json) else { return false };
183        let map: HashMap<Id, Id> = g.nodes.iter().map(|n| (n.id, project.new_id())).collect();
184        g.edges.retain(|e| map.contains_key(&e.from) && map.contains_key(&e.to));
185        for n in &mut g.nodes {
186            n.id = map[&n.id];
187        }
188        for e in &mut g.edges {
189            e.from = map[&e.from];
190            e.to = map[&e.to];
191        }
192        let Some(c) = project.clip_mut(clip) else { return false };
193        c.graph = Some(g);
194    } else {
195        let Ok(fx) = serde_json::from_str::<Vec<Effect>>(&preset.json) else { return false };
196        let Some(c) = project.clip_mut(clip) else { return false };
197        c.effects = fx;
198        c.graph = None;
199    }
200    true
201}
202
203/// True when a template holds nothing but adjustment layers — the Presets pane gives those their own
204/// section. ponytail: decodes the JSON per frame the list is drawn; templates are a handful of small
205/// blobs, memoise if that stops being true.
206pub fn is_adjustment_template(t: &Template) -> bool {
207    decode_template(t).is_some_and(|(c, _)| !c.is_empty() && c.iter().all(|c| c.kind == ClipKind::Adjustment))
208}
209
210/// True when a template holds at least one container clip.
211pub fn is_container_template(t: &Template) -> bool {
212    decode_template(t).is_some_and(|(c, _)| c.iter().any(|c| c.container))
213}
214
215#[derive(serde::Serialize, serde::Deserialize)]
216struct TemplateData {
217    clips: Vec<Clip>,
218    assets: Vec<Asset>,
219}
220
221/// Serialise a group of clips (+ the assets they use) into a Template (times relative to the earliest start).
222pub fn capture_template(name: &str, project: &Project, clip_ids: &[crate::model::Id]) -> Template {
223    let mut clips: Vec<Clip> = clip_ids.iter().filter_map(|&id| project.clip(id)).cloned().collect();
224    let start = clips.iter().map(|c| c.start).fold(f64::INFINITY, f64::min);
225    let mut assets = Vec::new();
226    for c in &mut clips {
227        c.start -= if start.is_finite() { start } else { 0.0 };
228        if c.uses_asset() && !assets.iter().any(|a: &Asset| a.id == c.asset) {
229            if let Some(a) = project.asset(c.asset) {
230                assets.push(a.clone());
231            }
232        }
233    }
234    let json = serde_json::to_string(&TemplateData { clips, assets }).unwrap_or_default();
235    Template { name: name.into(), json }
236}
237
238/// Decode a template into (clips, assets) ready for `Project::place_clips`. None on malformed JSON.
239pub fn decode_template(t: &Template) -> Option<(Vec<Clip>, Vec<Asset>)> {
240    serde_json::from_str::<TemplateData>(&t.json).ok().map(|d| (d.clips, d.assets))
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use crate::model::{AudioStreamInfo, ClipKind, Id, NodeKind};
247    use crate::settings::Settings;
248
249    fn asset(id: Id, dur: f64, streams: usize) -> Asset {
250        Asset {
251            id,
252            path: format!("C:/preset-test-{id}.mp4"),
253            kind: ClipKind::Video,
254            duration: dur,
255            width: 1280,
256            height: 720,
257            fps: 30.0,
258            audio_streams: (0..streams)
259                .map(|i| AudioStreamInfo { index: i, channels: 2, sample_rate: 48000, ..Default::default() })
260                .collect(),
261            codec: "h264".into(),
262            folder: String::new(),
263            tags: Vec::new(),
264            label: 0,
265            description: String::new(),
266        }
267    }
268
269    #[test]
270    fn curve_roundtrip_scaled_and_exact() {
271        let mut a = Animated::new(0.0);
272        a.toggle_key(0.0);
273        a.set_at(1.0, 5.0);
274        a.set_at(2.0, 10.0);
275        a.set_ease_at(0.0, Ease::EaseIn);
276        let p = capture_curve("c", &a, 2.0, false).unwrap();
277        assert!(!p.absolute);
278        assert!((p.keys[1].t - 0.5).abs() < 1e-9, "normalised to 0..1");
279        // apply scaled back to the same duration → identical values at sample times, ease kept
280        let mut b = Animated::new(0.0);
281        apply_curve(&p, &mut b, 2.0, true);
282        for t in [0.0, 0.3, 0.5, 1.0, 1.7, 2.0] {
283            assert!((a.at(t) - b.at(t)).abs() < 1e-9, "t={t}");
284        }
285        assert_eq!(b.keys[0].ease, Ease::EaseIn);
286        // scaled to twice the duration → same values at proportional times
287        apply_curve(&p, &mut b, 4.0, true);
288        assert!((b.at(2.0) - a.at(1.0)).abs() < 1e-9);
289        assert!((b.keys[2].t - 4.0).abs() < 1e-9);
290        // "exact" on a normalised preset still stretches — placing 0..1 as seconds would squash the
291        // whole animation into the first second of the clip
292        apply_curve(&p, &mut b, 4.0, false);
293        assert!((b.keys[2].t - 4.0).abs() < 1e-9);
294        // absolute preset: seconds survive any target duration, scaled or not
295        let pa = capture_curve("c", &a, 2.0, true).unwrap();
296        let mut c = Animated::new(0.0);
297        apply_curve(&pa, &mut c, 10.0, true);
298        assert!((c.keys[1].t - 1.0).abs() < 1e-9);
299        assert!((c.at(1.0) - 5.0).abs() < 1e-9);
300        // no keys → no preset
301        assert!(capture_curve("x", &Animated::new(1.0), 2.0, false).is_none());
302        // scaled_keys helper
303        let sk = scaled_keys(&p.keys, p.absolute, 8.0);
304        assert!((sk[1].t - 4.0).abs() < 1e-9);
305        let sk = scaled_keys(&pa.keys, pa.absolute, 8.0);
306        assert!((sk[1].t - 1.0).abs() < 1e-9);
307    }
308
309    #[test]
310    fn motion_capture_apply_with_effects() {
311        let mut c = Clip::new(1, ClipKind::Video, "c", 0.0, 2.0);
312        c.x.toggle_key(0.0);
313        c.x.set_at(2.0, 100.0);
314        c.effects.push(Effect::new(EffectKind::Blur));
315        c.effects[0].params[0].toggle_key(0.0);
316        c.effects[0].params[0].set_at(2.0, 20.0);
317        let m = capture_motion("m", &c);
318        let names: Vec<&str> = m.props.iter().map(|(n, _)| n.as_str()).collect();
319        assert_eq!(names, vec!["Position X", "Blur: Radius"]);
320        // apply to a fresh clip of another length: effect created, values reproduced at the scaled times
321        let mut d = Clip::new(2, ClipKind::Video, "d", 0.0, 4.0);
322        apply_motion(&m, &mut d, true);
323        assert!((d.x.at(4.0) - 100.0).abs() < 1e-9);
324        assert!((d.x.at(2.0) - c.x.at(1.0)).abs() < 1e-9);
325        assert_eq!(d.effects.len(), 1);
326        assert_eq!(d.effects[0].kind, EffectKind::Blur);
327        assert!((d.effects[0].params[0].at(4.0) - 20.0).abs() < 1e-9);
328        // unknown properties are skipped without touching the clip
329        let bogus = MotionPreset {
330            name: "b".into(),
331            props: vec![("Nope: Nothing".into(), CurvePreset::default()), ("Nonsense".into(), CurvePreset::default())],
332        };
333        let before = d.effects.len();
334        apply_motion(&bogus, &mut d, true);
335        assert_eq!(d.effects.len(), before);
336    }
337
338    #[test]
339    fn merge_keeps_existing_keys_and_layers_at_the_offset() {
340        // a property already animated 0..1 s
341        let mut a = Animated::new(0.0);
342        a.toggle_key(0.0);
343        a.set_at(1.0, 5.0);
344        let p = CurvePreset {
345            name: "slide".into(),
346            keys: vec![
347                Keyframe { t: 0.0, v: -100.0, ease: Ease::EaseIn },
348                Keyframe { t: 0.5, v: 0.0, ease: Ease::Linear },
349            ],
350            absolute: false,
351        };
352        // merged over the 2 s left after the offset: keys at 2.0 and 3.0, the old two untouched
353        merge_curve(&p, &mut a, 2.0, 2.0);
354        let ts: Vec<f64> = a.keys.iter().map(|k| k.t).collect();
355        assert_eq!(ts, vec![0.0, 1.0, 2.0, 3.0]);
356        assert!((a.at(1.0) - 5.0).abs() < 1e-9, "existing keys survive");
357        assert!((a.at(2.0) + 100.0).abs() < 1e-9);
358        assert_eq!(a.keys[2].ease, Ease::EaseIn);
359        // a preset key landing on an existing time wins, and stays sorted
360        merge_curve(&p, &mut a, 2.0, 1.0);
361        let ts: Vec<f64> = a.keys.iter().map(|k| k.t).collect();
362        assert_eq!(ts, vec![0.0, 1.0, 2.0, 3.0]);
363        assert!((a.at(1.0) + 100.0).abs() < 1e-9, "preset overwrote the key at 1.0");
364        // motion level: layering two presets on the same property keeps both
365        let mut c = Clip::new(1, ClipKind::Video, "c", 0.0, 4.0);
366        let m = MotionPreset { name: "m".into(), props: vec![("Position X".into(), p.clone())] };
367        merge_motion(&m, &mut c, 0.0); // 0.0 + 2.0
368        merge_motion(&m, &mut c, 2.5); // 2.5 + 3.25
369        assert_eq!(c.x.keys.len(), 4, "both layers are there: {:?}", c.x.keys);
370        assert!(c.x.keys.windows(2).all(|w| w[0].t < w[1].t), "sorted");
371        // effect params create their effect just like apply_motion
372        let m = MotionPreset { name: "m".into(), props: vec![("Blur: Radius".into(), p)] };
373        merge_motion(&m, &mut c, 0.0);
374        assert_eq!(c.effects.len(), 1);
375        assert!(c.effects[0].params[0].is_animated());
376    }
377
378    #[test]
379    fn builtin_motions_apply_clean() {
380        let motions = builtin_motions();
381        assert!(motions.len() >= 12);
382        for m in motions {
383            let mut c = Clip::new(1, ClipKind::Video, "c", 0.0, 3.0);
384            apply_motion(&m, &mut c, true);
385            assert!(c.all_animated().iter().any(|a| a.is_animated()), "{} did nothing", m.name);
386            for a in c.all_animated() {
387                for k in &a.keys {
388                    assert!(k.t >= -1e-9 && k.t <= 3.0 + 1e-9, "{}: key at {}", m.name, k.t);
389                }
390            }
391        }
392    }
393
394    #[test]
395    fn template_roundtrip_place() {
396        let mut p = Project::from_media(asset(0, 10.0, 1));
397        let ids: Vec<Id> = p.all_clips().map(|(_, c)| c.id).collect();
398        assert_eq!(ids.len(), 2); // video + audio
399        let t = capture_template("Pair", &p, &ids);
400        let (clips, assets) = decode_template(&t).unwrap();
401        assert_eq!((clips.len(), assets.len()), (2, 1));
402        assert!(clips.iter().all(|c| c.start.abs() < 1e-9));
403        // place into a different project: fresh ids, asset re-added by path, clips at the new time
404        let mut q = Project::from_media(asset(7, 5.0, 0));
405        let new = q.place_clips(clips, assets, 20.0);
406        assert_eq!(new.len(), 2);
407        for id in &new {
408            let c = q.clip(*id).unwrap();
409            assert!((c.start - 20.0).abs() < 1e-9);
410            let a = q.asset(c.asset).expect("asset remapped");
411            assert_eq!(a.path, "C:/preset-test-0.mp4");
412        }
413        assert_eq!(q.assets.len(), 2);
414        // times are stored relative to the earliest start
415        let mut r = Project::new();
416        let a = r.add_text_clip(3.0, 2.0);
417        let b = r.add_text_clip(6.0, 1.0);
418        let t = capture_template("Texts", &r, &[a, b]);
419        let (clips, _) = decode_template(&t).unwrap();
420        assert!(clips[0].start.abs() < 1e-9);
421        assert!((clips[1].start - 3.0).abs() < 1e-9);
422        // malformed JSON → None
423        assert!(decode_template(&Template { name: "x".into(), json: "{ nope".into() }).is_none());
424    }
425    /// Capture -> settings.json -> apply, for both flavours of effect preset.
426    #[test]
427    fn effect_presets_round_trip_through_settings() {
428        let mut p = Project::from_media(asset(0, 10.0, 0));
429        let id = p.all_clips().next().unwrap().1.id;
430        let c = p.clip_mut(id).unwrap();
431        c.effects.push(Effect::new(EffectKind::Blur));
432        c.effects[0].params[0].set_at(0.0, 12.0);
433
434        let mut s = Settings::default();
435        s.effect_presets.push(capture_effects("Look", p.clip(id).unwrap()));
436        p.ensure_graph(id);
437        s.effect_presets.push(capture_effects("Graph", p.clip(id).unwrap()));
438        // machine-local: the presets ride in settings.json, not the project file
439        let s: Settings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
440        assert!(!s.effect_presets[0].is_graph() && s.effect_presets[1].is_graph());
441
442        // apply into a different project
443        let mut q = Project::from_media(asset(1, 4.0, 0));
444        let qid = q.all_clips().next().unwrap().1.id;
445        assert!(apply_effects(&s.effect_presets[0], &mut q, qid));
446        assert_eq!(q.clip(qid).unwrap().effects, p.clip(id).unwrap().effects);
447        assert!(q.clip(qid).unwrap().graph.is_none(), "a stack preset clears the graph shadowing it");
448        assert!(apply_effects(&s.effect_presets[1], &mut q, qid));
449        let g = q.clip(qid).unwrap().graph.clone().unwrap();
450        assert_eq!(g.nodes.len(), p.clip(id).unwrap().graph.as_ref().unwrap().nodes.len());
451        assert!(g.edges.iter().all(|e| g.nodes.iter().any(|n| n.id == e.from)), "edges kept their nodes");
452        // the pasted nodes got project ids, so the next node added is not a duplicate
453        let fresh = q.add_node(qid, NodeKind::Color([0; 4]), 0.0, 0.0).unwrap();
454        assert!(g.nodes.iter().all(|n| n.id != fresh));
455        // JSON that is not what it claims to be changes nothing
456        assert!(!apply_effects(&EffectPreset { name: "x".into(), json: "{ nope".into() }, &mut q, qid));
457        assert!(!apply_effects(&EffectPreset { name: "x".into(), json: "[1,2]".into() }, &mut q, qid));
458
459        // adjustment layers are simply templates holding nothing else (own section in the pane)
460        let a = q.add_adjustment_clip(0.0, 2.0);
461        assert!(is_adjustment_template(&capture_template("Adj", &q, &[a])));
462        assert!(!is_adjustment_template(&capture_template("Clip", &q, &[qid])));
463    }
464
465    #[test]
466    fn container_roundtrip_template() {
467        let mut p = Project::new();
468        let (vid, aid) = p.add_container_clip(0.0, 5.0);
469        if let Some(c) = p.clip_mut(vid) {
470            c.container_label = "Hero Shot".into();
471        }
472        let t = capture_template("ContainerTpl", &p, &[vid, aid]);
473        assert!(is_container_template(&t));
474        assert!(!is_adjustment_template(&t));
475
476        let (clips, assets) = decode_template(&t).unwrap();
477        assert_eq!(clips.len(), 2);
478        assert!(clips[0].container);
479        assert!(clips[1].container);
480
481        let mut q = Project::new();
482        let placed = q.place_clips(clips, assets, 10.0);
483        assert_eq!(placed.len(), 2);
484        let v = q.clip(placed[0]).unwrap();
485        assert!(v.container);
486        assert_eq!(v.container_label, "Hero Shot");
487        assert_eq!(v.start, 10.0);
488        assert_eq!(v.duration, 5.0);
489        assert!(v.is_empty_container());
490    }
491
492    #[test]
493    fn replace_container_preserves_effects_and_transforms() {
494        let mut p = Project::new();
495        let (vid, _) = p.add_container_clip(2.0, 6.0);
496        let a1 = p.add_asset(asset(1, 10.0, 1));
497        p.replace_container_media(vid, a1);
498
499        // Add effects, keyframes, transform changes
500        let c = p.clip_mut(vid).unwrap();
501        c.x.set_at(0.0, 100.0);
502        c.scale.set_at(0.0, 1.5);
503        c.opacity.set_at(0.0, 0.8);
504        c.effects.push(Effect::new(EffectKind::Blur));
505        c.effects[0].params[0].set_at(0.0, 15.0);
506
507        // Replace with another asset
508        let a2 = p.add_asset(asset(2, 20.0, 1));
509        assert!(p.replace_container_media(vid, a2));
510
511        let c2 = p.clip(vid).unwrap();
512        assert_eq!(c2.asset, a2);
513        assert_eq!(c2.start, 2.0);
514        assert_eq!(c2.duration, 6.0);
515        assert_eq!(c2.src_in, 0.0);
516        assert_eq!(c2.x.at(0.0), 100.0);
517        assert_eq!(c2.scale.at(0.0), 1.5);
518        assert_eq!(c2.opacity.at(0.0), 0.8);
519        assert_eq!(c2.effects.len(), 1);
520        assert_eq!(c2.effects[0].kind, EffectKind::Blur);
521        assert_eq!(c2.effects[0].params[0].at(0.0), 15.0);
522    }
523
524    #[test]
525    fn replace_container_pair_syncs_audio() {
526        let mut p = Project::new();
527        let (vid, aid) = p.add_container_clip(0.0, 8.0);
528        let a = p.add_asset(asset(1, 15.0, 2));
529
530        assert!(p.replace_container_pair(vid, a));
531
532        let vc = p.clip(vid).unwrap();
533        let ac = p.clip(aid).unwrap();
534        assert_eq!(vc.asset, a);
535        assert_eq!(ac.asset, a);
536        assert_eq!(vc.link, ac.link);
537        assert_eq!(ac.audio_stream, 0);
538    }
539
540    #[test]
541    fn make_and_unmake_container() {
542        let mut p = Project::from_media(asset(1, 10.0, 1));
543        let ids: Vec<Id> = p.all_clips().map(|(_, c)| c.id).collect();
544        assert_eq!(ids.len(), 2);
545        assert!(!p.clip(ids[0]).unwrap().container);
546
547        p.make_container(&[ids[0]]);
548        assert!(p.clip(ids[0]).unwrap().container);
549        assert!(p.clip(ids[1]).unwrap().container, "linked audio converted together");
550
551        if let Some(c) = p.clip_mut(ids[0]) {
552            c.container_label = "Slot A".into();
553        }
554        p.unmake_container(&[ids[0]]);
555        assert!(!p.clip(ids[0]).unwrap().container);
556        assert!(p.clip(ids[0]).unwrap().container_label.is_empty());
557        assert!(!p.clip(ids[1]).unwrap().container);
558    }
559
560    #[test]
561    fn empty_container_serialization_roundtrip() {
562        let mut p = Project::new();
563        let (vid, aid) = p.add_container_clip(1.0, 4.0);
564        if let Some(c) = p.clip_mut(vid) {
565            c.container_label = "Intro".into();
566        }
567        let json = p.to_json();
568        let p2 = Project::from_json(&json).unwrap();
569        let vc = p2.clip(vid).unwrap();
570        let ac = p2.clip(aid).unwrap();
571        assert!(vc.container);
572        assert!(ac.container);
573        assert!(vc.is_empty_container());
574        assert!(ac.is_empty_container());
575        assert_eq!(vc.container_label, "Intro");
576        assert_eq!(vc.link, ac.link);
577    }
578}