Skip to main content

ffcine_assembly/
plan.rs

1//! Segment planner: flattens a timeline's picture track into render units (one per clip, one per
2//! dissolve overlap, one per gap) and its audio tracks into one mix, each keyed by content.
3
4use 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
27/// `ffcine.time.ramp/1`.
28pub const RAMP_SCHEMA: &str = "ffcine.time.ramp/1";
29
30/// The generator kind of a comp clip in a timeline ([`plan_with`]).
31pub const COMP_GENERATOR: &str = "ffcine.comp";
32
33/// Errors from [`plan`].
34#[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    /// A comp piece that doesn't lower (a parameter out of range, an unreadable layer source).
45    #[error("comp: {0}")]
46    Comp(ffcine_core::Error),
47    /// An `ffcine.comp` clip that can't be read or doesn't fit the timeline.
48    #[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/// A speed ramp (`ffcine.time.ramp/1`): `points [(u, speed)]` piecewise linear over the clip's
64/// `source_duration` seconds of source (u 0..1).
65#[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    /// `render.py` `ramp_map`: `(t0, t1, s0, s1, out0)` per source-time segment, output time =
73    /// `out0 + integral(dt / speed)`.
74    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    /// Source seconds since the clip's first frame shown at output second `t` (the inverse of
95    /// [`Ramp::segments`]' map).
96    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    /// ffmpeg `setpts` expression mapping source time since the first frame to output time
115    /// (`render.py` `ramp_setpts`).
116    pub fn setpts(&self) -> String {
117        self.setpts_of("((PTS-STARTPTS)*TB)")
118    }
119
120    /// [`Ramp::setpts`] with the source time given by the expression `x`.
121    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/// A clip's picture filters, read from its effects and resolved for the render profile.
140#[derive(Debug, Clone, Default, PartialEq)]
141pub struct Filters {
142    /// `ffcine.fx.stabilize/1`: GPU optical-flow detect over the clip, smoothed, warped in the
143    /// grade pass.
144    pub stabilize: Option<StabilizeParams>,
145    /// `ffcine.fx.denoise/1`, else the grade's `denoise` number: temporal (NVOF motion) and
146    /// spatial denoise on the decoded planes.
147    pub denoise: Option<Denoise>,
148    /// `ffcine.geo.reframe/1` with the profile's `profiles.<aspect>` override applied.
149    pub reframe: Option<Reframe>,
150    /// `ffcine.time.ramp/1`: the clip is retimed through the ramp, not its average speed.
151    pub ramp: Option<Ramp>,
152    /// `ffcine.time.blend/1` `frames` (> 1): each output frame is the mean of the last `frames`
153    /// frames at the timeline rate (the prototype's `tmix`), history from before the piece.
154    pub blend: Option<u32>,
155    /// `ffcine.geo.crop/1` and `ffcine.geo.transform/1` (keyed through the clip's `fx`).
156    pub geo: Geo,
157}
158
159impl Filters {
160    /// Whether a piece of the clip depends on where it sits in the clip (stabilized path,
161    /// animated reframe, ramp).
162    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    /// Everything about the filters that changes pixels, including the inputs they derive
170    /// from (the stabilizer's detect settings and motion cache format).
171    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/// `GradeParams.denoise` (the edit.yaml / `render.py` hqdn3d number `d`) as the GPU denoiser:
188/// temporal (NVOF, radius 2) at strength `d`, spatial NLM at `0.2 d` (the prototype's
189/// `nlmeans_vulkan s=0.2*d`).
190#[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/// A clip's picture as one render input: a source range at a speed, graded.
214#[derive(Debug, Clone, PartialEq)]
215pub struct Shot {
216    /// Display name (not part of the key).
217    pub name: String,
218    pub media: PathBuf,
219    pub fingerprint: Fingerprint,
220    /// Source time of the first frame, seconds.
221    pub start: f64,
222    /// Source seconds per timeline second: 1 normal, 0 freeze frame.
223    pub speed: f64,
224    /// Timeline frames.
225    pub frames: i64,
226    /// Resolved grade (base <- look <- source-kind rule <- clip).
227    pub grade: GradeParams,
228    /// The `.cube` file the grade's `lut` names.
229    pub lut: Option<PathBuf>,
230    /// Recipe of the resolved grade with the LUT by content.
231    grade_recipe: Value,
232    /// The clip's other ffcine effects (reframe, stabilize, ...), canonical, for the key.
233    pub(crate) fx: Vec<Value>,
234    /// Vignette, darken, fades, flash.
235    pub finish: Finish,
236    /// The clip's `ffcine.fx.grain/1`, overriding the timeline's.
237    pub grain: Option<Map<String, Value>>,
238    /// Frames from the clip's first frame to this one's (negative for the incoming side of a
239    /// transition), and the clip's length: fades, and whole-clip filters (stabilized path,
240    /// reframe animation, ramp) are evaluated over the clip, then sliced.
241    pub clip_at: i64,
242    pub clip_frames: i64,
243    pub filters: Filters,
244    /// Source time of the clip's first frame.
245    pub clip_start: f64,
246    /// Clip frames `[lo, hi)` the stabilizer analyses: every piece of the clip, so all of them
247    /// share one camera path.
248    pub span: (i64, i64),
249    /// Which clip of the timeline the piece is from (not part of the key).
250    clip_index: usize,
251}
252
253impl Shot {
254    /// The same shot starting `k` timeline frames later (earlier if negative).
255    #[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    /// Frames of the clip before and after this piece that a temporal denoiser reads as history
273    /// and lookahead: its radius on each side, fewer at the clip's ends.
274    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        // Fades, flashes and whole-clip filters depend on where the piece sits in its clip;
300        // nothing else does, so trimming a clip's tail leaves its other pieces' keys alone.
301        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/// One stretch of a split-screen panel: a shot, or nothing (the backdrop shows).
319#[derive(Debug, Clone, PartialEq)]
320pub struct PanelPart {
321    /// Frames from the split's start.
322    pub from: i64,
323    pub frames: i64,
324    pub shot: Option<Shot>,
325}
326
327/// A split-screen (`Stack` with `ffcine.comp.layout/1`), or a piece of one.
328#[derive(Debug, Clone, PartialEq)]
329pub struct Split {
330    /// `ffcine.comp.layout/1` parameters.
331    pub layout: Map<String, Value>,
332    /// Per panel, its parts covering the split.
333    pub panels: Vec<Vec<PanelPart>>,
334    /// The stack's own finish (vignette, fades).
335    pub finish: Finish,
336    /// Frames from the split's start to this piece's, and the split's length.
337    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/// What an overlay clip shows.
361#[allow(clippy::large_enum_variant)] // a few per timeline
362#[derive(Debug, Clone, PartialEq)]
363pub enum OverlaySource {
364    /// Picture-in-picture: a video clip, graded like a shot.
365    Video(Shot),
366    /// A still (PNG logo, sticker), drawn as is.
367    Image {
368        path: PathBuf,
369        fingerprint: Fingerprint,
370    },
371}
372
373/// A non-text clip on a video track above the picture.
374#[derive(Debug, Clone, PartialEq)]
375pub struct OverlayClip {
376    pub name: String,
377    pub source: OverlaySource,
378    /// Timeline frame of its first frame, and its length.
379    pub start: i64,
380    pub frames: i64,
381    /// `source_range.start_time` in seconds: where keyframe time starts.
382    pub source_start: f64,
383    /// `ffcine.geo.transform/1` and `ffcine.comp.blend/1` parameters.
384    pub transform: Map<String, Value>,
385    pub blend: Map<String, Value>,
386}
387
388/// An overlay clip's part within one segment.
389#[derive(Debug, Clone, PartialEq)]
390pub struct SegLayer {
391    pub clip: Arc<OverlayClip>,
392    /// Segment frames `from..to`.
393    pub from: i64,
394    pub to: i64,
395    /// Frames from the clip's first frame to `from`.
396    pub clip_at: i64,
397}
398
399impl SegLayer {
400    /// The video shot for this part (shifted into the clip), `None` for images.
401    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/// What a segment renders.
423#[allow(clippy::large_enum_variant)] // a few per timeline
424#[derive(Debug, Clone, PartialEq)]
425pub enum Unit {
426    Shot(Shot),
427    /// Black.
428    Gap,
429    /// A transition from `a` to `b`, both `frames` long.
430    Transition {
431        a: Shot,
432        b: Shot,
433        kind: TransitionKind,
434    },
435    /// A split-screen.
436    Split(Split),
437    /// A dissolve between a split-screen and a shot (`split_out`: the split is outgoing).
438    SplitDissolve {
439        split: Split,
440        shot: Shot,
441        split_out: bool,
442    },
443    /// A piece of a comp (`ffcine.comp/1`), lowered.
444    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            // The original form for dissolves, so their keys only change with the revision.
453            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/// One render unit of the picture.
473#[derive(Debug, Clone, PartialEq)]
474pub struct Segment {
475    pub key: Key,
476    pub name: String,
477    /// Timeline frame it starts at (display only, not in the key).
478    pub start: i64,
479    pub frames: i64,
480    pub unit: Unit,
481    /// Overlay clips over it (picture-in-picture, stickers), bottom first.
482    pub layers: Vec<SegLayer>,
483}
484
485/// The timeline's audio: one `ffcine_audio::render_audio` mix (clean chain, R128 levelling,
486/// buses with ducking, synced recorder clips, master limiter), keyed by its recipe: every clip
487/// it reads (placement, media identity, `ffcine.audio.*` effects, sync) and the mix options.
488/// Nothing about the picture's grade or effects is in it, and it isn't in any segment key.
489#[derive(Debug, Clone, PartialEq)]
490pub struct AudioPlan {
491    pub key: Key,
492    /// Clips mixed.
493    pub clips: usize,
494    /// What the mix reads.
495    pub timeline: Arc<Timeline>,
496    /// Defaults <- `project.mix` <- overrides ([`AudioPlan::set_options`]); `length` is the
497    /// picture's.
498    pub options: MixOptions,
499    /// The clip part of the key.
500    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    /// Replaces the mix options (CLI overrides of `project.mix`) and re-keys. `base` and
514    /// `length` stay the plan's.
515    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/// A planned render.
526#[derive(Debug, Clone, PartialEq)]
527pub struct Plan {
528    /// Timeline frame rate.
529    pub rate: f64,
530    pub profile: Profile,
531    pub segments: Vec<Segment>,
532    pub audio: Option<AudioPlan>,
533    /// The timeline's film grain (`ffcine.fx.grain/1` on its top-level stack, with the
534    /// profile's override folded in), on every segment.
535    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    /// Key of the joined picture: the segment keys in order.
545    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    /// Key of the muxed master: picture + audio + audio encode.
551    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
561/// Plans `timeline` (its file's directory is `base_dir`, for relative LUT paths) for `profile`.
562/// With `text`, segments are cut at every text in/out point and keyed by the text they show,
563/// so a card edit re-renders only the seconds it covers.
564///
565/// The picture is the first video track. Split-screen `Stack`s render as their first panel until
566/// compositing lands; other video tracks are text (`TextLayer`) or not yet composited (warned).
567/// `ffcine.comp` clips need probed media: plan those timelines with [`plan_with`].
568pub 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
578/// Whether `timeline` has an `ffcine.comp` clip (so [`plan_with`] needs `media`).
579pub fn has_comp_clips(timeline: &Timeline) -> bool {
580    timeline.clips().iter().any(|c| comp_clip(c).is_some())
581}
582
583/// `clip`'s `ffcine.comp` generator parameters, if it is one.
584fn 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/// [`plan`], with `media` to lower `ffcine.comp` clips (`docs/timeline.md`): a clip whose
592/// `GeneratorReference` is `ffcine.comp` with parameters `{"schema": "ffcine.comp/1", "src":
593/// PATH}` (relative to the timeline) or the comp document itself, inline. Its `source_range` is
594/// comp time; each comp piece it covers is a segment ([`Unit::Comp`]) keyed by its lowered
595/// layers, as [`plan_comp`]'s are.
596#[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        // Only when set, so timelines without them keep their keys.
689        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
719/// A source's camera profile, probed ([`ffcine_core::camera::Profile::detect_file`]).
720pub type ProbeProfile<'a> = &'a dyn Fn(&Path) -> ffcine_core::Result<ffcine_core::camera::Profile>;
721
722/// A video's probed average frame rate and frame count.
723pub type ProbeRate<'a> = &'a dyn Fn(&Path) -> ffcine_core::Result<(f64, u64)>;
724
725/// What a comp's lowering reads of its media: probed sizes (`dims`), camera profiles for
726/// `camera: auto` (`profile`; `None`: every source is display-referred), frame rates for time
727/// remaps (`rate`; `None`: a remap is an error) and fingerprints.
728pub 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
765/// Plans a comp (`docs/compositing.md`) for `profile` (see [`Profile::for_canvas`]): one
766/// segment per piece ([`ffcine_comp::Comp::pieces`]), keyed by its lowered, sampled values, so
767/// the segment cache, `diff` and the manifest work as for timelines. No audio.
768pub 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
812/// `ffcine.fx.grain/1` on the timeline's top-level stack, `profiles.<name>` folded in.
813fn 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
823/// A `ffcine.fx.grain/1`'s parameters for render profile `profile` (`profiles.<name>` folded
824/// in, `schema` dropped); `None` if its strength is 0.
825pub 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)] // a few per timeline
838enum 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    /// An `ffcine.comp` clip: its lowered pieces, `frames` in all.
849    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    /// For lowering `ffcine.comp` clips.
863    media: Option<&'a dyn ffcine_comp::Media>,
864    /// Clips seen so far ([`Shot::clip_index`]).
865    clips: usize,
866    warnings: Vec<String>,
867}
868
869/// Sets every stabilized piece's [`Shot::span`] to the union of its clip's pieces and the clip
870/// itself.
871fn 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    /// A split-screen stack: each child is a panel, flattened to its shots over `total` frames.
988    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    /// An `ffcine.comp` clip lowered over its source range (comp time; default the whole comp).
1059    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            // Past the comp's end: black, like a gap.
1143            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    /// The non-text clips of an overlay video track.
1153    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    /// A clip's picture, or `None` (rendered black) if its media isn't a file.
1241    #[allow(clippy::too_many_lines)] // one arm per effect
1242    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                        // The mix's, keyed there.
1289                    } 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                            // Only this aspect's override is keyed (in `filters` too).
1309                            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    /// Reads a stabilize, denoise, ramp or blend effect of clip `clip` into `filters`; `false` if `g`
1383    /// isn't one (they're keyed through `filters`, not `fx`).
1384    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(&params())
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(&params()).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    /// Turns transitions into dissolve units: the outgoing shot loses `before` frames, the
1457    /// incoming one `after`, and the overlap (`before + after`) is its own unit.
1458    #[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    /// The mix of `timeline`, `length` seconds long (the picture's).
1596    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            // Only what the mix reads: a picture effect (grade, reframe) on a camera clip whose
1620            // audio is the dialogue changes no audio.
1621            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
1660/// Pads with black or cuts `elems` to `total` frames.
1661fn 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
1693/// Splits shot, split-screen and gap units at timeline frames `cuts` (transitions stay whole), skipping cuts
1694/// that would leave a piece shorter than a second.
1695fn 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        // Pieces under a second cost more in per-segment setup than they save.
1701        #[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        // Comp pieces are cut where their values change already, and a part of one would need
1710        // lowering again.
1711        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
1739/// Segment keys of `plan` as a set.
1740pub 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    /// Fingerprints from the path alone.
1756    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        // The diff sees it like a timeline edit.
1809        let d = Diff::new(&a, &b);
1810        assert!(d.report(&b, false).contains("comp 2.00-4.00 s"));
1811    }
1812
1813    /// [`Fake`] with an ffmpeg build string.
1814    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    /// A new ffmpeg (the injected fingerprinter's build string) re-renders the segments with a
1828    /// filter node, and only those.
1829    #[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    /// An `ffcine.comp` generator clip over `from..to` frames of comp time.
1889    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        // The shots around it key as they would around a gap.
1944        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        // Past the comp's end is black; a transition into it is a cut.
1961        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        // No media, the wrong schema or rate, or `src` beside an inline comp: errors.
1979        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        // Effects on a comp clip aren't applied yet: one warning per effect.
2028        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    /// `review` decodes a camera proxy beside the clip when there is one, and keys apart from
2071    /// `preview`; a clip without one renders from its source, warned.
2072    #[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    /// Regression (a pale clip after an insert): a cache keyed by position gave inserted-after clips other clips'
2134    /// grades. Inserting or removing a clip must change no other clip's key.
2135    #[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        // Same clip, same content at another position: same key.
2176        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    /// Every mix edit (clip gain keyframes, fades, duck, sync drift, `project.mix`, CLI
2207    /// overrides) changes the audio key and nothing else: a remux, never a video render.
2208    #[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    /// With no audio tracks the camera audio is the dialogue: a grade edit changes its segment,
2276    /// not the mix; a level edit on the clip changes the mix, not the segment.
2277    #[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        // The outgoing side continues where "b" stops; the incoming side starts 6 frames before
2327        // "c"'s cut.
2328        assert!((a.start - (100.0 + 66.0) / RATE).abs() < 1e-9);
2329        assert!((b.start - (10.0 - 6.0) / RATE).abs() < 1e-9);
2330        // A longer dissolve changes the overlap and the incoming clip, not the others.
2331        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        // 10 is too close to the start, 250 too close to the gap's end.
2374        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    /// Per-clip filter parameters: `(clip index, schema, params)`.
2395    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        // Nothing changed: every segment comes from the cache.
2438        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        // Adding the filters re-renders the four clips.
2442        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        // The grade's exposure and denoise number are keyed with the clip.
2483        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        // One frame is no blend.
2509        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        // The reel override changes the reel render of "d" only, not the landscape one.
2529        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        // Animated: keyed by where the piece sits in its clip.
2569        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        // Text cuts split "b" in three; every piece analyses the whole clip.
2597        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        // A frame from either end: one frame of history, one of lookahead.
2634        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        // 3 s of source from speed 1 to 3: ln(3) / (2/3) s of output.
2650        assert!((segs[1].4 - 3.0_f64.ln() * 1.5).abs() < 1e-9);
2651        // Inverse: the mid point of the source is reached at the first segment's end.
2652        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        // A fade keys the piece's place in its clip: text cuts then give pieces their own keys.
2708        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        // Long splits are cut at text/overlay points like shots, each piece knowing its offset.
2783        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        // "b" (48..120) is cut at 60: its first 12 frames are too short a piece, so the cut is
2826        // skipped and "b" carries the overlay from frame 12.
2827        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        // Changing the overlay re-renders only what it covers.
2848        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}