1614 lines
86 KiB
Rust
1614 lines
86 KiB
Rust
//! Effect Controls: the selected clip's effects (fixed Motion/Opacity/Time Remapping first, as in
|
||
//! Premiere, then standard effects), generated from parameter schemas, with stopwatches and a
|
||
//! keyframe lane on the right. Also hosts the Lumetri Color panel body (same editor, grouped).
|
||
|
||
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, pos2, vec2};
|
||
use filmcraft_project::{ClipId, EffectInstance, ParamKind, ParamValue, TrackItem, TrackKind};
|
||
use filmcraft_time::Tick;
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::FilmcraftApp;
|
||
use crate::icons::{self, Icon};
|
||
use crate::state::KeyframeRef;
|
||
use crate::theme::Tokens;
|
||
|
||
#[path = "effect_controls_selection.rs"]
|
||
mod selection;
|
||
pub(crate) use selection::Interaction;
|
||
|
||
const ROW_H: f32 = 22.0;
|
||
/// The narrowest the effect list and the keyframe area can be dragged (#643): the list's rows are
|
||
/// laid out for at least 260 points (values from 150 points in, the reset button at the end).
|
||
const MIN_LIST_W: f32 = 260.0;
|
||
const MIN_LANE_W: f32 = 60.0;
|
||
/// Keyframe lane header: the time ruler (beside the Source / Sequence pills) and the clip's bar.
|
||
const RULER_H: f32 = 24.0;
|
||
const CLIP_BAR_H: f32 = 18.0;
|
||
/// Distance between the keyframe navigator's buttons (◀ ◆ ▶).
|
||
const NAV_STEP: f32 = 16.0;
|
||
/// The Y curve of a point parameter's value graph (X uses the accent colour).
|
||
const Y_CURVE: Color32 = Color32::from_rgb(0x60, 0xc0, 0x80);
|
||
/// Properties panel: the keyframe diamonds' column, from a row's right edge (the arrows sit one
|
||
/// [`NAV_STEP`] to either side of it, and a section's reset button above it).
|
||
const PROPS_NAV_X: f32 = 26.0;
|
||
/// Properties panel: where a row's value fields end, left of the navigator.
|
||
const PROPS_VALUE_R: f32 = PROPS_NAV_X + NAV_STEP + 12.0;
|
||
|
||
/// Timeline time at which the clip shows media time `m` (where a keyframe sits in the sequence).
|
||
fn timeline_time_of(it: &TrackItem, m: Tick) -> Tick {
|
||
let offset = if it.reverse { it.source_out() - m - Tick(1) } else { m - it.source_in };
|
||
it.start + Tick((offset.0 as f64 / it.speed.abs().max(1e-6)).round() as i64)
|
||
}
|
||
|
||
// The press frame can include movement before the button went down. Start from the press
|
||
// origin so that only gesture motion enters the preview, including same-frame movement.
|
||
fn graph_drag_offset(ui: &egui::Ui, response: &egui::Response, previous: egui::Vec2) -> egui::Vec2 {
|
||
if response.drag_started() {
|
||
response
|
||
.interact_pointer_pos()
|
||
.zip(ui.input(|i| i.pointer.press_origin()))
|
||
.map_or_else(|| response.drag_delta(), |(position, origin)| position - origin)
|
||
} else {
|
||
previous + response.drag_delta()
|
||
}
|
||
}
|
||
|
||
/// Whether `k` names a keyframe of the clip `it` (its effect, parameter and media time).
|
||
fn keyframe_exists(it: &TrackItem, k: &KeyframeRef) -> bool {
|
||
let Some(e) = it.effects.get(k.effect) else { return false };
|
||
let param = match k.mask {
|
||
Some(m) => e.masks.get(m).and_then(|m| m.param(&k.param)),
|
||
None => e.params.get(&k.param),
|
||
};
|
||
param.is_some_and(|p| p.keyframes.iter().any(|kf| kf.time == k.time))
|
||
}
|
||
|
||
/// Edit ▸ Select All (⌘A) with Effect Controls focused selects every keyframe the panel shows
|
||
/// instead of the Timeline's clips (#1241). False when the panel is not focused or shows none.
|
||
pub(crate) fn select_all_keyframes(app: &mut FilmcraftApp) -> bool {
|
||
if app.ui.focused != crate::dock::PanelKind::EffectControls {
|
||
return false;
|
||
}
|
||
let mut keys = Vec::new();
|
||
for (clip, it, _) in selected_clips(app) {
|
||
for (effect, e) in it.effects.iter().enumerate() {
|
||
let key = |param: &str, mask, time| KeyframeRef { clip: clip.0, effect, param: param.to_string(), mask, time };
|
||
for (param, p) in &e.params {
|
||
keys.extend(p.keyframes.iter().map(|kf| key(param, None, kf.time)));
|
||
}
|
||
for (m, mask) in e.masks.iter().enumerate() {
|
||
for param in filmcraft_project::mask::MASK_PARAMS {
|
||
keys.extend(mask.param(param).into_iter().flat_map(|p| &p.keyframes).map(|kf| key(param, Some(m), kf.time)));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if keys.is_empty() {
|
||
return false;
|
||
}
|
||
app.ui.keyframe_selection = keys;
|
||
true
|
||
}
|
||
|
||
fn selected_clip(app: &FilmcraftApp) -> Option<(ClipId, TrackItem, TrackKind)> {
|
||
selected_clips(app).into_iter().next()
|
||
}
|
||
|
||
/// The selected clip (video first) and the audio linked to it when that audio is selected or
|
||
/// linked selection is on — so clip audio FX (Parametric EQ, Custom Setup) stay visible on A/V pairs.
|
||
fn selected_clips(app: &FilmcraftApp) -> Vec<(ClipId, TrackItem, TrackKind)> {
|
||
let Some(seq) = app.session.active_sequence() else { return Vec::new() };
|
||
let picked: Vec<(ClipId, &TrackItem, TrackKind)> = app
|
||
.session
|
||
.state
|
||
.selection
|
||
.iter()
|
||
.filter_map(|c| {
|
||
let (tid, it) = seq.find_item(*c)?;
|
||
Some((*c, it, seq.track(tid)?.kind))
|
||
})
|
||
.collect();
|
||
let Some(&(clip, it, kind)) = picked.iter().find(|p| p.2 == TrackKind::Video).or(picked.first()) else { return Vec::new() };
|
||
let mut out = vec![(clip, it.clone(), kind)];
|
||
if kind == TrackKind::Video {
|
||
let linked = picked.iter().find(|p| p.2 == TrackKind::Audio && p.1.link.is_some() && p.1.link == it.link).copied().or_else(|| {
|
||
if !app.session.state.linked_selection {
|
||
return None;
|
||
}
|
||
let link = it.link?;
|
||
seq.audio_tracks.iter().find_map(|t| t.items.iter().find(|i| i.link == Some(link)).map(|i| (i.id, i, TrackKind::Audio)))
|
||
});
|
||
if let Some((ac, ai, ak)) = linked {
|
||
out.push((ac, ai.clone(), ak));
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||
let t = app.tokens;
|
||
let clips = selected_clips(app);
|
||
// keep only selected keyframes that still exist on a clip shown here
|
||
app.ui.keyframe_selection.retain(|k| clips.iter().any(|(c, it, _)| c.0 == k.clip && keyframe_exists(it, k)));
|
||
let Some((clip, it, _)) = clips.first().cloned() else {
|
||
// a transition clicked in the Timeline (#430)
|
||
if let Some(id) = crate::panels::transition_controls::selected(app) {
|
||
crate::panels::transition_controls::effect_controls(app, ui, rect, id);
|
||
return;
|
||
}
|
||
crate::dock::placeholder(ui, rect, &t, tl!("(no clip selected)"));
|
||
return;
|
||
};
|
||
// Keep the immutable project snapshot alive while UI commands mutate the session.
|
||
// Cloning a sequence here copies every clip and effect on every redraw.
|
||
let project = app.session.project.clone();
|
||
let Some(seq) = app.session.state.active_sequence.and_then(|id| project.sequence(id)) else {
|
||
crate::dock::placeholder(ui, rect, &t, tl!("(no sequences)"));
|
||
return;
|
||
};
|
||
// the divider between the effect list and the keyframe area; dragging it sets the list's width
|
||
// (#643), each side keeping a minimum width
|
||
// (the keyframe area has 4 points of margin left of it and 6 right)
|
||
let max_list = (rect.width() - MIN_LANE_W - 10.0).max(MIN_LIST_W);
|
||
let list_w = if app.ui.effect_controls_split > 0.0 { app.ui.effect_controls_split } else { (rect.width() * 0.58).max(260.0) };
|
||
let list_w = list_w.clamp(MIN_LIST_W, max_list);
|
||
let split = rect.min.x + list_w;
|
||
let head = Rect::from_min_size(rect.min + vec2(8.0, 4.0), vec2(split - rect.min.x - 12.0, 24.0));
|
||
// Premiere: two pill tabs — "Source · clip" and "Sequence · clip" (active)
|
||
let pill = |ui: &mut egui::Ui, r: Rect, text: &str, active: bool| {
|
||
ui.painter().rect_filled(r, 4.0, if active { t.pill_active_bg } else { t.panel_bg });
|
||
if !active {
|
||
ui.painter().rect_stroke(r, 4.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside);
|
||
}
|
||
let cp = ui.painter().with_clip_rect(r.shrink(2.0));
|
||
cp.text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, text, Tokens::ui(11.5), if active { t.text } else { t.text_dim });
|
||
};
|
||
let half = (head.width() - 6.0) / 2.0;
|
||
pill(ui, Rect::from_min_size(head.min, vec2(half, 24.0)), &tlf!("Source · {name}", name = it.name), false);
|
||
pill(ui, Rect::from_min_size(head.min + vec2(half + 6.0, 0.0), vec2(half, 24.0)), &format!("{} · {}", seq_name(app), it.name), true);
|
||
// keyframe lane header, as Premiere's: a time ruler over the clip's stretch of the sequence,
|
||
// which the playhead's handle rides on, and the clip's bar under it
|
||
let lane = Rect::from_min_max(pos2(split + 4.0, rect.min.y + 4.0), pos2(rect.max.x - 6.0, rect.max.y - 26.0));
|
||
ui.painter().rect_filled(lane, 0.0, t.tl_bg);
|
||
let ph = app.session.playhead();
|
||
let dur = it.duration.0.max(1) as f64;
|
||
let lx = |tk: Tick| -> f32 { lane.min.x + (((tk - it.start).0 as f64 / dur) as f32).clamp(0.0, 1.0) * lane.width() };
|
||
let rate = seq.settings.frame_rate;
|
||
let ruler = Rect::from_min_max(lane.min, pos2(lane.max.x, lane.min.y + RULER_H));
|
||
let bar = Rect::from_min_max(pos2(lane.min.x, ruler.max.y + 4.0), pos2(lane.max.x, ruler.max.y + 4.0 + CLIP_BAR_H));
|
||
let scrub = Rect::from_min_max(ruler.min, bar.max);
|
||
app.auto.add("effectControls.lane", lane, "keyframe lane");
|
||
app.auto.add("effectControls.ruler", scrub, "time ruler");
|
||
let body = Rect::from_min_max(pos2(rect.min.x, head.max.y + 4.0), pos2(split, rect.max.y - 26.0));
|
||
let mut actions: Vec<(String, Value)> = Vec::new();
|
||
let selection_area = Rect::from_min_max(pos2(lane.min.x, bar.max.y), lane.max).intersect(ui.clip_rect());
|
||
let mut keys = Interaction::begin(app, ui, selection_area, clip.0);
|
||
// the rows span the list and the keyframe area: the wheel scrolls anywhere over them and the
|
||
// scrollbar sits at the panel's right edge (#643)
|
||
let mut bui = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_max(body.min, pos2(lane.max.x, body.max.y))).id_salt("ec-body"));
|
||
bui.set_clip_rect(Rect::from_min_max(body.min, pos2(rect.max.x, body.max.y)));
|
||
let scroll_out = egui::ScrollArea::vertical().id_salt("ec-scroll").auto_shrink([false, false]).show(&mut bui, |bui| {
|
||
for (clip, it, kind) in &clips {
|
||
let clip = *clip;
|
||
let (hr, _) = bui.allocate_exact_size(vec2(body.width(), 18.0), Sense::hover());
|
||
let heading = if *kind == TrackKind::Video { tl!("Video") } else { tl!("Audio") };
|
||
bui.painter().text(pos2(hr.min.x + 8.0, hr.center().y), Align2::LEFT_CENTER, heading, Tokens::semibold(11.5), t.text_dim);
|
||
let mt_now = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
|
||
// Premiere lists the fixed effects (Motion, Opacity, Time Remapping / Volume…) first.
|
||
let mut order: Vec<usize> = (0..it.effects.len()).collect();
|
||
order.sort_by_key(|i| !it.effects[*i].def().is_some_and(|d| d.intrinsic));
|
||
for idx in order {
|
||
let e = &it.effects[idx];
|
||
let Some(def) = e.def() else { continue };
|
||
let key = format!("{}:{}", clip.0, idx);
|
||
let open = !app.ui.collapsed_fx.contains(&key);
|
||
let selected = app.session.state.selected_effect.as_ref().is_some_and(|s| s.clip == clip && s.index(&it.effects) == Some(idx));
|
||
let (r, resp) = bui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::click());
|
||
if selected {
|
||
bui.painter().rect_filled(crate::panels::effect_controls::row_fill(r), 0.0, t.row_selected);
|
||
} else if resp.hovered() {
|
||
bui.painter().rect_filled(crate::panels::effect_controls::row_fill(r), 0.0, t.hover);
|
||
}
|
||
row_line(bui, r, &lane, &t);
|
||
// only the arrow twirls the effect open or shut; the rest of the header selects it
|
||
let tw = Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(10.0, 10.0));
|
||
icons::paint(bui.painter(), tw, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim);
|
||
let twresp = bui.interact(tw.expand(3.0), egui::Id::new(("fx-twirl", clip.0, idx)), Sense::click());
|
||
app.auto.add(&format!("effectControls.effect.{}.twirl", e.effect), tw, "twirl");
|
||
// fx enable toggle
|
||
let fxr = Rect::from_center_size(pos2(r.min.x + 28.0, r.center().y), vec2(18.0, 14.0));
|
||
let fxresp = bui.interact(fxr, egui::Id::new(("fxen", clip.0, idx)), Sense::click());
|
||
bui.painter().text(fxr.center(), Align2::CENTER_CENTER, "fx", Tokens::semibold(10.5), if e.enabled { t.text } else { t.text_faint });
|
||
if !e.enabled {
|
||
bui.painter().line_segment([fxr.left_bottom(), fxr.right_top()], Stroke::new(1.0, t.text_faint));
|
||
}
|
||
if fxresp.clicked() {
|
||
actions.push(("effects.toggleEnabled".into(), json!({"clip": clip.0, "index": idx})));
|
||
}
|
||
// (the reset button sits at the row's right end)
|
||
row_label(bui, pos2(r.min.x + 42.0, r.center().y), crate::i18n::t(def.name), r.max.x - 26.0 - (r.min.x + 42.0), Tokens::ui(12.0), t.text);
|
||
// reset button
|
||
let rr = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(16.0, 16.0));
|
||
let rresp = bui.interact(rr, egui::Id::new(("fxreset", clip.0, idx)), Sense::click()).on_hover_text(tl!("Reset Effect"));
|
||
icons::paint(bui.painter(), rr.shrink(2.0), Icon::Reset, if rresp.hovered() { t.text } else { t.text_dim });
|
||
if rresp.clicked() {
|
||
actions.push(("effects.reset".into(), json!({"clip": clip.0, "index": idx})));
|
||
}
|
||
app.auto.add(&format!("effectControls.effect.{}", e.effect), r, def.name);
|
||
if twresp.clicked() {
|
||
if open {
|
||
app.ui.collapsed_fx.push(key.clone());
|
||
} else {
|
||
app.ui.collapsed_fx.retain(|k| *k != key);
|
||
}
|
||
} else if resp.clicked() && !fxresp.clicked() && !rresp.clicked() {
|
||
actions.push(("effects.select".into(), json!({"clip": clip.0, "effect": idx})));
|
||
}
|
||
let mut save_preset = false;
|
||
let fx_id = e.effect.clone();
|
||
resp.context_menu(|ui| {
|
||
let sp = ui.button(tl!("Save Preset…"));
|
||
app.auto.add(&format!("effectControls.effect.{fx_id}.savePreset"), sp.rect, "Save Preset…");
|
||
if sp.clicked() {
|
||
save_preset = true;
|
||
ui.close();
|
||
}
|
||
if !def.intrinsic && ui.button(tl!("Clear")).clicked() {
|
||
actions.push(("effects.remove".into(), json!({"clip": clip.0, "index": idx})));
|
||
ui.close();
|
||
}
|
||
});
|
||
if save_preset {
|
||
crate::panels::presets::open_save(app, clip.0, vec![idx], def.name);
|
||
}
|
||
if !open {
|
||
continue;
|
||
}
|
||
if crate::panels::audio_fx_editor::has_editor(&e.effect) {
|
||
custom_setup_row(app, bui, body, &lane, clip, idx, &e.effect);
|
||
}
|
||
for pd in &def.params {
|
||
param_row(app, bui, body, clip, idx, e, None, pd, mt_now, &mut actions, &lane, &lx, it, &mut keys);
|
||
if app.ui.expanded_fx.contains(&graph_key(clip, idx, pd.id))
|
||
&& let Some(param) = e.params.get(pd.id)
|
||
&& param.is_animated()
|
||
&& graphable(¶m.value)
|
||
{
|
||
graph_rows(app, bui, body, clip, idx, None, pd, param, &lane, it, &mut actions);
|
||
}
|
||
}
|
||
if crate::panels::masks::maskable(e) {
|
||
crate::panels::masks::effect_rows(app, bui, body, clip, idx, e, mt_now, &mut actions, &lane, &lx, it, &mut keys);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
let _ = scroll_out;
|
||
// the ruler and the clip's bar, painted after the rows so rows scrolled up under them (their
|
||
// lines and keyframes) stay hidden
|
||
ui.painter().rect_filled(Rect::from_min_max(lane.min, pos2(lane.max.x, bar.max.y)), 0.0, t.tl_bg);
|
||
paint_ruler(ui.painter(), ruler, &it, rate, seq.settings.drop_frame, &t);
|
||
ui.painter().rect_filled(bar.shrink2(vec2(0.0, 2.0)), 2.0, t.clip_bar_bg);
|
||
ui.painter().with_clip_rect(bar).text(pos2(bar.min.x + 4.0, bar.center().y), Align2::LEFT_CENTER, &it.name, Tokens::ui(10.0), t.text);
|
||
// click or drag anywhere on the ruler (or the bar) to move the playhead (after the rows, which
|
||
// reach under it, so it gets the pointer first)
|
||
let sresp = ui.interact(scrub, egui::Id::new(("ec-scrub", clip.0)), Sense::click_and_drag());
|
||
if (sresp.dragged() || sresp.clicked())
|
||
&& let Some(pos) = sresp.interact_pointer_pos()
|
||
{
|
||
let f = ((pos.x - lane.min.x) / lane.width()).clamp(0.0, 1.0) as f64;
|
||
let tk = rate.snap_nearest(it.start + Tick((f * it.duration.0 as f64) as i64));
|
||
app.stop();
|
||
app.session.set_playhead(tk);
|
||
}
|
||
// the divider: a line the height of the panel, a resize cursor and a wider grab area
|
||
let divider = Rect::from_min_max(pos2(split - 2.0, rect.min.y), pos2(split + 4.0, rect.max.y - 26.0));
|
||
let dresp = ui.interact(divider, egui::Id::new("ec-divider"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal);
|
||
if dresp.dragged() {
|
||
app.ui.effect_controls_split = (list_w + dresp.drag_delta().x).clamp(MIN_LIST_W, max_list);
|
||
}
|
||
let dcol = if dresp.hovered() || dresp.dragged() { t.accent } else { t.separator };
|
||
ui.painter().line_segment([pos2(split + 1.5, rect.min.y), pos2(split + 1.5, rect.max.y - 26.0)], Stroke::new(1.0, dcol));
|
||
app.auto.add("effectControls.divider", divider, "divider");
|
||
// playhead: a handle on the ruler and a line down the lane, while it is on the clip
|
||
if ph >= it.start && ph <= it.end() {
|
||
let px = lx(ph);
|
||
let (top, tip) = (ruler.max.y - 13.0, ruler.max.y);
|
||
let head = vec![pos2(px - 5.0, top), pos2(px + 5.0, top), pos2(px + 5.0, tip - 5.0), pos2(px, tip), pos2(px - 5.0, tip - 5.0)];
|
||
ui.painter().add(egui::Shape::convex_polygon(head, t.playhead, Stroke::NONE));
|
||
ui.painter().line_segment([pos2(px, tip), pos2(px, lane.max.y)], Stroke::new(1.0, t.playhead));
|
||
app.auto.add("effectControls.playhead", Rect::from_min_max(pos2(px - 5.0, top), pos2(px + 5.0, tip)), "playhead");
|
||
}
|
||
// footer timecode
|
||
let tc = filmcraft_time::format_time(ph, seq.settings.frame_rate, seq.settings.drop_frame, filmcraft_time::TimeDisplay::Timecode, 48000);
|
||
ui.painter().text(pos2(rect.min.x + 10.0, rect.max.y - 13.0), Align2::LEFT_CENTER, tc, Tokens::mono(13.0), t.timecode);
|
||
keys.finish(app, ui);
|
||
run(app, ui.ctx(), actions);
|
||
}
|
||
|
||
/// The thin line under a row, across the names, the values and the keyframe lane, so a row can be
|
||
/// followed across the panel, as in Premiere (#640). It runs through the middle of the spacing
|
||
/// between rows, so each row sits centred between its two lines.
|
||
pub(crate) fn row_line(ui: &egui::Ui, r: Rect, lane: &Rect, t: &Tokens) {
|
||
let y = r.max.y + ui.spacing().item_spacing.y / 2.0;
|
||
ui.painter().line_segment([pos2(r.min.x, y), pos2(lane.max.x, y)], Stroke::new(1.0, t.separator));
|
||
}
|
||
|
||
/// A row's label, cut off with "…" where it would run into what follows it, as Premiere does when
|
||
/// the effect list is narrow (#643).
|
||
pub(crate) fn row_label(ui: &egui::Ui, left_center: Pos2, text: &str, max_w: f32, font: egui::FontId, color: Color32) {
|
||
let mut job = egui::text::LayoutJob::single_section(text.to_owned(), egui::TextFormat::simple(font, color));
|
||
job.wrap.max_width = max_w.max(0.0);
|
||
job.wrap.max_rows = 1;
|
||
job.wrap.break_anywhere = true;
|
||
job.wrap.overflow_character = Some('…');
|
||
let g = ui.fonts_mut(|f| f.layout_job(job));
|
||
ui.painter().galley(pos2(left_center.x, left_center.y - g.size().y / 2.0), g, color);
|
||
}
|
||
|
||
/// Make a dropdown or a checkbox in a row fit inside the row, centred between its row lines.
|
||
pub(crate) fn fit_to_row(ui: &mut egui::Ui) {
|
||
ui.spacing_mut().interact_size.y = ROW_H - 4.0;
|
||
ui.spacing_mut().button_padding.y = 1.0;
|
||
}
|
||
|
||
/// Premiere's "Custom Setup ▸ Edit…" row: opens the effect's Clip Fx Editor window.
|
||
fn custom_setup_row(app: &mut FilmcraftApp, ui: &mut egui::Ui, body: Rect, lane: &Rect, clip: ClipId, idx: usize, effect: &str) {
|
||
let t = app.tokens;
|
||
let (r, _) = ui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::hover());
|
||
row_line(ui, r, lane, &t);
|
||
ui.painter().text(pos2(r.min.x + 42.0, r.center().y), Align2::LEFT_CENTER, tl!("Custom Setup"), Tokens::ui(12.0), t.text_dim);
|
||
let br = Rect::from_min_size(pos2(r.min.x + 160.0, r.min.y + 2.0), vec2(60.0, ROW_H - 4.0));
|
||
let resp = ui.interact(br, egui::Id::new(("fx-custom-setup", clip.0, idx)), Sense::click());
|
||
ui.painter().rect_filled(br, 3.0, if resp.hovered() { t.hover } else { t.field_bg });
|
||
ui.painter().rect_stroke(br, 3.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
|
||
ui.painter().text(br.center(), Align2::CENTER_CENTER, tl!("Edit…"), Tokens::ui(11.5), t.text);
|
||
app.auto.add(&format!("effectControls.effect.{effect}.edit"), br, "Edit…");
|
||
if resp.clicked() {
|
||
crate::panels::audio_fx_editor::open(app, crate::panels::audio_fx_editor::FxTarget::Clip { clip: clip.0, index: idx });
|
||
}
|
||
}
|
||
|
||
fn seq_name(app: &FilmcraftApp) -> String {
|
||
app.session.state.active_sequence.and_then(|s| app.session.project.item(s)).map(|i| i.name.clone()).unwrap_or_default()
|
||
}
|
||
|
||
/// The keyframe lane's time ruler: ticks and sequence timecode across the clip's stretch of the
|
||
/// timeline (the lane shows exactly the clip, so the ruler starts at the clip's start).
|
||
fn paint_ruler(p: &egui::Painter, ruler: Rect, it: &TrackItem, rate: filmcraft_time::FrameRate, drop_frame: bool, t: &Tokens) {
|
||
let p = p.with_clip_rect(ruler);
|
||
let dur = it.duration.0.max(1) as f64;
|
||
let frame_px = ruler.width() as f64 * rate.frame_duration().0.max(1) as f64 / dur;
|
||
let base = rate.timecode_base();
|
||
let steps = [
|
||
1,
|
||
2,
|
||
5,
|
||
10,
|
||
base / 2,
|
||
base,
|
||
base * 2,
|
||
base * 5,
|
||
base * 10,
|
||
base * 15,
|
||
base * 30,
|
||
base * 60,
|
||
base * 120,
|
||
base * 300,
|
||
base * 600,
|
||
base * 1800,
|
||
base * 3600,
|
||
];
|
||
// labels far enough apart to read; the small ticks divide the labelled ones evenly
|
||
let label_step = steps.iter().copied().find(|s| *s > 0 && *s as f64 * frame_px >= 80.0);
|
||
let minor = steps.iter().copied().find(|s| *s > 0 && *s as f64 * frame_px >= 8.0 && label_step.is_none_or(|l| l % s == 0));
|
||
let (f0, f1) = (rate.frame_at(it.start), rate.frame_at(it.end()));
|
||
let base_y = ruler.max.y - 1.0;
|
||
for (step, h, labelled) in [(minor, 3.0, false), (label_step, 7.0, true)] {
|
||
let Some(step) = step else { continue };
|
||
let mut f = f0.div_euclid(step).saturating_mul(step);
|
||
// a step is at least 8 px wide, so this covers any ruler; the cap is for damaged numbers
|
||
for _ in 0..4096 {
|
||
if f > f1 {
|
||
break;
|
||
}
|
||
let x = ruler.min.x + ((rate.tick_of(f) - it.start).0 as f64 / dur) as f32 * ruler.width();
|
||
p.line_segment([pos2(x, base_y - h), pos2(x, base_y)], Stroke::new(1.0, t.tl_ruler_tick));
|
||
if labelled {
|
||
let label = filmcraft_time::format_time(rate.tick_of(f), rate, drop_frame, filmcraft_time::TimeDisplay::Timecode, 48000);
|
||
p.text(pos2(x, ruler.min.y + 7.0), Align2::CENTER_CENTER, label, Tokens::ui(10.0), t.tl_ruler_text);
|
||
}
|
||
f = f.saturating_add(step);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Premiere's keyframe navigator, centred on `at`: a diamond that adds a keyframe at the playhead
|
||
/// or removes the one there (filled while the playhead is on a keyframe) and, once the parameter
|
||
/// is animated, arrows to the previous and the next keyframe. `param` is `None` while the effect
|
||
/// is not on the clip yet. Automation ids: `<auto>.addKeyframe`, `.prevKeyframe`, `.nextKeyframe`.
|
||
/// Returns whether the diamond was clicked; the arrows queue their own `playhead.set`.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn keyframe_nav(
|
||
app: &mut FilmcraftApp,
|
||
ui: &mut egui::Ui,
|
||
at: Pos2,
|
||
param: Option<&filmcraft_project::Param>,
|
||
mt: Tick,
|
||
it: &TrackItem,
|
||
id: egui::Id,
|
||
auto: &str,
|
||
tip: &str,
|
||
actions: &mut Vec<(String, Value)>,
|
||
) -> bool {
|
||
let t = app.tokens;
|
||
if let Some(param) = param.filter(|p| p.is_animated()) {
|
||
for (d, target, name, tip) in
|
||
[(-1.0, param.prev_keyframe(mt), "prevKeyframe", "Go to previous keyframe"), (1.0, param.next_keyframe(mt), "nextKeyframe", "Go to next keyframe")]
|
||
{
|
||
// the arrows almost the row's height to click, as Premiere's
|
||
let r = Rect::from_center_size(at + vec2(d * NAV_STEP, 0.0), vec2(14.0, 20.0));
|
||
let resp = ui.interact(r, id.with(name), Sense::click()).on_hover_text(crate::i18n::t(tip));
|
||
let c = r.center();
|
||
let pts = vec![c + vec2(-3.5 * d, -5.5), c + vec2(-3.5 * d, 5.5), c + vec2(3.5 * d, 0.0)];
|
||
ui.painter().add(egui::Shape::convex_polygon(pts, if target.is_some() { t.text } else { t.text_faint }, Stroke::NONE));
|
||
app.auto.add(&format!("{auto}.{name}"), r, tip);
|
||
if resp.clicked()
|
||
&& let Some(k) = target
|
||
{
|
||
actions.push(("playhead.set".into(), json!({"time": timeline_time_of(it, k).0})));
|
||
}
|
||
}
|
||
}
|
||
let r = Rect::from_center_size(at, vec2(15.0, 15.0));
|
||
let resp = ui.interact(r.expand(1.5), id.with("key"), Sense::click()).on_hover_text(crate::i18n::t(tip));
|
||
if param.is_some_and(|p| p.keyframes.iter().any(|k| k.time == mt)) {
|
||
icons::paint(ui.painter(), r, Icon::Keyframe, t.hot_text);
|
||
} else {
|
||
let s = 4.7;
|
||
let outline = vec![at + vec2(0.0, -s), at + vec2(s, 0.0), at + vec2(0.0, s), at + vec2(-s, 0.0)];
|
||
ui.painter().add(egui::Shape::closed_line(outline, Stroke::new(1.2, if resp.hovered() { t.text } else { t.text_dim })));
|
||
}
|
||
app.auto.add(&format!("{auto}.addKeyframe"), r, tip);
|
||
resp.clicked()
|
||
}
|
||
|
||
/// A Properties row's keyframe navigator for `effect`.`param`, with Premiere's tooltips. The
|
||
/// diamond is `effects.addKeyframe`: it adds a keyframe at the playhead (turning animation on) or
|
||
/// removes the one there, so the value falls back to what the remaining keyframes give. An effect
|
||
/// the clip does not have yet (Crop) is applied first.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn properties_nav(
|
||
app: &mut FilmcraftApp,
|
||
ui: &mut egui::Ui,
|
||
row: Rect,
|
||
clip: ClipId,
|
||
it: &TrackItem,
|
||
mt: Tick,
|
||
effect: &str,
|
||
param: &str,
|
||
actions: &mut Vec<(String, Value)>,
|
||
) {
|
||
let prm = it.effect(effect).and_then(|e| e.param(param));
|
||
let tip = match prm {
|
||
Some(p) if p.keyframes.iter().any(|k| k.time == mt) => "Remove keyframe",
|
||
Some(p) if p.is_animated() => "Add keyframe",
|
||
_ => "Turn on animation and add keyframe",
|
||
};
|
||
let at = pos2(row.max.x - PROPS_NAV_X, row.center().y);
|
||
let id = egui::Id::new(("props-nav", effect, param, clip.0));
|
||
if keyframe_nav(app, ui, at, prm, mt, it, id, &format!("properties.{effect}.{param}"), tip, actions) {
|
||
if it.effect(effect).is_none() {
|
||
actions.push(("effects.apply".into(), json!({"clips": [clip.0], "effect": effect})));
|
||
}
|
||
actions.push(("effects.addKeyframe".into(), json!({"clip": clip.0, "effect": effect, "param": param})));
|
||
}
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(crate) fn param_row(
|
||
app: &mut FilmcraftApp,
|
||
ui: &mut egui::Ui,
|
||
body: Rect,
|
||
clip: ClipId,
|
||
idx: usize,
|
||
e: &EffectInstance,
|
||
mask: Option<usize>,
|
||
pd: &filmcraft_project::ParamDef,
|
||
mt: Tick,
|
||
actions: &mut Vec<(String, Value)>,
|
||
lane: &Rect,
|
||
lx: &dyn Fn(Tick) -> f32,
|
||
it: &TrackItem,
|
||
keys: &mut Interaction,
|
||
) {
|
||
let _ = lx;
|
||
let t = app.tokens;
|
||
let param = match mask {
|
||
Some(k) => e.masks.get(k).and_then(|m| m.param(pd.id)),
|
||
None => e.params.get(pd.id),
|
||
};
|
||
let Some(param) = param else { return };
|
||
// mask parameters: automation ids / widget ids get a `m<k>.` prefix, commands a `mask` field
|
||
let pkey: String = match mask {
|
||
Some(k) => format!("mask{k}.{}", pd.id),
|
||
None => pd.id.to_string(),
|
||
};
|
||
let pkey = pkey.as_str();
|
||
let with_mask = |mut v: Value| -> Value {
|
||
if let Some(k) = mask {
|
||
v["mask"] = json!(k);
|
||
}
|
||
v
|
||
};
|
||
let (r, row) = ui.allocate_exact_size(vec2(body.width(), ROW_H), if mask.is_none() { Sense::click() } else { Sense::hover() });
|
||
row_line(ui, r, lane, &t);
|
||
// clicking one of an effect's properties selects the effect, as clicking its header does
|
||
if row.clicked() {
|
||
actions.push(("effects.select".into(), json!({"clip": clip.0, "effect": idx})));
|
||
}
|
||
let mut x = r.min.x + 26.0;
|
||
// twirl-down for the value/velocity graphs (animated scalar and point params)
|
||
if param.is_animated() && graphable(¶m.value) {
|
||
let key = graph_key(clip, idx, pkey);
|
||
let open = app.ui.expanded_fx.contains(&key);
|
||
let tw = Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(12.0, 12.0));
|
||
let tresp = ui.interact(tw.expand(2.0), egui::Id::new(("twirl", clip.0, idx, pkey)), Sense::click()).on_hover_text(tl!("Show graphs"));
|
||
icons::paint(ui.painter(), tw, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim);
|
||
app.auto.add(&format!("effectControls.{}.{}.graphs", e.effect, pkey), tw, "Show graphs");
|
||
if tresp.clicked() {
|
||
if open {
|
||
app.ui.expanded_fx.retain(|k| *k != key);
|
||
} else {
|
||
app.ui.expanded_fx.push(key);
|
||
}
|
||
}
|
||
}
|
||
if pd.animatable {
|
||
let sw = Rect::from_center_size(pos2(x, r.center().y), vec2(14.0, 14.0));
|
||
let resp = ui.interact(sw, egui::Id::new(("sw", clip.0, idx, pkey)), Sense::click()).on_hover_text(tl!("Toggle animation"));
|
||
icons::paint(ui.painter(), sw, Icon::Stopwatch, if param.is_animated() { t.accent } else { t.text_dim });
|
||
if resp.clicked() {
|
||
actions.push(("effects.toggleAnimation".into(), with_mask(json!({"clip": clip.0, "effect": idx, "param": pd.id}))));
|
||
}
|
||
app.auto.add(&format!("effectControls.{}.{}.stopwatch", e.effect, pkey), sw, "Toggle animation");
|
||
}
|
||
x += 14.0;
|
||
let vx = r.min.x + (r.width() * 0.5).max(150.0);
|
||
row_label(ui, pos2(x, r.center().y), crate::i18n::t(pd.label), vx - 6.0 - x, Tokens::ui(12.0), t.text);
|
||
let value = param.value_at(mt);
|
||
let mut vui = ui.new_child(
|
||
egui::UiBuilder::new()
|
||
.max_rect(Rect::from_min_max(pos2(vx, r.min.y + 1.0), pos2(r.max.x - 26.0, r.max.y - 1.0)))
|
||
.layout(egui::Layout::left_to_right(egui::Align::Center)),
|
||
);
|
||
let id = egui::Id::new(("pv", clip.0, idx, pkey));
|
||
let mut set: Option<Value> = None;
|
||
match (&pd.kind, &value) {
|
||
(ParamKind::Float { min, max, soft_min, soft_max, unit, decimals }, ParamValue::Float(v)) => {
|
||
let speed = ((soft_max - soft_min) / 400.0).max(0.01);
|
||
let (_, nv) = crate::widgets::hot_number(&mut vui, id, *v, speed, (*min, *max), *decimals as usize, unit, &t);
|
||
if let Some(nv) = nv {
|
||
set = Some(json!(nv));
|
||
}
|
||
}
|
||
(ParamKind::Angle, ParamValue::Float(v)) => {
|
||
let (_, nv) = crate::widgets::hot_number(&mut vui, id, *v, 0.5, (-36000.0, 36000.0), 1, "°", &t);
|
||
if let Some(nv) = nv {
|
||
set = Some(json!(nv));
|
||
}
|
||
}
|
||
(ParamKind::Point, ParamValue::Vec2(p)) => {
|
||
let (_, nx) = crate::widgets::hot_number(&mut vui, id.with("x"), p.x, 1.0, (-100_000.0, 100_000.0), 1, "", &t);
|
||
let (_, ny) = crate::widgets::hot_number(&mut vui, id.with("y"), p.y, 1.0, (-100_000.0, 100_000.0), 1, "", &t);
|
||
if nx.is_some() || ny.is_some() {
|
||
set = Some(json!([nx.unwrap_or(p.x), ny.unwrap_or(p.y)]));
|
||
}
|
||
}
|
||
(ParamKind::Bool, ParamValue::Bool(b)) => {
|
||
let mut v = *b;
|
||
fit_to_row(&mut vui);
|
||
if vui.checkbox(&mut v, "").changed() {
|
||
set = Some(json!(v));
|
||
}
|
||
}
|
||
(ParamKind::Choice(opts), ParamValue::Choice(c)) => {
|
||
let mut sel = *c as usize;
|
||
fit_to_row(&mut vui);
|
||
// (narrower when the effect list is narrow, so it stays left of the divider)
|
||
let w = vui.available_width().min(130.0);
|
||
egui::ComboBox::from_id_salt(id).selected_text(opts.get(sel).map_or("", |o| crate::i18n::t(o))).width(w).show_ui(&mut vui, |ui| {
|
||
for (i, o) in opts.iter().enumerate() {
|
||
if ui.selectable_value(&mut sel, i, crate::i18n::t(o)).changed() {
|
||
set = Some(json!(i));
|
||
}
|
||
}
|
||
});
|
||
}
|
||
(ParamKind::Color, ParamValue::Color(c)) => {
|
||
let mut rgba = egui::Rgba::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]);
|
||
if egui::color_picker::color_edit_button_rgba(&mut vui, &mut rgba, egui::color_picker::Alpha::Opaque).changed() {
|
||
set = Some(json!([rgba.r(), rgba.g(), rgba.b(), rgba.a()]));
|
||
}
|
||
// Eyedropper: arm it, then click a pixel in the Program monitor (Esc cancels)
|
||
let target = crate::state::Eyedropper { clip: clip.0, effect: idx, param: pd.id.to_string(), mask };
|
||
let armed = app.ui.eyedropper.as_ref() == Some(&target);
|
||
let drop = icons::button(&mut vui, Icon::Eyedropper, 18.0, armed, &t, tl!("Eyedropper (pick a color from the Program monitor)"));
|
||
app.auto.add(&format!("effectControls.{}.{}.eyedropper", e.effect, pkey), drop.rect, "Eyedropper");
|
||
if drop.clicked() {
|
||
app.ui.eyedropper = if armed { None } else { Some(target) };
|
||
}
|
||
}
|
||
(ParamKind::Path, _) => {
|
||
crate::panels::masks::path_value(app, &mut vui, clip, idx, mask, actions);
|
||
}
|
||
(ParamKind::Text, ParamValue::Text(s)) if !pd.id.ends_with("_lut") => {
|
||
// edited in a buffer; committed (one undo step) when the field loses focus
|
||
let mut buf = vui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_else(|| s.clone());
|
||
let r = vui.add(egui::TextEdit::singleline(&mut buf).desired_width(160.0));
|
||
app.auto.add(&format!("effectControls.text.{}.{}", idx, pd.id), r.rect, pd.label);
|
||
if r.lost_focus() {
|
||
if buf != *s {
|
||
set = Some(json!(buf));
|
||
}
|
||
vui.data_mut(|d| d.remove::<String>(id));
|
||
} else if r.has_focus() {
|
||
vui.data_mut(|d| d.insert_temp(id, buf));
|
||
} else {
|
||
vui.data_mut(|d| d.remove::<String>(id));
|
||
}
|
||
}
|
||
_ => {
|
||
vui.label(param_text(&app.session.project, pd.id, &value));
|
||
}
|
||
}
|
||
if let Some(v) = set {
|
||
actions.push(("effects.setParam".into(), with_mask(json!({"clip": clip.0, "effect": idx, "param": pd.id, "value": v}))));
|
||
}
|
||
// keyframe navigator ◀ ◆ ▶ (when animated)
|
||
let eff_json = json!(idx);
|
||
if pd.animatable && param.is_animated() {
|
||
let at = pos2(r.max.x - 42.0, r.center().y);
|
||
let auto = format!("effectControls.{}.{}", e.effect, pkey);
|
||
if keyframe_nav(app, ui, at, Some(param), mt, it, egui::Id::new(("knav", clip.0, idx, pkey)), &auto, "Add/Remove Keyframe", actions) {
|
||
actions.push(("effects.addKeyframe".into(), with_mask(json!({"clip": clip.0, "effect": eff_json, "param": pd.id}))));
|
||
}
|
||
}
|
||
// reset to the default, under the effect's own reset button (an animated parameter keeps its
|
||
// keyframes and gets one at the playhead)
|
||
if mask.is_none() {
|
||
let rr = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(12.0, 12.0));
|
||
let rresp = ui.interact(rr, egui::Id::new(("param-reset", clip.0, idx, pkey)), Sense::click()).on_hover_text(tl!("Reset Parameter"));
|
||
icons::paint(ui.painter(), rr, Icon::Reset, if rresp.hovered() { t.text } else { t.text_dim });
|
||
app.auto.add(&format!("effectControls.{}.{}.reset", e.effect, pkey), rr, "Reset Parameter");
|
||
if rresp.clicked() {
|
||
actions.push(("effects.resetParam".into(), json!({"clip": clip.0, "effect": eff_json, "param": pd.id})));
|
||
}
|
||
}
|
||
// keyframes in the lane: draggable diamonds; right-click for interpolation
|
||
if param.is_animated() {
|
||
let y = r.center().y;
|
||
let dur = it.duration.0.max(1) as f64;
|
||
for k in ¶m.keyframes {
|
||
let tl = timeline_time_of(it, k.time);
|
||
let f = ((tl - it.start).0 as f64 / dur) as f32;
|
||
if !(-0.01..=1.01).contains(&f) {
|
||
continue;
|
||
}
|
||
let id = egui::Id::new(("kf", clip.0, idx, pkey, k.time.0));
|
||
let original = Rect::from_center_size(pos2(lane.min.x + f * lane.width(), y), vec2(15.0, 15.0));
|
||
let resp = ui.interact(original.expand(2.0), id, Sense::click_and_drag());
|
||
let reference = KeyframeRef { clip: clip.0, effect: idx, param: pd.id.to_string(), mask, time: k.time };
|
||
let kr = keys.key(app, ui, reference.clone(), it, *lane, original, &resp, k.interp);
|
||
app.auto.add(&format!("effectControls.{}.{}.keyframe.{}", e.effect, pkey, k.time.0), kr, "keyframe");
|
||
let selected = app.ui.keyframe_selection.contains(&reference);
|
||
let col = if selected { t.hot_text } else { Color32::from_rgb(0xb0, 0xb0, 0xb0) };
|
||
selection::paint(ui.painter(), kr, k.interp, col);
|
||
if resp.clicked_by(egui::PointerButton::Primary) {
|
||
actions.push(("playhead.set".into(), json!({"time": tl.0})));
|
||
}
|
||
resp.context_menu(|ui| {
|
||
keyframe_menu(app, ui, KeyframeRef { clip: clip.0, effect: idx, param: pd.id.to_string(), mask, time: k.time }, actions);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Shared by the lane diamonds and graph points: commands use media time and preserve the mask.
|
||
fn keyframe_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, target: KeyframeRef, actions: &mut Vec<(String, Value)>) {
|
||
let mut params = json!({"clip": target.clip, "effect": target.effect, "param": target.param, "mediaTime": target.time.0});
|
||
if let Some(mask) = target.mask {
|
||
params["mask"] = json!(mask);
|
||
}
|
||
ui.label(egui::RichText::new(tl!("Temporal Interpolation")).color(app.tokens.text_dim));
|
||
for (label, key) in [
|
||
(tl!("Linear"), "linear"),
|
||
(tl!("Bezier"), "bezier"),
|
||
(tl!("Auto Bezier"), "autoBezier"),
|
||
(tl!("Continuous Bezier"), "continuousBezier"),
|
||
(tl!("Hold"), "hold"),
|
||
(tl!("Ease In"), "easeIn"),
|
||
(tl!("Ease Out"), "easeOut"),
|
||
] {
|
||
let response = ui.button(label);
|
||
app.auto.add(&format!("effectControls.keyframeMenu.{key}"), response.rect, label);
|
||
if response.clicked() {
|
||
let mut params = params.clone();
|
||
params["interpolation"] = json!(key);
|
||
actions.push(("effects.setInterpolation".into(), params));
|
||
ui.close();
|
||
}
|
||
}
|
||
ui.separator();
|
||
let response = ui.button(tl!("Clear"));
|
||
app.auto.add("effectControls.keyframeMenu.clear", response.rect, "Clear");
|
||
if response.clicked() {
|
||
actions.push(("effects.deleteKeyframe".into(), params));
|
||
ui.close();
|
||
}
|
||
}
|
||
|
||
/// Lumetri Color panel: edits (or adds) the Lumetri effect on the selected clip, grouped by section.
|
||
pub fn lumetri_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||
let t = app.tokens;
|
||
let Some((clip, it, _)) = selected_clip(app) else {
|
||
crate::dock::placeholder(ui, rect, &t, tl!("Select a clip to grade"));
|
||
return;
|
||
};
|
||
let idx = it.effects.iter().position(|e| e.effect == "lumetri");
|
||
let mut bui = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink(6.0)).id_salt("lumetri"));
|
||
bui.label(egui::RichText::new(tlf!("Master · {name}", name = it.name)).color(t.text_dim));
|
||
let Some(idx) = idx else {
|
||
if bui.button(tl!("Add Lumetri Color to clip")).clicked() {
|
||
let _ = app.session.execute("effects.apply", json!({"clips": [clip.0], "effect": "lumetri"}));
|
||
}
|
||
return;
|
||
};
|
||
let e = it.effects[idx].clone();
|
||
let Some(def) = e.def() else { return };
|
||
let ph = app.session.playhead();
|
||
let mt = it.source_time_at(ph.clamp(it.start, it.end() - Tick(1)));
|
||
let mut actions = Vec::new();
|
||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(&mut bui, |ui| {
|
||
let mut groups: Vec<&str> = Vec::new();
|
||
for p in &def.params {
|
||
if let Some(g) = p.group
|
||
&& !groups.contains(&g)
|
||
{
|
||
groups.push(g);
|
||
}
|
||
}
|
||
for g in groups {
|
||
let key = format!("lumetri:{g}");
|
||
let open = !app.ui.collapsed_fx.contains(&key);
|
||
let (resp, now_open) = crate::widgets::section_header(ui, egui::Id::new(&key), crate::i18n::t(g), open, &t, true);
|
||
app.auto.add(&format!("lumetri.section.{g}"), resp.rect, g);
|
||
if now_open != open {
|
||
if now_open {
|
||
app.ui.collapsed_fx.retain(|k| *k != key);
|
||
} else {
|
||
app.ui.collapsed_fx.push(key.clone());
|
||
}
|
||
}
|
||
if !now_open {
|
||
continue;
|
||
}
|
||
for pd in def.params.iter().filter(|p| p.group == Some(g)) {
|
||
let v = e.params.get(pd.id).map(|p| p.value_at(mt)).unwrap_or(pd.default.clone());
|
||
ui.horizontal(|ui| {
|
||
ui.add_space(18.0);
|
||
ui.add_sized(vec2(110.0, 18.0), egui::Label::new(egui::RichText::new(crate::i18n::t(pd.label)).size(12.0)));
|
||
if let (ParamKind::Float { min, max, soft_min, soft_max, .. }, ParamValue::Float(x)) = (&pd.kind, &v) {
|
||
let mut val = *x;
|
||
let s = ui.add(egui::Slider::new(&mut val, *soft_min..=*soft_max).show_value(false));
|
||
let (_, nv) =
|
||
crate::widgets::hot_number(ui, egui::Id::new(("lum", pd.id)), val, (soft_max - soft_min) / 300.0, (*min, *max), 1, "", &t);
|
||
if s.changed() {
|
||
actions.push(json!({"clip": clip.0, "effect": idx, "param": pd.id, "value": val}));
|
||
} else if let Some(nv) = nv {
|
||
actions.push(json!({"clip": clip.0, "effect": idx, "param": pd.id, "value": nv}));
|
||
}
|
||
if s.double_clicked() {
|
||
actions.push(json!({"clip": clip.0, "effect": idx, "param": pd.id, "value": pd.default.as_f64().unwrap_or(0.0)}));
|
||
}
|
||
} else if let ParamValue::Color(c) = v {
|
||
let mut rgba = egui::Rgba::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]);
|
||
if egui::color_picker::color_edit_button_rgba(ui, &mut rgba, egui::color_picker::Alpha::Opaque).changed() {
|
||
actions.push(json!({"clip": clip.0, "effect": idx, "param": pd.id, "value": [rgba.r(), rgba.g(), rgba.b(), 1.0]}));
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
});
|
||
run(app, ui.ctx(), actions.into_iter().map(|a| ("effects.setParam".to_string(), a)).collect());
|
||
}
|
||
|
||
/// Properties panel (Premiere 26): a compact inspector for the selected clip.
|
||
pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||
let t = app.tokens;
|
||
let clips = selected_clips(app);
|
||
let Some((clip, it, kind)) = clips.first().cloned() else {
|
||
crate::dock::placeholder(ui, rect, &t, tl!("Select a clip to see its properties"));
|
||
return;
|
||
};
|
||
let ph = app.session.playhead();
|
||
let mt = it.source_time_at(ph.clamp(it.start, it.end() - Tick(1)));
|
||
let mut actions: Vec<(String, Value)> = Vec::new();
|
||
let mut bui = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(14.0, 8.0))).id_salt("props"));
|
||
// header: clip name + menu
|
||
let (hr, _) = bui.allocate_exact_size(vec2(bui.available_width(), 30.0), Sense::hover());
|
||
let sw = Rect::from_center_size(pos2(hr.min.x + 8.0, hr.center().y), vec2(12.0, 12.0));
|
||
let lc = app.session.prefs.labels.rgb(it.label);
|
||
bui.painter().rect_filled(sw, 2.0, Color32::from_rgb(lc[0], lc[1], lc[2]));
|
||
bui.painter().text(pos2(hr.min.x + 22.0, hr.center().y), Align2::LEFT_CENTER, &it.name, Tokens::semibold(12.5), t.text);
|
||
let row = |ui: &mut egui::Ui, label: &str| -> Rect {
|
||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::hover());
|
||
ui.painter().text(pos2(r.min.x + 18.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text_dim);
|
||
r
|
||
};
|
||
let section = |ui: &mut egui::Ui, app: &mut FilmcraftApp, name: &str, reset: Option<(usize, u64)>, actions: &mut Vec<(String, Value)>| -> bool {
|
||
ui.add_space(4.0);
|
||
let key = format!("props:{name}");
|
||
let open = !app.ui.collapsed_fx.contains(&key);
|
||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
|
||
ui.painter().line_segment([r.left_top(), r.right_top()], Stroke::new(1.0, t.separator));
|
||
icons::paint(
|
||
ui.painter(),
|
||
Rect::from_center_size(pos2(r.min.x + 6.0, r.center().y), vec2(10.0, 10.0)),
|
||
if open { Icon::ChevronDown } else { Icon::ChevronRight },
|
||
t.text_dim,
|
||
);
|
||
ui.painter().text(pos2(r.min.x + 18.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(name), Tokens::semibold(13.0), t.text);
|
||
if let Some((idx, c)) = reset {
|
||
let rr = Rect::from_center_size(pos2(r.max.x - PROPS_NAV_X, r.center().y), vec2(14.0, 14.0));
|
||
icons::paint(ui.painter(), rr, Icon::Reset, t.text_dim);
|
||
if ui.interact(rr, egui::Id::new(("props-reset", name, c)), Sense::click()).clicked() {
|
||
actions.push(("effects.reset".into(), json!({"clip": c, "index": idx})));
|
||
}
|
||
}
|
||
if resp.clicked() {
|
||
if open {
|
||
app.ui.collapsed_fx.push(key);
|
||
} else {
|
||
app.ui.collapsed_fx.retain(|k| *k != key);
|
||
}
|
||
}
|
||
open
|
||
};
|
||
// the sections scroll: a short panel would otherwise cut Audio and the speed button off (#1322)
|
||
egui::ScrollArea::vertical().id_salt("props-scroll").auto_shrink([false, false]).show(&mut bui, |bui| {
|
||
// keep the rows (and their keyframe arrows) clear of the scrollbar at the panel edge
|
||
bui.set_max_width((bui.available_width() - 14.0).max(1.0));
|
||
let eff_idx = |id: &str| it.effects.iter().position(|e| e.effect == id);
|
||
let val = |eid: &str, p: &str| it.effect(eid).and_then(|e| e.param(p)).map(|p| p.value_at(mt));
|
||
if kind == TrackKind::Video {
|
||
if section(bui, app, "Transform", eff_idx("motion").map(|i| (i, clip.0)), &mut actions) {
|
||
for (label, p, unit, speed, range) in [
|
||
(tl!("Position"), "position", "", 1.0, (-100_000.0, 100_000.0)),
|
||
(tl!("Anchor point"), "anchor", "", 1.0, (-100_000.0, 100_000.0)),
|
||
(tl!("Scale"), "scale", " %", 0.5, (0.0, 10000.0)),
|
||
(tl!("Rotation"), "rotation", " °", 0.5, (-36000.0, 36000.0)),
|
||
] {
|
||
let r = row(bui, label);
|
||
let mut vui = bui.new_child(
|
||
egui::UiBuilder::new()
|
||
.max_rect(Rect::from_min_max(pos2(r.min.x + 150.0, r.min.y + 4.0), pos2(r.max.x - PROPS_VALUE_R, r.max.y - 4.0)))
|
||
.layout(egui::Layout::left_to_right(egui::Align::Center)),
|
||
);
|
||
match val("motion", p) {
|
||
Some(ParamValue::Vec2(v)) => {
|
||
let (_, nx) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp", p, "x", clip.0)), v.x, speed, range, 0, " X", &t);
|
||
vui.add_space(10.0);
|
||
let (_, ny) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp", p, "y", clip.0)), v.y, speed, range, 0, " Y", &t);
|
||
if nx.is_some() || ny.is_some() {
|
||
actions.push((
|
||
"effects.setParam".into(),
|
||
json!({"clip": clip.0, "effect": "motion", "param": p, "value": [nx.unwrap_or(v.x), ny.unwrap_or(v.y)]}),
|
||
));
|
||
}
|
||
}
|
||
Some(ParamValue::Float(v)) => {
|
||
let (_, nv) = crate::widgets::hot_number(
|
||
&mut vui,
|
||
egui::Id::new(("pp", p, clip.0)),
|
||
v,
|
||
speed,
|
||
range,
|
||
if p == "scale" { 0 } else { 1 },
|
||
unit,
|
||
&t,
|
||
);
|
||
if let Some(nv) = nv {
|
||
actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "motion", "param": p, "value": nv})));
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
properties_nav(app, bui, r, clip, &it, mt, "motion", p, &mut actions);
|
||
}
|
||
let r = row(bui, tl!("Opacity"));
|
||
let mut vui = bui.new_child(
|
||
egui::UiBuilder::new()
|
||
.max_rect(Rect::from_min_max(pos2(r.min.x + 150.0, r.min.y + 4.0), pos2(r.max.x - PROPS_VALUE_R, r.max.y - 4.0)))
|
||
.layout(egui::Layout::left_to_right(egui::Align::Center)),
|
||
);
|
||
if let Some(ParamValue::Float(v)) = val("opacity", "opacity") {
|
||
let (_, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp-op", clip.0)), v, 0.5, (0.0, 100.0), 0, " %", &t);
|
||
if let Some(nv) = nv {
|
||
actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "opacity", "param": "opacity", "value": nv})));
|
||
}
|
||
}
|
||
properties_nav(app, bui, r, clip, &it, mt, "opacity", "opacity", &mut actions);
|
||
}
|
||
let crop = eff_idx("crop");
|
||
if section(bui, app, "Crop", crop.map(|i| (i, clip.0)), &mut actions) {
|
||
for (label, p) in [(tl!("Left"), "left"), (tl!("Top"), "top"), (tl!("Right"), "right"), (tl!("Bottom"), "bottom")] {
|
||
let r = row(bui, label);
|
||
let mut vui = bui.new_child(
|
||
egui::UiBuilder::new()
|
||
.max_rect(Rect::from_min_max(pos2(r.min.x + 150.0, r.min.y + 4.0), pos2(r.max.x - PROPS_VALUE_R, r.max.y - 4.0)))
|
||
.layout(egui::Layout::left_to_right(egui::Align::Center)),
|
||
);
|
||
let v = val("crop", p).and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||
let (_, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp-crop", p, clip.0)), v, 0.2, (0.0, 100.0), 1, " %", &t);
|
||
if let Some(nv) = nv {
|
||
if crop.is_none() {
|
||
actions.push(("effects.apply".into(), json!({"clips": [clip.0], "effect": "crop"})));
|
||
}
|
||
actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "crop", "param": p, "value": nv})));
|
||
}
|
||
properties_nav(app, bui, r, clip, &it, mt, "crop", p, &mut actions);
|
||
}
|
||
}
|
||
}
|
||
if let Some((clip, it, _)) = clips.iter().find(|c| c.2 == TrackKind::Audio)
|
||
&& section(bui, app, "Audio", it.effects.iter().position(|e| e.effect == "volume").map(|i| (i, clip.0)), &mut actions)
|
||
{
|
||
let clip = *clip;
|
||
let mt = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
|
||
let val = |eid: &str, p: &str| it.effect(eid).and_then(|e| e.param(p)).map(|p| p.value_at(mt));
|
||
let r = row(bui, tl!("Level"));
|
||
let mut vui = bui.new_child(
|
||
egui::UiBuilder::new()
|
||
.max_rect(Rect::from_min_max(pos2(r.min.x + 150.0, r.min.y + 4.0), pos2(r.max.x - PROPS_VALUE_R, r.max.y - 4.0)))
|
||
.layout(egui::Layout::left_to_right(egui::Align::Center)),
|
||
);
|
||
let v = val("volume", "level").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||
let (_, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp-vol", clip.0)), v, 0.1, (-96.0, 15.0), 1, " dB", &t);
|
||
if let Some(nv) = nv {
|
||
actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "volume", "param": "level", "value": nv})));
|
||
}
|
||
properties_nav(app, bui, r, clip, it, mt, "volume", "level", &mut actions);
|
||
let r = row(bui, tl!("Pan"));
|
||
let mut vui = bui.new_child(
|
||
egui::UiBuilder::new()
|
||
.max_rect(Rect::from_min_max(pos2(r.min.x + 150.0, r.min.y + 4.0), pos2(r.max.x - PROPS_VALUE_R, r.max.y - 4.0)))
|
||
.layout(egui::Layout::left_to_right(egui::Align::Center)),
|
||
);
|
||
let v = val("panner", "balance").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||
let (_, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp-pan", clip.0)), v, 0.5, (-100.0, 100.0), 0, "", &t);
|
||
if let Some(nv) = nv {
|
||
actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "panner", "param": "balance", "value": nv})));
|
||
}
|
||
}
|
||
// speed footer
|
||
bui.add_space(12.0);
|
||
let (br, bresp) = bui.allocate_exact_size(vec2(118.0, 26.0), Sense::click());
|
||
bui.painter().rect_filled(br, 4.0, if bresp.hovered() { t.hover } else { t.panel_bg });
|
||
bui.painter().rect_stroke(br, 4.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside);
|
||
bui.painter().text(br.center(), Align2::CENTER_CENTER, tlf!("Speed {n}%", n = format!("{:.0}", it.speed * 100.0)), Tokens::ui(12.0), t.text);
|
||
app.auto.add("properties.speed", br, "Speed");
|
||
});
|
||
run(app, ui.ctx(), actions);
|
||
}
|
||
|
||
/// Where a row's highlight (hover, selection) is painted: the row less its first point, which is
|
||
/// where the focused panel's outline runs, so the highlight doesn't cut through the outline.
|
||
pub(crate) fn row_fill(r: Rect) -> Rect {
|
||
Rect::from_min_max(pos2(r.min.x + 1.0, r.min.y), r.max)
|
||
}
|
||
|
||
/// Run the panel's actions (also the Lumetri Color panel's). Parameter changes made
|
||
/// while the mouse button is down (a drag) share one undo step, which the first change of each
|
||
/// press begins (#201); typed values and clicks stay separate steps. A drag value only changes once
|
||
/// the mouse moves, after the press frame, so the press that already began a step is remembered by
|
||
/// its start time. That memory is refreshed on every frame the button stays down — including frames
|
||
/// with no value change — so a Scale scrub that pauses between percents is still one undo (#887). A
|
||
/// change that brings its own `merge` key (a Program monitor handle changing several parameters)
|
||
/// keeps it.
|
||
pub(crate) fn run(app: &mut FilmcraftApp, ctx: &egui::Context, actions: Vec<(String, Value)>) {
|
||
let (down, press) = ctx.input(|i| (i.pointer.any_down(), i.pointer.press_start_time()));
|
||
let key = egui::Id::new("effect-controls-drag-step");
|
||
let begun = ctx.data(|d| d.get_temp::<Option<f64>>(key)).flatten();
|
||
let mut wrote = false;
|
||
for (cmd, mut p) in actions {
|
||
if cmd == "effects.setParam" && down {
|
||
if p.get("merge").is_none() {
|
||
p["merge"] = json!(true);
|
||
}
|
||
// only the first write of a frame begins the step: a handle that changes several
|
||
// parameters at once (position and anchor point) sends them together
|
||
p["begin"] = json!(begun != press && !wrote);
|
||
wrote = true;
|
||
}
|
||
if let Err(e) = app.session.execute(&cmd, p) {
|
||
app.ui.status = e.to_string();
|
||
}
|
||
}
|
||
if down && (wrote || begun.is_some()) {
|
||
ctx.data_mut(|d| d.insert_temp(key, press));
|
||
} else if !down {
|
||
ctx.data_mut(|d| d.remove::<Option<f64>>(key));
|
||
}
|
||
}
|
||
|
||
pub(crate) fn graph_key(clip: ClipId, idx: usize, pid: &str) -> String {
|
||
format!("graph:{}:{}:{}", clip.0, idx, pid)
|
||
}
|
||
|
||
/// Parameters with value/velocity graphs: scalars, and points such as Position (#239).
|
||
pub(crate) fn graphable(v: &ParamValue) -> bool {
|
||
matches!(v, ParamValue::Float(_) | ParamValue::Vec2(_))
|
||
}
|
||
|
||
/// The curves a graphed value draws: a scalar's one, or a point's X and Y.
|
||
fn graph_components(v: &ParamValue) -> Vec<f64> {
|
||
match v {
|
||
ParamValue::Float(x) => vec![*x],
|
||
ParamValue::Vec2(p) => vec![p.x, p.y],
|
||
_ => Vec::new(),
|
||
}
|
||
}
|
||
|
||
/// Value and velocity graphs of an animated scalar or point parameter, drawn across the keyframe
|
||
/// lane. A point draws an X and a Y curve, and its velocity graph is the speed along its path.
|
||
/// Keyframes drag vertically (value); Bezier influence handles drag horizontally.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(crate) fn graph_rows(
|
||
app: &mut FilmcraftApp,
|
||
ui: &mut egui::Ui,
|
||
body: Rect,
|
||
clip: ClipId,
|
||
idx: usize,
|
||
mask: Option<usize>,
|
||
pd: &filmcraft_project::ParamDef,
|
||
param: &filmcraft_project::Param,
|
||
lane: &Rect,
|
||
it: &TrackItem,
|
||
actions: &mut Vec<(String, Value)>,
|
||
) {
|
||
let with_mask = |mut v: Value| -> Value {
|
||
if let Some(k) = mask {
|
||
v["mask"] = json!(k);
|
||
}
|
||
v
|
||
};
|
||
let t = app.tokens;
|
||
let (vr, _) = ui.allocate_exact_size(vec2(body.width(), 110.0), Sense::hover());
|
||
let (velr, _) = ui.allocate_exact_size(vec2(body.width(), 64.0), Sense::hover());
|
||
row_line(ui, velr, lane, &t);
|
||
let speed = it.speed.abs().max(1e-6);
|
||
let dur = it.duration.0.max(1) as f64;
|
||
let to_media = |f: f64| it.source_in + Tick((f * dur * speed) as i64);
|
||
let to_f = |m: Tick| ((m - it.source_in).0 as f64 / speed / dur) as f32;
|
||
let x_of = |f: f32| lane.min.x + f * lane.width();
|
||
// samples: one row of curve values per sample (a point's NaN "auto" coordinate is skipped)
|
||
let n = (lane.width() / 2.0).max(8.0) as usize;
|
||
let vals: Vec<Vec<f64>> = (0..=n).map(|i| graph_components(¶m.value_at(to_media(i as f64 / n as f64)))).collect();
|
||
let curves = graph_components(¶m.value).len();
|
||
let at_keys: Vec<Vec<f64>> = param.keyframes.iter().map(|k| graph_components(&k.value)).collect();
|
||
let (mut lo, mut hi) = vals.iter().chain(&at_keys).flatten().filter(|v| v.is_finite()).fold((f64::MAX, f64::MIN), |(a, b), v| (a.min(*v), b.max(*v)));
|
||
if lo > hi {
|
||
(lo, hi) = (0.0, 0.0);
|
||
}
|
||
if hi - lo < 1e-6 {
|
||
lo -= 1.0;
|
||
hi += 1.0;
|
||
}
|
||
let pad = (hi - lo) * 0.12;
|
||
let (mut lo, mut hi) = (lo - pad, hi + pad);
|
||
if let ParamKind::Float { min, max, .. } = pd.kind {
|
||
lo = lo.max(min);
|
||
hi = hi.min(max);
|
||
}
|
||
let area = Rect::from_min_max(pos2(lane.min.x, vr.min.y + 4.0), pos2(lane.max.x, vr.max.y - 4.0));
|
||
let y_of = |v: f64| area.max.y - ((v - lo) / (hi - lo)) as f32 * area.height();
|
||
let v_of = |y: f32| lo + ((area.max.y - y) / area.height()) as f64 * (hi - lo);
|
||
let p = ui.painter();
|
||
p.rect_filled(area, 0.0, t.keyframe_plot_bg);
|
||
for g in 1..4 {
|
||
let y = area.min.y + area.height() * g as f32 / 4.0;
|
||
p.line_segment([pos2(area.min.x, y), pos2(area.max.x, y)], Stroke::new(1.0, t.keyframe_plot_grid));
|
||
}
|
||
// range labels in the property column
|
||
let dec = if let ParamKind::Float { decimals, .. } = pd.kind { decimals as usize } else { 1 };
|
||
p.text(pos2(vr.max.x - 30.0, area.min.y + 6.0), Align2::RIGHT_CENTER, format!("{hi:.dec$}"), Tokens::ui(10.0), t.text_dim);
|
||
p.text(pos2(vr.max.x - 30.0, area.max.y - 6.0), Align2::RIGHT_CENTER, format!("{lo:.dec$}"), Tokens::ui(10.0), t.text_dim);
|
||
p.text(pos2(vr.min.x + 44.0, area.center().y), Align2::LEFT_CENTER, tl!("Value"), Tokens::ui(11.0), t.text_dim);
|
||
let curve_color = |c: usize| if c == 0 { t.accent } else { Y_CURVE };
|
||
if curves > 1 {
|
||
p.text(pos2(vr.min.x + 44.0, area.center().y + 14.0), Align2::LEFT_CENTER, "X", Tokens::ui(10.5), curve_color(0));
|
||
p.text(pos2(vr.min.x + 58.0, area.center().y + 14.0), Align2::LEFT_CENTER, "Y", Tokens::ui(10.5), curve_color(1));
|
||
}
|
||
for c in 0..curves {
|
||
let line: Vec<Pos2> =
|
||
vals.iter().enumerate().filter_map(|(i, v)| v.get(c).filter(|v| v.is_finite()).map(|v| pos2(x_of(i as f32 / n as f32), y_of(*v)))).collect();
|
||
p.add(egui::Shape::line(line, Stroke::new(1.5, curve_color(c))));
|
||
}
|
||
// velocity (units per second, derivative of the sampled value; a point's speed along its path)
|
||
let secs = (dur * speed / filmcraft_time::TICKS_PER_SECOND as f64 / n as f64).max(1e-9);
|
||
let vel: Vec<f64> = vals
|
||
.windows(2)
|
||
.map(|w| {
|
||
let [a, b] = w else { return 0.0 };
|
||
let d: Vec<f64> = a.iter().zip(b).map(|(a, b)| (b - a) / secs).collect();
|
||
match d.as_slice() {
|
||
[v] => *v,
|
||
d => d.iter().map(|v| v * v).sum::<f64>().sqrt(),
|
||
}
|
||
})
|
||
.collect();
|
||
let vmax = vel.iter().filter(|v| v.is_finite()).fold(1e-6f64, |a, v| a.max(v.abs())) * 1.15;
|
||
let varea = Rect::from_min_max(pos2(lane.min.x, velr.min.y + 2.0), pos2(lane.max.x, velr.max.y - 4.0));
|
||
p.rect_filled(varea, 0.0, t.keyframe_plot_bg);
|
||
let vy = |v: f64| varea.center().y - (v / vmax) as f32 * varea.height() / 2.0;
|
||
p.line_segment([pos2(varea.min.x, varea.center().y), pos2(varea.max.x, varea.center().y)], Stroke::new(1.0, t.keyframe_plot_axis));
|
||
let vline: Vec<Pos2> = vel.iter().enumerate().filter(|(_, v)| v.is_finite()).map(|(i, v)| pos2(x_of((i as f32 + 0.5) / n as f32), vy(*v))).collect();
|
||
p.add(egui::Shape::line(vline, Stroke::new(1.2, Color32::from_rgb(0xd0, 0xa0, 0x40))));
|
||
p.text(pos2(velr.min.x + 44.0, varea.center().y), Align2::LEFT_CENTER, tl!("Velocity"), Tokens::ui(11.0), t.text_dim);
|
||
p.text(pos2(velr.max.x - 30.0, varea.min.y + 6.0), Align2::RIGHT_CENTER, format!("{vmax:.1}/s"), Tokens::ui(10.0), t.text_dim);
|
||
let p = p.clone();
|
||
// keyframes + handles
|
||
let ks = ¶m.keyframes;
|
||
for (i, k) in ks.iter().enumerate() {
|
||
let f = to_f(k.time);
|
||
if !(-0.01..=1.01).contains(&f) {
|
||
continue;
|
||
}
|
||
let eases_out =
|
||
!matches!(k.interp, filmcraft_project::Interpolation::Linear | filmcraft_project::Interpolation::Hold | filmcraft_project::Interpolation::EaseIn);
|
||
let eases_in =
|
||
!matches!(k.interp, filmcraft_project::Interpolation::Linear | filmcraft_project::Interpolation::Hold | filmcraft_project::Interpolation::EaseOut);
|
||
let comps = graph_components(&k.value);
|
||
for (ci, &v) in comps.iter().enumerate() {
|
||
if !v.is_finite() {
|
||
continue;
|
||
}
|
||
let id = egui::Id::new(("kfg", clip.0, idx, mask, pd.id, k.time.0));
|
||
let id = if ci == 0 { id } else { id.with(ci) };
|
||
let dy: f32 = ui.data(|d| d.get_temp(id)).unwrap_or(0.0);
|
||
let c = pos2(x_of(f), y_of(v) + dy);
|
||
let r = Rect::from_center_size(c, vec2(10.0, 10.0));
|
||
let resp = ui.interact(r.expand(2.0), id, Sense::click_and_drag());
|
||
resp.context_menu(|ui| {
|
||
keyframe_menu(app, ui, KeyframeRef { clip: clip.0, effect: idx, param: pd.id.to_string(), mask, time: k.time }, actions);
|
||
});
|
||
let auto_id = match (comps.len(), ci) {
|
||
(1, _) => format!("effectControls.{}.graph.keyframe.{}", pd.id, k.time.0),
|
||
(_, 0) => format!("effectControls.{}.graph.keyframe.{}.x", pd.id, k.time.0),
|
||
_ => format!("effectControls.{}.graph.keyframe.{}.y", pd.id, k.time.0),
|
||
};
|
||
app.auto.add(&auto_id, r, "keyframe value");
|
||
// influence handles: flat (ease) tangents with length ∝ influence × neighbouring segment;
|
||
// a point's sit on its X curve only, as both coordinates share the influence
|
||
let (eases_out, eases_in) = (ci == 0 && eases_out, ci == 0 && eases_in);
|
||
for (side, on, nb) in [(1.0f32, eases_out, ks.get(i + 1)), (-1.0f32, eases_in && i > 0, if i > 0 { ks.get(i - 1) } else { None })] {
|
||
let (true, Some(nb)) = (on, nb) else { continue };
|
||
let seg = (x_of(to_f(nb.time)) - c.x).abs();
|
||
let infl = if side > 0.0 { k.out_influence } else { k.in_influence } as f32;
|
||
let hid = id.with(if side > 0.0 { "out" } else { "in" });
|
||
let hdx: f32 = ui.data(|d| d.get_temp(hid)).unwrap_or(0.0);
|
||
let hx = c.x + side * (infl * seg + hdx * side).clamp(seg * 0.01, seg);
|
||
let hp = pos2(hx, c.y);
|
||
p.line_segment([c, hp], Stroke::new(1.0, t.plot_handle_dim));
|
||
p.circle_filled(hp, 3.5, t.keyframe_handle);
|
||
let hr = ui.interact(Rect::from_center_size(hp, vec2(10.0, 10.0)), hid.with("h"), Sense::drag());
|
||
if hr.dragged_by(egui::PointerButton::Primary) {
|
||
let nx = graph_drag_offset(ui, &hr, vec2(hdx, 0.0)).x;
|
||
ui.data_mut(|d| d.insert_temp(hid, nx));
|
||
}
|
||
if hr.drag_stopped() && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||
ui.data_mut(|d| d.remove::<f32>(hid));
|
||
} else if hr.drag_stopped_by(egui::PointerButton::Primary) {
|
||
ui.data_mut(|d| d.remove::<f32>(hid));
|
||
let ni = ((infl * seg + hdx * side) / seg.max(1.0)).clamp(0.01, 1.0);
|
||
let key = if side > 0.0 { "outInfluence" } else { "inInfluence" };
|
||
actions.push((
|
||
"effects.setKeyframe".into(),
|
||
with_mask(json!({"clip": clip.0, "effect": idx, "param": pd.id, "mediaTime": k.time.0, key: ni})),
|
||
));
|
||
}
|
||
}
|
||
p.circle_filled(c, 4.5, if resp.dragged_by(egui::PointerButton::Primary) { t.hot_text } else { t.plot_handle });
|
||
if resp.dragged_by(egui::PointerButton::Primary) {
|
||
let ny = graph_drag_offset(ui, &resp, vec2(0.0, dy)).y;
|
||
ui.data_mut(|d| d.insert_temp(id, ny));
|
||
p.text(c + vec2(8.0, -10.0), Align2::LEFT_BOTTOM, format!("{:.dec$}", v_of(c.y)), Tokens::ui(10.5), t.hot_text);
|
||
}
|
||
// egui ends a drag on Escape: that stop abandons it (#580)
|
||
if resp.drag_stopped() && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||
ui.data_mut(|d| d.remove::<f32>(id));
|
||
} else if resp.drag_stopped_by(egui::PointerButton::Primary) {
|
||
ui.data_mut(|d| d.remove::<f32>(id));
|
||
let nv = v_of(c.y);
|
||
let nv = if let ParamKind::Float { min, max, .. } = pd.kind { nv.clamp(min, max) } else { nv };
|
||
let value = if comps.len() == 1 {
|
||
json!(nv)
|
||
} else {
|
||
let mut moved = comps.clone();
|
||
if let Some(slot) = moved.get_mut(ci) {
|
||
*slot = nv;
|
||
}
|
||
json!(moved)
|
||
};
|
||
actions.push((
|
||
"effects.setKeyframe".into(),
|
||
with_mask(json!({"clip": clip.0, "effect": idx, "param": pd.id, "mediaTime": k.time.0, "value": value})),
|
||
));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Read-only text for parameters without an inline editor (LUT references, free text, curves).
|
||
fn param_text(project: &filmcraft_project::Project, id: &str, value: &ParamValue) -> String {
|
||
match value {
|
||
ParamValue::Text(s) if id.ends_with("_lut") => filmcraft_render::luts::label(Some(project), s),
|
||
ParamValue::Text(s) if s.is_empty() => tl!("None").into(),
|
||
ParamValue::Text(s) => s.clone(),
|
||
ParamValue::Curve(pts) if pts.is_empty() || *pts == [[0.0, 0.0], [1.0, 1.0]] => tl!("Default").into(),
|
||
ParamValue::Curve(pts) => tlf!("Custom ({n} points)", n = pts.len()),
|
||
other => format!("{other:?}"),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod param_text_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn readable_values_for_text_lut_and_curve_params() {
|
||
let p = filmcraft_project::Project::default();
|
||
assert_eq!(param_text(&p, "input_lut", &ParamValue::Text(String::new())), "None");
|
||
assert_eq!(param_text(&p, "look_lut", &ParamValue::Text("builtin:look-teal-orange".into())), "Teal & Orange");
|
||
assert_eq!(param_text(&p, "label", &ParamValue::Text(String::new())), "None");
|
||
assert_eq!(param_text(&p, "label", &ParamValue::Text("Reel 3".into())), "Reel 3");
|
||
assert_eq!(param_text(&p, "curve_luma", &ParamValue::Curve(vec![[0.0, 0.0], [1.0, 1.0]])), "Default");
|
||
assert_eq!(param_text(&p, "hue_vs_sat", &ParamValue::Curve(vec![])), "Default");
|
||
assert_eq!(param_text(&p, "curve_luma", &ParamValue::Curve(vec![[0.0, 0.0], [0.5, 0.6], [1.0, 1.0]])), "Custom (3 points)");
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod drag_undo_tests {
|
||
use serde_json::json;
|
||
|
||
/// #201: a drag value changes only once the mouse moves, after the press frame; two drags of
|
||
/// the same parameter are still two undo steps.
|
||
#[test]
|
||
fn each_press_begins_its_own_undo_step() {
|
||
let mut s = filmcraft_engine::Session::default();
|
||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||
let clip = s.active_sequence().unwrap().video_tracks[0].items[0].id.0;
|
||
let mut app = crate::FilmcraftApp::new(s);
|
||
let ctx = egui::Context::default();
|
||
let opacity = |app: &crate::FilmcraftApp| {
|
||
let q = app.session.active_sequence().unwrap();
|
||
let it = q.find_item(filmcraft_project::ClipId(clip)).unwrap().1;
|
||
it.effects.iter().find(|e| e.effect == "opacity").unwrap().params["opacity"].value.as_f64().unwrap()
|
||
};
|
||
let start = opacity(&app);
|
||
let pos = egui::pos2(10.0, 10.0);
|
||
let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
let mut time = 0.0;
|
||
let mut frame = |app: &mut crate::FilmcraftApp, events: Vec<egui::Event>, value: Option<f64>| {
|
||
time += 0.1;
|
||
let raw = egui::RawInput { time: Some(time), events, ..Default::default() };
|
||
let mut out = ctx.run_ui(raw, |ui| {
|
||
let actions = value.map(|v| ("effects.setParam".to_string(), json!({"clip": clip, "effect": "opacity", "param": "opacity", "value": v})));
|
||
super::run(app, ui.ctx(), actions.into_iter().collect());
|
||
});
|
||
out.textures_delta.clear();
|
||
};
|
||
for values in [[90.0, 70.0], [50.0, 60.0]] {
|
||
frame(&mut app, vec![egui::Event::PointerMoved(pos), button(true)], None); // the press: nothing changes yet
|
||
for v in values {
|
||
frame(&mut app, vec![egui::Event::PointerMoved(pos)], Some(v));
|
||
}
|
||
frame(&mut app, vec![button(false)], None);
|
||
}
|
||
assert_eq!(opacity(&app), 60.0);
|
||
app.session.undo();
|
||
assert_eq!(opacity(&app), 70.0, "undo takes back only the second drag");
|
||
app.session.undo();
|
||
assert_eq!(opacity(&app), start, "and then the first");
|
||
}
|
||
|
||
/// #887: a Scale hot-text drag that pauses between percents (no `setParam` that frame) is
|
||
/// still one undo, not one step per 1%.
|
||
#[test]
|
||
fn a_scale_drag_with_idle_frames_is_one_undo_step() {
|
||
let mut s = filmcraft_engine::Session::default();
|
||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||
let clip = s.active_sequence().unwrap().video_tracks[0].items[0].id.0;
|
||
let mut app = crate::FilmcraftApp::new(s);
|
||
let ctx = egui::Context::default();
|
||
let scale = |app: &crate::FilmcraftApp| {
|
||
let q = app.session.active_sequence().unwrap();
|
||
let it = q.find_item(filmcraft_project::ClipId(clip)).unwrap().1;
|
||
it.effects.iter().find(|e| e.effect == "motion").unwrap().params["scale"].value.as_f64().unwrap()
|
||
};
|
||
let start = scale(&app);
|
||
let pos = egui::pos2(10.0, 10.0);
|
||
let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
let mut time = 0.0;
|
||
let mut frame = |app: &mut crate::FilmcraftApp, events: Vec<egui::Event>, value: Option<f64>| {
|
||
time += 0.1;
|
||
let raw = egui::RawInput { time: Some(time), events, ..Default::default() };
|
||
let mut out = ctx.run_ui(raw, |ui| {
|
||
let actions = value.map(|v| ("effects.setParam".to_string(), json!({"clip": clip, "effect": "motion", "param": "scale", "value": v})));
|
||
super::run(app, ui.ctx(), actions.into_iter().collect());
|
||
});
|
||
out.textures_delta.clear();
|
||
};
|
||
frame(&mut app, vec![egui::Event::PointerMoved(pos), button(true)], None);
|
||
for v in [101.0, 150.0, 200.0, 249.0] {
|
||
frame(&mut app, vec![egui::Event::PointerMoved(pos)], Some(v));
|
||
frame(&mut app, vec![egui::Event::PointerMoved(pos)], None); // idle: mouse down, no 1% tick
|
||
}
|
||
frame(&mut app, vec![button(false)], None);
|
||
assert_eq!(scale(&app), 249.0);
|
||
app.session.undo();
|
||
assert_eq!(scale(&app), start, "one undo restores the scale from before the drag");
|
||
assert_eq!(app.session.undo(), None, "the drag must not have left extra 1% steps");
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod lane_tests {
|
||
use super::*;
|
||
|
||
/// A context with the app's fonts (the panels use the semibold family).
|
||
fn context() -> egui::Context {
|
||
let ctx = egui::Context::default();
|
||
crate::theme::install(&ctx, &Tokens::for_kind(Default::default()));
|
||
ctx
|
||
}
|
||
|
||
/// A project file is only checked for overflow, so a damaged one can carry a clip with no
|
||
/// (or a negative) duration. Selecting it must still draw both panels, ruler, playhead and
|
||
/// keyframe navigators included.
|
||
#[test]
|
||
fn a_clip_without_a_duration_still_draws() {
|
||
let mut s = filmcraft_engine::Session::default();
|
||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||
let clip = s.active_sequence().unwrap().video_tracks[0].items[0].id;
|
||
s.execute("timeline.select", json!({"clips": [clip.0]})).unwrap();
|
||
s.execute("effects.toggleAnimation", json!({"clip": clip.0, "effect": "motion", "param": "scale"})).unwrap();
|
||
let seq = s.state.active_sequence.unwrap();
|
||
let mut app = crate::FilmcraftApp::new(s);
|
||
let ctx = context();
|
||
for duration in [0, -5 * filmcraft_time::TICKS_PER_SECOND] {
|
||
let project = std::sync::Arc::make_mut(&mut app.session.project);
|
||
project.sequence_mut(seq).unwrap().find_item_mut(clip).unwrap().1.duration = Tick(duration);
|
||
let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(700.0, 500.0));
|
||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||
show(&mut app, ui, rect);
|
||
properties_panel(&mut app, ui, rect);
|
||
});
|
||
out.textures_delta.clear();
|
||
}
|
||
}
|
||
|
||
/// The ruler takes its numbers from the project (clip times, the sequence's frame rate) and
|
||
/// the panel's size. Anything a project that opens can carry (times within `Tick::MIN..=MAX`,
|
||
/// a rate within `SequenceSettings::validate`, or an unset one) must not make it panic or
|
||
/// tick forever.
|
||
#[test]
|
||
fn ruler_survives_damaged_numbers() {
|
||
let mut s = filmcraft_engine::Session::default();
|
||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||
let mut it = s.active_sequence().unwrap().video_tracks[0].items[0].clone();
|
||
let ctx = context();
|
||
let t = Tokens::for_kind(Default::default());
|
||
let big = i64::from(u32::MAX);
|
||
let rates = [(24000, 1001), (30, 1), (1000, 1), (1, 1), (1, big), (big, big), (0, 0), (-25, 1)];
|
||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||
for width in [300.0, 0.0, -40.0, 0.001, 1.0e9, f32::NAN, f32::INFINITY] {
|
||
for duration in [1, 0, -7, filmcraft_time::TICKS_PER_SECOND, Tick::MAX.0 / 2] {
|
||
for start in [0, Tick::MIN.0, Tick::MAX.0 / 2] {
|
||
for (num, den) in rates {
|
||
it.start = Tick(start);
|
||
it.duration = Tick(duration);
|
||
let ruler = Rect::from_min_size(pos2(10.0, 10.0), vec2(width, RULER_H));
|
||
paint_ruler(ui.painter(), ruler, &it, filmcraft_time::FrameRate { num, den }, false, &t);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
out.textures_delta.clear();
|
||
}
|
||
|
||
/// A five-second lane 300 px wide is labelled every two seconds, in sequence timecode.
|
||
#[test]
|
||
fn ruler_labels_sequence_timecode() {
|
||
let mut s = filmcraft_engine::Session::default();
|
||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||
let mut it = s.active_sequence().unwrap().video_tracks[0].items[0].clone();
|
||
let rate = filmcraft_time::FrameRate { num: 24, den: 1 };
|
||
it.start = rate.tick_of(24 * 60);
|
||
it.duration = rate.tick_of(24 * 5);
|
||
let ctx = context();
|
||
let t = Tokens::for_kind(Default::default());
|
||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||
paint_ruler(ui.painter(), Rect::from_min_size(pos2(0.0, 0.0), vec2(300.0, RULER_H)), &it, rate, false, &t);
|
||
});
|
||
out.textures_delta.clear();
|
||
fn texts(s: egui::Shape, out: &mut Vec<String>) {
|
||
match s {
|
||
egui::Shape::Vec(v) => v.into_iter().for_each(|s| texts(s, out)),
|
||
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
|
||
_ => {}
|
||
}
|
||
}
|
||
let mut labels = Vec::new();
|
||
out.shapes.into_iter().for_each(|c| texts(c.shape, &mut labels));
|
||
assert_eq!(labels, ["00:01:00:00", "00:01:02:00", "00:01:04:00"]);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod graph_drag_tests {
|
||
use super::*;
|
||
use filmcraft_project::{Interpolation, Keyframe, Param};
|
||
|
||
struct Driver {
|
||
app: FilmcraftApp,
|
||
ctx: egui::Context,
|
||
clip: ClipId,
|
||
time: f64,
|
||
param: &'static str,
|
||
}
|
||
|
||
impl Driver {
|
||
fn new() -> Self {
|
||
Self::with("scale", ParamValue::Float(40.0), ParamValue::Float(80.0))
|
||
}
|
||
|
||
fn with(param: &'static str, from: ParamValue, to: ParamValue) -> Self {
|
||
let mut session = filmcraft_engine::Session::default();
|
||
session.execute("file.openDemoProject", json!({})).unwrap();
|
||
let seq = session.state.active_sequence.unwrap();
|
||
let clip = session.active_sequence().unwrap().video_tracks[0].items[0].id;
|
||
let it = std::sync::Arc::make_mut(&mut session.project).sequence_mut(seq).unwrap().find_item_mut(clip).unwrap().1;
|
||
let mut a = Keyframe::new(it.source_in + Tick(it.duration.0 / 4), from.clone());
|
||
let mut b = Keyframe::new(it.source_in + Tick(it.duration.0 * 3 / 4), to);
|
||
a.interp = Interpolation::Bezier;
|
||
b.interp = Interpolation::Bezier;
|
||
it.effects.iter_mut().find(|e| e.effect == "motion").unwrap().params.insert(param.into(), Param { value: from, keyframes: vec![a, b] });
|
||
let app = FilmcraftApp::new(session);
|
||
let ctx = egui::Context::default();
|
||
crate::theme::install(&ctx, &app.tokens);
|
||
Self { app, ctx, clip, time: 0.0, param }
|
||
}
|
||
|
||
fn param(&self) -> Param {
|
||
self.app.session.active_sequence().unwrap().find_item(self.clip).unwrap().1.effect("motion").unwrap().params[self.param].clone()
|
||
}
|
||
|
||
fn undo_len(&mut self) -> usize {
|
||
self.app.session.execute("history.list", json!({})).unwrap()["undo"].as_array().unwrap().len()
|
||
}
|
||
|
||
// Draw the actual scalar graph and execute its emitted command, as the panel does.
|
||
// Current paint shapes give a real held-frame position rather than previous telemetry.
|
||
fn frame(&mut self, events: Vec<egui::Event>) -> [Pos2; 2] {
|
||
self.time += 0.1;
|
||
self.app.auto.begin_frame();
|
||
let mut out = self.ctx.run_ui(egui::RawInput { time: Some(self.time), events, ..Default::default() }, |ui| {
|
||
let it = self.app.session.active_sequence().unwrap().find_item(self.clip).unwrap().1.clone();
|
||
let index = it.effects.iter().position(|e| e.effect == "motion").unwrap();
|
||
let effect = &it.effects[index];
|
||
let pd = effect.def().unwrap().param(self.param).unwrap();
|
||
let body = Rect::from_min_size(pos2(8.0, 8.0), vec2(320.0, 200.0));
|
||
let lane = Rect::from_min_max(pos2(360.0, 8.0), pos2(760.0, 220.0));
|
||
let mut actions = Vec::new();
|
||
graph_rows(&mut self.app, ui, body, self.clip, index, None, pd, &effect.params[self.param], &lane, &it, &mut actions);
|
||
run(&mut self.app, ui.ctx(), actions);
|
||
});
|
||
out.textures_delta.clear();
|
||
fn first_circle(shape: &egui::Shape, radius: f32) -> Option<Pos2> {
|
||
match shape {
|
||
egui::Shape::Circle(circle) if circle.radius == radius => Some(circle.center),
|
||
egui::Shape::Vec(shapes) => shapes.iter().find_map(|shape| first_circle(shape, radius)),
|
||
_ => None,
|
||
}
|
||
}
|
||
[4.5, 3.5].map(|radius| {
|
||
out.shapes.iter().find_map(|shape| first_circle(&shape.shape, radius)).expect("scalar keyframe and outgoing influence are painted")
|
||
})
|
||
}
|
||
}
|
||
|
||
fn button(pos: Pos2, pressed: bool) -> egui::Event {
|
||
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }
|
||
}
|
||
|
||
#[test]
|
||
fn scalar_graph_press_starts_at_the_pointer_origin_and_release_is_one_undo_step() {
|
||
for influence in [false, true] {
|
||
for batched in [true, false] {
|
||
let mut d = Driver::new();
|
||
let before = d.param();
|
||
let undo = d.undo_len();
|
||
let target = usize::from(influence);
|
||
let start = d.frame(vec![])[target];
|
||
// A different prior cursor makes the pre-press movement observable.
|
||
d.frame(vec![egui::Event::PointerMoved(start - vec2(80.0, 40.0))]);
|
||
if batched {
|
||
d.frame(vec![egui::Event::PointerMoved(start), button(start, true)]);
|
||
} else {
|
||
d.frame(vec![egui::Event::PointerMoved(start)]);
|
||
d.frame(vec![button(start, true)]);
|
||
}
|
||
let pressed = d.frame(vec![])[target];
|
||
assert!(pressed.distance(start) < 0.05, "press cannot jump: influence={influence}, batched={batched}, start={start:?}, pressed={pressed:?}");
|
||
assert_eq!(d.param(), before);
|
||
assert_eq!(d.undo_len(), undo);
|
||
let offset = if influence { vec2(20.0, 0.0) } else { vec2(0.0, -20.0) };
|
||
d.frame(vec![egui::Event::PointerMoved(start + offset)]);
|
||
let preview = d.frame(vec![])[target];
|
||
assert!(
|
||
preview.distance(start + offset) < 0.05,
|
||
"preview follows only gesture motion: influence={influence}, batched={batched}, start={start:?}, preview={preview:?}"
|
||
);
|
||
assert_eq!(d.param(), before, "held preview cannot commit");
|
||
assert_eq!(d.undo_len(), undo);
|
||
d.frame(vec![button(start + offset, false)]);
|
||
d.frame(vec![]);
|
||
let changed = d.param();
|
||
assert_eq!(d.undo_len(), undo + 1);
|
||
assert_eq!(changed.keyframes[0].time, before.keyframes[0].time);
|
||
assert_eq!(changed.keyframes[1], before.keyframes[1]);
|
||
if influence {
|
||
assert_eq!(changed.keyframes[0].value, before.keyframes[0].value);
|
||
assert!(changed.keyframes[0].out_influence > before.keyframes[0].out_influence);
|
||
} else {
|
||
assert!(changed.keyframes[0].value.as_f64().unwrap() > before.keyframes[0].value.as_f64().unwrap());
|
||
assert_eq!(changed.keyframes[0].out_influence, before.keyframes[0].out_influence);
|
||
}
|
||
d.app.session.undo().expect("one graph gesture to undo");
|
||
assert_eq!(d.param(), before);
|
||
assert_eq!(d.undo_len(), undo);
|
||
d.app.session.redo().expect("the graph gesture to redo");
|
||
assert_eq!(d.param(), changed);
|
||
assert_eq!(d.undo_len(), undo + 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// #239: Position (a point) has graphs too; dragging its X keyframe moves only X, in one undo step.
|
||
#[test]
|
||
fn position_graph_drags_one_coordinate_of_a_point_keyframe() {
|
||
use filmcraft_geom::Vec2;
|
||
let mut d = Driver::with("position", ParamValue::Vec2(Vec2::new(400.0, 300.0)), ParamValue::Vec2(Vec2::new(800.0, 500.0)));
|
||
assert!(graphable(&d.param().value));
|
||
let before = d.param();
|
||
let undo = d.undo_len();
|
||
let start = d.frame(vec![])[0];
|
||
assert!(d.app.auto.find(&format!("effectControls.position.graph.keyframe.{}.y", before.keyframes[0].time.0)).is_some());
|
||
d.frame(vec![egui::Event::PointerMoved(start), button(start, true)]);
|
||
d.frame(vec![egui::Event::PointerMoved(start - vec2(0.0, 20.0))]);
|
||
d.frame(vec![button(start - vec2(0.0, 20.0), false)]);
|
||
d.frame(vec![]);
|
||
let changed = d.param();
|
||
assert_eq!(d.undo_len(), undo + 1);
|
||
let (old, new) = (before.keyframes[0].value.as_vec2().unwrap(), changed.keyframes[0].value.as_vec2().unwrap());
|
||
assert!(new.x > old.x, "dragging the X keyframe up raises X: {old:?} -> {new:?}");
|
||
assert_eq!(new.y, old.y);
|
||
assert_eq!(changed.keyframes[1], before.keyframes[1]);
|
||
}
|
||
}
|