From 42aefd3a1aada47e7a80d020790056dc85ec29da Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Sat, 24 Oct 2026 07:24:34 -0700 Subject: [PATCH] Timeline: the work area bar, with Set Work Area Bar In and Out Point (#3087) Sequences already store a work area, and Export > Range lists Work Area, but no command set one, so the Work Area range was grayed out. The Timeline panel menu's Work Area Bar now shows the bar over the whole sequence or hides it. Alt+[ and Alt+] in the Timeline move its start and end to the playhead. On the ruler the bar's ends and middle can be dragged, a double-click fits it to the part of the sequence the ruler shows, and its tooltip shows the start, end and duration. The new commands are sequence.workAreaBar, sequence.setWorkAreaIn, sequence.setWorkAreaOut and sequence.setWorkArea. The two keys move from the skipped table in docs/keyboard.md to the keyboard-only commands. --- crates/engine/src/commands.rs | 75 +++++++++ crates/engine/src/lib.rs | 2 + crates/engine/src/shortcut_presets.rs | 2 + crates/engine/src/work_area_tests.rs | 116 +++++++++++++ crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/zh-cn.tsv | 4 + crates/ui-egui/src/panels/mod.rs | 8 + crates/ui-egui/src/panels/timeline.rs | 74 ++++++++- crates/ui-egui/src/panels/timeline_hit.rs | 58 +++++++ crates/ui-egui/tests/it/main.rs | 1 + .../ui-egui/tests/it/timeline_work_area_ui.rs | 153 ++++++++++++++++++ docs/control-protocol.md | 2 +- docs/keyboard.md | 2 +- docs/roadmap.md | 2 +- 15 files changed, 502 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/work_area_tests.rs create mode 100644 crates/ui-egui/tests/it/timeline_work_area_ui.rs diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 079f6c6db..813185a12 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -2295,6 +2295,55 @@ fn build() -> Vec { crate::previews::delete(s, true) }), query!("sequence.renderBar", "Render Bar", "{}", |s, _| crate::previews::bar_json(s)), + // ================= Work area bar ================= + cmd!("sequence.workAreaBar", "Work Area Bar", [], None, r#"{"show":bool?}"#, has_seq, |s, p| { + let shown = s.active_sequence().ok_or(EngineError::NoSequence)?.work_area.is_some(); + let show = bool_p(p, "show").unwrap_or(!shown); + if show == shown { + return Ok(json!({"show": show})); + } + let fd = s.sequence_rate().frame_duration(); + s.edit_sequence("Work Area Bar", |q, _, _| { + // shown again, the bar spans the whole sequence + q.work_area = show.then(|| TimeRange::from_bounds(Tick::ZERO, q.duration().max(fd))); + Ok(()) + })?; + Ok(json!({"show": show})) + }), + cmd!( + "sequence.setWorkAreaIn", + "Set Work Area Bar In Point", + [], + None, + r#"{"time":ticks?,"frame":i64?,"seconds":f64?,"timecode":str?}"#, + has_seq, + |s, p| set_work_area_point(s, p, true) + ), + cmd!( + "sequence.setWorkAreaOut", + "Set Work Area Bar Out Point", + [], + None, + r#"{"time":ticks?,"frame":i64?,"seconds":f64?,"timecode":str?}"#, + has_seq, + |s, p| set_work_area_point(s, p, false) + ), + cmd!("sequence.setWorkArea", "Set Work Area", [], None, r#"{"start":ticks,"end":ticks}"#, has_seq, |s, p| { + const CMD: &str = "sequence.setWorkArea"; + let tick = |key: &str| p.get(key).and_then(Value::as_i64).map(Tick).ok_or_else(|| bad(CMD, format!("need `{key}` (ticks)"))); + let (start, end) = (tick("start")?, tick("end")?); + if start < Tick::ZERO { + return Err(bad(CMD, "`start` must not be negative")); + } + if end <= start { + return Err(bad(CMD, "`end` must be after `start`")); + } + s.edit_sequence("Set Work Area", |q, _, _| { + q.work_area = Some(TimeRange::from_bounds(start, end)); + Ok(()) + })?; + Ok(json!({"start": start.0, "end": end.0})) + }), cmd!("sequence.matchFrame", "Match Frame", ["Sequence"], Some("F"), "{}", has_seq, |s, _| { let t = s.playhead(); let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; @@ -3640,6 +3689,32 @@ fn mark(s: &mut Session, p: &Value, is_in: bool) -> Result { Ok(Value::Null) } +/// Set Work Area Bar In / Out Point: the work area starts (`is_in`) or ends at `time`, else at the +/// playhead. An In point at or after the end moves the end to the end of the sequence, or to one +/// frame after the In point when the In point is at or past the end of the sequence; an Out point +/// at or before the start moves the start to 0. With no work area yet, the other end is the end +/// or the start of the sequence. +fn set_work_area_point(s: &mut Session, p: &Value, is_in: bool) -> Result { + let cmd = if is_in { "sequence.setWorkAreaIn" } else { "sequence.setWorkAreaOut" }; + let t = sequence_time_p(s, p, "", cmd)?.unwrap_or(s.playhead()); + let fd = s.sequence_rate().frame_duration(); + let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; + let (start, end) = seq.work_area.map(|w| (w.start, w.end())).unwrap_or((Tick::ZERO, seq.duration())); + let (start, end) = if is_in { + (t, if end > t { end } else { seq.duration().max(t + fd) }) + } else { + if t <= Tick::ZERO { + return Err(bad(cmd, "the Out point must be after the start of the sequence")); + } + (if start < t { start } else { Tick::ZERO }, t) + }; + s.edit_sequence(if is_in { "Set Work Area Bar In Point" } else { "Set Work Area Bar Out Point" }, |q, _, _| { + q.work_area = Some(TimeRange::from_bounds(start, end)); + Ok(()) + })?; + Ok(json!({"start": start.0, "end": end.0})) +} + pub(crate) fn json_to_param(template: &ParamValue, v: &Value) -> Option { Some(match template { ParamValue::Float(_) => ParamValue::Float(v.as_f64()?), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f711ad190..580098090 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1372,6 +1372,8 @@ mod trim_tests; mod vfx_tests; #[cfg(test)] mod voiceover_tests; +#[cfg(test)] +mod work_area_tests; #[cfg(test)] mod source_placement_tests; diff --git a/crates/engine/src/shortcut_presets.rs b/crates/engine/src/shortcut_presets.rs index 14bd2bf2a..0fe87fe33 100644 --- a/crates/engine/src/shortcut_presets.rs +++ b/crates/engine/src/shortcut_presets.rs @@ -290,6 +290,8 @@ pub const PREMIERE_PANEL: &[Entry] = &[ ("timeline.decreaseAudioHeight", "Alt+-", "Timeline"), ("timeline.nextScreen", "PageDown", "Timeline"), ("timeline.prevScreen", "PageUp", "Timeline"), + ("sequence.setWorkAreaIn", "Alt+[", "Timeline"), + ("sequence.setWorkAreaOut", "Alt+]", "Timeline"), ("graphics.nudgeLeft", "Cmd+Left", "Program"), ("graphics.nudgeRight", "Cmd+Right", "Program"), ("graphics.nudgeUp", "Cmd+Up", "Program"), diff --git a/crates/engine/src/work_area_tests.rs b/crates/engine/src/work_area_tests.rs new file mode 100644 index 000000000..122cb9f7d --- /dev/null +++ b/crates/engine/src/work_area_tests.rs @@ -0,0 +1,116 @@ +//! The work area bar: the Timeline panel menu's Work Area Bar shows and hides it, Set Work Area Bar +//! In / Out Point (Alt+[ / Alt+] in the Timeline) move its ends to the playhead, `sequence.setWorkArea` +//! sets both ends (a drag of the bar), and Export ▸ Range ▸ Work Area exports it. + +use filmcraft_time::{Tick, TimeRange}; +use serde_json::json; + +use crate::Session; +use crate::shortcuts::{Chord, Platform}; + +fn demo() -> Session { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s +} + +fn area(s: &Session) -> Option<(Tick, Tick)> { + s.active_sequence().unwrap().work_area.map(|w| (w.start, w.end())) +} + +#[test] +fn the_work_area_bar_spans_the_sequence_when_shown_and_undoes() { + let mut s = demo(); + let end = s.active_sequence().unwrap().duration(); + assert!(end > Tick::ZERO); + assert_eq!(area(&s), None, "the bar starts hidden"); + assert!(s.execute("export.resolve", json!({"range": "workArea"})).is_err(), "no work area to export"); + + assert_eq!(s.execute("sequence.workAreaBar", json!({})).unwrap(), json!({"show": true})); + assert_eq!(area(&s), Some((Tick::ZERO, end))); + assert_eq!(s.history.undo.last().map(|u| u.0.as_str()), Some("Work Area Bar")); + let frames = s.execute("export.resolve", json!({"preset": "PNG Sequence", "range": "workArea"})).unwrap()["frames"].as_i64().unwrap(); + let whole = s.execute("export.resolve", json!({"preset": "PNG Sequence", "range": "entire"})).unwrap()["frames"].as_i64().unwrap(); + assert_eq!(frames, whole, "the work area covers the whole sequence"); + + // asking for the state it is already in changes nothing + let steps = s.history.undo.len(); + assert_eq!(s.execute("sequence.workAreaBar", json!({"show": true})).unwrap(), json!({"show": true})); + assert_eq!(s.history.undo.len(), steps); + + assert_eq!(s.execute("sequence.workAreaBar", json!({})).unwrap(), json!({"show": false})); + assert_eq!(area(&s), None); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(area(&s), Some((Tick::ZERO, end))); +} + +#[test] +fn set_work_area_in_and_out_points_follow_the_playhead() { + let mut s = demo(); + let rate = s.sequence_rate(); + let f = |n: i64| rate.tick_of(n); + let end = s.active_sequence().unwrap().duration(); + + // with no bar yet, the In point runs to the end of the sequence + s.set_playhead(f(24)); + assert_eq!(s.execute("sequence.setWorkAreaIn", json!({})).unwrap(), json!({"start": f(24).0, "end": end.0})); + assert_eq!(area(&s), Some((f(24), end))); + assert_eq!(s.history.undo.last().map(|u| u.0.as_str()), Some("Set Work Area Bar In Point")); + + s.set_playhead(f(72)); + s.execute("sequence.setWorkAreaOut", json!({})).unwrap(); + assert_eq!(area(&s), Some((f(24), f(72)))); + assert_eq!(s.history.undo.last().map(|u| u.0.as_str()), Some("Set Work Area Bar Out Point")); + let frames = s.execute("export.resolve", json!({"preset": "PNG Sequence", "range": "workArea"})).unwrap()["frames"].as_i64().unwrap(); + assert!((47..=49).contains(&frames), "{frames}"); + + // an In point past the Out point moves the Out point to the end of the sequence + s.execute("sequence.setWorkAreaIn", json!({"frame": 96})).unwrap(); + assert_eq!(area(&s), Some((f(96), end))); + // an Out point before the In point moves the In point to the start + s.execute("sequence.setWorkAreaOut", json!({"frame": 48})).unwrap(); + assert_eq!(area(&s), Some((Tick::ZERO, f(48)))); + // an In point at or past the end of the sequence keeps one frame + s.execute("sequence.setWorkAreaIn", json!({"time": end.0})).unwrap(); + assert_eq!(area(&s), Some((end, end + rate.frame_duration()))); + + let before = area(&s); + assert!(s.execute("sequence.setWorkAreaOut", json!({"time": 0})).is_err(), "an Out point at 0 leaves no work area"); + assert!(s.execute("sequence.setWorkAreaIn", json!({"time": -1})).is_err(), "negative times are refused"); + assert_eq!(area(&s), before); +} + +#[test] +fn set_work_area_takes_both_ends_and_refuses_bad_ones() { + let mut s = demo(); + let rate = s.sequence_rate(); + let (a, b) = (rate.tick_of(10), rate.tick_of(20)); + assert_eq!(s.execute("sequence.setWorkArea", json!({"start": a.0, "end": b.0})).unwrap(), json!({"start": a.0, "end": b.0})); + assert_eq!(s.active_sequence().unwrap().work_area, Some(TimeRange::from_bounds(a, b))); + for p in [ + json!({"start": b.0, "end": a.0}), + json!({"start": a.0, "end": a.0}), + json!({"start": -1, "end": b.0}), + json!({"start": a.0}), + json!({"end": b.0}), + json!({"start": 0, "end": i64::MAX}), + ] { + assert!(s.execute("sequence.setWorkArea", p.clone()).is_err(), "{p}"); + assert_eq!(s.active_sequence().unwrap().work_area, Some(TimeRange::from_bounds(a, b)), "{p}"); + } + let mut empty = Session::default(); + for id in ["sequence.workAreaBar", "sequence.setWorkAreaIn", "sequence.setWorkAreaOut", "sequence.setWorkArea"] { + assert!(empty.execute(id, json!({"start": 0, "end": 1})).is_err(), "{id} needs a sequence"); + } +} + +#[test] +fn alt_brackets_set_the_work_area_in_the_timeline() { + let s = Session::default(); + for p in [Platform::Mac, Platform::Windows] { + for (keys, cmd) in [("Alt+[", "sequence.setWorkAreaIn"), ("Alt+]", "sequence.setWorkAreaOut")] { + let chord = Chord::parse(keys).unwrap(); + assert_eq!(s.shortcuts.resolve(&chord, Some("Timeline"), p).map(|b| b.command.as_str()), Some(cmd), "{keys} {p:?}"); + } + } +} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 19fbf457c..b13e79d6e 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -511,6 +511,10 @@ Reveal Sequence in Project Mostrar secuencia en el proyecto Video Thumbnails Miniaturas de video Audio Waveforms Formas de onda de audio + Work Area Bar Barra de área de trabajo + Start: {tc} Inicio: {tc} + End: {tc} Fin: {tc} + Duration: {tc} Duración: {tc} Delete Preset Eliminar ajuste preestablecido Export Preset… Exportar ajuste preestablecido… Import Presets… Importar ajustes preestablecidos… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index fac3d1d9d..eacaa51a2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -508,6 +508,10 @@ Reveal Sequence in Project プロジェクトでシーケンスを表示 Video Thumbnails ビデオサムネイル Audio Waveforms オーディオ波形 + Work Area Bar ワークエリアバー + Start: {tc} 開始: {tc} + End: {tc} 終了: {tc} + Duration: {tc} 長さ: {tc} Delete Preset プリセットを削除 Export Preset… プリセットを書き出し… Import Presets… プリセットを読み込み… diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 262632a97..3e3e2f5bf 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -502,6 +502,10 @@ Reveal Sequence in Project 在项目面板中显示序列 Video Thumbnails 视频缩略图 Audio Waveforms 音频波形 + Work Area Bar 工作区域栏 + Start: {tc} 开始:{tc} + End: {tc} 结束:{tc} + Duration: {tc} 持续时间:{tc} Delete Preset 删除预设 Export Preset… 导出预设… Import Presets… 导入预设… diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 532f8db17..68bc2d2ff 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -339,6 +339,14 @@ pub fn panel_menu_popup(app: &mut FilmcraftApp, ui: &mut egui::Ui) { } ui.checkbox(&mut app.ui.timeline.show_thumbnails, tl!("Video Thumbnails")); ui.checkbox(&mut app.ui.timeline.show_waveforms, tl!("Audio Waveforms")); + let mut work_area = app.session.active_sequence().is_some_and(|q| q.work_area.is_some()); + let r = ui.add_enabled(app.session.state.active_sequence.is_some(), egui::Checkbox::new(&mut work_area, tl!("Work Area Bar"))); + app.auto.add("panel.menu.Timeline.workAreaBar", r.rect, "Work Area Bar"); + if r.changed() + && let Err(e) = app.session.execute("sequence.workAreaBar", serde_json::json!({"show": work_area})) + { + app.ui.status = e.to_string(); + } } if p == PanelKind::Project { ui.separator(); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 5e9c666b8..3d59a0c6c 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -14,7 +14,7 @@ use std::sync::{Arc, Mutex}; use egui::{Align2, Color32, CursorIcon, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use filmcraft_project::{ClipId, ItemId, MarkerId, Sequence, TrackId, TrackItem, TrackKind}; -use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeDisplay, format_time}; +use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeDisplay, TimeRange, format_time}; use serde_json::{Value, json}; use crate::FilmcraftApp; @@ -22,7 +22,7 @@ use crate::icons::{self, Icon}; use crate::state::{ThumbnailMode, TimelineView, Tool}; use crate::theme::Tokens; -use super::timeline_hit::{EdgeKind, Grab, edge_geometry, grab_at, marker_at, marker_range_rect, marker_rect, marker_under_pointer}; +use super::timeline_hit::{EDGE_PX, EdgeKind, Grab, edge_geometry, grab_at, marker_at, marker_range_rect, marker_rect, marker_under_pointer, work_area_rect}; pub use super::timeline_hit::{Hit, ROLL_PX, hit, selection_trim_kind}; const TOP_H: f32 = 58.0; // timecode + toolbar (left) / ruler (right) @@ -157,6 +157,15 @@ pub enum Drag { start: Tick, duration: Tick, }, + /// The work area bar dragged by an end (`edge`: that end moves) or its middle (the bar moves); + /// `start` / `end` are where it is shown now, committed on release. + WorkArea { + edge: Option, + /// As in [`Drag::Trim`]. + from: Tick, + start: Tick, + end: Tick, + }, Pan { last: Pos2, start_scroll: f64, @@ -1620,6 +1629,27 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc }); } } + // work area bar: between the markers row and the time labels, as dragged while a drag of it runs + let shown = match app.tl.drag { + Some(Drag::WorkArea { start, end, .. }) => Some(TimeRange::from_bounds(start, end)), + _ => seq.work_area, + }; + if let Some(area) = shown { + let r = work_area_rect(layout, area); + clip.rect_filled(r, 1.0, t.text_dim); + for x in [r.min.x, r.max.x] { + clip.rect_filled(Rect::from_center_size(pos2(x, r.center().y), vec2(3.0, r.height() + 2.0)), 1.0, t.text); + } + app.auto.add("timeline.workArea", r.intersect(ruler), "Work Area Bar"); + let fmt = |tick: Tick| format_time(tick, rate, seq.settings.drop_frame, TimeDisplay::Timecode, 48000); + if ui.rect_contains_pointer(r.expand2(vec2(EDGE_PX, 1.0)).intersect(ruler)) { + egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("work-area-tip"), egui::PopupAnchor::Pointer).show(|ui| { + ui.label(tlf!("Start: {tc}", tc = fmt(area.start))); + ui.label(tlf!("End: {tc}", tc = fmt(area.end()))); + ui.label(tlf!("Duration: {tc}", tc = fmt(area.duration))); + }); + } + } // markers: 8 × 12 pt pentagons in the marker colour, on the top row for m in &seq.markers { let x = layout.x_of(m.start); @@ -1835,6 +1865,7 @@ pub fn hit_json(app: &FilmcraftApp, pos: Pos2, mods: egui::Modifiers) -> Value { }; match grab.hit() { Hit::Ruler => json!({"hit": "ruler", "time": t.0}), + Hit::WorkArea { edge } => json!({"hit": "workArea", "edge": edge.map(|e| format!("{e:?}")), "time": t.0}), Hit::Clip { track, clip, edge } => { json!({"hit": "clip", "track": track.0, "clip": clip.0, "edge": edge.map(|e| format!("{e:?}")), "kind": kind, "time": t.0}) } @@ -2348,6 +2379,8 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & (_, Grab::Edge { .. }) => CursorIcon::ResizeColumn, (Tool::Selection, Grab::Other(Hit::Transition { edge: Some(_), .. })) => CursorIcon::ResizeColumn, (Tool::Selection, Grab::Other(Hit::Transition { edge: None, .. })) => CursorIcon::Grab, + (_, Grab::Other(Hit::WorkArea { edge: Some(_) })) => CursorIcon::ResizeColumn, + (_, Grab::Other(Hit::WorkArea { edge: None })) => CursorIcon::Grab, (_, Grab::Volume { .. }) if tool == Tool::Pen || mods.command => CursorIcon::Crosshair, (_, Grab::Volume { .. }) => CursorIcon::ResizeVertical, (Tool::Razor, Grab::Other(Hit::Clip { .. })) => CursorIcon::Crosshair, @@ -2399,6 +2432,9 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & Some(Drag::Pan { last, start_scroll: app.ui.timeline.target_scroll, start_v: app.ui.timeline.v_scroll }) } (_, Grab::Other(Hit::Ruler)) => Some(Drag::Scrub { initial: app.session.playhead() }), + (_, Grab::Other(Hit::WorkArea { edge })) => { + seq.work_area.filter(|_| resp.drag_started()).map(|w| Drag::WorkArea { edge, from: t, start: w.start, end: w.end() }) + } (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() { @@ -2674,6 +2710,21 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & let (start, duration) = span.unwrap_or((start, duration)); Some(Drag::Transition { id, edge, from, start, duration }) } + Drag::WorkArea { edge, from, start, end } => { + // the bar as it was at the press: the sequence keeps it until the release + let (s0, e0) = seq.work_area.map(|w| (w.start, w.end())).unwrap_or((start, end)); + let fd = rate.frame_duration(); + let delta = rate.snap_nearest(t_here - from); + let (start, end) = match edge { + Some(filmcraft_edit::Edge::In) => (snap(app, seq, layout, rate.snap_nearest(s0 + delta), &[]).min(e0 - fd).max(Tick::ZERO), e0), + Some(filmcraft_edit::Edge::Out) => (s0, snap(app, seq, layout, rate.snap_nearest(e0 + delta), &[]).max(s0 + fd)), + None => { + let d = delta.max(Tick::ZERO - s0); + (s0 + d, e0 + d) + } + }; + Some(Drag::WorkArea { edge, from, start, end }) + } Drag::Volume(v) => Some(Drag::Volume(super::timeline_volume::drag(app, ui, seq, layout, v, p.y))), Drag::Marquee { start, before, add, individual } => { let r = Rect::from_two_pos(start, p); @@ -2746,6 +2797,10 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & 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::WorkArea { start, end, .. } => { + let moved = seq.work_area.is_some_and(|w| (w.start, w.end()) != (start, end)); + moved.then(|| app.session.execute("sequence.setWorkArea", json!({"start": start.0, "end": end.0}))) + } Drag::Marquee { start, before, add, individual } => { app.session.state.selection = before; // egui ends a drag on Escape: that cancels the box, as in the Project panel @@ -2767,6 +2822,21 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } } + // ---- double-click the work area bar: it spans the part of the sequence the ruler shows + if resp.double_clicked() + && let Some(p) = resp.interact_pointer_pos() + && let Hit::WorkArea { .. } = hit(seq, layout, p) + { + let start = rate.snap_nearest(layout.tick_at(layout.content.min.x).max(Tick::ZERO)); + let end = rate.snap_nearest(layout.tick_at(layout.content.max.x)).min(seq.duration()); + // a ruler scrolled past the end of the sequence leaves the bar as it is + if end > start + && let Err(e) = app.session.execute("sequence.setWorkArea", json!({"start": start.0, "end": end.0})) + { + app.ui.status = e.to_string(); + } + } + // ---- double-click a transition: Set Transition Duration (Premiere) if resp.double_clicked() && tool == Tool::Selection diff --git a/crates/ui-egui/src/panels/timeline_hit.rs b/crates/ui-egui/src/panels/timeline_hit.rs index f94e9be92..95a7fe313 100644 --- a/crates/ui-egui/src/panels/timeline_hit.rs +++ b/crates/ui-egui/src/panels/timeline_hit.rs @@ -51,6 +51,11 @@ pub(crate) fn edge_geometry(seq: &Sequence, layout: &Layout, track: TrackId, cli #[derive(Clone, Copy, Debug, PartialEq)] pub enum Hit { Ruler, + /// The work area bar on the ruler; `edge` is its start (`In`) or end (`Out`) when the press is + /// on one of its ends (dragging it moves that end), `None` on its middle (dragging moves it). + WorkArea { + edge: Option, + }, Clip { track: TrackId, clip: ClipId, @@ -96,8 +101,35 @@ pub fn marker_at(seq: &Sequence, layout: &Layout, pos: Pos2) -> Option { marker_under_pointer(seq, layout, pos).map(|m| m.start) } +/// Where the Timeline ruler draws (and hit-tests) the work area bar, between the markers row and +/// the time labels. +pub fn work_area_rect(layout: &Layout, area: filmcraft_time::TimeRange) -> Rect { + let y = layout.ruler.min.y + 14.0; + Rect::from_min_max(pos2(layout.x_of(area.start), y), pos2(layout.x_of(area.end()), y + 6.0)) +} + +/// The part of the work area bar under the pointer: an end (an edge zone, narrower on a short +/// bar so its middle stays grabbable) or its middle. +pub fn work_area_at(seq: &Sequence, layout: &Layout, pos: Pos2) -> Option> { + let r = work_area_rect(layout, seq.work_area?); + let zone = zone(r.width())?; + if !r.expand2(vec2(zone, 1.0)).contains(pos) || !layout.ruler.contains(pos) { + return None; + } + Some(if (pos.x - r.min.x).abs() <= zone { + Some(Edge::In) + } else if (r.max.x - pos.x).abs() <= zone { + Some(Edge::Out) + } else { + None + }) +} + pub fn hit(seq: &Sequence, layout: &Layout, pos: Pos2) -> Hit { if layout.ruler.contains(pos) { + if let Some(edge) = work_area_at(seq, layout, pos) { + return Hit::WorkArea { edge }; + } return Hit::Ruler; } if !layout.content.contains(pos) { @@ -477,6 +509,32 @@ mod tests { assert_eq!(marker_under_pointer(&seq, &layout, at).unwrap().id.0, 9); } + #[test] + fn the_work_area_bar_has_two_ends_and_a_middle() { + let (mut seq, l, _) = fixture(clips()); + // hidden, the bar's row is plain ruler + assert_eq!(hit(&seq, &l, pos2(300.0, 17.0)), Hit::Ruler); + seq.work_area = Some(filmcraft_time::TimeRange::from_bounds(Tick::from_seconds_f64(2.0), Tick::from_seconds_f64(5.0))); + let y = work_area_rect(&l, seq.work_area.unwrap()).center().y; + assert_eq!(hit(&seq, &l, pos2(197.0, y)), Hit::WorkArea { edge: Some(Edge::In) }); + assert_eq!(hit(&seq, &l, pos2(204.0, y)), Hit::WorkArea { edge: Some(Edge::In) }); + assert_eq!(hit(&seq, &l, pos2(350.0, y)), Hit::WorkArea { edge: None }); + assert_eq!(hit(&seq, &l, pos2(503.0, y)), Hit::WorkArea { edge: Some(Edge::Out) }); + assert_eq!(hit(&seq, &l, pos2(510.0, y)), Hit::Ruler, "past its end zone"); + // the markers row and the time labels stay ruler + assert_eq!(hit(&seq, &l, pos2(350.0, 8.0)), Hit::Ruler); + assert_eq!(hit(&seq, &l, pos2(350.0, 30.0)), Hit::Ruler); + assert_eq!(grab_at(&seq, &l, pos2(350.0, y), Tool::Selection, Modifiers::NONE, false), Grab::Other(Hit::WorkArea { edge: None })); + // a bar a few px wide keeps its middle third; hostile points grab nothing + seq.work_area = Some(filmcraft_time::TimeRange::from_bounds(Tick::from_seconds_f64(2.0), Tick::from_seconds_f64(2.09))); + assert_eq!(hit(&seq, &l, pos2(204.5, y)), Hit::WorkArea { edge: None }); + assert_eq!(hit(&seq, &l, pos2(f32::NAN, y)), Hit::None); + for pps in [0.0, f64::NAN, f64::INFINITY] { + let broken = Layout { pps, ..l.clone() }; + assert!(!matches!(hit(&seq, &broken, pos2(204.5, y)), Hit::WorkArea { edge: Some(_) }), "pps {pps}"); + } + } + use egui::Modifiers; use crate::state::Tool; diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 57bfabdf0..b8e5b4409 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -68,6 +68,7 @@ mod timeline_tabs_ui; mod timeline_track_height_ui; mod timeline_transition_move_ui; mod timeline_trim_ui; +mod timeline_work_area_ui; mod track_color_ui; mod track_select_drag_ui; mod transcript_ui; diff --git a/crates/ui-egui/tests/it/timeline_work_area_ui.rs b/crates/ui-egui/tests/it/timeline_work_area_ui.rs new file mode 100644 index 000000000..d04c919f6 --- /dev/null +++ b/crates/ui-egui/tests/it/timeline_work_area_ui.rs @@ -0,0 +1,153 @@ +//! The work area bar on the Timeline ruler: dragging an end moves that end, dragging its middle +//! moves the bar, a double-click fits it to the visible part of the sequence, and none of these +//! moves the playhead. Each gesture is one `sequence.setWorkArea` undo step. + +use std::sync::mpsc::{Sender, channel}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use serde_json::{Value, json}; + +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"); + let (tx, rx) = channel(); + let app = FilmcraftApp::new(session).with_control(rx); + // 60 frames a second: the two clicks of a double-click are one frame apart each, well + // inside egui's 0.3 s + 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 + } + 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 ok(&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() { + assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}"); + return v["result"].clone(); + } + } + panic!("no reply to {method} {params}"); + } + fn exec(&mut self, command: &str, params: Value) -> Value { + self.ok("engine.execute", json!({"command": command, "params": params})) + } + fn bar(&mut self) -> Option<[f64; 4]> { + let v = self.ok("ui.elements", json!({"prefix": "timeline.workArea"})); + let e = v.as_array().unwrap().iter().find(|e| e["id"] == "timeline.workArea")?.clone(); + let r: Vec = e["rect"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); + Some([r[0], r[1], r[2], r[3]]) + } + fn drag(&mut self, from: (f64, f64), dx: f64) { + self.ok("ui.drag", json!({"from": {"x": from.0, "y": from.1}, "to": {"x": from.0 + dx, "y": from.1}, "steps": 8})); + self.frames(3); + } + fn area(&self) -> Option<(i64, i64)> { + self.harness.state().session.active_sequence().unwrap().work_area.map(|w| (w.start.0, w.end().0)) + } + fn last_undo(&self) -> Option { + self.harness.state().session.history.undo.last().map(|u| u.0.clone()) + } +} + +#[test] +fn dragging_and_double_clicking_the_work_area_bar() { + let mut d = Driver::demo(); + d.exec("sequence.snap", json!({"on": false})); + let rate = d.harness.state().session.sequence_rate(); + let (f24, f72) = (rate.tick_of(24).0, rate.tick_of(72).0); + let frame = rate.frame_duration().0; + assert_eq!(d.bar(), None, "hidden, the bar is not drawn"); + d.exec("sequence.setWorkArea", json!({"start": f24, "end": f72})); + d.frames(2); + let r = d.bar().expect("the bar is drawn once it is set"); + let (y, per_frame) = (r[1] + r[3] / 2.0, r[2] / 48.0); + let playhead = d.harness.state().session.playhead(); + + // the Out end, 24 frames to the right + d.drag((r[0] + r[2] - 1.0, y), 24.0 * per_frame); + let (start, end) = d.area().unwrap(); + assert_eq!(start, f24, "the In end stays"); + assert!((end - rate.tick_of(96).0).abs() <= frame, "the Out end moved 24 frames: {end}"); + assert_eq!(d.last_undo().as_deref(), Some("Set Work Area")); + + // the middle, 12 frames to the left: both ends move, the length stays + let r = d.bar().unwrap(); + d.drag((r[0] + r[2] / 2.0, y), -12.0 * per_frame); + let (start2, end2) = d.area().unwrap(); + assert_eq!(end2 - start2, end - start); + assert!((start2 - rate.tick_of(12).0).abs() <= frame, "the bar moved 12 frames: {start2}"); + assert_eq!(d.harness.state().session.playhead(), playhead, "dragging the bar leaves the playhead"); + + // the In end cannot cross the Out end + let r = d.bar().unwrap(); + d.drag((r[0] + 1.0, y), r[2] + 40.0 * per_frame); + let (start3, end3) = d.area().unwrap(); + assert_eq!((start3, end3), (end2 - frame, end2), "the In end stops one frame before the Out end"); + + // a double-click spans the visible part of the sequence (the whole sequence is in view) + d.exec("sequence.setWorkArea", json!({"start": f24, "end": f72})); + d.ok("ui.menu.invoke", json!({"id": "view.zoomToSequence"})); + d.frames(60); + let r = d.bar().unwrap(); + let hit = d.ok("ui.timeline.hit", json!({"x": r[0] + r[2] / 2.0, "y": y})); + assert_eq!(hit["hit"], "workArea", "the control channel reports the bar: {hit}"); + assert_eq!(hit["edge"], Value::Null, "{hit}"); + d.ok("ui.click", json!({"x": r[0] + r[2] / 2.0, "y": y, "count": 2})); + d.frames(3); + let duration = d.harness.state().session.active_sequence().unwrap().duration().0; + let (start4, end4) = d.area().unwrap(); + assert_eq!(start4, 0); + assert!((end4 - duration).abs() <= frame, "{end4} of {duration}"); + assert_eq!(d.harness.state().session.playhead(), playhead, "nor does a double-click"); + + // hidden again from the Timeline panel menu's command + d.exec("sequence.workAreaBar", json!({"show": false})); + d.frames(2); + assert_eq!(d.bar(), None); +} + +#[test] +fn the_in_end_of_a_work_area_shorter_than_a_frame_stays_at_zero() { + let mut d = Driver::demo(); + d.exec("sequence.snap", json!({"on": false})); + let rate = d.harness.state().session.sequence_rate(); + let frame = rate.frame_duration().0; + // zoomed in about the start of the sequence, so half a frame is a few pixels wide + d.exec("playhead.start", json!({})); + for _ in 0..5 { + d.ok("ui.menu.invoke", json!({"id": "view.zoomIn"})); + } + d.exec("sequence.setWorkArea", json!({"start": rate.tick_of(24).0, "end": rate.tick_of(72).0})); + d.frames(2); + let per_frame = d.bar().unwrap()[2] / 48.0; + assert!(per_frame > 4.0, "a frame is wide enough to grab half of it: {per_frame}"); + d.exec("sequence.setWorkArea", json!({"start": 0, "end": frame / 2})); + d.frames(2); + let r = d.bar().unwrap(); + let y = r[1] + r[3] / 2.0; + d.drag((r[0] + 0.5, y), 10.0 * per_frame); + assert_eq!(d.area(), Some((0, frame / 2)), "the In end stays at 0"); + let status = d.harness.state().ui.status.clone(); + assert!(!status.contains("must not be negative"), "the release sends a valid start: {status}"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f5b32a1d9..c21746922 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -56,7 +56,7 @@ Methods (handlers in `crates/ui-egui/src/control.rs`): | `ui.drag` | `{from, to, steps, modifiers}` | press–move–release; `steps` is an integer 0–1024 (default 12; 0 or 1 uses 2 steps) | | `ui.scroll` | `{id|x,y, dx, dy, modifiers}` | wheel / trackpad | | `ui.key` / `ui.type` | `{key}` (`Cmd+K`, `Space`…; off macOS `Ctrl+K` is the same key) / `{text}` | keyboard | -| `ui.timeline.hit` / `ui.timeline.locate` | `{x,y,modifiers?}` / `{clip, edge?}` | timeline hit-testing. `hit` reports a clip's `edge` (`In`/`Out`) within 7 px of it (a third of a narrow clip); just left of a cut that is the left clip's Out, from the cut rightwards the right clip's In. `kind` is the trim a press of the current tool with `modifiers` starts there (`trim`, `ripple`, `roll`, `rateStretch`, `remix`), `volume` on an audio clip's Volume line (Selection and Pen tools), or null | +| `ui.timeline.hit` / `ui.timeline.locate` | `{x,y,modifiers?}` / `{clip, edge?}` | timeline hit-testing. `hit` reports a clip's `edge` (`In`/`Out`) within 7 px of it (a third of a narrow clip); just left of a cut that is the left clip's Out, from the cut rightwards the right clip's In. `kind` is the trim a press of the current tool with `modifiers` starts there (`trim`, `ripple`, `roll`, `rateStretch`, `remix`), `volume` on an audio clip's Volume line (Selection and Pen tools), or null. On the ruler it reports `ruler`, or `workArea` with the bar's `edge` on the work area bar | | `ui.playback` | `{action: play|stop|toggle, speed?}` | | | `ui.screenshot` | `{path?, panel?}` | PNG of the window or one panel; fails after 10 s when no frame is presented (window hidden, display asleep) | | `ui.resize`, `app.quit` | `{width,height}` / `{}` | resize or close the native window; `app.quit` quits without the save prompt (unsaved changes stay in the recovery journal), while the command `app.quit` asks first | diff --git a/docs/keyboard.md b/docs/keyboard.md index 4ca06cfa8..487d4df12 100644 --- a/docs/keyboard.md +++ b/docs/keyboard.md @@ -26,6 +26,7 @@ Premiere has these as keys only (most are not in a menu). Modules: `filmcraft_en | Trimming | Extend Previous/Next Edit To Playhead; Nudge Clip Selection Left/Right One/Five Frames, Up/Down; Slip and Slide Clip Selection Left/Right One/Five Frames | Shift+Q/W; Cmd+(Shift+)Left/Right, Alt+Up/Down; Cmd+Alt+(Shift+)Left/Right; Alt+(Shift+), / . (Timeline) | | Targeting | Toggle All Video/Audio Targets, Toggle All Source Video/Audio; Toggle Target Video/Audio 1–8, Move All Video/Audio Targets Up/Down, Toggle Mute/Solo for targeted audio, Toggle Track Output for targeted video (unbound, as in Premiere) | Cmd+0/9, Cmd+Alt+0/9 | | Tracks | Expand/Minimize All Tracks (again restores), Increase/Decrease Video/Audio Tracks Height, Show Next/Previous Screen | Shift+= / Shift+-, Cmd+= / Cmd+- / Alt+= / Alt+-, PageDown/PageUp (Timeline) | +| Work area | Set Work Area Bar In/Out Point (`sequence.setWorkAreaIn` / `setWorkAreaOut` in `filmcraft_engine::commands`; the Timeline panel menu's Work Area Bar shows and hides the bar) | Alt+[ / Alt+] (Timeline) | | Monitors | Zoom Program/Source Monitor to 100%/Fit, Toggle Full Screen, Play In to Out with Preroll/Postroll, Play from Playhead to Out Point, Export Frame, Set/Clear Poster Frame | Cmd+Shift+1/0, Cmd+Alt+Shift+1/0, Ctrl+`, Shift+Space, Ctrl+Space, Shift+E, Cmd+P / Alt+P | | Panels | Maximize or Restore Active Frame / Frame Under Cursor, Select Next/Previous Panel, Toggle Source/Program Monitor Focus, Workspaces 7–9 | Shift+`, `, Ctrl+Shift+. / ,, Alt+Shift+7/8/9 | | Audio | Increase/Decrease Clip Volume (1 dB) and …Many (Settings ▸ Audio ▸ Large Volume Adjustment), Nudge Volume ±1/±3 dB, Toggle Audio During Scrubbing | ] / [, Shift+] / Shift+[, Shift+S | @@ -61,4 +62,3 @@ Frame has no default key; the Premiere preset moves Shift+E to Export Frame and | Project panel: Next/Previous Column Field, Next/Previous Row Field (Tab, ⇧Tab, Return, ⇧Return) | The list view has no inline-editable metadata cells. | | Text panel: Edit Segment (Return) | No inline transcript text editing (corrections go through `transcript.set`). | | Text panel: Follow Active Monitor (⇧C), Show Source Transcript (⇧Z) | Only sequence transcripts exist; source clips have no transcript view. | -| Timeline: Set Work Area Bar In/Out Point (⌥[ / ⌥]) | FilmCraft has no work area bar (Premiere hides it by default; renders use In/Out). | diff --git a/docs/roadmap.md b/docs/roadmap.md index 6f419b182..ae2e49261 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -63,7 +63,7 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining | M0 | Skeleton + visual shell | ✅ | Workspace, 20 crates, dock/workspaces, Premiere 26 look, native menus, control channel, MCP, xtask gates (layers, wasm) | — | — | | M1 | Media I/O | ✅ | MP4/MOV demux+mux, WAV, stills, MJPEG, symphonia audio (MP3/FLAC/ALAC/Vorbis), GOP seek + frame cache, import | Media Browser polish | 2 | | M2 | H.264 decoder | ✅ | Own decoder, bit-exact on 37+ streams, 500–600 fps 1080p | — | — | -| M3 | Editing core | 🟡 | Edit algebra (insert/overwrite/razor/lift/extract/ripple/roll/slip/slide/rate-stretch/nest/paste), tools, markers, trim mode + Trim Monitor + dynamic J/K/L trimming, Keyboard Shortcuts editor with FilmCraft/Premiere/FCP/Avid presets; Edit/Clip/File menu commands (Label colours and Select Label Group, Paste/Remove Attributes, Select All Matching, Remove Unused, Consolidate Duplicates, Sequence From Clip, Bin From Selection, Offline File, Close Project, Make/Edit Subclip, Modify Audio Channels/Timecode, Frame Hold Options/Add Frame Hold/Insert Frame Hold Segment, Time Interpolation with frame blending, Fit/Fill frame, Breakout to Mono, Extract Audio, Replace With Clip; M3.11: Scene Edit Detection (pure-Rust cut detection, background job), Normalize Mix Track, Simplify Sequence, Transcribe Sequence, Find/Find Next and search bins, Automate to Sequence, Edit Original, Edit Offline, Source Settings, Update Metadata (XMP), Generate Audio Waveform, Project Settings General/Scratch Disks, Get Media File Properties, Save as Template, Selection as FilmCraft Project, Avid Log Exchange export, Flash Cue markers, Dynamic Audio Waveforms, Reveal Log Files, System Compatibility Report); M3.12 keyboard parity: Premiere's default keyboard (~115 keyboard-only commands: edit-point navigation on targeted/any track, select clip at playhead/next/previous, extend edit to playhead, nudge/slip/slide selection, target and source-patch toggles, clip volume ±1 dB/many, frame maximize/full screen/panel cycling, monitor zoom, track heights, Project and Text panel keyboard navigation, text size/leading/alignment, Export Frame, poster frames; see docs/keyboard.md); multicam (Create Multi-Camera Source Sequence, Multi-Camera view with live switching on 1–9, angle switching, Enable/Flatten, Edit Cameras) and sync (Synchronize, Merge Clips; In/Out/timecode/marker/audio — GCC-PHAT, sample-accurate) | Multicam paging >16 angles and grid thumbnails, optical flow (renders as frame blending), 39 Premiere default shortcuts skipped with reasons in docs/keyboard.md (Productions, AI/cloud tools, work area bar, Production/Search panels) | 6–10 | +| M3 | Editing core | 🟡 | Edit algebra (insert/overwrite/razor/lift/extract/ripple/roll/slip/slide/rate-stretch/nest/paste), tools, markers, trim mode + Trim Monitor + dynamic J/K/L trimming, Keyboard Shortcuts editor with FilmCraft/Premiere/FCP/Avid presets; Edit/Clip/File menu commands (Label colours and Select Label Group, Paste/Remove Attributes, Select All Matching, Remove Unused, Consolidate Duplicates, Sequence From Clip, Bin From Selection, Offline File, Close Project, Make/Edit Subclip, Modify Audio Channels/Timecode, Frame Hold Options/Add Frame Hold/Insert Frame Hold Segment, Time Interpolation with frame blending, Fit/Fill frame, Breakout to Mono, Extract Audio, Replace With Clip; M3.11: Scene Edit Detection (pure-Rust cut detection, background job), Normalize Mix Track, Simplify Sequence, Transcribe Sequence, Find/Find Next and search bins, Automate to Sequence, Edit Original, Edit Offline, Source Settings, Update Metadata (XMP), Generate Audio Waveform, Project Settings General/Scratch Disks, Get Media File Properties, Save as Template, Selection as FilmCraft Project, Avid Log Exchange export, Flash Cue markers, Dynamic Audio Waveforms, Reveal Log Files, System Compatibility Report); M3.12 keyboard parity: Premiere's default keyboard (~115 keyboard-only commands: edit-point navigation on targeted/any track, select clip at playhead/next/previous, extend edit to playhead, nudge/slip/slide selection, target and source-patch toggles, clip volume ±1 dB/many, frame maximize/full screen/panel cycling, monitor zoom, track heights, Project and Text panel keyboard navigation, text size/leading/alignment, Export Frame, poster frames; see docs/keyboard.md); multicam (Create Multi-Camera Source Sequence, Multi-Camera view with live switching on 1–9, angle switching, Enable/Flatten, Edit Cameras) and sync (Synchronize, Merge Clips; In/Out/timecode/marker/audio — GCC-PHAT, sample-accurate) | Multicam paging >16 angles and grid thumbnails, optical flow (renders as frame blending), 34 Premiere default shortcuts skipped with reasons in docs/keyboard.md (Productions, AI/cloud tools, Production/Search panels) | 6–10 | | M4 | Playback | 🟡 | Audio-clock master, prefetch with cancellation, J/K/L, correct dropped-frame stats, playback resolution, render bar + content-hashed render previews, App Nap opt-out; 1080p H.264 and 3 stacked 1080p streams play with 0 dropped frames | 4K under load, 8K, frame-threaded AV1 decode, reduced-resolution decode for multicam grids | 6–10 | | M5 | Effects, keyframes, GPU | 🟡 | All 93 Premiere 26 video effects and 84 transitions (CPU), blend modes and 33 common effects on the GPU (#32, GPU2, GPU4, GPU5), keyframes + value/velocity graphs, wgpu compositor, effect + opacity masks (ellipse/polygon/pen, feather, expansion, modes; CPU/WGSL parity), mask tracking (Lucas–Kanade + RANSAC), adjustment layers, effect presets (built-in + user, JSON import/export) | More effects on the GPU (34 ids done incl. Lumetri basic / creative / vignette; GPU export landed #421), GPU masks in the live path, exact Warp Stabilizer (3-D) and Morph Cut | 40–70 | | M6 | Export | ✅ | Own H.264 encoder (High/Main/Baseline, B-frames, VBR/CBR/2-pass) → MP4 or QuickTime + own AAC; ProRes, DNxHR, MJPEG, PNG/TIFF/BMP sequences (Premiere-style numbering), GIF, WAV, AIFF; background jobs; M6.5: Export mode parity (frame size / rate / scaling / pixel aspect, profile / level, CBR / VBR 1- and 2-pass, keyframe distance, audio codec / rate / channels / bitrate / sample size, multiplexer, burn-in or sidecar captions, image / name / timecode overlays, video limiter, loudness normalization with true-peak limiter, metadata, ranges, estimated size, summary), 24 built-in presets + user presets with favourites and import/export (Preset Manager), export queue (reorder, cancel, retry, several sequences / ranges), Quick Export; `export.*` commands and `filmcraft-cli export --preset` | Interlaced encoding, 5.1 audio, smart render, publishing destinations | 6–8 |