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 |