diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d5766c0b..56048064 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -43,6 +43,8 @@ pub struct TlState { pub drag: Option, /// What the primary button went down on in the Timeline (#259); see [`Pressed`]. pub pressed: Option, + /// A drag or press aborted with Escape; ignores release until the button comes up (#580). + pub cancelled: bool, /// Last layout (for hit-testing from the control channel). pub layout: Option, /// Snap indicator x (screen) this frame. @@ -87,7 +89,9 @@ pub struct Pressed { #[derive(Clone, Debug)] pub enum Drag { - Scrub, + Scrub { + initial: Tick, + }, Move { clips: Vec, grab_tick: Tick, @@ -150,6 +154,8 @@ pub enum Drag { }, Pan { last: Pos2, + start_scroll: f64, + start_v: f32, }, Marquee { start: Pos2, @@ -159,6 +165,7 @@ pub enum Drag { /// Where the pointer was pressed, and the view's left edge (seconds) at that moment. grab: f32, start: f64, + start_pps: f64, mode: u8, }, } @@ -1498,9 +1505,18 @@ fn zoom_scrollbar(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, seq_seco } else { 0 }; - app.tl.drag = Some(Drag::ZoomBar { grab: pos.x, start: app.ui.timeline.target_scroll, mode }); + app.tl.drag = Some(Drag::ZoomBar { grab: pos.x, start: app.ui.timeline.target_scroll, start_pps: app.ui.timeline.target_pps, mode }); } - if let Some(Drag::ZoomBar { grab, start, mode }) = app.tl.drag.clone() + if matches!(app.tl.drag, Some(Drag::ZoomBar { .. })) && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + if let Some(Drag::ZoomBar { start, start_pps, .. }) = app.tl.drag.take() { + let v = &mut app.ui.timeline; + v.target_scroll = start; + v.scroll = start; + v.target_pps = start_pps; + v.pps = start_pps; + } + } + if let Some(Drag::ZoomBar { grab, start, mode, .. }) = app.tl.drag.clone() && resp.dragged() { let v = &mut app.ui.timeline; @@ -1955,11 +1971,13 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & // ---- what the button went down on (#259): kept from the press frame until it comes up if ctx.input(|i| !i.pointer.any_down() && !i.pointer.any_released()) { app.tl.pressed = None; + app.tl.cancelled = false; } if resp.is_pointer_button_down_on() && ctx.input(|i| i.pointer.primary_pressed()) && let Some(p) = ctx.input(|i| i.pointer.press_origin()) { + app.tl.cancelled = false; app.tl.pressed = Some(Pressed { pos: p, tick: layout.tick_at(p.x), grab: grab_at(seq, layout, p, tool, mods, roll_ripple) }); } @@ -2038,7 +2056,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } // ---- press: act on what was under the pointer when the button went down (#259) - if resp.drag_started() || (resp.clicked() && app.tl.drag.is_none()) { + if !app.tl.cancelled && (resp.drag_started() || (resp.clicked() && app.tl.drag.is_none())) { let pressed = match app.tl.pressed.take() { Some(pr) => pr, // a press and release in the same frame is never stored: it happened where egui says @@ -2055,8 +2073,8 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & g => g, }; let started = match (tool, grab) { - (_, Grab::Other(Hit::Ruler)) => Some(Drag::Scrub), - (Tool::Hand, _) => Some(Drag::Pan { last: p }), + (_, Grab::Other(Hit::Ruler)) => Some(Drag::Scrub { initial: app.session.playhead() }), + (Tool::Hand, _) => Some(Drag::Pan { last: p, start_scroll: app.ui.timeline.target_scroll, start_v: app.ui.timeline.v_scroll }), (Tool::Zoom, _) => { if resp.clicked() { let f = if mods.alt { 1.0 / 2.0 } else { 2.0 }; @@ -2182,7 +2200,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & (_, Grab::Other(Hit::Empty { .. } | Hit::None)) => None, }; if let Some(d) = started { - if matches!(d, Drag::Scrub) { + if matches!(d, Drag::Scrub { .. }) { app.stop(); let mut tt = rate.snap_nearest(layout.tick_at(p.x).max(Tick::ZERO)); if mods.shift || app.session.prefs.timeline.snap_playhead { @@ -2200,29 +2218,55 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } } + // Escape cancels an ongoing drag or press (#580) + if (app.tl.drag.is_some() || app.tl.pressed.is_some()) && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + if let Some(d) = app.tl.drag.take() { + match d { + Drag::Scrub { initial } => { + app.session.set_playhead(initial); + } + Drag::Volume(v) => { + if v.last.is_some() { + let _ = app.session.execute("edit.undo", json!({})); + } + } + Drag::Pan { start_scroll, start_v, .. } => { + let v = &mut app.ui.timeline; + v.target_scroll = start_scroll; + v.scroll = start_scroll; + v.v_scroll = start_v; + } + _ => {} + } + } + app.tl.pressed = None; + app.tl.cancelled = true; + } + // ---- drag - if resp.dragged() + if !app.tl.cancelled + && resp.dragged() && let Some(p) = resp.interact_pointer_pos() && let Some(d) = app.tl.drag.clone() { let t_here = layout.tick_at(p.x); let new = match d { - Drag::Scrub => { + Drag::Scrub { initial } => { let mut tt = rate.snap_nearest(t_here.max(Tick::ZERO)); // Shift snaps; Settings ▸ Timeline ▸ "Snap playhead in Timeline when Snap is enabled" if mods.shift || app.session.prefs.timeline.snap_playhead { tt = snap_playhead(app, seq, layout, tt); } app.session.set_playhead(tt); - Some(Drag::Scrub) + Some(Drag::Scrub { initial }) } - Drag::Pan { last } => { + Drag::Pan { last, start_scroll, start_v } => { let dx = p.x - last.x; let v = &mut app.ui.timeline; v.target_scroll = (v.target_scroll - dx as f64 / v.pps).max(0.0); v.scroll = v.target_scroll; app.ui.timeline.v_scroll += p.y - last.y; - Some(Drag::Pan { last: p }) + Some(Drag::Pan { last: p, start_scroll, start_v }) } Drag::Move { clips, grab_tick, start_track, .. } => { let raw = rate.snap_nearest(t_here - grab_tick); @@ -2301,54 +2345,59 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } // ---- release: commit exactly one command - if resp.drag_stopped() - && let Some(d) = app.tl.drag.take() - { - let r = match d { - Drag::Move { clips, offset, track_delta, copy, .. } if offset != Tick::ZERO || track_delta != 0 => { - let moves: Vec = clips - .iter() - .filter_map(|c| seq.find_item(*c).map(|(tid, it)| json!({"clip": c.0, "track": shift_track(seq, tid, track_delta).unwrap_or(tid).0, "time": (it.start + offset).max(Tick::ZERO).0}))) - .collect(); - // Option held while dropping copies (Premiere: Option-drag duplicates) - Some(app.session.execute("timeline.move", json!({"moves": moves, "insert": mods.command, "linked": false, "copy": copy || mods.alt}))) - } - Drag::Trim { clip, edge, mode, delta, .. } if delta != Tick::ZERO => Some(app.session.execute( - "timeline.trim", - json!({"clip": clip.0, "edge": if edge == filmcraft_edit::Edge::In {"in"} else {"out"}, "mode": if mode == filmcraft_edit::TrimMode::Ripple {"ripple"} else {"regular"}, "delta": delta.0}), - )), - Drag::Stretch { clip, edge, delta, .. } if delta != Tick::ZERO => Some(app.session.execute("timeline.rateStretch", json!({"clip": clip.0, "edge": if edge == filmcraft_edit::Edge::In {"in"} else {"out"}, "delta": delta.0}))), - Drag::Remix { clip, delta, .. } if delta != Tick::ZERO => { - let d = seq.find_item(clip).map(|(_, it)| it.duration + delta).unwrap_or(Tick::ZERO); - Some(app.session.execute("clip.remix", json!({"clip": clip.0, "duration": d.0}))) - } - Drag::Roll { left, right, delta, .. } if delta != Tick::ZERO => Some(app.session.execute("timeline.roll", json!({"left": left.0, "right": right.0, "delta": delta.0}))), - Drag::Slip { clip, delta } if delta != Tick::ZERO => Some(app.session.execute("timeline.slip", json!({"clip": clip.0, "delta": delta.0}))), - Drag::Slide { clip, delta } if delta != Tick::ZERO => Some(app.session.execute("timeline.slide", json!({"clip": clip.0, "delta": delta.0}))), - Drag::Transition { id, start, duration, .. } => { - let moved = seq.all_tracks().find_map(|tr| tr.transitions.iter().find(|x| x.id == id)).is_some_and(|x| (x.start, x.duration) != (start, duration)); - moved.then(|| app.session.execute("sequence.setTransition", json!({"transition": id.0, "start": start.0, "duration": duration.0}))) - } - Drag::Marquee { start } => { - if let Some(end) = resp.interact_pointer_pos() { - let r = Rect::from_two_pos(start, end); - let (a, b) = (layout.tick_at(r.min.x), layout.tick_at(r.max.x)); - let ids: Vec = layout - .rows + if resp.drag_stopped() { + let cancelled = std::mem::take(&mut app.tl.cancelled); + if !cancelled + && let Some(d) = app.tl.drag.take() + { + let r = match d { + Drag::Move { clips, offset, track_delta, copy, .. } if offset != Tick::ZERO || track_delta != 0 => { + let moves: Vec = clips .iter() - .filter(|row| row.rect.intersects(r)) - .filter_map(|row| seq.track(row.track)) - .flat_map(|tr| tr.items.iter().filter(|i| i.start < b && i.end() > a).map(|i| i.id.0)) + .filter_map(|c| seq.find_item(*c).map(|(tid, it)| json!({"clip": c.0, "track": shift_track(seq, tid, track_delta).unwrap_or(tid).0, "time": (it.start + offset).max(Tick::ZERO).0}))) .collect(); - Some(app.session.execute("timeline.select", json!({"clips": ids, "add": mods.shift}))) - } else { - None + // Option held while dropping copies (Premiere: Option-drag duplicates) + Some(app.session.execute("timeline.move", json!({"moves": moves, "insert": mods.command, "linked": false, "copy": copy || mods.alt}))) } + Drag::Trim { clip, edge, mode, delta, .. } if delta != Tick::ZERO => Some(app.session.execute( + "timeline.trim", + json!({"clip": clip.0, "edge": if edge == filmcraft_edit::Edge::In {"in"} else {"out"}, "mode": if mode == filmcraft_edit::TrimMode::Ripple {"ripple"} else {"regular"}, "delta": delta.0}), + )), + Drag::Stretch { clip, edge, delta, .. } if delta != Tick::ZERO => Some(app.session.execute("timeline.rateStretch", json!({"clip": clip.0, "edge": if edge == filmcraft_edit::Edge::In {"in"} else {"out"}, "delta": delta.0}))), + Drag::Remix { clip, delta, .. } if delta != Tick::ZERO => { + let d = seq.find_item(clip).map(|(_, it)| it.duration + delta).unwrap_or(Tick::ZERO); + Some(app.session.execute("clip.remix", json!({"clip": clip.0, "duration": d.0}))) + } + Drag::Roll { left, right, delta, .. } if delta != Tick::ZERO => Some(app.session.execute("timeline.roll", json!({"left": left.0, "right": right.0, "delta": delta.0}))), + Drag::Slip { clip, delta } if delta != Tick::ZERO => Some(app.session.execute("timeline.slip", json!({"clip": clip.0, "delta": delta.0}))), + Drag::Slide { clip, delta } if delta != Tick::ZERO => Some(app.session.execute("timeline.slide", json!({"clip": clip.0, "delta": delta.0}))), + Drag::Transition { id, start, duration, .. } => { + let moved = seq.all_tracks().find_map(|tr| tr.transitions.iter().find(|x| x.id == id)).is_some_and(|x| (x.start, x.duration) != (start, duration)); + moved.then(|| app.session.execute("sequence.setTransition", json!({"transition": id.0, "start": start.0, "duration": duration.0}))) + } + Drag::Marquee { start } => { + if let Some(end) = resp.interact_pointer_pos() { + let r = Rect::from_two_pos(start, end); + let (a, b) = (layout.tick_at(r.min.x), layout.tick_at(r.max.x)); + let ids: Vec = layout + .rows + .iter() + .filter(|row| row.rect.intersects(r)) + .filter_map(|row| seq.track(row.track)) + .flat_map(|tr| tr.items.iter().filter(|i| i.start < b && i.end() > a).map(|i| i.id.0)) + .collect(); + Some(app.session.execute("timeline.select", json!({"clips": ids, "add": mods.shift}))) + } else { + None + } + } + _ => None, + }; + if let Some(Err(e)) = r { + app.ui.status = e.to_string(); } - _ => None, - }; - if let Some(Err(e)) = r { - app.ui.status = e.to_string(); + } else { + app.tl.drag = None; } } diff --git a/crates/ui-egui/src/panels/timeline_volume.rs b/crates/ui-egui/src/panels/timeline_volume.rs index c6a05250..5ff6f228 100644 --- a/crates/ui-egui/src/panels/timeline_volume.rs +++ b/crates/ui-egui/src/panels/timeline_volume.rs @@ -206,6 +206,15 @@ fn key_drag_id() -> egui::Id { /// it to delete it. Runs after the timeline's own interaction, so they are above the clip. pub fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &Layout, aclip: Rect) { let held: Option = ui.data(|d| d.get_temp(key_drag_id())); + if held.is_some() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + if let Some(h) = held { + if h.begun { + let _ = app.session.execute("edit.undo", json!({})); + } + } + ui.data_mut(|d| d.remove::(key_drag_id())); + return; + } if held.is_none() && (app.tl.drag.is_some() || !matches!(app.ui.tool, Tool::Selection | Tool::Pen)) { return; } diff --git a/crates/ui-egui/tests/timeline_escape_ui.rs b/crates/ui-egui/tests/timeline_escape_ui.rs new file mode 100644 index 00000000..4190101a --- /dev/null +++ b/crates/ui-egui/tests/timeline_escape_ui.rs @@ -0,0 +1,225 @@ +//! Headless UI tests for Escape key drag cancellation in the Timeline (#580). + +use std::sync::mpsc::{Sender, channel}; + +use egui::{Pos2, pos2}; +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_project::ClipId; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use filmcraft_ui_egui::panels::timeline::Drag; +use serde_json::{Value, json}; + +const DUNES: &str = "Desert_Dunes.mp4"; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, +} + +impl Driver { + fn demo() -> Self { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).expect("demo project"); + session.execute("sequence.snap", json!({"on": false})).expect("snapping off"); + let (tx, rx) = channel(); + let app = FilmcraftApp::new(session).with_control(rx); + let harness = Harness::builder() + .with_size(egui::vec2(1600.0, 980.0)) + .with_step_dt(1.0 / 60.0) + .with_max_steps(10_000) + .build_eframe(move |_cc| app); + let mut d = Driver { harness, tx }; + d.frames(4); + d.ok("ui.elements", json!({"prefix": "timeline.clip."})); + d + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + } + } + + fn call(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).unwrap(); + for _ in 0..600 { + self.frames(1); + if let Ok(v) = reply.try_recv() { + return v; + } + } + panic!("no reply to {method} {params}"); + } + + fn ok(&mut self, method: &str, params: Value) -> Value { + let v = self.call(method, params.clone()); + assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}"); + v["result"].clone() + } + + fn exec(&mut self, command: &str, params: Value) -> Value { + self.ok("engine.execute", json!({"command": command, "params": params})) + } + + fn app(&self) -> &FilmcraftApp { + self.harness.state() + } + + fn send(&mut self, e: egui::Event) { + self.harness.input_mut().events.push(e); + self.frames(1); + } + + fn escape(&mut self) { + self.send(egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: Default::default(), + }); + } + + fn rect(&mut self, id: &str) -> [f64; 4] { + let v = self.ok("ui.elements", json!({"prefix": id})); + let e = v.as_array().unwrap().iter().find(|e| e["id"] == id).unwrap_or_else(|| panic!("no element {id}")).clone(); + let r: Vec = e["rect"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); + [r[0], r[1], r[2], r[3]] + } + + fn v1_clip(&self, name: &str) -> u64 { + let seq = self.app().session.active_sequence().expect("sequence"); + seq.video_tracks[0].items.iter().find(|i| i.name == name).unwrap_or_else(|| panic!("no {name} on V1")).id.0 + } + + fn span(&self, clip: u64) -> (i64, i64) { + let seq = self.app().session.active_sequence().expect("sequence"); + let (_, it) = seq.find_item(ClipId(clip)).expect("clip"); + (it.start.0, it.end().0) + } + + fn edges(&mut self, clip: u64) -> (f32, f32, f32) { + let i = self.ok("ui.timeline.locate", json!({"clip": clip, "edge": "in"})); + let o = self.ok("ui.timeline.locate", json!({"clip": clip, "edge": "out"})); + let f = |v: &Value, k: &str| v[k].as_f64().expect("number") as f32; + let id = format!("timeline.clip.{clip}"); + let els = self.ok("ui.elements", json!({"prefix": id})); + let el = els.as_array().expect("elements").iter().find(|e| e["id"] == id.as_str()).unwrap_or_else(|| panic!("no {id}: {els}")).clone(); + let top = el["rect"][1].as_f64().expect("rect") as f32; + (f(&i, "x") - 2.0, f(&o, "x") + 2.0, top + 8.0) + } +} + +#[test] +fn escape_cancels_clip_move_drag() { + let mut d = Driver::demo(); + let q = d.app().session.active_sequence().unwrap().clone(); + let v2 = q.video_tracks[1].items[0].clone(); + let r = d.rect(&format!("timeline.clip.{}", v2.id.0)); + let from = pos2(r[0] as f32 + 10.0, (r[1] + r[3] / 2.0) as f32); + let to = pos2(from.x + 120.0, from.y); + + d.send(egui::Event::PointerMoved(from)); + d.send(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + for i in 1..=10 { + d.send(egui::Event::PointerMoved(from.lerp(to, i as f32 / 10.0))); + } + assert!(matches!(d.app().tl.drag, Some(Drag::Move { .. })), "drag is active"); + + d.escape(); + assert!(d.app().tl.drag.is_none(), "Escape cleared the drag state"); + + d.send(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + d.frames(2); + + let q_after = d.app().session.active_sequence().unwrap().clone(); + let item_after = q_after.find_item(v2.id).unwrap().1; + assert_eq!(item_after.start, v2.start, "clip position is unchanged"); + assert_eq!(item_after.duration, v2.duration, "clip duration is unchanged"); +} + +#[test] +fn escape_cancels_clip_trim_drag() { + let mut d = Driver::demo(); + let clip = d.v1_clip(DUNES); + let (s0, e0) = d.span(clip); + let (_, x_out, y) = d.edges(clip); + let from = pos2(x_out, y); + let to = pos2(from.x - 40.0, from.y); + + d.send(egui::Event::PointerMoved(from)); + d.send(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + for i in 1..=10 { + d.send(egui::Event::PointerMoved(from.lerp(to, i as f32 / 10.0))); + } + assert!(matches!(d.app().tl.drag, Some(Drag::Trim { .. })), "trim drag is active"); + + d.escape(); + assert!(d.app().tl.drag.is_none(), "Escape cleared the trim drag"); + + d.send(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + d.frames(2); + + assert_eq!(d.span(clip), (s0, e0), "clip bounds are unchanged after cancel"); +} + +#[test] +fn escape_cancels_ruler_scrub_and_restores_playhead() { + let mut d = Driver::demo(); + let initial_playhead = d.app().session.playhead(); + let layout = d.app().tl.layout.as_ref().expect("layout").clone(); + let from = pos2(layout.x_of(initial_playhead), layout.ruler.center().y); + let to = pos2(from.x + 100.0, from.y); + + d.send(egui::Event::PointerMoved(from)); + d.send(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + for i in 1..=10 { + d.send(egui::Event::PointerMoved(from.lerp(to, i as f32 / 10.0))); + } + assert!(matches!(d.app().tl.drag, Some(Drag::Scrub { .. })), "ruler scrub is active"); + assert_ne!(d.app().session.playhead(), initial_playhead, "playhead scrubbed to a new position"); + + d.escape(); + assert!(d.app().tl.drag.is_none(), "Escape cleared the scrub state"); + assert_eq!(d.app().session.playhead(), initial_playhead, "Escape restored the playhead to initial time"); + + d.send(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + d.frames(2); + + assert_eq!(d.app().session.playhead(), initial_playhead, "playhead remains at initial time after release"); +} + +#[test] +fn escape_cancels_marquee_selection() { + let mut d = Driver::demo(); + let q = d.app().session.active_sequence().unwrap().clone(); + let v2 = q.video_tracks[1].items[0].clone(); + d.exec("timeline.select", json!({"clips": [v2.id.0]})); + assert_eq!(d.app().session.state.selection, vec![v2.id]); + + let layout = d.app().tl.layout.as_ref().expect("layout").clone(); + let from = pos2(layout.content.min.x + 50.0, layout.content.max.y - 10.0); + let to = pos2(from.x + 200.0, from.y - 50.0); + + d.send(egui::Event::PointerMoved(from)); + d.send(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + for i in 1..=10 { + d.send(egui::Event::PointerMoved(from.lerp(to, i as f32 / 10.0))); + } + assert!(matches!(d.app().tl.drag, Some(Drag::Marquee { .. })), "marquee is active"); + + d.escape(); + assert!(d.app().tl.drag.is_none(), "Escape cleared the marquee"); + + d.send(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + d.frames(2); + + assert_eq!(d.app().session.state.selection, vec![v2.id], "original selection is preserved"); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 13bb67a4..826d4715 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -105,7 +105,7 @@ agent-hours to close the area to Premiere, sequential. | Area | Weight | Breadth | Ready | Hours | Evidence | Detail | |---|---|---|---|---|---|---| -| Editing, timeline, trimming, multicam | 20% | ~90% | ~70% | 80–140 | Edit algebra, trim modes, Trim Monitor, multicam with audio sync, Premiere default keyboard (143 commands), Source playback and range dragging (10-08). Open: ripple-delete of a gap by column (#668), Backspace on gaps (#648), clip stretching (#653), deselect on empty space (#683), new tracks by dragging media (#483), Escape cancelling drags (#580), time remapping (#510), effects on a whole track (#623) | [ui-parity.md](ui-parity.md) | +| Editing, timeline, trimming, multicam | 20% | ~90% | ~70% | 80–140 | Edit algebra, trim modes, Trim Monitor, multicam with audio sync, Premiere default keyboard (143 commands), Source playback and range dragging (10-08). Open: ripple-delete of a gap by column (#668), Backspace on gaps (#648), clip stretching (#653), deselect on empty space (#683), new tracks by dragging media (#483), Escape cancelling drags (#580 fixed), time remapping (#510), effects on a whole track (#623) | [ui-parity.md](ui-parity.md) | | Effects and transitions | 12% | 100% (measured) | ~60% | 80–140 | Every effect exists; Warp Stabilizer is 2-D, Auto Reframe approximate, Morph Cut morphs with optical flow but without face tracking (#805); Optical Flow time interpolation is motion compensated on the CPU (#780). 34 effect ids run on the GPU against ~82 of Premiere's ~92 marked Accelerated. No Object Mask (26.0, AI). | [hardware-parity.md](hardware-parity.md) | | Media import and codecs | 12% | ~75% | ~60% | 150–250 | Own bit-exact decoders for H.264, HEVC, VP9, AV1, ProRes, DNx, APV, MPEG-2, AAC, AC-3, Opus. But H.264 only 8-bit 4:2:0 and HEVC no 4:2:2 in software (most 10-bit cameras, #626); no camera RAW (R3D, ARRIRAW, BRAW, Sony RAW, Canon RAW, ProRes RAW, #345, #383); no E-AC-3 (#647), AVI (#598), WMV, DV, XAVC / P2 metadata; Sony start timecode (#460); WebM black (#432) | [codec-parity.md](codec-parity.md), [file-format-parity.md](file-format-parity.md) | | Audio | 10% | ~88% | ~65% | 50–90 | Mixer graph, automation, all 53 effects, Essential Sound, 5.1, voice-over, Remix, multi-stream container audio (#361). Device and playback reports: crackle / cut-outs (#336, #410, #602, #605, #617); Enhance Speech is DSP, not a model; ALSA fallback fixed (#254) | | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 75844919..838a98ad 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -18,7 +18,7 @@ open interaction reports (about 40 of the 188 open issues). |---|---|---|---|---|---| | Menus | 366 in-scope menu items | ~338 present (label diff, 92%) | 92% | spelling, Render and Replace, 5 workspaces | 10–20 h | | Keyboard | Premiere default keyboard + editor | Premiere-compatible preset (143 keyboard-only commands; 39 cloud / absent-panel keys skipped with reasons), FilmCraft / FCP / Avid presets, editor | 90% | Ctrl chords (#256 fixed), modifier hints on Windows (#270 fixed) | 5–10 h | -| Timeline editing tools | Selection, Track Select, Ripple / Rolling / Rate Stretch, Razor, Slip / Slide, Pen, Hand, Zoom, Type | all present, trim modes, Trim Monitor, dynamic trimming | 80% | deselect in empty space (#683), gap delete (#648, #668), clip stretch (#653), new tracks by dropping (#483), Escape cancels drag (#580), delete in timeline (#680, #404) | 30–50 h | +| Timeline editing tools | Selection, Track Select, Ripple / Rolling / Rate Stretch, Razor, Slip / Slide, Pen, Hand, Zoom, Type | all present, trim modes, Trim Monitor, dynamic trimming | 80% | deselect in empty space (#683), gap delete (#648, #668), clip stretch (#653), new tracks by dropping (#483), Escape cancels drag (#580 fixed), delete in timeline (#680, #404) | 30–50 h | | Program monitor on-screen controls | Motion / Transform handles, mask handles, safe margins, guides | guides, rulers, safe margins, snapping, mask drawing; **no transform handles** (#639); Button Editor (+) does nothing (#431) | 55% | #639, #431 | 15–25 h | | Effect Controls | property rows, keyframe area with zoom, divider, graphs, eyedroppers | rows, keyframes, value / velocity graphs, eyedropper (#518); missing keyframe zoom (#641, #645), draggable divider (#643), row lines (#640), Position curves (#448) | 60% | #412, #640–#645, #448 | 15–25 h | | 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 |