1use crate::engine::compose::placement;
18use crate::hotkeys::Action;
19use crate::media::Frame;
20use crate::model::{ClipKind, Id, Mask, MaskShape, Project, ShapeKind, Stroke as ModelStroke};
21use crate::theme::Palette;
22use crate::ui::timecode;
23use crate::ui::tools::{glyph_text_button, Dir, Glyph, Tool};
24use eframe::egui::{self, pos2, vec2, Color32, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, TextureOptions, Vec2};
25use std::sync::Arc;
26
27pub const QUALITIES: [u32; 4] = [100, 75, 50, 25];
29
30struct ToolDrag {
32 from: Pos2,
34 t0: f64,
36 points: Vec<(f32, f32, f32)>,
38}
39
40pub struct PreviewState {
41 pub texture: Option<egui::TextureHandle>,
42 drag: Option<(f64, f64, Vec2)>,
44 tool_drag: Option<ToolDrag>,
46 poly: Vec<(f32, f32)>,
48 point_drag: Option<usize>,
50 moved_at: Option<std::time::Instant>,
52 transport: Rect,
55}
56
57impl Default for PreviewState {
58 fn default() -> Self {
59 Self {
60 texture: None,
61 drag: None,
62 tool_drag: None,
63 poly: Vec::new(),
64 point_drag: None,
65 moved_at: None,
66 transport: Rect::ZERO,
67 }
68 }
69}
70
71pub struct PreviewCtx<'a> {
72 pub project: &'a mut Project,
73 pub selection: &'a [Id],
74 pub playhead: f64,
75 pub playing: bool,
76 pub fullscreen: bool,
78 pub palette: &'a Palette,
79 pub undo: &'a mut dyn FnMut(&Project),
80 pub frame: Option<Arc<Frame>>,
82 pub gpu_texture: Option<(egui::TextureId, [u32; 2])>,
85 pub tool: Tool,
87 pub quality: u32,
89 pub movie_mode: bool,
91 pub prerender: Option<f32>,
93 pub buffering: bool,
95 pub proxy: Option<f32>,
97 pub tracker: Option<(f32, f32, f32, f32)>,
100}
101
102#[derive(Default)]
103pub struct PreviewResponse {
104 pub seek: Option<f64>,
105 pub actions: Vec<Action>,
107 pub edited: bool,
108 pub canvas: (u32, u32),
110 pub set_quality: Option<u32>,
112 pub set_movie_mode: Option<bool>,
114 pub new_shape: Option<(ShapeKind, f32, f32, f32, f32)>,
117 pub new_points: Vec<(f32, f32)>,
119 pub stroke: Option<ModelStroke>,
121 pub mask_edit: bool,
123 pub set_tracker: Option<(f32, f32)>,
125}
126
127pub fn show(ui: &mut egui::Ui, state: &mut PreviewState, mut c: PreviewCtx<'_>) -> PreviewResponse {
128 let mut r = PreviewResponse::default();
129 if c.fullscreen {
130 video(ui, state, &mut c, &mut r);
131 if state.moved_at.is_some_and(|t| t.elapsed().as_secs_f32() < 2.0) {
134 egui::Area::new(ui.id().with("fs_transport"))
135 .anchor(egui::Align2::CENTER_BOTTOM, vec2(0.0, -24.0))
136 .order(egui::Order::Foreground)
137 .show(ui.ctx(), |ui| {
138 egui::Frame::popup(ui.style()).fill(c.palette.panel.gamma_multiply(0.92)).show(ui, |ui| {
139 transport(ui, state, &c, &mut r);
140 if ui.ui_contains_pointer() {
142 state.moved_at = Some(std::time::Instant::now());
143 }
144 });
145 });
146 }
147 return r;
148 }
149 ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| {
150 transport(ui, state, &c, &mut r);
151 video(ui, state, &mut c, &mut r);
152 });
153 r
154}
155
156fn transport(ui: &mut egui::Ui, state: &mut PreviewState, c: &PreviewCtx<'_>, r: &mut PreviewResponse) {
157 let fps = c.project.fps;
158 let pad = ((ui.available_width() - state.transport.width()) * 0.5).max(0.0);
160 let row = ui
161 .horizontal(|ui| {
162 ui.spacing_mut().item_spacing.x = 2.0;
163 ui.add_space(pad);
164 let mut b = |ui: &mut egui::Ui, icon: Option<Glyph>, label: &str, a: Action| {
166 let hit = match icon {
167 Some(g) => glyph_text_button(ui, g, "").on_hover_text(label),
168 None => ui.button(label),
169 };
170 if hit.clicked() {
171 r.actions.push(a);
172 }
173 };
174 b(ui, Some(Glyph::Jump(Dir::Left)), "Go to start", Action::GoStart);
175 b(ui, Some(Glyph::Skip(Dir::Left)), "Previous cut", Action::PrevCut);
176 b(ui, Some(Glyph::Tri(Dir::Left)), "Step back one frame", Action::StepBack);
177 let (pp, pp_tip) = if c.playing { (Glyph::Pause, "Pause") } else { (Glyph::Play, "Play") };
178 b(ui, Some(pp), pp_tip, Action::PlayPause);
179 b(ui, Some(Glyph::Stop), "Stop", Action::Stop);
180 b(ui, Some(Glyph::Tri(Dir::Right)), "Step forward one frame", Action::StepForward);
181 b(ui, Some(Glyph::Skip(Dir::Right)), "Next cut", Action::NextCut);
182 b(ui, Some(Glyph::Jump(Dir::Right)), "Go to end", Action::GoEnd);
183 ui.add_space(8.0);
184 ui.monospace(format!("{} / {}", timecode(c.playhead, fps), timecode(c.project.duration(), fps)));
185 ui.add_space(8.0);
186 b(ui, None, "In", Action::MarkIn);
187 b(ui, None, "Out", Action::MarkOut);
188 b(ui, None, "Clear", Action::ClearInOut);
189 if c.project.in_point.is_some() || c.project.out_point.is_some() {
190 ui.add_space(4.0);
191 let tc = |t: Option<f64>| t.map(|t| timecode(t, fps)).unwrap_or_else(|| "-".into());
192 ui.monospace(format!("{} → {}", tc(c.project.in_point), tc(c.project.out_point)));
193 }
194 ui.add_space(8.0);
195 let cur = QUALITIES.iter().copied().find(|&q| q == c.quality).unwrap_or(100);
196 egui::ComboBox::from_id_salt("preview_quality")
197 .selected_text(format!("{cur} %"))
198 .width(64.0)
199 .show_ui(ui, |ui| {
200 for q in QUALITIES {
201 if ui.selectable_label(cur == q, format!("{q} %")).clicked() && q != cur {
202 r.set_quality = Some(q);
203 }
204 }
205 })
206 .response
207 .on_hover_text("Preview render scale");
208 if crate::ui::tools::icon_button(
210 ui,
211 c.palette,
212 ui.id().with("movie"),
213 Glyph::FilmStrip,
214 "Movie mode: play pre-rendered full-quality frames",
215 c.movie_mode,
216 )
217 .clicked()
218 {
219 r.set_movie_mode = Some(!c.movie_mode);
220 }
221 b(ui, Some(Glyph::Fullscreen), "Fullscreen", Action::Fullscreen);
222 })
223 .response;
224 state.transport = Rect::from_min_max(pos2(row.rect.left() + pad, row.rect.top()), row.rect.max);
226}
227
228fn signed_min(v: f32, min: f32) -> f32 {
231 if v < 0.0 {
232 v.min(-min)
233 } else {
234 v.max(min)
235 }
236}
237
238fn constrain_drag(kind: ShapeKind, from: Pos2, to: Pos2, modifiers: egui::Modifiers) -> (Pos2, Pos2) {
243 let mut delta = to - from;
244 if modifiers.shift {
245 if matches!(kind, ShapeKind::Line | ShapeKind::Arrow) {
246 let len = delta.length();
247 let step = std::f32::consts::FRAC_PI_4;
248 let angle = (delta.y.atan2(delta.x) / step).round() * step;
249 delta = vec2(angle.cos(), angle.sin()) * len;
250 } else {
251 let m = delta.x.abs().max(delta.y.abs());
252 delta = vec2(m.copysign(delta.x), m.copysign(delta.y));
253 }
254 }
255 if modifiers.alt {
256 (from - delta, from + delta)
257 } else {
258 (from, from + delta)
259 }
260}
261
262fn draw_shape_preview(p: &egui::Painter, kind: ShapeKind, from: Pos2, to: Pos2, palette: &Palette, stroke: Stroke) {
267 let fill = palette.selection.gamma_multiply(0.25);
268 let r = Rect::from_two_pos(from, to);
269 let c = r.center();
270 let (hw, hh) = (r.width() / 2.0, r.height() / 2.0);
271 let poly = |n: u32, rot: f32| -> Vec<Pos2> {
272 (0..n)
273 .map(|i| {
274 let a = rot + std::f32::consts::TAU * i as f32 / n as f32;
275 pos2(c.x + a.cos() * hw, c.y + a.sin() * hh)
276 })
277 .collect()
278 };
279 let top = -std::f32::consts::FRAC_PI_2;
280 match kind {
281 ShapeKind::Rect => {
282 p.rect(r, 0.0, fill, stroke, StrokeKind::Inside);
283 }
284 ShapeKind::Ellipse => {
285 let pts = poly(48, 0.0);
286 p.add(Shape::convex_polygon(pts.clone(), fill, Stroke::NONE));
287 p.add(Shape::closed_line(pts, stroke));
288 }
289 ShapeKind::Triangle | ShapeKind::Polygon | ShapeKind::Star => {
290 let pts = if kind == ShapeKind::Star {
291 let (o, i) = (poly(5, top), poly(5, top + std::f32::consts::TAU / 10.0));
292 (0..5).flat_map(|k| [o[k], pos2(c.x + (i[k].x - c.x) * 0.45, c.y + (i[k].y - c.y) * 0.45)]).collect()
293 } else {
294 poly(if kind == ShapeKind::Triangle { 3 } else { 5 }, top)
295 };
296 p.add(Shape::convex_polygon(pts.clone(), fill, Stroke::NONE));
297 p.add(Shape::closed_line(pts, stroke));
298 }
299 ShapeKind::Line | ShapeKind::Arrow => {
300 let (a, b) = (from, to);
301 p.line_segment([a, b], stroke);
302 if kind == ShapeKind::Arrow {
303 let v = b - a;
304 let len = v.length().max(1.0);
305 let (ux, uy) = (v.x / len, v.y / len);
306 let head = (len * 0.25).clamp(6.0, 40.0);
307 let base = pos2(b.x - ux * head, b.y - uy * head);
308 let (px, py) = (-uy * head * 0.5, ux * head * 0.5);
309 p.add(Shape::convex_polygon(
310 vec![b, pos2(base.x + px, base.y + py), pos2(base.x - px, base.y - py)],
311 stroke.color,
312 Stroke::NONE,
313 ));
314 }
315 }
316 ShapeKind::Draw => {
317 p.rect_stroke(r, 0.0, stroke, StrokeKind::Inside);
318 }
319 }
320}
321
322pub(crate) fn letterbox(area: Rect, aspect: f32, ppp: f32) -> Rect {
324 let (aw, ah) = (area.width(), area.height());
325 let (w, h) = if aw / ah > aspect { (ah * aspect, ah) } else { (aw, aw / aspect) };
326 let snap = |v: f32| (v * ppp).round() / ppp;
327 let min = pos2(snap(area.min.x + (aw - w) / 2.0), snap(area.min.y + (ah - h) / 2.0));
328 Rect::from_min_size(min, vec2(snap(w), snap(h)))
329}
330
331fn tool_drag(
335 ui: &egui::Ui,
336 state: &mut PreviewState,
337 c: &mut PreviewCtx<'_>,
338 r: &mut PreviewResponse,
339 resp: &egui::Response,
340 lb: Rect,
341) -> bool {
342 let tool = c.tool;
343 if tool == Tool::Select {
344 state.tool_drag = None;
345 return false;
346 }
347 let k = c.project.width as f32 / lb.width().max(1.0);
349 let (hw, hh) = (c.project.width as f32 / 2.0, c.project.height as f32 / 2.0);
350 let to_proj = move |p: Pos2| ((p.x - lb.min.x) * k - hw, (p.y - lb.min.y) * k - hh);
351 let to_screen = move |&(x, y): &(f32, f32)| pos2(lb.min.x + (x + hw) / k, lb.min.y + (y + hh) / k);
352
353 if tool == Tool::Shape(ShapeKind::Polygon) {
355 let at = resp.interact_pointer_pos();
356 let on_vertex = at.is_some_and(|p| state.poly.iter().any(|q| (to_screen(q) - p).length() <= 6.0));
360 if (resp.clicked() || resp.drag_stopped()) && !on_vertex {
361 if let Some(p) = at {
362 state.poly.push(to_proj(p));
363 }
364 }
365 let p = ui.painter_at(lb);
366 let stroke = Stroke::new(1.5, c.palette.selection);
367 let mut pts: Vec<Pos2> = state.poly.iter().map(to_screen).collect();
368 if !pts.is_empty() {
369 for q in &pts {
370 p.circle_filled(*q, 3.5, c.palette.selection);
371 }
372 if let Some(at) = resp.hover_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())) {
374 p.line_segment([pts[0], at], Stroke::new(1.0, c.palette.selection.gamma_multiply(0.5)));
375 pts.push(at);
376 }
377 p.add(Shape::line(pts, stroke));
378 }
379 if (on_vertex && (resp.clicked() || resp.double_clicked())) || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
380 if state.poly.len() >= 3 {
381 let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
382 for &(x, y) in &state.poly {
383 (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y));
384 }
385 let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
386 r.new_points = state.poly.iter().map(|&(x, y)| (x - cx, y - cy)).collect();
387 r.new_shape =
388 Some((ShapeKind::Polygon, cx, cy, ((x1 - x0) / 2.0).max(2.0), ((y1 - y0) / 2.0).max(2.0)));
389 }
390 state.poly.clear();
391 }
392 return true;
393 }
394
395 let mask_target = c.selection.iter().copied().find(|&id| c.project.clip(id).is_some_and(|cl| cl.is_visual()));
398
399 if resp.drag_started() {
400 if let Some(p) = resp.interact_pointer_pos() {
401 let t0 = ui.input(|i| i.time);
402 state.tool_drag = Some(ToolDrag { from: p, t0, points: Vec::new() });
403 if matches!(tool, Tool::Mask(_)) && mask_target.is_some() {
404 (c.undo)(c.project);
405 }
406 }
407 }
408 let Some(d) = &mut state.tool_drag else { return !matches!(tool, Tool::Zoom | Tool::Text) };
409 let now = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())).unwrap_or(d.from);
410
411 if resp.dragged() {
412 match tool {
413 Tool::Draw => {
414 let (x, y) = to_proj(now);
415 let t = (ui.input(|i| i.time) - d.t0).max(0.0) as f32;
416 if d.points.last().is_none_or(|&(px, py, _)| (px - x).abs() + (py - y).abs() > 1.0) {
418 d.points.push((x, y, t));
419 }
420 }
421 Tool::Mask(shape) => {
422 let (x0, y0) = to_proj(d.from);
423 let (x1, y1) = to_proj(now);
424 if let Some(clip) = mask_target.and_then(|id| c.project.clip_mut(id)) {
425 let m = clip.mask.get_or_insert_with(|| Mask::new(shape));
426 m.shape = shape;
427 m.cx.value = ((x0 + x1) / 2.0) as f64;
428 m.cy.value = ((y0 + y1) / 2.0) as f64;
429 m.rx.value = ((x1 - x0).abs() / 2.0).max(1.0) as f64;
430 m.ry.value = ((y1 - y0).abs() / 2.0).max(1.0) as f64;
431 if matches!(shape, MaskShape::Polygon | MaskShape::Path) {
432 m.points.clear();
435 crate::ui::seed_mask_points(m);
436 }
437 r.mask_edit = true;
438 r.edited = true;
439 }
440 }
441 _ => {}
442 }
443 let p = ui.painter_at(lb);
445 let stroke = Stroke::new(1.0, c.palette.selection);
446 match tool {
447 Tool::Draw => {
448 let pts: Vec<Pos2> =
449 d.points.iter().map(|&(x, y, _)| pos2(lb.min.x + (x + hw) / k, lb.min.y + (y + hh) / k)).collect();
450 if pts.len() > 1 {
451 p.add(Shape::line(pts, Stroke::new(2.0, c.palette.selection)));
452 }
453 }
454 Tool::Shape(kind) => {
459 let modifiers = ui.input(|i| i.modifiers);
460 let (from, to) = constrain_drag(kind, d.from, now, modifiers);
461 draw_shape_preview(&p, kind, from, to, c.palette, stroke);
462 }
463 _ => {
464 p.rect_stroke(Rect::from_two_pos(d.from, now), 0.0, stroke, StrokeKind::Inside);
465 }
466 }
467 }
468
469 if resp.drag_stopped() {
470 let (x0, y0) = to_proj(d.from);
471 let (x1, y1) = to_proj(now);
472 match tool {
473 Tool::Shape(kind) => {
474 let modifiers = ui.input(|i| i.modifiers);
479 let (p0, p1) = constrain_drag(kind, pos2(x0, y0), pos2(x1, y1), modifiers);
480 let (dx, dy) = ((p1.x - p0.x) / 2.0, (p1.y - p0.y) / 2.0);
481 let (w, h) = match kind {
482 ShapeKind::Line | ShapeKind::Arrow => (signed_min(dx, 2.0), signed_min(dy, 2.0)),
483 _ => (dx.abs().max(2.0), dy.abs().max(2.0)),
484 };
485 r.new_shape = Some((kind, (p0.x + p1.x) / 2.0, (p0.y + p1.y) / 2.0, w, h));
486 }
487 Tool::Draw if d.points.len() > 1 => {
488 r.stroke = Some(ModelStroke { color: [255, 255, 255, 255], width: 6.0, points: d.points.clone() });
489 }
490 _ => {}
491 }
492 state.tool_drag = None;
493 }
494 true
495}
496
497fn video(ui: &mut egui::Ui, state: &mut PreviewState, c: &mut PreviewCtx<'_>, r: &mut PreviewResponse) {
498 let (rect, resp) = ui.allocate_exact_size(ui.available_size_before_wrap(), Sense::click_and_drag());
499 let painter = ui.painter_at(rect);
500 painter.rect_filled(rect, 0.0, Color32::BLACK);
501 let ppp = ui.pixels_per_point();
502 let aspect = c.project.width.max(1) as f32 / c.project.height.max(1) as f32;
503 let lb = letterbox(rect, aspect, ppp);
504 let (cw, ch) = ((lb.width() * ppp).round().max(16.0) as u32, (lb.height() * ppp).round().max(16.0) as u32);
505 r.canvas = (cw, ch);
506
507 if let Some(f) = &c.frame {
508 let (w, h) = (f.width as usize, f.height as usize);
509 if w > 0 && h > 0 && f.rgba.len() == w * h * 4 {
510 let img = egui::ColorImage::from_rgba_premultiplied([w, h], &f.rgba);
511 match &mut state.texture {
512 Some(t) if t.size() == [w, h] => t.set_partial([0, 0], img, TextureOptions::LINEAR),
514 Some(t) => t.set(img, TextureOptions::LINEAR),
515 None => state.texture = Some(ui.ctx().load_texture("preview", img, TextureOptions::LINEAR)),
516 }
517 }
518 }
519 if let Some((id, _)) = c.gpu_texture {
521 painter.image(id, lb, Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE);
522 } else if let Some(t) = &state.texture {
523 painter.image(t.id(), lb, Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE);
524 }
525 if c.buffering {
527 ui.put(Rect::from_center_size(lb.center(), vec2(32.0, 32.0)), egui::Spinner::new().size(32.0));
528 ui.ctx().request_repaint_after(std::time::Duration::from_millis(50));
529 }
530 if c.fullscreen {
531 if resp.double_clicked() {
533 r.actions.push(Action::Fullscreen);
534 }
535 let moved = ui.input(|i| i.pointer.delta() != Vec2::ZERO || i.pointer.any_down());
537 if moved || state.moved_at.is_none() {
538 state.moved_at = Some(std::time::Instant::now());
539 }
540 if state.moved_at.map(|t| t.elapsed().as_secs_f32() > 2.0).unwrap_or(false) {
541 ui.ctx().set_cursor_icon(egui::CursorIcon::None);
542 } else {
543 ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
544 }
545 state.drag = None;
546 return;
547 }
548 state.moved_at = None;
549 prerender_badge(ui, &painter, lb, c);
550
551 if tool_drag(ui, state, c, r, &resp, lb) {
554 state.drag = None;
555 return;
556 }
557 if resp.double_clicked() {
558 r.actions.push(Action::Fullscreen);
559 }
560
561 if let Some((tcx, tcy, thw, thh)) = c.tracker {
566 let k = lb.width() / c.project.width.max(1) as f32; let ctr = lb.center();
568 let bx = Rect::from_center_size(ctr + vec2(tcx * k, tcy * k), vec2(thw * 2.0 * k, thh * 2.0 * k));
569 let st = Stroke::new(1.5, c.palette.accent);
570 painter.rect_stroke(bx, 0.0, st, StrokeKind::Middle);
571 painter.line_segment([bx.center() - vec2(5.0, 0.0), bx.center() + vec2(5.0, 0.0)], st);
572 painter.line_segment([bx.center() - vec2(0.0, 5.0), bx.center() + vec2(0.0, 5.0)], st);
573 if ui.input(|i| i.pointer.press_origin()).is_some_and(|p| bx.contains(p)) && resp.dragged() {
574 if let Some(p) = resp.interact_pointer_pos() {
575 r.set_tracker = Some(((p.x - ctr.x) / k, (p.y - ctr.y) / k));
576 }
577 state.drag = None;
578 return;
579 }
580 }
581
582 let Some(clip) = c
584 .selection
585 .iter()
586 .filter_map(|&id| c.project.clip(id))
587 .find(|cl| cl.is_visual() && cl.enabled && cl.contains(c.playhead))
588 else {
589 state.drag = None;
590 return;
591 };
592 let id = clip.id;
593 let lt = clip.local(c.playhead);
594 let to_screen =
595 |x: f32, y: f32| pos2(lb.min.x + x / cw as f32 * lb.width(), lb.min.y + y / ch as f32 * lb.height());
596 let stroke = Stroke::new(1.5, c.palette.selection);
597 if clip.kind == ClipKind::Text {
598 let p = placement(c.project, clip, c.playhead, (1, 1), cw, ch, false);
599 let o = to_screen(p.cx, p.cy);
600 painter.line_segment([o - vec2(6.0, 0.0), o + vec2(6.0, 0.0)], stroke);
601 painter.line_segment([o - vec2(0.0, 6.0), o + vec2(0.0, 6.0)], stroke);
602 } else if let Some(a) = c.project.asset(clip.asset) {
603 let p = placement(c.project, clip, c.playhead, (a.width, a.height), cw, ch, true);
604 let (s, co) = p.rot.to_radians().sin_cos();
605 let (hw, hh) = (p.w / 2.0, p.h / 2.0);
606 let pts = [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)]
607 .iter()
608 .map(|&(x, y)| to_screen(p.cx + x * co - y * s, p.cy + x * s + y * co))
609 .collect();
610 painter.add(Shape::closed_line(pts, stroke));
611 }
612
613 let poly: Vec<(f32, f32)> =
615 clip.shape.as_ref().and_then(|s| s.poly_points()).map(|p| p.to_vec()).unwrap_or_default();
616 let frame = (!poly.is_empty()).then(|| {
617 let p = placement(c.project, clip, c.playhead, (1, 1), cw, ch, false);
618 let (sn, cs) = p.rot.to_radians().sin_cos();
619 let k = clip.scale.at(lt) as f32 * lb.width() / c.project.width.max(1) as f32;
621 (to_screen(p.cx, p.cy), sn, cs, if k.is_finite() && k.abs() > 1e-4 { k } else { 1e-4 })
622 });
623 let press = ui.input(|i| i.pointer.press_origin());
625 let mut hit = None;
626 if let Some((o, sn, cs, k)) = frame {
627 let scr: Vec<Pos2> =
628 poly.iter().map(|&(x, y)| pos2(o.x + (x * cs - y * sn) * k, o.y + (x * sn + y * cs) * k)).collect();
629 painter.add(Shape::closed_line(scr.clone(), stroke));
630 for (i, q) in scr.iter().enumerate() {
631 painter.circle_filled(*q, 3.5, c.palette.selection);
632 if press.is_some_and(|pp| (pp - *q).length() <= 7.0) {
633 hit = Some(i);
634 }
635 }
636 }
637
638 if resp.drag_started() {
639 (c.undo)(c.project);
640 state.point_drag = hit;
641 state.drag = hit.is_none().then(|| (clip.x.at(lt), clip.y.at(lt), Vec2::ZERO));
642 }
643 if resp.dragged() {
644 if let (Some(i), Some((o, sn, cs, k)), Some(pp)) = (state.point_drag, frame, resp.interact_pointer_pos()) {
645 let (dx, dy) = ((pp.x - o.x) / k, (pp.y - o.y) / k);
647 if let Some(p) = c.project.clip_mut(id).and_then(|cl| cl.shape.as_mut()).and_then(|s| s.points.get_mut(i)) {
648 *p = (dx * cs + dy * sn, -dx * sn + dy * cs);
649 r.edited = true;
650 }
651 } else if let Some((x0, y0, acc)) = &mut state.drag {
652 *acc += resp.drag_delta();
653 let k = c.project.width as f32 / lb.width(); let (nx, ny) = (*x0 + (acc.x * k) as f64, *y0 + (acc.y * k) as f64);
655 if let Some(cl) = c.project.clip_mut(id) {
656 cl.x.set_at(lt, nx);
657 cl.y.set_at(lt, ny);
658 r.edited = true;
659 }
660 }
661 }
662 if resp.drag_stopped() {
663 state.drag = None;
664 state.point_drag = None;
665 }
666}
667
668fn prerender_badge(ui: &egui::Ui, painter: &egui::Painter, lb: Rect, c: &PreviewCtx<'_>) {
670 let (p, label) = match (c.prerender, c.proxy) {
671 (Some(p), _) => (p, "Pre-rendering"),
672 (None, Some(p)) => (p, "Building proxy"),
673 (None, None) => return,
674 };
675 let text = format!("{label} {:.0} %", (p.clamp(0.0, 1.0) * 100.0));
676 let font = egui::TextStyle::Small.resolve(ui.style());
677 let galley = painter.layout_no_wrap(text, font, c.palette.text);
678 let pad = vec2(6.0, 3.0);
679 let at = lb.min + vec2(6.0, 6.0);
680 let bg = Rect::from_min_size(at, galley.size() + pad * 2.0);
681 painter.rect_filled(bg, 3.0, c.palette.header.gamma_multiply(0.9));
682 painter.rect_stroke(bg, 3.0, Stroke::new(1.0, c.palette.accent), StrokeKind::Inside);
683 painter.galley(at + pad, galley, c.palette.text);
684}
685
686#[cfg(test)]
687mod tests {
688 use super::*;
689 use crate::model::{Clip, MaskShape};
690 use eframe::egui::{Event, Modifiers, PointerButton, RawInput};
691
692 #[test]
693 fn letterbox_fits_and_centres() {
694 let area = Rect::from_min_size(pos2(10.0, 20.0), vec2(400.0, 300.0));
695 let lb = letterbox(area, 16.0 / 9.0, 1.0);
697 assert_eq!(lb.width(), 400.0);
698 assert_eq!(lb.height(), 225.0);
699 assert!((lb.center() - area.center()).length() <= 0.5);
700 let lb = letterbox(area, 0.5, 1.0);
702 assert_eq!(lb.height(), 300.0);
703 assert_eq!(lb.width(), 150.0);
704 assert!((lb.center() - area.center()).length() <= 0.5);
705 }
706
707 #[test]
708 fn letterbox_snaps_to_pixels() {
709 let area = Rect::from_min_size(pos2(0.3, 0.0), vec2(333.3, 200.0));
710 let lb = letterbox(area, 16.0 / 9.0, 2.0);
711 for v in [lb.min.x, lb.min.y, lb.width(), lb.height()] {
712 assert!((v * 2.0 - (v * 2.0).round()).abs() < 1e-3, "{v} not pixel aligned");
713 }
714 assert!(lb.width() <= area.width() + 0.5 && lb.height() <= area.height() + 0.5);
715 }
716
717 struct H {
718 ctx: egui::Context,
719 state: PreviewState,
720 project: Project,
721 selection: Vec<Id>,
722 tool: Tool,
723 undos: usize,
724 time: f64,
725 panel: Rect,
726 }
727
728 impl H {
729 fn new() -> Self {
730 let mut project = Project::new();
731 project.tracks[0].clips.push(Clip::new(7, ClipKind::Video, "v", 0.0, 4.0));
732 Self {
733 ctx: egui::Context::default(),
734 state: PreviewState::default(),
735 project,
736 selection: vec![7],
737 tool: Tool::Select,
738 undos: 0,
739 time: 0.0,
740 panel: Rect::from_min_size(Pos2::ZERO, vec2(700.0, 460.0)),
741 }
742 }
743 fn frame(&mut self, events: Vec<Event>) -> PreviewResponse {
744 self.frame_mod(events, Modifiers::NONE)
745 }
746 fn frame_mod(&mut self, events: Vec<Event>, modifiers: Modifiers) -> PreviewResponse {
747 self.time += 0.05;
748 let input = RawInput {
749 screen_rect: Some(self.panel),
750 time: Some(self.time),
751 events,
752 modifiers,
753 ..Default::default()
754 };
755 let pal = Palette::new(true, Color32::WHITE);
756 let H { ctx, state, project, selection, tool, undos, .. } = self;
757 let mut out = PreviewResponse::default();
758 let _ = ctx.run(input, |ctx| {
759 egui::CentralPanel::default().show(ctx, |ui| {
760 let mut undo = |_: &Project| *undos += 1;
761 out = show(
762 ui,
763 state,
764 PreviewCtx {
765 project,
766 selection,
767 playhead: 1.0,
768 playing: false,
769 fullscreen: false,
770 palette: &pal,
771 undo: &mut undo,
772 frame: None,
773 gpu_texture: None,
774 tool: *tool,
775 quality: 100,
776 movie_mode: false,
777 prerender: Some(0.4),
778 buffering: false,
779 proxy: None,
780 tracker: None,
781 },
782 );
783 });
784 });
785 out
786 }
787 fn click(&mut self, at: Pos2) -> PreviewResponse {
788 self.frame(vec![Event::PointerMoved(at)]);
789 let btn = |pressed| Event::PointerButton {
790 pos: at,
791 button: PointerButton::Primary,
792 pressed,
793 modifiers: Modifiers::NONE,
794 };
795 self.frame(vec![btn(true)]);
796 self.frame(vec![btn(false)])
797 }
798 fn drag(&mut self, from: Pos2, to: Pos2) -> PreviewResponse {
799 self.drag_mod(from, to, Modifiers::NONE)
800 }
801 fn drag_mod(&mut self, from: Pos2, to: Pos2, modifiers: Modifiers) -> PreviewResponse {
803 self.frame_mod(vec![Event::PointerMoved(from)], modifiers);
804 self.frame_mod(
805 vec![Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers }],
806 modifiers,
807 );
808 let steps = 4;
809 let (mut mask_edit, mut edited) = (false, false);
810 for i in 1..=steps {
811 let p = from + (to - from) * (i as f32 / steps as f32);
812 let f = self.frame_mod(vec![Event::PointerMoved(p)], modifiers);
813 mask_edit |= f.mask_edit;
814 edited |= f.edited;
815 }
816 let mut out = self.frame_mod(
817 vec![Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers }],
818 modifiers,
819 );
820 out.mask_edit |= mask_edit;
822 out.edited |= edited;
823 out
824 }
825 }
826
827 #[test]
828 fn transport_is_centred_under_the_video() {
829 let mut h = H::new();
830 h.frame(vec![]); h.frame(vec![]); let row = h.state.transport;
833 assert!(row.width() > 100.0, "transport measured: {row:?}");
834 let dx = (row.center().x - h.panel.center().x).abs();
835 assert!(dx < 6.0, "transport centre {} vs panel centre {}", row.center().x, h.panel.center().x);
836 }
837
838 #[test]
839 fn shape_tool_drag_reports_a_shape_instead_of_moving_the_clip() {
840 let mut h = H::new();
841 h.tool = Tool::Shape(ShapeKind::Rect);
842 h.frame(vec![]);
843 let out = h.drag(pos2(250.0, 150.0), pos2(400.0, 250.0));
844 let (kind, cx, cy, w, hh) = out.new_shape.expect("a shape was dragged out");
845 assert_eq!(kind, ShapeKind::Rect);
846 assert!(w > 1.0 && hh > 1.0, "non-empty shape: {w} x {hh}");
847 let c = h.project.clip(7).unwrap();
849 assert_eq!((c.x.value, c.y.value), (0.0, 0.0), "shape tool never moves the clip");
850 assert_eq!(h.undos, 0, "no clip undo for a shape gesture");
851 let _ = (cx, cy);
852 }
853
854 #[test]
859 fn a_shape_drag_emits_exactly_one_shape() {
860 let mut h = H::new();
861 h.tool = Tool::Shape(ShapeKind::Line);
862 h.frame(vec![]);
863 let (from, to) = (pos2(250.0, 150.0), pos2(400.0, 80.0));
864 h.frame(vec![Event::PointerMoved(from)]);
866 let mut n = 0;
867 let mut count = |r: PreviewResponse| {
868 if r.new_shape.is_some() {
869 n += 1;
870 }
871 };
872 count(h.frame(vec![Event::PointerButton {
873 pos: from,
874 button: PointerButton::Primary,
875 pressed: true,
876 modifiers: Modifiers::NONE,
877 }]));
878 for i in 1..=4 {
879 let p = from + (to - from) * (i as f32 / 4.0);
880 count(h.frame(vec![Event::PointerMoved(p)]));
881 }
882 count(h.frame(vec![Event::PointerButton {
883 pos: to,
884 button: PointerButton::Primary,
885 pressed: false,
886 modifiers: Modifiers::NONE,
887 }]));
888 for _ in 0..3 {
890 count(h.frame(vec![]));
891 }
892 assert_eq!(n, 1, "one drag, one shape (each extra one is an extra undo step)");
893 }
894
895 #[test]
896 fn line_tool_keeps_the_press_as_its_origin() {
897 for (to, want) in [
899 (pos2(400.0, 250.0), (1.0, 1.0)), (pos2(400.0, 50.0), (1.0, -1.0)), (pos2(100.0, 250.0), (-1.0, 1.0)), (pos2(100.0, 50.0), (-1.0, -1.0)), ] {
904 let mut h = H::new();
905 h.tool = Tool::Shape(ShapeKind::Line);
906 h.frame(vec![]);
907 let from = pos2(250.0, 150.0);
908 let out = h.drag(from, to);
909 let (kind, cx, cy, w, hh) = out.new_shape.expect("a line was dragged out");
910 assert_eq!(kind, ShapeKind::Line);
911 assert_eq!(w.signum(), want.0, "w sign for a drag to {to:?}: got {w}");
912 assert_eq!(hh.signum(), want.1, "h sign for a drag to {to:?}: got {hh}");
913 let (x0, y0) = (cx - w, cy - hh);
915 let (x1, y1) = (cx + w, cy + hh);
916 assert!(x1 - x0 != 0.0 && y1 - y0 != 0.0);
917 assert_eq!(((x1 - x0).signum(), (y1 - y0).signum()), want, "the far end must follow the cursor");
918 }
919 }
920
921 #[test]
922 fn shift_locks_a_rect_drag_to_a_square() {
923 let mut h = H::new();
924 h.tool = Tool::Shape(ShapeKind::Rect);
925 h.frame(vec![]);
926 let out = h.drag_mod(pos2(250.0, 150.0), pos2(400.0, 220.0), Modifiers::SHIFT);
927 let (_, _, _, w, hh) = out.new_shape.expect("a shape was dragged out");
928 assert!((w - hh).abs() < 0.01, "Shift must square the drag: {w} x {hh}");
929 }
930
931 #[test]
932 fn shift_locks_a_line_to_45_degrees() {
933 let mut h = H::new();
934 h.tool = Tool::Shape(ShapeKind::Line);
935 h.frame(vec![]);
936 let out = h.drag_mod(pos2(250.0, 150.0), pos2(400.0, 280.0), Modifiers::SHIFT);
938 let (_, _, _, w, hh) = out.new_shape.expect("a line was dragged out");
939 assert!((w.abs() - hh.abs()).abs() < 0.5, "45 deg: |w| should equal |hh|, got {w} x {hh}");
940 }
941
942 #[test]
943 fn alt_grows_a_shape_from_the_press_point() {
944 let mut h = H::new();
945 h.tool = Tool::Shape(ShapeKind::Rect);
946 h.frame(vec![]);
947 let press = pos2(250.0, 150.0);
948 let plain = h.drag(press, pos2(350.0, 220.0)).new_shape.unwrap();
949 let mut h2 = H::new();
950 h2.tool = Tool::Shape(ShapeKind::Rect);
951 h2.frame(vec![]);
952 let alt = h2.drag_mod(press, pos2(350.0, 220.0), Modifiers::ALT).new_shape.unwrap();
953 assert!((alt.3 - plain.3 * 2.0).abs() < 0.5, "half-width doubles under Alt: {} vs {}", alt.3, plain.3);
955 assert!((alt.4 - plain.4 * 2.0).abs() < 0.5, "half-height doubles under Alt: {} vs {}", alt.4, plain.4);
956 }
957
958 #[test]
959 fn polygon_tool_places_and_closes_real_points() {
960 let mut h = H::new();
961 h.tool = Tool::Shape(ShapeKind::Polygon);
962 h.frame(vec![]);
963 for p in [pos2(300.0, 120.0), pos2(380.0, 240.0), pos2(220.0, 240.0)] {
964 let out = h.click(p);
965 assert!(out.new_shape.is_none(), "the path is still open");
966 }
967 assert_eq!(h.state.poly.len(), 3, "one vertex per click");
968 let out = h.frame(vec![Event::Key {
969 key: egui::Key::Enter,
970 physical_key: None,
971 pressed: true,
972 repeat: false,
973 modifiers: Modifiers::NONE,
974 }]);
975 let (kind, cx, cy, w, hh) = out.new_shape.expect("Enter closes the path");
976 assert_eq!(kind, ShapeKind::Polygon);
977 assert_eq!(out.new_points.len(), 3);
978 let mx = out.new_points.iter().fold(0.0f32, |a, p| a.max(p.0.abs()));
980 let my = out.new_points.iter().fold(0.0f32, |a, p| a.max(p.1.abs()));
981 assert!(w > 1.0 && hh > 1.0 && cx.is_finite() && cy.is_finite(), "{:?}", (cx, cy, w, hh));
982 assert!((mx - w).abs() < 0.5 && (my - hh).abs() < 0.5, "{:?} vs {:?}", (mx, my), (w, hh));
983 assert!(h.state.poly.is_empty(), "the in-progress path is consumed");
984 for p in [pos2(200.0, 100.0), pos2(260.0, 100.0), pos2(260.0, 160.0)] {
986 h.click(p);
987 }
988 let out = h.click(pos2(260.0, 160.0));
989 assert_eq!(out.new_points.len(), 3, "a click back on a vertex closes: {:?}", out.new_points);
990 }
991
992 #[test]
993 fn select_tool_drags_a_polygon_vertex() {
994 let mut h = H::new();
995 let id = h.project.add_shape_clip(ShapeKind::Polygon, 0.0, 4.0);
996 let s = h.project.clip_mut(id).unwrap().shape.as_mut().unwrap();
997 s.points = vec![(0.0, 0.0), (200.0, 100.0), (-200.0, 100.0)];
998 h.selection = vec![id];
999 h.frame(vec![]);
1000 h.frame(vec![]);
1002 let at = pos2(h.panel.center().x, h.state.transport.top() / 2.0);
1004 let out = h.drag(at, at + vec2(40.0, 20.0));
1005 assert!(out.edited, "dragging a handle edits");
1006 let cl = h.project.clip(id).unwrap();
1007 let p = cl.shape.as_ref().unwrap().points[0];
1008 assert!(p.0 > 10.0 && p.1 > 5.0, "the vertex followed the pointer: {p:?}");
1009 assert_eq!(cl.shape.as_ref().unwrap().points[1], (200.0, 100.0), "the other vertices stay put");
1010 assert_eq!((cl.x.value, cl.y.value), (0.0, 0.0), "grabbing a handle never moves the clip");
1011 assert_eq!(h.undos, 1, "one undo for the gesture");
1012 }
1013
1014 #[test]
1015 fn draw_tool_records_a_timed_stroke() {
1016 let mut h = H::new();
1017 h.tool = Tool::Draw;
1018 h.frame(vec![]);
1019 let out = h.drag(pos2(200.0, 120.0), pos2(430.0, 260.0));
1020 let s = out.stroke.expect("a stroke was recorded");
1021 assert!(s.points.len() > 1, "{} points", s.points.len());
1022 assert!(s.points[0].2 <= s.points[s.points.len() - 1].2, "times increase");
1023 }
1024
1025 #[test]
1026 fn mask_tool_drag_edits_the_selected_clips_mask() {
1027 let mut h = H::new();
1028 h.tool = Tool::Mask(MaskShape::Ellipse);
1029 h.frame(vec![]);
1030 let out = h.drag(pos2(240.0, 140.0), pos2(420.0, 260.0));
1031 assert!(out.mask_edit, "mask edits are reported");
1032 let m = h.project.clip(7).unwrap().mask.clone().expect("mask created");
1033 assert_eq!(m.shape, MaskShape::Ellipse);
1034 assert!(m.rx.value > 1.0 && m.ry.value > 1.0, "{:?}", (m.rx.value, m.ry.value));
1035 assert_eq!(h.undos, 1, "one undo for the mask gesture");
1036 }
1037
1038 #[test]
1040 fn mask_tool_drag_ignores_an_audio_clip() {
1041 let mut h = H::new();
1042 h.project.clip_mut(7).unwrap().kind = ClipKind::Audio;
1043 h.tool = Tool::Mask(MaskShape::Ellipse);
1044 h.frame(vec![]);
1045 let out = h.drag(pos2(240.0, 140.0), pos2(420.0, 260.0));
1046 assert!(!out.mask_edit, "nothing was masked");
1047 assert!(h.project.clip(7).unwrap().mask.is_none(), "no mask on an audio clip");
1048 assert_eq!(h.undos, 0, "and no undo entry for the edit that never happened");
1049 }
1050
1051 #[test]
1052 fn polygon_mask_drag_leaves_usable_vertices() {
1053 let mut h = H::new();
1054 h.tool = Tool::Mask(MaskShape::Polygon);
1055 h.frame(vec![]);
1056 h.drag(pos2(240.0, 140.0), pos2(420.0, 260.0));
1057 let m = h.project.clip(7).unwrap().mask.clone().expect("mask created");
1058 assert_eq!(m.shape, MaskShape::Polygon);
1059 assert!(m.points.len() >= 3, "{:?}", m.points);
1061 let xs: Vec<f32> = m.points.iter().map(|p| p.0).collect();
1062 assert!(xs.iter().cloned().fold(f32::MIN, f32::max) > xs.iter().cloned().fold(f32::MAX, f32::min));
1063 }
1064
1065 #[test]
1066 fn select_tool_still_moves_the_clip() {
1067 let mut h = H::new();
1068 h.project.clip_mut(7).unwrap().asset = 0;
1069 h.frame(vec![]);
1070 let out = h.drag(pos2(300.0, 150.0), pos2(360.0, 190.0));
1071 let _ = out;
1073 }
1074}