mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-24 10:10:05 -06:00
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.
This commit is contained in:
@@ -2295,6 +2295,55 @@ fn build() -> Vec<CommandSpec> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<ParamValue> {
|
||||
Some(match template {
|
||||
ParamValue::Float(_) => ParamValue::Float(v.as_f64()?),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3 and column 95.
|
@@ -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… プリセットを読み込み…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 59.
|
@@ -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… 导入预设…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3 and column 95.
|
@@ -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();
|
||||
|
||||
@@ -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<filmcraft_edit::Edge>,
|
||||
/// 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
|
||||
|
||||
@@ -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<Edge>,
|
||||
},
|
||||
Clip {
|
||||
track: TrackId,
|
||||
clip: ClipId,
|
||||
@@ -96,8 +101,35 @@ pub fn marker_at(seq: &Sequence, layout: &Layout, pos: Pos2) -> Option<Tick> {
|
||||
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<Option<Edge>> {
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ControlRequest>,
|
||||
}
|
||||
|
||||
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<f64> = 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<String> {
|
||||
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}");
|
||||
}
|
||||
Reference in New Issue
Block a user