1use std::collections::HashSet;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7
8use ffcine_audio::schema::{CLEAN, Clean, clip_effect};
9use ffcine_audio::{MixOptions, sources};
10
11use ffcine_timeline::project::GRADE_SCHEMA;
12use ffcine_timeline::{
13 Clip, ClipGrade, Effect, GradeParams, HasMetadata, Item, MediaReference, Project, SourceKind,
14 Timeline, Track, track_kind,
15};
16use serde_json::{Map, Value, json};
17
18use ffcine_filters::{Denoise, Motion, Spatial, Temporal};
19use ffcine_reframe::{Geo, Reframe};
20use ffcine_stabilize::{DetectParams, StabilizeParams};
21
22use crate::comp::{Finish, TransitionKind};
23use crate::profile::Profile;
24use crate::recipe::{AUDIO_REVISION, Fingerprint, Fingerprinter, Key, RENDER_REVISION};
25pub use ffcine_text::TextLayer;
26
27pub const RAMP_SCHEMA: &str = "ffcine.time.ramp/1";
29
30pub const COMP_GENERATOR: &str = "ffcine.comp";
32
33#[derive(Debug, thiserror::Error)]
35pub enum PlanError {
36 #[error("timeline has no video track")]
37 NoVideo,
38 #[error("timeline has no frame rate (project.rate or a clip's)")]
39 NoRate,
40 #[error("project metadata: {0}")]
41 Project(#[from] ffcine_core::Error),
42 #[error("audio: {0}")]
43 Audio(ffcine_core::Error),
44 #[error("comp: {0}")]
46 Comp(ffcine_core::Error),
47 #[error("comp clip {clip:?}: {msg}")]
49 CompClip { clip: String, msg: String },
50 #[error("clip {clip:?}: {source}")]
51 Grade {
52 clip: String,
53 source: ffcine_timeline::project::GradeError,
54 },
55 #[error("clip {clip:?}: {effect}: {msg}")]
56 Effect {
57 clip: String,
58 effect: &'static str,
59 msg: String,
60 },
61}
62
63#[derive(Debug, Clone, PartialEq, serde::Serialize)]
66pub struct Ramp {
67 pub points: Vec<(f64, f64)>,
68 pub source_duration: f64,
69}
70
71impl Ramp {
72 pub fn segments(&self) -> Vec<(f64, f64, f64, f64, f64)> {
75 let mut segs = Vec::new();
76 let mut out = 0.0;
77 for w in self.points.windows(2) {
78 let ((u0, a), (u1, b)) = (w[0], w[1]);
79 let (t0, t1) = (u0 * self.source_duration, u1 * self.source_duration);
80 if t1 - t0 <= 1e-6 {
81 continue;
82 }
83 segs.push((t0, t1, a, b, out));
84 let m = (b - a) / (t1 - t0);
85 out += if m.abs() < 1e-9 {
86 (t1 - t0) / a
87 } else {
88 (b / a).ln() / m
89 };
90 }
91 segs
92 }
93
94 pub fn source_time(&self, t: f64) -> f64 {
97 let mut last = 0.0;
98 for (t0, t1, a, b, o0) in self.segments() {
99 let m = (b - a) / (t1 - t0);
100 let dt = t - o0;
101 let x = if m.abs() < 1e-9 {
102 t0 + dt * a
103 } else {
104 t0 + (a * (m * dt).exp() - a) / m
105 };
106 if x <= t1 {
107 return x.max(t0);
108 }
109 last = t1;
110 }
111 last
112 }
113
114 pub fn setpts(&self) -> String {
117 self.setpts_of("((PTS-STARTPTS)*TB)")
118 }
119
120 pub fn setpts_of(&self, x: &str) -> String {
122 let mut expr: Option<String> = None;
123 for (t0, t1, a, b, o0) in self.segments().into_iter().rev() {
124 let m = (b - a) / (t1 - t0);
125 let e = if m.abs() < 1e-9 {
126 format!("({o0:.6}+({x}-{t0:.6})/{a:.6})")
127 } else {
128 format!("({o0:.6}+log(({a:.6}+{m:.6}*({x}-{t0:.6}))/{a:.6})/{m:.6})")
129 };
130 expr = Some(match expr {
131 None => e,
132 Some(rest) => format!("if(lt({x},{t1:.6}),{e},{rest})"),
133 });
134 }
135 format!("setpts='({})/TB'", expr.unwrap_or_else(|| x.to_owned()))
136 }
137}
138
139#[derive(Debug, Clone, Default, PartialEq)]
141pub struct Filters {
142 pub stabilize: Option<StabilizeParams>,
145 pub denoise: Option<Denoise>,
148 pub reframe: Option<Reframe>,
150 pub ramp: Option<Ramp>,
152 pub blend: Option<u32>,
155 pub geo: Geo,
157}
158
159impl Filters {
160 fn whole_clip(&self) -> bool {
163 self.stabilize.is_some()
164 || self.reframe.is_some()
165 || self.ramp.is_some()
166 || self.geo.is_animated()
167 }
168
169 fn recipe(&self) -> Value {
172 json!({
173 "stabilize": self.stabilize.map(|p| json!({
174 "smoothing": p.smoothing,
175 "zoom": format!("{:?}", p.zoom),
176 "detect": format!("{:?}", DetectParams::default()),
177 "motion_cache": ffcine_stabilize::cache::FORMAT_VERSION,
178 })),
179 "denoise": self.denoise,
180 "reframe": self.reframe.as_ref().map(|r| format!("{r:?}")),
181 "ramp": self.ramp,
182 "blend": self.blend,
183 })
184 }
185}
186
187#[allow(clippy::cast_possible_truncation)]
191pub fn grade_denoise(d: f64) -> Option<Denoise> {
192 (d > 0.0).then(|| {
193 let d = d as f32;
194 Denoise {
195 temporal: Some(Temporal {
196 radius: 2,
197 motion: Motion::Nvof,
198 strength: d,
199 chroma: None,
200 protect: 0.0,
201 }),
202 spatial: Some(Spatial {
203 radius: 2,
204 luma: 0.2 * d,
205 chroma: 0.3 * d,
206 protect: 0.0,
207 }),
208 }
209 .clamped()
210 })
211}
212
213#[derive(Debug, Clone, PartialEq)]
215pub struct Shot {
216 pub name: String,
218 pub media: PathBuf,
219 pub fingerprint: Fingerprint,
220 pub start: f64,
222 pub speed: f64,
224 pub frames: i64,
226 pub grade: GradeParams,
228 pub lut: Option<PathBuf>,
230 grade_recipe: Value,
232 pub(crate) fx: Vec<Value>,
234 pub finish: Finish,
236 pub grain: Option<Map<String, Value>>,
238 pub clip_at: i64,
242 pub clip_frames: i64,
243 pub filters: Filters,
244 pub clip_start: f64,
246 pub span: (i64, i64),
249 clip_index: usize,
251}
252
253impl Shot {
254 #[allow(clippy::cast_precision_loss)]
256 #[must_use]
257 pub fn shifted(&self, k: i64, rate: f64) -> Self {
258 Self {
259 start: (self.start + k as f64 * self.speed / rate).max(0.0),
260 frames: self.frames - k,
261 clip_at: self.clip_at + k,
262 ..self.clone()
263 }
264 }
265
266 #[must_use]
267 pub fn with_frames(mut self, n: i64) -> Self {
268 self.frames = n;
269 self
270 }
271
272 pub fn context(&self) -> (i64, i64) {
275 let r = self
276 .filters
277 .denoise
278 .filter(|d| !d.is_off())
279 .map_or(0, |d| i64::from(d.radius()));
280 if r == 0 || self.speed.abs() < 1e-9 {
281 return (0, 0);
282 }
283 (
284 r.min(self.clip_at).max(0),
285 r.min(self.clip_frames - self.clip_at - self.frames).max(0),
286 )
287 }
288
289 fn recipe(&self) -> Value {
290 let mut v = json!({
291 "media": self.fingerprint,
292 "start": self.start,
293 "speed": self.speed,
294 "frames": self.frames,
295 "grade": self.grade_recipe,
296 "fx": self.fx,
297 "filters": self.filters.recipe(),
298 });
299 if self.finish.is_timed() || self.filters.whole_clip() {
302 v["clip"] = json!([self.clip_at, self.clip_frames]);
303 }
304 if self.filters.whole_clip() {
305 v["clip_start"] = json!(self.clip_start);
306 }
307 if self.filters.stabilize.is_some() {
308 v["span"] = json!([self.span.0, self.span.1]);
309 }
310 let ctx = self.context();
311 if ctx != (0, 0) {
312 v["context"] = json!([ctx.0, ctx.1]);
313 }
314 v
315 }
316}
317
318#[derive(Debug, Clone, PartialEq)]
320pub struct PanelPart {
321 pub from: i64,
323 pub frames: i64,
324 pub shot: Option<Shot>,
325}
326
327#[derive(Debug, Clone, PartialEq)]
329pub struct Split {
330 pub layout: Map<String, Value>,
332 pub panels: Vec<Vec<PanelPart>>,
334 pub finish: Finish,
336 pub at: i64,
338 pub total: i64,
339}
340
341impl Split {
342 fn recipe(&self) -> Value {
343 let panels: Vec<Vec<Value>> = self
344 .panels
345 .iter()
346 .map(|p| {
347 p.iter()
348 .map(|part| {
349 json!({"from": part.from, "frames": part.frames,
350 "shot": part.shot.as_ref().map(Shot::recipe)})
351 })
352 .collect()
353 })
354 .collect();
355 json!({"layout": self.layout, "panels": panels, "finish": format!("{:?}", self.finish),
356 "at": self.at, "total": self.total})
357 }
358}
359
360#[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, PartialEq)]
363pub enum OverlaySource {
364 Video(Shot),
366 Image {
368 path: PathBuf,
369 fingerprint: Fingerprint,
370 },
371}
372
373#[derive(Debug, Clone, PartialEq)]
375pub struct OverlayClip {
376 pub name: String,
377 pub source: OverlaySource,
378 pub start: i64,
380 pub frames: i64,
381 pub source_start: f64,
383 pub transform: Map<String, Value>,
385 pub blend: Map<String, Value>,
386}
387
388#[derive(Debug, Clone, PartialEq)]
390pub struct SegLayer {
391 pub clip: Arc<OverlayClip>,
392 pub from: i64,
394 pub to: i64,
395 pub clip_at: i64,
397}
398
399impl SegLayer {
400 pub fn shot(&self, rate: f64) -> Option<Shot> {
402 match &self.clip.source {
403 OverlaySource::Video(s) => Some(
404 s.shifted(self.clip_at, rate)
405 .with_frames(self.to - self.from),
406 ),
407 OverlaySource::Image { .. } => None,
408 }
409 }
410
411 fn recipe(&self, rate: f64) -> Value {
412 let src = match &self.clip.source {
413 OverlaySource::Video(_) => self.shot(rate).map(|s| s.recipe()).unwrap_or_default(),
414 OverlaySource::Image { fingerprint, .. } => json!({"image": fingerprint}),
415 };
416 json!({"source": src, "transform": self.clip.transform, "blend": self.clip.blend,
417 "source_start": self.clip.source_start, "clip_at": self.clip_at,
418 "from": self.from, "to": self.to})
419 }
420}
421
422#[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, PartialEq)]
425pub enum Unit {
426 Shot(Shot),
427 Gap,
429 Transition {
431 a: Shot,
432 b: Shot,
433 kind: TransitionKind,
434 },
435 Split(Split),
437 SplitDissolve {
439 split: Split,
440 shot: Shot,
441 split_out: bool,
442 },
443 Comp(Arc<ffcine_comp::Piece>),
445}
446
447impl Unit {
448 fn recipe(&self) -> Value {
449 match self {
450 Self::Shot(s) => json!({"shot": s.recipe()}),
451 Self::Gap => json!("gap"),
452 Self::Transition {
454 a,
455 b,
456 kind: TransitionKind::Dissolve,
457 } => json!({"dissolve": [a.recipe(), b.recipe()]}),
458 Self::Transition { a, b, kind } => {
459 json!({"transition": [a.recipe(), b.recipe()], "kind": kind.recipe()})
460 }
461 Self::Split(s) => json!({"split": s.recipe()}),
462 Self::SplitDissolve {
463 split,
464 shot,
465 split_out,
466 } => json!({"split_dissolve": [split.recipe(), shot.recipe()], "split_out": split_out}),
467 Self::Comp(p) => p.recipe.clone(),
468 }
469 }
470}
471
472#[derive(Debug, Clone, PartialEq)]
474pub struct Segment {
475 pub key: Key,
476 pub name: String,
477 pub start: i64,
479 pub frames: i64,
480 pub unit: Unit,
481 pub layers: Vec<SegLayer>,
483}
484
485#[derive(Debug, Clone, PartialEq)]
490pub struct AudioPlan {
491 pub key: Key,
492 pub clips: usize,
494 pub timeline: Arc<Timeline>,
496 pub options: MixOptions,
499 inputs: Value,
501}
502
503impl AudioPlan {
504 fn key_of(inputs: &Value, options: &MixOptions) -> Key {
505 Key::of(&json!({
506 "rev": AUDIO_REVISION,
507 "ffcine": env!("CARGO_PKG_VERSION"),
508 "options": options,
509 "clips": inputs,
510 }))
511 }
512
513 pub fn set_options(&mut self, options: MixOptions) {
516 self.options = MixOptions {
517 base: self.options.base.clone(),
518 length: self.options.length,
519 ..options
520 };
521 self.key = Self::key_of(&self.inputs, &self.options);
522 }
523}
524
525#[derive(Debug, Clone, PartialEq)]
527pub struct Plan {
528 pub rate: f64,
530 pub profile: Profile,
531 pub segments: Vec<Segment>,
532 pub audio: Option<AudioPlan>,
533 pub grain: Option<Map<String, Value>>,
536 pub warnings: Vec<String>,
537}
538
539impl Plan {
540 pub fn frames(&self) -> i64 {
541 self.segments.iter().map(|s| s.frames).sum()
542 }
543
544 pub fn program_key(&self) -> Key {
546 let keys: Vec<&str> = self.segments.iter().map(|s| s.key.0.as_str()).collect();
547 Key::of(&json!({"program": keys, "rate": self.rate}))
548 }
549
550 pub fn master_key(&self) -> Key {
552 Key::of(&json!({
553 "rev": RENDER_REVISION,
554 "program": self.program_key(),
555 "audio": self.audio.as_ref().map(|a| &a.key),
556 "audio_bitrate": self.profile.audio_bitrate,
557 }))
558 }
559}
560
561pub fn plan(
569 timeline: &Timeline,
570 base_dir: &Path,
571 profile: &Profile,
572 fp: &dyn Fingerprinter,
573 text: Option<&TextLayer>,
574) -> Result<Plan, PlanError> {
575 plan_with(timeline, base_dir, profile, fp, text, None)
576}
577
578pub fn has_comp_clips(timeline: &Timeline) -> bool {
580 timeline.clips().iter().any(|c| comp_clip(c).is_some())
581}
582
583fn comp_clip(clip: &Clip) -> Option<&Map<String, Value>> {
585 match clip.media_reference()? {
586 MediaReference::Generator(g) if g.generator_kind == COMP_GENERATOR => Some(&g.parameters),
587 _ => None,
588 }
589}
590
591#[allow(clippy::too_many_lines)]
597pub fn plan_with(
598 timeline: &Timeline,
599 base_dir: &Path,
600 profile: &Profile,
601 fp: &dyn Fingerprinter,
602 text: Option<&TextLayer>,
603 media: Option<&dyn ffcine_comp::Media>,
604) -> Result<Plan, PlanError> {
605 let project = match timeline.project() {
606 Some(p) => p?,
607 None => Project::default(),
608 };
609 let rate = project
610 .rate
611 .or_else(|| {
612 timeline
613 .clips()
614 .iter()
615 .find_map(|c| c.source_range.as_ref().map(|r| r.duration.rate))
616 })
617 .filter(|r| *r > 0.0)
618 .ok_or(PlanError::NoRate)?;
619 let mut p = Planner {
620 project: &project,
621 base_dir,
622 rate,
623 fp,
624 profile,
625 media,
626 clips: 0,
627 warnings: Vec::new(),
628 };
629 let videos: Vec<&Track> = timeline
630 .track_list()
631 .filter(|t| t.kind == track_kind::VIDEO && t.enabled)
632 .collect();
633 let picture = videos.first().ok_or(PlanError::NoVideo)?;
634 let elems = p.flatten(&picture.children)?;
635 let mut units = p.resolve_transitions(elems);
636 let overlays: Vec<Arc<OverlayClip>> = videos
637 .iter()
638 .skip(1)
639 .map(|t| p.overlays(t))
640 .collect::<Result<Vec<_>, _>>()?
641 .into_iter()
642 .flatten()
643 .map(Arc::new)
644 .collect();
645 #[allow(clippy::cast_possible_truncation)]
646 let mut cuts: Vec<i64> = text
647 .map(|t| t.cuts().iter().map(|c| (c * rate).round() as i64).collect())
648 .unwrap_or_default();
649 cuts.extend(overlays.iter().flat_map(|o| [o.start, o.start + o.frames]));
650 cuts.sort_unstable();
651 cuts.dedup();
652 if !cuts.is_empty() {
653 units = split_at(units, &cuts, rate);
654 }
655 set_spans(&mut units);
656 let grain = timeline_grain(timeline, &profile.name);
657
658 let mut segments = Vec::new();
659 let mut at = 0;
660 for (name, frames, unit) in units {
661 #[allow(clippy::cast_precision_loss)]
662 let text_key = text.map(|t| {
663 let (s, e) = (at as f64 / rate, (at + frames) as f64 / rate);
664 (t.in_range(s, e).next().is_some()).then(|| t.fingerprint(s, e).to_string())
665 });
666 let layers: Vec<SegLayer> = overlays
667 .iter()
668 .filter_map(|o| {
669 let from = o.start.max(at);
670 let to = (o.start + o.frames).min(at + frames);
671 (to > from).then(|| SegLayer {
672 clip: Arc::clone(o),
673 from: from - at,
674 to: to - at,
675 clip_at: from - o.start,
676 })
677 })
678 .collect();
679 let mut recipe = json!({
680 "rev": RENDER_REVISION,
681 "ffcine": env!("CARGO_PKG_VERSION"),
682 "rate": rate,
683 "profile": profile.recipe(),
684 "frames": frames,
685 "unit": unit.recipe(),
686 "text": text_key.flatten(),
687 });
688 if !layers.is_empty() {
690 recipe["layers"] = layers.iter().map(|l| l.recipe(rate)).collect();
691 }
692 if let Some(g) = &grain {
693 recipe["grain"] = Value::Object(g.clone());
694 }
695 segments.push(Segment {
696 key: Key::of(&recipe),
697 name,
698 start: at,
699 frames,
700 unit,
701 layers,
702 });
703 at += frames;
704 }
705
706 let frames: i64 = segments.iter().map(|s| s.frames).sum();
707 #[allow(clippy::cast_precision_loss)]
708 let audio = p.audio(timeline, frames as f64 / rate)?;
709 Ok(Plan {
710 rate,
711 profile: profile.clone(),
712 segments,
713 audio,
714 grain,
715 warnings: p.warnings,
716 })
717}
718
719pub type ProbeProfile<'a> = &'a dyn Fn(&Path) -> ffcine_core::Result<ffcine_core::camera::Profile>;
721
722pub type ProbeRate<'a> = &'a dyn Fn(&Path) -> ffcine_core::Result<(f64, u64)>;
724
725pub struct CompMedia<'a> {
729 pub fp: &'a dyn Fingerprinter,
730 pub dims: &'a dyn Fn(&Path) -> ffcine_core::Result<(u32, u32)>,
731 pub profile: Option<ProbeProfile<'a>>,
732 pub rate: Option<ProbeRate<'a>>,
733}
734
735impl ffcine_comp::Media for CompMedia<'_> {
736 fn dims(&self, path: &Path) -> ffcine_core::Result<(u32, u32)> {
737 (self.dims)(path)
738 }
739 fn identity(&self, path: &Path) -> Value {
740 serde_json::to_value(self.fp.fingerprint(path)).unwrap_or(Value::Null)
741 }
742 fn ffmpeg_build(&self) -> String {
743 self.fp.ffmpeg_build()
744 }
745 fn content_hash(&self, path: &Path) -> Value {
746 self.fp
747 .content_hash(path)
748 .map_or_else(|| self.identity(path), Value::String)
749 }
750 fn profile(&self, path: &Path) -> ffcine_core::Result<ffcine_core::camera::Profile> {
751 self.profile
752 .map_or(Ok(ffcine_core::camera::Profile::Rec709), |f| f(path))
753 }
754 fn video_rate(&self, path: &Path) -> ffcine_core::Result<(f64, u64)> {
755 match self.rate {
756 Some(f) => f(path),
757 None => Err(ffcine_core::Error::Invalid {
758 what: "time remap (no frame rate probe)",
759 value: path.display().to_string(),
760 }),
761 }
762 }
763}
764
765pub fn plan_comp(
769 comp: &ffcine_comp::Comp,
770 profile: &Profile,
771 media: &dyn ffcine_comp::Media,
772) -> Result<Plan, PlanError> {
773 let rate = comp.canvas.fps;
774 let mut segments = Vec::new();
775 for r in comp.pieces() {
776 let piece = comp
777 .lower(r.clone(), profile.size, media)
778 .map_err(PlanError::Comp)?;
779 let unit = Unit::Comp(Arc::new(piece));
780 let recipe = json!({
781 "rev": RENDER_REVISION,
782 "ffcine": env!("CARGO_PKG_VERSION"),
783 "rate": rate,
784 "profile": profile.recipe(),
785 "frames": r.end - r.start,
786 "unit": unit.recipe(),
787 });
788 #[allow(clippy::cast_precision_loss)]
789 segments.push(Segment {
790 key: Key::of(&recipe),
791 name: format!(
792 "comp {:.2}-{:.2} s",
793 r.start as f64 / rate,
794 r.end as f64 / rate
795 ),
796 start: r.start,
797 frames: r.end - r.start,
798 unit,
799 layers: Vec::new(),
800 });
801 }
802 Ok(Plan {
803 rate,
804 profile: profile.clone(),
805 segments,
806 audio: None,
807 grain: None,
808 warnings: Vec::new(),
809 })
810}
811
812fn timeline_grain(timeline: &Timeline, profile: &str) -> Option<Map<String, Value>> {
814 let g = timeline.tracks.effects.iter().find_map(|e| match e {
815 Effect::Effect(g) if g.ffcine_schema() == Some(("ffcine.fx.grain", 1)) => {
816 g.ffcine().cloned()
817 }
818 _ => None,
819 })?;
820 grain_for(g, profile)
821}
822
823pub fn grain_for(mut g: Map<String, Value>, profile: &str) -> Option<Map<String, Value>> {
826 let over = g.remove("profiles").and_then(|p| p.get(profile).cloned());
827 if let Some(Value::Object(o)) = over {
828 g.extend(o);
829 }
830 g.remove("schema");
831 g.get("strength")
832 .and_then(Value::as_f64)
833 .is_none_or(|s| s > 0.0)
834 .then_some(g)
835}
836
837#[allow(clippy::large_enum_variant)] enum Elem {
839 Shot(Shot),
840 Gap(i64),
841 Transition {
842 name: String,
843 before: i64,
844 after: i64,
845 kind: TransitionKind,
846 },
847 Split(Split),
848 Comp {
850 name: String,
851 pieces: Vec<(String, i64, Unit)>,
852 frames: i64,
853 },
854}
855
856struct Planner<'a> {
857 project: &'a Project,
858 base_dir: &'a Path,
859 rate: f64,
860 fp: &'a dyn Fingerprinter,
861 profile: &'a Profile,
862 media: Option<&'a dyn ffcine_comp::Media>,
864 clips: usize,
866 warnings: Vec<String>,
867}
868
869fn set_spans(units: &mut [(String, i64, Unit)]) {
872 fn each(units: &mut [(String, i64, Unit)], f: &mut dyn FnMut(&mut Shot)) {
873 for (_, _, u) in units.iter_mut() {
874 match u {
875 Unit::Shot(s) => f(s),
876 Unit::Transition { a, b, .. } => {
877 f(a);
878 f(b);
879 }
880 Unit::SplitDissolve { shot, .. } => f(shot),
881 Unit::Gap | Unit::Split(_) | Unit::Comp(_) => {}
882 }
883 }
884 }
885 let mut spans: std::collections::HashMap<usize, (i64, i64)> = std::collections::HashMap::new();
886 each(units, &mut |s| {
887 let e = spans.entry(s.clip_index).or_insert((0, s.clip_frames));
888 *e = (e.0.min(s.clip_at), e.1.max(s.clip_at + s.frames));
889 });
890 each(units, &mut |s| {
891 if let Some(&span) = spans.get(&s.clip_index) {
892 s.span = span;
893 }
894 });
895}
896
897#[allow(clippy::cast_possible_truncation)]
898fn frames_at(t: &ffcine_timeline::RationalTime, rate: f64) -> i64 {
899 t.rescaled_to(rate).value.round() as i64
900}
901
902fn item_frames(item: &Item, rate: f64) -> i64 {
903 match item {
904 Item::Clip(c) => c
905 .source_range
906 .as_ref()
907 .map_or(0, |r| frames_at(&r.duration, rate)),
908 Item::Gap(g) => g
909 .source_range
910 .as_ref()
911 .map_or(0, |r| frames_at(&r.duration, rate)),
912 Item::Track(t) => t.children.iter().map(|i| item_frames(i, rate)).sum(),
913 Item::Stack(s) => s.source_range.as_ref().map_or_else(
914 || {
915 s.children
916 .iter()
917 .map(|i| item_frames(i, rate))
918 .max()
919 .unwrap_or(0)
920 },
921 |r| frames_at(&r.duration, rate),
922 ),
923 Item::Transition(_) | Item::Unknown(_) => 0,
924 }
925}
926
927impl Planner<'_> {
928 fn warn(&mut self, w: String) {
929 if !self.warnings.contains(&w) {
930 self.warnings.push(w);
931 }
932 }
933
934 fn media_path(&self, clip: &Clip) -> Option<PathBuf> {
935 match clip.media_reference()? {
936 MediaReference::External(r) => r.path().map(|p| self.base_dir.join(p)),
937 _ => None,
938 }
939 }
940
941 fn flatten(&mut self, items: &[Item]) -> Result<Vec<Elem>, PlanError> {
942 let mut out = Vec::new();
943 for item in items {
944 match item {
945 Item::Clip(c) if !c.enabled => {
946 out.push(Elem::Gap(item_frames(item, self.rate)));
947 }
948 Item::Clip(c) if comp_clip(c).is_some() => out.push(self.comp(c)?),
949 Item::Clip(c) => match self.shot(c)? {
950 Some(s) => out.push(Elem::Shot(s)),
951 None => out.push(Elem::Gap(item_frames(item, self.rate))),
952 },
953 Item::Gap(_) => out.push(Elem::Gap(item_frames(item, self.rate))),
954 Item::Transition(t) => out.push(Elem::Transition {
955 name: t.name.clone(),
956 before: frames_at(&t.in_offset, self.rate),
957 after: frames_at(&t.out_offset, self.rate),
958 kind: TransitionKind::from_meta(t.ffcine()),
959 }),
960 Item::Stack(s)
961 if s.children
962 .iter()
963 .filter(|c| !matches!(c, Item::Unknown(_)))
964 .count()
965 > 1 =>
966 {
967 out.push(Elem::Split(self.split(s, item_frames(item, self.rate))?));
968 }
969 Item::Stack(s) => {
970 let total = item_frames(item, self.rate);
971 let first = s.children.iter().find(|c| !matches!(c, Item::Unknown(_)));
972 let mut inner = match first {
973 Some(Item::Track(t)) => self.flatten(&t.children)?,
974 Some(other) => self.flatten(std::slice::from_ref(other))?,
975 None => Vec::new(),
976 };
977 fit(&mut inner, total);
978 out.extend(inner);
979 }
980 Item::Track(t) => out.extend(self.flatten(&t.children)?),
981 Item::Unknown(_) => {}
982 }
983 }
984 Ok(out)
985 }
986
987 fn split(&mut self, s: &ffcine_timeline::Stack, total: i64) -> Result<Split, PlanError> {
989 let mut layout = Map::new();
990 let mut finish = Finish::default();
991 for e in &s.effects {
992 if let Effect::Effect(g) = e
993 && let (Some(schema), Some(m)) = (g.ffcine_schema(), g.ffcine())
994 {
995 if schema == ("ffcine.comp.layout", 1) {
996 layout.clone_from(m);
997 layout.remove("schema");
998 } else {
999 finish.add(schema, m);
1000 }
1001 }
1002 }
1003 let mut panels = Vec::new();
1004 for child in s.children.iter().filter(|c| !matches!(c, Item::Unknown(_))) {
1005 let mut elems = match child {
1006 Item::Track(t) => self.flatten(&t.children)?,
1007 other => self.flatten(std::slice::from_ref(other))?,
1008 };
1009 fit(&mut elems, total);
1010 let mut parts = Vec::new();
1011 let mut at = 0;
1012 for e in elems {
1013 let (frames, shot) = match e {
1014 Elem::Shot(sh) => (sh.frames, Some(sh)),
1015 Elem::Gap(n) => (n, None),
1016 Elem::Transition { name, .. } => {
1017 self.warn(format!(
1018 "split-screen {:?}: transition {name:?} inside a panel rendered as a cut",
1019 s.name
1020 ));
1021 continue;
1022 }
1023 Elem::Split(_) => {
1024 self.warn(format!(
1025 "split-screen {:?}: nested split-screen left out",
1026 s.name
1027 ));
1028 continue;
1029 }
1030 Elem::Comp { name, frames, .. } => {
1031 self.warn(format!(
1032 "split-screen {:?}: comp clip {name:?} in a panel rendered black",
1033 s.name
1034 ));
1035 (frames, None)
1036 }
1037 };
1038 if frames > 0 {
1039 parts.push(PanelPart {
1040 from: at,
1041 frames,
1042 shot,
1043 });
1044 }
1045 at += frames;
1046 }
1047 panels.push(parts);
1048 }
1049 Ok(Split {
1050 layout,
1051 panels,
1052 finish,
1053 at: 0,
1054 total,
1055 })
1056 }
1057
1058 fn comp(&mut self, c: &Clip) -> Result<Elem, PlanError> {
1060 let bad = |msg: String| PlanError::CompClip {
1061 clip: c.name.clone(),
1062 msg,
1063 };
1064 let params = comp_clip(c).cloned().unwrap_or_default();
1065 let schema = params.get("schema").and_then(Value::as_str);
1066 if schema != Some("ffcine.comp/1") {
1067 return Err(bad(format!(
1068 "parameters need \"schema\": \"ffcine.comp/1\" (got {schema:?})"
1069 )));
1070 }
1071 let comp = match params.get("src") {
1072 Some(Value::String(src)) => {
1073 if let Some(k) = params
1074 .keys()
1075 .find(|k| !matches!(k.as_str(), "schema" | "src"))
1076 {
1077 return Err(bad(format!(
1078 "`{k}` beside `src`: give `src` or an inline comp, not both"
1079 )));
1080 }
1081 ffcine_comp::Comp::from_file(&self.base_dir.join(src))
1082 }
1083 Some(other) => return Err(bad(format!("`src`: expected a path, got {other}"))),
1084 None => ffcine_comp::Comp::from_value(&Value::Object(params), self.base_dir),
1085 }
1086 .map_err(|e| bad(e.to_string()))?;
1087 if (comp.canvas.fps - self.rate).abs() > 1e-6 * self.rate {
1088 return Err(bad(format!(
1089 "canvas fps {} differs from the timeline's {}",
1090 comp.canvas.fps, self.rate
1091 )));
1092 }
1093 let [cw, ch] = comp.canvas.size;
1094 let out = self.profile.size;
1095 let aspect = |w: u32, h: u32| f64::from(w) / f64::from(h);
1096 if (aspect(cw, ch) / aspect(out.width, out.height) - 1.0).abs() > 0.01 {
1097 return Err(bad(format!(
1098 "canvas {cw}x{ch} isn't the output's aspect ({}x{}); it would stretch",
1099 out.width, out.height
1100 )));
1101 }
1102 let Some(media) = self.media else {
1103 return Err(bad("comp clips need probed media".into()));
1104 };
1105 let (from, frames) = match &c.source_range {
1106 Some(r) => (
1107 frames_at(&r.start_time, self.rate),
1108 frames_at(&r.duration, self.rate),
1109 ),
1110 None => (0, comp.frames()),
1111 };
1112 if from < 0 {
1113 return Err(bad(format!(
1114 "source_range starts at comp frame {from}; it can't be before the comp's start"
1115 )));
1116 }
1117 for e in &c.effects {
1118 let schema = match e {
1119 Effect::Effect(g) => g.ffcine_schema().map_or_else(
1120 || g.effect_name.clone(),
1121 |(name, major)| format!("{name}/{major}"),
1122 ),
1123 other => other.effect_name().unwrap_or("unnamed effect").to_owned(),
1124 };
1125 self.warn(format!(
1126 "comp clip {:?}: comp clip effects are not applied yet: {schema}",
1127 c.name
1128 ));
1129 }
1130 let mut pieces = Vec::new();
1131 for r in comp.pieces() {
1132 let (a, b) = (r.start.max(from), r.end.min(from + frames));
1133 if b > a {
1134 let piece = comp
1135 .lower(a..b, self.profile.size, media)
1136 .map_err(|e| bad(e.to_string()))?;
1137 pieces.push((c.name.clone(), b - a, Unit::Comp(Arc::new(piece))));
1138 }
1139 }
1140 let got: i64 = pieces.iter().map(|p| p.1).sum();
1141 if got < frames {
1142 pieces.push((c.name.clone(), frames - got, Unit::Gap));
1144 }
1145 Ok(Elem::Comp {
1146 name: c.name.clone(),
1147 pieces,
1148 frames,
1149 })
1150 }
1151
1152 fn overlays(&mut self, t: &Track) -> Result<Vec<OverlayClip>, PlanError> {
1154 let mut out = Vec::new();
1155 let mut at = 0;
1156 for item in &t.children {
1157 let n = item_frames(item, self.rate);
1158 if let Item::Clip(c) = item
1159 && c.enabled
1160 && comp_clip(c).is_some()
1161 {
1162 self.warn(format!(
1163 "track {:?}: comp clip {:?} composited on the picture track only, left out",
1164 t.name, c.name
1165 ));
1166 at += n;
1167 continue;
1168 }
1169 if let Item::Clip(c) = item
1170 && c.enabled
1171 && !matches!(c.media_reference(),
1172 Some(MediaReference::Generator(g)) if g.generator_kind == "ffcine.text")
1173 {
1174 let mut transform = Map::new();
1175 let mut blend = Map::new();
1176 for e in &c.effects {
1177 if let Effect::Effect(g) = e
1178 && let (Some(schema), Some(m)) = (g.ffcine_schema(), g.ffcine())
1179 {
1180 match schema {
1181 ("ffcine.geo.transform", 1) => transform.clone_from(m),
1182 ("ffcine.comp.blend", 1) => blend.clone_from(m),
1183 _ => {}
1184 }
1185 }
1186 }
1187 transform.remove("schema");
1188 blend.remove("schema");
1189 if blend
1190 .get("mode")
1191 .and_then(Value::as_str)
1192 .is_some_and(|m| m != "normal")
1193 {
1194 self.warn(format!(
1195 "overlay {:?}: blend mode {} rendered as normal",
1196 c.name, blend["mode"]
1197 ));
1198 }
1199 let kind = c
1200 .ffcine_value("source")
1201 .and_then(|s| s.get("kind"))
1202 .and_then(Value::as_str);
1203 let media = self.media_path(c);
1204 let image = kind == Some("image")
1205 || media.as_ref().is_some_and(|p| {
1206 p.extension().and_then(|e| e.to_str()).is_some_and(|e| {
1207 matches!(
1208 e.to_ascii_lowercase().as_str(),
1209 "png" | "jpg" | "jpeg" | "webp"
1210 )
1211 })
1212 });
1213 let source = match media {
1214 Some(path) if image => Some(OverlaySource::Image {
1215 fingerprint: self.fp.fingerprint(&path),
1216 path,
1217 }),
1218 _ => self.shot(c)?.map(OverlaySource::Video),
1219 };
1220 if let Some(source) = source {
1221 out.push(OverlayClip {
1222 name: c.name.clone(),
1223 source,
1224 start: at,
1225 frames: n,
1226 source_start: c
1227 .source_range
1228 .as_ref()
1229 .map_or(0.0, |r| r.start_time.to_seconds()),
1230 transform,
1231 blend,
1232 });
1233 }
1234 }
1235 at += n;
1236 }
1237 Ok(out)
1238 }
1239
1240 #[allow(clippy::too_many_lines)] fn shot(&mut self, c: &Clip) -> Result<Option<Shot>, PlanError> {
1243 let Some(range) = c.source_range.as_ref().or_else(|| {
1244 c.media_reference()
1245 .and_then(MediaReference::available_range)
1246 }) else {
1247 self.warn(format!("clip {:?}: no source range, skipped", c.name));
1248 return Ok(None);
1249 };
1250 let Some(media) = self.media_path(c) else {
1251 let generated = matches!(c.media_reference(), Some(MediaReference::Generator(_)));
1252 if !generated {
1253 self.warn(format!("clip {:?}: no media file, rendered black", c.name));
1254 }
1255 return Ok(None);
1256 };
1257 let media = if self.profile.proxies {
1258 crate::proxy::find(&media, self.profile.proxy_dir.as_deref(), self.fp).unwrap_or_else(
1259 || {
1260 self.warn(format!(
1261 "clip {:?}: no proxy, rendered from the source",
1262 c.name
1263 ));
1264 media
1265 },
1266 )
1267 } else {
1268 media
1269 };
1270 let mut speed = 1.0;
1271 let mut clip_grade = None;
1272 let mut fx = Vec::new();
1273 let mut finish = Finish::default();
1274 let mut grain = None;
1275 let mut filters = Filters::default();
1276 for e in &c.effects {
1277 match e {
1278 Effect::LinearTimeWarp(w) => speed *= w.time_scalar,
1279 Effect::FreezeFrame(_) => speed = 0.0,
1280 Effect::Effect(g) if Self::filter(&c.name, g, &mut filters)? => {}
1281 Effect::Effect(g) => {
1282 if let Some(r) = g.ffcine_params::<ClipGrade>(
1283 GRADE_SCHEMA.split('/').next().unwrap_or_default(),
1284 1,
1285 ) {
1286 clip_grade = Some(r?);
1287 } else if g.effect_name.starts_with("ffcine.audio.") {
1288 } else {
1290 if let (Some(schema), Some(m)) = (g.ffcine_schema(), g.ffcine()) {
1291 if schema == ("ffcine.fx.grain", 1) {
1292 grain = Some(m.clone());
1293 } else {
1294 finish.add(schema, m);
1295 }
1296 }
1297 let mut params = g.ffcine().cloned();
1298 if g.ffcine_schema() == Some(("ffcine.geo.reframe", 1)) {
1299 let m = params.clone().unwrap_or_default();
1300 filters.reframe =
1301 Some(Reframe::parse(&m, Some(&self.profile.aspect.name)).map_err(
1302 |e| PlanError::Effect {
1303 clip: c.name.clone(),
1304 effect: "reframe",
1305 msg: e.to_string(),
1306 },
1307 )?);
1308 if let Some(Value::Object(p)) =
1310 params.as_mut().and_then(|m| m.get_mut("profiles"))
1311 {
1312 p.retain(|k, _| *k == self.profile.aspect.name);
1313 }
1314 }
1315 fx.push(json!({"effect": g.effect_name, "ffcine": params}));
1316 }
1317 }
1318 Effect::Unknown(m) => fx.push(Value::Object(m.clone())),
1319 }
1320 }
1321 filters.geo = Geo::from_effects(&c.effects).map_err(|e| PlanError::Effect {
1322 clip: c.name.clone(),
1323 effect: "geo",
1324 msg: e.to_string(),
1325 })?;
1326 let kind = match c
1327 .ffcine_value("source")
1328 .and_then(|s| s.get("kind"))
1329 .and_then(Value::as_str)
1330 {
1331 Some("iphone" | "ai" | "still" | "display") => SourceKind::Display,
1332 _ => SourceKind::Log,
1333 };
1334 let grade = self
1335 .project
1336 .resolve_grade(clip_grade.as_ref(), kind)
1337 .map_err(|source| PlanError::Grade {
1338 clip: c.name.clone(),
1339 source,
1340 })?;
1341 let lut = self
1342 .project
1343 .lut_path(grade.lut.as_deref())
1344 .map(|l| self.base_dir.join(l));
1345 if filters.denoise.is_none() {
1346 filters.denoise = grade.denoise.and_then(grade_denoise);
1347 }
1348 let mut grade_recipe = serde_json::to_value(&grade).unwrap_or_default();
1349 if let Value::Object(m) = &mut grade_recipe {
1350 let lut_id = lut.as_ref().map(|p| {
1351 self.fp
1352 .content_hash(p)
1353 .map_or_else(|| json!({"missing": p}), |h| json!({"sha": h}))
1354 });
1355 m.insert("lut".into(), lut_id.unwrap_or(Value::Null));
1356 }
1357 let frames = frames_at(&range.duration, self.rate);
1358 let start = range.start_time.to_seconds();
1359 self.clips += 1;
1360 Ok(Some(Shot {
1361 name: c.name.clone(),
1362 fingerprint: self.fp.fingerprint(&media),
1363 media,
1364 start,
1365 speed,
1366 frames,
1367 grade,
1368 lut,
1369 grade_recipe,
1370 fx,
1371 finish,
1372 grain,
1373 clip_at: 0,
1374 clip_frames: frames,
1375 filters,
1376 clip_start: start,
1377 span: (0, frames),
1378 clip_index: self.clips,
1379 }))
1380 }
1381
1382 fn filter(
1385 clip: &str,
1386 g: &ffcine_timeline::GenericEffect,
1387 filters: &mut Filters,
1388 ) -> Result<bool, PlanError> {
1389 let bad = |effect: &'static str, msg: String| PlanError::Effect {
1390 clip: clip.to_owned(),
1391 effect,
1392 msg,
1393 };
1394 let params = || {
1395 let mut m = g.ffcine().cloned().unwrap_or_default();
1396 m.remove("schema");
1397 m
1398 };
1399 match g.ffcine_schema() {
1400 Some(("ffcine.fx.stabilize", 1)) => {
1401 filters.stabilize = Some(
1402 ffcine_stabilize::fx::params(¶ms())
1403 .map_err(|e| bad("stabilize", e.to_string()))?,
1404 );
1405 }
1406 Some(("ffcine.fx.denoise", 1)) => {
1407 let d =
1408 ffcine_filters::effect::denoise(¶ms()).map_err(|e| bad("denoise", e))?;
1409 filters.denoise = (!d.is_off()).then_some(d);
1410 }
1411 Some(("ffcine.time.ramp", 1)) => {
1412 let m = params();
1413 let points: Vec<(f64, f64)> = m
1414 .get("points")
1415 .and_then(Value::as_array)
1416 .map(|a| {
1417 a.iter()
1418 .filter_map(|p| {
1419 let p = p.as_array()?;
1420 Some((p.first()?.as_f64()?, p.get(1)?.as_f64()?))
1421 })
1422 .collect()
1423 })
1424 .unwrap_or_default();
1425 match m.get("source_duration").and_then(Value::as_f64) {
1426 Some(d) if d > 0.0 && points.len() >= 2 && points.iter().all(|p| p.1 > 0.0) => {
1427 filters.ramp = Some(Ramp {
1428 points,
1429 source_duration: d,
1430 });
1431 }
1432 _ => {
1433 return Err(bad(
1434 "ramp",
1435 "needs points [[u, speed > 0]] and source_duration".into(),
1436 ));
1437 }
1438 }
1439 }
1440 Some(("ffcine.time.blend", 1)) => {
1441 let n = params().get("frames").and_then(Value::as_f64);
1442 match n {
1443 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1444 Some(n) if (1.0..=64.0).contains(&n.round()) => {
1445 let n = n.round() as u32;
1446 filters.blend = (n > 1).then_some(n);
1447 }
1448 _ => return Err(bad("blend", "needs frames 1..64".into())),
1449 }
1450 }
1451 _ => return Ok(false),
1452 }
1453 Ok(true)
1454 }
1455
1456 #[allow(clippy::too_many_lines)]
1459 fn resolve_transitions(&mut self, elems: Vec<Elem>) -> Vec<(String, i64, Unit)> {
1460 let mut out: Vec<(String, i64, Unit)> = Vec::new();
1461 let mut pending: Option<(String, i64, i64, TransitionKind)> = None;
1462 for e in elems {
1463 match e {
1464 Elem::Transition {
1465 name,
1466 before,
1467 after,
1468 kind,
1469 } => pending = Some((name, before, after, kind)),
1470 Elem::Comp { name, pieces, .. } => {
1471 if let Some((t, ..)) = pending.take() {
1472 self.warn(format!(
1473 "transition {t:?} into comp clip {name:?} rendered as a cut"
1474 ));
1475 }
1476 out.extend(pieces);
1477 }
1478 Elem::Split(sp) => {
1479 let label = format!("split {}", sp.panels.len());
1480 let mut sp = sp;
1481 let mut frames = sp.total;
1482 if let Some((name, before, after, kind)) = pending.take() {
1483 let n = before + after;
1484 match out.last_mut() {
1485 Some((_, a_frames, Unit::Shot(a)))
1486 if n > 0 && a.frames > before && sp.total > after =>
1487 {
1488 if kind != TransitionKind::Dissolve {
1489 self.warn(format!(
1490 "transition {name:?} into a split-screen rendered as a dissolve"
1491 ));
1492 }
1493 let shot = a.shifted(a.frames - before, self.rate).with_frames(n);
1494 a.frames -= before;
1495 *a_frames = a.frames;
1496 let into = Split {
1497 at: -before,
1498 ..sp.clone()
1499 };
1500 out.push((
1501 format!("{} x {label}", shot.name),
1502 n,
1503 Unit::SplitDissolve {
1504 split: into,
1505 shot,
1506 split_out: false,
1507 },
1508 ));
1509 sp.at = after;
1510 frames -= after;
1511 }
1512 _ => self.warn(format!(
1513 "transition {name:?} needs a shot on one side, rendered as a cut"
1514 )),
1515 }
1516 }
1517 out.push((label, frames, Unit::Split(sp)));
1518 }
1519 Elem::Gap(n) if n > 0 => {
1520 if let Some((name, ..)) = pending.take() {
1521 self.warn(format!(
1522 "transition {name:?} next to a gap, rendered as a cut"
1523 ));
1524 }
1525 match out.last_mut() {
1526 Some((_, frames, Unit::Gap)) => *frames += n,
1527 _ => out.push(("gap".into(), n, Unit::Gap)),
1528 }
1529 }
1530 Elem::Gap(_) => {}
1531 Elem::Shot(b) => {
1532 let mut b = b;
1533 if let Some((name, before, after, kind)) = pending.take() {
1534 let n = before + after;
1535 match out.last_mut() {
1536 Some((_, a_frames, Unit::Shot(a)))
1537 if n > 0 && a.frames > before && b.frames > after =>
1538 {
1539 let a_part = a.shifted(a.frames - before, self.rate).with_frames(n);
1540 let b_part = b.shifted(-before, self.rate).with_frames(n);
1541 a.frames -= before;
1542 *a_frames = a.frames;
1543 let label = format!("{} x {}", a.name, b.name);
1544 out.push((
1545 label,
1546 n,
1547 Unit::Transition {
1548 a: a_part,
1549 b: b_part,
1550 kind,
1551 },
1552 ));
1553 b = b.shifted(after, self.rate);
1554 }
1555 Some((a_label, a_frames, Unit::Split(sp)))
1556 if n > 0 && *a_frames > before && b.frames > after =>
1557 {
1558 if kind != TransitionKind::Dissolve {
1559 self.warn(format!(
1560 "transition {name:?} out of a split-screen rendered as a dissolve"
1561 ));
1562 }
1563 let out_of = Split {
1564 at: sp.at + *a_frames - before,
1565 ..sp.clone()
1566 };
1567 *a_frames -= before;
1568 let label = format!("{a_label} x {}", b.name);
1569 out.push((
1570 label,
1571 n,
1572 Unit::SplitDissolve {
1573 split: out_of,
1574 shot: b.shifted(-before, self.rate).with_frames(n),
1575 split_out: true,
1576 },
1577 ));
1578 b = b.shifted(after, self.rate);
1579 }
1580 _ => self.warn(format!(
1581 "transition {name:?} needs a shot on one side, rendered as a cut"
1582 )),
1583 }
1584 }
1585 if b.frames > 0 {
1586 out.push((b.name.clone(), b.frames, Unit::Shot(b)));
1587 }
1588 }
1589 }
1590 }
1591 out.retain(|(_, n, _)| *n > 0);
1592 out
1593 }
1594
1595 fn audio(&mut self, timeline: &Timeline, length: f64) -> Result<Option<AudioPlan>, PlanError> {
1597 for t in timeline
1598 .track_list()
1599 .filter(|t| t.kind == track_kind::AUDIO && t.enabled)
1600 {
1601 if t.children.iter().any(|i| matches!(i, Item::Stack(_))) {
1602 self.warn(format!(
1603 "audio track {:?}: nested stacks not mixed yet",
1604 t.name
1605 ));
1606 }
1607 }
1608 let mut options =
1609 MixOptions::for_timeline(timeline, Some(self.base_dir)).map_err(PlanError::Audio)?;
1610 options.length = Some(length);
1611 let (clips, _) = sources(timeline, options.video_audio);
1612 if clips.is_empty() {
1613 return Ok(None);
1614 }
1615 let mut inputs = Vec::new();
1616 for (bus, p) in &clips {
1617 let c = p.clip;
1618 let media = p.path(Some(self.base_dir)).map(|m| self.fp.fingerprint(&m));
1619 let effects: Vec<Value> = c
1622 .effects
1623 .iter()
1624 .filter_map(|e| match e {
1625 Effect::Effect(g) if g.effect_name.starts_with("ffcine.audio.") => {
1626 Some(json!({"effect": g.effect_name, "ffcine": g.ffcine()}))
1627 }
1628 _ => None,
1629 })
1630 .collect();
1631 let model = clip_effect::<Clean>(c, CLEAN)
1632 .ok()
1633 .flatten()
1634 .and_then(|cl| cl.model)
1635 .map(|m| self.fp.content_hash(&self.base_dir.join(m)));
1636 inputs.push(json!({
1637 "bus": bus.name(),
1638 "media": media,
1639 "start": p.start,
1640 "duration": p.duration,
1641 "source_start": p.source_start,
1642 "xfade": [p.xfade_in, p.xfade_out],
1643 "speed": p.speed,
1644 "effects": effects,
1645 "sync": c.ffcine_value("sync"),
1646 "model": model,
1647 }));
1648 }
1649 let inputs = Value::Array(inputs);
1650 Ok(Some(AudioPlan {
1651 key: AudioPlan::key_of(&inputs, &options),
1652 clips: clips.len(),
1653 timeline: Arc::new(timeline.clone()),
1654 options,
1655 inputs,
1656 }))
1657 }
1658}
1659
1660fn fit(elems: &mut Vec<Elem>, total: i64) {
1662 let len = |e: &Elem| match e {
1663 Elem::Shot(s) => s.frames,
1664 Elem::Gap(n) => *n,
1665 Elem::Transition { .. } => 0,
1666 Elem::Split(s) => s.total,
1667 Elem::Comp { frames, .. } => *frames,
1668 };
1669 let mut sum: i64 = elems.iter().map(len).sum();
1670 while sum > total {
1671 let Some(last) = elems.last_mut() else { break };
1672 let over = sum - total;
1673 match last {
1674 Elem::Shot(s) if s.frames > over => {
1675 s.frames -= over;
1676 sum = total;
1677 }
1678 Elem::Gap(n) if *n > over => {
1679 *n -= over;
1680 sum = total;
1681 }
1682 _ => {
1683 sum -= len(last);
1684 elems.pop();
1685 }
1686 }
1687 }
1688 if sum < total {
1689 elems.push(Elem::Gap(total - sum));
1690 }
1691}
1692
1693fn split_at(units: Vec<(String, i64, Unit)>, cuts: &[i64], rate: f64) -> Vec<(String, i64, Unit)> {
1696 let mut out = Vec::new();
1697 let mut at = 0;
1698 for (name, frames, unit) in units {
1699 let end = at + frames;
1700 #[allow(clippy::cast_possible_truncation)]
1702 let min = rate.round() as i64;
1703 let mut inner: Vec<i64> = Vec::new();
1704 for &c in cuts {
1705 if c - inner.last().copied().unwrap_or(at) >= min && end - c >= min {
1706 inner.push(c);
1707 }
1708 }
1709 if inner.is_empty()
1712 || matches!(
1713 unit,
1714 Unit::Transition { .. } | Unit::SplitDissolve { .. } | Unit::Comp(_)
1715 )
1716 {
1717 out.push((name, frames, unit));
1718 } else {
1719 let mut from = at;
1720 for to in inner.into_iter().chain([end]) {
1721 let n = to - from;
1722 let piece = match &unit {
1723 Unit::Shot(s) => Unit::Shot(s.shifted(from - at, rate).with_frames(n)),
1724 Unit::Split(s) => Unit::Split(Split {
1725 at: s.at + from - at,
1726 ..s.clone()
1727 }),
1728 other => other.clone(),
1729 };
1730 out.push((name.clone(), n, piece));
1731 from = to;
1732 }
1733 }
1734 at = end;
1735 }
1736 out
1737}
1738
1739pub fn key_set(plan: &Plan) -> HashSet<&Key> {
1741 plan.segments.iter().map(|s| &s.key).collect()
1742}
1743
1744#[cfg(test)]
1745mod tests {
1746 use super::*;
1747 use crate::Diff;
1748 use ffcine_timeline::{
1749 ExternalReference, Gap, GenericEffect, LinearTimeWarp, RationalTime, TimeRange, Transition,
1750 };
1751 use serde_json::Map;
1752
1753 const RATE: f64 = 24000.0 / 1001.0;
1754
1755 struct Fake;
1757 impl Fingerprinter for Fake {
1758 fn fingerprint(&self, path: &Path) -> Fingerprint {
1759 Fingerprint {
1760 path: path.to_owned(),
1761 size: Some(path.as_os_str().len() as u64),
1762 mtime_ns: Some(1),
1763 }
1764 }
1765 fn content_hash(&self, path: &Path) -> Option<String> {
1766 Some(format!("hash-of-{}", path.display()))
1767 }
1768 }
1769
1770 #[test]
1771 fn comp_pieces_are_segments_keyed_by_their_values() {
1772 let comp = |fade: &str| {
1773 ffcine_comp::Comp::from_value(
1774 &json!({
1775 "schema": "ffcine.comp/1",
1776 "canvas": { "size": [1080, 1920], "fps": 30, "duration": 4.0 },
1777 "layers": [
1778 { "source": { "video": "a.mov" }, "out": 2.0 },
1779 { "source": { "solid": "white" }, "in": 2.0, "fade": fade },
1780 ],
1781 }),
1782 Path::new("/media"),
1783 )
1784 .expect("comp")
1785 };
1786 let dims = |_: &Path| Ok((1920, 1080));
1787 let media = CompMedia {
1788 fp: &Fake,
1789 dims: &dims,
1790 profile: None,
1791 rate: None,
1792 };
1793 let profile = Profile::for_canvas("master", 1080, 1920).expect("profile");
1794 assert_eq!(
1795 (profile.size.width, profile.size.height),
1796 (2160, 3840),
1797 "the canvas aspect at the master's short side"
1798 );
1799 let a = plan_comp(&comp("1"), &profile, &media).expect("plan");
1800 let b = plan_comp(&comp("clip(4 - t, 0, 1)"), &profile, &media).expect("plan");
1801 assert_eq!(a.segments.len(), 2);
1802 assert_eq!(a.frames(), 120);
1803 assert!(
1804 matches!(&a.segments[0].unit, Unit::Comp(p) if matches!(p.base, ffcine_comp::Base::Video { .. }))
1805 );
1806 assert_eq!(a.segments[0].key, b.segments[0].key);
1807 assert_ne!(a.segments[1].key, b.segments[1].key);
1808 let d = Diff::new(&a, &b);
1810 assert!(d.report(&b, false).contains("comp 2.00-4.00 s"));
1811 }
1812
1813 struct Build(&'static str);
1815 impl Fingerprinter for Build {
1816 fn fingerprint(&self, path: &Path) -> Fingerprint {
1817 Fake.fingerprint(path)
1818 }
1819 fn content_hash(&self, path: &Path) -> Option<String> {
1820 Fake.content_hash(path)
1821 }
1822 fn ffmpeg_build(&self) -> String {
1823 self.0.into()
1824 }
1825 }
1826
1827 #[test]
1830 fn ffmpeg_build_keys_filter_nodes_only() {
1831 let comp = ffcine_comp::Comp::from_value(
1832 &json!({
1833 "schema": "ffcine.comp/1",
1834 "canvas": { "size": [1920, 1080], "fps": 30, "duration": 4.0 },
1835 "layers": [
1836 { "source": { "video": "a.mov" }, "out": 2.0,
1837 "filter": { "graph": "hqdn3d" } },
1838 { "source": { "video": "b.mov" }, "in": 2.0 },
1839 ],
1840 }),
1841 Path::new("/media"),
1842 )
1843 .expect("comp");
1844 let dims = |_: &Path| Ok((1920, 1080));
1845 let rate = |_: &Path| Ok((30.0, 300));
1846 let plan = |fp: &dyn Fingerprinter| {
1847 let media = CompMedia {
1848 fp,
1849 dims: &dims,
1850 profile: None,
1851 rate: Some(&rate),
1852 };
1853 let profile = Profile::for_canvas("master", 1920, 1080).expect("profile");
1854 plan_comp(&comp, &profile, &media).expect("plan")
1855 };
1856 let (a, b) = (plan(&Build("ffmpeg 8")), plan(&Build("ffmpeg 9")));
1857 assert_eq!(a.segments.len(), 2);
1858 assert_ne!(a.segments[0].key, b.segments[0].key);
1859 assert_eq!(a.segments[1].key, b.segments[1].key);
1860 }
1861
1862 fn clip(name: &str, file: &str, start: f64, frames: f64, look: &str) -> Item {
1863 let range = TimeRange::from_frames(start, start + frames, RATE);
1864 let media = MediaReference::External(ExternalReference::from_path(
1865 Path::new(&format!("/media/{file}.mp4")),
1866 None,
1867 ));
1868 let mut c = Clip::new(name, media, Some(range));
1869 let mut params = Map::new();
1870 params.insert("look".into(), look.into());
1871 c.effects
1872 .push(Effect::Effect(GenericEffect::ffcine(GRADE_SCHEMA, params)));
1873 Item::Clip(c)
1874 }
1875
1876 fn music(file: &str) -> Item {
1877 let media = MediaReference::External(ExternalReference::from_path(
1878 Path::new(&format!("/media/{file}.flac")),
1879 None,
1880 ));
1881 Item::Clip(Clip::new(
1882 file,
1883 media,
1884 Some(TimeRange::from_frames(0.0, 400.0, RATE)),
1885 ))
1886 }
1887
1888 fn comp_clip(name: &str, params: &Value, from: f64, to: f64) -> Item {
1890 let g = ffcine_timeline::GeneratorReference::new(
1891 COMP_GENERATOR,
1892 params.as_object().cloned().unwrap_or_default(),
1893 );
1894 Item::Clip(Clip::new(
1895 name,
1896 MediaReference::Generator(g),
1897 Some(TimeRange::from_frames(from, to, RATE)),
1898 ))
1899 }
1900
1901 #[test]
1902 fn comp_clips_lower_to_comp_segments() {
1903 let inline = json!({
1904 "schema": "ffcine.comp/1",
1905 "canvas": { "size": [1920, 1080], "fps": "24000/1001", "duration": 3.0 },
1906 "layers": [
1907 { "source": { "still": "a.png" } },
1908 { "source": { "solid": "white" }, "in": 1.0, "fade": 0.5 },
1909 ],
1910 });
1911 let dims = |_: &Path| Ok((1920, 1080));
1912 let media = CompMedia {
1913 fp: &Fake,
1914 dims: &dims,
1915 profile: None,
1916 rate: None,
1917 };
1918 let with = |t: &Timeline| {
1919 plan_with(
1920 t,
1921 Path::new("/proj"),
1922 &Profile::preview(),
1923 &Fake,
1924 None,
1925 Some(&media),
1926 )
1927 };
1928 let shot = || clip("a", "A", 0.0, 48.0, "day");
1929 let t = timeline(
1930 vec![shot(), comp_clip("c", &inline, 12.0, 60.0), shot()],
1931 vec![],
1932 );
1933 assert!(has_comp_clips(&t));
1934 let p = with(&t).expect("plan");
1935 let comps: Vec<&Segment> = p
1936 .segments
1937 .iter()
1938 .filter(|s| matches!(s.unit, Unit::Comp(_)))
1939 .collect();
1940 assert_eq!(comps.len(), 2, "split where the white layer comes in");
1941 assert_eq!(comps.iter().map(|s| s.frames).sum::<i64>(), 48);
1942 assert_eq!(p.frames(), 144);
1943 let gap = timeline(
1945 vec![
1946 shot(),
1947 Item::Gap(Gap::new(RationalTime::new(48.0, RATE))),
1948 shot(),
1949 ],
1950 vec![],
1951 );
1952 assert!(!has_comp_clips(&gap));
1953 let g = plan_of(&gap);
1954 assert_eq!(p.segments[0].key, g.segments[0].key);
1955 assert_eq!(
1956 p.segments.last().map(|s| &s.key),
1957 g.segments.last().map(|s| &s.key)
1958 );
1959
1960 let mut tr = vec![shot()];
1962 tr.push(Item::Transition(Transition::dissolve(
1963 RationalTime::new(6.0, RATE),
1964 RationalTime::new(6.0, RATE),
1965 )));
1966 tr.push(comp_clip("c", &inline, 48.0, 96.0));
1967 let p = with(&timeline(tr, vec![])).expect("plan");
1968 assert!(matches!(
1969 p.segments.last().map(|s| &s.unit),
1970 Some(Unit::Gap)
1971 ));
1972 assert!(
1973 p.warnings.iter().any(|w| w.contains("rendered as a cut")),
1974 "{:?}",
1975 p.warnings
1976 );
1977
1978 let err = |params: &Value, m: Option<&dyn ffcine_comp::Media>| {
1980 let t = timeline(vec![comp_clip("c", params, 0.0, 24.0)], vec![]);
1981 plan_with(&t, Path::new("/proj"), &Profile::preview(), &Fake, None, m)
1982 .expect_err("an error")
1983 .to_string()
1984 };
1985 assert!(err(&inline, None).contains("probed media"));
1986 let mut bad = inline.clone();
1987 bad["schema"] = json!("ffcine.comp/2");
1988 assert!(err(&bad, Some(&media)).contains("ffcine.comp/1"));
1989 let mut bad = inline.clone();
1990 bad["canvas"]["fps"] = json!(30);
1991 assert!(err(&bad, Some(&media)).contains("fps"));
1992 let mut bad = inline.clone();
1993 bad["canvas"]["size"] = json!([1080, 1920]);
1994 assert!(err(&bad, Some(&media)).contains("aspect"));
1995 let both = json!({"schema": "ffcine.comp/1", "src": "a.comp.json", "layers": []});
1996 assert!(err(&both, Some(&media)).contains("`layers` beside `src`"));
1997 }
1998
1999 #[test]
2000 fn comp_clip_effects_warn_and_a_negative_start_is_an_error() {
2001 let inline = json!({
2002 "schema": "ffcine.comp/1",
2003 "canvas": { "size": [1920, 1080], "fps": "24000/1001", "duration": 2.0 },
2004 "layers": [{ "source": { "solid": "white" } }],
2005 });
2006 let dims = |_: &Path| Ok((1920, 1080));
2007 let media = CompMedia {
2008 fp: &Fake,
2009 dims: &dims,
2010 profile: None,
2011 rate: None,
2012 };
2013 let with = |t: &Timeline| {
2014 plan_with(
2015 t,
2016 Path::new("/proj"),
2017 &Profile::preview(),
2018 &Fake,
2019 None,
2020 Some(&media),
2021 )
2022 };
2023 let early = timeline(vec![comp_clip("c", &inline, -6.0, 24.0)], vec![]);
2024 let msg = with(&early).expect_err("a negative start").to_string();
2025 assert!(msg.contains("before the comp's start"), "{msg}");
2026
2027 let fx = with_fx(
2029 with_fx(
2030 comp_clip("c", &inline, 0.0, 24.0),
2031 effect("ffcine.comp.blend/1", json!({"opacity": 0.5})),
2032 ),
2033 effect("ffcine.geo.transform/1", json!({"scale": 0.5})),
2034 );
2035 let planned = with(&timeline(vec![fx], vec![])).expect("plan");
2036 for schema in ["ffcine.comp.blend/1", "ffcine.geo.transform/1"] {
2037 let w = format!("comp clip \"c\": comp clip effects are not applied yet: {schema}");
2038 assert!(planned.warnings.contains(&w), "{:?}", planned.warnings);
2039 }
2040 }
2041
2042 fn timeline(video: Vec<Item>, audio: Vec<Item>) -> Timeline {
2043 let mut t = Timeline::new("t");
2044 let mut project = Project {
2045 rate: Some(RATE),
2046 ..Project::default()
2047 };
2048 for (name, warmth) in [("day", 0.02), ("night", -0.03)] {
2049 project.looks.insert(
2050 name.into(),
2051 GradeParams {
2052 warmth: Some(warmth),
2053 lut: Some("normal".into()),
2054 ..GradeParams::default()
2055 },
2056 );
2057 }
2058 project
2059 .luts
2060 .insert("normal".into(), "luts/normal.cube".into());
2061 t.set_project(&project).unwrap();
2062 let mut v = Track::new("V1", track_kind::VIDEO);
2063 v.children = video;
2064 let mut a = Track::new("Music", track_kind::AUDIO);
2065 a.children = audio;
2066 t.tracks.children = vec![Item::Track(v), Item::Track(a)];
2067 t
2068 }
2069
2070 #[test]
2073 fn review_renders_from_proxies() {
2074 let dir = std::env::temp_dir().join(format!("ffcine-plan-proxy-{}", std::process::id()));
2075 std::fs::create_dir_all(&dir).unwrap();
2076 for f in ["A.mp4", "A.LRF", "B.mp4"] {
2077 std::fs::write(dir.join(f), b"x").unwrap();
2078 }
2079 let file = |name: &str, f: &str| {
2080 let media = MediaReference::External(ExternalReference::from_path(&dir.join(f), None));
2081 Item::Clip(Clip::new(
2082 name,
2083 media,
2084 Some(TimeRange::from_frames(0.0, 48.0, RATE)),
2085 ))
2086 };
2087 let t = timeline(vec![file("a", "A.mp4"), file("b", "B.mp4")], vec![]);
2088 let media = |p: &Plan| -> Vec<PathBuf> {
2089 p.segments
2090 .iter()
2091 .filter_map(|s| match &s.unit {
2092 Unit::Shot(s) => Some(s.media.clone()),
2093 _ => None,
2094 })
2095 .collect()
2096 };
2097 let review = plan(&t, &dir, &Profile::review(), &Fake, None).unwrap();
2098 assert_eq!(media(&review), [dir.join("A.LRF"), dir.join("B.mp4")]);
2099 assert!(
2100 review
2101 .warnings
2102 .iter()
2103 .any(|w| w.contains("\"b\": no proxy")),
2104 "{:?}",
2105 review.warnings
2106 );
2107 let preview = plan(&t, &dir, &Profile::preview(), &Fake, None).unwrap();
2108 assert_eq!(media(&preview), [dir.join("A.mp4"), dir.join("B.mp4")]);
2109 assert!(key_set(&review).is_disjoint(&key_set(&preview)));
2110 let _ = std::fs::remove_dir_all(dir);
2111 }
2112
2113 fn plan_of(t: &Timeline) -> Plan {
2114 plan(t, Path::new("/proj"), &Profile::preview(), &Fake, None).unwrap()
2115 }
2116
2117 fn keys(p: &Plan) -> Vec<(String, Key)> {
2118 p.segments
2119 .iter()
2120 .map(|s| (s.name.clone(), s.key.clone()))
2121 .collect()
2122 }
2123
2124 fn shots() -> Vec<Item> {
2125 vec![
2126 clip("a", "A", 0.0, 48.0, "day"),
2127 clip("b", "B", 100.0, 72.0, "night"),
2128 clip("c", "C", 10.0, 30.0, "day"),
2129 clip("d", "D", 0.0, 50.0, "night"),
2130 ]
2131 }
2132
2133 #[test]
2136 fn insert_or_remove_changes_no_other_key() {
2137 let base = plan_of(&timeline(shots(), vec![]));
2138 let mut inserted = shots();
2139 inserted.insert(1, clip("new", "N", 5.0, 40.0, "night"));
2140 inserted.insert(0, clip("new0", "N", 50.0, 20.0, "day"));
2141 let ins = plan_of(&timeline(inserted, vec![]));
2142 let ins_keys = keys(&ins);
2143 for (name, key) in keys(&base) {
2144 assert!(
2145 ins_keys.contains(&(name.clone(), key)),
2146 "{name} changed key on insert"
2147 );
2148 }
2149 let mut removed = shots();
2150 removed.remove(1);
2151 let rem = plan_of(&timeline(removed, vec![]));
2152 let base_keys = keys(&base);
2153 for k in keys(&rem) {
2154 assert!(base_keys.contains(&k), "{} changed key on remove", k.0);
2155 }
2156 let d = Diff::new(&base, &ins);
2157 assert_eq!(d.render_count(), 2);
2158 assert_eq!(d.dropped, 0);
2159 }
2160
2161 #[test]
2162 fn grade_edit_changes_only_that_clip() {
2163 let base = plan_of(&timeline(shots(), vec![]));
2164 let mut edited = shots();
2165 edited[2] = clip("c", "C", 10.0, 30.0, "night");
2166 let new = plan_of(&timeline(edited, vec![]));
2167 let changed: Vec<_> = base
2168 .segments
2169 .iter()
2170 .zip(&new.segments)
2171 .filter(|(a, b)| a.key != b.key)
2172 .map(|(a, _)| a.name.as_str())
2173 .collect();
2174 assert_eq!(changed, ["c"]);
2175 let mut moved = shots();
2177 moved.swap(0, 3);
2178 let m = plan_of(&timeline(moved, vec![]));
2179 assert_eq!(Diff::new(&base, &m).render_count(), 0);
2180 }
2181
2182 #[test]
2183 fn identical_content_shares_a_key_whatever_the_name() {
2184 let p = plan_of(&timeline(
2185 vec![
2186 clip("x", "A", 0.0, 48.0, "day"),
2187 clip("y", "A", 0.0, 48.0, "day"),
2188 ],
2189 vec![],
2190 ));
2191 assert_eq!(p.segments[0].key, p.segments[1].key);
2192 }
2193
2194 #[test]
2195 fn music_swap_keeps_picture() {
2196 let a = plan_of(&timeline(shots(), vec![music("song1")]));
2197 let b = plan_of(&timeline(shots(), vec![music("song2")]));
2198 assert_eq!(a.program_key(), b.program_key());
2199 assert_ne!(a.master_key(), b.master_key());
2200 let d = Diff::new(&a, &b);
2201 assert_eq!(d.render_count(), 0);
2202 assert!(d.audio_changed);
2203 assert!(d.report(&b, false).contains("audio remux only"));
2204 }
2205
2206 #[test]
2209 fn mix_edits_change_only_the_audio() {
2210 let base = || timeline(shots(), vec![music("song1")]);
2211 let a = plan_of(&base());
2212 let keyed = |t: &Timeline| plan_of(t);
2213 let mut edits: Vec<(&str, Plan)> = Vec::new();
2214
2215 let gain = json!({"gain_db": [
2216 {"t": {"OTIO_SCHEMA": "RationalTime.1", "rate": RATE, "value": 0.0}, "v": -6.0},
2217 {"t": {"OTIO_SCHEMA": "RationalTime.1", "rate": RATE, "value": 48.0}, "v": 0.0}
2218 ]});
2219 let fade = json!({"fade_in": 1.5});
2220 for (name, params) in [("gain keyframes", gain), ("fade", fade)] {
2221 let t = timeline(
2222 shots(),
2223 vec![with_fx(
2224 music("song1"),
2225 effect("ffcine.audio.level/1", params),
2226 )],
2227 );
2228 edits.push((name, keyed(&t)));
2229 }
2230
2231 let mut t = base();
2232 let mut h2 = Track::new("H2", track_kind::AUDIO);
2233 let mut rec = music("h2-0001");
2234 if let Item::Clip(c) = &mut rec {
2235 c.ffcine_mut().insert(
2236 "sync".into(),
2237 json!({"schema": "ffcine.audio.sync/1", "video": "a", "offset": 3.2,
2238 "drift_ppm": 12.0, "confidence": 0.8}),
2239 );
2240 }
2241 h2.children = vec![rec];
2242 t.tracks.children.push(Item::Track(h2));
2243 let synced = keyed(&t);
2244 if let Some(Item::Track(h2)) = t.tracks.children.last_mut()
2245 && let Some(Item::Clip(c)) = h2.children.first_mut()
2246 {
2247 c.ffcine_mut()["sync"]["drift_ppm"] = json!(15.0);
2248 }
2249 let drifted = keyed(&t);
2250 assert_ne!(synced.audio, drifted.audio, "sync drift");
2251 edits.push(("synced recorder", synced));
2252
2253 let mut t = base();
2254 let mut project = t.project().unwrap().unwrap();
2255 project.mix.duck_db = Some(-10.0);
2256 t.set_project(&project).unwrap();
2257 edits.push(("project.mix", keyed(&t)));
2258
2259 let mut over = a.clone();
2260 if let Some(au) = &mut over.audio {
2261 let mut o = au.options.clone();
2262 o.master.lufs = Some(-16.0);
2263 au.set_options(o);
2264 }
2265 edits.push(("override", over));
2266
2267 for (name, b) in &edits {
2268 assert_eq!(a.program_key(), b.program_key(), "{name}");
2269 let d = Diff::new(&a, b);
2270 assert_eq!(d.render_count(), 0, "{name}");
2271 assert!(d.audio_changed, "{name}");
2272 }
2273 }
2274
2275 #[test]
2278 fn camera_audio_is_keyed_apart_from_the_picture() {
2279 let a = plan_of(&timeline(shots(), vec![]));
2280 assert_eq!(a.audio.as_ref().map(|x| x.clips), Some(4));
2281 let mut v = shots();
2282 v[1] = clip("b", "B", 100.0, 72.0, "day");
2283 let graded = plan_of(&timeline(v, vec![]));
2284 assert_eq!(Diff::new(&a, &graded).render_count(), 1);
2285 assert_eq!(
2286 a.audio.as_ref().map(|x| &x.key),
2287 graded.audio.as_ref().map(|x| &x.key)
2288 );
2289 let mut v = shots();
2290 v[1] = with_fx(
2291 v[1].clone(),
2292 effect("ffcine.audio.level/1", json!({"gain_db": 3.0})),
2293 );
2294 let louder = plan_of(&timeline(v, vec![]));
2295 assert_eq!(a.program_key(), louder.program_key());
2296 assert!(Diff::new(&a, &louder).audio_changed);
2297 }
2298
2299 #[test]
2300 fn dissolve_splits_the_overlap_out() {
2301 let t = |extra: f64| {
2302 let mut v = shots();
2303 v.insert(
2304 2,
2305 Item::Transition(Transition::dissolve(
2306 RationalTime::new(6.0, RATE),
2307 RationalTime::new(6.0 + extra, RATE),
2308 )),
2309 );
2310 plan_of(&timeline(v, vec![]))
2311 };
2312 let p = t(0.0);
2313 let names: Vec<_> = p
2314 .segments
2315 .iter()
2316 .map(|s| (s.name.as_str(), s.frames))
2317 .collect();
2318 assert_eq!(
2319 names,
2320 [("a", 48), ("b", 66), ("b x c", 12), ("c", 24), ("d", 50)]
2321 );
2322 assert_eq!(p.frames(), 48 + 72 + 30 + 50);
2323 let Unit::Transition { a, b, .. } = &p.segments[2].unit else {
2324 panic!("not a dissolve")
2325 };
2326 assert!((a.start - (100.0 + 66.0) / RATE).abs() < 1e-9);
2329 assert!((b.start - (10.0 - 6.0) / RATE).abs() < 1e-9);
2330 let q = t(4.0);
2332 assert_eq!(Diff::new(&p, &q).render_count(), 2);
2333 }
2334
2335 #[test]
2336 fn gaps_freezes_and_speed() {
2337 let mut freeze = clip("f", "A", 47.0, 24.0, "day");
2338 let mut fast = clip("s", "B", 0.0, 24.0, "day");
2339 if let Item::Clip(c) = &mut freeze {
2340 c.effects
2341 .push(Effect::FreezeFrame(LinearTimeWarp::freeze_frame()));
2342 }
2343 if let Item::Clip(c) = &mut fast {
2344 c.effects
2345 .push(Effect::LinearTimeWarp(LinearTimeWarp::new(2.0)));
2346 }
2347 let p = plan_of(&timeline(
2348 vec![
2349 Item::Gap(Gap::new(RationalTime::new(12.0, RATE))),
2350 Item::Gap(Gap::new(RationalTime::new(12.0, RATE))),
2351 freeze,
2352 fast,
2353 ],
2354 vec![],
2355 ));
2356 assert_eq!(p.segments[0].unit, Unit::Gap);
2357 assert_eq!(p.segments[0].frames, 24);
2358 let speeds: Vec<f64> = p.segments[1..]
2359 .iter()
2360 .map(|s| match &s.unit {
2361 Unit::Shot(s) => s.speed,
2362 _ => f64::NAN,
2363 })
2364 .collect();
2365 assert_eq!(speeds, [0.0, 2.0]);
2366 }
2367
2368 #[test]
2369 fn text_cuts_split_long_units_only() {
2370 let p = plan_of(&timeline(vec![clip("a", "A", 100.0, 240.0, "day")], vec![]));
2371 let shot = p.segments[0].unit.clone();
2372 let units = vec![("a".to_owned(), 240, shot), ("g".into(), 48, Unit::Gap)];
2373 let out = split_at(units, &[10, 100, 110, 200, 250], RATE);
2375 let lens: Vec<i64> = out.iter().map(|u| u.1).collect();
2376 assert_eq!(lens, [100, 100, 40, 48]);
2377 let Unit::Shot(second) = &out[1].2 else {
2378 panic!()
2379 };
2380 assert!((second.start - 200.0 / RATE).abs() < 1e-9);
2381 assert_eq!(second.frames, 100);
2382 }
2383
2384 fn with_filter(mut item: Item, schema: &str, params: &Value) -> Item {
2385 if let Item::Clip(c) = &mut item {
2386 c.effects.push(Effect::Effect(GenericEffect::ffcine(
2387 schema,
2388 params.as_object().cloned().unwrap_or_default(),
2389 )));
2390 }
2391 item
2392 }
2393
2394 fn filtered(fx: &[(usize, &str, Value)]) -> Vec<Item> {
2396 let mut v = shots();
2397 for (i, schema, params) in fx {
2398 v[*i] = with_filter(v[*i].clone(), schema, params);
2399 }
2400 v
2401 }
2402
2403 fn base_fx() -> Vec<(usize, &'static str, Value)> {
2404 vec![
2405 (
2406 0,
2407 "ffcine.time.ramp/1",
2408 json!({"points": [[0, 1], [0.5, 2], [1, 1]],
2409 "source_duration": 3.0}),
2410 ),
2411 (1, "ffcine.fx.stabilize/1", json!({"smoothing": 20})),
2412 (
2413 2,
2414 "ffcine.fx.denoise/1",
2415 json!({"luma": 1.0, "temporal": {"radius": 2}}),
2416 ),
2417 (
2418 3,
2419 "ffcine.geo.reframe/1",
2420 json!({"zoom": 1.2, "profiles": {"reel": {"crop_x": 0.2}}}),
2421 ),
2422 ]
2423 }
2424
2425 fn changed(a: &Plan, b: &Plan) -> Vec<String> {
2426 a.segments
2427 .iter()
2428 .zip(&b.segments)
2429 .filter(|(x, y)| x.key != y.key)
2430 .map(|(x, _)| x.name.clone())
2431 .collect()
2432 }
2433
2434 #[test]
2435 fn filter_edits_rerender_only_their_clip() {
2436 let base = plan_of(&timeline(filtered(&base_fx()), vec![]));
2437 let again = plan_of(&timeline(filtered(&base_fx()), vec![]));
2439 assert_eq!(Diff::new(&base, &again).render_count(), 0);
2440 assert_eq!(base.master_key(), again.master_key());
2441 let none = plan_of(&timeline(shots(), vec![]));
2443 assert_eq!(Diff::new(&none, &base).render_count(), 4);
2444
2445 let edits: [(usize, &str, Value); 6] = [
2446 (
2447 0,
2448 "ffcine.time.ramp/1",
2449 json!({"points": [[0, 1], [0.5, 2.5], [1, 1]],
2450 "source_duration": 3.0}),
2451 ),
2452 (1, "ffcine.fx.stabilize/1", json!({"smoothing": 30})),
2453 (
2454 1,
2455 "ffcine.fx.stabilize/1",
2456 json!({"smoothing": 20, "zoom": 1.1}),
2457 ),
2458 (
2459 2,
2460 "ffcine.fx.denoise/1",
2461 json!({"luma": 1.0, "temporal": {"radius": 3}}),
2462 ),
2463 (
2464 2,
2465 "ffcine.fx.denoise/1",
2466 json!({"luma": 1.0, "temporal": {"radius": 2, "motion": "blocks"}}),
2467 ),
2468 (
2469 3,
2470 "ffcine.geo.reframe/1",
2471 json!({"zoom": 1.3, "profiles": {"reel": {"crop_x": 0.2}}}),
2472 ),
2473 ];
2474 let names = ["a", "b", "c", "d"];
2475 for (i, schema, params) in edits {
2476 let mut fx = base_fx();
2477 fx.retain(|f| f.0 != i);
2478 fx.push((i, schema, params.clone()));
2479 let p = plan_of(&timeline(filtered(&fx), vec![]));
2480 assert_eq!(changed(&base, &p), [names[i]], "{schema} {params}");
2481 }
2482 let mut v = filtered(&base_fx());
2484 v[1] = with_filter(
2485 v[1].clone(),
2486 GRADE_SCHEMA,
2487 &json!({"look": "night", "exposure": 0.5}),
2488 );
2489 assert_eq!(changed(&base, &plan_of(&timeline(v, vec![]))), ["b"]);
2490 }
2491
2492 #[test]
2493 fn blend_is_a_keyed_filter() {
2494 let base = plan_of(&timeline(shots(), vec![]));
2495 let blend = |n: f64| {
2496 let mut v = shots();
2497 v[2] = with_filter(v[2].clone(), "ffcine.time.blend/1", &json!({"frames": n}));
2498 plan_of(&timeline(v, vec![]))
2499 };
2500 let p = blend(3.0);
2501 assert_eq!(changed(&base, &p), ["c"]);
2502 let Unit::Shot(s) = &p.segments[2].unit else {
2503 panic!("shot")
2504 };
2505 assert_eq!(s.filters.blend, Some(3));
2506 assert!(s.fx.is_empty(), "keyed as a filter, not an effect");
2507 assert_eq!(changed(&p, &blend(4.0)), ["c"]);
2508 assert_eq!(changed(&base, &blend(1.0)), Vec::<String>::new());
2510 }
2511
2512 #[test]
2513 fn reframe_override_is_keyed_per_aspect() {
2514 let reel =
2515 Profile::resolve("preview", &ffcine_timeline::Output::default(), Some("reel")).unwrap();
2516 let at = |fx: &[(usize, &str, Value)], p: &Profile| {
2517 plan(
2518 &timeline(filtered(fx), vec![]),
2519 Path::new("/proj"),
2520 p,
2521 &Fake,
2522 None,
2523 )
2524 .unwrap()
2525 };
2526 let mut fx = base_fx();
2527 fx[3].2 = json!({"zoom": 1.2, "profiles": {"reel": {"crop_x": 0.8}}});
2528 assert_eq!(changed(&at(&base_fx(), &reel), &at(&fx, &reel)), ["d"]);
2530 let land = Profile::preview();
2531 assert_eq!(
2532 changed(&at(&base_fx(), &land), &at(&fx, &land)),
2533 Vec::<String>::new()
2534 );
2535 let reframe = |p: &Profile| match &at(&fx, p).segments[3].unit {
2536 Unit::Shot(d) => d.filters.reframe.clone(),
2537 _ => None,
2538 };
2539 assert_ne!(reframe(&reel), reframe(&land));
2540 }
2541
2542 #[test]
2543 fn nle_geometry_is_read_and_keyed() {
2544 let at = |fx: &[(usize, &str, Value)]| {
2545 plan(
2546 &timeline(filtered(fx), vec![]),
2547 Path::new("/proj"),
2548 &Profile::preview(),
2549 &Fake,
2550 None,
2551 )
2552 .unwrap()
2553 };
2554 let mut fx = base_fx();
2555 fx.push((
2556 2,
2557 "ffcine.geo.transform/1",
2558 json!({"units": "picture", "y": "down",
2559 "scale_x": [{"t": 0.0, "v": 1.0}, {"t": 1.0, "v": 0.5}]}),
2560 ));
2561 fx.push((2, "ffcine.geo.crop/1", json!({"left": 0.1})));
2562 let p = at(&fx);
2563 assert_eq!(changed(&at(&base_fx()), &p), ["c"]);
2564 let Unit::Shot(c) = &p.segments[2].unit else {
2565 panic!("shot")
2566 };
2567 assert!(!c.filters.geo.transforms.is_empty() && c.filters.geo.crop.is_some());
2568 assert!(c.filters.whole_clip());
2570 let mut bad = base_fx();
2571 bad.push((2, "ffcine.geo.transform/1", json!({"units": "inches"})));
2572 assert!(
2573 plan(
2574 &timeline(filtered(&bad), vec![]),
2575 Path::new("/proj"),
2576 &Profile::preview(),
2577 &Fake,
2578 None,
2579 )
2580 .is_err()
2581 );
2582 }
2583
2584 #[test]
2585 fn stabilized_pieces_share_one_span_and_detect_inputs_are_keyed() {
2586 let p = plan_of(&timeline(filtered(&base_fx()), vec![]));
2587 let Unit::Shot(b) = p.segments[1].unit.clone() else {
2588 panic!()
2589 };
2590 let recipe = b.filters.recipe().to_string();
2591 assert!(recipe.contains(&format!(
2592 "\"motion_cache\":{}",
2593 ffcine_stabilize::cache::FORMAT_VERSION
2594 )));
2595 assert!(recipe.contains("analysis_width"));
2596 let mut units = vec![("b".to_owned(), 72, Unit::Shot(b))];
2598 units = split_at(units, &[24, 48], RATE);
2599 set_spans(&mut units);
2600 let pieces: Vec<(i64, (i64, i64))> = units
2601 .iter()
2602 .map(|u| match &u.2 {
2603 Unit::Shot(s) => (s.clip_at, s.span),
2604 _ => panic!(),
2605 })
2606 .collect();
2607 assert_eq!(pieces, [(0, (0, 72)), (24, (0, 72)), (48, (0, 72))]);
2608 }
2609
2610 #[test]
2611 fn denoised_pieces_read_their_neighbours_inside_the_clip() {
2612 let p = plan_of(&timeline(filtered(&base_fx()), vec![]));
2613 let Unit::Shot(mut b) = p.segments[1].unit.clone() else {
2614 panic!()
2615 };
2616 b.filters.denoise = grade_denoise(2.0);
2617 let len = b.clip_frames;
2618 assert_eq!(len, 72);
2619 let units = split_at(vec![("b".to_owned(), len, Unit::Shot(b))], &[24], RATE);
2620 let ctx: Vec<(i64, i64, (i64, i64))> = units
2621 .iter()
2622 .map(|u| match &u.2 {
2623 Unit::Shot(s) => (s.clip_at, s.frames, s.context()),
2624 _ => panic!(),
2625 })
2626 .collect();
2627 assert_eq!(ctx, [(0, 24, (0, 2)), (24, 48, (2, 0))]);
2628 for (at, n, (before, after)) in ctx {
2629 assert!(at - before >= 0 && at + n + after <= len);
2630 }
2631 let Unit::Shot(s) = &units[1].2 else { panic!() };
2632 assert_eq!(s.recipe()["context"], json!([2, 0]));
2633 let inner = s.shifted(-23, RATE).with_frames(70);
2635 assert_eq!((inner.clip_at, inner.context()), (1, (1, 1)));
2636 let mut frozen = s.clone();
2637 frozen.speed = 0.0;
2638 assert_eq!(frozen.context(), (0, 0));
2639 }
2640
2641 #[test]
2642 fn ramp_maps_like_render_py() {
2643 let r = Ramp {
2644 points: vec![(0.0, 1.0), (0.5, 3.0), (1.0, 1.0)],
2645 source_duration: 6.0,
2646 };
2647 let segs = r.segments();
2648 assert_eq!(segs.len(), 2);
2649 assert!((segs[1].4 - 3.0_f64.ln() * 1.5).abs() < 1e-9);
2651 assert!((r.source_time(segs[1].4) - 3.0).abs() < 1e-9);
2653 assert!((r.source_time(0.5) - 0.5).abs() < 0.2);
2654 let e = r.setpts();
2655 assert!(e.starts_with("setpts='(if(lt(((PTS-STARTPTS)*TB),3.000000),"));
2656 assert!(e.contains("log("));
2657 }
2658
2659 #[test]
2660 fn lut_is_keyed_by_content() {
2661 struct Changed;
2662 impl Fingerprinter for Changed {
2663 fn fingerprint(&self, path: &Path) -> Fingerprint {
2664 Fake.fingerprint(path)
2665 }
2666 fn content_hash(&self, _: &Path) -> Option<String> {
2667 Some("edited".into())
2668 }
2669 }
2670 let t = timeline(shots(), vec![]);
2671 let a = plan_of(&t);
2672 let b = plan(&t, Path::new("/proj"), &Profile::preview(), &Changed, None).unwrap();
2673 assert_eq!(Diff::new(&a, &b).render_count(), 4);
2674 let c = plan(&t, Path::new("/proj"), &Profile::master(), &Fake, None).unwrap();
2675 assert_eq!(Diff::new(&a, &c).render_count(), 4);
2676 }
2677
2678 #[allow(clippy::needless_pass_by_value)]
2679 fn effect(schema: &str, v: Value) -> Effect {
2680 Effect::Effect(GenericEffect::ffcine(
2681 schema,
2682 v.as_object().cloned().unwrap_or_default(),
2683 ))
2684 }
2685
2686 fn with_fx(mut item: Item, e: Effect) -> Item {
2687 if let Item::Clip(c) = &mut item {
2688 c.effects.push(e);
2689 }
2690 item
2691 }
2692
2693 #[test]
2694 fn finish_effects_key_only_their_clip() {
2695 let base = plan_of(&timeline(shots(), vec![]));
2696 let mut v = shots();
2697 v[1] = with_fx(
2698 v.remove(1),
2699 effect("ffcine.fx.vignette/1", json!({"amount": 0.3})),
2700 );
2701 let p = plan_of(&timeline(v, vec![]));
2702 assert_eq!(Diff::new(&base, &p).render_count(), 1);
2703 let Unit::Shot(b) = &p.segments[1].unit else {
2704 panic!()
2705 };
2706 assert!((b.finish.vignette - 0.3).abs() < 1e-9);
2707 let mut v = shots();
2709 v[0] = with_fx(
2710 clip("a", "A", 0.0, 240.0, "day"),
2711 effect("ffcine.fx.fade/1", json!({"out": 1.0})),
2712 );
2713 let p = plan_of(&timeline(v, vec![]));
2714 let Unit::Shot(a) = &p.segments[0].unit else {
2715 panic!()
2716 };
2717 assert!(a.finish.is_timed());
2718 let pieces = split_at(vec![("a".into(), 240, Unit::Shot(a.clone()))], &[120], RATE);
2719 let Unit::Shot(second) = &pieces[1].2 else {
2720 panic!()
2721 };
2722 assert_eq!((second.clip_at, second.clip_frames), (120, 240));
2723 }
2724
2725 #[test]
2726 fn transition_kinds_key_the_overlap_only() {
2727 let t = |meta: Option<Value>| {
2728 let mut v = shots();
2729 let mut tr =
2730 Transition::dissolve(RationalTime::new(0.0, RATE), RationalTime::new(8.0, RATE));
2731 if let Some(m) = meta {
2732 tr.ffcine_mut()
2733 .extend(m.as_object().cloned().unwrap_or_default());
2734 }
2735 v.insert(2, Item::Transition(tr));
2736 plan_of(&timeline(v, vec![]))
2737 };
2738 let d = t(None);
2739 let w = t(Some(
2740 json!({"schema": "ffcine.transition/1", "kind": "whip"}),
2741 ));
2742 let Unit::Transition { kind, .. } = &w.segments[2].unit else {
2743 panic!()
2744 };
2745 assert_eq!(*kind, TransitionKind::Whip([-1.0, 0.0]));
2746 assert_eq!(Diff::new(&d, &w).render_count(), 1);
2747 }
2748
2749 fn split_stack(panels: usize, frames: f64) -> Item {
2750 let mut st = ffcine_timeline::Stack {
2751 name: "split".into(),
2752 ..ffcine_timeline::Stack::default()
2753 };
2754 for k in 0..panels {
2755 let mut t = Track::new(format!("panel {k}"), track_kind::VIDEO);
2756 t.children = vec![clip(&format!("p{k}"), &format!("P{k}"), 0.0, frames, "day")];
2757 st.children.push(Item::Track(t));
2758 }
2759 st.effects.push(effect(
2760 "ffcine.comp.layout/1",
2761 json!({"layout": "split", "gap": 0.01}),
2762 ));
2763 Item::Stack(st)
2764 }
2765
2766 #[test]
2767 fn split_screens_keep_every_panel() {
2768 let mut v = shots();
2769 v.insert(1, split_stack(3, 60.0));
2770 let p = plan_of(&timeline(v, vec![]));
2771 let Unit::Split(sp) = &p.segments[1].unit else {
2772 panic!("not a split: {:?}", p.segments[1].name)
2773 };
2774 assert_eq!(sp.panels.len(), 3);
2775 assert!(
2776 sp.panels
2777 .iter()
2778 .all(|parts| parts.len() == 1 && parts[0].shot.is_some())
2779 );
2780 assert_eq!(p.segments[1].frames, 60);
2781 assert!(p.warnings.is_empty(), "{:?}", p.warnings);
2782 let pieces = split_at(vec![("s".into(), 60, Unit::Split(sp.clone()))], &[30], RATE);
2784 let Unit::Split(second) = &pieces[1].2 else {
2785 panic!()
2786 };
2787 assert_eq!(second.at, 30);
2788 }
2789
2790 fn overlay_track(at: f64, frames: f64, opacity: f64) -> Item {
2791 let mut t = Track::new("PiP 1", track_kind::VIDEO);
2792 let logo = MediaReference::External(ExternalReference::from_path(
2793 Path::new("/media/logo.png"),
2794 None,
2795 ));
2796 let mut c = Clip::new(
2797 "logo",
2798 logo,
2799 Some(TimeRange::from_frames(0.0, frames, RATE)),
2800 );
2801 c.effects.push(effect(
2802 "ffcine.geo.transform/1",
2803 json!({"units": "frame", "translate_x": 0.3, "scale_x": 0.2, "scale_y": 0.2}),
2804 ));
2805 c.effects
2806 .push(effect("ffcine.comp.blend/1", json!({"opacity": opacity})));
2807 t.children = vec![
2808 Item::Gap(Gap::new(RationalTime::new(at, RATE))),
2809 Item::Clip(c),
2810 ];
2811 Item::Track(t)
2812 }
2813
2814 #[test]
2815 fn overlays_cut_and_key_the_segments_under_them() {
2816 let with = |opacity: f64| {
2817 let mut t = timeline(shots(), vec![]);
2818 t.tracks
2819 .children
2820 .insert(1, overlay_track(60.0, 60.0, opacity));
2821 plan_of(&t)
2822 };
2823 let base = plan_of(&timeline(shots(), vec![]));
2824 let p = with(1.0);
2825 let layered: Vec<_> = p
2828 .segments
2829 .iter()
2830 .filter(|s| !s.layers.is_empty())
2831 .map(|s| {
2832 (
2833 s.name.as_str(),
2834 s.layers[0].from,
2835 s.layers[0].to,
2836 s.layers[0].clip_at,
2837 )
2838 })
2839 .collect();
2840 assert_eq!(layered, [("b", 12, 72, 0)]);
2841 assert!(matches!(
2842 p.segments[1].layers[0].clip.source,
2843 OverlaySource::Image { .. }
2844 ));
2845 let d = Diff::new(&base, &p);
2846 assert_eq!(d.render_count(), 1);
2847 assert_eq!(Diff::new(&p, &with(0.5)).render_count(), 1);
2849 }
2850
2851 #[test]
2852 fn timeline_grain_keys_everything_per_profile() {
2853 let mut t = timeline(shots(), vec![]);
2854 let base = plan_of(&t);
2855 t.tracks.effects.push(effect(
2856 "ffcine.fx.grain/1",
2857 json!({"strength": 1.7, "chroma": 0.4, "profiles": {"master": {"strength": 0.0}}}),
2858 ));
2859 let p = plan_of(&t);
2860 assert_eq!(
2861 p.grain.as_ref().and_then(|g| g.get("strength")),
2862 Some(&json!(1.7))
2863 );
2864 assert!(
2865 p.grain
2866 .as_ref()
2867 .is_some_and(|g| !g.contains_key("profiles"))
2868 );
2869 assert_eq!(Diff::new(&base, &p).render_count(), 4);
2870 let m = plan(&t, Path::new("/proj"), &Profile::master(), &Fake, None).unwrap();
2871 assert!(m.grain.is_none());
2872 }
2873
2874 #[test]
2875 fn dissolves_into_and_out_of_splits() {
2876 let mut v = shots();
2877 let tr = || {
2878 Item::Transition(Transition::dissolve(
2879 RationalTime::new(0.0, RATE),
2880 RationalTime::new(10.0, RATE),
2881 ))
2882 };
2883 v.insert(1, tr());
2884 v.insert(2, split_stack(2, 60.0));
2885 v.insert(3, tr());
2886 let p = plan_of(&timeline(v, vec![]));
2887 assert!(p.warnings.is_empty(), "{:?}", p.warnings);
2888 let kinds: Vec<_> = p
2889 .segments
2890 .iter()
2891 .map(|s| match &s.unit {
2892 Unit::SplitDissolve {
2893 split, split_out, ..
2894 } => format!("sd{}@{}", u8::from(*split_out), split.at),
2895 Unit::Split(sp) => format!("split@{}", sp.at),
2896 _ => s.name.clone(),
2897 })
2898 .collect();
2899 assert_eq!(kinds, ["a", "sd0@0", "split@10", "sd1@60", "b", "c", "d"]);
2900 assert_eq!(p.frames(), 48 + 60 + 72 + 30 + 50);
2901 }
2902}