1use 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
11pub 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
21pub 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
30pub 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
48pub 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
66pub fn builtin_motions() -> Vec<MotionPreset> {
68 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
92const 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
108pub 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 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
130fn 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
145pub 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
156pub 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
167pub 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
177pub 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
203pub 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
210pub 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
221pub 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
238pub 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 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 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 apply_curve(&p, &mut b, 4.0, false);
293 assert!((b.keys[2].t - 4.0).abs() < 1e-9);
294 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 assert!(capture_curve("x", &Animated::new(1.0), 2.0, false).is_none());
302 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 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 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 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 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 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 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); merge_motion(&m, &mut c, 2.5); 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 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); 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 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 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 assert!(decode_template(&Template { name: "x".into(), json: "{ nope".into() }).is_none());
424 }
425 #[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 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 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 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 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 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 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 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}