simple_editor/
model.rs

1//! Project data model — the shared contract between UI, engine, playback and export.
2//! All times are seconds (f64). Keyframe times are clip-local (seconds from `clip.start`).
3//! Serialized with serde_json as the `.sedit` project format.
4
5use serde::{Deserialize, Serialize};
6use std::hash::{Hash, Hasher};
7use std::path::Path;
8
9pub type Id = u64;
10/// Smallest clip length / trim epsilon.
11pub const MIN_CLIP: f64 = 0.001;
12const EPS: f64 = 1e-6;
13const KEY_EPS: f64 = 1e-4;
14/// Two clips abut (for transitions) when their boundary times differ by less than this.
15pub const ABUT_EPS: f64 = 1e-4;
16
17#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize, Hash)]
18pub enum TrackKind {
19    Video,
20    Audio,
21}
22
23#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize, Hash)]
24pub enum ClipKind {
25    Video,
26    Image,
27    Text,
28    Audio,
29    /// A nested timeline (`Project.sequences`) used as footage; `clip.sequence` is its id. Lives on video
30    /// tracks; carries the sequence's audio too (the mixer walks video tracks for these).
31    Sequence,
32    /// A vector shape or a recorded drawing (`Clip.shape`).
33    Shape,
34    /// An automation / adjustment layer: its effects (or node graph) apply to everything composited
35    /// below it on lower video tracks, for as long as the clip lasts. Draws nothing of its own.
36    Adjustment,
37}
38
39/// Resampling quality used when the compositor scales/rotates layers (export == preview).
40#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize, Default, Hash)]
41pub enum Scaler {
42    Nearest,
43    #[default]
44    Bilinear,
45    Bicubic,
46}
47
48impl Scaler {
49    pub const ALL: [Scaler; 3] = [Scaler::Nearest, Scaler::Bilinear, Scaler::Bicubic];
50    pub fn name(self) -> &'static str {
51        match self {
52            Scaler::Nearest => "Nearest neighbour",
53            Scaler::Bilinear => "Bilinear",
54            Scaler::Bicubic => "Bicubic",
55        }
56    }
57}
58
59#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize, Default, Hash)]
60pub enum BlendMode {
61    #[default]
62    Normal,
63    Multiply,
64    Screen,
65    Overlay,
66    Darken,
67    Lighten,
68    Add,
69    Subtract,
70    Difference,
71    SoftLight,
72    HardLight,
73    ColorDodge,
74    ColorBurn,
75}
76
77impl BlendMode {
78    pub const ALL: [BlendMode; 13] = [
79        BlendMode::Normal,
80        BlendMode::Multiply,
81        BlendMode::Screen,
82        BlendMode::Overlay,
83        BlendMode::Darken,
84        BlendMode::Lighten,
85        BlendMode::Add,
86        BlendMode::Subtract,
87        BlendMode::Difference,
88        BlendMode::SoftLight,
89        BlendMode::HardLight,
90        BlendMode::ColorDodge,
91        BlendMode::ColorBurn,
92    ];
93    pub fn name(self) -> &'static str {
94        match self {
95            BlendMode::Normal => "Normal",
96            BlendMode::Multiply => "Multiply",
97            BlendMode::Screen => "Screen",
98            BlendMode::Overlay => "Overlay",
99            BlendMode::Darken => "Darken",
100            BlendMode::Lighten => "Lighten",
101            BlendMode::Add => "Add",
102            BlendMode::Subtract => "Subtract",
103            BlendMode::Difference => "Difference",
104            BlendMode::SoftLight => "Soft Light",
105            BlendMode::HardLight => "Hard Light",
106            BlendMode::ColorDodge => "Color Dodge",
107            BlendMode::ColorBurn => "Color Burn",
108        }
109    }
110}
111
112/// Interpolation of the segment that *starts* at a keyframe.
113#[derive(Clone, Copy, PartialEq, Debug, Serialize, Deserialize, Default)]
114pub enum Ease {
115    #[default]
116    Linear,
117    EaseIn,
118    EaseOut,
119    EaseInOut,
120    /// Step: hold the key's value until the next key.
121    Hold,
122    /// CSS-style cubic bezier through (0,0) (x1,y1) (x2,y2) (1,1): the velocity handles of the curve
123    /// editor. y may leave 0..1 (overshoot / anticipate).
124    Bezier {
125        x1: f32,
126        y1: f32,
127        x2: f32,
128        y2: f32,
129    },
130}
131
132impl Ease {
133    /// The fixed kinds (the curve editor offers these plus the bezier presets below).
134    pub const ALL: [Ease; 5] = [Ease::Linear, Ease::EaseIn, Ease::EaseOut, Ease::EaseInOut, Ease::Hold];
135    /// Named velocity presets (bezier handles).
136    pub const PRESETS: [(&'static str, Ease); 6] = [
137        ("Smooth", Ease::Bezier { x1: 0.42, y1: 0.0, x2: 0.58, y2: 1.0 }),
138        ("Snap", Ease::Bezier { x1: 0.9, y1: 0.0, x2: 0.1, y2: 1.0 }),
139        ("Slow Start", Ease::Bezier { x1: 0.55, y1: 0.0, x2: 1.0, y2: 0.45 }),
140        ("Slow End", Ease::Bezier { x1: 0.0, y1: 0.55, x2: 0.45, y2: 1.0 }),
141        ("Overshoot", Ease::Bezier { x1: 0.34, y1: 1.56, x2: 0.64, y2: 1.0 }),
142        ("Anticipate", Ease::Bezier { x1: 0.36, y1: 0.0, x2: 0.66, y2: -0.56 }),
143    ];
144    pub fn name(self) -> &'static str {
145        match self {
146            Ease::Linear => "Linear",
147            Ease::EaseIn => "Ease In",
148            Ease::EaseOut => "Ease Out",
149            Ease::EaseInOut => "Ease In/Out",
150            Ease::Hold => "Hold",
151            Ease::Bezier { .. } => "Bezier",
152        }
153    }
154    /// Bezier handles equivalent to this ease (for dragging handles in the curve editor).
155    pub fn handles(self) -> (f32, f32, f32, f32) {
156        match self {
157            Ease::Linear | Ease::Hold => (0.33, 0.33, 0.67, 0.67),
158            Ease::EaseIn => (0.42, 0.0, 1.0, 1.0),
159            Ease::EaseOut => (0.0, 0.0, 0.58, 1.0),
160            Ease::EaseInOut => (0.42, 0.0, 0.58, 1.0),
161            Ease::Bezier { x1, y1, x2, y2 } => (x1, y1, x2, y2),
162        }
163    }
164    /// Map a linear 0..1 progress to the eased progress.
165    pub fn apply(self, f: f64) -> f64 {
166        let f = f.clamp(0.0, 1.0);
167        match self {
168            Ease::Linear => f,
169            Ease::EaseIn => f * f,
170            Ease::EaseOut => 1.0 - (1.0 - f) * (1.0 - f),
171            Ease::EaseInOut => f * f * (3.0 - 2.0 * f),
172            Ease::Hold => 0.0,
173            Ease::Bezier { x1, y1, x2, y2 } => cubic_bezier(f, x1 as f64, y1 as f64, x2 as f64, y2 as f64),
174        }
175    }
176}
177
178/// y for the x = `f` on the cubic bezier (0,0) (x1,y1) (x2,y2) (1,1) — Newton iterations on the x polynomial.
179fn cubic_bezier(f: f64, x1: f64, y1: f64, x2: f64, y2: f64) -> f64 {
180    let (x1, x2) = (x1.clamp(0.0, 1.0), x2.clamp(0.0, 1.0));
181    let bx = |t: f64| 3.0 * (1.0 - t) * (1.0 - t) * t * x1 + 3.0 * (1.0 - t) * t * t * x2 + t * t * t;
182    let by = |t: f64| 3.0 * (1.0 - t) * (1.0 - t) * t * y1 + 3.0 * (1.0 - t) * t * t * y2 + t * t * t;
183    let dbx = |t: f64| 3.0 * (1.0 - t) * (1.0 - t) * x1 + 6.0 * (1.0 - t) * t * (x2 - x1) + 3.0 * t * t * (1.0 - x2);
184    let mut t = f;
185    for _ in 0..8 {
186        let d = dbx(t);
187        if d.abs() < 1e-6 {
188            break;
189        }
190        t -= (bx(t) - f) / d;
191        t = t.clamp(0.0, 1.0);
192    }
193    by(t)
194}
195
196fn is_linear(e: &Ease) -> bool {
197    *e == Ease::Linear
198}
199
200#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
201pub struct Keyframe {
202    pub t: f64,
203    pub v: f64,
204    /// Easing of the segment from this key to the next.
205    #[serde(default, skip_serializing_if = "is_linear")]
206    pub ease: Ease,
207}
208
209/// A scalar property that is either constant (`value`) or keyframed (`keys`, sorted by t).
210#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
211pub struct Animated {
212    pub value: f64,
213    #[serde(default, skip_serializing_if = "Vec::is_empty")]
214    pub keys: Vec<Keyframe>,
215}
216
217impl Animated {
218    pub fn new(value: f64) -> Self {
219        Self { value, keys: Vec::new() }
220    }
221    pub fn is_animated(&self) -> bool {
222        !self.keys.is_empty()
223    }
224    pub fn is_default(&self, def: f64) -> bool {
225        !self.is_animated() && (self.value - def).abs() < EPS
226    }
227    /// Value at clip-local time t (eased interpolation, clamped at the ends).
228    pub fn at(&self, t: f64) -> f64 {
229        let k = &self.keys;
230        if k.is_empty() {
231            return self.value;
232        }
233        if t <= k[0].t {
234            return k[0].v;
235        }
236        let last = k[k.len() - 1];
237        if t >= last.t {
238            return last.v;
239        }
240        for w in k.windows(2) {
241            if t < w[1].t {
242                let span = w[1].t - w[0].t;
243                let f = if span > 0.0 { (t - w[0].t) / span } else { 1.0 };
244                return w[0].v + (w[1].v - w[0].v) * w[0].ease.apply(f);
245            }
246        }
247        last.v
248    }
249    pub fn key_index_at(&self, t: f64) -> Option<usize> {
250        self.keys.iter().position(|k| (k.t - t).abs() < KEY_EPS)
251    }
252    pub fn has_key_at(&self, t: f64) -> bool {
253        self.key_index_at(t).is_some()
254    }
255    fn insert(&mut self, t: f64, v: f64) {
256        let i = self.keys.partition_point(|k| k.t < t);
257        self.keys.insert(i, Keyframe { t, v, ease: Ease::Linear });
258    }
259    /// Set the value at time t: upserts a keyframe when animated, otherwise sets the constant.
260    pub fn set_at(&mut self, t: f64, v: f64) {
261        if !self.is_animated() {
262            self.value = v;
263            return;
264        }
265        match self.key_index_at(t) {
266            Some(i) => self.keys[i].v = v,
267            None => self.insert(t, v),
268        }
269    }
270    /// Add a keyframe at t holding the current value, or remove the one already there.
271    pub fn toggle_key(&mut self, t: f64) {
272        if let Some(i) = self.key_index_at(t) {
273            let v = self.keys.remove(i).v;
274            if self.keys.is_empty() {
275                self.value = v;
276            }
277        } else {
278            let v = self.at(t);
279            self.insert(t, v);
280        }
281    }
282    /// Remove all keyframes, keeping the value at t.
283    pub fn clear_keys(&mut self, t: f64) {
284        self.value = self.at(t);
285        self.keys.clear();
286    }
287    /// Shift all keyframe times by dt (used when trimming/splitting).
288    pub fn shift(&mut self, dt: f64) {
289        for k in &mut self.keys {
290            k.t += dt;
291        }
292    }
293    /// Move key `i` to `new_t` (keeps keys sorted); returns its new index.
294    /// Move key `i` to `new_t`, keeping the list sorted. A key already sitting at `new_t` is replaced
295    /// (dragging one keyframe onto another merges them instead of stacking duplicates).
296    pub fn move_key(&mut self, i: usize, new_t: f64) -> usize {
297        if i >= self.keys.len() {
298            return i;
299        }
300        let mut k = self.keys.remove(i);
301        k.t = new_t;
302        if let Some(j) = self.key_index_at(new_t) {
303            self.keys[j] = k;
304            return j;
305        }
306        let j = self.keys.partition_point(|o| o.t < new_t);
307        self.keys.insert(j, k);
308        j
309    }
310    pub fn set_ease_at(&mut self, t: f64, ease: Ease) {
311        if let Some(i) = self.key_index_at(t) {
312            self.keys[i].ease = ease;
313        }
314    }
315}
316
317/// Text clip styling. Sizes are in project pixels (at project resolution).
318#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
319#[serde(default)]
320pub struct TextStyle {
321    pub text: String,
322    pub font: String,
323    pub size: f32,
324    pub bold: bool,
325    pub italic: bool,
326    pub color: [u8; 4],
327    pub outline_width: f32,
328    pub outline_color: [u8; 4],
329    pub shadow: bool,
330    pub shadow_color: [u8; 4],
331    pub shadow_x: f32,
332    pub shadow_y: f32,
333    pub shadow_blur: f32,
334    /// 0 = left, 1 = center, 2 = right
335    pub align: u8,
336    pub line_spacing: f32,
337    pub letter_spacing: f32,
338    /// Background box behind the text; alpha 0 = none.
339    pub box_color: [u8; 4],
340    pub box_padding: f32,
341}
342
343impl Default for TextStyle {
344    fn default() -> Self {
345        Self {
346            text: "Text".into(),
347            font: "Segoe UI".into(),
348            size: 72.0,
349            bold: false,
350            italic: false,
351            color: [255, 255, 255, 255],
352            outline_width: 0.0,
353            outline_color: [0, 0, 0, 255],
354            shadow: false,
355            shadow_color: [0, 0, 0, 160],
356            shadow_x: 4.0,
357            shadow_y: 4.0,
358            shadow_blur: 2.0,
359            align: 1,
360            line_spacing: 1.0,
361            letter_spacing: 0.0,
362            box_color: [0, 0, 0, 0],
363            box_padding: 8.0,
364        }
365    }
366}
367
368impl TextStyle {
369    /// Default look for burnt-in subtitles.
370    pub fn subtitle_default() -> Self {
371        Self { text: String::new(), size: 48.0, outline_width: 3.0, ..Self::default() }
372    }
373    /// Stable hash of every field (for render caches).
374    pub fn cache_key(&self) -> u64 {
375        let mut h = std::collections::hash_map::DefaultHasher::new();
376        self.text.hash(&mut h);
377        self.font.hash(&mut h);
378        for f in [
379            self.size,
380            self.outline_width,
381            self.shadow_x,
382            self.shadow_y,
383            self.shadow_blur,
384            self.line_spacing,
385            self.letter_spacing,
386            self.box_padding,
387        ] {
388            f.to_bits().hash(&mut h);
389        }
390        (self.bold, self.italic, self.shadow, self.align).hash(&mut h);
391        (self.color, self.outline_color, self.shadow_color, self.box_color).hash(&mut h);
392        h.finish()
393    }
394}
395
396#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
397#[serde(default)]
398pub struct AudioStreamInfo {
399    /// 0-based index among the file's audio streams (ffmpeg `0:a:N`).
400    pub index: usize,
401    pub channels: u32,
402    pub sample_rate: u32,
403    pub language: String,
404    pub title: String,
405    pub codec: String,
406}
407
408impl AudioStreamInfo {
409    pub fn label(&self) -> String {
410        if !self.title.is_empty() {
411            self.title.clone()
412        } else if !self.language.is_empty() && self.language != "und" {
413            self.language.clone()
414        } else {
415            format!("Audio {}", self.index + 1)
416        }
417    }
418}
419
420/// Colour labels for assets (0 = none).
421pub const LABEL_COLORS: [(&str, [u8; 3]); 8] = [
422    ("Red", [220, 70, 70]),
423    ("Orange", [230, 140, 50]),
424    ("Yellow", [220, 200, 60]),
425    ("Green", [80, 180, 90]),
426    ("Teal", [60, 180, 180]),
427    ("Blue", [70, 120, 220]),
428    ("Purple", [150, 90, 200]),
429    ("Gray", [150, 150, 150]),
430];
431
432#[derive(Clone, Debug, Serialize, Deserialize)]
433pub struct Asset {
434    pub id: Id,
435    pub path: String,
436    /// Video, Image or Audio (never Text).
437    pub kind: ClipKind,
438    pub duration: f64,
439    pub width: u32,
440    pub height: u32,
441    pub fps: f64,
442    #[serde(default)]
443    pub audio_streams: Vec<AudioStreamInfo>,
444    #[serde(default)]
445    pub codec: String,
446    /// Library folder ("" = root, "Footage/Day 1" = nested).
447    #[serde(default)]
448    pub folder: String,
449    #[serde(default)]
450    pub tags: Vec<String>,
451    /// 0 = none, 1..=8 = index+1 into LABEL_COLORS.
452    #[serde(default)]
453    pub label: u8,
454    /// Free-form notes: what this asset is / what it's for (library + inspector).
455    #[serde(default)]
456    pub description: String,
457}
458
459impl Asset {
460    pub fn name(&self) -> String {
461        Path::new(&self.path).file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| self.path.clone())
462    }
463    pub fn has_video(&self) -> bool {
464        matches!(self.kind, ClipKind::Video | ClipKind::Image)
465    }
466}
467
468// ---------- effects ----------
469
470#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize, Hash)]
471pub enum EffectKind {
472    Blur,
473    Pixelate,
474    Tint,
475    Color,
476    Vignette,
477    Sharpen,
478    Invert,
479    Grayscale,
480    Flip,
481    Crop,
482    Wobble,
483    // --- round 3 ---
484    ChromaKey,
485    Curves,
486    Levels,
487    HueShift,
488    JpegCompress,
489    MotionBlur,
490    Plane3d,
491    EdgeGlow,
492    Threshold,
493    BlobTrack,
494    Vhs,
495    RecDot,
496    // --- round 4 ---
497    ColorReplace,
498    /// A user-written GLSL fragment shader (source in `Effect.shader`, up to 8 generic knobs).
499    Shader,
500}
501
502/// One effect parameter: label, default and UI range.
503#[derive(Clone, Copy, Debug)]
504pub struct ParamSpec {
505    pub name: &'static str,
506    pub default: f64,
507    pub min: f64,
508    pub max: f64,
509}
510
511const fn ps(name: &'static str, default: f64, min: f64, max: f64) -> ParamSpec {
512    ParamSpec { name, default, min, max }
513}
514
515impl EffectKind {
516    pub const ALL: [EffectKind; 25] = [
517        EffectKind::Blur,
518        EffectKind::MotionBlur,
519        EffectKind::Pixelate,
520        EffectKind::JpegCompress,
521        EffectKind::Vhs,
522        EffectKind::ChromaKey,
523        EffectKind::ColorReplace,
524        EffectKind::Threshold,
525        EffectKind::EdgeGlow,
526        EffectKind::Tint,
527        EffectKind::Color,
528        EffectKind::Curves,
529        EffectKind::Levels,
530        EffectKind::HueShift,
531        EffectKind::Grayscale,
532        EffectKind::Invert,
533        EffectKind::Vignette,
534        EffectKind::Sharpen,
535        EffectKind::Flip,
536        EffectKind::Crop,
537        EffectKind::Plane3d,
538        EffectKind::Wobble,
539        EffectKind::BlobTrack,
540        EffectKind::RecDot,
541        EffectKind::Shader,
542    ];
543    /// Catalogue grouping for the effects panel.
544    pub fn category(self) -> &'static str {
545        use EffectKind::*;
546        match self {
547            Color | Curves | Levels | HueShift | Tint | Grayscale | Invert | Threshold => "Adjustments",
548            Blur | MotionBlur | Sharpen | Pixelate | JpegCompress | Vhs | EdgeGlow | Vignette | RecDot => "Stylize",
549            ChromaKey | ColorReplace | Crop | BlobTrack => "Keying & Matte",
550            Flip | Plane3d | Wobble => "Transform",
551            Shader => "Custom",
552        }
553    }
554    /// Effects that make sense on an audio clip, for the catalogue's audio-aware filtering — an audio
555    /// clip has no pixels, so every current kind (all pixel/GLSL effects) is false here. Exhaustive on
556    /// purpose (see `engine::effects::apply`'s tail) so a future audio kind is a compile-time reminder
557    /// to flip it on.
558    // ponytail: no audio EffectKind exists yet; add one and flip its arm here when it does.
559    pub fn applies_to_audio(self) -> bool {
560        use EffectKind::*;
561        match self {
562            Blur | Pixelate | Tint | Color | Vignette | Sharpen | Invert | Grayscale | Flip | Crop | Wobble
563            | ChromaKey | Curves | Levels | HueShift | JpegCompress | MotionBlur | Plane3d | EdgeGlow | Threshold
564            | BlobTrack | Vhs | RecDot | ColorReplace | Shader => false,
565        }
566    }
567    pub fn name(self) -> &'static str {
568        match self {
569            EffectKind::Blur => "Blur",
570            EffectKind::Pixelate => "Pixelate",
571            EffectKind::Tint => "Color Tint",
572            EffectKind::Color => "Color Correction",
573            EffectKind::Vignette => "Vignette",
574            EffectKind::Sharpen => "Sharpen",
575            EffectKind::Invert => "Invert",
576            EffectKind::Grayscale => "Black & White",
577            EffectKind::Flip => "Flip",
578            EffectKind::Crop => "Crop",
579            EffectKind::Wobble => "Camera Shake",
580            EffectKind::ChromaKey => "Chroma Key",
581            EffectKind::ColorReplace => "Color Replace",
582            EffectKind::Curves => "Color Curves",
583            EffectKind::Levels => "Levels",
584            EffectKind::HueShift => "Hue / Saturation",
585            EffectKind::JpegCompress => "JPEG Compression",
586            EffectKind::MotionBlur => "Motion Blur",
587            EffectKind::Plane3d => "3D Plane",
588            EffectKind::EdgeGlow => "Edge Glow",
589            EffectKind::Threshold => "Threshold",
590            EffectKind::BlobTrack => "Blob Tracking",
591            EffectKind::Vhs => "VHS",
592            EffectKind::RecDot => "Security Camera REC",
593            EffectKind::Shader => "Custom Shader",
594        }
595    }
596    /// Parameters in index order (matches `Effect.params`). Pixel sizes are project pixels.
597    pub fn params(self) -> &'static [ParamSpec] {
598        match self {
599            EffectKind::Blur => P_BLUR,
600            EffectKind::Pixelate => P_PIXELATE,
601            EffectKind::Tint => P_TINT,
602            EffectKind::Color => P_COLOR,
603            EffectKind::Vignette => P_VIGNETTE,
604            EffectKind::Sharpen => P_SHARPEN,
605            EffectKind::Invert => P_INVERT,
606            EffectKind::Grayscale => P_GRAYSCALE,
607            EffectKind::Flip => P_FLIP,
608            EffectKind::Crop => P_CROP,
609            EffectKind::Wobble => P_WOBBLE,
610            EffectKind::ChromaKey => P_CHROMA,
611            EffectKind::ColorReplace => P_COLOR_REPLACE,
612            EffectKind::Curves => P_CURVES,
613            EffectKind::Levels => P_LEVELS,
614            EffectKind::HueShift => P_HUE,
615            EffectKind::JpegCompress => P_JPEG,
616            EffectKind::MotionBlur => P_MOTIONBLUR,
617            EffectKind::Plane3d => P_PLANE3D,
618            EffectKind::EdgeGlow => P_EDGEGLOW,
619            EffectKind::Threshold => P_THRESHOLD,
620            EffectKind::BlobTrack => P_BLOBTRACK,
621            EffectKind::Vhs => P_VHS,
622            EffectKind::RecDot => P_RECDOT,
623            EffectKind::Shader => P_SHADER,
624        }
625    }
626    /// Parameters shown as a checkbox (stored as 0/1) rather than a number.
627    pub fn is_bool_param(self, i: usize) -> bool {
628        matches!(
629            (self, i),
630            (EffectKind::Flip, 0)
631                | (EffectKind::Flip, 1)
632                | (EffectKind::ChromaKey, 5)
633                | (EffectKind::Vhs, 6)
634                | (EffectKind::RecDot, 3)
635                | (EffectKind::BlobTrack, 4)
636                | (EffectKind::Threshold, 2)
637                | (EffectKind::JpegCompress, 2)
638                | (EffectKind::MotionBlur, 2)
639                | (EffectKind::EdgeGlow, 6)
640        )
641    }
642    /// Effects whose output depends on neighbouring frames (the renderer must supply them).
643    pub fn needs_motion(self) -> bool {
644        self == EffectKind::MotionBlur
645    }
646    /// Effects the compositor applies by moving the layer instead of touching pixels.
647    pub fn is_geometric(self) -> bool {
648        matches!(self, EffectKind::Wobble | EffectKind::Plane3d)
649    }
650}
651
652const P_BLUR: &[ParamSpec] = &[ps("Radius", 8.0, 0.0, 100.0)];
653const P_PIXELATE: &[ParamSpec] = &[ps("Block size", 16.0, 1.0, 200.0)];
654const P_TINT: &[ParamSpec] = &[
655    ps("Red", 255.0, 0.0, 255.0),
656    ps("Green", 128.0, 0.0, 255.0),
657    ps("Blue", 0.0, 0.0, 255.0),
658    ps("Amount", 0.5, 0.0, 1.0),
659];
660const P_COLOR: &[ParamSpec] = &[
661    ps("Brightness", 0.0, -1.0, 1.0),
662    ps("Contrast", 1.0, 0.0, 3.0),
663    ps("Saturation", 1.0, 0.0, 3.0),
664    ps("Hue", 0.0, -180.0, 180.0),
665    ps("Gamma", 1.0, 0.1, 5.0),
666];
667const P_VIGNETTE: &[ParamSpec] =
668    &[ps("Radius", 0.8, 0.0, 1.5), ps("Softness", 0.5, 0.0, 1.0), ps("Strength", 0.6, 0.0, 1.0)];
669const P_SHARPEN: &[ParamSpec] = &[ps("Amount", 0.5, 0.0, 3.0), ps("Radius", 2.0, 0.5, 20.0)];
670const P_INVERT: &[ParamSpec] = &[ps("Amount", 1.0, 0.0, 1.0)];
671const P_GRAYSCALE: &[ParamSpec] = &[ps("Amount", 1.0, 0.0, 1.0)];
672const P_FLIP: &[ParamSpec] = &[ps("Horizontal", 1.0, 0.0, 1.0), ps("Vertical", 0.0, 0.0, 1.0)];
673const P_CROP: &[ParamSpec] = &[
674    ps("Left", 0.0, 0.0, 0.5),
675    ps("Right", 0.0, 0.0, 0.5),
676    ps("Top", 0.0, 0.0, 0.5),
677    ps("Bottom", 0.0, 0.0, 0.5),
678    ps("Feather", 0.0, 0.0, 0.5),
679];
680/// Swap one colour for another: everything within `Tolerance` of the source colour is blended to the
681/// target, `Softness` widening the falloff past the tolerance.
682const P_COLOR_REPLACE: &[ParamSpec] = &[
683    ps("From R", 255.0, 0.0, 255.0),
684    ps("From G", 0.0, 0.0, 255.0),
685    ps("From B", 0.0, 0.0, 255.0),
686    ps("To R", 0.0, 0.0, 255.0),
687    ps("To G", 128.0, 0.0, 255.0),
688    ps("To B", 255.0, 0.0, 255.0),
689    ps("Tolerance", 0.2, 0.0, 1.0),
690    ps("Softness", 0.1, 0.0, 1.0),
691];
692const P_CHROMA: &[ParamSpec] = &[
693    ps("Key R", 0.0, 0.0, 255.0),
694    ps("Key G", 255.0, 0.0, 255.0),
695    ps("Key B", 0.0, 0.0, 255.0),
696    ps("Similarity", 0.4, 0.0, 1.0),
697    ps("Smoothness", 0.1, 0.0, 1.0),
698    ps("Show mask", 0.0, 0.0, 1.0),
699    ps("Spill removal", 0.5, 0.0, 1.0),
700    ps("Edge shrink", 0.0, -5.0, 5.0),
701];
702/// Five control points per channel (input -> output, 0..1) drive a monotone spline; the UI draws a
703/// classic curve editor and writes these values.
704const P_CURVES: &[ParamSpec] = &[
705    ps("Master 1/4", 0.25, 0.0, 1.0),
706    ps("Master 2/4", 0.5, 0.0, 1.0),
707    ps("Master 3/4", 0.75, 0.0, 1.0),
708    ps("Red 1/4", 0.25, 0.0, 1.0),
709    ps("Red 2/4", 0.5, 0.0, 1.0),
710    ps("Red 3/4", 0.75, 0.0, 1.0),
711    ps("Green 1/4", 0.25, 0.0, 1.0),
712    ps("Green 2/4", 0.5, 0.0, 1.0),
713    ps("Green 3/4", 0.75, 0.0, 1.0),
714    ps("Blue 1/4", 0.25, 0.0, 1.0),
715    ps("Blue 2/4", 0.5, 0.0, 1.0),
716    ps("Blue 3/4", 0.75, 0.0, 1.0),
717];
718const P_LEVELS: &[ParamSpec] = &[
719    ps("In black", 0.0, 0.0, 1.0),
720    ps("In white", 1.0, 0.0, 1.0),
721    ps("Gamma", 1.0, 0.1, 5.0),
722    ps("Out black", 0.0, 0.0, 1.0),
723    ps("Out white", 1.0, 0.0, 1.0),
724];
725const P_HUE: &[ParamSpec] =
726    &[ps("Hue", 0.0, -180.0, 180.0), ps("Saturation", 1.0, 0.0, 3.0), ps("Lightness", 0.0, -1.0, 1.0)];
727const P_JPEG: &[ParamSpec] =
728    &[ps("Quality", 20.0, 1.0, 100.0), ps("Block size", 8.0, 2.0, 32.0), ps("Chroma subsample", 1.0, 0.0, 1.0)];
729const P_MOTIONBLUR: &[ParamSpec] =
730    &[ps("Shutter angle", 180.0, 0.0, 360.0), ps("Samples", 8.0, 2.0, 32.0), ps("Adaptive", 1.0, 0.0, 1.0)];
731/// A 3D plane: the layer is mapped through a perspective transform (also usable as a node).
732const P_PLANE3D: &[ParamSpec] = &[
733    ps("Yaw", 0.0, -89.0, 89.0),
734    ps("Pitch", 0.0, -89.0, 89.0),
735    ps("Roll", 0.0, -180.0, 180.0),
736    ps("Distance", 2.0, 0.5, 20.0),
737    ps("Field of view", 45.0, 5.0, 120.0),
738    ps("Offset Z", 0.0, -5.0, 5.0),
739];
740const P_EDGEGLOW: &[ParamSpec] = &[
741    ps("Threshold", 0.2, 0.0, 1.0),
742    ps("Width", 2.0, 0.5, 20.0),
743    ps("Glow", 1.0, 0.0, 4.0),
744    ps("R", 120.0, 0.0, 255.0),
745    ps("G", 200.0, 0.0, 255.0),
746    ps("B", 255.0, 0.0, 255.0),
747    ps("Keep source", 1.0, 0.0, 1.0),
748];
749const P_THRESHOLD: &[ParamSpec] =
750    &[ps("Level", 0.5, 0.0, 1.0), ps("Softness", 0.05, 0.0, 0.5), ps("Per channel", 0.0, 0.0, 1.0)];
751/// Tracks the largest blob matching a colour; its centre drives `BlobTrack`-linked properties.
752const P_BLOBTRACK: &[ParamSpec] = &[
753    ps("Target R", 255.0, 0.0, 255.0),
754    ps("Target G", 0.0, 0.0, 255.0),
755    ps("Target B", 0.0, 0.0, 255.0),
756    ps("Tolerance", 0.25, 0.0, 1.0),
757    ps("Show overlay", 1.0, 0.0, 1.0),
758    ps("Smoothing", 0.5, 0.0, 1.0),
759];
760const P_VHS: &[ParamSpec] = &[
761    ps("Noise", 0.3, 0.0, 1.0),
762    ps("Chroma bleed", 0.5, 0.0, 1.0),
763    ps("Scanlines", 0.4, 0.0, 1.0),
764    ps("Tracking jitter", 0.2, 0.0, 1.0),
765    ps("Head switching", 0.3, 0.0, 1.0),
766    ps("Sharpen ringing", 0.4, 0.0, 1.0),
767    ps("Colour bleed only", 0.0, 0.0, 1.0),
768    ps("Tape wear", 0.2, 0.0, 1.0),
769];
770const P_RECDOT: &[ParamSpec] = &[
771    ps("Size", 18.0, 2.0, 200.0),
772    ps("Blink Hz", 0.5, 0.0, 5.0),
773    ps("Corner", 0.0, 0.0, 3.0),
774    ps("Timecode", 1.0, 0.0, 1.0),
775    ps("Margin", 40.0, 0.0, 500.0),
776];
777const P_SHADER: &[ParamSpec] = &[
778    ps("u1", 0.0, -10.0, 10.0),
779    ps("u2", 0.0, -10.0, 10.0),
780    ps("u3", 0.0, -10.0, 10.0),
781    ps("u4", 0.0, -10.0, 10.0),
782    ps("u5", 0.0, -10.0, 10.0),
783    ps("u6", 0.0, -10.0, 10.0),
784    ps("u7", 0.0, -10.0, 10.0),
785    ps("u8", 0.0, -10.0, 10.0),
786];
787const P_WOBBLE: &[ParamSpec] = &[
788    ps("Amplitude X", 20.0, 0.0, 500.0),
789    ps("Amplitude Y", 20.0, 0.0, 500.0),
790    ps("Roll", 2.0, 0.0, 45.0),
791    ps("Yaw", 3.0, 0.0, 45.0),
792    ps("Pitch", 3.0, 0.0, 45.0),
793    ps("Frequency", 2.0, 0.05, 30.0),
794    ps("Seed", 1.0, 0.0, 1000.0),
795    // 0 = Sine (one clean wave), 1 = Layered (three sines, the old look), 2 = Cubic (smoothed random
796    // steps), 3 = Triangle, 4 = Random (stepped hold). See `WOBBLE_MOTIONS` / `engine::effects::wobble`.
797    ps("Motion", 1.0, 0.0, 4.0),
798    // 0 = every wiggle as-is, 1 = heavily smoothed (a slow drift). Divides the effective frequency.
799    ps("Smoothness", 0.0, 0.0, 1.0),
800];
801
802/// Names for the Camera Shake "Motion" knob, in value order (the inspector shows these).
803pub const WOBBLE_MOTIONS: [&str; 5] = ["Sine", "Layered", "Cubic", "Triangle", "Random"];
804
805/// Starting point for `EffectKind::Shader`: `tex` = the layer, `uv` = 0..1, `u_time` = clip-local
806/// seconds, `u1..u8` = the eight knobs, `u_res` = layer size in px. Output goes to `out_color`
807/// (straight alpha, same convention as every other effect).
808pub const DEFAULT_SHADER: &str = r#"// custom effect — edit freely
809vec4 effect(vec4 src, vec2 uv) {
810    // u1 = amount, u2 = speed
811    float wave = sin(uv.y * 40.0 + u_time * max(u2, 0.0) * 6.28318) * u1 * 0.02;
812    return texture(tex, vec2(uv.x + wave, uv.y));
813}
814"#;
815
816/// A mask shape. Points are in project pixels relative to the layer centre; a mask limits where an
817/// effect applies (or where the whole clip is visible when it sits on `Clip.mask`).
818#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize, Hash, Default)]
819pub enum MaskShape {
820    #[default]
821    Rect,
822    Ellipse,
823    /// Straight-edged polygon through `points`.
824    Polygon,
825    /// Free-hand / bezier path through `points` (smoothed).
826    Path,
827}
828
829impl MaskShape {
830    pub const ALL: [MaskShape; 4] = [MaskShape::Rect, MaskShape::Ellipse, MaskShape::Polygon, MaskShape::Path];
831    pub fn name(self) -> &'static str {
832        match self {
833            MaskShape::Rect => "Rectangle",
834            MaskShape::Ellipse => "Ellipse",
835            MaskShape::Polygon => "Polygon",
836            MaskShape::Path => "Path",
837        }
838    }
839}
840
841#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
842#[serde(default)]
843pub struct Mask {
844    pub shape: MaskShape,
845    /// Rect/Ellipse: centre + half-size. Polygon/Path: ignored (see `points`).
846    pub cx: Animated,
847    pub cy: Animated,
848    pub rx: Animated,
849    pub ry: Animated,
850    /// Polygon/Path vertices (project px, relative to the layer centre).
851    pub points: Vec<(f32, f32)>,
852    pub rotation: Animated,
853    /// Soft edge in project px.
854    pub feather: Animated,
855    /// Grow (+) / shrink (-) the shape in project px.
856    pub expand: Animated,
857    /// Mask strength 0..1.
858    pub opacity: Animated,
859    pub invert: bool,
860    pub enabled: bool,
861}
862
863impl Default for Mask {
864    fn default() -> Self {
865        Self {
866            shape: MaskShape::Rect,
867            cx: Animated::new(0.0),
868            cy: Animated::new(0.0),
869            rx: Animated::new(200.0),
870            ry: Animated::new(200.0),
871            points: Vec::new(),
872            rotation: Animated::new(0.0),
873            feather: Animated::new(0.0),
874            expand: Animated::new(0.0),
875            opacity: Animated::new(1.0),
876            invert: false,
877            enabled: true,
878        }
879    }
880}
881
882impl Mask {
883    pub fn new(shape: MaskShape) -> Self {
884        Self { shape, ..Default::default() }
885    }
886    pub fn animated_mut(&mut self) -> Vec<&mut Animated> {
887        vec![
888            &mut self.cx,
889            &mut self.cy,
890            &mut self.rx,
891            &mut self.ry,
892            &mut self.rotation,
893            &mut self.feather,
894            &mut self.expand,
895            &mut self.opacity,
896        ]
897    }
898    pub fn animated(&self) -> Vec<&Animated> {
899        vec![&self.cx, &self.cy, &self.rx, &self.ry, &self.rotation, &self.feather, &self.expand, &self.opacity]
900    }
901}
902
903/// An effect instance on a clip; `params[i]` follows `kind.params()[i]` (each keyframeable).
904#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
905pub struct Effect {
906    pub kind: EffectKind,
907    #[serde(default = "tru")]
908    pub enabled: bool,
909    #[serde(default)]
910    pub params: Vec<Animated>,
911    /// Limits the effect to (or outside) a shape.
912    #[serde(default)]
913    pub mask: Option<Mask>,
914    /// `EffectKind::Shader` only: the GLSL fragment shader source.
915    #[serde(default, skip_serializing_if = "String::is_empty")]
916    pub shader: String,
917    /// Clip-local second the effect switches on (CapCut-style window inside the clip).
918    #[serde(default)]
919    pub start: f64,
920    /// How long it stays on; `<= 0` means "to the end of the clip", which is what every effect written
921    /// before this field existed deserialises to.
922    #[serde(default)]
923    pub len: f64,
924}
925
926impl Effect {
927    pub fn new(kind: EffectKind) -> Self {
928        Self {
929            kind,
930            enabled: true,
931            params: kind.params().iter().map(|p| Animated::new(p.default)).collect(),
932            mask: None,
933            shader: if kind == EffectKind::Shader { DEFAULT_SHADER.to_string() } else { String::new() },
934            start: 0.0,
935            len: 0.0,
936        }
937    }
938    /// Is the effect on at clip-local time `t`? Enabled + inside its window (the default window is the
939    /// whole clip, so this is just `enabled` for every project that never touched the timing).
940    pub fn on_at(&self, t: f64) -> bool {
941        self.enabled && t >= self.start - EPS && (self.len <= 0.0 || t <= self.start + self.len + EPS)
942    }
943    /// Parameter i at clip-local time t (spec default when missing, e.g. older files).
944    pub fn at(&self, i: usize, t: f64) -> f64 {
945        self.params
946            .get(i)
947            .map(|a| a.at(t))
948            .unwrap_or_else(|| self.kind.params().get(i).map(|p| p.default).unwrap_or(0.0))
949    }
950    pub fn specs(&self) -> &'static [ParamSpec] {
951        self.kind.params()
952    }
953}
954
955// ---------- node graph ----------
956
957/// Arithmetic for a `Math` node.
958#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize, Default, Hash)]
959pub enum MathOp {
960    #[default]
961    Add,
962    Sub,
963    Mul,
964    Div,
965    Min,
966    Max,
967    Pow,
968    Mod,
969}
970
971impl MathOp {
972    pub const ALL: [MathOp; 8] =
973        [MathOp::Add, MathOp::Sub, MathOp::Mul, MathOp::Div, MathOp::Min, MathOp::Max, MathOp::Pow, MathOp::Mod];
974    pub fn name(self) -> &'static str {
975        match self {
976            MathOp::Add => "Add",
977            MathOp::Sub => "Subtract",
978            MathOp::Mul => "Multiply",
979            MathOp::Div => "Divide",
980            MathOp::Min => "Min",
981            MathOp::Max => "Max",
982            MathOp::Pow => "Power",
983            MathOp::Mod => "Modulo",
984        }
985    }
986}
987
988/// Comparison for a `Compare` node.
989#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize, Default, Hash)]
990pub enum CmpOp {
991    #[default]
992    Lt,
993    Le,
994    Gt,
995    Ge,
996    Eq,
997    Ne,
998}
999
1000impl CmpOp {
1001    pub const ALL: [CmpOp; 6] = [CmpOp::Lt, CmpOp::Le, CmpOp::Gt, CmpOp::Ge, CmpOp::Eq, CmpOp::Ne];
1002    pub fn name(self) -> &'static str {
1003        match self {
1004            CmpOp::Lt => "Less",
1005            CmpOp::Le => "Less or equal",
1006            CmpOp::Gt => "Greater",
1007            CmpOp::Ge => "Greater or equal",
1008            CmpOp::Eq => "Equal",
1009            CmpOp::Ne => "Not equal",
1010        }
1011    }
1012}
1013
1014/// Boolean logic for a `Logic` node.
1015#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize, Default, Hash)]
1016pub enum LogicOp {
1017    #[default]
1018    And,
1019    Or,
1020    Not,
1021    Xor,
1022}
1023
1024impl LogicOp {
1025    pub const ALL: [LogicOp; 4] = [LogicOp::And, LogicOp::Or, LogicOp::Not, LogicOp::Xor];
1026    pub fn name(self) -> &'static str {
1027        match self {
1028            LogicOp::And => "And",
1029            LogicOp::Or => "Or",
1030            LogicOp::Not => "Not",
1031            LogicOp::Xor => "Xor",
1032        }
1033    }
1034}
1035
1036/// What a node does. `Input` is the clip's own decoded layer; `Output` is what the compositor draws.
1037///
1038/// Two kinds of wire run through a graph: pictures (one texture per node, evaluated on the GPU) and
1039/// **values** (one number per node, `NodeGraph::eval_values`). Value ports on a picture node — an
1040/// effect's parameter ports, a blend's amount — are what let the logic nodes drive the image.
1041#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
1042pub enum NodeKind {
1043    /// The clip's decoded layer (or, in an adjustment clip, everything below it).
1044    Input,
1045    /// A solid colour (RGBA) filling the canvas.
1046    Color([u8; 4]),
1047    /// Another clip's layer at the same time (compositing across tracks).
1048    Clip(Id),
1049    /// A project asset sampled as a texture — footage that need not be on the timeline at all.
1050    Asset(Id),
1051    Effect(Effect),
1052    /// Combines two inputs (`a` under `b`) with a blend mode and opacity.
1053    Blend {
1054        mode: BlendMode,
1055        opacity: Animated,
1056    },
1057    /// Same two inputs, mixed in by `factor` (0..1) — "how much of `b`", not its opacity.
1058    Combine {
1059        mode: BlendMode,
1060        factor: Animated,
1061    },
1062    /// `b` composited straight over `a` (alpha over, no knobs).
1063    Merge,
1064    /// Uses `b`'s luminance (or alpha) as a matte for `a`.
1065    Matte {
1066        invert: bool,
1067        use_alpha: bool,
1068    },
1069    /// A standalone mask (matte generator).
1070    Mask(Mask),
1071    /// A string rasterised into the graph. `{frame}`, `{time}` and `{n}` expand at evaluation time,
1072    /// which is all a frame counter or a running clock needs (see `expand_text`). Called `Text` in
1073    /// projects written before it was renamed — the alias keeps those loading.
1074    #[serde(alias = "Text")]
1075    String(TextStyle),
1076    /// A keyframeable constant: the plain number input, and a grey card at its value as a picture.
1077    Number(Animated),
1078    /// A constant flag — 1.0 or 0.0 downstream.
1079    Bool(bool),
1080    /// Deterministic noise in `min..max`, hashed from (`seed`, frame): the same project always
1081    /// renders the same numbers, and every frame gets a different one. A constant is a `Number`.
1082    Random {
1083        seed: u32,
1084        min: f64,
1085        max: f64,
1086    },
1087    /// Arithmetic on two value inputs.
1088    Math(MathOp),
1089    /// Compares two value inputs: 1.0 when it holds, 0.0 when it does not.
1090    Compare(CmpOp),
1091    /// Boolean logic on two value inputs (`Not` reads only `a`); anything >= 0.5 counts as true.
1092    Logic(LogicOp),
1093    /// `cond ? a : b` — the switch. Works on values *and* on pictures.
1094    Select,
1095    Output,
1096}
1097
1098impl NodeKind {
1099    /// How many inputs the node consumes. An effect takes its picture on port 0 and one optional
1100    /// value per parameter after it, so any number in the graph can drive any knob.
1101    pub fn inputs(&self) -> usize {
1102        match self {
1103            NodeKind::Input
1104            | NodeKind::Color(_)
1105            | NodeKind::Clip(_)
1106            | NodeKind::Asset(_)
1107            | NodeKind::Mask(_)
1108            | NodeKind::String(_)
1109            | NodeKind::Number(_)
1110            | NodeKind::Bool(_)
1111            | NodeKind::Random { .. } => 0,
1112            NodeKind::Output => 1,
1113            NodeKind::Effect(e) => 1 + e.specs().len(),
1114            NodeKind::Merge | NodeKind::Matte { .. } | NodeKind::Math(_) | NodeKind::Compare(_) => 2,
1115            NodeKind::Logic(op) => {
1116                if *op == LogicOp::Not {
1117                    1
1118                } else {
1119                    2
1120                }
1121            }
1122            NodeKind::Blend { .. } | NodeKind::Combine { .. } | NodeKind::Select => 3,
1123        }
1124    }
1125    /// What port `i` takes, for the node box and the tooltips. Empty when it has no name.
1126    pub fn port_label(&self, i: usize) -> &str {
1127        match self {
1128            NodeKind::Effect(e) => {
1129                if i == 0 {
1130                    "in"
1131                } else {
1132                    e.specs().get(i - 1).map(|s| s.name).unwrap_or("")
1133                }
1134            }
1135            NodeKind::Blend { .. } => ["a", "b", "opacity"][i.min(2)],
1136            NodeKind::Combine { .. } => ["a", "b", "factor"][i.min(2)],
1137            NodeKind::Merge => ["a", "b"][i.min(1)],
1138            NodeKind::Matte { .. } => ["a", "matte"][i.min(1)],
1139            NodeKind::Math(_) | NodeKind::Compare(_) | NodeKind::Logic(_) => ["a", "b"][i.min(1)],
1140            NodeKind::Select => ["cond", "a", "b"][i.min(2)],
1141            _ => "",
1142        }
1143    }
1144    /// Nodes whose output is a number rather than a picture (they still paint as a grey card, so a
1145    /// value can be used as a matte without a conversion node).
1146    pub fn is_value(&self) -> bool {
1147        matches!(
1148            self,
1149            NodeKind::Number(_)
1150                | NodeKind::Bool(_)
1151                | NodeKind::Random { .. }
1152                | NodeKind::Math(_)
1153                | NodeKind::Compare(_)
1154                | NodeKind::Logic(_)
1155        )
1156    }
1157    pub fn title(&self) -> String {
1158        match self {
1159            NodeKind::Input => "Input".into(),
1160            NodeKind::Color(_) => "Color".into(),
1161            NodeKind::Clip(_) => "Clip".into(),
1162            NodeKind::Asset(_) => "Asset".into(),
1163            NodeKind::Effect(e) => e.kind.name().into(),
1164            NodeKind::Blend { .. } => "Blend".into(),
1165            NodeKind::Combine { .. } => "Combine".into(),
1166            NodeKind::Merge => "Merge".into(),
1167            NodeKind::Matte { .. } => "Matte".into(),
1168            NodeKind::Mask(m) => format!("Mask ({})", m.shape.name()),
1169            NodeKind::String(_) => "String".into(),
1170            NodeKind::Number(_) => "Number".into(),
1171            NodeKind::Bool(_) => "Boolean".into(),
1172            NodeKind::Random { .. } => "Random".into(),
1173            NodeKind::Math(op) => op.name().into(),
1174            NodeKind::Compare(op) => op.name().into(),
1175            NodeKind::Logic(op) => op.name().into(),
1176            NodeKind::Select => "Select".into(),
1177            NodeKind::Output => "Output".into(),
1178        }
1179    }
1180}
1181
1182/// splitmix64 folded to 0..1 — the `Random` node's whole implementation.
1183fn hash01(x: u64) -> f64 {
1184    let mut z = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
1185    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
1186    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
1187    z ^= z >> 31;
1188    (z >> 11) as f64 / (1u64 << 53) as f64
1189}
1190
1191/// Expand a text node's format at time `t` (timeline seconds, `lt` clip-local): `{frame}` is the
1192/// timeline frame number, `{time}` the timeline clock, `{n}` the frames since the clip started — so a
1193/// counter is `{n}` and a clock is `{time}`. Anything else is left alone.
1194pub fn expand_text(fmt: &str, t: f64, lt: f64, fps: f64) -> String {
1195    let fps = if fps.is_finite() && fps > 0.0 { fps } else { 30.0 };
1196    let count = |s: f64| (s.max(0.0) * fps).floor() as i64;
1197    let mut out = fmt.to_string();
1198    if out.contains("{frame}") {
1199        out = out.replace("{frame}", &count(t).to_string());
1200    }
1201    if out.contains("{n}") {
1202        out = out.replace("{n}", &count(lt).to_string());
1203    }
1204    if out.contains("{time}") {
1205        let s = t.max(0.0);
1206        let (h, m, sec, cs) = (s as i64 / 3600, (s as i64 / 60) % 60, s as i64 % 60, (s.fract() * 100.0) as i64);
1207        let clock = if h > 0 { format!("{h}:{m:02}:{sec:02}.{cs:02}") } else { format!("{m:02}:{sec:02}.{cs:02}") };
1208        out = out.replace("{time}", &clock);
1209    }
1210    out
1211}
1212
1213#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
1214pub struct Node {
1215    pub id: Id,
1216    pub kind: NodeKind,
1217    /// Editor position (graph units).
1218    pub x: f32,
1219    pub y: f32,
1220    #[serde(default = "tru")]
1221    pub enabled: bool,
1222}
1223
1224/// `to`'s input `port` is fed by `from`'s output.
1225#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
1226pub struct Edge {
1227    pub from: Id,
1228    pub to: Id,
1229    pub port: usize,
1230}
1231
1232/// A clip's effect chain as a DAG. When present it replaces `Clip.effects` (kept as the simple linear
1233/// stack for clips that never opened the node editor). Always contains exactly one `Output`.
1234#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
1235#[serde(default)]
1236pub struct NodeGraph {
1237    pub nodes: Vec<Node>,
1238    pub edges: Vec<Edge>,
1239}
1240
1241impl NodeGraph {
1242    /// Input -> Output, ready to have effects dropped in between.
1243    pub fn new(next_id: &mut impl FnMut() -> Id) -> Self {
1244        let (i, o) = (next_id(), next_id());
1245        Self {
1246            nodes: vec![
1247                Node { id: i, kind: NodeKind::Input, x: 0.0, y: 0.0, enabled: true },
1248                Node { id: o, kind: NodeKind::Output, x: 320.0, y: 0.0, enabled: true },
1249            ],
1250            edges: vec![Edge { from: i, to: o, port: 0 }],
1251        }
1252    }
1253    /// A graph equivalent to a linear effect stack (used when a clip's stack is converted).
1254    pub fn from_effects(effects: &[Effect], next_id: &mut impl FnMut() -> Id) -> Self {
1255        let mut g = Self::new(next_id);
1256        let out = g.output().unwrap();
1257        let mut prev = g.nodes[0].id;
1258        g.edges.clear();
1259        for (i, e) in effects.iter().enumerate() {
1260            let id = next_id();
1261            g.nodes.push(Node {
1262                id,
1263                kind: NodeKind::Effect(e.clone()),
1264                x: 160.0 * (i + 1) as f32,
1265                y: 0.0,
1266                enabled: e.enabled,
1267            });
1268            g.edges.push(Edge { from: prev, to: id, port: 0 });
1269            prev = id;
1270        }
1271        if let Some(o) = g.nodes.iter_mut().find(|n| n.id == out) {
1272            o.x = 160.0 * (effects.len() + 1) as f32;
1273        }
1274        g.edges.push(Edge { from: prev, to: out, port: 0 });
1275        g
1276    }
1277    /// The inverse of `from_effects`: the port-0 chain from Output back to Input as a linear stack.
1278    /// Err (with a reason for the toast) when the graph is not that shape — anything the output does
1279    /// not read is not part of the picture and is dropped without complaint.
1280    pub fn to_effects(&self) -> Result<Vec<Effect>, String> {
1281        let out = self.output().ok_or("it has no Output node")?;
1282        let mut chain = Vec::new();
1283        let mut spine = vec![out];
1284        let mut cur = self.input_of(out, 0);
1285        while let Some(id) = cur {
1286            let n = self.node(id).ok_or("a wire points at a missing node")?;
1287            spine.push(id);
1288            match &n.kind {
1289                NodeKind::Input => break,
1290                NodeKind::Effect(e) => {
1291                    let mut e = e.clone();
1292                    e.enabled &= n.enabled;
1293                    chain.push(e);
1294                    cur = self.input_of(id, 0);
1295                }
1296                k => return Err(format!("a {} node has no effect-stack equivalent", k.title())),
1297            }
1298        }
1299        if cur.is_none() {
1300            return Err("the chain does not start at the Input node".into());
1301        }
1302        if let Some(extra) = self.eval_order().into_iter().find(|id| !spine.contains(id)) {
1303            let name = self.node(extra).map(|n| n.kind.title()).unwrap_or_default();
1304            return Err(format!("the {name} node feeds a branch a flat effect list can't hold"));
1305        }
1306        chain.reverse();
1307        Ok(chain)
1308    }
1309    pub fn node(&self, id: Id) -> Option<&Node> {
1310        self.nodes.iter().find(|n| n.id == id)
1311    }
1312    pub fn node_mut(&mut self, id: Id) -> Option<&mut Node> {
1313        self.nodes.iter_mut().find(|n| n.id == id)
1314    }
1315    pub fn output(&self) -> Option<Id> {
1316        self.nodes.iter().find(|n| n.kind == NodeKind::Output).map(|n| n.id)
1317    }
1318    /// The node feeding `to`'s input `port`.
1319    pub fn input_of(&self, to: Id, port: usize) -> Option<Id> {
1320        self.edges.iter().find(|e| e.to == to && e.port == port).map(|e| e.from)
1321    }
1322    /// Connect (replacing whatever fed that port). Refused when it would create a cycle.
1323    pub fn connect(&mut self, from: Id, to: Id, port: usize) -> bool {
1324        if from == to || self.node(from).is_none() || self.node(to).is_none() {
1325            return false;
1326        }
1327        let prev: Vec<Edge> = self.edges.iter().filter(|e| e.to == to && e.port == port).copied().collect();
1328        self.edges.retain(|e| !(e.to == to && e.port == port));
1329        self.edges.push(Edge { from, to, port });
1330        if self.has_cycle() {
1331            self.edges.pop();
1332            self.edges.extend(prev);
1333            return false;
1334        }
1335        true
1336    }
1337    pub fn disconnect(&mut self, to: Id, port: usize) {
1338        self.edges.retain(|e| !(e.to == to && e.port == port));
1339    }
1340    /// Remove a node (the Output can never be removed) and re-link its first input to its consumers.
1341    pub fn remove_node(&mut self, id: Id) {
1342        if self.node(id).map(|n| n.kind == NodeKind::Output).unwrap_or(true) {
1343            return;
1344        }
1345        let up = self.input_of(id, 0);
1346        let down: Vec<(Id, usize)> = self.edges.iter().filter(|e| e.from == id).map(|e| (e.to, e.port)).collect();
1347        self.nodes.retain(|n| n.id != id);
1348        self.edges.retain(|e| e.from != id && e.to != id);
1349        if let Some(up) = up {
1350            for (to, port) in down {
1351                self.connect(up, to, port);
1352            }
1353        }
1354    }
1355    /// Depth-first cycle check (the editor refuses connections that would loop).
1356    pub fn has_cycle(&self) -> bool {
1357        fn visit(g: &NodeGraph, id: Id, state: &mut std::collections::HashMap<Id, u8>) -> bool {
1358            match state.get(&id) {
1359                Some(1) => return true,
1360                Some(2) => return false,
1361                _ => {}
1362            }
1363            state.insert(id, 1);
1364            let ins: Vec<Id> = g.edges.iter().filter(|e| e.to == id).map(|e| e.from).collect();
1365            for i in ins {
1366                if visit(g, i, state) {
1367                    return true;
1368                }
1369            }
1370            state.insert(id, 2);
1371            false
1372        }
1373        let mut state = std::collections::HashMap::new();
1374        self.nodes.iter().any(|n| visit(self, n.id, &mut state))
1375    }
1376    /// Nodes reachable from the output, inputs before consumers.
1377    pub fn eval_order(&self) -> Vec<Id> {
1378        let Some(out) = self.output() else { return Vec::new() };
1379        let mut order = Vec::new();
1380        let mut seen = std::collections::HashSet::new();
1381        fn walk(g: &NodeGraph, id: Id, seen: &mut std::collections::HashSet<Id>, order: &mut Vec<Id>) {
1382            if !seen.insert(id) {
1383                return;
1384            }
1385            let mut ins: Vec<(usize, Id)> = g.edges.iter().filter(|e| e.to == id).map(|e| (e.port, e.from)).collect();
1386            ins.sort();
1387            for (_, from) in ins {
1388                walk(g, from, seen, order);
1389            }
1390            order.push(id);
1391        }
1392        walk(self, out, &mut seen, &mut order);
1393        order
1394    }
1395    /// The scalar half of the graph at clip-local time `lt`: one number per node, in evaluation
1396    /// order, so a `Math`/`Compare`/`Select` chain resolves in a single pass. Picture-only nodes
1397    /// evaluate to 0. GL-free on purpose — the renderer calls it once per frame and it is what the
1398    /// tests exercise.
1399    pub fn eval_values(&self, lt: f64, fps: f64) -> std::collections::HashMap<Id, f64> {
1400        let fps = if fps.is_finite() && fps > 0.0 { fps } else { 30.0 };
1401        let frame = (lt.max(0.0) * fps).floor() as u64;
1402        let mut vals: std::collections::HashMap<Id, f64> = std::collections::HashMap::new();
1403        for id in self.eval_order() {
1404            let Some(n) = self.node(id) else { continue };
1405            let port = |p: usize| self.input_of(id, p).and_then(|f| vals.get(&f).copied()).unwrap_or(0.0);
1406            let (a, b) = (port(0), port(1));
1407            // a disabled node passes its first input through, exactly like the picture side
1408            let v = if !n.enabled {
1409                a
1410            } else {
1411                match &n.kind {
1412                    NodeKind::Number(x) => x.at(lt),
1413                    NodeKind::Bool(x) => *x as u8 as f64,
1414                    NodeKind::Random { seed, min, max } => min + (max - min) * hash01((*seed as u64) << 32 ^ frame),
1415                    NodeKind::Math(op) => match op {
1416                        MathOp::Add => a + b,
1417                        MathOp::Sub => a - b,
1418                        MathOp::Mul => a * b,
1419                        MathOp::Div => {
1420                            if b == 0.0 {
1421                                0.0
1422                            } else {
1423                                a / b
1424                            }
1425                        }
1426                        MathOp::Min => a.min(b),
1427                        MathOp::Max => a.max(b),
1428                        MathOp::Pow => a.powf(b),
1429                        MathOp::Mod => {
1430                            if b == 0.0 {
1431                                0.0
1432                            } else {
1433                                a.rem_euclid(b)
1434                            }
1435                        }
1436                    },
1437                    NodeKind::Compare(op) => {
1438                        let t = match op {
1439                            CmpOp::Lt => a < b,
1440                            CmpOp::Le => a <= b,
1441                            CmpOp::Gt => a > b,
1442                            CmpOp::Ge => a >= b,
1443                            CmpOp::Eq => (a - b).abs() < 1e-9,
1444                            CmpOp::Ne => (a - b).abs() >= 1e-9,
1445                        };
1446                        t as u8 as f64
1447                    }
1448                    NodeKind::Logic(op) => {
1449                        let (x, y) = (a >= 0.5, b >= 0.5);
1450                        let t = match op {
1451                            LogicOp::And => x && y,
1452                            LogicOp::Or => x || y,
1453                            LogicOp::Not => !x,
1454                            LogicOp::Xor => x != y,
1455                        };
1456                        t as u8 as f64
1457                    }
1458                    NodeKind::Select => {
1459                        if a >= 0.5 {
1460                            b
1461                        } else {
1462                            port(2)
1463                        }
1464                    }
1465                    NodeKind::Blend { opacity, .. } => opacity.at(lt),
1466                    NodeKind::Combine { factor, .. } => factor.at(lt),
1467                    _ => 0.0,
1468                }
1469            };
1470            vals.insert(id, if v.is_finite() { v } else { 0.0 });
1471        }
1472        vals
1473    }
1474    /// Every animated property in the graph (effect params, blend opacity, numbers, mask properties).
1475    pub fn animated_mut(&mut self) -> Vec<&mut Animated> {
1476        let mut v = Vec::new();
1477        for n in &mut self.nodes {
1478            match &mut n.kind {
1479                NodeKind::Effect(e) => {
1480                    v.extend(e.params.iter_mut());
1481                    if let Some(m) = &mut e.mask {
1482                        v.extend(m.animated_mut());
1483                    }
1484                }
1485                NodeKind::Blend { opacity, .. } => v.push(opacity),
1486                NodeKind::Combine { factor, .. } => v.push(factor),
1487                NodeKind::Number(a) => v.push(a),
1488                NodeKind::Mask(m) => v.extend(m.animated_mut()),
1489                _ => {}
1490            }
1491        }
1492        v
1493    }
1494    pub fn animated(&self) -> Vec<&Animated> {
1495        let mut v = Vec::new();
1496        for n in &self.nodes {
1497            match &n.kind {
1498                NodeKind::Effect(e) => {
1499                    v.extend(e.params.iter());
1500                    if let Some(m) = &e.mask {
1501                        v.extend(m.animated());
1502                    }
1503                }
1504                NodeKind::Blend { opacity, .. } => v.push(opacity),
1505                NodeKind::Combine { factor, .. } => v.push(factor),
1506                NodeKind::Number(a) => v.push(a),
1507                NodeKind::Mask(m) => v.extend(m.animated()),
1508                _ => {}
1509            }
1510        }
1511        v
1512    }
1513}
1514
1515// ---------- shapes & drawing ----------
1516
1517#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize, Hash, Default)]
1518pub enum ShapeKind {
1519    #[default]
1520    Rect,
1521    Ellipse,
1522    Triangle,
1523    Polygon,
1524    Star,
1525    Line,
1526    Arrow,
1527    /// Free-hand strokes (see `ShapeStyle.strokes`).
1528    Draw,
1529}
1530
1531impl ShapeKind {
1532    pub const ALL: [ShapeKind; 8] = [
1533        ShapeKind::Rect,
1534        ShapeKind::Ellipse,
1535        ShapeKind::Triangle,
1536        ShapeKind::Polygon,
1537        ShapeKind::Star,
1538        ShapeKind::Line,
1539        ShapeKind::Arrow,
1540        ShapeKind::Draw,
1541    ];
1542    pub fn name(self) -> &'static str {
1543        match self {
1544            ShapeKind::Rect => "Rectangle",
1545            ShapeKind::Ellipse => "Ellipse",
1546            ShapeKind::Triangle => "Triangle",
1547            ShapeKind::Polygon => "Polygon",
1548            ShapeKind::Star => "Star",
1549            ShapeKind::Line => "Line",
1550            ShapeKind::Arrow => "Arrow",
1551            ShapeKind::Draw => "Drawing",
1552        }
1553    }
1554}
1555
1556/// One free-hand stroke: points in project px (relative to the layer centre) with the clip-local time
1557/// each point was drawn, so a recorded sketch can play back at any rate.
1558#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
1559pub struct Stroke {
1560    pub color: [u8; 4],
1561    pub width: f32,
1562    /// (x, y, t) - t in clip-local seconds at the recording rate.
1563    pub points: Vec<(f32, f32, f32)>,
1564}
1565
1566#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
1567#[serde(default)]
1568pub struct ShapeStyle {
1569    pub kind: ShapeKind,
1570    pub fill: [u8; 4],
1571    pub stroke: [u8; 4],
1572    pub stroke_width: f32,
1573    /// Rect corner radius / Arrow head size (project px).
1574    pub corner: f32,
1575    /// Polygon / Star sides.
1576    pub sides: u32,
1577    /// Explicit Polygon vertices in project px relative to the shape centre (the Polygon tool places
1578    /// and drags these). Empty = the regular `sides`-gon, so old projects keep their shape.
1579    pub points: Vec<(f32, f32)>,
1580    /// Half-size in project px (Rect/Ellipse/...) or the offset of the far end (Line/Arrow).
1581    pub w: Animated,
1582    pub h: Animated,
1583    /// Free-hand strokes for `ShapeKind::Draw`.
1584    pub strokes: Vec<Stroke>,
1585    /// Playback rate of a recorded drawing (1 = as recorded, 2 = twice as fast, 0 = all at once).
1586    pub draw_rate: f32,
1587    /// Page behind a drawing (alpha 0 = transparent, e.g. a white sketch page).
1588    pub page: [u8; 4],
1589}
1590
1591impl Default for ShapeStyle {
1592    fn default() -> Self {
1593        Self {
1594            kind: ShapeKind::Rect,
1595            fill: [255, 255, 255, 255],
1596            stroke: [0, 0, 0, 0],
1597            stroke_width: 4.0,
1598            corner: 0.0,
1599            sides: 5,
1600            points: Vec::new(),
1601            w: Animated::new(300.0),
1602            h: Animated::new(200.0),
1603            strokes: Vec::new(),
1604            draw_rate: 1.0,
1605            page: [0, 0, 0, 0],
1606        }
1607    }
1608}
1609
1610impl ShapeStyle {
1611    pub fn new(kind: ShapeKind) -> Self {
1612        let mut s = Self { kind, ..Default::default() };
1613        if matches!(kind, ShapeKind::Line | ShapeKind::Arrow | ShapeKind::Draw) {
1614            s.stroke = [255, 255, 255, 255];
1615            s.fill = [0, 0, 0, 0];
1616        }
1617        s
1618    }
1619    /// The explicit vertex list, or None when the regular `sides`-gon applies (fewer than 3 points
1620    /// cannot enclose anything, so a half-placed path still draws as the n-gon).
1621    pub fn poly_points(&self) -> Option<&[(f32, f32)]> {
1622        (self.kind == ShapeKind::Polygon && self.points.len() >= 3).then_some(&self.points[..])
1623    }
1624    /// The outline as a timed path, ready to drive an animation: a drawing's strokes end to end, or an
1625    /// explicit polygon's vertices (one per second, so a closed shape still has a direction).
1626    pub fn path_points(&self) -> Vec<(f32, f32, f32)> {
1627        if self.kind != ShapeKind::Draw {
1628            return self.points.iter().enumerate().map(|(i, &(x, y))| (x, y, i as f32)).collect();
1629        }
1630        let mut out: Vec<(f32, f32, f32)> = Vec::new();
1631        for st in &self.strokes {
1632            // strokes from one take already share a clock; older ones each start at 0, so anything that
1633            // would step back in time is pushed behind what came before
1634            let off = (out.last().map_or(0.0, |&(_, _, t)| t) - st.points.first().map_or(0.0, |p| p.2)).max(0.0);
1635            out.extend(st.points.iter().map(|&(x, y, t)| (x, y, t + off)));
1636        }
1637        out
1638    }
1639    /// Recorded length of a drawing in seconds.
1640    pub fn draw_duration(&self) -> f64 {
1641        self.strokes.iter().flat_map(|s| s.points.iter().map(|p| p.2 as f64)).fold(0.0, f64::max)
1642    }
1643    pub fn cache_key(&self) -> u64 {
1644        let mut h = std::collections::hash_map::DefaultHasher::new();
1645        self.kind.hash(&mut h);
1646        (self.fill, self.stroke, self.page).hash(&mut h);
1647        for f in [self.stroke_width, self.corner, self.draw_rate] {
1648            f.to_bits().hash(&mut h);
1649        }
1650        self.sides.hash(&mut h);
1651        // w/h were missing entirely: the rasteriser's own cache key only adds the LAYER size (built from
1652        // their absolute value, engine::shapes::half_size), so two Lines of the same length pointing
1653        // opposite ways hashed identically and one silently got served the other's cached pixels. Hash
1654        // the animated curve itself, sign included, the same way `points` and `strokes` are below.
1655        for a in [&self.w, &self.h] {
1656            a.value.to_bits().hash(&mut h);
1657            a.keys.len().hash(&mut h);
1658            for k in &a.keys {
1659                (k.t.to_bits(), k.v.to_bits()).hash(&mut h);
1660                // Ease is not Hash (Bezier carries f32 handles), so hash it by hand
1661                match k.ease {
1662                    Ease::Linear => 0u8.hash(&mut h),
1663                    Ease::EaseIn => 1u8.hash(&mut h),
1664                    Ease::EaseOut => 2u8.hash(&mut h),
1665                    Ease::EaseInOut => 3u8.hash(&mut h),
1666                    Ease::Hold => 4u8.hash(&mut h),
1667                    Ease::Bezier { x1, y1, x2, y2 } => {
1668                        5u8.hash(&mut h);
1669                        for f in [x1, y1, x2, y2] {
1670                            f.to_bits().hash(&mut h);
1671                        }
1672                    }
1673                }
1674            }
1675        }
1676        // vertices move under the mouse, so their values (not just the count) key the cache
1677        for p in &self.points {
1678            (p.0.to_bits(), p.1.to_bits()).hash(&mut h);
1679        }
1680        self.strokes.len().hash(&mut h);
1681        for st in &self.strokes {
1682            st.points.len().hash(&mut h);
1683            st.color.hash(&mut h);
1684            st.width.to_bits().hash(&mut h);
1685        }
1686        h.finish()
1687    }
1688}
1689
1690/// A drawing or polygon outline kept on the project so it can be reused: as a motion path for a clip's
1691/// X/Y, as the centre of a mask node, or just as a sketch to look at again.
1692#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
1693#[serde(default)]
1694pub struct PathAsset {
1695    pub id: Id,
1696    pub name: String,
1697    /// (x, y, t): project px relative to the canvas centre, t in seconds from the start of the path.
1698    pub points: Vec<(f32, f32, f32)>,
1699}
1700
1701/// Turn a recorded path into X/Y keyframes spanning `duration` seconds. The recording's own timing is
1702/// kept when it has any (the replay keeps its rhythm) and the points are spread evenly otherwise — a
1703/// polygon has no clock. Key times are always strictly increasing: a still mouse records several points
1704/// at one instant, and two keys at the same time would swallow each other (`Animated::key_index_at`).
1705pub fn path_to_keys(points: &[(f32, f32, f32)], duration: f64) -> (Animated, Animated) {
1706    let (mut x, mut y) = (Animated::new(0.0), Animated::new(0.0));
1707    let (Some(first), Some(last)) = (points.first(), points.last()) else { return (x, y) };
1708    let span = (last.2 - first.2) as f64;
1709    let dur = duration.max(MIN_CLIP);
1710    for (i, p) in points.iter().enumerate() {
1711        let f = match (span > 0.0, points.len() > 1) {
1712            (true, _) => (p.2 - first.2) as f64 / span,
1713            (false, true) => i as f64 / (points.len() - 1) as f64,
1714            (false, false) => 0.0,
1715        };
1716        let t = (f * dur).clamp(0.0, dur);
1717        let t = match x.keys.last() {
1718            Some(k) if t <= k.t + KEY_EPS => k.t + KEY_EPS,
1719            _ => t,
1720        };
1721        x.keys.push(Keyframe { t, v: p.0 as f64, ease: Ease::Linear });
1722        y.keys.push(Keyframe { t, v: p.1 as f64, ease: Ease::Linear });
1723    }
1724    // duplicate timestamps pushed the tail past the end: squeeze the whole path back into `dur` so it
1725    // still finishes exactly where it was drawn
1726    if let Some(k) = x.keys.last().filter(|k| k.t > dur) {
1727        let s = dur / k.t;
1728        for (a, b) in x.keys.iter_mut().zip(y.keys.iter_mut()) {
1729            a.t *= s;
1730            b.t *= s;
1731        }
1732    }
1733    (x, y)
1734}
1735
1736// ---------- markers ----------
1737
1738/// A note on the timeline (or, in `Clip.markers`, on a clip). The AI tools read these too.
1739#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
1740#[serde(default)]
1741pub struct Marker {
1742    pub id: Id,
1743    /// Timeline seconds (project markers) or clip-local seconds (clip markers).
1744    pub t: f64,
1745    /// 0 = a point marker; > 0 = a range.
1746    pub duration: f64,
1747    pub name: String,
1748    pub note: String,
1749    /// Index into `Project.labels` + 1 (0 = none).
1750    pub label: u8,
1751}
1752
1753impl Default for Marker {
1754    fn default() -> Self {
1755        Self { id: 0, t: 0.0, duration: 0.0, name: String::new(), note: String::new(), label: 0 }
1756    }
1757}
1758
1759// ---------- labels ----------
1760
1761/// A user-editable colour label. `Project.labels` starts as `default_labels()`.
1762#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
1763pub struct Label {
1764    pub name: String,
1765    pub color: [u8; 3],
1766}
1767
1768pub fn default_labels() -> Vec<Label> {
1769    LABEL_COLORS.iter().map(|(n, c)| Label { name: (*n).to_string(), color: *c }).collect()
1770}
1771
1772// ---------- audio buses ----------
1773
1774#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize, Hash)]
1775pub enum FilterKind {
1776    /// 5-band parametric EQ (low shelf, three peaks, high shelf).
1777    Eq,
1778    HighPass,
1779    LowPass,
1780    Reverb,
1781    Echo,
1782    Distortion,
1783    Compressor,
1784    NoiseGate,
1785    /// Adds noise (white / pink / a pure tone).
1786    Noise,
1787    Gain,
1788}
1789
1790impl FilterKind {
1791    pub const ALL: [FilterKind; 10] = [
1792        FilterKind::Eq,
1793        FilterKind::HighPass,
1794        FilterKind::LowPass,
1795        FilterKind::Reverb,
1796        FilterKind::Echo,
1797        FilterKind::Distortion,
1798        FilterKind::Compressor,
1799        FilterKind::NoiseGate,
1800        FilterKind::Noise,
1801        FilterKind::Gain,
1802    ];
1803    pub fn name(self) -> &'static str {
1804        match self {
1805            FilterKind::Eq => "EQ (5-band)",
1806            FilterKind::HighPass => "High-pass",
1807            FilterKind::LowPass => "Low-pass",
1808            FilterKind::Reverb => "Reverb",
1809            FilterKind::Echo => "Echo / Delay",
1810            FilterKind::Distortion => "Distortion",
1811            FilterKind::Compressor => "Compressor",
1812            FilterKind::NoiseGate => "Noise gate",
1813            FilterKind::Noise => "Noise",
1814            FilterKind::Gain => "Gain",
1815        }
1816    }
1817    pub fn params(self) -> &'static [ParamSpec] {
1818        match self {
1819            FilterKind::Eq => F_EQ,
1820            FilterKind::HighPass | FilterKind::LowPass => F_PASS,
1821            FilterKind::Reverb => F_REVERB,
1822            FilterKind::Echo => F_ECHO,
1823            FilterKind::Distortion => F_DIST,
1824            FilterKind::Compressor => F_COMP,
1825            FilterKind::NoiseGate => F_GATE,
1826            FilterKind::Noise => F_NOISE,
1827            FilterKind::Gain => F_GAIN,
1828        }
1829    }
1830}
1831
1832/// Five bands, but the first seven slots are frozen in the order the 3-band EQ used so old projects
1833/// keep their meaning; `mixer_fx::EQ_BANDS` groups them back into bands for the DSP and the UI.
1834const F_EQ: &[ParamSpec] = &[
1835    ps("Low gain dB", 0.0, -24.0, 24.0),
1836    ps("Low freq", 120.0, 20.0, 1000.0),
1837    ps("Mid gain dB", 0.0, -24.0, 24.0),
1838    ps("Mid freq", 1000.0, 100.0, 8000.0),
1839    ps("Mid Q", 1.0, 0.1, 10.0),
1840    ps("High gain dB", 0.0, -24.0, 24.0),
1841    ps("High freq", 6000.0, 1000.0, 20000.0),
1842    ps("Low Q", 0.707, 0.1, 10.0),
1843    ps("High Q", 0.707, 0.1, 10.0),
1844    ps("Low-mid gain dB", 0.0, -24.0, 24.0),
1845    ps("Low-mid freq", 400.0, 40.0, 4000.0),
1846    ps("Low-mid Q", 1.0, 0.1, 10.0),
1847    ps("High-mid gain dB", 0.0, -24.0, 24.0),
1848    ps("High-mid freq", 3000.0, 400.0, 16000.0),
1849    ps("High-mid Q", 1.0, 0.1, 10.0),
1850];
1851const F_PASS: &[ParamSpec] = &[ps("Frequency", 200.0, 20.0, 20000.0), ps("Resonance", 0.7, 0.1, 10.0)];
1852const F_REVERB: &[ParamSpec] = &[
1853    ps("Room size", 0.5, 0.0, 1.0),
1854    ps("Damping", 0.5, 0.0, 1.0),
1855    ps("Width", 1.0, 0.0, 1.0),
1856    ps("Mix", 0.25, 0.0, 1.0),
1857    ps("Pre-delay ms", 20.0, 0.0, 200.0),
1858];
1859const F_ECHO: &[ParamSpec] = &[
1860    ps("Delay ms", 350.0, 1.0, 2000.0),
1861    ps("Feedback", 0.35, 0.0, 0.95),
1862    ps("Mix", 0.3, 0.0, 1.0),
1863    ps("Ping-pong", 0.0, 0.0, 1.0),
1864];
1865const F_DIST: &[ParamSpec] = &[ps("Drive", 4.0, 1.0, 50.0), ps("Tone", 0.5, 0.0, 1.0), ps("Mix", 1.0, 0.0, 1.0)];
1866const F_COMP: &[ParamSpec] = &[
1867    ps("Threshold dB", -18.0, -60.0, 0.0),
1868    ps("Ratio", 4.0, 1.0, 20.0),
1869    ps("Attack ms", 10.0, 0.1, 200.0),
1870    ps("Release ms", 120.0, 5.0, 2000.0),
1871    ps("Makeup dB", 0.0, -12.0, 24.0),
1872];
1873const F_GATE: &[ParamSpec] =
1874    &[ps("Threshold dB", -45.0, -80.0, 0.0), ps("Attack ms", 2.0, 0.1, 100.0), ps("Release ms", 120.0, 5.0, 2000.0)];
1875const F_NOISE: &[ParamSpec] =
1876    &[ps("Level dB", -40.0, -80.0, 0.0), ps("Type", 0.0, 0.0, 2.0), ps("Tone Hz", 1000.0, 20.0, 18000.0)];
1877const F_GAIN: &[ParamSpec] = &[ps("Gain dB", 0.0, -60.0, 24.0)];
1878
1879#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
1880pub struct AudioFilter {
1881    pub kind: FilterKind,
1882    #[serde(default = "tru")]
1883    pub enabled: bool,
1884    #[serde(default, deserialize_with = "de_params")]
1885    pub params: Vec<Animated>,
1886}
1887
1888/// Filter parameters were plain numbers before they became keyframeable; those load as constants.
1889fn de_params<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<Animated>, D::Error> {
1890    #[derive(Deserialize)]
1891    #[serde(untagged)]
1892    enum NumOrAnim {
1893        Num(f64),
1894        Anim(Animated),
1895    }
1896    Ok(Vec::<NumOrAnim>::deserialize(d)?
1897        .into_iter()
1898        .map(|p| match p {
1899            NumOrAnim::Num(v) => Animated::new(v),
1900            NumOrAnim::Anim(a) => a,
1901        })
1902        .collect())
1903}
1904
1905impl AudioFilter {
1906    pub fn new(kind: FilterKind) -> Self {
1907        Self { kind, enabled: true, params: kind.params().iter().map(|p| Animated::new(p.default)).collect() }
1908    }
1909    /// Append the parameters a shorter (older) project is missing, at their spec defaults.
1910    pub fn fill_params(&mut self) {
1911        let specs = self.kind.params();
1912        for s in &specs[self.params.len().min(specs.len())..] {
1913            self.params.push(Animated::new(s.default));
1914        }
1915    }
1916    /// Parameter i at time t (spec default when missing).
1917    pub fn at(&self, i: usize, t: f64) -> f64 {
1918        self.params
1919            .get(i)
1920            .map(|a| a.at(t))
1921            .unwrap_or_else(|| self.kind.params().get(i).map(|p| p.default).unwrap_or(0.0))
1922    }
1923}
1924
1925/// A mixer bus. The first bus is always "Main"; every other bus routes into it (or into another bus).
1926#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
1927#[serde(default)]
1928pub struct Bus {
1929    pub id: Id,
1930    pub name: String,
1931    pub gain: Animated,
1932    pub pan: Animated,
1933    pub muted: bool,
1934    pub solo: bool,
1935    /// Fold to mono after the filter chain.
1936    pub mono: bool,
1937    pub filters: Vec<AudioFilter>,
1938    /// Where this bus sends its output (0 = Main). Main sends nowhere.
1939    pub output: Id,
1940}
1941
1942impl Default for Bus {
1943    fn default() -> Self {
1944        Self {
1945            id: 0,
1946            name: "Bus".into(),
1947            gain: Animated::new(1.0),
1948            pan: Animated::new(0.0),
1949            muted: false,
1950            solo: false,
1951            mono: false,
1952            filters: Vec::new(),
1953            output: 0,
1954        }
1955    }
1956}
1957
1958// ---------- transitions ----------
1959
1960#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize, Hash)]
1961pub enum TransitionKind {
1962    /// Dissolve A → B.
1963    CrossFade,
1964    /// A → colour → B (dip to black/white/…).
1965    FadeToColor,
1966    /// B pushes A out (direction).
1967    Push,
1968    /// B wipes over A (direction).
1969    Wipe,
1970}
1971
1972impl TransitionKind {
1973    pub const ALL: [TransitionKind; 4] =
1974        [TransitionKind::CrossFade, TransitionKind::FadeToColor, TransitionKind::Push, TransitionKind::Wipe];
1975    pub fn name(self) -> &'static str {
1976        match self {
1977            TransitionKind::CrossFade => "Cross Fade",
1978            TransitionKind::FadeToColor => "Fade to Color",
1979            TransitionKind::Push => "Push",
1980            TransitionKind::Wipe => "Wipe",
1981        }
1982    }
1983    pub fn has_direction(self) -> bool {
1984        matches!(self, TransitionKind::Push | TransitionKind::Wipe)
1985    }
1986}
1987
1988/// Where a transition sits. `Cut` (the default) is centred on the cut between a clip and its left
1989/// neighbour; `In` / `Out` sit inside a single clip's first/last `duration` seconds and blend
1990/// from/to nothing (black), so they need no neighbour.
1991#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize, Default, Hash)]
1992pub enum TransitionEdge {
1993    #[default]
1994    Cut,
1995    In,
1996    Out,
1997}
1998
1999/// A transition on a cut or a clip edge. For `Cut` it is centred on the cut between `right` and its
2000/// left neighbour, spanning [right.start - duration/2, right.start + duration/2); both clips are
2001/// extended virtually into that window (the engine clamps source times). For `In` / `Out`, `right`
2002/// is the single clip it belongs to and the window is its first/last `duration` seconds.
2003/// Audio tracks use CrossFade (a gain crossfade / fade).
2004#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
2005pub struct Transition {
2006    pub id: Id,
2007    /// The clip on the right side of the cut (`Cut`), or the clip the edge transition belongs to.
2008    pub right: Id,
2009    pub kind: TransitionKind,
2010    pub duration: f64,
2011    #[serde(default = "black")]
2012    pub color: [u8; 4],
2013    /// 0 = left, 1 = right, 2 = up, 3 = down (Push / Wipe).
2014    #[serde(default)]
2015    pub direction: u8,
2016    #[serde(default)]
2017    pub ease: Ease,
2018    #[serde(default)]
2019    pub edge: TransitionEdge,
2020}
2021
2022fn black() -> [u8; 4] {
2023    [0, 0, 0, 255]
2024}
2025
2026impl Transition {
2027    /// Half the transition length, clamped to the clips it joins: an over-long transition must not
2028    /// reach past either neighbour (it would hide the clips beside them).
2029    pub fn half(&self, left: &Clip, right: &Clip) -> f64 {
2030        (self.duration / 2.0).min(left.duration).min(right.duration)
2031    }
2032    /// Eased progress 0..1 across a window of `cut ± half`.
2033    pub fn progress_at(&self, cut: f64, half: f64, t: f64) -> f64 {
2034        if half <= 0.0 {
2035            return 1.0;
2036        }
2037        self.ease.apply(((t - (cut - half)) / (2.0 * half)).clamp(0.0, 1.0))
2038    }
2039    /// (centre, half-width) of the clamped window for any placement; the window is `centre ± half`.
2040    /// `Cut` needs both clips; `In` / `Out` need only their own (the window stays inside the clip).
2041    pub fn cut_half(&self, left: Option<&Clip>, right: Option<&Clip>) -> Option<(f64, f64)> {
2042        match self.edge {
2043            TransitionEdge::Cut => {
2044                let (l, r) = (left?, right?);
2045                Some((r.start, self.half(l, r)))
2046            }
2047            TransitionEdge::In => {
2048                let c = right?;
2049                let h = (self.duration / 2.0).min(c.duration / 2.0);
2050                Some((c.start + h, h))
2051            }
2052            TransitionEdge::Out => {
2053                let c = left?;
2054                let h = (self.duration / 2.0).min(c.duration / 2.0);
2055                Some((c.end() - h, h))
2056            }
2057        }
2058    }
2059}
2060
2061// ---------- subtitles ----------
2062
2063#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
2064pub struct Cue {
2065    pub id: Id,
2066    pub start: f64,
2067    pub end: f64,
2068    pub text: String,
2069}
2070
2071fn tru() -> bool {
2072    true
2073}
2074fn one() -> f64 {
2075    1.0
2076}
2077fn a0() -> Animated {
2078    Animated::new(0.0)
2079}
2080fn a1() -> Animated {
2081    Animated::new(1.0)
2082}
2083
2084#[derive(Clone, Debug, Serialize, Deserialize)]
2085pub struct Clip {
2086    pub id: Id,
2087    /// Clips sharing a non-zero link id move/split/delete together (video + its audio).
2088    #[serde(default)]
2089    pub link: Id,
2090    pub kind: ClipKind,
2091    /// True when this clip is a container (slot) whose media can be replaced without losing effects/transforms.
2092    #[serde(default)]
2093    pub container: bool,
2094    /// User-facing label for the container slot (e.g. "Main Shot", "B-Roll 1"). Empty = unnamed.
2095    #[serde(default)]
2096    pub container_label: String,
2097    /// Asset id (0 for Text clips).
2098    #[serde(default)]
2099    pub asset: Id,
2100    pub name: String,
2101    /// Timeline start (seconds).
2102    pub start: f64,
2103    pub duration: f64,
2104    /// Earliest source time used by the clip (the source window is [src_in, src_in + duration*speed)).
2105    #[serde(default)]
2106    pub src_in: f64,
2107    /// For Audio clips: which audio stream of the asset (0-based among audio streams).
2108    #[serde(default)]
2109    pub audio_stream: usize,
2110    /// For Sequence clips: the nested timeline id.
2111    #[serde(default)]
2112    pub sequence: Id,
2113    #[serde(default = "tru")]
2114    pub enabled: bool,
2115    /// Colour label shown on the timeline: 0 = inherit the asset's label, 1..=8 = LABEL_COLORS index + 1.
2116    #[serde(default)]
2117    pub label: u8,
2118    // --- retime ---
2119    /// Playback rate (1 = normal, 2 = twice as fast, 0.5 = half speed). Always > 0.
2120    #[serde(default = "one")]
2121    pub speed: f64,
2122    /// Keyframed speed ramp (curve editor). Only consulted while it has keys — the constant `speed`
2123    /// above still owns the source window and the duration.
2124    #[serde(default = "a1")]
2125    pub speed_curve: Animated,
2126    /// Play the source window backwards.
2127    #[serde(default)]
2128    pub reverse: bool,
2129    /// Freeze frame: show/hold the source frame at this source time for the whole clip (audio is silent).
2130    #[serde(default)]
2131    pub freeze: Option<f64>,
2132    // --- visual properties (project pixels / degrees / 0..1) ---
2133    #[serde(default = "a0")]
2134    pub x: Animated,
2135    #[serde(default = "a0")]
2136    pub y: Animated,
2137    #[serde(default = "a1")]
2138    pub scale: Animated,
2139    #[serde(default = "a0")]
2140    pub rotation: Animated,
2141    #[serde(default = "a1")]
2142    pub opacity: Animated,
2143    #[serde(default)]
2144    pub blend: BlendMode,
2145    /// Effect stack, applied in order before blending.
2146    #[serde(default)]
2147    pub effects: Vec<Effect>,
2148    // --- audio ---
2149    /// Linear gain (1 = unity).
2150    #[serde(default = "a1")]
2151    pub volume: Animated,
2152    /// -1 = left … 0 = centre … 1 = right.
2153    #[serde(default = "a0")]
2154    pub pan: Animated,
2155    /// Fade in/out lengths in seconds (gain ramps at the clip edges).
2156    #[serde(default)]
2157    pub fade_in: f64,
2158    #[serde(default)]
2159    pub fade_out: f64,
2160    /// Which bus this audio clip feeds (0 = its track's bus).
2161    #[serde(default)]
2162    pub bus: Id,
2163    /// Present for Text clips.
2164    #[serde(default)]
2165    pub text: Option<TextStyle>,
2166    /// Present for Shape clips.
2167    #[serde(default)]
2168    pub shape: Option<ShapeStyle>,
2169    /// Limits where the whole clip is visible.
2170    #[serde(default)]
2171    pub mask: Option<Mask>,
2172    /// Node graph; when present it replaces `effects` for rendering.
2173    #[serde(default)]
2174    pub graph: Option<NodeGraph>,
2175    /// Clip-local markers.
2176    #[serde(default)]
2177    pub markers: Vec<Marker>,
2178}
2179
2180impl Clip {
2181    pub fn new(id: Id, kind: ClipKind, name: impl Into<String>, start: f64, duration: f64) -> Self {
2182        Self {
2183            id,
2184            link: 0,
2185            kind,
2186            container: false,
2187            container_label: String::new(),
2188            asset: 0,
2189            name: name.into(),
2190            start,
2191            duration,
2192            src_in: 0.0,
2193            audio_stream: 0,
2194            sequence: 0,
2195            enabled: true,
2196            label: 0,
2197            speed: 1.0,
2198            speed_curve: a1(),
2199            reverse: false,
2200            freeze: None,
2201            x: a0(),
2202            y: a0(),
2203            scale: a1(),
2204            rotation: a0(),
2205            opacity: a1(),
2206            blend: BlendMode::Normal,
2207            effects: Vec::new(),
2208            volume: a1(),
2209            pan: a0(),
2210            fade_in: 0.0,
2211            fade_out: 0.0,
2212            bus: 0,
2213            text: if kind == ClipKind::Text { Some(TextStyle::default()) } else { None },
2214            shape: if kind == ClipKind::Shape { Some(ShapeStyle::default()) } else { None },
2215            mask: None,
2216            graph: None,
2217            markers: Vec::new(),
2218        }
2219    }
2220    /// True when this container has no media (asset == 0 for video/image/audio).
2221    pub fn is_empty_container(&self) -> bool {
2222        self.container && self.asset == 0
2223    }
2224    pub fn end(&self) -> f64 {
2225        self.start + self.duration
2226    }
2227    /// True for t in [start, end).
2228    pub fn contains(&self, t: f64) -> bool {
2229        t >= self.start - EPS && t < self.end() - EPS
2230    }
2231    /// Clip-local time.
2232    pub fn local(&self, t: f64) -> f64 {
2233        t - self.start
2234    }
2235    /// Gain/opacity multiplier of the fade in/out ramps at clip-local time `lt`. Time is clamped
2236    /// into the clip so virtual transition extensions hold the edge value (mixer rule).
2237    pub fn fade_mult(&self, lt: f64) -> f64 {
2238        let lt = lt.clamp(0.0, self.duration);
2239        let mut g = 1.0;
2240        if self.fade_in > 0.0 && lt < self.fade_in {
2241            g *= (lt / self.fade_in).clamp(0.0, 1.0);
2242        }
2243        if self.fade_out > 0.0 && lt > self.duration - self.fade_out {
2244            g *= ((self.duration - lt) / self.fade_out).clamp(0.0, 1.0);
2245        }
2246        g
2247    }
2248    /// Length of the source window in source seconds.
2249    pub fn src_len(&self) -> f64 {
2250        self.duration * self.speed
2251    }
2252    /// Playback rate at clip-local time `l` (the ramp when keyframed, else the constant).
2253    pub fn rate(&self, l: f64) -> f64 {
2254        if self.speed_curve.is_animated() {
2255            self.speed_curve.at(l).clamp(0.01, 100.0)
2256        } else {
2257            self.speed
2258        }
2259    }
2260    /// Source media time at timeline time t (speed, reverse and freeze applied).
2261    pub fn src_time(&self, t: f64) -> f64 {
2262        if let Some(f) = self.freeze {
2263            return f;
2264        }
2265        // ponytail: a keyframed ramp samples the rate at the clip-local time instead of integrating it, so
2266        // the source lands where the keys say at the keys and drifts between them. Sum the eased segments
2267        // if the drift ever shows.
2268        let l = self.local(t) * self.rate(self.local(t));
2269        if self.reverse {
2270            self.src_in + self.src_len() - l
2271        } else {
2272            self.src_in + l
2273        }
2274    }
2275    /// Latest source time used by the clip.
2276    pub fn src_end(&self) -> f64 {
2277        self.src_in + self.src_len()
2278    }
2279    pub fn is_visual(&self) -> bool {
2280        self.kind != ClipKind::Audio
2281    }
2282    /// Draws pixels of its own (an Adjustment layer only re-processes what is below it).
2283    pub fn draws(&self) -> bool {
2284        self.is_visual() && self.kind != ClipKind::Adjustment
2285    }
2286    pub fn uses_asset(&self) -> bool {
2287        matches!(self.kind, ClipKind::Video | ClipKind::Image | ClipKind::Audio)
2288    }
2289    /// Does the renderer evaluate the node graph instead of the linear effect stack? The node editor is
2290    /// opt-in: a bare Input→Output graph says nothing the stack does not, so it never shadows it —
2291    /// otherwise a clip that once had the node pane pointed at it could never take a plain effect again.
2292    pub fn uses_graph(&self) -> bool {
2293        self.graph.as_ref().is_some_and(|g| g.nodes.len() > 2)
2294    }
2295    /// Speed, reverse or freeze in effect.
2296    pub fn is_retimed(&self) -> bool {
2297        (self.speed - 1.0).abs() > EPS || self.speed_curve.is_animated() || self.reverse || self.freeze.is_some()
2298    }
2299    /// Anything that needs re-rendering (disqualifies a lossless `-c copy` export).
2300    pub fn has_effects(&self) -> bool {
2301        !self.x.is_default(0.0)
2302            || !self.y.is_default(0.0)
2303            || !self.scale.is_default(1.0)
2304            || !self.rotation.is_default(0.0)
2305            || !self.opacity.is_default(1.0)
2306            || self.blend != BlendMode::Normal
2307            || self.is_retimed()
2308            || !self.effects.is_empty()
2309            || self.mask.is_some()
2310            || self.uses_graph()
2311            || !self.pan.is_default(0.0)
2312            || self.fade_in > 0.0
2313            || self.fade_out > 0.0
2314    }
2315    /// Change the playback rate keeping the source window and the timeline start (duration follows).
2316    pub fn set_speed(&mut self, speed: f64) {
2317        let speed = if speed.is_finite() { speed.clamp(0.01, 100.0) } else { 1.0 };
2318        if self.freeze.is_none() {
2319            let len = self.src_len();
2320            self.duration = (len / speed).max(MIN_CLIP);
2321        }
2322        self.speed = speed;
2323        // keep the curve's constant in step so the graph editor starts a ramp from the real rate
2324        if !self.speed_curve.is_animated() {
2325            self.speed_curve.value = speed;
2326        }
2327    }
2328    pub fn animated(&self) -> [&Animated; 8] {
2329        [&self.x, &self.y, &self.scale, &self.rotation, &self.opacity, &self.volume, &self.pan, &self.speed_curve]
2330    }
2331    /// Every keyframeable property including effect parameters.
2332    pub fn all_animated_mut(&mut self) -> Vec<&mut Animated> {
2333        let mut v: Vec<&mut Animated> = vec![
2334            &mut self.x,
2335            &mut self.y,
2336            &mut self.scale,
2337            &mut self.rotation,
2338            &mut self.opacity,
2339            &mut self.volume,
2340            &mut self.pan,
2341            &mut self.speed_curve,
2342        ];
2343        for e in &mut self.effects {
2344            v.extend(e.params.iter_mut());
2345            if let Some(m) = &mut e.mask {
2346                v.extend(m.animated_mut());
2347            }
2348        }
2349        if let Some(m) = &mut self.mask {
2350            v.extend(m.animated_mut());
2351        }
2352        if let Some(g) = &mut self.graph {
2353            v.extend(g.animated_mut());
2354        }
2355        if let Some(sh) = &mut self.shape {
2356            v.push(&mut sh.w);
2357            v.push(&mut sh.h);
2358        }
2359        v
2360    }
2361    pub fn all_animated(&self) -> Vec<&Animated> {
2362        let mut v: Vec<&Animated> = self.animated().to_vec();
2363        for e in &self.effects {
2364            v.extend(e.params.iter());
2365            if let Some(m) = &e.mask {
2366                v.extend(m.animated());
2367            }
2368        }
2369        if let Some(m) = &self.mask {
2370            v.extend(m.animated());
2371        }
2372        if let Some(g) = &self.graph {
2373            v.extend(g.animated());
2374        }
2375        if let Some(sh) = &self.shape {
2376            v.push(&sh.w);
2377            v.push(&sh.h);
2378        }
2379        v
2380    }
2381    /// (label, property) pairs for the inspector — visual ones for visual clips, volume/pan for audio.
2382    pub fn props_mut(&mut self) -> Vec<(&'static str, &mut Animated)> {
2383        if self.is_visual() {
2384            vec![
2385                ("Position X", &mut self.x),
2386                ("Position Y", &mut self.y),
2387                ("Scale", &mut self.scale),
2388                ("Rotation", &mut self.rotation),
2389                ("Opacity", &mut self.opacity),
2390            ]
2391        } else {
2392            vec![("Volume", &mut self.volume), ("Pan", &mut self.pan)]
2393        }
2394    }
2395    /// Sorted, de-duplicated clip-local keyframe times across all properties (for drawing diamonds).
2396    pub fn key_times(&self) -> Vec<f64> {
2397        let mut v: Vec<f64> = self.all_animated().iter().flat_map(|a| a.keys.iter().map(|k| k.t)).collect();
2398        v.sort_by(f64::total_cmp);
2399        v.dedup_by(|a, b| (*a - *b).abs() < KEY_EPS);
2400        v
2401    }
2402    /// Shift every keyframe (all properties, effects included) by dt.
2403    pub fn shift_keys(&mut self, dt: f64) {
2404        for a in self.all_animated_mut() {
2405            a.shift(dt);
2406        }
2407    }
2408    /// Move every keyframe sitting at clip-local `t_old` to `t_new` (clamped inside the clip).
2409    pub fn move_keys(&mut self, t_old: f64, t_new: f64) {
2410        let t_new = t_new.clamp(0.0, self.duration);
2411        for a in self.all_animated_mut() {
2412            if let Some(i) = a.key_index_at(t_old) {
2413                a.move_key(i, t_new);
2414            }
2415        }
2416    }
2417    /// Split at timeline time t. `self` becomes the left part; returns the right part with `new_id`.
2418    /// None if t is not strictly inside the clip. Speed/reverse aware.
2419    pub fn split(&mut self, t: f64, new_id: Id) -> Option<Clip> {
2420        if t <= self.start + MIN_CLIP || t >= self.end() - MIN_CLIP {
2421            return None;
2422        }
2423        let off = t - self.start;
2424        let mut right = self.clone();
2425        right.id = new_id;
2426        right.start = t;
2427        right.duration = self.end() - t;
2428        if self.freeze.is_none() {
2429            if self.reverse {
2430                // left plays the later part of the source window, right the earlier part
2431                let src_in = self.src_in;
2432                self.src_in = src_in + (self.duration - off) * self.speed;
2433                right.src_in = src_in;
2434            } else {
2435                right.src_in = self.src_in + off * self.speed;
2436            }
2437        }
2438        right.shift_keys(-off);
2439        self.duration = off;
2440        Some(right)
2441    }
2442    /// Move the left edge to `new_start`, keeping the right edge fixed (slip-trim).
2443    /// `headroom` = source seconds available before the left edge (`Project::head_room`), INFINITY = unbounded.
2444    pub fn trim_start(&mut self, new_start: f64, headroom: f64) {
2445        let min_start =
2446            if headroom.is_finite() { self.start - headroom.max(0.0) / self.speed } else { f64::NEG_INFINITY };
2447        let ns = new_start.max(min_start).max(0.0).min(self.end() - MIN_CLIP);
2448        let d = ns - self.start;
2449        self.start = ns;
2450        self.duration -= d;
2451        if self.freeze.is_none() && !self.reverse {
2452            self.src_in += d * self.speed;
2453        }
2454        self.shift_keys(-d);
2455    }
2456    /// Move the right edge to `new_end`. `max_duration` = longest allowed duration (`Project::max_clip_duration`).
2457    pub fn trim_end(&mut self, new_end: f64, max_duration: f64) {
2458        let ne = new_end.min(self.start + max_duration).max(self.start + MIN_CLIP);
2459        let d = ne - self.end();
2460        self.duration = ne - self.start;
2461        if self.freeze.is_none() && self.reverse {
2462            // the right edge of a reversed clip is the earliest source time
2463            self.src_in = (self.src_in - d * self.speed).max(0.0);
2464        }
2465    }
2466}
2467
2468fn dh() -> f32 {
2469    60.0
2470}
2471
2472#[derive(Clone, Debug, Serialize, Deserialize)]
2473pub struct Track {
2474    pub id: Id,
2475    pub name: String,
2476    pub kind: TrackKind,
2477    /// Audio: muted. Video: hidden (the "V" visibility toggle).
2478    #[serde(default)]
2479    pub muted: bool,
2480    #[serde(default)]
2481    pub solo: bool,
2482    /// UI height in points.
2483    #[serde(default = "dh")]
2484    pub height: f32,
2485    #[serde(default)]
2486    pub clips: Vec<Clip>,
2487    #[serde(default)]
2488    pub transitions: Vec<Transition>,
2489    /// Audio tracks: the bus every clip feeds unless the clip overrides it (0 = Main).
2490    #[serde(default)]
2491    pub bus: Id,
2492}
2493
2494impl Track {
2495    pub fn new(id: Id, kind: TrackKind, name: impl Into<String>) -> Self {
2496        Self {
2497            id,
2498            name: name.into(),
2499            kind,
2500            muted: false,
2501            solo: false,
2502            height: if kind == TrackKind::Video { 64.0 } else { 56.0 },
2503            clips: Vec::new(),
2504            transitions: Vec::new(),
2505            bus: 0,
2506        }
2507    }
2508    pub fn sort(&mut self) {
2509        self.clips.sort_by(|a, b| a.start.total_cmp(&b.start));
2510    }
2511    pub fn end(&self) -> f64 {
2512        self.clips.iter().map(|c| c.end()).fold(0.0, f64::max)
2513    }
2514    /// True if [start, start+dur) is free on this track, ignoring clips in `ignore`.
2515    pub fn fits(&self, start: f64, dur: f64, ignore: &[Id]) -> bool {
2516        start >= -EPS
2517            && !self
2518                .clips
2519                .iter()
2520                .any(|c| !ignore.contains(&c.id) && c.start < start + dur - EPS && start < c.end() - EPS)
2521    }
2522    /// The clip ending exactly where `right` starts (the left side of that cut).
2523    pub fn left_of(&self, right: &Clip) -> Option<&Clip> {
2524        self.clips.iter().find(|c| c.id != right.id && (c.end() - right.start).abs() < ABUT_EPS)
2525    }
2526    /// The (left, right) sides of a transition, if it is still valid. Edge transitions have one side
2527    /// missing: `In` blends nothing → clip (no left), `Out` blends clip → nothing (no right).
2528    pub fn transition_clips(&self, tr: &Transition) -> Option<(Option<&Clip>, Option<&Clip>)> {
2529        let c = self.clips.iter().find(|c| c.id == tr.right)?;
2530        match tr.edge {
2531            TransitionEdge::Cut => Some((Some(self.left_of(c)?), Some(c))),
2532            TransitionEdge::In => Some((None, Some(c))),
2533            TransitionEdge::Out => Some((Some(c), None)),
2534        }
2535    }
2536    /// The transition playing at timeline time t (clamped window), with its clips.
2537    pub fn transition_at(&self, t: f64) -> Option<(&Transition, Option<&Clip>, Option<&Clip>)> {
2538        self.transitions.iter().find_map(|tr| {
2539            let (l, r) = self.transition_clips(tr)?;
2540            let (cut, h) = tr.cut_half(l, r)?;
2541            (t >= cut - h && t < cut + h).then_some((tr, l, r))
2542        })
2543    }
2544    /// Drop transitions whose clips no longer abut (edge transitions only need their clip to exist).
2545    pub fn prune_transitions(&mut self) {
2546        let keep: Vec<Id> =
2547            self.transitions.iter().filter(|t| self.transition_clips(t).is_some()).map(|t| t.id).collect();
2548        self.transitions.retain(|t| keep.contains(&t.id));
2549    }
2550}
2551
2552/// A nested timeline ("compound clip"): its own tracks/size/fps; placed on video tracks as a
2553/// `ClipKind::Sequence` clip and rendered/mixed recursively. Edited by swapping it into `Project.tracks`
2554/// (`open_sequence` / `close_sequence`).
2555#[derive(Clone, Debug, Serialize, Deserialize)]
2556pub struct Sequence {
2557    pub id: Id,
2558    pub name: String,
2559    pub width: u32,
2560    pub height: u32,
2561    pub fps: f64,
2562    pub tracks: Vec<Track>,
2563}
2564
2565impl Sequence {
2566    pub fn duration(&self) -> f64 {
2567        self.tracks.iter().map(|t| t.end()).fold(0.0, f64::max)
2568    }
2569}
2570
2571/// The main timeline's state while a sequence is swapped in for editing.
2572#[derive(Clone, Debug, Serialize, Deserialize)]
2573pub struct Stash {
2574    pub tracks: Vec<Track>,
2575    pub width: u32,
2576    pub height: u32,
2577    pub fps: f64,
2578    pub in_point: Option<f64>,
2579    pub out_point: Option<f64>,
2580}
2581
2582/// Planner item: a checkable task with notes, colour, nested sub-tasks and a moodboard of assets.
2583#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
2584#[serde(default)]
2585pub struct PlanItem {
2586    pub id: Id,
2587    pub title: String,
2588    pub done: bool,
2589    pub notes: String,
2590    /// 0 = none, 1..=8 = LABEL_COLORS index + 1.
2591    pub color: u8,
2592    /// Moodboard: asset ids (each with an optional description in `asset_notes`).
2593    pub assets: Vec<Id>,
2594    pub asset_notes: Vec<String>,
2595    pub children: Vec<PlanItem>,
2596}
2597
2598impl Default for PlanItem {
2599    fn default() -> Self {
2600        Self {
2601            id: 0,
2602            title: String::new(),
2603            done: false,
2604            notes: String::new(),
2605            color: 0,
2606            assets: Vec::new(),
2607            asset_notes: Vec::new(),
2608            children: Vec::new(),
2609        }
2610    }
2611}
2612
2613#[derive(Clone, Debug, Serialize, Deserialize)]
2614#[serde(default)]
2615pub struct Project {
2616    pub version: u32,
2617    pub name: String,
2618    pub width: u32,
2619    pub height: u32,
2620    pub fps: f64,
2621    pub assets: Vec<Asset>,
2622    /// Library folders that exist even while empty ("A/B" nesting by '/').
2623    pub folders: Vec<String>,
2624    /// Folders on disk browsed directly from the library (files are imported on drop).
2625    pub linked_folders: Vec<String>,
2626    /// Order: all video tracks (V1, V2, ...) then all audio tracks (A1, A2, ...). While `editing` is
2627    /// Some(seq), these are that sequence's tracks (the main timeline is in `main_stash`).
2628    pub tracks: Vec<Track>,
2629    pub in_point: Option<f64>,
2630    pub out_point: Option<f64>,
2631    /// Set when the project was created by opening a video file directly; enables "Save" (overwrite).
2632    pub source_video: Option<String>,
2633    /// Subtitle cues (kept sorted by start). Rendered bottom-centre by the compositor when `show_subtitles`.
2634    pub subtitles: Vec<Cue>,
2635    pub subtitle_style: TextStyle,
2636    /// Distance from the bottom edge in project pixels.
2637    pub subtitle_margin: f32,
2638    pub show_subtitles: bool,
2639    /// Prepended/appended to a generated cue where a sentence continues across the cue split
2640    /// (e.g. "…" / " —"). Applied by "Regenerate cues", not retroactively.
2641    pub subtitle_cont_prefix: String,
2642    pub subtitle_cont_suffix: String,
2643    /// Compositor resampling quality.
2644    pub scaler: Scaler,
2645    /// Colour labels (name + colour), editable by the user.
2646    pub labels: Vec<Label>,
2647    /// Timeline markers (sorted by time).
2648    pub markers: Vec<Marker>,
2649    /// Mixer buses; `buses[0]` is Main and always exists.
2650    pub buses: Vec<Bus>,
2651    /// Nested timelines (usable as footage via `ClipKind::Sequence`).
2652    pub sequences: Vec<Sequence>,
2653    /// The sequence currently swapped into `tracks` for editing (None = main timeline).
2654    pub editing: Option<Id>,
2655    pub main_stash: Option<Stash>,
2656    /// Planner (nested tasks with moodboards) and free-form notes (process / ideas / style).
2657    pub plan: Vec<PlanItem>,
2658    pub notes: String,
2659    /// Saved drawing / polygon outlines, reusable as motion paths (see `PathAsset`).
2660    pub paths: Vec<PathAsset>,
2661    next_id: Id,
2662}
2663
2664impl Default for Project {
2665    fn default() -> Self {
2666        Self::new()
2667    }
2668}
2669
2670impl Project {
2671    pub fn new() -> Self {
2672        let mut p = Self {
2673            version: 1,
2674            name: "Untitled".into(),
2675            width: 1920,
2676            height: 1080,
2677            fps: 30.0,
2678            assets: Vec::new(),
2679            folders: Vec::new(),
2680            linked_folders: Vec::new(),
2681            tracks: Vec::new(),
2682            in_point: None,
2683            out_point: None,
2684            source_video: None,
2685            subtitles: Vec::new(),
2686            subtitle_style: TextStyle::subtitle_default(),
2687            subtitle_margin: 60.0,
2688            show_subtitles: true,
2689            subtitle_cont_prefix: String::new(),
2690            subtitle_cont_suffix: String::new(),
2691            scaler: Scaler::Bilinear,
2692            labels: default_labels(),
2693            markers: Vec::new(),
2694            buses: Vec::new(),
2695            sequences: Vec::new(),
2696            editing: None,
2697            main_stash: None,
2698            plan: Vec::new(),
2699            notes: String::new(),
2700            paths: Vec::new(),
2701            next_id: 0,
2702        };
2703        p.add_track(TrackKind::Video);
2704        p.add_track(TrackKind::Audio);
2705        p
2706    }
2707
2708    /// New project sized from a media asset, with the asset laid out at t=0 (video + one audio track per stream).
2709    pub fn from_media(asset: Asset) -> Self {
2710        let mut p = Self::new();
2711        if asset.has_video() && asset.width > 0 && asset.height > 0 {
2712            p.width = asset.width;
2713            p.height = asset.height;
2714            if asset.kind == ClipKind::Video && asset.fps > 1.0 {
2715                p.fps = asset.fps;
2716            }
2717        }
2718        p.name = Path::new(&asset.path)
2719            .file_stem()
2720            .map(|s| s.to_string_lossy().into_owned())
2721            .unwrap_or_else(|| "Untitled".into());
2722        if asset.kind == ClipKind::Video {
2723            p.source_video = Some(asset.path.clone());
2724        }
2725        let aid = p.add_asset(asset);
2726        p.insert_asset_clips(aid, 0.0, None);
2727        p
2728    }
2729
2730    pub fn new_id(&mut self) -> Id {
2731        self.next_id += 1;
2732        self.next_id
2733    }
2734
2735    // ---------- assets ----------
2736    pub fn asset(&self, id: Id) -> Option<&Asset> {
2737        self.assets.iter().find(|a| a.id == id)
2738    }
2739    pub fn asset_mut(&mut self, id: Id) -> Option<&mut Asset> {
2740        self.assets.iter_mut().find(|a| a.id == id)
2741    }
2742    pub fn asset_by_path(&self, path: &str) -> Option<&Asset> {
2743        self.assets.iter().find(|a| a.path.eq_ignore_ascii_case(path))
2744    }
2745    /// Adds an asset (de-duplicated by path) and returns its id.
2746    pub fn add_asset(&mut self, mut a: Asset) -> Id {
2747        if let Some(e) = self.asset_by_path(&a.path) {
2748            return e.id;
2749        }
2750        a.id = self.new_id();
2751        let id = a.id;
2752        self.assets.push(a);
2753        id
2754    }
2755    /// Removes an asset and every clip using it.
2756    /// Removes an asset and every clip using it — in the live timeline, the stashed main timeline and
2757    /// every nested sequence (a leftover clip would render black / silent).
2758    pub fn remove_asset(&mut self, id: Id) {
2759        self.assets.retain(|a| a.id != id);
2760        let drop_clips = |tracks: &mut Vec<Track>| {
2761            for t in tracks.iter_mut() {
2762                t.clips.retain(|c| !(c.uses_asset() && c.asset == id));
2763                t.prune_transitions();
2764            }
2765        };
2766        drop_clips(&mut self.tracks);
2767        if let Some(st) = &mut self.main_stash {
2768            drop_clips(&mut st.tracks);
2769        }
2770        for seq in &mut self.sequences {
2771            drop_clips(&mut seq.tracks);
2772        }
2773        self.tidy();
2774    }
2775    /// All folder names (explicit + used by assets), sorted.
2776    pub fn folder_names(&self) -> Vec<String> {
2777        let mut v: Vec<String> = self.folders.clone();
2778        v.extend(self.assets.iter().filter(|a| !a.folder.is_empty()).map(|a| a.folder.clone()));
2779        v.sort();
2780        v.dedup();
2781        v
2782    }
2783    /// Create (or ensure) a folder; returns false if the name is empty.
2784    pub fn add_folder(&mut self, name: &str) -> bool {
2785        let name = name.trim().trim_matches('/').to_string();
2786        if name.is_empty() {
2787            return false;
2788        }
2789        if !self.folders.contains(&name) {
2790            self.folders.push(name);
2791            self.folders.sort();
2792        }
2793        true
2794    }
2795    /// Remove a folder (and sub-folders); assets inside move to the root.
2796    pub fn remove_folder(&mut self, name: &str) {
2797        let prefix = format!("{name}/");
2798        self.folders.retain(|f| f != name && !f.starts_with(&prefix));
2799        for a in &mut self.assets {
2800            if a.folder == name || a.folder.starts_with(&prefix) {
2801                a.folder.clear();
2802            }
2803        }
2804    }
2805
2806    // ---------- queries ----------
2807    pub fn duration(&self) -> f64 {
2808        self.tracks.iter().map(|t| t.end()).fold(0.0, f64::max)
2809    }
2810    pub fn is_empty(&self) -> bool {
2811        self.tracks.iter().all(|t| t.clips.is_empty())
2812    }
2813    pub fn frame_dur(&self) -> f64 {
2814        1.0 / self.fps.max(1.0)
2815    }
2816    pub fn snap_frame(&self, t: f64) -> f64 {
2817        (t * self.fps).round() / self.fps
2818    }
2819    /// (track index, clip index) of a clip.
2820    pub fn find(&self, id: Id) -> Option<(usize, usize)> {
2821        for (ti, t) in self.tracks.iter().enumerate() {
2822            if let Some(ci) = t.clips.iter().position(|c| c.id == id) {
2823                return Some((ti, ci));
2824            }
2825        }
2826        None
2827    }
2828    pub fn clip(&self, id: Id) -> Option<&Clip> {
2829        self.find(id).map(|(t, c)| &self.tracks[t].clips[c])
2830    }
2831    pub fn clip_mut(&mut self, id: Id) -> Option<&mut Clip> {
2832        let (t, c) = self.find(id)?;
2833        Some(&mut self.tracks[t].clips[c])
2834    }
2835    pub fn track_of(&self, clip: Id) -> Option<usize> {
2836        self.find(clip).map(|(t, _)| t)
2837    }
2838    pub fn all_clips(&self) -> impl Iterator<Item = (usize, &Clip)> {
2839        self.tracks.iter().enumerate().flat_map(|(i, t)| t.clips.iter().map(move |c| (i, c)))
2840    }
2841    /// All clip ids linked with `id` (including itself).
2842    pub fn linked(&self, id: Id) -> Vec<Id> {
2843        match self.clip(id) {
2844            Some(c) if c.link != 0 => {
2845                let l = c.link;
2846                self.all_clips().filter(|(_, c)| c.link == l).map(|(_, c)| c.id).collect()
2847            }
2848            Some(_) => vec![id],
2849            None => Vec::new(),
2850        }
2851    }
2852    /// Expand a selection to include linked clips.
2853    pub fn expand_links(&self, ids: &[Id]) -> Vec<Id> {
2854        let mut out: Vec<Id> = Vec::new();
2855        for &id in ids {
2856            for l in self.linked(id) {
2857                if !out.contains(&l) {
2858                    out.push(l);
2859                }
2860            }
2861        }
2862        out
2863    }
2864    pub fn video_tracks(&self) -> Vec<usize> {
2865        (0..self.tracks.len()).filter(|&i| self.tracks[i].kind == TrackKind::Video).collect()
2866    }
2867    pub fn audio_tracks(&self) -> Vec<usize> {
2868        (0..self.tracks.len()).filter(|&i| self.tracks[i].kind == TrackKind::Audio).collect()
2869    }
2870    /// Sorted distinct clip boundaries (plus 0) for prev/next-cut navigation.
2871    pub fn cut_points(&self) -> Vec<f64> {
2872        let mut v = vec![0.0];
2873        for (_, c) in self.all_clips() {
2874            v.push(c.start);
2875            v.push(c.end());
2876        }
2877        v.sort_by(f64::total_cmp);
2878        v.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
2879        v
2880    }
2881    /// Mute/solo resolution: a track is audible/visible if (no solo among its kind && !muted) || solo.
2882    pub fn active(&self, tidx: usize) -> bool {
2883        let t = &self.tracks[tidx];
2884        let any_solo = self.tracks.iter().any(|o| o.kind == t.kind && o.solo);
2885        if any_solo {
2886            t.solo
2887        } else {
2888            !t.muted
2889        }
2890    }
2891    /// Source seconds available before the clip's left edge (for `Clip::trim_start`).
2892    pub fn head_room(&self, clip: &Clip) -> f64 {
2893        if !matches!(clip.kind, ClipKind::Video | ClipKind::Audio) || clip.freeze.is_some() {
2894            return f64::INFINITY;
2895        }
2896        let Some(a) = self.asset(clip.asset) else { return f64::INFINITY };
2897        if clip.reverse {
2898            (a.duration - clip.src_end()).max(0.0)
2899        } else {
2900            clip.src_in.max(0.0)
2901        }
2902    }
2903    /// Longest duration the clip may have (right edge), given its source window, speed and direction.
2904    pub fn max_clip_duration(&self, clip: &Clip) -> f64 {
2905        if !matches!(clip.kind, ClipKind::Video | ClipKind::Audio) || clip.freeze.is_some() {
2906            return f64::INFINITY;
2907        }
2908        let Some(a) = self.asset(clip.asset) else { return f64::INFINITY };
2909        let extra = if clip.reverse { clip.src_in } else { a.duration - clip.src_end() };
2910        (clip.duration + extra.max(0.0) / clip.speed).max(MIN_CLIP)
2911    }
2912
2913    // ---------- tracks ----------
2914    /// Adds a track of `kind` (video tracks stay before audio tracks) and returns its index.
2915    pub fn add_track(&mut self, kind: TrackKind) -> usize {
2916        let n = self.tracks.iter().filter(|t| t.kind == kind).count() + 1;
2917        let id = self.new_id();
2918        let name = format!("{}{}", if kind == TrackKind::Video { "V" } else { "A" }, n);
2919        let track = Track::new(id, kind, name);
2920        let idx = match kind {
2921            TrackKind::Video => self.video_tracks().last().map(|i| i + 1).unwrap_or(0),
2922            TrackKind::Audio => self.tracks.len(),
2923        };
2924        self.tracks.insert(idx, track);
2925        idx
2926    }
2927    pub fn remove_track(&mut self, idx: usize) {
2928        if idx < self.tracks.len() {
2929            self.tracks.remove(idx);
2930            self.rename_tracks();
2931        }
2932    }
2933    fn rename_tracks(&mut self) {
2934        let (mut v, mut a) = (0, 0);
2935        for t in &mut self.tracks {
2936            match t.kind {
2937                TrackKind::Video => {
2938                    v += 1;
2939                    t.name = format!("V{v}");
2940                }
2941                TrackKind::Audio => {
2942                    a += 1;
2943                    t.name = format!("A{a}");
2944                }
2945            }
2946        }
2947    }
2948    /// First track of `kind` (preferring `prefer`) where [start,start+dur) is free; adds one if needed.
2949    pub fn find_free_track(&mut self, kind: TrackKind, start: f64, dur: f64, prefer: Option<usize>) -> usize {
2950        if let Some(p) = prefer {
2951            if p < self.tracks.len() && self.tracks[p].kind == kind && self.tracks[p].fits(start, dur, &[]) {
2952                return p;
2953            }
2954        }
2955        let list = if kind == TrackKind::Video { self.video_tracks() } else { self.audio_tracks() };
2956        for i in list {
2957            if self.tracks[i].fits(start, dur, &[]) {
2958                return i;
2959            }
2960        }
2961        self.add_track(kind)
2962    }
2963
2964    // ---------- editing ----------
2965    /// Re-sort clips and drop transitions whose cuts no longer exist. Call after any layout change.
2966    pub fn tidy(&mut self) {
2967        for t in &mut self.tracks {
2968            t.sort();
2969            t.prune_transitions();
2970        }
2971    }
2972
2973    /// Place an asset on the timeline at `at`: video/image clip on a video track (preferring `video_track`)
2974    /// plus one linked audio clip per audio stream (stream N preferring track A(N+1)). Returns new clip ids.
2975    pub fn insert_asset_clips(&mut self, asset_id: Id, at: f64, video_track: Option<usize>) -> Vec<Id> {
2976        let Some(asset) = self.asset(asset_id).cloned() else { return Vec::new() };
2977        let dur = if asset.kind == ClipKind::Image { 5.0 } else { asset.duration.max(MIN_CLIP) };
2978        let n_parts = asset.has_video() as usize + asset.audio_streams.len();
2979        let link = if n_parts > 1 { self.new_id() } else { 0 };
2980        let mut ids = Vec::new();
2981        if asset.has_video() {
2982            let ti = self.find_free_track(TrackKind::Video, at, dur, video_track);
2983            let mut c = Clip::new(self.new_id(), asset.kind, asset.name(), at, dur);
2984            c.asset = asset.id;
2985            c.link = link;
2986            ids.push(c.id);
2987            self.tracks[ti].clips.push(c);
2988            self.tracks[ti].sort();
2989        }
2990        for (i, s) in asset.audio_streams.iter().enumerate() {
2991            let prefer = self.audio_tracks().get(i).copied();
2992            let ti = self.find_free_track(TrackKind::Audio, at, dur, prefer);
2993            let name =
2994                if asset.audio_streams.len() > 1 { format!("{} [{}]", asset.name(), s.label()) } else { asset.name() };
2995            let mut c = Clip::new(self.new_id(), ClipKind::Audio, name, at, dur);
2996            c.asset = asset.id;
2997            c.audio_stream = s.index;
2998            c.link = link;
2999            ids.push(c.id);
3000            self.tracks[ti].clips.push(c);
3001            self.tracks[ti].sort();
3002        }
3003        ids
3004    }
3005
3006    /// Add a text clip on the topmost video track that has room (or a new one).
3007    pub fn add_text_clip(&mut self, at: f64, dur: f64) -> Id {
3008        let prefer = self.video_tracks().last().copied();
3009        let ti = self.find_free_track(TrackKind::Video, at, dur, prefer);
3010        let c = Clip::new(self.new_id(), ClipKind::Text, "Text", at, dur);
3011        let id = c.id;
3012        self.tracks[ti].clips.push(c);
3013        self.tracks[ti].sort();
3014        id
3015    }
3016
3017    /// Split every clip crossing t (or only the given ids). Right halves of a linked group stay linked
3018    /// to each other under a fresh link id. Returns the new (right-half) ids.
3019    pub fn split_at(&mut self, t: f64, only: Option<&[Id]>) -> Vec<Id> {
3020        let mut new_ids = Vec::new();
3021        let mut link_map: std::collections::HashMap<Id, Id> = std::collections::HashMap::new();
3022        for ti in 0..self.tracks.len() {
3023            let mut added = Vec::new();
3024            for ci in 0..self.tracks[ti].clips.len() {
3025                let c = &self.tracks[ti].clips[ci];
3026                if !c.contains(t) || only.map(|o| !o.contains(&c.id)).unwrap_or(false) {
3027                    continue;
3028                }
3029                let old_link = c.link;
3030                let nid = self.new_id();
3031                let new_link = if old_link != 0 {
3032                    match link_map.get(&old_link) {
3033                        Some(&l) => l,
3034                        None => {
3035                            let l = self.new_id();
3036                            link_map.insert(old_link, l);
3037                            l
3038                        }
3039                    }
3040                } else {
3041                    0
3042                };
3043                if let Some(mut right) = self.tracks[ti].clips[ci].split(t, nid) {
3044                    right.link = new_link;
3045                    new_ids.push(right.id);
3046                    added.push(right);
3047                }
3048            }
3049            if !added.is_empty() {
3050                self.tracks[ti].clips.extend(added);
3051                self.tracks[ti].sort();
3052            }
3053        }
3054        self.tidy();
3055        new_ids
3056    }
3057
3058    /// Freeze-frame the clips at timeline time t: each clip is split at t and its right part becomes a
3059    /// still of the frame at t (linked clips of the same time too). Returns the frozen clip ids.
3060    pub fn freeze_at(&mut self, t: f64, ids: &[Id]) -> Vec<Id> {
3061        let ids = self.expand_links(ids);
3062        let mut frozen = Vec::new();
3063        for id in ids {
3064            let Some(c) = self.clip(id) else { continue };
3065            if !c.contains(t) {
3066                continue; // freezing a clip the playhead is not over would store an out-of-range source time
3067            }
3068            let src = c.src_time(t);
3069            let target =
3070                if t > c.start + MIN_CLIP { self.split_at(t, Some(&[id])).first().copied().unwrap_or(id) } else { id };
3071            if let Some(c) = self.clip_mut(target) {
3072                c.freeze = Some(src);
3073                frozen.push(target);
3074            }
3075        }
3076        frozen
3077    }
3078
3079    /// Delete clips. With `ripple`, later clips close the gap (per track, only where no other clip overlaps the gap).
3080    pub fn delete_clips(&mut self, ids: &[Id], ripple: bool) {
3081        let mut ranges: Vec<(f64, f64)> = Vec::new();
3082        for &id in ids {
3083            if let Some(c) = self.clip(id) {
3084                ranges.push((c.start, c.end()));
3085            }
3086        }
3087        for t in &mut self.tracks {
3088            t.clips.retain(|c| !ids.contains(&c.id));
3089        }
3090        if ripple {
3091            ranges.sort_by(|a, b| b.0.total_cmp(&a.0)); // right to left
3092            ranges.dedup_by(|a, b| (a.0 - b.0).abs() < EPS && (a.1 - b.1).abs() < EPS);
3093            for (a, b) in ranges {
3094                self.close_gap(a, b);
3095            }
3096        }
3097        self.tidy();
3098    }
3099
3100    /// Shift clips starting at/after `b` left by (b-a) on every track where [a,b) is free.
3101    fn close_gap(&mut self, a: f64, b: f64) {
3102        let len = b - a;
3103        if len <= 0.0 {
3104            return;
3105        }
3106        for t in &mut self.tracks {
3107            if !t.fits(a, len, &[]) {
3108                continue;
3109            }
3110            for c in &mut t.clips {
3111                if c.start >= b - EPS {
3112                    c.start -= len;
3113                }
3114            }
3115        }
3116    }
3117
3118    /// Remove everything in [a,b) on all tracks and close the gap.
3119    pub fn ripple_delete_range(&mut self, a: f64, b: f64) {
3120        if b <= a + EPS {
3121            return;
3122        }
3123        self.split_at(a, None);
3124        self.split_at(b, None);
3125        let ids: Vec<Id> =
3126            self.all_clips().filter(|(_, c)| c.start >= a - EPS && c.end() <= b + EPS).map(|(_, c)| c.id).collect();
3127        self.delete_clips(&ids, false);
3128        self.close_gap(a, b);
3129        self.tidy();
3130    }
3131
3132    /// Open a gap of `span` seconds at `at`: split anything crossing it, then slide every clip from
3133    /// there on to the right. The inverse of `ripple_delete_range`, used by Paste Insert.
3134    pub fn ripple_open(&mut self, at: f64, span: f64) {
3135        if span <= EPS {
3136            return;
3137        }
3138        self.split_at(at, None);
3139        // right to left, so a clip never lands on one that has not moved yet
3140        let mut ids: Vec<(Id, f64)> =
3141            self.all_clips().filter(|(_, c)| c.start >= at - EPS).map(|(_, c)| (c.id, c.start)).collect();
3142        ids.sort_by(|x, y| y.1.total_cmp(&x.1));
3143        for (id, _) in ids {
3144            self.move_clips(&[id], span, 0, None);
3145        }
3146        self.tidy();
3147    }
3148
3149    /// Keep only [a,b), moving it to start at 0. Clears in/out points.
3150    pub fn trim_to_range(&mut self, a: f64, b: f64) {
3151        let end = self.duration().max(b) + 1.0;
3152        self.ripple_delete_range(b, end);
3153        self.ripple_delete_range(0.0, a);
3154        self.in_point = None;
3155        self.out_point = None;
3156    }
3157
3158    /// Move clips by dt seconds; clips on tracks of `track_kind` also move by `dtrack` tracks within their kind.
3159    /// All-or-nothing: returns false (and changes nothing) if any destination is blocked or out of range.
3160    pub fn move_clips(&mut self, ids: &[Id], dt: f64, dtrack: i32, track_kind: Option<TrackKind>) -> bool {
3161        // (clip id, from track, to track, new start, dur)
3162        let mut plan: Vec<(Id, usize, usize, f64, f64)> = Vec::new();
3163        for &id in ids {
3164            let Some((ti, ci)) = self.find(id) else { return false };
3165            let c = &self.tracks[ti].clips[ci];
3166            let kind = self.tracks[ti].kind;
3167            let mut to = ti;
3168            if dtrack != 0 && track_kind == Some(kind) {
3169                let list = if kind == TrackKind::Video { self.video_tracks() } else { self.audio_tracks() };
3170                let pos = list.iter().position(|&x| x == ti).unwrap() as i32 + dtrack;
3171                if pos < 0 || pos >= list.len() as i32 {
3172                    return false;
3173                }
3174                to = list[pos as usize];
3175            }
3176            let ns = c.start + dt;
3177            if ns < -EPS {
3178                return false;
3179            }
3180            plan.push((id, ti, to, ns.max(0.0), c.duration));
3181        }
3182        // moved clips must not overlap each other (sorted per destination track: neighbours suffice)
3183        // or any clip that stays put
3184        plan.sort_by(|a, b| a.2.cmp(&b.2).then(a.3.total_cmp(&b.3)));
3185        for w in plan.windows(2) {
3186            if w[0].2 == w[1].2 && w[1].3 < w[0].3 + w[0].4 - EPS {
3187                return false;
3188            }
3189        }
3190        let moved: std::collections::HashSet<Id> = ids.iter().copied().collect();
3191        for (_, _, to, ns, dur) in &plan {
3192            let blocked = self.tracks[*to]
3193                .clips
3194                .iter()
3195                .any(|c| !moved.contains(&c.id) && c.start < ns + dur - EPS && *ns < c.end() - EPS);
3196            if blocked {
3197                return false;
3198            }
3199        }
3200        for (id, from, to, ns, _) in plan {
3201            let ci = self.tracks[from].clips.iter().position(|c| c.id == id).unwrap();
3202            let mut c = self.tracks[from].clips.remove(ci);
3203            c.start = ns;
3204            // transitions belong to the track; moving the right clip across tracks carries none
3205            self.tracks[to].clips.push(c);
3206        }
3207        self.tidy();
3208        true
3209    }
3210
3211    /// Set `enabled` on the clips (linked clips too).
3212    pub fn set_enabled(&mut self, ids: &[Id], enabled: bool) {
3213        for id in self.expand_links(ids) {
3214            if let Some(c) = self.clip_mut(id) {
3215                c.enabled = enabled;
3216            }
3217        }
3218    }
3219    /// Unlink the clips if any is linked; otherwise link them together.
3220    pub fn toggle_link(&mut self, ids: &[Id]) {
3221        let linked = ids.iter().any(|&id| self.clip(id).map(|c| c.link != 0).unwrap_or(false));
3222        let link = if linked { 0 } else { self.new_id() };
3223        for &id in ids {
3224            if let Some(c) = self.clip_mut(id) {
3225                c.link = link;
3226            }
3227        }
3228    }
3229    /// Apply speed/reverse to the clips and their linked clips (keeps source windows; durations follow).
3230    /// Returns false if the new lengths would collide with neighbours (nothing changed).
3231    pub fn set_speed(&mut self, ids: &[Id], speed: f64, reverse: bool) -> bool {
3232        let ids = self.expand_links(ids);
3233        let mut tmp: Vec<(usize, usize, Clip)> = Vec::new();
3234        for &id in &ids {
3235            let Some((ti, ci)) = self.find(id) else { continue };
3236            let mut c = self.tracks[ti].clips[ci].clone();
3237            c.set_speed(speed);
3238            c.reverse = reverse;
3239            if !self.tracks[ti].fits(c.start, c.duration, &ids) {
3240                return false;
3241            }
3242            tmp.push((ti, ci, c));
3243        }
3244        for (ti, ci, c) in tmp {
3245            self.tracks[ti].clips[ci] = c;
3246        }
3247        self.tidy();
3248        true
3249    }
3250
3251    // ---------- transitions ----------
3252    /// Add (or replace) a transition at the cut on the left of clip `right`. Linked audio clips whose
3253    /// left neighbour is linked with the video's left neighbour get an audio CrossFade of the same length.
3254    /// Returns the new transition id, or None if `right` has no abutting left neighbour.
3255    pub fn add_transition(&mut self, right: Id, kind: TransitionKind, duration: f64) -> Option<Id> {
3256        self.add_transition_at(right, kind, duration, TransitionEdge::Cut)
3257    }
3258    /// Add (or replace) an edge transition at the start (`out` false) or end (`out` true) of `clip`:
3259    /// the clip blends from/to nothing, no neighbour needed. Mirrors on linked clips like `add_transition`.
3260    pub fn add_edge_transition(&mut self, clip: Id, kind: TransitionKind, duration: f64, out: bool) -> Option<Id> {
3261        self.add_transition_at(clip, kind, duration, if out { TransitionEdge::Out } else { TransitionEdge::In })
3262    }
3263    fn add_transition_at(
3264        &mut self,
3265        right: Id,
3266        kind: TransitionKind,
3267        duration: f64,
3268        edge: TransitionEdge,
3269    ) -> Option<Id> {
3270        let (ti, _) = self.find(right)?;
3271        let r = self.clip(right)?.clone();
3272        if edge == TransitionEdge::Cut {
3273            self.tracks[ti].left_of(&r)?;
3274        }
3275        let duration = duration.max(MIN_CLIP);
3276        let id = self.new_id();
3277        Self::clear_transition_slot(&mut self.tracks[ti], &r, edge);
3278        self.tracks[ti].transitions.push(Transition {
3279            id,
3280            right,
3281            kind,
3282            duration,
3283            color: [0, 0, 0, 255],
3284            direction: 0,
3285            ease: Ease::Linear,
3286            edge,
3287        });
3288        // mirror on linked clips (audio crossfade / fade)
3289        if r.link != 0 {
3290            let partners: Vec<Id> = self.linked(right).into_iter().filter(|&p| p != right).collect();
3291            for p in partners {
3292                let Some((pti, _)) = self.find(p) else { continue };
3293                if pti == ti {
3294                    continue;
3295                }
3296                let pc = self.clip(p).unwrap().clone();
3297                if edge == TransitionEdge::Cut && self.tracks[pti].left_of(&pc).is_none() {
3298                    continue;
3299                }
3300                let pid = self.new_id();
3301                let pkind = if self.tracks[pti].kind == TrackKind::Audio { TransitionKind::CrossFade } else { kind };
3302                Self::clear_transition_slot(&mut self.tracks[pti], &pc, edge);
3303                self.tracks[pti].transitions.push(Transition {
3304                    id: pid,
3305                    right: p,
3306                    kind: pkind,
3307                    duration,
3308                    color: [0, 0, 0, 255],
3309                    direction: 0,
3310                    ease: Ease::Linear,
3311                    edge,
3312                });
3313            }
3314        }
3315        Some(id)
3316    }
3317    /// Remove whatever transition already occupies the spot a new one is going to: a `Cut` or `In`
3318    /// at the start of `c` share the start slot (plus the previous clip's `Out`); an `Out` at the end
3319    /// of `c` shares the end slot with the next clip's `Cut` / `In`.
3320    fn clear_transition_slot(track: &mut Track, c: &Clip, edge: TransitionEdge) {
3321        let prev = track.left_of(c).map(|l| l.id);
3322        let next = track.clips.iter().find(|o| o.id != c.id && (o.start - c.end()).abs() < ABUT_EPS).map(|o| o.id);
3323        let (start_clip, end_clip) = match edge {
3324            TransitionEdge::Cut | TransitionEdge::In => (Some(c.id), prev),
3325            TransitionEdge::Out => (next, Some(c.id)),
3326        };
3327        track.transitions.retain(|t| {
3328            !(start_clip.is_some_and(|s| t.right == s && t.edge != TransitionEdge::Out)
3329                || end_clip.is_some_and(|e| t.right == e && t.edge == TransitionEdge::Out))
3330        });
3331    }
3332    pub fn remove_transition(&mut self, id: Id) {
3333        for t in &mut self.tracks {
3334            t.transitions.retain(|x| x.id != id);
3335        }
3336    }
3337    pub fn transition_mut(&mut self, id: Id) -> Option<&mut Transition> {
3338        self.tracks.iter_mut().flat_map(|t| t.transitions.iter_mut()).find(|x| x.id == id)
3339    }
3340    /// Transitions touching a clip (as left or right side): (track index, transition).
3341    pub fn transitions_of(&self, clip: Id) -> Vec<(usize, &Transition)> {
3342        let mut out = Vec::new();
3343        for (ti, t) in self.tracks.iter().enumerate() {
3344            for tr in &t.transitions {
3345                if let Some((l, r)) = t.transition_clips(tr) {
3346                    if l.is_some_and(|c| c.id == clip) || r.is_some_and(|c| c.id == clip) {
3347                        out.push((ti, tr));
3348                    }
3349                }
3350            }
3351        }
3352        out
3353    }
3354
3355    // ---------- subtitles ----------
3356    pub fn cue_at(&self, t: f64) -> Option<&Cue> {
3357        self.subtitles.iter().find(|c| t >= c.start && t < c.end)
3358    }
3359    pub fn add_cue(&mut self, start: f64, end: f64, text: impl Into<String>) -> Id {
3360        let id = self.new_id();
3361        self.subtitles.push(Cue { id, start, end: end.max(start + MIN_CLIP), text: text.into() });
3362        self.sort_cues();
3363        id
3364    }
3365    pub fn remove_cue(&mut self, id: Id) {
3366        self.subtitles.retain(|c| c.id != id);
3367    }
3368    /// Split cue `id` at `t` (both halves keep the text, like splitting a clip); the new right-half id,
3369    /// or None when `t` is outside the cue or too close to an edge for two readable halves.
3370    pub fn split_cue(&mut self, id: Id, t: f64) -> Option<Id> {
3371        let c = self.subtitles.iter_mut().find(|c| c.id == id)?;
3372        if t < c.start + 0.05 || t > c.end - 0.05 {
3373            return None;
3374        }
3375        let (end, text) = (c.end, c.text.clone());
3376        c.end = t;
3377        Some(self.add_cue(t, end, text))
3378    }
3379    /// Convert cues into editable Text clips on a topmost "Subtitles" video track, styled and placed
3380    /// like the burn-in. `only` limits it to those cue ids; converted cues are removed. Returns how many.
3381    pub fn cues_to_text_clips(&mut self, only: Option<&[Id]>) -> usize {
3382        let take: Vec<Cue> =
3383            self.subtitles.iter().filter(|c| only.is_none_or(|o| o.contains(&c.id))).cloned().collect();
3384        if take.is_empty() {
3385            return 0;
3386        }
3387        let ti = match self.tracks.iter().position(|t| t.kind == TrackKind::Video && t.name == "Subtitles") {
3388            Some(i) => i,
3389            None => {
3390                let id = self.new_id();
3391                self.tracks.push(Track::new(id, TrackKind::Video, "Subtitles"));
3392                self.tracks.len() - 1
3393            }
3394        };
3395        // bottom-centred like the burn-in; the box height is estimated as one line of text
3396        let y = self.height as f64 / 2.0 - self.subtitle_margin as f64 - self.subtitle_style.size as f64 * 0.75;
3397        for cue in &take {
3398            let mut c =
3399                Clip::new(self.new_id(), ClipKind::Text, "Subtitle", cue.start, (cue.end - cue.start).max(MIN_CLIP));
3400            let mut style = self.subtitle_style.clone();
3401            style.text.clone_from(&cue.text);
3402            c.text = Some(style);
3403            c.y = Animated::new(y);
3404            self.tracks[ti].clips.push(c);
3405        }
3406        self.tracks[ti].sort();
3407        let ids: Vec<Id> = take.iter().map(|c| c.id).collect();
3408        self.subtitles.retain(|c| !ids.contains(&c.id));
3409        take.len()
3410    }
3411    pub fn sort_cues(&mut self) {
3412        self.subtitles.sort_by(|a, b| a.start.total_cmp(&b.start));
3413    }
3414
3415    // ---------- sequences (nested timelines) ----------
3416    pub fn sequence(&self, id: Id) -> Option<&Sequence> {
3417        self.sequences.iter().find(|s| s.id == id)
3418    }
3419    pub fn sequence_mut(&mut self, id: Id) -> Option<&mut Sequence> {
3420        self.sequences.iter_mut().find(|s| s.id == id)
3421    }
3422    /// New empty sequence (V1 + A1) with the given format; returns its id.
3423    pub fn new_sequence(&mut self, name: impl Into<String>, width: u32, height: u32, fps: f64) -> Id {
3424        let id = self.new_id();
3425        let mut seq = Sequence { id, name: name.into(), width, height, fps, tracks: Vec::new() };
3426        let v = Track::new(self.new_id(), TrackKind::Video, "V1");
3427        let a = Track::new(self.new_id(), TrackKind::Audio, "A1");
3428        seq.tracks.push(v);
3429        seq.tracks.push(a);
3430        self.sequences.push(seq);
3431        id
3432    }
3433    /// Duration of a sequence (the live tracks when it is the one being edited).
3434    pub fn sequence_duration(&self, id: Id) -> f64 {
3435        if self.editing == Some(id) {
3436            return self.duration();
3437        }
3438        self.sequence(id).map(|s| s.duration()).unwrap_or(0.0)
3439    }
3440    /// Tracks of a sequence for rendering (its own, or the live `tracks` while it is being edited).
3441    pub fn sequence_tracks(&self, id: Id) -> Option<&[Track]> {
3442        if self.editing == Some(id) {
3443            return Some(&self.tracks);
3444        }
3445        self.sequence(id).map(|s| s.tracks.as_slice())
3446    }
3447    /// True if sequence `outer` contains `inner` directly or through nested sequence clips.
3448    pub fn sequence_contains(&self, outer: Id, inner: Id) -> bool {
3449        fn walk(p: &Project, tracks: &[Track], inner: Id, depth: u32) -> bool {
3450            if depth > 32 {
3451                return true; // treat runaway nesting as a cycle
3452            }
3453            tracks.iter().flat_map(|t| t.clips.iter()).any(|c| {
3454                c.kind == ClipKind::Sequence
3455                    && (c.sequence == inner
3456                        || p.sequence_tracks(c.sequence).map(|tr| walk(p, tr, inner, depth + 1)).unwrap_or(false))
3457            })
3458        }
3459        outer == inner || self.sequence_tracks(outer).map(|t| walk(self, t, inner, 0)).unwrap_or(false)
3460    }
3461    /// Swap sequence `id` into `tracks` for editing (closing any other open sequence first).
3462    pub fn open_sequence(&mut self, id: Id) -> bool {
3463        if self.editing == Some(id) {
3464            return true;
3465        }
3466        if self.sequence(id).is_none() {
3467            return false;
3468        }
3469        self.close_sequence();
3470        let stash = Stash {
3471            tracks: std::mem::take(&mut self.tracks),
3472            width: self.width,
3473            height: self.height,
3474            fps: self.fps,
3475            in_point: self.in_point.take(),
3476            out_point: self.out_point.take(),
3477        };
3478        let seq = self.sequence_mut(id).unwrap();
3479        let tracks = std::mem::take(&mut seq.tracks);
3480        let (w, h, fps) = (seq.width, seq.height, seq.fps);
3481        self.main_stash = Some(stash);
3482        self.tracks = tracks;
3483        self.width = w;
3484        self.height = h;
3485        self.fps = fps;
3486        self.editing = Some(id);
3487        true
3488    }
3489    /// Put the edited sequence back and restore the main timeline.
3490    pub fn close_sequence(&mut self) {
3491        let Some(id) = self.editing.take() else { return };
3492        let tracks = std::mem::take(&mut self.tracks);
3493        let (w, h, fps) = (self.width, self.height, self.fps);
3494        if let Some(seq) = self.sequence_mut(id) {
3495            seq.tracks = tracks;
3496            seq.width = w;
3497            seq.height = h;
3498            seq.fps = fps;
3499        }
3500        if let Some(st) = self.main_stash.take() {
3501            self.tracks = st.tracks;
3502            self.width = st.width;
3503            self.height = st.height;
3504            self.fps = st.fps;
3505            self.in_point = st.in_point;
3506            self.out_point = st.out_point;
3507        }
3508    }
3509    /// Place a sequence as a clip at `at` (video track `video_track` preferred). None on cycles / unknown id.
3510    pub fn insert_sequence_clip(&mut self, seq: Id, at: f64, video_track: Option<usize>) -> Option<Id> {
3511        let name = self.sequence(seq)?.name.clone();
3512        // a sequence can't contain itself: the timeline being edited (or main) must not be inside `seq`
3513        if let Some(cur) = self.editing {
3514            if self.sequence_contains(seq, cur) {
3515                return None;
3516            }
3517        }
3518        let dur = self.sequence_duration(seq).max(MIN_CLIP);
3519        let ti = self.find_free_track(TrackKind::Video, at, dur, video_track);
3520        let mut c = Clip::new(self.new_id(), ClipKind::Sequence, name, at, dur);
3521        c.sequence = seq;
3522        let id = c.id;
3523        self.tracks[ti].clips.push(c);
3524        self.tracks[ti].sort();
3525        Some(id)
3526    }
3527    /// Move the selected clips (+ linked) into a new sequence and replace them with one Sequence clip.
3528    /// Returns the new sequence id.
3529    pub fn nest_selection(&mut self, ids: &[Id], name: impl Into<String>) -> Option<Id> {
3530        let ids = self.expand_links(ids);
3531        if ids.is_empty() {
3532            return None;
3533        }
3534        let start = ids.iter().filter_map(|&id| self.clip(id)).map(|c| c.start).fold(f64::INFINITY, f64::min);
3535        let end = ids.iter().filter_map(|&id| self.clip(id)).map(|c| c.end()).fold(0.0, f64::max);
3536        if !start.is_finite() || end <= start {
3537            return None;
3538        }
3539        let (w, h, fps) = (self.width, self.height, self.fps);
3540        let seq_id = self.new_sequence(name, w, h, fps);
3541        // move clips: keep their track kind and relative order of tracks
3542        let mut moved: Vec<(TrackKind, usize, Clip)> = Vec::new(); // (kind, index within kind, clip)
3543        let mut top_video: Option<usize> = None;
3544        for &id in &ids {
3545            let Some((ti, ci)) = self.find(id) else { continue };
3546            let kind = self.tracks[ti].kind;
3547            let list = if kind == TrackKind::Video { self.video_tracks() } else { self.audio_tracks() };
3548            let pos = list.iter().position(|&x| x == ti).unwrap_or(0);
3549            if kind == TrackKind::Video {
3550                top_video = Some(top_video.map_or(ti, |t: usize| t.max(ti)));
3551            }
3552            let mut c = self.tracks[ti].clips.remove(ci);
3553            c.start -= start;
3554            moved.push((kind, pos, c));
3555        }
3556        self.tidy();
3557        let next_ids: Vec<Id> = (0..64).map(|_| self.new_id()).collect();
3558        let mut nid = next_ids.into_iter();
3559        if let Some(seq) = self.sequence_mut(seq_id) {
3560            for (kind, pos, c) in moved {
3561                // ensure track `pos` of this kind exists
3562                loop {
3563                    let have = seq.tracks.iter().filter(|t| t.kind == kind).count();
3564                    if have > pos {
3565                        break;
3566                    }
3567                    let id = nid.next().unwrap_or(0);
3568                    let n = have + 1;
3569                    let name = format!("{}{}", if kind == TrackKind::Video { "V" } else { "A" }, n);
3570                    let t = Track::new(id, kind, name);
3571                    let idx = if kind == TrackKind::Video {
3572                        seq.tracks.iter().rposition(|t| t.kind == TrackKind::Video).map(|i| i + 1).unwrap_or(0)
3573                    } else {
3574                        seq.tracks.len()
3575                    };
3576                    seq.tracks.insert(idx, t);
3577                }
3578                let ti =
3579                    seq.tracks.iter().enumerate().filter(|(_, t)| t.kind == kind).nth(pos).map(|(i, _)| i).unwrap_or(0);
3580                seq.tracks[ti].clips.push(c);
3581                seq.tracks[ti].sort();
3582            }
3583        }
3584        let vt = top_video.or_else(|| self.video_tracks().last().copied());
3585        self.insert_sequence_clip(seq_id, start, vt);
3586        Some(seq_id)
3587    }
3588
3589    // ---------- labels ----------
3590    /// Effective colour label of a clip (its own, else its asset's). 0 = none.
3591    pub fn clip_label(&self, clip: &Clip) -> u8 {
3592        if clip.label != 0 {
3593            return clip.label;
3594        }
3595        if clip.uses_asset() {
3596            return self.asset(clip.asset).map(|a| a.label).unwrap_or(0);
3597        }
3598        0
3599    }
3600
3601    // ---------- planner ----------
3602    fn plan_find_in(items: &mut [PlanItem], id: Id) -> Option<&mut PlanItem> {
3603        for it in items {
3604            if it.id == id {
3605                return Some(it);
3606            }
3607            if let Some(f) = Self::plan_find_in(&mut it.children, id) {
3608                return Some(f);
3609            }
3610        }
3611        None
3612    }
3613    pub fn plan_item_mut(&mut self, id: Id) -> Option<&mut PlanItem> {
3614        Self::plan_find_in(&mut self.plan, id)
3615    }
3616    /// Add an item (under `parent` or at the top level); returns its id.
3617    pub fn plan_add(&mut self, parent: Option<Id>, title: impl Into<String>) -> Id {
3618        let id = self.new_id();
3619        let item = PlanItem { id, title: title.into(), ..Default::default() };
3620        match parent.and_then(|p| self.plan_item_mut(p)) {
3621            Some(p) => p.children.push(item),
3622            None => self.plan.push(item),
3623        }
3624        id
3625    }
3626    pub fn plan_remove(&mut self, id: Id) {
3627        fn rm(items: &mut Vec<PlanItem>, id: Id) {
3628            items.retain(|i| i.id != id);
3629            for i in items {
3630                rm(&mut i.children, id);
3631            }
3632        }
3633        rm(&mut self.plan, id);
3634    }
3635    /// All asset ids referenced by moodboards (never counted as "unused").
3636    pub fn plan_assets(&self) -> std::collections::HashSet<Id> {
3637        fn walk(items: &[PlanItem], out: &mut std::collections::HashSet<Id>) {
3638            for i in items {
3639                out.extend(i.assets.iter().copied());
3640                walk(&i.children, out);
3641            }
3642        }
3643        let mut s = std::collections::HashSet::new();
3644        walk(&self.plan, &mut s);
3645        s
3646    }
3647
3648    // ---------- usage ----------
3649    /// Asset ids referenced by any clip (main timeline, stash, every sequence).
3650    pub fn used_assets(&self) -> std::collections::HashSet<Id> {
3651        let mut s = std::collections::HashSet::new();
3652        let mut add = |tracks: &[Track]| {
3653            for c in tracks.iter().flat_map(|t| t.clips.iter()) {
3654                if c.uses_asset() {
3655                    s.insert(c.asset);
3656                }
3657            }
3658        };
3659        add(&self.tracks);
3660        if let Some(st) = &self.main_stash {
3661            add(&st.tracks);
3662        }
3663        for seq in &self.sequences {
3664            add(&seq.tracks);
3665        }
3666        s
3667    }
3668    /// Remove assets no clip uses (planner moodboard assets are kept). Returns how many were removed.
3669    pub fn remove_unused_assets(&mut self) -> usize {
3670        let used = self.used_assets();
3671        let planned = self.plan_assets();
3672        let before = self.assets.len();
3673        self.assets.retain(|a| used.contains(&a.id) || planned.contains(&a.id));
3674        before - self.assets.len()
3675    }
3676
3677    // ---------- auto-cut ----------
3678    /// Split the clips (+ linked) at every time in `cuts`, then delete the pieces lying inside any of the
3679    /// `remove` ranges (timeline times, half-open), optionally closing the gaps (ripple). Returns the number
3680    /// of pieces removed.
3681    pub fn auto_cut(&mut self, ids: &[Id], cuts: &[f64], remove: &[(f64, f64)], ripple: bool) -> usize {
3682        let ids = self.expand_links(ids);
3683        let mut group = ids.clone();
3684        for &t in cuts {
3685            let new = self.split_at(t, Some(&group));
3686            group.extend(new);
3687        }
3688        let victims: Vec<Id> = group
3689            .iter()
3690            .filter_map(|&id| self.clip(id))
3691            .filter(|c| {
3692                let mid = c.start + c.duration / 2.0;
3693                remove.iter().any(|&(a, b)| mid >= a && mid < b)
3694            })
3695            .map(|c| c.id)
3696            .collect();
3697        let n = victims.len();
3698        if n > 0 {
3699            self.delete_clips(&victims, ripple);
3700        }
3701        n
3702    }
3703
3704    // ---------- templates ----------
3705    /// Place a saved group of clips (times relative to the group start) at `at`. Assets are re-added by
3706    /// path (ids remapped); clip/link ids are fresh; tracks chosen by kind in order (new ones when blocked).
3707    pub fn place_clips(&mut self, clips: Vec<Clip>, assets: Vec<Asset>, at: f64) -> Vec<Id> {
3708        let mut asset_map: std::collections::HashMap<Id, Id> = std::collections::HashMap::new();
3709        for a in assets {
3710            let old = a.id;
3711            let new = self.add_asset(a);
3712            asset_map.insert(old, new);
3713        }
3714        let mut link_map: std::collections::HashMap<Id, Id> = std::collections::HashMap::new();
3715        let mut out = Vec::new();
3716        for mut c in clips {
3717            c.id = self.new_id();
3718            // clip markers carry ids too: a copy that kept them would shadow the original in `marker_mut`
3719            for i in 0..c.markers.len() {
3720                c.markers[i].id = self.new_id();
3721            }
3722            if c.link != 0 {
3723                let l = *link_map.entry(c.link).or_insert_with(|| 0);
3724                c.link = if l == 0 {
3725                    let nl = self.new_id();
3726                    link_map.insert(c.link, nl);
3727                    nl
3728                } else {
3729                    l
3730                };
3731            }
3732            if c.uses_asset() && c.asset != 0 {
3733                match asset_map.get(&c.asset) {
3734                    Some(&a) => c.asset = a,
3735                    None => continue,
3736                }
3737            }
3738            if c.kind == ClipKind::Sequence && self.sequence(c.sequence).is_none() {
3739                continue;
3740            }
3741            c.start += at;
3742            let kind = if c.kind == ClipKind::Audio { TrackKind::Audio } else { TrackKind::Video };
3743            let ti = self.find_free_track(kind, c.start, c.duration, None);
3744            out.push(c.id);
3745            self.tracks[ti].clips.push(c);
3746            self.tracks[ti].sort();
3747        }
3748        out
3749    }
3750
3751    // ---------- motion helpers ----------
3752    /// Make two abutting clips "flow": for every visual property animated on either clip, the outgoing
3753    /// value/velocity of `a` continues into `b` (a gets an ease-in to the cut, b an ease-out from it, and
3754    /// both meet at the same value at the cut). Returns false if the clips don't abut.
3755    pub fn flow_clips(&mut self, a: Id, b: Id) -> bool {
3756        let (Some(ca), Some(cb)) = (self.clip(a).cloned(), self.clip(b).cloned()) else { return false };
3757        if (ca.end() - cb.start).abs() > ABUT_EPS {
3758            return false;
3759        }
3760        let props = ["x", "y", "scale", "rotation", "opacity"];
3761        fn pick(c: &mut Clip, i: usize) -> &mut Animated {
3762            match i {
3763                0 => &mut c.x,
3764                1 => &mut c.y,
3765                2 => &mut c.scale,
3766                3 => &mut c.rotation,
3767                _ => &mut c.opacity,
3768            }
3769        }
3770        let (da, db) = (ca.duration, cb.duration);
3771        let mut na = ca.clone();
3772        let mut nb = cb.clone();
3773        for i in 0..props.len() {
3774            let va_end = pick(&mut na, i).at(da);
3775            let vb_start = pick(&mut nb, i).at(0.0);
3776            let animated = pick(&mut na, i).is_animated() || pick(&mut nb, i).is_animated();
3777            if !animated {
3778                continue;
3779            }
3780            let meet = (va_end + vb_start) / 2.0;
3781            let pa = pick(&mut na, i);
3782            if !pa.is_animated() {
3783                pa.toggle_key(0.0);
3784            }
3785            pa.set_at(da, meet);
3786            if let Some(k) = pa.keys.iter_mut().rev().nth(1) {
3787                k.ease = Ease::EaseIn;
3788            }
3789            let pb = pick(&mut nb, i);
3790            if !pb.is_animated() {
3791                pb.toggle_key(db);
3792            }
3793            pb.set_at(0.0, meet);
3794            if let Some(k) = pb.keys.first_mut() {
3795                k.ease = Ease::EaseOut;
3796            }
3797        }
3798        *self.clip_mut(a).unwrap() = na;
3799        *self.clip_mut(b).unwrap() = nb;
3800        true
3801    }
3802
3803    // ---------- labels ----------
3804    /// Colour of label index `idx` (1-based; 0 or unknown = None).
3805    pub fn label_color(&self, idx: u8) -> Option<[u8; 3]> {
3806        (idx > 0).then(|| self.labels.get(idx as usize - 1).map(|l| l.color)).flatten()
3807    }
3808    pub fn label_name(&self, idx: u8) -> &str {
3809        if idx == 0 {
3810            return "None";
3811        }
3812        self.labels.get(idx as usize - 1).map(|l| l.name.as_str()).unwrap_or("None")
3813    }
3814    /// Add a label; returns its 1-based index.
3815    pub fn add_label(&mut self, name: impl Into<String>, color: [u8; 3]) -> u8 {
3816        self.labels.push(Label { name: name.into(), color });
3817        self.labels.len() as u8
3818    }
3819    /// Remove a label; clips/assets/markers using it fall back to "none", higher indices shift down.
3820    pub fn remove_label(&mut self, idx: u8) {
3821        if idx == 0 || idx as usize > self.labels.len() {
3822            return;
3823        }
3824        self.labels.remove(idx as usize - 1);
3825        let fix = |l: &mut u8| {
3826            if *l == idx {
3827                *l = 0;
3828            } else if *l > idx {
3829                *l -= 1;
3830            }
3831        };
3832        for a in &mut self.assets {
3833            fix(&mut a.label);
3834        }
3835        for m in &mut self.markers {
3836            fix(&mut m.label);
3837        }
3838        let mut all: Vec<&mut Track> = self.tracks.iter_mut().collect();
3839        if let Some(st) = &mut self.main_stash {
3840            all.extend(st.tracks.iter_mut());
3841        }
3842        for sq in &mut self.sequences {
3843            all.extend(sq.tracks.iter_mut());
3844        }
3845        for t in all {
3846            for c in &mut t.clips {
3847                fix(&mut c.label);
3848                for m in &mut c.markers {
3849                    fix(&mut m.label);
3850                }
3851            }
3852        }
3853    }
3854
3855    // ---------- markers ----------
3856    pub fn add_marker(&mut self, t: f64, name: impl Into<String>) -> Id {
3857        let id = self.new_id();
3858        self.markers.push(Marker { id, t: t.max(0.0), name: name.into(), ..Default::default() });
3859        self.sort_markers();
3860        id
3861    }
3862    pub fn remove_marker(&mut self, id: Id) {
3863        self.markers.retain(|m| m.id != id);
3864        for t in &mut self.tracks {
3865            for c in &mut t.clips {
3866                c.markers.retain(|m| m.id != id);
3867            }
3868        }
3869    }
3870    pub fn marker_mut(&mut self, id: Id) -> Option<&mut Marker> {
3871        if let Some(i) = self.markers.iter().position(|m| m.id == id) {
3872            return self.markers.get_mut(i);
3873        }
3874        self.tracks.iter_mut().flat_map(|t| t.clips.iter_mut()).flat_map(|c| c.markers.iter_mut()).find(|m| m.id == id)
3875    }
3876    pub fn sort_markers(&mut self) {
3877        self.markers.sort_by(|a, b| a.t.total_cmp(&b.t));
3878    }
3879    /// Add a marker on a clip (time is clip-local).
3880    pub fn add_clip_marker(&mut self, clip: Id, local_t: f64, name: impl Into<String>) -> Option<Id> {
3881        let id = self.new_id();
3882        let name = name.into();
3883        let c = self.clip_mut(clip)?;
3884        c.markers.push(Marker { id, t: local_t.clamp(0.0, c.duration), name, ..Default::default() });
3885        c.markers.sort_by(|a, b| a.t.total_cmp(&b.t));
3886        Some(id)
3887    }
3888    /// Every marker in timeline time: project markers plus clip markers offset by their clip.
3889    pub fn markers_in_timeline(&self) -> Vec<(Id, f64, f64, String, u8)> {
3890        let mut v: Vec<(Id, f64, f64, String, u8)> =
3891            self.markers.iter().map(|m| (m.id, m.t, m.duration, m.name.clone(), m.label)).collect();
3892        for (_, c) in self.all_clips() {
3893            for m in &c.markers {
3894                v.push((m.id, c.start + m.t, m.duration, m.name.clone(), m.label));
3895            }
3896        }
3897        v.sort_by(|a, b| a.1.total_cmp(&b.1));
3898        v
3899    }
3900
3901    // ---------- buses ----------
3902    /// The Main bus id, creating the default bus set on first use.
3903    pub fn main_bus(&mut self) -> Id {
3904        if self.buses.is_empty() {
3905            let id = self.new_id();
3906            self.buses.push(Bus { id, name: "Main".into(), output: 0, ..Default::default() });
3907        }
3908        self.buses[0].id
3909    }
3910    pub fn bus(&self, id: Id) -> Option<&Bus> {
3911        self.buses.iter().find(|b| b.id == id)
3912    }
3913    pub fn bus_mut(&mut self, id: Id) -> Option<&mut Bus> {
3914        self.buses.iter_mut().find(|b| b.id == id)
3915    }
3916    pub fn add_bus(&mut self, name: impl Into<String>) -> Id {
3917        let main = self.main_bus();
3918        let id = self.new_id();
3919        self.buses.push(Bus { id, name: name.into(), output: main, ..Default::default() });
3920        id
3921    }
3922    /// Remove a bus (never Main); tracks/clips and sends fall back to Main.
3923    pub fn remove_bus(&mut self, id: Id) {
3924        let main = self.main_bus();
3925        if id == main {
3926            return;
3927        }
3928        self.buses.retain(|b| b.id != id);
3929        for b in &mut self.buses {
3930            if b.output == id {
3931                b.output = main;
3932            }
3933        }
3934        for t in &mut self.tracks {
3935            if t.bus == id {
3936                t.bus = 0;
3937            }
3938            for c in &mut t.clips {
3939                if c.bus == id {
3940                    c.bus = 0;
3941                }
3942            }
3943        }
3944    }
3945    /// Which bus a clip feeds: its own override, else its track's, else Main.
3946    pub fn bus_of(&self, track: usize, clip: &Clip) -> Id {
3947        let main = self.buses.first().map(|b| b.id).unwrap_or(0);
3948        if clip.bus != 0 && self.bus(clip.bus).is_some() {
3949            return clip.bus;
3950        }
3951        let t = self.tracks.get(track).map(|t| t.bus).unwrap_or(0);
3952        if t != 0 && self.bus(t).is_some() {
3953            t
3954        } else {
3955            main
3956        }
3957    }
3958
3959    // ---------- shapes / adjustment layers ----------
3960    /// Add a shape clip on the topmost video track that has room.
3961    pub fn add_shape_clip(&mut self, kind: ShapeKind, at: f64, dur: f64) -> Id {
3962        let prefer = self.video_tracks().last().copied();
3963        let ti = self.find_free_track(TrackKind::Video, at, dur, prefer);
3964        let mut c = Clip::new(self.new_id(), ClipKind::Shape, kind.name(), at, dur);
3965        c.shape = Some(ShapeStyle::new(kind));
3966        let id = c.id;
3967        self.tracks[ti].clips.push(c);
3968        self.tracks[ti].sort();
3969        id
3970    }
3971    /// Add an adjustment (automation) layer above everything at `at`.
3972    pub fn add_adjustment_clip(&mut self, at: f64, dur: f64) -> Id {
3973        let prefer = self.video_tracks().last().copied();
3974        let ti = self.find_free_track(TrackKind::Video, at, dur, prefer);
3975        let c = Clip::new(self.new_id(), ClipKind::Adjustment, "Adjustment", at, dur);
3976        let id = c.id;
3977        self.tracks[ti].clips.push(c);
3978        self.tracks[ti].sort();
3979        id
3980    }
3981    /// Add a container clip pair (video slot + audio slot linked together) at `at`.
3982    pub fn add_container_clip(&mut self, at: f64, dur: f64) -> (Id, Id) {
3983        let dur = dur.max(MIN_CLIP);
3984        let link = self.new_id();
3985        let prefer_v = self.video_tracks().last().copied();
3986        let vti = self.find_free_track(TrackKind::Video, at, dur, prefer_v);
3987        let mut vc = Clip::new(self.new_id(), ClipKind::Video, "Container", at, dur);
3988        vc.link = link;
3989        vc.container = true;
3990        let vid = vc.id;
3991        self.tracks[vti].clips.push(vc);
3992        self.tracks[vti].sort();
3993
3994        let prefer_a = self.audio_tracks().first().copied();
3995        let ati = self.find_free_track(TrackKind::Audio, at, dur, prefer_a);
3996        let mut ac = Clip::new(self.new_id(), ClipKind::Audio, "Container Audio", at, dur);
3997        ac.link = link;
3998        ac.container = true;
3999        let aid = ac.id;
4000        self.tracks[ati].clips.push(ac);
4001        self.tracks[ati].sort();
4002
4003        (vid, aid)
4004    }
4005    /// Insert asset clips marked as containers (slots).
4006    pub fn add_container_from_asset(&mut self, asset_id: Id, at: f64, video_track: Option<usize>) -> Vec<Id> {
4007        let ids = self.insert_asset_clips(asset_id, at, video_track);
4008        for &id in &ids {
4009            if let Some(c) = self.clip_mut(id) {
4010                c.container = true;
4011            }
4012        }
4013        ids
4014    }
4015    /// Replace the media of a container clip. Effects, transforms, keyframes, transitions and timeline duration are kept.
4016    pub fn replace_container_media(&mut self, clip_id: Id, new_asset_id: Id) -> bool {
4017        let Some(asset) = self.asset(new_asset_id).cloned() else { return false };
4018        let Some(clip) = self.clip_mut(clip_id) else { return false };
4019        if !clip.container {
4020            return false;
4021        }
4022        clip.asset = asset.id;
4023        clip.src_in = 0.0;
4024        if clip.kind == ClipKind::Audio {
4025            clip.audio_stream = 0;
4026        } else if asset.kind == ClipKind::Image {
4027            clip.kind = ClipKind::Image;
4028        } else if asset.kind == ClipKind::Video {
4029            clip.kind = ClipKind::Video;
4030        }
4031        let base_name = asset.name();
4032        clip.name = if !clip.container_label.is_empty() {
4033            format!("{} [{}]", base_name, clip.container_label)
4034        } else {
4035            base_name
4036        };
4037        true
4038    }
4039    /// Replace a video container clip and any linked audio container clips with the new asset.
4040    pub fn replace_container_pair(&mut self, video_id: Id, new_asset_id: Id) -> bool {
4041        let Some(asset) = self.asset(new_asset_id).cloned() else { return false };
4042        let Some(vclip) = self.clip(video_id).cloned() else { return false };
4043        if !vclip.container {
4044            return false;
4045        }
4046        let link = vclip.link;
4047        if !self.replace_container_media(video_id, new_asset_id) {
4048            return false;
4049        }
4050        if link != 0 {
4051            let mut stream_idx = 0;
4052            let linked_ids: Vec<Id> =
4053                self.all_clips().filter(|(_, c)| c.link == link && c.id != video_id).map(|(_, c)| c.id).collect();
4054            for aid in linked_ids {
4055                if let Some(c) = self.clip_mut(aid) {
4056                    if c.kind == ClipKind::Audio && c.container {
4057                        c.asset = asset.id;
4058                        c.src_in = 0.0;
4059                        if stream_idx < asset.audio_streams.len() {
4060                            c.audio_stream = asset.audio_streams[stream_idx].index;
4061                            stream_idx += 1;
4062                        } else {
4063                            c.audio_stream = 0;
4064                        }
4065                        let base_name = if asset.audio_streams.len() > 1 {
4066                            format!(
4067                                "{} [{}]",
4068                                asset.name(),
4069                                asset.audio_streams.get(c.audio_stream).map(|s| s.label()).unwrap_or_default()
4070                            )
4071                        } else {
4072                            asset.name()
4073                        };
4074                        c.name = if !c.container_label.is_empty() {
4075                            format!("{} [{}]", base_name, c.container_label)
4076                        } else {
4077                            base_name
4078                        };
4079                    }
4080                }
4081            }
4082        }
4083        true
4084    }
4085    /// Convert clips to containers (slots). Linked clips are included.
4086    pub fn make_container(&mut self, clip_ids: &[Id]) {
4087        let all = self.expand_links(clip_ids);
4088        for id in all {
4089            if let Some(c) = self.clip_mut(id) {
4090                c.container = true;
4091            }
4092        }
4093    }
4094    /// Remove container flag from clips. Linked clips are included.
4095    pub fn unmake_container(&mut self, clip_ids: &[Id]) {
4096        let all = self.expand_links(clip_ids);
4097        for id in all {
4098            if let Some(c) = self.clip_mut(id) {
4099                c.container = false;
4100                c.container_label.clear();
4101            }
4102        }
4103    }
4104
4105    // ---------- reusable paths ----------
4106    pub fn path(&self, id: Id) -> Option<&PathAsset> {
4107        self.paths.iter().find(|p| p.id == id)
4108    }
4109    /// Keep a path on the project. An empty name gets a numbered one.
4110    pub fn add_path(&mut self, name: String, points: Vec<(f32, f32, f32)>) -> Id {
4111        let id = self.new_id();
4112        let name = if name.trim().is_empty() { format!("Path {}", self.paths.len() + 1) } else { name };
4113        self.paths.push(PathAsset { id, name, points });
4114        id
4115    }
4116    /// A clip's drawing / polygon outline in canvas coordinates — the clip's own position is folded in,
4117    /// so the path lands where the sketch is on screen.
4118    pub fn path_from_clip(&self, clip: Id) -> Vec<(f32, f32, f32)> {
4119        let Some(c) = self.clip(clip) else { return Vec::new() };
4120        let (ox, oy) = (c.x.value as f32, c.y.value as f32);
4121        let Some(s) = &c.shape else { return Vec::new() };
4122        s.path_points().into_iter().map(|(x, y, t)| (x + ox, y + oy, t)).collect()
4123    }
4124    /// Drive a clip's X/Y along a path over the clip's own length. False when the path is too short to
4125    /// animate anything.
4126    pub fn apply_path(&mut self, clip: Id, points: &[(f32, f32, f32)]) -> bool {
4127        let Some(dur) = self.clip(clip).map(|c| c.duration) else { return false };
4128        let (x, y) = path_to_keys(points, dur);
4129        if x.keys.len() < 2 {
4130            return false;
4131        }
4132        let Some(c) = self.clip_mut(clip) else { return false };
4133        c.x = x;
4134        c.y = y;
4135        true
4136    }
4137
4138    // ---------- node graphs ----------
4139    /// Give the clip a node graph built from its current effect stack (idempotent).
4140    pub fn ensure_graph(&mut self, clip: Id) -> bool {
4141        let Some(c) = self.clip(clip) else { return false };
4142        if c.graph.is_some() {
4143            return true;
4144        }
4145        let effects = c.effects.clone();
4146        let mut next = || {
4147            self.next_id += 1;
4148            self.next_id
4149        };
4150        let g = NodeGraph::from_effects(&effects, &mut next);
4151        if let Some(c) = self.clip_mut(clip) {
4152            c.graph = Some(g);
4153        }
4154        true
4155    }
4156    /// Drop a clip's node graph back onto its linear effect stack; returns how many effects landed.
4157    /// Err when the graph is more than a chain (the caller toasts it) — nothing is touched then.
4158    /// Drop a clip's node graph entirely, keeping whatever of it can be expressed as an effect chain.
4159    /// A graph that will not linearise (a branch, a cycle) still goes: "unlink" means the graph is gone
4160    /// and the clip is back on its effect list, so leaving the nodes in place would be the one outcome
4161    /// the user did not ask for. Err only when there was no graph to begin with.
4162    pub fn unlink_graph(&mut self, clip: Id) -> Result<usize, String> {
4163        let g = self.clip(clip).and_then(|c| c.graph.as_ref()).ok_or("that clip has no node graph")?;
4164        let converted = g.to_effects().ok();
4165        let c = self.clip_mut(clip).ok_or("that clip is gone")?;
4166        let n = match converted {
4167            Some(effects) => {
4168                let n = effects.len();
4169                c.effects = effects;
4170                n
4171            }
4172            // nothing salvageable: the clip keeps the effects it already had
4173            None => 0,
4174        };
4175        c.graph = None;
4176        Ok(n)
4177    }
4178    /// Add a node to a clip's graph at editor position (x, y); returns its id.
4179    pub fn add_node(&mut self, clip: Id, kind: NodeKind, x: f32, y: f32) -> Option<Id> {
4180        self.ensure_graph(clip);
4181        let id = self.new_id();
4182        let c = self.clip_mut(clip)?;
4183        let g = c.graph.as_mut()?;
4184        g.nodes.push(Node { id, kind, x, y, enabled: true });
4185        Some(id)
4186    }
4187
4188    // ---------- copy / paste attributes ----------
4189    /// Snapshot of a clip to paste attributes from (Ctrl+Alt+C).
4190    pub fn copy_attributes(&self, clip: Id) -> Option<Clip> {
4191        self.clip(clip).cloned()
4192    }
4193    /// Apply the selected attributes of `src` onto `ids` (timing and media are never touched).
4194    /// Returns how many clips changed.
4195    pub fn paste_attributes(&mut self, src: &Clip, ids: &[Id], set: AttrSet) -> usize {
4196        let mut n = 0;
4197        for &id in ids {
4198            let Some(c) = self.clip_mut(id) else { continue };
4199            if c.id == src.id {
4200                continue;
4201            }
4202            if set.transform {
4203                c.x = src.x.clone();
4204                c.y = src.y.clone();
4205                c.scale = src.scale.clone();
4206                c.rotation = src.rotation.clone();
4207            }
4208            if set.opacity {
4209                c.opacity = src.opacity.clone();
4210            }
4211            if set.blend {
4212                c.blend = src.blend;
4213            }
4214            if set.effects {
4215                c.effects = src.effects.clone();
4216            }
4217            if set.graph {
4218                c.graph = src.graph.clone();
4219            }
4220            if set.mask {
4221                c.mask = src.mask.clone();
4222            }
4223            if set.speed {
4224                c.set_speed(src.speed);
4225                c.reverse = src.reverse;
4226                c.freeze = src.freeze;
4227            }
4228            if set.audio {
4229                c.volume = src.volume.clone();
4230                c.pan = src.pan.clone();
4231                c.fade_in = src.fade_in;
4232                c.fade_out = src.fade_out;
4233                c.bus = src.bus;
4234            }
4235            if set.text {
4236                if let Some(t) = &src.text {
4237                    c.text = Some(t.clone());
4238                }
4239            }
4240            if set.shape {
4241                if let Some(sh) = &src.shape {
4242                    c.shape = Some(sh.clone());
4243                }
4244            }
4245            if set.label {
4246                c.label = src.label;
4247            }
4248            if set.markers {
4249                c.markers = src.markers.clone();
4250            }
4251            n += 1;
4252        }
4253        if n > 0 {
4254            self.tidy();
4255        }
4256        n
4257    }
4258
4259    // ---------- persistence ----------
4260    pub fn to_json(&self) -> String {
4261        serde_json::to_string_pretty(self).unwrap_or_default()
4262    }
4263    pub fn from_json(s: &str) -> Result<Self, String> {
4264        let mut p: Project = serde_json::from_str(s).map_err(|e| e.to_string())?;
4265        let max_id = p
4266            .assets
4267            .iter()
4268            .map(|a| a.id)
4269            .chain(p.tracks.iter().map(|t| t.id))
4270            .chain(p.tracks.iter().flat_map(|t| t.transitions.iter().map(|x| x.id)))
4271            .chain(p.subtitles.iter().map(|c| c.id))
4272            .chain(p.markers.iter().map(|m| m.id))
4273            .chain(p.buses.iter().map(|b| b.id))
4274            .chain(p.all_clips().flat_map(|(_, c)| c.markers.iter().map(|m| m.id)))
4275            .chain(p.all_clips().filter_map(|(_, c)| c.graph.as_ref()).flat_map(|g| g.nodes.iter().map(|n| n.id)))
4276            .chain(p.sequences.iter().map(|s| s.id))
4277            .chain(p.sequences.iter().flat_map(|s| s.tracks.iter().map(|t| t.id)))
4278            .chain(
4279                p.sequences.iter().flat_map(|s| s.tracks.iter().flat_map(|t| t.clips.iter().map(|c| c.id.max(c.link)))),
4280            )
4281            .chain(
4282                p.main_stash
4283                    .iter()
4284                    .flat_map(|s| s.tracks.iter().flat_map(|t| t.clips.iter().map(|c| c.id.max(c.link)))),
4285            )
4286            .chain(p.all_clips().map(|(_, c)| c.id.max(c.link)))
4287            .max()
4288            .unwrap_or(0);
4289        fn plan_max(items: &[PlanItem]) -> Id {
4290            items.iter().map(|i| i.id.max(plan_max(&i.children))).max().unwrap_or(0)
4291        }
4292        p.next_id = p.next_id.max(max_id).max(plan_max(&p.plan));
4293        if p.editing.is_some_and(|id| p.sequence(id).is_none()) {
4294            p.editing = None;
4295        }
4296        // hand-edited files: keep every value in the range the UI can produce (fps 0 would make NaN times)
4297        if !(p.fps >= 1.0 && p.fps <= 1000.0) {
4298            p.fps = 30.0;
4299        }
4300        p.width = p.width.max(16);
4301        p.height = p.height.max(16);
4302        for t in &mut p.tracks {
4303            t.clips.retain(|c| c.start >= 0.0 && c.duration.is_finite() && c.duration > 0.0);
4304            for c in &mut t.clips {
4305                if !(c.speed.is_finite() && c.speed > 0.0) {
4306                    c.speed = 1.0;
4307                }
4308                c.speed_curve.keys.retain(|k| k.t.is_finite() && k.v.is_finite() && k.v > 0.0);
4309                if !c.speed_curve.is_animated() {
4310                    c.speed_curve.value = c.speed; // older files have no curve at all
4311                }
4312                for e in &mut c.effects {
4313                    // older files / edited files: make sure every parameter exists
4314                    while e.params.len() < e.kind.params().len() {
4315                        let d = e.kind.params()[e.params.len()].default;
4316                        e.params.push(Animated::new(d));
4317                    }
4318                }
4319            }
4320        }
4321        p.subtitles.retain(|c| c.start.is_finite() && c.end.is_finite() && c.end > c.start);
4322        if p.labels.is_empty() {
4323            p.labels = default_labels();
4324        }
4325        p.markers.retain(|m| m.t.is_finite() && m.t >= 0.0);
4326        p.sort_markers();
4327        p.tidy();
4328        p.sort_cues();
4329        Ok(p)
4330    }
4331    /// Writes beside the target then renames, so a failed write can't destroy the previous save.
4332    pub fn save(&self, path: &Path) -> std::io::Result<()> {
4333        let mut tmp = path.as_os_str().to_owned();
4334        tmp.push(".tmp");
4335        std::fs::write(&tmp, self.to_json())?;
4336        std::fs::rename(&tmp, path)
4337    }
4338    pub fn load(path: &Path) -> Result<Self, String> {
4339        let s = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
4340        Self::from_json(&s)
4341    }
4342}
4343
4344#[cfg(test)]
4345mod tests {
4346    use super::*;
4347
4348    fn asset(id: Id, dur: f64, streams: usize) -> Asset {
4349        Asset {
4350            id,
4351            path: format!("C:/v{id}.mp4"),
4352            kind: ClipKind::Video,
4353            duration: dur,
4354            width: 1280,
4355            height: 720,
4356            fps: 30.0,
4357            audio_streams: (0..streams)
4358                .map(|i| AudioStreamInfo { index: i, channels: 2, sample_rate: 48000, ..Default::default() })
4359                .collect(),
4360            codec: "h264".into(),
4361            folder: String::new(),
4362            tags: Vec::new(),
4363            label: 0,
4364            description: String::new(),
4365        }
4366    }
4367
4368    #[test]
4369    fn animated_interp() {
4370        let mut a = Animated::new(5.0);
4371        assert_eq!(a.at(3.0), 5.0);
4372        a.toggle_key(0.0);
4373        a.set_at(2.0, 15.0);
4374        assert_eq!(a.keys.len(), 2);
4375        assert!((a.at(1.0) - 10.0).abs() < 1e-9);
4376        assert_eq!(a.at(-1.0), 5.0);
4377        assert_eq!(a.at(9.0), 15.0);
4378        a.toggle_key(2.0);
4379        a.toggle_key(0.0);
4380        assert!(!a.is_animated());
4381        assert_eq!(a.value, 5.0);
4382    }
4383
4384    #[test]
4385    fn easing_and_key_moves() {
4386        let mut a = Animated::new(0.0);
4387        a.toggle_key(0.0);
4388        a.set_at(2.0, 10.0);
4389        a.set_ease_at(0.0, Ease::Hold);
4390        assert_eq!(a.at(1.0), 0.0);
4391        a.set_ease_at(0.0, Ease::EaseInOut);
4392        assert!((a.at(1.0) - 5.0).abs() < 1e-9);
4393        assert!(a.at(0.5) < 2.5);
4394        a.set_ease_at(0.0, Ease::EaseIn);
4395        assert!((a.at(1.0) - 2.5).abs() < 1e-9);
4396        let j = a.move_key(1, -1.0); // moves before the first key and stays sorted
4397        assert_eq!(j, 0);
4398        assert_eq!(a.keys[0].v, 10.0);
4399    }
4400
4401    #[test]
4402    fn from_media_layout() {
4403        let p = Project::from_media(asset(0, 10.0, 2));
4404        assert_eq!(p.width, 1280);
4405        assert_eq!(p.tracks.len(), 3); // V1 A1 A2
4406        assert_eq!(p.tracks[0].clips.len(), 1);
4407        assert_eq!(p.tracks[2].clips[0].audio_stream, 1);
4408        let v = &p.tracks[0].clips[0];
4409        assert_eq!(p.linked(v.id).len(), 3);
4410        assert_eq!(p.duration(), 10.0);
4411        assert!(p.source_video.is_some());
4412    }
4413
4414    #[test]
4415    fn split_delete_ripple() {
4416        let mut p = Project::from_media(asset(0, 10.0, 1));
4417        let new = p.split_at(4.0, None);
4418        assert_eq!(new.len(), 2);
4419        assert_eq!(p.tracks[0].clips.len(), 2);
4420        assert!((p.tracks[0].clips[1].src_in - 4.0).abs() < 1e-9);
4421        // right halves linked to each other but not to left halves
4422        assert_eq!(p.linked(new[0]).len(), 2);
4423        assert!(p.linked(new[0]).contains(&new[1]));
4424        let left = p.tracks[0].clips[0].id;
4425        assert!(!p.linked(left).contains(&new[0]));
4426        // ripple delete the left halves
4427        let ids = p.linked(left);
4428        p.delete_clips(&ids, true);
4429        assert_eq!(p.tracks[0].clips.len(), 1);
4430        assert!((p.tracks[0].clips[0].start).abs() < 1e-9);
4431        assert!((p.tracks[1].clips[0].start).abs() < 1e-9);
4432        assert!((p.duration() - 6.0).abs() < 1e-9);
4433    }
4434
4435    #[test]
4436    fn trim_to_range() {
4437        let mut p = Project::from_media(asset(0, 10.0, 1));
4438        p.trim_to_range(2.0, 5.0);
4439        assert!((p.duration() - 3.0).abs() < 1e-9);
4440        let c = &p.tracks[0].clips[0];
4441        assert!((c.src_in - 2.0).abs() < 1e-9);
4442        assert!(c.start.abs() < 1e-9);
4443    }
4444
4445    #[test]
4446    fn move_and_fit() {
4447        let mut p = Project::from_media(asset(0, 10.0, 1));
4448        let a2 = asset(1, 3.0, 0);
4449        let aid = p.add_asset(a2);
4450        let ids = p.insert_asset_clips(aid, 10.0, Some(0));
4451        assert_eq!(ids.len(), 1);
4452        assert_eq!(p.track_of(ids[0]), Some(0));
4453        // can't move onto the first clip
4454        assert!(!p.move_clips(&ids, -5.0, 0, None));
4455        // can move right
4456        assert!(p.move_clips(&ids, 5.0, 0, None));
4457        assert!((p.clip(ids[0]).unwrap().start - 15.0).abs() < 1e-9);
4458        // move to a new video track: none exists -> false
4459        assert!(!p.move_clips(&ids, 0.0, 1, Some(TrackKind::Video)));
4460        p.add_track(TrackKind::Video);
4461        assert!(p.move_clips(&ids, -15.0, 1, Some(TrackKind::Video)));
4462        assert_eq!(p.track_of(ids[0]), Some(1));
4463    }
4464
4465    #[test]
4466    fn json_roundtrip() {
4467        let mut p = Project::from_media(asset(0, 10.0, 2));
4468        p.add_text_clip(1.0, 2.0);
4469        p.add_cue(0.5, 1.5, "hi");
4470        let s = p.to_json();
4471        let q = Project::from_json(&s).unwrap();
4472        assert_eq!(q.tracks.len(), p.tracks.len());
4473        assert_eq!(q.to_json(), s);
4474        let mut q = q;
4475        let id = q.new_id();
4476        assert!(id > p.next_id);
4477    }
4478
4479    #[test]
4480    fn polygon_points_round_trip() {
4481        let mut p = Project::new();
4482        let id = p.add_shape_clip(ShapeKind::Polygon, 0.0, 2.0);
4483        let s = p.clip_mut(id).unwrap().shape.as_mut().unwrap();
4484        assert!(s.poly_points().is_none(), "no points = the regular n-gon");
4485        s.points = vec![(0.0, -50.0), (60.0, 40.0), (-60.0, 40.0)];
4486        let key = s.cache_key();
4487        let t = Project::from_json(&p.to_json()).unwrap().clip(id).unwrap().shape.clone().unwrap();
4488        assert_eq!(t.points, vec![(0.0, -50.0), (60.0, 40.0), (-60.0, 40.0)]);
4489        assert_eq!(t.poly_points().map(|v| v.len()), Some(3));
4490        assert_eq!(t.cache_key(), key, "a round trip is the same rasterised layer");
4491        // a dragged vertex must not reuse the cached layer
4492        let mut moved = t.clone();
4493        moved.points[1].0 += 5.0;
4494        assert_ne!(moved.cache_key(), key);
4495        // a project written before points existed still loads, as the regular n-gon
4496        let old = p.to_json().replace("\"points\"", "\"was_not_a_field\"");
4497        let o = Project::from_json(&old).unwrap();
4498        assert!(o.clip(id).unwrap().shape.as_ref().unwrap().poly_points().is_none());
4499    }
4500
4501    #[test]
4502    fn a_recorded_stroke_becomes_a_motion_path() {
4503        let mut p = Project::new();
4504        let id = p.add_shape_clip(ShapeKind::Draw, 0.0, 4.0);
4505        let s = p.clip_mut(id).unwrap().shape.as_mut().unwrap();
4506        // two takes, the second recorded 2 s in, with a still-mouse pause in the middle of the first
4507        s.strokes = vec![
4508            Stroke {
4509                color: [255; 4],
4510                width: 6.0,
4511                points: vec![(-100.0, 0.0, 0.0), (0.0, 50.0, 0.5), (0.0, 50.0, 0.5)],
4512            },
4513            Stroke { color: [255; 4], width: 6.0, points: vec![(60.0, 20.0, 2.0), (120.0, -30.0, 2.5)] },
4514        ];
4515        p.clip_mut(id).unwrap().x.value = 10.0; // the sketch's own position is part of the path
4516        let pts = p.path_from_clip(id);
4517        assert_eq!(pts.len(), 5);
4518        let mover = p.add_shape_clip(ShapeKind::Rect, 0.0, 8.0);
4519        assert!(p.apply_path(mover, &pts));
4520        let c = p.clip(mover).unwrap();
4521        assert_eq!(c.x.keys.len(), 5);
4522        for w in c.x.keys.windows(2) {
4523            assert!(w[1].t > w[0].t, "key times must increase: {:?} -> {:?}", w[0].t, w[1].t);
4524        }
4525        assert!(c.x.keys.iter().all(|k| k.t <= 8.0 + 1e-9), "the path fits the clip: {:?}", c.x.keys);
4526        // starts on the first point and ends on the last, both at the clip's own ends
4527        assert!((c.x.at(0.0) - -90.0).abs() < 1e-6 && (c.y.at(0.0) - 0.0).abs() < 1e-6);
4528        assert!((c.x.at(8.0) - 130.0).abs() < 1e-6 && (c.y.at(8.0) - -30.0).abs() < 1e-6);
4529        // a path with no clock (a polygon outline) spreads its points evenly over the clip
4530        let poly = path_to_keys(&[(0.0, -50.0, 0.0), (60.0, 40.0, 0.0), (-60.0, 40.0, 0.0)], 2.0);
4531        assert_eq!(poly.0.keys.iter().map(|k| k.t).collect::<Vec<_>>(), vec![0.0, 1.0, 2.0]);
4532        assert!(path_to_keys(&[], 3.0).0.keys.is_empty(), "an empty path animates nothing");
4533    }
4534
4535    #[test]
4536    fn trims() {
4537        let mut c = Clip::new(1, ClipKind::Video, "c", 2.0, 5.0);
4538        c.src_in = 1.0;
4539        c.opacity.toggle_key(1.0);
4540        c.trim_start(0.0, 1.0); // headroom 1 s → clamped to start - 1 = 1.0
4541        assert!((c.start - 1.0).abs() < 1e-9);
4542        assert!((c.src_in).abs() < 1e-9);
4543        assert!((c.duration - 6.0).abs() < 1e-9);
4544        assert!((c.opacity.keys[0].t - 2.0).abs() < 1e-9);
4545        c.trim_end(100.0, 10.0 - c.src_in);
4546        assert!((c.duration - 10.0).abs() < 1e-9);
4547        // images/text are unbounded on both sides
4548        let mut img = Clip::new(2, ClipKind::Image, "i", 5.0, 5.0);
4549        img.trim_start(3.0, f64::INFINITY);
4550        assert!((img.start - 3.0).abs() < 1e-9);
4551        assert!((img.duration - 7.0).abs() < 1e-9);
4552    }
4553
4554    #[test]
4555    fn speed_reverse_freeze() {
4556        let mut p = Project::from_media(asset(0, 10.0, 1));
4557        let v = p.tracks[0].clips[0].id;
4558        // 2x: source window stays [0,10), duration halves, audio follows
4559        assert!(p.set_speed(&[v], 2.0, false));
4560        let c = p.clip(v).unwrap().clone();
4561        assert!((c.duration - 5.0).abs() < 1e-9);
4562        assert!((c.src_time(1.0) - 2.0).abs() < 1e-9);
4563        assert!((p.tracks[1].clips[0].duration - 5.0).abs() < 1e-9);
4564        assert!((p.max_clip_duration(&c) - 5.0).abs() < 1e-9);
4565        // split at 2 s: right half starts at source 4 s
4566        p.split_at(2.0, None);
4567        assert!((p.tracks[0].clips[1].src_in - 4.0).abs() < 1e-9);
4568        assert!((p.tracks[0].clips[1].src_time(3.0) - 6.0).abs() < 1e-9);
4569        // reverse the right half: t=2 shows source 10, t=5 shows source 4
4570        let r = p.tracks[0].clips[1].id;
4571        assert!(p.set_speed(&[r], 2.0, true));
4572        let c = p.clip(r).unwrap().clone();
4573        assert!((c.src_time(2.0) - 10.0).abs() < 1e-9);
4574        assert!((c.src_time(5.0) - 4.0).abs() < 1e-9);
4575        // reversed: head room is what is left after src_end (nothing), max duration adds src_in/speed
4576        assert!((p.head_room(&c)).abs() < 1e-9);
4577        assert!((p.max_clip_duration(&c) - 5.0).abs() < 1e-9);
4578        // extend the right edge by 1 s → earliest source time moves 2 s earlier
4579        let mut c2 = c.clone();
4580        c2.trim_end(6.0, p.max_clip_duration(&c));
4581        assert!((c2.src_in - 2.0).abs() < 1e-9);
4582        assert!((c2.src_time(6.0) - 2.0).abs() < 1e-9);
4583        // freeze at t=3 splits and holds source 8 (2x reversed clip: 10 - 1*2)
4584        let frozen = p.freeze_at(3.0, &[r]);
4585        assert_eq!(frozen.len(), 2); // video + linked audio
4586        let f = p.clip(frozen[0]).unwrap();
4587        assert_eq!(f.freeze, Some(8.0));
4588        assert!((f.src_time(4.0) - 8.0).abs() < 1e-9);
4589        assert!(p.max_clip_duration(f).is_infinite());
4590    }
4591
4592    #[test]
4593    fn keyframed_speed_ramps_src_time() {
4594        let mut c = Clip::new(1, ClipKind::Video, "v", 2.0, 4.0);
4595        c.speed_curve.keys =
4596            vec![Keyframe { t: 0.0, v: 1.0, ease: Ease::Linear }, Keyframe { t: 4.0, v: 3.0, ease: Ease::Linear }];
4597        assert!(c.is_retimed());
4598        // local 2 s at rate 2 → source 4 s; local 3 s at rate 2.5 → source 7.5 s
4599        assert!((c.src_time(4.0) - 4.0).abs() < 1e-9, "{}", c.src_time(4.0));
4600        assert!((c.src_time(5.0) - 7.5).abs() < 1e-9, "{}", c.src_time(5.0));
4601        // no keys → the constant speed still rules
4602        c.speed_curve.keys.clear();
4603        c.set_speed(2.0);
4604        assert!((c.src_time(4.0) - 4.0).abs() < 1e-9);
4605        assert!((c.speed_curve.value - 2.0).abs() < 1e-9);
4606    }
4607
4608    #[test]
4609    fn transitions_add_and_prune() {
4610        let mut p = Project::from_media(asset(0, 10.0, 1));
4611        let right = p.split_at(4.0, None)[0];
4612        let id = p.add_transition(right, TransitionKind::CrossFade, 1.0).unwrap();
4613        assert_eq!(p.tracks[0].transitions.len(), 1);
4614        assert_eq!(p.tracks[1].transitions.len(), 1); // mirrored on the linked audio cut
4615        let (tr, l, r) = p.tracks[0].transition_at(3.9).unwrap();
4616        let (l, r) = (l.unwrap(), r.unwrap());
4617        assert_eq!(tr.id, id);
4618        assert!(l.end() == r.start);
4619        let (cut, half) = tr.cut_half(Some(l), Some(r)).unwrap();
4620        assert!((tr.progress_at(cut, half, 3.5) - 0.0).abs() < 1e-9);
4621        assert!((tr.progress_at(cut, half, 4.5) - 1.0).abs() < 1e-9);
4622        assert!(p.tracks[0].transition_at(4.6).is_none());
4623        // no left neighbour → None
4624        let first = p.tracks[0].clips[0].id;
4625        assert!(p.add_transition(first, TransitionKind::Push, 1.0).is_none());
4626        // moving the right clip away invalidates the transition
4627        assert!(p.move_clips(&p.expand_links(&[right]), 2.0, 0, None));
4628        assert!(p.tracks[0].transitions.is_empty());
4629        assert!(p.tracks[1].transitions.is_empty());
4630    }
4631
4632    #[test]
4633    fn edge_transitions_need_no_neighbour() {
4634        let mut p = Project::from_media(asset(0, 10.0, 1));
4635        let lone = p.tracks[0].clips[0].id;
4636        let id = p.add_edge_transition(lone, TransitionKind::CrossFade, 2.0, false).unwrap();
4637        assert_eq!(p.tracks[1].transitions.len(), 1, "mirrored fade on the linked audio clip");
4638        assert_eq!(p.tracks[1].transitions[0].kind, TransitionKind::CrossFade);
4639        let (tr, l, r) = p.tracks[0].transition_at(0.5).unwrap();
4640        assert_eq!((tr.id, tr.edge), (id, TransitionEdge::In));
4641        assert!(l.is_none() && r.is_some_and(|c| c.id == lone));
4642        let (cut, h) = tr.cut_half(l, r).unwrap();
4643        assert!((cut - 1.0).abs() < 1e-9 && (h - 1.0).abs() < 1e-9, "window = first 2 s of the clip");
4644        assert!((tr.progress_at(cut, h, 0.0) - 0.0).abs() < 1e-9);
4645        assert!((tr.progress_at(cut, h, 2.0) - 1.0).abs() < 1e-9);
4646        assert!(p.tracks[0].transition_at(2.5).is_none());
4647        // Out edge at the clip end, and the window is clamped to the clip
4648        let out = p.add_edge_transition(lone, TransitionKind::FadeToColor, 30.0, true).unwrap();
4649        let (tr, l, r) = p.tracks[0].transition_at(9.9).unwrap();
4650        assert_eq!(tr.id, out);
4651        assert!(l.is_some_and(|c| c.id == lone) && r.is_none());
4652        let (cut, h) = tr.cut_half(l, r).unwrap();
4653        assert!((cut - 5.0).abs() < 1e-9 && (h - 5.0).abs() < 1e-9, "clamped to the 10 s clip");
4654        // both edges live on one clip; pruning keeps them while the clip exists
4655        assert_eq!(p.tracks[0].transitions.len(), 2);
4656        p.tracks[0].prune_transitions();
4657        assert_eq!(p.tracks[0].transitions.len(), 2);
4658    }
4659
4660    #[test]
4661    fn a_cut_and_an_in_share_the_start_slot() {
4662        let mut p = Project::from_media(asset(0, 10.0, 1));
4663        let right = p.split_at(4.0, None)[0];
4664        let left = p.tracks[0].clips[0].id;
4665        p.add_edge_transition(right, TransitionKind::CrossFade, 1.0, false).unwrap();
4666        // a Cut on the same clip's start replaces the In (same spot on the track)
4667        p.add_transition(right, TransitionKind::Wipe, 1.0).unwrap();
4668        assert_eq!(p.tracks[0].transitions.len(), 1);
4669        assert_eq!(p.tracks[0].transitions[0].edge, TransitionEdge::Cut);
4670        // ... and the previous clip's Out replaces that Cut in turn (same spot again)
4671        p.add_edge_transition(left, TransitionKind::CrossFade, 1.0, true).unwrap();
4672        assert_eq!(p.tracks[0].transitions.len(), 1);
4673        assert_eq!(p.tracks[0].transitions[0].edge, TransitionEdge::Out);
4674    }
4675
4676    #[test]
4677    fn fade_mult_ramps_and_clamps() {
4678        let mut c = Clip::new(1, ClipKind::Video, "v", 0.0, 10.0);
4679        c.fade_in = 2.0;
4680        c.fade_out = 4.0;
4681        assert!((c.fade_mult(-1.0) - 0.0).abs() < 1e-9, "virtual extension holds the edge value");
4682        assert!((c.fade_mult(1.0) - 0.5).abs() < 1e-9);
4683        assert!((c.fade_mult(5.0) - 1.0).abs() < 1e-9);
4684        assert!((c.fade_mult(8.0) - 0.5).abs() < 1e-9);
4685        assert!((c.fade_mult(11.0) - 0.0).abs() < 1e-9);
4686    }
4687
4688    #[test]
4689    fn effects_params_and_keys() {
4690        let mut c = Clip::new(1, ClipKind::Video, "c", 0.0, 4.0);
4691        c.effects.push(Effect::new(EffectKind::Blur));
4692        assert_eq!(c.effects[0].at(0, 1.0), 8.0);
4693        c.effects[0].params[0].toggle_key(1.0);
4694        c.effects[0].params[0].set_at(3.0, 20.0);
4695        assert_eq!(c.key_times(), vec![1.0, 3.0]);
4696        c.move_keys(3.0, 2.0);
4697        assert_eq!(c.key_times(), vec![1.0, 2.0]);
4698        assert!(c.has_effects());
4699        let json = serde_json::to_string(&c).unwrap();
4700        let d: Clip = serde_json::from_str(&json).unwrap();
4701        assert_eq!(d.effects[0].kind, EffectKind::Blur);
4702    }
4703
4704    /// Every current kind is a pixel/GLSL effect — none apply to an audio clip yet (see the doc comment
4705    /// on `applies_to_audio`). This pins that so the effects panel's audio filter stays correct rather
4706    /// than silently drifting if a kind's classification is ever meant to change.
4707    #[test]
4708    fn no_effect_kind_applies_to_audio_yet() {
4709        assert!(EffectKind::ALL.iter().all(|k| !k.applies_to_audio()));
4710    }
4711
4712    #[test]
4713    fn subtitles_and_folders() {
4714        let mut p = Project::new();
4715        p.add_cue(2.0, 3.0, "b");
4716        p.add_cue(0.0, 1.0, "a");
4717        assert_eq!(p.subtitles[0].text, "a");
4718        assert_eq!(p.cue_at(2.5).unwrap().text, "b");
4719        assert!(p.cue_at(1.5).is_none());
4720        assert!(p.add_folder("Footage/Day 1"));
4721        assert!(!p.add_folder("  "));
4722        let aid = p.add_asset(asset(0, 1.0, 0));
4723        p.asset_mut(aid).unwrap().folder = "Footage/Day 1".into();
4724        assert_eq!(p.folder_names(), vec!["Footage/Day 1".to_string()]);
4725        p.remove_folder("Footage");
4726        assert!(p.folders.is_empty());
4727        assert_eq!(p.asset(aid).unwrap().folder, "");
4728    }
4729
4730    #[test]
4731    fn move_many() {
4732        let mut p = Project::new();
4733        let ids: Vec<Id> = (0..4).map(|i| p.add_text_clip(i as f64 * 2.0, 2.0)).collect(); // V1: [0,2)[2,4)[4,6)[6,8)
4734        let blocker = p.add_text_clip(10.0, 1.0);
4735        assert!(p.move_clips(&ids, 1.0, 0, None)); // adjacent moved clips don't block each other
4736        assert!(!p.move_clips(&ids, 2.0, 0, None)); // last one would hit the blocker
4737        assert!(!p.move_clips(&[ids[1]], 1.0, 0, None)); // onto a stationary clip
4738        assert!((p.clip(ids[0]).unwrap().start - 1.0).abs() < 1e-9);
4739        assert!((p.clip(blocker).unwrap().start - 10.0).abs() < 1e-9);
4740    }
4741
4742    #[test]
4743    fn from_json_sanitizes() {
4744        let mut p = Project::from_media(asset(0, 10.0, 1));
4745        p.add_text_clip(12.0, 1.0);
4746        let s = p.to_json().replace("\"fps\": 30.0", "\"fps\": 0.0").replace("\"width\": 1280", "\"width\": 0");
4747        let q = Project::from_json(&s).unwrap();
4748        assert_eq!(q.fps, 30.0);
4749        assert!(q.width >= 16);
4750        assert!(q.snap_frame(1.0).is_finite());
4751        let mut bad = Project::new();
4752        bad.tracks[0].clips.push(Clip::new(99, ClipKind::Text, "t", 0.0, -1.0));
4753        assert!(Project::from_json(&bad.to_json()).unwrap().is_empty());
4754    }
4755
4756    #[test]
4757    fn sequences_open_close_nest() {
4758        let mut p = Project::from_media(asset(0, 10.0, 1));
4759        let v = p.tracks[0].clips[0].id;
4760        // nest the whole clip (+ audio) into a sequence
4761        let seq = p.nest_selection(&[v], "Intro").unwrap();
4762        assert_eq!(p.sequences.len(), 1);
4763        assert_eq!(p.sequence(seq).unwrap().tracks.len(), 2);
4764        assert_eq!(p.tracks[0].clips.len(), 1);
4765        assert_eq!(p.tracks[0].clips[0].kind, ClipKind::Sequence);
4766        assert!((p.sequence_duration(seq) - 10.0).abs() < 1e-9);
4767        assert!((p.duration() - 10.0).abs() < 1e-9);
4768        assert!(p.used_assets().len() == 1); // the asset is used inside the sequence
4769                                             // open the sequence for editing: tracks swap, main is stashed
4770        assert!(p.open_sequence(seq));
4771        assert_eq!(p.editing, Some(seq));
4772        assert_eq!(p.tracks[0].clips[0].kind, ClipKind::Video);
4773        assert!(p.main_stash.is_some());
4774        // can't place itself inside itself
4775        assert!(p.insert_sequence_clip(seq, 0.0, None).is_none());
4776        p.close_sequence();
4777        assert_eq!(p.editing, None);
4778        assert_eq!(p.tracks[0].clips[0].kind, ClipKind::Sequence);
4779        // round trip keeps everything
4780        let q = Project::from_json(&p.to_json()).unwrap();
4781        assert_eq!(q.sequences.len(), 1);
4782        assert!((q.sequence_duration(seq) - 10.0).abs() < 1e-9);
4783    }
4784
4785    #[test]
4786    fn planner_and_unused() {
4787        let mut p = Project::from_media(asset(0, 10.0, 1));
4788        let extra = p.add_asset(asset(1, 3.0, 0));
4789        let third = p.add_asset(asset(2, 3.0, 0));
4790        let a = p.plan_add(None, "Intro");
4791        let b = p.plan_add(Some(a), "Hook shot");
4792        p.plan_item_mut(b).unwrap().assets.push(extra);
4793        p.plan_item_mut(b).unwrap().done = true;
4794        assert_eq!(p.plan[0].children[0].title, "Hook shot");
4795        assert!(p.plan_assets().contains(&extra));
4796        assert_eq!(p.remove_unused_assets(), 1); // `third` gone, `extra` kept by the moodboard
4797        assert!(p.asset(extra).is_some() && p.asset(third).is_none());
4798        p.plan_remove(a);
4799        assert!(p.plan.is_empty());
4800    }
4801
4802    #[test]
4803    fn auto_cut_removes_quiet_parts() {
4804        let mut p = Project::from_media(asset(0, 10.0, 1));
4805        let a = p.tracks[1].clips[0].id;
4806        // cuts at 2,4,6,8; remove [0,2) and [4,6) and [8,10) → keeps [2,4) and [6,8), rippled together
4807        let n = p.auto_cut(&[a], &[2.0, 4.0, 6.0, 8.0], &[(0.0, 2.0), (4.0, 6.0), (8.0, 10.0)], true);
4808        assert_eq!(n, 6); // 3 audio + 3 linked video pieces
4809        assert_eq!(p.tracks[1].clips.len(), 2);
4810        assert_eq!(p.tracks[0].clips.len(), 2);
4811        assert!((p.duration() - 4.0).abs() < 1e-9);
4812        assert!((p.tracks[0].clips[1].src_in - 6.0).abs() < 1e-9);
4813    }
4814
4815    #[test]
4816    fn flow_and_place() {
4817        let mut p = Project::new();
4818        let a = p.add_text_clip(0.0, 2.0);
4819        let b = p.add_text_clip(2.0, 2.0);
4820        p.clip_mut(a).unwrap().x.toggle_key(0.0);
4821        p.clip_mut(a).unwrap().x.set_at(2.0, 100.0);
4822        p.clip_mut(b).unwrap().x.toggle_key(0.0);
4823        p.clip_mut(b).unwrap().x.set_at(2.0, -100.0); // b starts at 0 → after flow both meet at 50
4824        assert!(p.flow_clips(a, b));
4825        assert!((p.clip(a).unwrap().x.at(2.0) - 50.0).abs() < 1e-9);
4826        assert!((p.clip(b).unwrap().x.at(0.0) - 50.0).abs() < 1e-9);
4827        assert_eq!(p.clip(b).unwrap().x.keys[0].ease, Ease::EaseOut);
4828        // place the two clips again as a template at t=10
4829        let clips: Vec<Clip> = [a, b].iter().map(|&id| p.clip(id).unwrap().clone()).collect();
4830        let ids = p.place_clips(clips, Vec::new(), 10.0);
4831        assert_eq!(ids.len(), 2);
4832        assert!((p.clip(ids[0]).unwrap().start - 10.0).abs() < 1e-9);
4833        assert_eq!(p.tracks[0].clips.len(), 4);
4834    }
4835
4836    #[test]
4837    fn bezier_ease() {
4838        let e = Ease::Bezier { x1: 0.42, y1: 0.0, x2: 0.58, y2: 1.0 };
4839        assert!((e.apply(0.0)).abs() < 1e-6 && (e.apply(1.0) - 1.0).abs() < 1e-6);
4840        assert!((e.apply(0.5) - 0.5).abs() < 1e-3);
4841        assert!(e.apply(0.25) < 0.25); // ease-in start
4842        let s = serde_json::to_string(&e).unwrap();
4843        assert_eq!(serde_json::from_str::<Ease>(&s).unwrap(), e);
4844    }
4845
4846    #[test]
4847    fn save_is_atomic() {
4848        let dir = std::env::temp_dir().join("simple-editor-model-test");
4849        std::fs::create_dir_all(&dir).unwrap();
4850        let path = dir.join("p.sedit");
4851        let p = Project::from_media(asset(0, 10.0, 1));
4852        p.save(&path).unwrap();
4853        p.save(&path).unwrap(); // overwrites
4854        assert!(!dir.join("p.sedit.tmp").exists());
4855        assert_eq!(Project::load(&path).unwrap().to_json(), p.to_json());
4856        let _ = std::fs::remove_dir_all(&dir);
4857    }
4858
4859    #[test]
4860    fn unlink_graph_round_trips_the_effect_stack() {
4861        let mut p = Project::from_media(asset(0, 10.0, 1));
4862        let id = p.tracks[0].clips[0].id;
4863        let mut blur = Effect::new(EffectKind::Blur);
4864        blur.params[0].value = 7.0;
4865        let mut off = Effect::new(EffectKind::Sharpen);
4866        off.enabled = false;
4867        p.clip_mut(id).unwrap().effects = vec![blur, off];
4868        p.ensure_graph(id);
4869        assert_eq!(p.unlink_graph(id), Ok(2));
4870        let c = p.clip(id).unwrap();
4871        assert!(c.graph.is_none(), "the graph is gone");
4872        // order, parameters and the disabled flag survive the round trip
4873        assert_eq!(c.effects.iter().map(|e| e.kind).collect::<Vec<_>>(), vec![EffectKind::Blur, EffectKind::Sharpen]);
4874        assert_eq!(c.effects[0].at(0, 0.0), 7.0);
4875        assert!(c.effects[0].enabled && !c.effects[1].enabled);
4876
4877        // a branch (Blend pulling in a second source) has no flat equivalent, but unlink still means the
4878        // graph is gone: the clip keeps the effects it already had rather than staying on the nodes
4879        p.ensure_graph(id);
4880        let g = p.clip(id).unwrap().graph.clone().unwrap();
4881        let (input, out) = (g.nodes[0].id, g.output().unwrap());
4882        let blend = p.add_node(id, NodeKind::Blend { mode: BlendMode::Normal, opacity: Animated::new(1.0) }, 0.0, 0.0);
4883        let color = p.add_node(id, NodeKind::Color([255, 0, 0, 255]), 0.0, 0.0).unwrap();
4884        let blend = blend.unwrap();
4885        let g = p.clip_mut(id).unwrap().graph.as_mut().unwrap();
4886        assert!(g.connect(input, blend, 0) && g.connect(color, blend, 1) && g.connect(blend, out, 0));
4887        let kept: Vec<EffectKind> = p.clip(id).unwrap().effects.iter().map(|e| e.kind).collect();
4888        assert_eq!(p.unlink_graph(id), Ok(0), "nothing of a branch converts");
4889        assert!(p.clip(id).unwrap().graph.is_none(), "but the graph is gone either way");
4890        assert_eq!(
4891            p.clip(id).unwrap().effects.iter().map(|e| e.kind).collect::<Vec<_>>(),
4892            kept,
4893            "and the clip keeps the effect stack it already had"
4894        );
4895        // unlinking a clip that never had a graph is still an error
4896        assert!(p.unlink_graph(id).is_err(), "no graph, nothing to unlink");
4897    }
4898
4899    /// Undo restores the project by parsing a JSON snapshot, and silently does nothing when that parse
4900    /// fails — so every shape the tools can make must survive a round trip, signs and all.
4901    #[test]
4902    fn every_shape_kind_round_trips_through_json() {
4903        let mut p = Project::new();
4904        for kind in [
4905            ShapeKind::Rect,
4906            ShapeKind::Ellipse,
4907            ShapeKind::Triangle,
4908            ShapeKind::Polygon,
4909            ShapeKind::Star,
4910            ShapeKind::Line,
4911            ShapeKind::Arrow,
4912            ShapeKind::Draw,
4913        ] {
4914            let id = p.add_shape_clip(kind, 0.0, 4.0);
4915            // a line dragged up-and-right: the extents carry the direction, so they are negative
4916            if let Some(sh) = p.clip_mut(id).and_then(|c| c.shape.as_mut()) {
4917                sh.w.value = 60.0;
4918                sh.h.value = -40.0;
4919            }
4920        }
4921        let json = p.to_json();
4922        let back = Project::from_json(&json).expect("a project full of shapes must parse back");
4923        let kinds: Vec<ShapeKind> = back.all_clips().filter_map(|(_, c)| c.shape.as_ref().map(|s| s.kind)).collect();
4924        assert_eq!(kinds.len(), 8, "every shape came back: {kinds:?}");
4925        let h: Vec<f64> = back.all_clips().filter_map(|(_, c)| c.shape.as_ref().map(|s| s.h.value)).collect();
4926        assert!(h.iter().all(|v| *v == -40.0), "the negative extent survived: {h:?}");
4927    }
4928
4929    /// An effect covers the whole clip until it is given a window, and a file written before the window
4930    /// existed deserialises to exactly that.
4931    #[test]
4932    fn effect_window_defaults_to_the_whole_clip() {
4933        let mut e = Effect::new(EffectKind::Blur);
4934        assert!(e.on_at(0.0) && e.on_at(1000.0));
4935        e.start = 1.0;
4936        e.len = 2.0;
4937        assert!(!e.on_at(0.5) && e.on_at(1.0) && e.on_at(3.0) && !e.on_at(3.5));
4938        e.enabled = false;
4939        assert!(!e.on_at(2.0), "disabled beats the window");
4940
4941        let old: Effect = serde_json::from_str(r#"{"kind":"Blur","params":[]}"#).unwrap();
4942        assert!(old.on_at(0.0) && old.on_at(1e6), "an old project keeps whole-clip effects");
4943    }
4944
4945    /// The node editor is opt-in: a bare Input→Output graph must not shadow the clip's effect list.
4946    #[test]
4947    fn a_bare_graph_does_not_take_over_the_effect_stack() {
4948        let mut p = Project::from_media(asset(0, 10.0, 1));
4949        let id = p.tracks[0].clips[0].id;
4950        p.ensure_graph(id);
4951        assert_eq!(p.clip(id).unwrap().graph.as_ref().unwrap().nodes.len(), 2);
4952        assert!(!p.clip(id).unwrap().uses_graph(), "Input→Output says nothing the stack does not");
4953        assert!(p.add_node(id, NodeKind::Effect(Effect::new(EffectKind::Blur)), 0.0, 0.0).is_some());
4954        assert!(p.clip(id).unwrap().uses_graph(), "a real node makes the graph the renderer's truth");
4955    }
4956
4957    #[test]
4958    fn text_node_format_expands() {
4959        // 25 fps: t = 2 s is timeline frame 50, 1 s into the clip is counter 25
4960        assert_eq!(expand_text("{n} / {frame}", 2.0, 1.0, 25.0), "25 / 50");
4961        assert_eq!(expand_text("{time}", 61.5, 0.0, 25.0), "01:01.50");
4962        assert_eq!(expand_text("{time}", 3661.0, 0.0, 25.0), "1:01:01.00");
4963        // nonsense fps falls back, unknown braces and negative times are left alone
4964        assert_eq!(expand_text("{frame} {x}", 1.0, 0.0, 0.0), "30 {x}");
4965        assert_eq!(expand_text("{n}", 0.0, -5.0, 30.0), "0");
4966    }
4967
4968    /// A bare node straight into a graph (the editor goes through `Project::add_node`).
4969    fn push(g: &mut NodeGraph, id: Id, kind: NodeKind) -> Id {
4970        g.nodes.push(Node { id, kind, x: 0.0, y: 0.0, enabled: true });
4971        id
4972    }
4973
4974    #[test]
4975    fn the_logic_nodes_evaluate_as_a_chain() {
4976        let mut next = 100;
4977        let mut nid = || {
4978            next += 1;
4979            next
4980        };
4981        let mut g = NodeGraph::new(&mut nid);
4982        let out = g.output().unwrap();
4983        let a = push(&mut g, nid(), NodeKind::Number(Animated::new(3.0)));
4984        let b = push(&mut g, nid(), NodeKind::Number(Animated::new(4.0)));
4985        let sum = push(&mut g, nid(), NodeKind::Math(MathOp::Add));
4986        let gt = push(&mut g, nid(), NodeKind::Compare(CmpOp::Gt));
4987        let not = push(&mut g, nid(), NodeKind::Logic(LogicOp::Not));
4988        let sel = push(&mut g, nid(), NodeKind::Select);
4989        assert!(g.connect(a, sum, 0) && g.connect(b, sum, 1));
4990        assert!(g.connect(sum, gt, 0) && g.connect(b, gt, 1)); // 7 > 4
4991        assert!(g.connect(gt, not, 0));
4992        assert!(g.connect(not, sel, 0) && g.connect(a, sel, 1) && g.connect(b, sel, 2));
4993        assert!(g.connect(sel, out, 0));
4994        let v = g.eval_values(0.0, 30.0);
4995        assert_eq!(v[&sum], 7.0);
4996        assert_eq!(v[&gt], 1.0);
4997        assert_eq!(v[&not], 0.0);
4998        assert_eq!(v[&sel], 4.0, "the condition was negated, so Select takes b");
4999        // flip the comparison and the switch follows
5000        g.node_mut(gt).unwrap().kind = NodeKind::Compare(CmpOp::Lt);
5001        let v = g.eval_values(0.0, 30.0);
5002        assert_eq!(v[&sel], 3.0);
5003        // only what the Output reads is evaluated, exactly like the picture side
5004        let loose = push(&mut g, nid(), NodeKind::Number(Animated::new(9.0)));
5005        assert!(!g.eval_values(0.0, 30.0).contains_key(&loose));
5006        // a division by zero is 0, not a NaN loose in the render
5007        g.node_mut(sum).unwrap().kind = NodeKind::Math(MathOp::Div);
5008        g.disconnect(sum, 1);
5009        assert_eq!(g.eval_values(0.0, 30.0)[&sum], 0.0);
5010    }
5011
5012    #[test]
5013    fn random_is_seeded_so_a_render_repeats() {
5014        let mut next = 200;
5015        let mut nid = || {
5016            next += 1;
5017            next
5018        };
5019        let mut g = NodeGraph::new(&mut nid);
5020        let out = g.output().unwrap();
5021        let r = push(&mut g, nid(), NodeKind::Random { seed: 7, min: -1.0, max: 1.0 });
5022        assert!(g.connect(r, out, 0));
5023        let first = g.eval_values(0.5, 30.0)[&r];
5024        assert_eq!(first, g.eval_values(0.5, 30.0)[&r], "same seed, same frame, same number");
5025        assert!((-1.0..=1.0).contains(&first), "{first} is outside min..max");
5026        assert_ne!(first, g.eval_values(1.5, 30.0)[&r], "a different frame draws again");
5027        g.node_mut(r).unwrap().kind = NodeKind::Random { seed: 8, min: -1.0, max: 1.0 };
5028        assert_ne!(first, g.eval_values(0.5, 30.0)[&r], "a different seed is a different sequence");
5029    }
5030
5031    #[test]
5032    fn an_old_text_node_loads_as_a_string_node() {
5033        let k = NodeKind::String(TextStyle { text: "hello".into(), ..Default::default() });
5034        let json = serde_json::to_string(&k).unwrap();
5035        assert!(json.starts_with("{\"String\""), "{json}");
5036        let old = json.replacen("\"String\"", "\"Text\"", 1);
5037        assert_eq!(serde_json::from_str::<NodeKind>(&old).unwrap(), k, "projects from before the rename still load");
5038    }
5039
5040    #[test]
5041    fn an_effect_node_takes_one_value_port_per_parameter() {
5042        let fx = NodeKind::Effect(Effect::new(EffectKind::Tint));
5043        assert_eq!(fx.inputs(), 1 + EffectKind::Tint.params().len());
5044        assert_eq!(fx.port_label(0), "in");
5045        assert_eq!(fx.port_label(1), EffectKind::Tint.params()[0].name);
5046        // Not reads one input, the other logic nodes two
5047        assert_eq!(NodeKind::Logic(LogicOp::Not).inputs(), 1);
5048        assert_eq!(NodeKind::Logic(LogicOp::And).inputs(), 2);
5049        assert_eq!(NodeKind::Select.port_label(0), "cond");
5050    }
5051}
5052
5053/// Which parts of a clip `Project::paste_attributes` copies.
5054#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5055pub struct AttrSet {
5056    pub transform: bool,
5057    pub opacity: bool,
5058    pub blend: bool,
5059    pub effects: bool,
5060    pub graph: bool,
5061    pub mask: bool,
5062    pub speed: bool,
5063    pub audio: bool,
5064    pub text: bool,
5065    pub shape: bool,
5066    pub label: bool,
5067    pub markers: bool,
5068}
5069
5070impl Default for AttrSet {
5071    fn default() -> Self {
5072        Self {
5073            transform: true,
5074            opacity: true,
5075            blend: true,
5076            effects: true,
5077            graph: true,
5078            mask: true,
5079            speed: false,
5080            audio: true,
5081            text: false,
5082            shape: false,
5083            label: true,
5084            markers: false,
5085        }
5086    }
5087}
5088
5089impl AttrSet {
5090    pub const NONE: AttrSet = AttrSet {
5091        transform: false,
5092        opacity: false,
5093        blend: false,
5094        effects: false,
5095        graph: false,
5096        mask: false,
5097        speed: false,
5098        audio: false,
5099        text: false,
5100        shape: false,
5101        label: false,
5102        markers: false,
5103    };
5104    /// (label, field) pairs for the paste dialog.
5105    pub fn fields(&mut self) -> Vec<(&'static str, &mut bool)> {
5106        vec![
5107            ("Transform (position, scale, rotation)", &mut self.transform),
5108            ("Opacity", &mut self.opacity),
5109            ("Blend mode", &mut self.blend),
5110            ("Effects", &mut self.effects),
5111            ("Node graph", &mut self.graph),
5112            ("Mask", &mut self.mask),
5113            ("Speed / reverse / freeze", &mut self.speed),
5114            ("Audio (volume, pan, fades, bus)", &mut self.audio),
5115            ("Text style", &mut self.text),
5116            ("Shape style", &mut self.shape),
5117            ("Colour label", &mut self.label),
5118            ("Markers", &mut self.markers),
5119        ]
5120    }
5121    pub fn any(&self) -> bool {
5122        let mut me = *self;
5123        me.fields().iter().any(|(_, v)| **v)
5124    }
5125}