simple_editor\engine/
compose.rs

1//! Compositor: renders the timeline at time t into an RGBA canvas. Used by preview (small canvas)
2//! and export (project-size canvas) — same output, so preview == export. The one gap left against the
3//! GL renderer: node graphs (`Clip.graph`) and the GPU-only effect kinds (`effects::gpu_only`) are not
4//! evaluated here; `export::cpu_gaps` names them in the export progress line.
5//!
6//! Layer order: video tracks bottom→top in `project.tracks` order (V1 first, then V2 drawn over it…).
7//! For each active (`project.active(track)`), enabled, visual clip containing t:
8//!   * Video/Image: `pool.video(asset.path).frame_at(clip.src_time(t), dw, dh, ..)` where (dw,dh) is the
9//!     placement size (clamped to native size, min 1) — decoders scale for us.
10//!   * Text / Shape: `text.render(style, canvas_w / project.width)` / `shapes.render(style, s, local_t)`
11//!     (already at canvas scale) — contain=false.
12//!   * Sequence: the nested timeline is rendered recursively at `clip.src_time(t)` into its own canvas
13//!     (the sequence's size, fit like a video layer), depth ≤ 8.
14//!   * Adjustment: no layer of its own — the effect stack re-runs over the canvas below it.
15//!   * `clip.effects` run in stack order on the decoded layer (Wobble moves the placement instead:
16//!     dx/dy/roll, and yaw/pitch render through a perspective homography). `Effect.mask` limits an
17//!     effect to its shape (layer space); `Clip.mask` limits the whole layer (canvas space) — both
18//!     mirror `shaders.rs::MASK`.
19//!   * Transform the layer into canvas space per `placement()` (sampling per `project.scaler`;
20//!     straight row copies for the common axis-aligned case), then `blend::composite_row` /
21//!     `blend::composite_rect`.
22//! Transitions render both clips of the cut — extended virtually into the window, which is clamped to
23//! the two clips (`trans_window`) so an over-long one cannot hide the rest of the track — and blend per
24//! kind. Subtitles (`Project::cue_at`) draw last, bottom-centre.
25//! Background is opaque black. Out-of-view layers are skipped.
26
27use crate::engine::blend;
28use crate::engine::effects;
29use crate::engine::shapes::ShapeRasterizer;
30use crate::engine::text::TextRasterizer;
31use crate::media::{DecoderPool, Frame};
32use crate::model::{
33    BlendMode, Clip, ClipKind, Mask, MaskShape, Project, Scaler, TextStyle, Track, TrackKind, Transition,
34    TransitionKind,
35};
36
37/// Recursion limit for nested sequences.
38const MAX_DEPTH: usize = 8;
39/// Mask polygon vertex cap — same as the GPU's `m_points[64]`.
40const MAX_MASK_POINTS: usize = 64;
41
42/// Where a layer lands on a canvas of (cw, ch) pixels: centre, size and rotation (degrees, clockwise).
43/// `yaw`/`pitch` (degrees) tilt the layer about the vertical/horizontal axis (camera shake) — rendered
44/// through a perspective homography with focal length ≈ canvas width; 0 = flat.
45#[derive(Clone, Copy, Debug, PartialEq)]
46pub struct Placement {
47    pub cx: f32,
48    pub cy: f32,
49    pub w: f32,
50    pub h: f32,
51    pub rot: f32,
52    pub yaw: f32,
53    pub pitch: f32,
54}
55
56impl Placement {
57    /// Axis-aligned bounding box (x0, y0, x1, y1) of the flat (yaw/pitch = 0) rect.
58    pub fn bounds(&self) -> (f32, f32, f32, f32) {
59        let (s, c) = self.rot.to_radians().sin_cos();
60        let hw = self.w / 2.0;
61        let hh = self.h / 2.0;
62        let ex = (hw * c).abs() + (hh * s).abs();
63        let ey = (hw * s).abs() + (hh * c).abs();
64        (self.cx - ex, self.cy - ey, self.cx + ex, self.cy + ey)
65    }
66}
67
68/// Placement of a clip's layer on a (cw, ch) canvas at timeline time t.
69/// `native` = the layer image size. With `contain` (video/images) the image is first fitted inside the
70/// canvas preserving aspect; text is already rendered at canvas scale so `contain = false`.
71/// Then: scale about the centre by `clip.scale`, offset by (clip.x, clip.y) project pixels (scaled to
72/// canvas), rotate by `clip.rotation`.
73pub fn placement(
74    project: &Project,
75    clip: &Clip,
76    t: f64,
77    native: (u32, u32),
78    cw: u32,
79    ch: u32,
80    contain: bool,
81) -> Placement {
82    placement_w(project.width, clip, t, native, cw, ch, contain)
83}
84
85/// `placement` with an explicit project width (nested sequences use the sequence's own size).
86fn placement_w(pw: u32, clip: &Clip, t: f64, native: (u32, u32), cw: u32, ch: u32, contain: bool) -> Placement {
87    let lt = clip.local(t);
88    let s = cw as f32 / pw.max(1) as f32;
89    let (nw, nh) = (native.0.max(1) as f32, native.1.max(1) as f32);
90    let fit = if contain { (cw as f32 / nw).min(ch as f32 / nh) } else { 1.0 };
91    let k = clip.scale.at(lt) as f32;
92    Placement {
93        cx: cw as f32 / 2.0 + clip.x.at(lt) as f32 * s,
94        cy: ch as f32 / 2.0 + clip.y.at(lt) as f32 * s,
95        w: nw * fit * k,
96        h: nh * fit * k,
97        rot: clip.rotation.at(lt) as f32,
98        yaw: 0.0,
99        pitch: 0.0,
100    }
101}
102
103/// Per-clip render tweaks used by transitions (opacity fade, push offset, fade-to-colour).
104#[derive(Clone, Copy)]
105struct Extra {
106    opacity: f32,
107    dx: f32,
108    dy: f32,
109    /// Mix the layer's RGB towards `color` by this amount (FadeToColor).
110    fade: f32,
111    color: [u8; 4],
112}
113
114impl Extra {
115    const NONE: Extra = Extra { opacity: 1.0, dx: 0.0, dy: 0.0, fade: 0.0, color: [0, 0, 0, 255] };
116}
117
118/// Eased progress 0..1 across a transition window of `cut ± half`.
119pub fn trans_progress_at(tr: &Transition, cut: f64, half: f64, t: f64) -> f64 {
120    tr.progress_at(cut, half, t)
121}
122
123/// Eased progress 0..1 over the clamped window of any placement (edge transitions have one side).
124pub fn trans_progress(tr: &Transition, left: Option<&Clip>, right: Option<&Clip>, t: f64) -> f64 {
125    match tr.cut_half(left, right) {
126        Some((cut, h)) => tr.progress_at(cut, h, t),
127        None => 1.0,
128    }
129}
130
131/// Mute/solo resolution over an arbitrary track list (mirrors `Project::active`).
132fn track_active(tracks: &[Track], ti: usize) -> bool {
133    let t = &tracks[ti];
134    let any_solo = tracks.iter().any(|o| o.kind == t.kind && o.solo);
135    if any_solo {
136        t.solo
137    } else {
138        !t.muted
139    }
140}
141
142/// Holds scratch buffers so rendering does not allocate per frame.
143pub struct Compositor {
144    layer: Frame,
145    src: Frame,
146    /// Effects scratch (blur passes).
147    fx: Frame,
148    /// Pre-effect copy of the layer, for mixing a masked effect back in.
149    pre: Frame,
150    /// Mask coverage (0..255), one byte per pixel of whatever buffer the mask is rasterised over.
151    cov: Vec<u8>,
152    /// Vector shapes / drawings (own cache; one per compositor, like the GPU's texture cache).
153    shapes: ShapeRasterizer,
154    /// Wipe-transition scratch canvas.
155    wipe: Frame,
156    /// Nested sequence canvases, one per recursion depth.
157    seq: Vec<Frame>,
158    /// Cached subtitle style (subtitle_style + current cue text) to avoid per-frame clones.
159    sub_style: TextStyle,
160    sub_key: u64,
161}
162
163impl Default for Compositor {
164    fn default() -> Self {
165        Self::new()
166    }
167}
168
169impl Compositor {
170    pub fn new() -> Self {
171        Self {
172            layer: Frame::default(),
173            src: Frame::default(),
174            fx: Frame::default(),
175            pre: Frame::default(),
176            cov: Vec::new(),
177            shapes: ShapeRasterizer::new(),
178            wipe: Frame::default(),
179            seq: Vec::new(),
180            sub_style: TextStyle::subtitle_default(),
181            sub_key: 0,
182        }
183    }
184
185    /// Render timeline time `t` into `out` at w×h (out is resized). Never panics on missing media:
186    /// a clip whose decoder fails is simply skipped.
187    #[allow(clippy::too_many_arguments)]
188    pub fn render(
189        &mut self,
190        project: &Project,
191        t: f64,
192        w: u32,
193        h: u32,
194        pool: &mut DecoderPool,
195        text: &mut TextRasterizer,
196        out: &mut Frame,
197    ) {
198        self.render_tracks(project, &project.tracks, project.width, t, w, h, pool, text, out, 0);
199        out.pts = t;
200        if w == 0 || h == 0 {
201            return;
202        }
203        if project.show_subtitles {
204            if let Some(cue) = project.cue_at(t) {
205                if !cue.text.trim().is_empty() {
206                    let sk = project.subtitle_style.cache_key();
207                    if self.sub_key != sk || self.sub_style.text != cue.text {
208                        self.sub_style.clone_from(&project.subtitle_style);
209                        self.sub_style.text.clone_from(&cue.text);
210                        self.sub_key = sk;
211                    }
212                    let s = w as f32 / project.width.max(1) as f32;
213                    let img = text.render(&self.sub_style, s);
214                    if img.width > 1 || img.height > 1 {
215                        let sv = h as f32 / project.height.max(1) as f32;
216                        let p = Placement {
217                            cx: w as f32 / 2.0,
218                            cy: h as f32 - project.subtitle_margin * sv - img.height as f32 / 2.0,
219                            w: img.width as f32,
220                            h: img.height as f32,
221                            rot: 0.0,
222                            yaw: 0.0,
223                            pitch: 0.0,
224                        };
225                        draw_layer(out, &img, p, BlendMode::Normal, 1.0, Scaler::Bilinear, &mut self.layer);
226                    }
227                }
228            }
229        }
230    }
231
232    /// Render a track list onto a fresh opaque-black canvas (recursion entry for nested sequences).
233    /// `pw` = the "project width" the tracks' clips are placed against (project or sequence width).
234    #[allow(clippy::too_many_arguments)]
235    fn render_tracks(
236        &mut self,
237        project: &Project,
238        tracks: &[Track],
239        pw: u32,
240        t: f64,
241        w: u32,
242        h: u32,
243        pool: &mut DecoderPool,
244        text: &mut TextRasterizer,
245        out: &mut Frame,
246        depth: usize,
247    ) {
248        out.resize(w, h);
249        out.fill([0, 0, 0, 255]);
250        if w == 0 || h == 0 {
251            return;
252        }
253        for (ti, track) in tracks.iter().enumerate() {
254            if track.kind != TrackKind::Video || !track_active(tracks, ti) {
255                continue;
256            }
257            if let Some((tr, left, right)) = track.transition_at(t) {
258                self.render_transition(project, pw, tr, left, right, t, w, h, pool, text, out, depth);
259                continue;
260            }
261            for clip in &track.clips {
262                if !clip.enabled || !clip.contains(t) {
263                    continue;
264                }
265                self.render_clip(project, pw, clip, t, w, h, pool, text, out, depth, Extra::NONE);
266            }
267        }
268    }
269
270    /// Blend the two sides of a transition per kind at progress `tr.progress`. Edge transitions have
271    /// one side missing (In: no left, Out: no right): that side is nothing — the black canvas.
272    #[allow(clippy::too_many_arguments)]
273    fn render_transition(
274        &mut self,
275        project: &Project,
276        pw: u32,
277        tr: &Transition,
278        left: Option<&Clip>,
279        right: Option<&Clip>,
280        t: f64,
281        w: u32,
282        h: u32,
283        pool: &mut DecoderPool,
284        text: &mut TextRasterizer,
285        out: &mut Frame,
286        depth: usize,
287    ) {
288        let p = trans_progress(tr, left, right, t) as f32;
289        match tr.kind {
290            TransitionKind::CrossFade => {
291                if let Some(left) = left {
292                    // fading out to nothing, the outgoing clip itself thins; on a cut it stays opaque
293                    let op = if right.is_none() { 1.0 - p } else { 1.0 };
294                    let e = Extra { opacity: op, ..Extra::NONE };
295                    self.render_clip(project, pw, left, t, w, h, pool, text, out, depth, e);
296                }
297                if let Some(right) = right {
298                    let e = Extra { opacity: p, ..Extra::NONE };
299                    self.render_clip(project, pw, right, t, w, h, pool, text, out, depth, e);
300                }
301            }
302            TransitionKind::FadeToColor => {
303                // A→colour for p < 0.5, colour→B after; an edge fades one clip over the whole window
304                let (clip, fade) = match (left, right) {
305                    (Some(l), Some(r)) => {
306                        if p < 0.5 {
307                            (l, 2.0 * p)
308                        } else {
309                            (r, 2.0 * (1.0 - p))
310                        }
311                    }
312                    (Some(l), None) => (l, p),
313                    (None, Some(r)) => (r, 1.0 - p),
314                    (None, None) => return,
315                };
316                let extra = Extra { fade: fade.clamp(0.0, 1.0), color: tr.color, ..Extra::NONE };
317                self.render_clip(project, pw, clip, t, w, h, pool, text, out, depth, extra);
318            }
319            TransitionKind::Push => {
320                let (dx, dy) = dir_vec(tr.direction, w, h);
321                if let Some(left) = left {
322                    let a = Extra { dx: -p * dx, dy: -p * dy, ..Extra::NONE };
323                    self.render_clip(project, pw, left, t, w, h, pool, text, out, depth, a);
324                }
325                if let Some(right) = right {
326                    let b = Extra { dx: (1.0 - p) * dx, dy: (1.0 - p) * dy, ..Extra::NONE };
327                    self.render_clip(project, pw, right, t, w, h, pool, text, out, depth, b);
328                }
329            }
330            TransitionKind::Wipe => {
331                if let Some(left) = left {
332                    self.render_clip(project, pw, left, t, w, h, pool, text, out, depth, Extra::NONE);
333                }
334                // tmp = what the incoming side looks like: the canvas with `right` drawn on it, or
335                // plain black when the clip wipes out to nothing
336                let mut tmp = std::mem::take(&mut self.wipe);
337                tmp.resize(w, h);
338                match right {
339                    Some(right) => {
340                        tmp.rgba.copy_from_slice(&out.rgba);
341                        tmp.pts = out.pts;
342                        self.render_clip(project, pw, right, t, w, h, pool, text, &mut tmp, depth, Extra::NONE);
343                    }
344                    None => tmp.fill([0, 0, 0, 255]),
345                }
346                // copy the wiped region (where B has arrived) from tmp back into out
347                let (x0, x1, y0, y1) = match tr.direction {
348                    0 => (0, (w as f32 * p) as u32, 0, h),         // from the left
349                    1 => ((w as f32 * (1.0 - p)) as u32, w, 0, h), // from the right
350                    2 => (0, w, 0, (h as f32 * p) as u32),         // from the top
351                    _ => (0, w, (h as f32 * (1.0 - p)) as u32, h), // from the bottom
352                };
353                let stride = out.stride();
354                for y in y0..y1.min(h) {
355                    let row = y as usize * stride;
356                    let (a, b) = (row + x0.min(w) as usize * 4, row + x1.min(w) as usize * 4);
357                    if a < b {
358                        out.rgba[a..b].copy_from_slice(&tmp.rgba[a..b]);
359                    }
360                }
361                self.wipe = tmp;
362            }
363        }
364    }
365
366    /// Render one clip's layer onto `out`. Does NOT check `contains` — transitions render clips
367    /// virtually extended past their window (source times are clamped to the asset/sequence).
368    #[allow(clippy::too_many_arguments)]
369    fn render_clip(
370        &mut self,
371        project: &Project,
372        pw: u32,
373        clip: &Clip,
374        t: f64,
375        w: u32,
376        h: u32,
377        pool: &mut DecoderPool,
378        text: &mut TextRasterizer,
379        out: &mut Frame,
380        depth: usize,
381        extra: Extra,
382    ) {
383        let lt = clip.local(t);
384        // fade in/out: the same clip fields the mixer ramps, applied as opacity on visual layers
385        let opacity = clip.opacity.at(lt).clamp(0.0, 1.0) as f32 * extra.opacity * clip.fade_mult(lt) as f32;
386        if opacity <= 0.0 {
387            return;
388        }
389        let s = w as f32 / pw.max(1) as f32;
390        match clip.kind {
391            ClipKind::Shape => {
392                let Some(style) = &clip.shape else { return };
393                let img = self.shapes.render(style, s, lt);
394                if img.is_empty() {
395                    return;
396                }
397                // place by the shape's own project size (the rasteriser may clamp very large layers)
398                let (sw, sh) = ShapeRasterizer::size(style, lt);
399                let native = ((sw * s).round().max(1.0) as u32, (sh * s).round().max(1.0) as u32);
400                self.draw_bitmap(project, pw, clip, &img, native, t, w, h, out, extra, opacity);
401            }
402            ClipKind::Adjustment => {
403                // No layer of its own: the effect stack re-runs over everything already on the canvas
404                // (mirrors gpu.rs, which likewise ignores placement, opacity and `clip.mask` here).
405                let mut p = placement_w(pw, clip, t, (w, h), w, h, false);
406                apply_effects(clip, lt, s, s, out, &mut self.fx, &mut self.pre, &mut self.cov, &mut p);
407            }
408            ClipKind::Video | ClipKind::Image => {
409                if clip.is_empty_container() {
410                    let label_text = if !clip.container_label.is_empty() {
411                        format!("Container Slot\n[{}]", clip.container_label)
412                    } else {
413                        "Container Slot\n(Empty)".to_string()
414                    };
415                    let style = crate::model::TextStyle {
416                        text: label_text,
417                        size: 32.0,
418                        color: [220, 220, 220, 255],
419                        box_color: [40, 40, 40, 220],
420                        box_padding: 16.0,
421                        align: 1,
422                        ..Default::default()
423                    };
424                    let img = text.render(&style, s);
425                    if img.width > 1 || img.height > 1 {
426                        let native = (img.width, img.height);
427                        self.draw_bitmap(project, pw, clip, &img, native, t, w, h, out, extra, opacity);
428                    }
429                    return;
430                }
431                let Some(asset) = project.asset(clip.asset) else {
432                    return;
433                };
434                let Some(dec) = pool.video(&asset.path) else {
435                    return;
436                };
437                let mut native = (asset.width, asset.height);
438                if native.0 == 0 || native.1 == 0 {
439                    native = dec.size();
440                }
441                if native.0 == 0 || native.1 == 0 {
442                    return;
443                }
444                let mut p = placement_w(pw, clip, t, native, w, h, true);
445                if !(p.w.is_finite() && p.h.is_finite()) {
446                    return;
447                }
448                // Keyframed scale on an image: decode once at the size the largest key needs (images
449                // always go through ffmpeg, which re-decodes per requested size); draw_layer resizes.
450                let pd = match clip.scale.keys.iter().max_by(|a, b| a.v.total_cmp(&b.v)) {
451                    Some(k) if clip.kind == ClipKind::Image => {
452                        placement_w(pw, clip, clip.start + k.t, native, w, h, true)
453                    }
454                    _ => p,
455                };
456                let dw = (pd.w.round() as u32).clamp(1, native.0);
457                let dh = (pd.h.round() as u32).clamp(1, native.1);
458                let st = if asset.duration > 0.0 {
459                    clip.src_time(t).clamp(0.0, (asset.duration - 1e-4).max(0.0))
460                } else {
461                    clip.src_time(t).max(0.0)
462                };
463                if !dec.frame_at(st, dw, dh, &mut self.src) {
464                    return;
465                }
466                let img_scale = s * (dw as f32 / p.w.max(1e-3));
467                apply_effects(
468                    clip,
469                    lt,
470                    s,
471                    img_scale,
472                    &mut self.src,
473                    &mut self.fx,
474                    &mut self.pre,
475                    &mut self.cov,
476                    &mut p,
477                );
478                fade_to(&mut self.src, extra.color, extra.fade);
479                p.cx += extra.dx;
480                p.cy += extra.dy;
481                let m = clip_mask(clip, w, h, lt, s, (p.cx, p.cy), &mut self.cov);
482                draw_layer_masked(out, &self.src, p, clip.blend, opacity, project.scaler, &mut self.layer, m);
483            }
484            ClipKind::Text => {
485                let Some(style) = &clip.text else { return };
486                let img = text.render(style, s);
487                let native = (img.width, img.height);
488                self.draw_bitmap(project, pw, clip, &img, native, t, w, h, out, extra, opacity);
489            }
490            ClipKind::Sequence => {
491                if depth >= MAX_DEPTH {
492                    return;
493                }
494                let (qw, qh) = if project.editing == Some(clip.sequence) {
495                    (project.width, project.height) // its live size is swapped into the project
496                } else {
497                    match project.sequence(clip.sequence) {
498                        Some(sq) => (sq.width, sq.height),
499                        None => return,
500                    }
501                };
502                let Some(seq_tracks) = project.sequence_tracks(clip.sequence) else {
503                    return;
504                };
505                if qw == 0 || qh == 0 {
506                    return;
507                }
508                let mut p = placement_w(pw, clip, t, (qw, qh), w, h, true);
509                if !(p.w.is_finite() && p.h.is_finite() && p.w > 0.0 && p.h > 0.0) {
510                    return;
511                }
512                // render the nested timeline at the placed size (capped at the sequence's own size,
513                // like a decode request) — draw_layer scales the rest
514                let dw = (p.w.round() as u32).clamp(1, qw);
515                let dh = (p.h.round() as u32).clamp(1, qh);
516                let dur = project.sequence_duration(clip.sequence);
517                let st = clip.src_time(t).clamp(0.0, (dur - 1e-6).max(0.0));
518                while self.seq.len() <= depth {
519                    self.seq.push(Frame::default());
520                }
521                let mut nested = std::mem::take(&mut self.seq[depth]);
522                self.render_tracks(project, seq_tracks, qw, st, dw, dh, pool, text, &mut nested, depth + 1);
523                let img_scale = s * (dw as f32 / p.w.max(1e-3));
524                apply_effects(clip, lt, s, img_scale, &mut nested, &mut self.fx, &mut self.pre, &mut self.cov, &mut p);
525                fade_to(&mut nested, extra.color, extra.fade);
526                p.cx += extra.dx;
527                p.cy += extra.dy;
528                let m = clip_mask(clip, w, h, lt, s, (p.cx, p.cy), &mut self.cov);
529                draw_layer_masked(out, &nested, p, clip.blend, opacity, project.scaler, &mut self.layer, m);
530                self.seq[depth] = nested;
531            }
532            ClipKind::Audio => {}
533        }
534    }
535
536    /// Draw an already-rasterised layer bitmap (text / shape): effects, fade-to-colour, clip mask.
537    /// `native` is the layer size in canvas px (the bitmap itself may be rasterised smaller).
538    #[allow(clippy::too_many_arguments)]
539    fn draw_bitmap(
540        &mut self,
541        project: &Project,
542        pw: u32,
543        clip: &Clip,
544        img: &Frame,
545        native: (u32, u32),
546        t: f64,
547        w: u32,
548        h: u32,
549        out: &mut Frame,
550        extra: Extra,
551        opacity: f32,
552    ) {
553        let lt = clip.local(t);
554        let s = w as f32 / pw.max(1) as f32;
555        let mut p = placement_w(pw, clip, t, native, w, h, false);
556        // a fade also needs the copy path — fade_to writes into the layer, and the cached bitmap is
557        // shared with the rasteriser's cache
558        if clip.effects.iter().any(|e| e.on_at(lt)) || extra.fade > 0.0 {
559            self.src.resize(img.width, img.height);
560            self.src.rgba.copy_from_slice(&img.rgba);
561            apply_effects(clip, lt, s, s, &mut self.src, &mut self.fx, &mut self.pre, &mut self.cov, &mut p);
562            fade_to(&mut self.src, extra.color, extra.fade);
563            p.cx += extra.dx;
564            p.cy += extra.dy;
565            let m = clip_mask(clip, w, h, lt, s, (p.cx, p.cy), &mut self.cov);
566            draw_layer_masked(out, &self.src, p, clip.blend, opacity, project.scaler, &mut self.layer, m);
567        } else {
568            p.cx += extra.dx;
569            p.cy += extra.dy;
570            let m = clip_mask(clip, w, h, lt, s, (p.cx, p.cy), &mut self.cov);
571            draw_layer_masked(out, img, p, clip.blend, opacity, project.scaler, &mut self.layer, m);
572        }
573    }
574}
575
576/// Direction unit vector scaled to canvas size: 0 = left, 1 = right, 2 = up, 3 = down.
577fn dir_vec(direction: u8, w: u32, h: u32) -> (f32, f32) {
578    match direction {
579        0 => (-(w as f32), 0.0),
580        1 => (w as f32, 0.0),
581        2 => (0.0, -(h as f32)),
582        _ => (0.0, h as f32),
583    }
584}
585
586/// Run the clip's effect stack on the layer image; geometric effects (Wobble) move `p` instead.
587/// `s` = canvas px per project px; `img_scale` = layer-image px per project px. A masked effect is
588/// mixed back over the pre-effect image by the mask's coverage (as `shaders.rs::apply_mask` does).
589#[allow(clippy::too_many_arguments)]
590fn apply_effects(
591    clip: &Clip,
592    lt: f64,
593    s: f32,
594    img_scale: f32,
595    img: &mut Frame,
596    fx: &mut Frame,
597    pre: &mut Frame,
598    cov: &mut Vec<u8>,
599    p: &mut Placement,
600) {
601    for e in &clip.effects {
602        if !e.on_at(lt) {
603            continue;
604        }
605        if e.kind.is_geometric() {
606            let (dx, dy, roll, yaw, pitch) = effects::wobble(e, lt);
607            p.cx += dx as f32 * s;
608            p.cy += dy as f32 * s;
609            p.rot += roll as f32;
610            p.yaw += yaw as f32;
611            p.pitch += pitch as f32;
612            continue;
613        }
614        let mask = e.mask.as_ref().filter(|m| m.enabled);
615        if mask.is_none() {
616            effects::apply(e, lt, img_scale, img, fx);
617            continue;
618        }
619        pre.resize(img.width, img.height);
620        pre.rgba.copy_from_slice(&img.rgba);
621        effects::apply(e, lt, img_scale, img, fx);
622        if img.width != pre.width || img.height != pre.height {
623            continue; // an effect that resized the layer cannot be mixed pixel-for-pixel
624        }
625        let centre = (img.width as f32 / 2.0, img.height as f32 / 2.0);
626        mask_coverage(mask.unwrap(), img.width, img.height, lt, img_scale, centre, cov);
627        for ((px, pp), &c) in img.rgba.chunks_exact_mut(4).zip(pre.rgba.chunks_exact(4)).zip(cov.iter()) {
628            if c == 255 {
629                continue;
630            }
631            for k in 0..4 {
632                px[k] = (pp[k] as i32 + (px[k] as i32 - pp[k] as i32) * c as i32 / 255) as u8;
633            }
634        }
635    }
636}
637
638/// Coverage for `clip.mask` over the whole canvas, or None when the clip has no enabled mask.
639/// The mask lives in canvas space around the layer's placed centre (matching gpu.rs).
640fn clip_mask<'a>(
641    clip: &Clip,
642    w: u32,
643    h: u32,
644    lt: f64,
645    s: f32,
646    centre: (f32, f32),
647    cov: &'a mut Vec<u8>,
648) -> Option<&'a [u8]> {
649    let mask = clip.mask.as_ref().filter(|m| m.enabled)?;
650    mask_coverage(mask, w, h, lt, s, centre, cov);
651    Some(&cov[..])
652}
653
654/// Rasterise `mask` into `out` (one coverage byte per pixel of a w×h buffer) — the CPU twin of
655/// `shaders.rs::MASK`, so preview and export agree. `scale` = buffer px per project px, `centre` =
656/// the layer centre in buffer px.
657fn mask_coverage(mask: &Mask, w: u32, h: u32, t: f64, scale: f32, centre: (f32, f32), out: &mut Vec<u8>) {
658    let n = w as usize * h as usize;
659    out.clear();
660    out.resize(n, 255);
661    if n == 0 {
662        return;
663    }
664    let opacity = mask.opacity.at(t).clamp(0.0, 1.0) as f32;
665    let feather = (mask.feather.at(t) as f32 * scale).max(1.0);
666    let expand = mask.expand.at(t) as f32 * scale;
667    let (sa, ca) = (mask.rotation.at(t) as f32).to_radians().sin_cos();
668    let poly = matches!(mask.shape, MaskShape::Polygon | MaskShape::Path);
669    // Rect/Ellipse: centred on cx/cy. Polygon/Path: points are relative to the layer centre.
670    let o =
671        if poly { centre } else { (centre.0 + mask.cx.at(t) as f32 * scale, centre.1 + mask.cy.at(t) as f32 * scale) };
672    let r = ((mask.rx.at(t) as f32 * scale).abs(), (mask.ry.at(t) as f32 * scale).abs());
673    let pts: Vec<[f32; 2]> = mask.points.iter().take(MAX_MASK_POINTS).map(|&(x, y)| [x * scale, y * scale]).collect();
674    // Rotated-space bounding box of the shape plus its soft edge, so a small mask does not pay for
675    // an SDF evaluation over the whole canvas.
676    let (mut bx, mut by) = (r.0, r.1);
677    if poly {
678        let (mut mx, mut my) = (0.0f32, 0.0f32);
679        for q in &pts {
680            mx = mx.max(q[0].abs());
681            my = my.max(q[1].abs());
682        }
683        (bx, by) = (mx, my);
684    }
685    let margin = feather + expand.max(0.0) + 1.0;
686    let (bx, by) = if mask.shape == MaskShape::Ellipse {
687        // the ellipse SDF is scaled by its smaller radius, so its soft edge reaches further out
688        // along the longer axis — grow the box proportionally instead of by a flat margin
689        let k = 1.0 + margin / r.0.min(r.1).max(1e-4);
690        (bx * k, by * k)
691    } else {
692        (bx + margin, by + margin)
693    };
694    let outside = if mask.invert { (opacity * 255.0 + 0.5) as u8 } else { 0 };
695    let far = pts.len() < 3 && poly;
696    for y in 0..h {
697        let dy = y as f32 + 0.5 - o.1;
698        let row = y as usize * w as usize;
699        for x in 0..w {
700            let dx = x as f32 + 0.5 - o.0;
701            let (qx, qy) = (dx * ca + dy * sa, -dx * sa + dy * ca);
702            if far || qx.abs() > bx || qy.abs() > by {
703                out[row + x as usize] = outside;
704                continue;
705            }
706            let d = match mask.shape {
707                MaskShape::Rect => sd_box(qx, qy, r.0, r.1),
708                MaskShape::Ellipse => sd_ellipse(qx, qy, r.0, r.1),
709                _ => sd_poly(qx, qy, &pts),
710            } - expand;
711            let mut c = (0.5 - d / feather).clamp(0.0, 1.0);
712            if mask.invert {
713                c = 1.0 - c;
714            }
715            out[row + x as usize] = (c * opacity * 255.0 + 0.5) as u8;
716        }
717    }
718}
719
720/// Signed distance to a box of half-size (bx, by).
721#[inline]
722fn sd_box(px: f32, py: f32, bx: f32, by: f32) -> f32 {
723    let (dx, dy) = (px.abs() - bx, py.abs() - by);
724    (dx.max(0.0).hypot(dy.max(0.0))) + dx.max(dy).min(0.0)
725}
726
727/// Cheap ellipse SDF (exact only on circles), matching the shader.
728#[inline]
729fn sd_ellipse(px: f32, py: f32, rx: f32, ry: f32) -> f32 {
730    let (rx, ry) = (rx.max(1e-4), ry.max(1e-4));
731    ((px / rx).hypot(py / ry) - 1.0) * rx.min(ry)
732}
733
734/// Signed distance to the closed polygon through `pts` (negative inside).
735fn sd_poly(px: f32, py: f32, pts: &[[f32; 2]]) -> f32 {
736    if pts.len() < 3 {
737        return 1e6;
738    }
739    let mut d = (px - pts[0][0]).powi(2) + (py - pts[0][1]).powi(2);
740    let mut s = 1.0f32;
741    let mut j = pts.len() - 1;
742    for i in 0..pts.len() {
743        let (ex, ey) = (pts[j][0] - pts[i][0], pts[j][1] - pts[i][1]);
744        let (wx, wy) = (px - pts[i][0], py - pts[i][1]);
745        let k = ((wx * ex + wy * ey) / (ex * ex + ey * ey).max(1e-9)).clamp(0.0, 1.0);
746        let (bx, by) = (wx - ex * k, wy - ey * k);
747        d = d.min(bx * bx + by * by);
748        let c = [py >= pts[i][1], py < pts[j][1], ex * wy > ey * wx];
749        if c.iter().all(|&v| v) || c.iter().all(|&v| !v) {
750            s = -s;
751        }
752        j = i;
753    }
754    s * d.sqrt()
755}
756
757/// Mix the layer's RGB towards `color` by `f` (FadeToColor transitions).
758fn fade_to(img: &mut Frame, color: [u8; 4], f: f32) {
759    if f <= 0.0 {
760        return;
761    }
762    let f = f.min(1.0);
763    let mut lut = [[0u8; 256]; 3];
764    for (c, l) in lut.iter_mut().enumerate() {
765        for (v, o) in l.iter_mut().enumerate() {
766            *o = (v as f32 + (color[c] as f32 - v as f32) * f + 0.5) as u8;
767        }
768    }
769    for px in img.rgba.chunks_exact_mut(4) {
770        px[0] = lut[0][px[0] as usize];
771        px[1] = lut[1][px[1] as usize];
772        px[2] = lut[2][px[2] as usize];
773    }
774}
775
776/// Sample `src` at (sx, sy) — already clamped to [0, sw-1]/[0, sh-1] — returning straight RGBA
777/// (r, g, b in 0..255, a in 0..255).
778#[inline]
779fn sample(src: &Frame, sx: f32, sy: f32, scaler: Scaler) -> [f32; 4] {
780    let (sw, sh) = (src.width as usize, src.height as usize);
781    let sstride = src.stride();
782    match scaler {
783        Scaler::Nearest => {
784            let ix = (sx + 0.5) as usize;
785            let iy = (sy + 0.5) as usize;
786            let o = iy.min(sh - 1) * sstride + ix.min(sw - 1) * 4;
787            let p = &src.rgba[o..o + 4];
788            [p[0] as f32, p[1] as f32, p[2] as f32, p[3] as f32]
789        }
790        Scaler::Bilinear => {
791            let (fx, fy) = (sx.floor(), sy.floor());
792            let (tx, ty) = (sx - fx, sy - fy);
793            let ix0 = fx as usize;
794            let iy0 = fy as usize;
795            let ix1 = (ix0 + 1).min(sw - 1);
796            let iy1 = (iy0 + 1).min(sh - 1);
797            let taps = [
798                (iy0 * sstride + ix0 * 4, (1.0 - tx) * (1.0 - ty)),
799                (iy0 * sstride + ix1 * 4, tx * (1.0 - ty)),
800                (iy1 * sstride + ix0 * 4, (1.0 - tx) * ty),
801                (iy1 * sstride + ix1 * 4, tx * ty),
802            ];
803            let (mut r, mut g, mut b, mut a) = (0.0f32, 0.0f32, 0.0f32, 0.0f32);
804            for (off, wgt) in taps {
805                let s = &src.rgba[off..off + 4];
806                let wa = wgt * s[3] as f32;
807                if wa > 0.0 {
808                    r += wa * s[0] as f32;
809                    g += wa * s[1] as f32;
810                    b += wa * s[2] as f32;
811                    a += wa;
812                }
813            }
814            if a <= 0.0 {
815                return [0.0; 4];
816            }
817            [r / a, g / a, b / a, a]
818        }
819        Scaler::Bicubic => {
820            // 16-tap Catmull-Rom, premultiplied accumulation, clamped edges
821            let (fx, fy) = (sx.floor(), sy.floor());
822            let (tx, ty) = (sx - fx, sy - fy);
823            let wx = catmull(tx);
824            let wy = catmull(ty);
825            let (ix, iy) = (fx as isize, fy as isize);
826            let (mut r, mut g, mut b, mut a) = (0.0f32, 0.0f32, 0.0f32, 0.0f32);
827            for (j, wyj) in wy.iter().enumerate() {
828                let y = (iy + j as isize - 1).clamp(0, sh as isize - 1) as usize;
829                let row = y * sstride;
830                for (i, wxi) in wx.iter().enumerate() {
831                    let x = (ix + i as isize - 1).clamp(0, sw as isize - 1) as usize;
832                    let wgt = wyj * wxi;
833                    let s = &src.rgba[row + x * 4..row + x * 4 + 4];
834                    let wa = wgt * s[3] as f32;
835                    r += wa * s[0] as f32;
836                    g += wa * s[1] as f32;
837                    b += wa * s[2] as f32;
838                    a += wa;
839                }
840            }
841            if a <= 0.0 {
842                return [0.0; 4];
843            }
844            [(r / a).clamp(0.0, 255.0), (g / a).clamp(0.0, 255.0), (b / a).clamp(0.0, 255.0), a.clamp(0.0, 255.0)]
845        }
846    }
847}
848
849/// Catmull-Rom weights for taps at offsets -1, 0, 1, 2 for fractional position `t`.
850#[inline]
851fn catmull(t: f32) -> [f32; 4] {
852    let t2 = t * t;
853    let t3 = t2 * t;
854    [0.5 * (-t3 + 2.0 * t2 - t), 0.5 * (3.0 * t3 - 5.0 * t2 + 2.0), 0.5 * (-3.0 * t3 + 4.0 * t2 + t), 0.5 * (t3 - t2)]
855}
856
857/// Draw `src` into `dst` (canvas) at `p` with blend mode & opacity, using `scratch` as the canvas-sized
858/// layer buffer. Sampling per `scaler`; pixels outside `src` are transparent.
859pub fn draw_layer(
860    dst: &mut Frame,
861    src: &Frame,
862    p: Placement,
863    mode: BlendMode,
864    opacity: f32,
865    scaler: Scaler,
866    scratch: &mut Frame,
867) {
868    draw_layer_masked(dst, src, p, mode, opacity, scaler, scratch, None);
869}
870
871/// `draw_layer` with an optional canvas-sized coverage mask (0..255 per pixel) multiplied into the
872/// layer's alpha. A masked draw always takes the general path, which is where coverage is applied.
873#[allow(clippy::too_many_arguments)]
874fn draw_layer_masked(
875    dst: &mut Frame,
876    src: &Frame,
877    p: Placement,
878    mode: BlendMode,
879    opacity: f32,
880    scaler: Scaler,
881    scratch: &mut Frame,
882    mask: Option<&[u8]>,
883) {
884    if dst.is_empty() || src.is_empty() || !(opacity > 0.0) {
885        return;
886    }
887    if !(p.cx.is_finite() && p.cy.is_finite() && p.w > 0.0 && p.h > 0.0 && p.w.is_finite() && p.h.is_finite()) {
888        return;
889    }
890    let mask = mask.filter(|m| m.len() >= dst.width as usize * dst.height as usize);
891    if p.yaw != 0.0 || p.pitch != 0.0 {
892        draw_layer_perspective(dst, src, p, mode, opacity, scaler, scratch, mask);
893        return;
894    }
895    let (cw, ch) = (dst.width as i64, dst.height as i64);
896    let (sw, sh) = (src.width as i64, src.height as i64);
897
898    // (a) axis-aligned, 1:1 — composite src rows straight onto the canvas at an integer offset.
899    if mask.is_none() && p.rot == 0.0 && (p.w - sw as f32).abs() < 0.5 && (p.h - sh as f32).abs() < 0.5 {
900        let ox = (p.cx - sw as f32 / 2.0).round() as i64;
901        let oy = (p.cy - sh as f32 / 2.0).round() as i64;
902        let (x0, x1) = (ox.max(0), (ox + sw).min(cw));
903        let (y0, y1) = (oy.max(0), (oy + sh).min(ch));
904        if x0 >= x1 || y0 >= y1 {
905            return;
906        }
907        let (ds, ss) = (dst.stride(), src.stride());
908        for y in y0..y1 {
909            let d = y as usize * ds;
910            let s = (y - oy) as usize * ss;
911            let (dx0, dx1) = (x0 as usize * 4, x1 as usize * 4);
912            let (sx0, sx1) = ((x0 - ox) as usize * 4, (x1 - ox) as usize * 4);
913            blend::composite_row(&mut dst.rgba[d + dx0..d + dx1], &src.rgba[s + sx0..s + sx1], mode, opacity);
914        }
915        return;
916    }
917
918    // (b) general: inverse-map the pixels inside bounds ∩ canvas, sampling per `scaler`.
919    let (bx0, by0, bx1, by1) = p.bounds();
920    let x0 = (bx0.floor() as i64).clamp(0, cw) as u32;
921    let y0 = (by0.floor() as i64).clamp(0, ch) as u32;
922    let x1 = (bx1.ceil() as i64).clamp(0, cw) as u32;
923    let y1 = (by1.ceil() as i64).clamp(0, ch) as u32;
924    if x0 >= x1 || y0 >= y1 {
925        return;
926    }
927    scratch.resize(dst.width, dst.height);
928    let (sin, cos) = p.rot.to_radians().sin_cos();
929    let (hw, hh) = (p.w / 2.0, p.h / 2.0);
930    let (kx, ky) = (sw as f32 / p.w, sh as f32 / p.h);
931    let (swm, shm) = ((sw - 1) as f32, (sh - 1) as f32);
932    let stride = scratch.stride();
933    let mw = dst.width as usize;
934    for y in y0..y1 {
935        let row = y as usize * stride;
936        let out = &mut scratch.rgba[row + x0 as usize * 4..row + x1 as usize * 4];
937        let mrow = mask.map(|m| &m[y as usize * mw..(y as usize + 1) * mw]);
938        let dy = y as f32 + 0.5 - p.cy;
939        for (i, o) in out.chunks_exact_mut(4).enumerate() {
940            let dx = (x0 + i as u32) as f32 + 0.5 - p.cx;
941            // canvas → layer space (undo the clockwise rotation)
942            let u = dx * cos + dy * sin;
943            let v = -dx * sin + dy * cos;
944            // 1px feathered rectangle coverage, times the mask's
945            let mut cov = ((hw - u.abs()).min(hh - v.abs()) + 0.5).clamp(0.0, 1.0);
946            if let Some(m) = mrow {
947                cov *= m[(x0 + i as u32) as usize] as f32 / 255.0;
948            }
949            if cov <= 0.0 {
950                o.copy_from_slice(&[0, 0, 0, 0]);
951                continue;
952            }
953            let sx = ((u + hw) * kx - 0.5).clamp(0.0, swm);
954            let sy = ((v + hh) * ky - 0.5).clamp(0.0, shm);
955            let c = sample(src, sx, sy, scaler);
956            if c[3] <= 0.0 {
957                o.copy_from_slice(&[0, 0, 0, 0]);
958                continue;
959            }
960            o[0] = (c[0] + 0.5) as u8;
961            o[1] = (c[1] + 0.5) as u8;
962            o[2] = (c[2] + 0.5) as u8;
963            o[3] = (c[3] * cov + 0.5) as u8;
964        }
965    }
966    blend::composite_rect(dst, scratch, mode, opacity, x0, y0, x1, y1);
967}
968
969/// Perspective path: project the placed rect's corners tilted by yaw (about Y) and pitch (about X)
970/// with focal length = canvas width, then inverse-map each canvas pixel through the homography.
971#[allow(clippy::too_many_arguments)]
972fn draw_layer_perspective(
973    dst: &mut Frame,
974    src: &Frame,
975    p: Placement,
976    mode: BlendMode,
977    opacity: f32,
978    scaler: Scaler,
979    scratch: &mut Frame,
980    mask: Option<&[u8]>,
981) {
982    let f = dst.width as f32;
983    let (hw, hh) = (p.w / 2.0, p.h / 2.0);
984    let (sy_, cy_) = p.yaw.to_radians().sin_cos();
985    let (sp, cp) = p.pitch.to_radians().sin_cos();
986    let (sr, cr) = p.rot.to_radians().sin_cos();
987    // rect corners TL, TR, BR, BL ↔ unit square (0,0) (1,0) (1,1) (0,1)
988    let mut quad = [[0.0f32; 2]; 4];
989    for (k, (x, y)) in [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)].into_iter().enumerate() {
990        let x1 = x * cy_;
991        let z1 = -x * sy_;
992        let y2 = y * cp - z1 * sp;
993        let z2 = y * sp + z1 * cp;
994        let d = f + z2;
995        if d < f * 0.05 {
996            return; // corner (nearly) behind the camera — skip the layer
997        }
998        let px = f * x1 / d;
999        let py = f * y2 / d;
1000        quad[k] = [p.cx + px * cr - py * sr, p.cy + px * sr + py * cr];
1001    }
1002    let hm = square_to_quad(&quad);
1003    let Some(inv) = invert3(&hm) else { return };
1004    let (cw, ch) = (dst.width as i64, dst.height as i64);
1005    let (mut bx0, mut by0, mut bx1, mut by1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
1006    for q in &quad {
1007        bx0 = bx0.min(q[0]);
1008        by0 = by0.min(q[1]);
1009        bx1 = bx1.max(q[0]);
1010        by1 = by1.max(q[1]);
1011    }
1012    let x0 = (bx0.floor() as i64).clamp(0, cw) as u32;
1013    let y0 = (by0.floor() as i64).clamp(0, ch) as u32;
1014    let x1 = (bx1.ceil() as i64).clamp(0, cw) as u32;
1015    let y1 = (by1.ceil() as i64).clamp(0, ch) as u32;
1016    if x0 >= x1 || y0 >= y1 {
1017        return;
1018    }
1019    scratch.resize(dst.width, dst.height);
1020    let (sw, sh) = (src.width as f32, src.height as f32);
1021    let (swm, shm) = (sw - 1.0, sh - 1.0);
1022    let stride = scratch.stride();
1023    let mw = dst.width as usize;
1024    for y in y0..y1 {
1025        let row = y as usize * stride;
1026        let out = &mut scratch.rgba[row + x0 as usize * 4..row + x1 as usize * 4];
1027        let mrow = mask.map(|m| &m[y as usize * mw..(y as usize + 1) * mw]);
1028        let py = y as f32 + 0.5;
1029        for (i, o) in out.chunks_exact_mut(4).enumerate() {
1030            let px = (x0 + i as u32) as f32 + 0.5;
1031            let ww = inv[2][0] * px + inv[2][1] * py + inv[2][2];
1032            if ww.abs() < 1e-9 {
1033                o.copy_from_slice(&[0, 0, 0, 0]);
1034                continue;
1035            }
1036            let u = (inv[0][0] * px + inv[0][1] * py + inv[0][2]) / ww;
1037            let v = (inv[1][0] * px + inv[1][1] * py + inv[1][2]) / ww;
1038            // feathered coverage ≈ distance to the unit-square edge in source pixels
1039            let mut cov = ((u.min(1.0 - u) * sw).min(v.min(1.0 - v) * sh) + 0.5).clamp(0.0, 1.0);
1040            if let Some(m) = mrow {
1041                cov *= m[(x0 + i as u32) as usize] as f32 / 255.0;
1042            }
1043            if cov <= 0.0 || ww < 0.0 {
1044                o.copy_from_slice(&[0, 0, 0, 0]);
1045                continue;
1046            }
1047            let sx = (u * sw - 0.5).clamp(0.0, swm);
1048            let sy = (v * sh - 0.5).clamp(0.0, shm);
1049            let c = sample(src, sx, sy, scaler);
1050            if c[3] <= 0.0 {
1051                o.copy_from_slice(&[0, 0, 0, 0]);
1052                continue;
1053            }
1054            o[0] = (c[0] + 0.5) as u8;
1055            o[1] = (c[1] + 0.5) as u8;
1056            o[2] = (c[2] + 0.5) as u8;
1057            o[3] = (c[3] * cov + 0.5) as u8;
1058        }
1059    }
1060    blend::composite_rect(dst, scratch, mode, opacity, x0, y0, x1, y1);
1061}
1062
1063/// Homography mapping the unit square (0,0)(1,0)(1,1)(0,1) onto `q` (Heckbert).
1064fn square_to_quad(q: &[[f32; 2]; 4]) -> [[f32; 3]; 3] {
1065    let [p0, p1, p2, p3] = *q;
1066    let (dx1, dy1) = (p1[0] - p2[0], p1[1] - p2[1]);
1067    let (dx2, dy2) = (p3[0] - p2[0], p3[1] - p2[1]);
1068    let (sx, sy) = (p0[0] - p1[0] + p2[0] - p3[0], p0[1] - p1[1] + p2[1] - p3[1]);
1069    let (g, h) = if sx.abs() < 1e-6 && sy.abs() < 1e-6 {
1070        (0.0, 0.0)
1071    } else {
1072        let den = dx1 * dy2 - dx2 * dy1;
1073        if den.abs() < 1e-9 {
1074            (0.0, 0.0)
1075        } else {
1076            ((sx * dy2 - dx2 * sy) / den, (dx1 * sy - sx * dy1) / den)
1077        }
1078    };
1079    [
1080        [p1[0] - p0[0] + g * p1[0], p3[0] - p0[0] + h * p3[0], p0[0]],
1081        [p1[1] - p0[1] + g * p1[1], p3[1] - p0[1] + h * p3[1], p0[1]],
1082        [g, h, 1.0],
1083    ]
1084}
1085
1086/// Inverse of a 3×3 (adjugate / det); None when singular.
1087fn invert3(m: &[[f32; 3]; 3]) -> Option<[[f32; 3]; 3]> {
1088    let a = m[0][0] as f64;
1089    let b = m[0][1] as f64;
1090    let c = m[0][2] as f64;
1091    let d = m[1][0] as f64;
1092    let e = m[1][1] as f64;
1093    let f = m[1][2] as f64;
1094    let g = m[2][0] as f64;
1095    let h = m[2][1] as f64;
1096    let i = m[2][2] as f64;
1097    let det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
1098    if det.abs() < 1e-12 {
1099        return None;
1100    }
1101    let inv = 1.0 / det;
1102    Some([
1103        [((e * i - f * h) * inv) as f32, ((c * h - b * i) * inv) as f32, ((b * f - c * e) * inv) as f32],
1104        [((f * g - d * i) * inv) as f32, ((a * i - c * g) * inv) as f32, ((c * d - a * f) * inv) as f32],
1105        [((d * h - e * g) * inv) as f32, ((b * g - a * h) * inv) as f32, ((a * e - b * d) * inv) as f32],
1106    ])
1107}
1108
1109#[cfg(test)]
1110mod tests {
1111    use super::*;
1112    use crate::media::{Backend, VideoSource};
1113    use crate::model::{Asset, ClipKind, Ease, Keyframe, Project, TransitionKind};
1114
1115    fn px(f: &Frame, x: u32, y: u32) -> [u8; 4] {
1116        let i = (y * f.width + x) as usize * 4;
1117        [f.rgba[i], f.rgba[i + 1], f.rgba[i + 2], f.rgba[i + 3]]
1118    }
1119
1120    fn black(w: u32, h: u32) -> Frame {
1121        let mut f = Frame::new(w, h);
1122        f.fill([0, 0, 0, 255]);
1123        f
1124    }
1125
1126    fn pl(cx: f32, cy: f32, w: f32, h: f32, rot: f32) -> Placement {
1127        Placement { cx, cy, w, h, rot, yaw: 0.0, pitch: 0.0 }
1128    }
1129
1130    #[test]
1131    fn draw_layer_scale_2_centred() {
1132        let mut dst = black(8, 8);
1133        let mut src = Frame::new(2, 2);
1134        src.fill([255, 0, 0, 255]);
1135        let mut scratch = Frame::default();
1136        let p = pl(4.0, 4.0, 4.0, 4.0, 0.0);
1137        draw_layer(&mut dst, &src, p, BlendMode::Normal, 1.0, Scaler::Bilinear, &mut scratch);
1138        for y in 0..8 {
1139            for x in 0..8 {
1140                let inside = (2..6).contains(&x) && (2..6).contains(&y);
1141                let want = if inside { [255, 0, 0, 255] } else { [0, 0, 0, 255] };
1142                assert_eq!(px(&dst, x, y), want, "at {x},{y}");
1143            }
1144        }
1145    }
1146
1147    #[test]
1148    fn draw_layer_fast_path_offset_and_clip() {
1149        let mut dst = black(4, 4);
1150        let mut src = Frame::new(2, 2);
1151        src.fill([0, 255, 0, 255]);
1152        let mut scratch = Frame::default();
1153        // native size at the top-left corner, 50% opacity
1154        let p = pl(1.0, 1.0, 2.0, 2.0, 0.0);
1155        draw_layer(&mut dst, &src, p, BlendMode::Normal, 0.5, Scaler::Bilinear, &mut scratch);
1156        assert_eq!(px(&dst, 0, 0), [0, 128, 0, 255]);
1157        assert_eq!(px(&dst, 1, 1), [0, 128, 0, 255]);
1158        assert_eq!(px(&dst, 2, 2), [0, 0, 0, 255]);
1159        // partially off-canvas
1160        let mut dst = black(4, 4);
1161        let p = pl(0.0, 0.0, 2.0, 2.0, 0.0);
1162        draw_layer(&mut dst, &src, p, BlendMode::Normal, 1.0, Scaler::Bilinear, &mut scratch);
1163        assert_eq!(px(&dst, 0, 0), [0, 255, 0, 255]);
1164        assert_eq!(px(&dst, 1, 0), [0, 0, 0, 255]);
1165        // fully off-canvas: no-op, no panic
1166        let p = pl(100.0, 100.0, 2.0, 2.0, 45.0);
1167        draw_layer(&mut dst, &src, p, BlendMode::Normal, 1.0, Scaler::Bilinear, &mut scratch);
1168    }
1169
1170    #[test]
1171    fn draw_layer_rotated_90() {
1172        // 4x2 source, left half red, right half blue; rotated 90 deg cw -> top red, bottom blue.
1173        let mut dst = black(8, 8);
1174        let mut src = Frame::new(4, 2);
1175        for y in 0..2 {
1176            for x in 0..4 {
1177                let i = (y * 4 + x) * 4;
1178                let c = if x < 2 { [255, 0, 0, 255] } else { [0, 0, 255, 255] };
1179                src.rgba[i..i + 4].copy_from_slice(&c);
1180            }
1181        }
1182        let mut scratch = Frame::default();
1183        let p = pl(4.0, 4.0, 4.0, 2.0, 90.0);
1184        draw_layer(&mut dst, &src, p, BlendMode::Normal, 1.0, Scaler::Bilinear, &mut scratch);
1185        assert_eq!(px(&dst, 3, 2), [255, 0, 0, 255]);
1186        assert_eq!(px(&dst, 4, 2), [255, 0, 0, 255]);
1187        assert_eq!(px(&dst, 3, 5), [0, 0, 255, 255]);
1188        assert_eq!(px(&dst, 4, 5), [0, 0, 255, 255]);
1189        assert_eq!(px(&dst, 0, 0), [0, 0, 0, 255]);
1190        assert_eq!(px(&dst, 1, 4), [0, 0, 0, 255]);
1191    }
1192
1193    #[test]
1194    fn scaler_nearest_vs_bilinear_differ() {
1195        // 2x2 checkerboard upscaled 4x: nearest keeps pure colours, bilinear interpolates
1196        let mut src = Frame::new(2, 2);
1197        for (i, c) in [[255u8, 255, 255, 255], [0, 0, 0, 255], [0, 0, 0, 255], [255, 255, 255, 255]].iter().enumerate()
1198        {
1199            src.rgba[i * 4..i * 4 + 4].copy_from_slice(c);
1200        }
1201        let mut scratch = Frame::default();
1202        let p = pl(4.0, 4.0, 8.0, 8.0, 0.0);
1203        let mut near = black(8, 8);
1204        draw_layer(&mut near, &src, p, BlendMode::Normal, 1.0, Scaler::Nearest, &mut scratch);
1205        let mut bil = black(8, 8);
1206        draw_layer(&mut bil, &src, p, BlendMode::Normal, 1.0, Scaler::Bilinear, &mut scratch);
1207        let mut cub = black(8, 8);
1208        draw_layer(&mut cub, &src, p, BlendMode::Normal, 1.0, Scaler::Bicubic, &mut scratch);
1209        assert_ne!(near.rgba, bil.rgba);
1210        // nearest: every pixel is a pure source colour
1211        for p in near.rgba.chunks_exact(4) {
1212            assert!(p[0] == 0 || p[0] == 255, "{p:?}");
1213        }
1214        // bilinear: intermediate values appear
1215        assert!(bil.rgba.chunks_exact(4).any(|p| p[0] > 20 && p[0] < 235));
1216        // bicubic resolves too and differs from nearest
1217        assert_ne!(near.rgba, cub.rgba);
1218        assert_eq!(px(&cub, 1, 1)[0], 255);
1219    }
1220
1221    #[test]
1222    fn perspective_yaw_draws_and_shrinks() {
1223        let mut src = Frame::new(4, 4);
1224        src.fill([255, 255, 255, 255]);
1225        let mut scratch = Frame::default();
1226        let lit = |f: &Frame| f.rgba.chunks_exact(4).filter(|p| p[0] > 64).count();
1227        let mut flat = black(16, 16);
1228        draw_layer(
1229            &mut flat,
1230            &src,
1231            pl(8.0, 8.0, 10.0, 10.0, 0.0),
1232            BlendMode::Normal,
1233            1.0,
1234            Scaler::Bilinear,
1235            &mut scratch,
1236        );
1237        let mut tilted = black(16, 16);
1238        let p = Placement { cx: 8.0, cy: 8.0, w: 10.0, h: 10.0, rot: 0.0, yaw: 50.0, pitch: 10.0 };
1239        draw_layer(&mut tilted, &src, p, BlendMode::Normal, 1.0, Scaler::Bilinear, &mut scratch);
1240        assert!(lit(&tilted) > 0, "tilted layer vanished");
1241        assert!(lit(&tilted) < lit(&flat), "yaw should foreshorten: {} vs {}", lit(&tilted), lit(&flat));
1242    }
1243
1244    struct FakeVideo([u8; 4]);
1245    impl VideoSource for FakeVideo {
1246        fn size(&self) -> (u32, u32) {
1247            (320, 240)
1248        }
1249        fn frame_at(&mut self, t: f64, w: u32, h: u32, out: &mut Frame) -> bool {
1250            if t >= 10.0 {
1251                return false;
1252            }
1253            out.resize(w, h);
1254            out.fill(self.0);
1255            out.pts = t;
1256            true
1257        }
1258    }
1259
1260    fn fake_asset() -> Asset {
1261        Asset {
1262            id: 0,
1263            path: "Z:\\nope\\fake.mp4".into(),
1264            kind: ClipKind::Video,
1265            duration: 10.0,
1266            width: 320,
1267            height: 240,
1268            fps: 30.0,
1269            audio_streams: Vec::new(),
1270            codec: "h264".into(),
1271            folder: String::new(),
1272            tags: Vec::new(),
1273            label: 0,
1274            description: String::new(),
1275        }
1276    }
1277
1278    #[test]
1279    fn compositor_renders_fake_video() {
1280        let project = Project::from_media(fake_asset());
1281        let mut pool = DecoderPool::new(Backend::Ffmpeg);
1282        pool.insert_video(&project.assets[0].path, Box::new(FakeVideo([255, 0, 0, 255])));
1283        let mut comp = Compositor::new();
1284        let mut text = TextRasterizer::new();
1285        let mut out = Frame::default();
1286        comp.render(&project, 1.0, 64, 48, &mut pool, &mut text, &mut out);
1287        assert_eq!((out.width, out.height), (64, 48));
1288        assert_eq!(px(&out, 32, 24), [255, 0, 0, 255]);
1289        assert_eq!(px(&out, 0, 0), [255, 0, 0, 255]);
1290        // outside the clip -> black
1291        comp.render(&project, 20.0, 64, 48, &mut pool, &mut text, &mut out);
1292        assert_eq!(px(&out, 32, 24), [0, 0, 0, 255]);
1293        // scale 0.5 + opacity 0.5 -> centred half-size block at half brightness, corners black
1294        let mut p2 = project.clone();
1295        let c = &mut p2.tracks[0].clips[0];
1296        c.scale.value = 0.5;
1297        c.opacity.value = 0.5;
1298        comp.render(&p2, 1.0, 64, 48, &mut pool, &mut text, &mut out);
1299        assert_eq!(px(&out, 32, 24), [128, 0, 0, 255]);
1300        assert_eq!(px(&out, 0, 0), [0, 0, 0, 255]);
1301        // muted (disabled) video track -> black
1302        let mut p3 = project.clone();
1303        p3.tracks[0].muted = true;
1304        comp.render(&p3, 1.0, 64, 48, &mut pool, &mut text, &mut out);
1305        assert_eq!(px(&out, 32, 24), [0, 0, 0, 255]);
1306        // missing decoder -> skipped, no panic
1307        let mut empty = DecoderPool::new(Backend::Ffmpeg);
1308        empty.insert_video("other", Box::new(FakeVideo([0, 0, 0, 255])));
1309        comp.render(&project, 1.0, 16, 16, &mut empty, &mut text, &mut out);
1310        assert_eq!(px(&out, 8, 8), [0, 0, 0, 255]);
1311    }
1312
1313    /// Records every (w, h) it is asked for.
1314    struct SizeSpy(std::sync::Arc<std::sync::Mutex<Vec<(u32, u32)>>>);
1315    impl VideoSource for SizeSpy {
1316        fn size(&self) -> (u32, u32) {
1317            (320, 240)
1318        }
1319        fn frame_at(&mut self, _t: f64, w: u32, h: u32, out: &mut Frame) -> bool {
1320            self.0.lock().unwrap().push((w, h));
1321            out.resize(w, h);
1322            out.fill([0, 0, 255, 255]);
1323            true
1324        }
1325    }
1326
1327    #[test]
1328    fn keyframed_image_scale_decodes_one_size() {
1329        let mut project = Project::new();
1330        (project.width, project.height, project.fps) = (320, 240, 30.0);
1331        let mut a = fake_asset();
1332        a.kind = ClipKind::Image;
1333        a.path = "Z:\\nope\\pic.png".into();
1334        let id = project.add_asset(a);
1335        project.insert_asset_clips(id, 0.0, None);
1336        let clip = &mut project.tracks[0].clips[0];
1337        assert_eq!(clip.kind, ClipKind::Image);
1338        clip.scale.keys =
1339            vec![Keyframe { t: 0.0, v: 0.25, ease: Ease::Linear }, Keyframe { t: 4.0, v: 0.5, ease: Ease::Linear }];
1340        let sizes = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1341        let mut pool = DecoderPool::new(Backend::Ffmpeg);
1342        pool.insert_video(&project.assets[0].path, Box::new(SizeSpy(sizes.clone())));
1343        let mut comp = Compositor::new();
1344        let mut text = TextRasterizer::new();
1345        let mut out = Frame::default();
1346        for i in 0..5 {
1347            comp.render(&project, i as f64, 320, 240, &mut pool, &mut text, &mut out);
1348        }
1349        // one decode size for the whole clip (the largest key), yet drawn at the animated size
1350        let s = sizes.lock().unwrap().clone();
1351        assert_eq!(s.len(), 5);
1352        assert!(s.iter().all(|&x| x == (160, 120)), "{s:?}");
1353        comp.render(&project, 0.0, 320, 240, &mut pool, &mut text, &mut out);
1354        assert_eq!(px(&out, 160, 120), [0, 0, 255, 255]);
1355        assert_eq!(px(&out, 100, 120), [0, 0, 0, 255]); // scale 0.25 -> 80 px wide, centred
1356    }
1357
1358    /// Two abutting full-frame clips (red then blue) with a transition on the cut at t=2.
1359    fn transition_project(kind: TransitionKind, duration: f64) -> (Project, DecoderPool) {
1360        let mut project = Project::new();
1361        (project.width, project.height) = (320, 240);
1362        let a1 = fake_asset();
1363        let mut a2 = fake_asset();
1364        a2.path = "Z:\\nope\\fake2.mp4".into();
1365        let id1 = project.add_asset(a1);
1366        let id2 = project.add_asset(a2);
1367        let c1 = {
1368            let mut c = crate::model::Clip::new(project.new_id(), ClipKind::Video, "a", 0.0, 2.0);
1369            c.asset = id1;
1370            c
1371        };
1372        let mut c2 = crate::model::Clip::new(project.new_id(), ClipKind::Video, "b", 2.0, 2.0);
1373        c2.asset = id2;
1374        let right = c2.id;
1375        project.tracks[0].clips.push(c1);
1376        project.tracks[0].clips.push(c2);
1377        let tid = project.new_id();
1378        project.tracks[0].transitions.push(crate::model::Transition {
1379            id: tid,
1380            right,
1381            kind,
1382            duration,
1383            color: [255, 255, 255, 255],
1384            direction: 0,
1385            ease: Ease::Linear,
1386            edge: Default::default(),
1387        });
1388        let mut pool = DecoderPool::new(Backend::Ffmpeg);
1389        pool.insert_video("Z:\\nope\\fake.mp4", Box::new(FakeVideo([255, 0, 0, 255])));
1390        pool.insert_video("Z:\\nope\\fake2.mp4", Box::new(FakeVideo([0, 0, 255, 255])));
1391        (project, pool)
1392    }
1393
1394    #[test]
1395    fn transition_crossfade_midpoint_mixes() {
1396        let (project, mut pool) = transition_project(TransitionKind::CrossFade, 1.0);
1397        let mut comp = Compositor::new();
1398        let mut text = TextRasterizer::new();
1399        let mut out = Frame::default();
1400        // t = cut -> progress 0.5 -> 50/50 red/blue
1401        comp.render(&project, 2.0, 64, 48, &mut pool, &mut text, &mut out);
1402        let c = px(&out, 32, 24);
1403        assert!((120..=135).contains(&c[0]) && (120..=135).contains(&c[2]), "{c:?}");
1404        // before the window -> pure red; after -> pure blue
1405        comp.render(&project, 1.0, 64, 48, &mut pool, &mut text, &mut out);
1406        assert_eq!(px(&out, 32, 24), [255, 0, 0, 255]);
1407        comp.render(&project, 3.0, 64, 48, &mut pool, &mut text, &mut out);
1408        assert_eq!(px(&out, 32, 24), [0, 0, 255, 255]);
1409    }
1410
1411    #[test]
1412    fn transition_push_moves_content() {
1413        let (project, mut pool) = transition_project(TransitionKind::Push, 2.0);
1414        let mut comp = Compositor::new();
1415        let mut text = TextRasterizer::new();
1416        let mut out = Frame::default();
1417        // t = cut -> progress 0.5: A pushed half off to the right, B occupies the left half
1418        comp.render(&project, 2.0, 64, 48, &mut pool, &mut text, &mut out);
1419        assert_eq!(px(&out, 8, 24), [0, 0, 255, 255], "left half is B");
1420        assert_eq!(px(&out, 56, 24), [255, 0, 0, 255], "right half is A");
1421    }
1422
1423    #[test]
1424    fn transition_wipe_reveals_region() {
1425        let (project, mut pool) = transition_project(TransitionKind::Wipe, 2.0);
1426        let mut comp = Compositor::new();
1427        let mut text = TextRasterizer::new();
1428        let mut out = Frame::default();
1429        // progress 0.5, direction "from the left": left half B, right half A (both full-frame)
1430        comp.render(&project, 2.0, 64, 48, &mut pool, &mut text, &mut out);
1431        assert_eq!(px(&out, 8, 24), [0, 0, 255, 255]);
1432        assert_eq!(px(&out, 56, 24), [255, 0, 0, 255]);
1433    }
1434
1435    #[test]
1436    fn transition_fade_to_color() {
1437        let (project, mut pool) = transition_project(TransitionKind::FadeToColor, 2.0);
1438        let mut comp = Compositor::new();
1439        let mut text = TextRasterizer::new();
1440        let mut out = Frame::default();
1441        // t = cut -> progress 0.5 -> fully the transition colour (white)
1442        comp.render(&project, 2.0, 64, 48, &mut pool, &mut text, &mut out);
1443        assert_eq!(px(&out, 32, 24), [255, 255, 255, 255]);
1444        // early in the window: red fading towards white
1445        comp.render(&project, 1.5, 64, 48, &mut pool, &mut text, &mut out);
1446        let c = px(&out, 32, 24);
1447        assert_eq!(c[0], 255);
1448        assert!(c[1] > 100 && c[1] < 155, "{c:?}");
1449    }
1450
1451    /// Edge transitions blend a lone clip from/to nothing: In fades up from black over the first
1452    /// `duration` seconds, Out down to black over the last, and outside the windows the clip is solid.
1453    #[test]
1454    fn edge_transitions_fade_from_and_to_black() {
1455        let (mut project, mut pool) = transition_project(TransitionKind::CrossFade, 1.0);
1456        project.tracks[0].clips.remove(1); // lone red clip [0,2)
1457        let clip = project.tracks[0].clips[0].id;
1458        let tr = &mut project.tracks[0].transitions[0];
1459        (tr.right, tr.edge, tr.duration) = (clip, crate::model::TransitionEdge::In, 0.5);
1460        let out_id = project.new_id();
1461        project.tracks[0].transitions.push(crate::model::Transition {
1462            id: out_id,
1463            right: clip,
1464            kind: TransitionKind::CrossFade,
1465            duration: 0.5,
1466            color: [0, 0, 0, 255],
1467            direction: 0,
1468            ease: Ease::Linear,
1469            edge: crate::model::TransitionEdge::Out,
1470        });
1471        let mut comp = Compositor::new();
1472        let mut text = TextRasterizer::new();
1473        let mut out = Frame::default();
1474        comp.render(&project, 0.25, 64, 48, &mut pool, &mut text, &mut out);
1475        let c = px(&out, 32, 24);
1476        assert!((120..=135).contains(&c[0]) && c[2] == 0, "half-way up the fade-in: {c:?}");
1477        comp.render(&project, 1.0, 64, 48, &mut pool, &mut text, &mut out);
1478        assert_eq!(px(&out, 32, 24), [255, 0, 0, 255], "between the windows the clip is solid");
1479        comp.render(&project, 1.875, 64, 48, &mut pool, &mut text, &mut out);
1480        let c = px(&out, 32, 24);
1481        assert!((55..=70).contains(&c[0]), "3/4 through the fade-out: {c:?}");
1482    }
1483
1484    /// Video clips apply the fade in/out fields as an opacity ramp (the audio fade, on pixels).
1485    #[test]
1486    fn video_fades_ramp_opacity() {
1487        let (mut project, mut pool) = transition_project(TransitionKind::CrossFade, 1.0);
1488        project.tracks[0].transitions.clear();
1489        project.tracks[0].clips.remove(1);
1490        let c = &mut project.tracks[0].clips[0];
1491        c.fade_in = 1.0;
1492        c.fade_out = 1.0;
1493        let mut comp = Compositor::new();
1494        let mut text = TextRasterizer::new();
1495        let mut out = Frame::default();
1496        comp.render(&project, 0.5, 64, 48, &mut pool, &mut text, &mut out);
1497        let c = px(&out, 32, 24);
1498        assert!((120..=135).contains(&c[0]), "half faded in over black: {c:?}");
1499        comp.render(&project, 1.75, 64, 48, &mut pool, &mut text, &mut out);
1500        let c = px(&out, 32, 24);
1501        assert!((55..=70).contains(&c[0]), "3/4 through the fade-out: {c:?}");
1502    }
1503
1504    #[test]
1505    fn transition_window_clamped_to_clips() {
1506        // A [0,2) red, B [2,3) green, C [3,5) blue with a 4 s transition on the A|B cut: the window is
1507        // clamped to [1,3) by the 1 s clip B, so C renders normally at t=3.5.
1508        let (mut project, mut pool) = transition_project(TransitionKind::CrossFade, 4.0);
1509        project.tracks[0].clips[1].duration = 1.0;
1510        let mut c3 = crate::model::Clip::new(project.new_id(), ClipKind::Video, "c", 3.0, 2.0);
1511        let mut a3 = fake_asset();
1512        a3.path = "Z:\\nope\\fake3.mp4".into();
1513        c3.asset = project.add_asset(a3);
1514        project.tracks[0].clips.push(c3);
1515        pool.insert_video("Z:\\nope\\fake3.mp4", Box::new(FakeVideo([0, 255, 255, 255])));
1516        let mut comp = Compositor::new();
1517        let mut text = TextRasterizer::new();
1518        let mut out = Frame::default();
1519        comp.render(&project, 3.5, 64, 48, &mut pool, &mut text, &mut out);
1520        assert_eq!(px(&out, 32, 24), [0, 255, 255, 255], "clip C hidden by an over-long transition");
1521        // inside the clamped window the cut still dissolves (t = 2 is the cut → 50/50)
1522        comp.render(&project, 2.0, 64, 48, &mut pool, &mut text, &mut out);
1523        let c = px(&out, 32, 24);
1524        assert!((120..=135).contains(&c[0]) && (120..=135).contains(&c[2]), "{c:?}");
1525    }
1526
1527    #[test]
1528    fn text_clip_fades_to_colour() {
1529        // Two abutting text clips with an opaque box, FadeToColor to white: the cut is fully white even
1530        // though neither clip has an effect.
1531        let mut project = Project::new();
1532        (project.width, project.height) = (320, 240);
1533        let a = project.add_text_clip(0.0, 2.0);
1534        let b = project.add_text_clip(2.0, 2.0);
1535        for id in [a, b] {
1536            let st = project.clip_mut(id).unwrap().text.as_mut().expect("text style");
1537            st.box_color = [255, 0, 0, 255];
1538            st.color = [0, 255, 0, 255];
1539        }
1540        let tid = project.new_id();
1541        let vt = project.video_tracks()[0];
1542        project.tracks[vt].transitions.push(crate::model::Transition {
1543            id: tid,
1544            right: b,
1545            kind: TransitionKind::FadeToColor,
1546            duration: 2.0,
1547            color: [255, 255, 255, 255],
1548            direction: 0,
1549            ease: Ease::Linear,
1550            edge: Default::default(),
1551        });
1552        let mut pool = DecoderPool::new(Backend::Ffmpeg);
1553        let mut comp = Compositor::new();
1554        let mut text = TextRasterizer::new();
1555        let mut out = Frame::default();
1556        let count = |f: &Frame, c: [u8; 4]| f.rgba.chunks_exact(4).filter(|p| *p == c).count();
1557        comp.render(&project, 1.0, 64, 48, &mut pool, &mut text, &mut out);
1558        if text.families().is_empty() {
1559            eprintln!("no system fonts - skipping");
1560            return;
1561        }
1562        assert!(count(&out, [255, 0, 0, 255]) > 0, "no text box before the window");
1563        // at the cut the fade is full: the layer is the transition colour, no source colours left
1564        comp.render(&project, 2.0, 64, 48, &mut pool, &mut text, &mut out);
1565        assert_eq!(count(&out, [255, 0, 0, 255]), 0, "text clip ignored the fade");
1566        assert_eq!(count(&out, [0, 255, 0, 255]), 0);
1567        assert!(count(&out, [255, 255, 255, 255]) > 0, "no fade colour drawn");
1568    }
1569
1570    #[test]
1571    fn sequence_clip_renders_nested() {
1572        let mut project = Project::new();
1573        (project.width, project.height) = (320, 240);
1574        let id = project.add_asset(fake_asset());
1575        let sid = project.new_sequence("seq", 320, 240, 30.0);
1576        let cid = project.new_id();
1577        let mut c = crate::model::Clip::new(cid, ClipKind::Video, "v", 0.0, 5.0);
1578        c.asset = id;
1579        project.sequence_mut(sid).unwrap().tracks[0].clips.push(c);
1580        project.insert_sequence_clip(sid, 0.0, None).expect("sequence clip");
1581        let mut pool = DecoderPool::new(Backend::Ffmpeg);
1582        pool.insert_video("Z:\\nope\\fake.mp4", Box::new(FakeVideo([0, 255, 0, 255])));
1583        let mut comp = Compositor::new();
1584        let mut text = TextRasterizer::new();
1585        let mut out = Frame::default();
1586        comp.render(&project, 1.0, 64, 48, &mut pool, &mut text, &mut out);
1587        assert_eq!(px(&out, 32, 24), [0, 255, 0, 255]);
1588        // past the sequence clip -> black
1589        comp.render(&project, 9.0, 64, 48, &mut pool, &mut text, &mut out);
1590        assert_eq!(px(&out, 32, 24), [0, 0, 0, 255]);
1591    }
1592
1593    #[test]
1594    fn subtitle_renders_near_bottom() {
1595        let mut project = Project::from_media(fake_asset());
1596        project.add_cue(0.0, 2.0, "HELLO");
1597        assert!(project.show_subtitles);
1598        let mut pool = DecoderPool::new(Backend::Ffmpeg);
1599        pool.insert_video(&project.assets[0].path, Box::new(FakeVideo([255, 0, 0, 255])));
1600        let mut comp = Compositor::new();
1601        let mut text = TextRasterizer::new();
1602        let mut out = Frame::default();
1603        comp.render(&project, 1.0, 128, 96, &mut pool, &mut text, &mut out);
1604        if text.families().is_empty() {
1605            eprintln!("no system fonts - skipping");
1606            return;
1607        }
1608        // white subtitle pixels in the bottom half, none in the top half (background is red)
1609        let white = |out: &Frame, y0: u32, y1: u32| {
1610            (y0..y1)
1611                .flat_map(|y| (0..128).map(move |x| (x, y)))
1612                .filter(|&(x, y)| px(out, x, y) == [255, 255, 255, 255])
1613                .count()
1614        };
1615        assert!(white(&out, 48, 96) > 0, "no subtitle pixels");
1616        assert_eq!(white(&out, 0, 48), 0);
1617        // hidden when show_subtitles is off
1618        project.show_subtitles = false;
1619        comp.render(&project, 1.0, 128, 96, &mut pool, &mut text, &mut out);
1620        assert_eq!(white(&out, 48, 96), 0);
1621    }
1622
1623    #[test]
1624    fn shape_clip_renders_and_masks() {
1625        // a blue 320x240 rect over the whole canvas (project px half-size 160x120)
1626        let mut project = Project::new();
1627        (project.width, project.height) = (320, 240);
1628        let id = project.add_shape_clip(crate::model::ShapeKind::Rect, 0.0, 2.0);
1629        {
1630            let st = project.clip_mut(id).unwrap().shape.as_mut().expect("shape style");
1631            st.fill = [0, 0, 255, 255];
1632            st.w.value = 160.0;
1633            st.h.value = 120.0;
1634        }
1635        let mut pool = DecoderPool::new(Backend::Ffmpeg);
1636        let mut comp = Compositor::new();
1637        let mut text = TextRasterizer::new();
1638        let mut out = Frame::default();
1639        comp.render(&project, 1.0, 64, 48, &mut pool, &mut text, &mut out);
1640        assert_eq!(px(&out, 32, 24), [0, 0, 255, 255], "shape clip did not render");
1641        assert_eq!(px(&out, 1, 1), [0, 0, 255, 255]);
1642        // outside the clip -> black
1643        comp.render(&project, 5.0, 64, 48, &mut pool, &mut text, &mut out);
1644        assert_eq!(px(&out, 32, 24), [0, 0, 0, 255]);
1645        // a small centred ellipse mask keeps the middle and drops the corners
1646        let mut m = crate::model::Mask::new(MaskShape::Ellipse);
1647        (m.rx.value, m.ry.value) = (40.0, 40.0);
1648        project.clip_mut(id).unwrap().mask = Some(m);
1649        comp.render(&project, 1.0, 64, 48, &mut pool, &mut text, &mut out);
1650        assert_eq!(px(&out, 32, 24), [0, 0, 255, 255], "mask hid the middle");
1651        assert_eq!(px(&out, 1, 1), [0, 0, 0, 255], "mask did not cut the corner");
1652        // inverted: the middle goes, the corners stay
1653        project.clip_mut(id).unwrap().mask.as_mut().unwrap().invert = true;
1654        comp.render(&project, 1.0, 64, 48, &mut pool, &mut text, &mut out);
1655        assert_eq!(px(&out, 32, 24), [0, 0, 0, 255]);
1656        assert_eq!(px(&out, 1, 1), [0, 0, 255, 255]);
1657    }
1658
1659    #[test]
1660    fn mask_coverage_shapes_and_feather() {
1661        let mut cov = Vec::new();
1662        // a very eccentric ellipse: its soft edge reaches far past rx along the long axis, so the
1663        // bounding-box shortcut must scale with the radii instead of using a flat margin
1664        let mut m = crate::model::Mask::new(MaskShape::Ellipse);
1665        (m.rx.value, m.ry.value) = (30.0, 3.0);
1666        m.feather.value = 4.0;
1667        mask_coverage(&m, 160, 20, 0.0, 1.0, (80.0, 10.0), &mut cov);
1668        assert_eq!(cov[10 * 160 + 80], 255, "centre of the mask");
1669        let edge = cov[10 * 160 + 118];
1670        assert!(edge > 0 && edge < 255, "feathered edge along the long axis: {edge}");
1671        assert_eq!(cov[10 * 160 + 2], 0, "far outside");
1672        // rect, inverted: the hole is inside
1673        let mut r = crate::model::Mask::new(MaskShape::Rect);
1674        (r.rx.value, r.ry.value) = (10.0, 5.0);
1675        r.invert = true;
1676        mask_coverage(&r, 160, 20, 0.0, 1.0, (80.0, 10.0), &mut cov);
1677        assert_eq!(cov[10 * 160 + 80], 0);
1678        assert_eq!(cov[10 * 160 + 2], 255);
1679        // scale doubles every dimension
1680        r.invert = false;
1681        mask_coverage(&r, 160, 20, 0.0, 2.0, (80.0, 10.0), &mut cov);
1682        assert_eq!(cov[10 * 160 + 99], 255, "rx 10 at scale 2 reaches 20 px");
1683        assert_eq!(cov[10 * 160 + 105], 0);
1684    }
1685
1686    #[test]
1687    fn adjustment_layer_processes_the_canvas_below() {
1688        // red video on V1, an Invert adjustment layer above it -> cyan
1689        let mut project = Project::from_media(fake_asset());
1690        let id = project.add_adjustment_clip(0.0, 2.0);
1691        project.clip_mut(id).unwrap().effects.push(crate::model::Effect::new(crate::model::EffectKind::Invert));
1692        let mut pool = DecoderPool::new(Backend::Ffmpeg);
1693        pool.insert_video(&project.assets[0].path, Box::new(FakeVideo([255, 0, 0, 255])));
1694        let mut comp = Compositor::new();
1695        let mut text = TextRasterizer::new();
1696        let mut out = Frame::default();
1697        comp.render(&project, 1.0, 64, 48, &mut pool, &mut text, &mut out);
1698        assert_eq!(px(&out, 32, 24), [0, 255, 255, 255], "adjustment layer did nothing");
1699        // past the adjustment clip the canvas is untouched
1700        comp.render(&project, 3.0, 64, 48, &mut pool, &mut text, &mut out);
1701        assert_eq!(px(&out, 32, 24), [255, 0, 0, 255]);
1702    }
1703
1704    #[test]
1705    fn effect_mask_limits_the_effect() {
1706        // Invert masked to a small centred rect: the middle flips, the edges do not
1707        let mut project = Project::from_media(fake_asset());
1708        let mut e = crate::model::Effect::new(crate::model::EffectKind::Invert);
1709        let mut m = crate::model::Mask::new(MaskShape::Rect);
1710        (m.rx.value, m.ry.value) = (30.0, 30.0);
1711        e.mask = Some(m);
1712        project.tracks[0].clips[0].effects.push(e);
1713        let mut pool = DecoderPool::new(Backend::Ffmpeg);
1714        pool.insert_video(&project.assets[0].path, Box::new(FakeVideo([255, 0, 0, 255])));
1715        let mut comp = Compositor::new();
1716        let mut text = TextRasterizer::new();
1717        let mut out = Frame::default();
1718        comp.render(&project, 1.0, 64, 48, &mut pool, &mut text, &mut out);
1719        assert_eq!(px(&out, 32, 24), [0, 255, 255, 255], "masked effect did not run inside the mask");
1720        assert_eq!(px(&out, 1, 1), [255, 0, 0, 255], "masked effect leaked outside the mask");
1721    }
1722
1723    #[test]
1724    fn wobble_effect_moves_placement() {
1725        let mut project = Project::from_media(fake_asset());
1726        let clip = &mut project.tracks[0].clips[0];
1727        let mut e = crate::model::Effect::new(crate::model::EffectKind::Wobble);
1728        e.params[0].value = 100.0; // Amplitude X
1729        e.params[1].value = 100.0;
1730        clip.effects.push(e);
1731        let mut pool = DecoderPool::new(Backend::Ffmpeg);
1732        pool.insert_video(&project.assets[0].path, Box::new(FakeVideo([255, 0, 0, 255])));
1733        let mut comp = Compositor::new();
1734        let mut text = TextRasterizer::new();
1735        let mut out = Frame::default();
1736        // full-frame red shifted by wobble -> some border pixels are black
1737        comp.render(&project, 0.37, 64, 48, &mut pool, &mut text, &mut out);
1738        let corners = [px(&out, 0, 0), px(&out, 63, 0), px(&out, 0, 47), px(&out, 63, 47)];
1739        assert!(corners.iter().any(|c| *c == [0, 0, 0, 255]), "{corners:?}");
1740    }
1741}