1use 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
37const MAX_DEPTH: usize = 8;
39const MAX_MASK_POINTS: usize = 64;
41
42#[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 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
68pub 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
85fn 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#[derive(Clone, Copy)]
105struct Extra {
106 opacity: f32,
107 dx: f32,
108 dy: f32,
109 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
118pub fn trans_progress_at(tr: &Transition, cut: f64, half: f64, t: f64) -> f64 {
120 tr.progress_at(cut, half, t)
121}
122
123pub 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
131fn 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
142pub struct Compositor {
144 layer: Frame,
145 src: Frame,
146 fx: Frame,
148 pre: Frame,
150 cov: Vec<u8>,
152 shapes: ShapeRasterizer,
154 wipe: Frame,
156 seq: Vec<Frame>,
158 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 #[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 #[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 #[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 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 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 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 let (x0, x1, y0, y1) = match tr.direction {
348 0 => (0, (w as f32 * p) as u32, 0, h), 1 => ((w as f32 * (1.0 - p)) as u32, w, 0, h), 2 => (0, w, 0, (h as f32 * p) as u32), _ => (0, w, (h as f32 * (1.0 - p)) as u32, h), };
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 #[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 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 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 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 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) } 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 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 #[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 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
576fn 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#[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; }
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
638fn 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
654fn 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 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 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 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#[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#[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
734fn 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
757fn 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#[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 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#[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
857pub 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#[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 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 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 let u = dx * cos + dy * sin;
943 let v = -dx * sin + dy * cos;
944 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#[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 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; }
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 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
1063fn 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
1086fn 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 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 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 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 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 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 for p in near.rgba.chunks_exact(4) {
1212 assert!(p[0] == 0 || p[0] == 255, "{p:?}");
1213 }
1214 assert!(bil.rgba.chunks_exact(4).any(|p| p[0] > 20 && p[0] < 235));
1216 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 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 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 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 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 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 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]); }
1357
1358 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 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 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 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 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 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 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 #[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); 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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; 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 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}