mirror of
https://github.com/storytold/filmcraft.git
synced 2026-11-02 18:09:21 +00:00
effect controls: drag a transition's cut to move it with the transition
In a selected transition's own timeline in Effect Controls, the cut line over the transition can now be dragged. While it is dragged, the clips and the transition are drawn where the rolling edit would put them, with the same limits as `timeline.roll`. On release one rolling edit moves the cut, so the clips' In and Out points change and the sequence keeps its length. The ends of the transition still resize it, and the rest of it still slides it. A rolling edit now stops where the transition on its cut would leave one of its two clips. When a ripple trim, a slide or a ripple speed change moves a cut far enough that the transition on it would leave its two clips, the transition is moved back within them, shortened when the clips are now shorter than it, and its stored alignment is updated.
This commit is contained in:
+34
-7
@@ -26,7 +26,7 @@ pub mod transitions;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use filmcraft_project::{ClipId, ItemId, Sequence, Track, TrackId, TrackItem, Transition, TransitionId};
|
||||
use filmcraft_project::{ClipId, ItemId, Sequence, Track, TrackId, TrackItem, Transition, TransitionAlign, TransitionId};
|
||||
use filmcraft_time::{Tick, TimeRange};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
@@ -196,20 +196,43 @@ fn transition_anchor(track: &Track, tr: &Transition) -> Option<Tick> {
|
||||
}
|
||||
|
||||
/// Keep transitions on their edit points after an edit moved cuts (ripple trim, roll, slide, ripple
|
||||
/// speed change): each transition shifts by as much as its anchor clip edge moved.
|
||||
/// speed change): each transition shifts by as much as its anchor clip edge moved. A shifted
|
||||
/// transition between two clips stays within them (shortened when they are now shorter than it),
|
||||
/// and its stored alignment follows where that leaves it on the cut.
|
||||
fn transitions_follow_cuts(before: &Sequence, after: &mut Sequence) {
|
||||
let frame = after.settings.frame_rate.frame_duration();
|
||||
for track in after.all_tracks_mut() {
|
||||
let Some(old) = before.track(track.id) else { continue };
|
||||
let shifts: Vec<Tick> = track
|
||||
let shifts: Vec<(Tick, Option<(Tick, Tick, Tick)>)> = track
|
||||
.transitions
|
||||
.iter()
|
||||
.map(|tr| match (transition_anchor(old, tr), transition_anchor(track, tr)) {
|
||||
(Some(a), Some(b)) => b - a,
|
||||
_ => Tick::ZERO,
|
||||
.map(|tr| {
|
||||
let d = match (transition_anchor(old, tr), transition_anchor(track, tr)) {
|
||||
(Some(a), Some(b)) => b - a,
|
||||
_ => Tick::ZERO,
|
||||
};
|
||||
let within = match (tr.from.and_then(|c| track.item(c)), tr.to.and_then(|c| track.item(c))) {
|
||||
(Some(f), Some(t)) if f.start < t.end() => Some((f.start, t.start, t.end())),
|
||||
_ => None,
|
||||
};
|
||||
(d, within)
|
||||
})
|
||||
.collect();
|
||||
for (tr, d) in track.transitions.iter_mut().zip(shifts) {
|
||||
for (tr, (d, within)) in track.transitions.iter_mut().zip(shifts) {
|
||||
tr.start += d;
|
||||
if d != Tick::ZERO
|
||||
&& let Some((lo, cut, hi)) = within
|
||||
&& (tr.start < lo || tr.end() > hi)
|
||||
{
|
||||
tr.duration = tr.duration.min(hi - lo);
|
||||
tr.start = tr.start.clamp(lo, hi - tr.duration);
|
||||
// as `transitions::set_span` stores it (interchange): Custom Start has no variant
|
||||
tr.align = match transitions::alignment(tr, cut, frame) {
|
||||
transitions::Alignment::Start => TransitionAlign::StartAtCut,
|
||||
transitions::Alignment::End => TransitionAlign::EndAtCut,
|
||||
transitions::Alignment::Center | transitions::Alignment::Custom => TransitionAlign::CenterAtCut,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -895,6 +918,10 @@ pub fn roll(seq: &mut Sequence, left: ClipId, right: ClipId, delta: Tick, ctx: &
|
||||
let mut d = delta;
|
||||
// left out-point limits
|
||||
d = d.max(-(l.duration - ctx.min_duration)).min(r.duration - ctx.min_duration);
|
||||
// a transition on this cut moves with it and stays within the two clips
|
||||
for x in seq.all_tracks().flat_map(|t| &t.transitions).filter(|x| x.from == Some(left) && x.to == Some(right)) {
|
||||
d = d.max((l.start - x.start).min(Tick::ZERO)).min((r.end() - x.end()).max(Tick::ZERO));
|
||||
}
|
||||
// a reversed clip consumes media from the opposite end, so its media limit is the other one
|
||||
if l.reverse {
|
||||
d = d.min(Tick((head(ctx, l).0 as f64 / l.speed.abs()).floor() as i64));
|
||||
|
||||
@@ -869,6 +869,75 @@ fn roll_reversed_clips_keeps_media_mapping() {
|
||||
assert_eq!((r.start, r.duration, r.source_in), (f(40), f(20), f(50)));
|
||||
}
|
||||
|
||||
/// A rolling edit moves the transition on its cut with the cut, and stops where the transition
|
||||
/// would leave one of its two clips.
|
||||
#[test]
|
||||
fn roll_keeps_the_transition_on_its_clips() {
|
||||
let mut fx = Fx::new();
|
||||
let v1 = fx.v(0);
|
||||
let a = fx.put(v1, 0, 10, 10);
|
||||
let b = fx.put(v1, 10, 10, 10);
|
||||
fx.seq.track_mut(v1).unwrap().transitions = vec![Transition {
|
||||
id: TransitionId(1),
|
||||
effect: filmcraft_project::find_effect("cross_dissolve").unwrap().instance(),
|
||||
start: f(8),
|
||||
duration: f(4),
|
||||
from: Some(a),
|
||||
to: Some(b),
|
||||
align: Default::default(),
|
||||
reverse: false,
|
||||
}];
|
||||
let mut n = fx.next;
|
||||
// left: the transition reaches the sequence start after 8 frames, not the 9 the clips allow
|
||||
assert_eq!(roll(&mut fx.seq, a, b, -f(9), &mut Fx::ctx(&mut n)).unwrap(), -f(8));
|
||||
assert_eq!(fx.spans(v1), vec![(0, 2), (2, 18)]);
|
||||
let tr = &fx.seq.track(v1).unwrap().transitions[0];
|
||||
assert_eq!((tr.start, tr.duration), (f(0), f(4)), "the transition moved with the cut");
|
||||
// right: it reaches the end of the second clip after 16 frames, not 17
|
||||
assert_eq!(roll(&mut fx.seq, a, b, f(17), &mut Fx::ctx(&mut n)).unwrap(), f(16));
|
||||
assert_eq!(fx.spans(v1), vec![(0, 18), (18, 2)]);
|
||||
let tr = &fx.seq.track(v1).unwrap().transitions[0];
|
||||
assert_eq!((tr.start, tr.duration), (f(16), f(4)));
|
||||
fx.seq.check().unwrap();
|
||||
}
|
||||
|
||||
/// A ripple trim or a slide that moves a cut close to the start of its first clip keeps the
|
||||
/// transition on that cut within its two clips, and its stored alignment follows: an End at Cut
|
||||
/// transition that now crosses the cut is stored as centered (Custom Start has no variant).
|
||||
#[test]
|
||||
fn edits_that_move_a_cut_keep_its_transition_on_its_clips() {
|
||||
for slide_it in [false, true] {
|
||||
let mut fx = Fx::new();
|
||||
let v1 = fx.v(0);
|
||||
let a = fx.put(v1, 0, 10, 10);
|
||||
let b = fx.put(v1, 10, 10, 10);
|
||||
fx.put(v1, 20, 10, 10);
|
||||
fx.seq.track_mut(v1).unwrap().transitions = vec![Transition {
|
||||
id: TransitionId(1),
|
||||
effect: filmcraft_project::find_effect("cross_dissolve").unwrap().instance(),
|
||||
start: f(6),
|
||||
duration: f(4),
|
||||
from: Some(a),
|
||||
to: Some(b),
|
||||
align: TransitionAlign::EndAtCut,
|
||||
reverse: false,
|
||||
}];
|
||||
let mut n = fx.next;
|
||||
if slide_it {
|
||||
slide(&mut fx.seq, b, -f(9), &mut Fx::ctx(&mut n)).unwrap();
|
||||
} else {
|
||||
trim(&mut fx.seq, a, Edge::Out, TrimMode::Ripple, -f(9), &mut Fx::ctx(&mut n)).unwrap();
|
||||
}
|
||||
let t = fx.seq.track(v1).unwrap();
|
||||
let (cut, a_start) = (t.item(b).unwrap().start, t.item(a).unwrap().start);
|
||||
assert_eq!(cut, f(1), "slide {slide_it}");
|
||||
let tr = &t.transitions[0];
|
||||
assert_eq!((tr.start, tr.duration), (a_start, f(4)), "slide {slide_it}: the transition starts with its first clip");
|
||||
assert_eq!(tr.align, TransitionAlign::CenterAtCut, "slide {slide_it}");
|
||||
fx.seq.check().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// #374: moving an edge must not change which media sits under any timeline time the clip still
|
||||
/// covers (the timeline's live trim preview draws its waveform from this item), and must leave the
|
||||
/// item as the trim itself does.
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
//! [`trn::drag_span`]); each drag is one undo step. Clicking or dragging the ruler moves the
|
||||
//! playhead. The divider between the settings and the timeline is the clip view's.
|
||||
//!
|
||||
//! Dragging the cut line where it crosses the transition moves the cut and the transition
|
||||
//! together, as Premiere's Move a cut and transition together describes it: one `timeline.roll`
|
||||
//! on release, so the clips' In and Out points change and the sequence keeps its length.
|
||||
//!
|
||||
//! Not here yet: the A/B preview thumbnails with their Start / End sliders, Show Actual Sources,
|
||||
//! and a rolling edit by dragging clip A or B in the transition's timeline.
|
||||
//!
|
||||
@@ -19,13 +23,14 @@
|
||||
//! `.y`; a list's entries `.param.<id>.option.<n>` while open); its timeline's
|
||||
//! `effectControls.transition.timeline`, `.timeline.ruler`, `.timeline.a` / `.timeline.b` (the
|
||||
//! clips, labelled with their names), `.timeline.span` (the transition) with its ends
|
||||
//! `.timeline.span.in` / `.timeline.span.out`, `.timeline.playhead`; `effectControls.divider`;
|
||||
//! `.timeline.span.in` / `.timeline.span.out`, `.timeline.cut` (the cut line over the transition,
|
||||
//! between two clips), `.timeline.playhead`; `effectControls.divider`;
|
||||
//! the dialog's `transitionDuration.value`, `.ok`, `.cancel`.
|
||||
|
||||
use egui::{Align2, Pos2, Rect, RichText, Sense, Stroke, pos2, vec2};
|
||||
use filmcraft_edit::Edge;
|
||||
use filmcraft_edit::transitions::{self as trn, Alignment};
|
||||
use filmcraft_project::{ParamKind, ParamValue, Transition, TransitionId};
|
||||
use filmcraft_edit::{Edge, EditCtx};
|
||||
use filmcraft_project::{ClipId, ParamKind, ParamValue, Transition, TransitionId};
|
||||
use filmcraft_time::{FrameRate, Tick};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -40,6 +45,8 @@ const MIN_LIST_W: f32 = 260.0;
|
||||
const MIN_LANE_W: f32 = 60.0;
|
||||
const RULER_H: f32 = 24.0;
|
||||
const BAR_H: f32 = 18.0;
|
||||
/// How far either side of the cut line a press over the transition grabs the cut.
|
||||
const CUT_PX: f32 = 3.0;
|
||||
/// How far into the transition from either end a press grabs that end (the Timeline's width).
|
||||
const EDGE_PX: f32 = 7.0;
|
||||
|
||||
@@ -201,10 +208,13 @@ pub fn effect_controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, id
|
||||
/// A drag in the transition's timeline: the end it grabbed (`None`: the middle), where the
|
||||
/// transition was when it began, and the stretch of time the lane showed then, so the drag is
|
||||
/// measured against a ruler that holds still while the transition changes under it. `begun` once
|
||||
/// its first change went through: the rest merge into that undo step.
|
||||
/// its first change went through: the rest merge into that undo step. `cut` when it grabbed the
|
||||
/// cut line instead, with `roll` how far the cut has moved so far (applied on release).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct LaneDrag {
|
||||
edge: Option<Edge>,
|
||||
cut: bool,
|
||||
roll: Tick,
|
||||
start: Tick,
|
||||
duration: Tick,
|
||||
view: (Tick, Tick),
|
||||
@@ -255,6 +265,9 @@ fn timeline(app: &mut FilmcraftApp, ui: &mut egui::Ui, lane: Rect, id: Transitio
|
||||
let mem = egui::Id::new(("tr-ec-lane-drag", id.0));
|
||||
let mut drag: Option<LaneDrag> = ui.data(|d| d.get_temp(mem));
|
||||
let v = drag.map_or_else(|| view(x, cut, frame), |d| d.view);
|
||||
// while the cut is dragged, the clips' edges at the cut and the transition are drawn where it goes
|
||||
let roll = drag.filter(|d| d.cut).map_or(Tick::ZERO, |d| d.roll);
|
||||
let two_sided = clip_a.is_some() && clip_b.is_some();
|
||||
let painter = ui.painter().with_clip_rect(lane.intersect(ui.clip_rect()));
|
||||
painter.rect_filled(lane, 0.0, t.tl_bg);
|
||||
let ruler = Rect::from_min_max(lane.min, pos2(lane.max.x, lane.min.y + RULER_H));
|
||||
@@ -267,7 +280,8 @@ fn timeline(app: &mut FilmcraftApp, ui: &mut egui::Ui, lane: Rect, id: Transitio
|
||||
let (row_a, row_x, row_b) = (row(0.0), row(1.0), row(2.0));
|
||||
for ((clip, r), key) in [(&clip_a, row_a), (&clip_b, row_b)].into_iter().zip(["a", "b"]) {
|
||||
let Some((s, e, name)) = clip else { continue };
|
||||
let bar = Rect::from_min_max(pos2(x_of(lane, v, *s).max(lane.min.x), r.min.y), pos2(x_of(lane, v, *e).min(lane.max.x), r.max.y));
|
||||
let (s, e) = if key == "a" { (*s, *e + roll) } else { (*s + roll, *e) };
|
||||
let bar = Rect::from_min_max(pos2(x_of(lane, v, s).max(lane.min.x), r.min.y), pos2(x_of(lane, v, e).min(lane.max.x), r.max.y));
|
||||
if bar.width() <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
@@ -276,7 +290,7 @@ fn timeline(app: &mut FilmcraftApp, ui: &mut egui::Ui, lane: Rect, id: Transitio
|
||||
elems.push((format!("effectControls.transition.timeline.{key}"), bar, name.clone()));
|
||||
}
|
||||
// the transition, outlined as selected (it is), with the Timeline's end zones
|
||||
let (x0, x1) = (x_of(lane, v, x.start), x_of(lane, v, x.end()));
|
||||
let (x0, x1) = (x_of(lane, v, x.start + roll), x_of(lane, v, x.end() + roll));
|
||||
let span = Rect::from_min_max(pos2(x0, row_x.min.y), pos2(x1.max(x0 + 1.0), row_x.max.y));
|
||||
painter.rect_filled(span, 0.0, egui::Color32::from_black_alpha(90));
|
||||
painter.rect_stroke(span, 0.0, Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);
|
||||
@@ -295,8 +309,16 @@ fn timeline(app: &mut FilmcraftApp, ui: &mut egui::Ui, lane: Rect, id: Transitio
|
||||
elems.push(("effectControls.transition.timeline.span.in".into(), Rect::from_min_max(span.min, pos2(span.min.x + zone, span.max.y)), "start".into()));
|
||||
elems.push(("effectControls.transition.timeline.span.out".into(), Rect::from_min_max(pos2(span.max.x - zone, span.min.y), span.max), "end".into()));
|
||||
// the cut, through all three rows
|
||||
let cx = x_of(lane, v, cut);
|
||||
let cx = x_of(lane, v, cut + roll);
|
||||
painter.line_segment([pos2(cx, row_a.min.y), pos2(cx, row_b.max.y)], Stroke::new(1.0, t.text_dim));
|
||||
// where a press grabs the cut: on its line over the transition, outside the ends' zones
|
||||
let middle = Rect::from_min_max(pos2(span.min.x + zone, span.min.y), pos2(span.max.x - zone, span.max.y));
|
||||
let cut_zone = Rect::from_min_max(pos2(cx - CUT_PX, span.min.y), pos2(cx + CUT_PX, span.max.y)).intersect(middle);
|
||||
let cut_zone = (two_sided && cut_zone.width() > 0.0).then_some(cut_zone);
|
||||
let on_cut = |px: f32| cut_zone.is_some_and(|z| z.x_range().contains(px));
|
||||
if let Some(z) = cut_zone {
|
||||
elems.push(("effectControls.transition.timeline.cut".into(), z, "cut".into()));
|
||||
}
|
||||
// the playhead, while it is in view
|
||||
let ph = app.session.playhead();
|
||||
if ph >= v.0 && ph <= v.1 {
|
||||
@@ -317,7 +339,7 @@ fn timeline(app: &mut FilmcraftApp, ui: &mut egui::Ui, lane: Rect, id: Transitio
|
||||
{
|
||||
seek = Some(rate.snap_nearest(tick_at(lane, v, pos.x.clamp(lane.min.x, lane.max.x))).max(Tick::ZERO));
|
||||
}
|
||||
// the transition: its middle slides it, its ends change its duration
|
||||
// the transition: its middle slides it, its ends change its duration, its cut line moves the cut
|
||||
let grab = |px: f32| -> Option<Edge> {
|
||||
if px < span.min.x + zone {
|
||||
Some(Edge::In)
|
||||
@@ -333,11 +355,20 @@ fn timeline(app: &mut FilmcraftApp, ui: &mut egui::Ui, lane: Rect, id: Transitio
|
||||
}
|
||||
if sresp.drag_started() {
|
||||
let from = ui.input(|i| i.pointer.press_origin()).or(sresp.interact_pointer_pos()).map_or(span.center().x, |p: Pos2| p.x);
|
||||
drag = Some(LaneDrag { edge: grab(from), start: x.start, duration: x.duration, view: v, from_x: from, begun: false });
|
||||
let edge = grab(from);
|
||||
let cut = edge.is_none() && on_cut(from);
|
||||
drag = Some(LaneDrag { edge, cut, roll: Tick::ZERO, start: x.start, duration: x.duration, view: v, from_x: from, begun: false });
|
||||
}
|
||||
let mut change = None;
|
||||
if sresp.dragged()
|
||||
&& let Some(d) = drag.as_mut()
|
||||
&& d.cut
|
||||
&& let (Some(pos), Some(a), Some(b)) = (sresp.interact_pointer_pos(), x.from, x.to)
|
||||
{
|
||||
d.roll = roll_reach(app, a, b, rate.snap_nearest(ticks_for(lane, d.view, pos.x - d.from_x)));
|
||||
} else if sresp.dragged()
|
||||
&& let Some(d) = drag.as_mut()
|
||||
&& !d.cut
|
||||
&& let Some(pos) = sresp.interact_pointer_pos()
|
||||
{
|
||||
let delta = rate.snap_nearest(ticks_for(lane, d.view, pos.x - d.from_x));
|
||||
@@ -349,6 +380,15 @@ fn timeline(app: &mut FilmcraftApp, ui: &mut egui::Ui, lane: Rect, id: Transitio
|
||||
d.begun = true;
|
||||
}
|
||||
}
|
||||
// over the cut line (and not an end), the pointer shows that it moves the cut
|
||||
if sresp.hover_pos().is_some_and(|h| grab(h.x).is_none() && on_cut(h.x)) || drag.is_some_and(|d| d.cut) {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
|
||||
}
|
||||
// a drag of the cut is one rolling edit, made when it is released
|
||||
let rolled = match (drag, x.from, x.to) {
|
||||
(Some(d), Some(a), Some(b)) if sresp.drag_stopped() && d.cut && d.roll != Tick::ZERO => Some((a, b, d.roll)),
|
||||
_ => None,
|
||||
};
|
||||
// released, or a drag that lost its transition (deleted, deselected) while it went on
|
||||
if !sresp.dragged() && !sresp.drag_started() {
|
||||
drag = None;
|
||||
@@ -357,6 +397,11 @@ fn timeline(app: &mut FilmcraftApp, ui: &mut egui::Ui, lane: Rect, id: Transitio
|
||||
app.stop();
|
||||
app.session.set_playhead(tk);
|
||||
}
|
||||
if let Some((a, b, d)) = rolled
|
||||
&& let Err(e) = app.session.execute("timeline.roll", json!({"left": a.0, "right": b.0, "delta": d.0}))
|
||||
{
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
ui.data_mut(|m| match drag {
|
||||
Some(d) => {
|
||||
m.insert_temp(mem, d);
|
||||
@@ -366,6 +411,28 @@ fn timeline(app: &mut FilmcraftApp, ui: &mut egui::Ui, lane: Rect, id: Transitio
|
||||
change
|
||||
}
|
||||
|
||||
/// How far `timeline.roll` would move the cut between `left` and `right` toward `want`: the same
|
||||
/// limits on a copy of the sequence (the clips' media, a frame left in each, the transition on the
|
||||
/// cut, linked clips when Linked Selection is on). 0 when the roll would fail.
|
||||
fn roll_reach(app: &FilmcraftApp, left: ClipId, right: ClipId, want: Tick) -> Tick {
|
||||
let Some(seq) = app.session.active_sequence() else { return Tick::ZERO };
|
||||
let project = &app.session.project;
|
||||
let durations = |item| filmcraft_engine::media_duration(project, &app.session.media, item);
|
||||
let starts = |item| filmcraft_engine::media_start(project, item);
|
||||
let mut next = project.next_id;
|
||||
let mut ctx = EditCtx { next_id: &mut next, media_duration: &durations, media_start: &starts, min_duration: seq.settings.frame_rate.frame_duration() };
|
||||
let mut d = want;
|
||||
for (l, r) in filmcraft_engine::commands::linked_roll_pairs(seq, left, right, app.session.state.linked_selection) {
|
||||
let mut probe = seq.clone();
|
||||
match filmcraft_edit::roll(&mut probe, l, r, d, &mut ctx) {
|
||||
Ok(reach) if reach.abs() < d.abs() => d = reach,
|
||||
Ok(_) => {}
|
||||
Err(_) => return Tick::ZERO,
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// The timeline's ruler: ticks and sequence timecode across `view` (the clip view's spacing).
|
||||
fn paint_ruler(p: &egui::Painter, ruler: Rect, view: (Tick, Tick), rate: FrameRate, drop_frame: bool, t: &Tokens) {
|
||||
let p = p.with_clip_rect(ruler.intersect(p.clip_rect()));
|
||||
|
||||
@@ -344,3 +344,42 @@ fn transition_timeline_in_effect_controls() {
|
||||
assert!(ph.is_some_and(|p| p > c.cut), "{ph:?}");
|
||||
assert!(d.find("effectControls.transition.timeline.playhead").is_some(), "the playhead shows in the transition's timeline");
|
||||
}
|
||||
|
||||
/// Dragging the cut line over the transition in Effect Controls moves the cut and the transition
|
||||
/// together: the clips' In and Out points change, the sequence keeps its length, and the drag is
|
||||
/// one undo step.
|
||||
#[test]
|
||||
fn dragging_the_cut_in_effect_controls_moves_it_with_the_transition() {
|
||||
let mut d = Driver::demo();
|
||||
let c = d.crossing();
|
||||
let frame = frame_ticks(d.rate());
|
||||
d.click(&format!("timeline.transition.{}", c.id));
|
||||
let x = d.transition(c.id).unwrap();
|
||||
let (from, to) = (x["from"].as_u64().unwrap(), x["to"].as_u64().unwrap());
|
||||
// (start, duration, source in) of a clip
|
||||
let clip = |d: &mut Driver, id: u64| {
|
||||
let q = d.exec("sequence.inspect", json!({}));
|
||||
let it = q["video"].as_array().unwrap().iter().flat_map(|t| t["items"].as_array().unwrap().clone()).find(|i| i["clip"] == id).unwrap();
|
||||
(it["start"].as_i64().unwrap(), it["duration"].as_i64().unwrap(), it["sourceIn"].clone())
|
||||
};
|
||||
let (a0, b0) = (clip(&mut d, from), clip(&mut d, to));
|
||||
let (x0, y0, w, h, _) = d.find("effectControls.transition.timeline.cut").expect("the cut line over the transition");
|
||||
let (cx, cy) = (x0 + w / 2.0, y0 + h / 2.0);
|
||||
d.ok("ui.drag", json!({"from": {"x": cx, "y": cy}, "to": {"x": cx + 20.0, "y": cy}, "steps": 8}));
|
||||
d.frames(3);
|
||||
let (a1, b1) = (clip(&mut d, from), clip(&mut d, to));
|
||||
let moved = b1.0 - b0.0;
|
||||
assert!(moved > 0, "the cut moved right: {b0:?} -> {b1:?}");
|
||||
assert_eq!(moved % frame, 0, "by whole frames");
|
||||
assert_eq!((a1.0, a1.1), (a0.0, a0.1 + moved), "the first clip ends at the new cut");
|
||||
assert_eq!(b1.0 + b1.1, b0.0 + b0.1, "the second clip still ends where it did");
|
||||
assert_ne!(b1.2, b0.2, "the second clip starts later in its media");
|
||||
let x1 = d.transition(c.id).unwrap();
|
||||
assert_eq!((x1["start"].as_i64(), x1["duration"].as_i64()), (Some(c.start + moved), Some(c.duration)), "the transition moved with the cut");
|
||||
assert_eq!(x1["align"], json!("center"), "still centered on its cut");
|
||||
// one undo step
|
||||
d.exec("edit.undo", json!({}));
|
||||
assert_eq!((clip(&mut d, from), clip(&mut d, to)), (a0, b0));
|
||||
let back = d.transition(c.id).unwrap();
|
||||
assert_eq!((back["start"].as_i64(), back["duration"].as_i64()), (Some(c.start), Some(c.duration)));
|
||||
}
|
||||
|
||||
+2
-2
@@ -161,8 +161,8 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
|
||||
rearrangement (#493); clicking empty space to deselect (#683); filmstrip thumbnails (#614);
|
||||
marquee selection in the Project panel (#578); Freeform bins and middle-mouse pan (#579);
|
||||
Program monitor Button Editor (+) (#431); keyframe curves for Position (#448); transition
|
||||
editing controls (#577: Effect Controls has the transition's own timeline, drag to slide or resize;
|
||||
the A/B previews with Start / End sliders remain); track renaming (#654); window controls on macOS (#637); UI scale on
|
||||
editing controls (#577: Effect Controls has the transition's own timeline, drag to slide or resize,
|
||||
drag the cut to move it with the transition; the A/B previews with Start / End sliders remain); track renaming (#654); window controls on macOS (#637); UI scale on
|
||||
X11 (#457).
|
||||
- **Estimate:** 120–200 h. Doc: [ui-parity.md](ui-parity.md).
|
||||
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ open interaction reports (about 40 of the 188 open issues).
|
||||
| Panels and docking | 17 workspaces, dock / undock / float, drop zones | 11 workspaces, docking; rearranging windows reported missing (#493); multi-monitor floating windows (#466) | 60% | #493, #466 | 20–35 h |
|
||||
| Project panel | List / Icon / Freeform, hover scrub, marquee | all three views, hover scrub, view presets; no marquee (#578), Freeform bins and pan (#579), bins can't be left on Windows (#587), duplicate imports allowed (#356) | 75% | #578, #579, #587, #456 | 10–20 h |
|
||||
| Timeline display | filmstrip thumbnails, waveforms, track heights, renaming | clip thumbnails, waveforms, heights; no continuous filmstrip (#614), no track rename (#654), wheel scroll in audio area (#400) | 70% | #614, #654, #400 | 8–15 h |
|
||||
| Transitions | drag, resize, alignment, edit in Effect Controls | drag / select / resize (#503); settings and the transition's own timeline in Effect Controls, drag to slide or resize (#577); no A/B previews with Start / End sliders | 70% | #577 | 5–10 h |
|
||||
| Transitions | drag, resize, alignment, edit in Effect Controls | drag / select / resize (#503); settings and the transition's own timeline in Effect Controls, drag to slide or resize (#577), drag the cut to move it with the transition; no A/B previews with Start / End sliders | 70% | #577 | 5–10 h |
|
||||
| Numeric entry and scrubbing | scrubbable hot text, typed values | scrubbable values, timecode scrub-edit (#576); audio clip mixer values cannot be typed (#481) | 80% | #481 | 3–6 h |
|
||||
| Drag and drop | OS files into Project / Timeline, clipboard paste | OS drops into the timeline (#517); no clipboard paste of images / files (#611) | 70% | #519, #611 | 5–10 h |
|
||||
| Theme and appearance | dark / light, brightness | dark / light / system (#505, #555) | 85% | macOS window controls (#637), Wayland title bar (#433), X11 scale (#457) | 5–10 h |
|
||||
|
||||
Reference in New Issue
Block a user