Merge pull request #656 from vangel76/fix-412-single-keyframe-select

#412: select a single keyframe in Effect Controls
This commit is contained in:
Brandon Thomas 2026-10-10 14:53:45 -04:00 committed by GitHub
commit 5f9b5e1234
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 83 additions and 2 deletions

View File

@ -193,6 +193,14 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest)
if let Some(q) = s("effectsSearch") {
app.ui.effects_search = q.to_string();
}
// Effect Controls keyframe selection (`[{clip, effect, param, mask?, time}]`), replaced;
// references to missing keyframes are dropped by the panel
if let Some(v) = p.get("keyframeSelection") {
match serde_json::from_value(v.clone()) {
Ok(sel) => app.ui.keyframe_selection = sel,
Err(e) => return err(format!("`keyframeSelection`: {e}")),
}
}
if let Some(v) = p.get("safeMargins").and_then(Value::as_bool) {
app.ui.program.safe_margins = v;
}

View File

@ -9,6 +9,7 @@ use serde_json::{Value, json};
use crate::FilmcraftApp;
use crate::icons::{self, Icon};
use crate::state::KeyframeRef;
use crate::theme::Tokens;
const ROW_H: f32 = 22.0;
@ -41,6 +42,16 @@ fn graph_drag_offset(ui: &egui::Ui, response: &egui::Response, previous: egui::V
}
}
/// 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))
}
fn selected_clip(app: &FilmcraftApp) -> Option<(ClipId, TrackItem, TrackKind)> {
selected_clips(app).into_iter().next()
}
@ -71,6 +82,8 @@ fn selected_clips(app: &FilmcraftApp) -> Vec<(ClipId, TrackItem, TrackKind)> {
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) {
@ -561,7 +574,13 @@ pub(crate) fn param_row(
let kr = Rect::from_center_size(pos2(kx, y), vec2(11.0, 11.0));
let resp = ui.interact(kr.expand(2.0), id, Sense::click_and_drag());
app.auto.add(&format!("effectControls.{}.{}.keyframe.{}", e.effect, pkey, k.time.0), kr, "keyframe");
let sel = k.time == mt || resp.dragged();
// highlighted when selected (not merely under the playhead: that lit up every
// parameter's keyframe at the same time, #412)
let is_this = |s: &KeyframeRef| s.clip == clip.0 && s.effect == idx && s.mask == mask && s.param == pd.id && s.time == k.time;
if resp.clicked() || resp.drag_started() {
app.ui.keyframe_selection = vec![KeyframeRef { clip: clip.0, effect: idx, param: pd.id.to_string(), mask, time: k.time }];
}
let sel = resp.dragged() || app.ui.keyframe_selection.iter().any(is_this);
let col = if sel { t.hot_text } else { Color32::from_rgb(0xb0, 0xb0, 0xb0) };
match k.interp {
filmcraft_project::Interpolation::Hold => {
@ -583,6 +602,10 @@ pub(crate) fn param_row(
rate.snap_nearest(it.start + Tick(((f + off / lane.width()) as f64 * dur) as i64)).clamp(it.start, it.end() - rate.frame_duration());
let new_media = it.source_in + Tick(((new_tl - it.start).0 as f64 * it.speed.abs()) as i64);
if new_media != k.time {
// the moved keyframe stays selected
for s in app.ui.keyframe_selection.iter_mut().filter(|s| is_this(s)) {
s.time = new_media;
}
actions.push((
"effects.moveKeyframe".into(),
with_mask(json!({"clip": clip.0, "effect": eff_json, "param": pd.id, "mediaTime": k.time.0, "to": new_media.0})),

View File

@ -368,6 +368,19 @@ pub struct Eyedropper {
pub mask: Option<usize>,
}
/// A keyframe selected in the Effect Controls keyframe lane: clip, effect index, parameter (of an
/// effect mask when `mask` is set) and the keyframe's media time. The panel drops references that
/// no longer match a keyframe (deleted, moved, another clip selected).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeyframeRef {
pub clip: u64,
pub effect: usize,
pub param: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mask: Option<usize>,
pub time: filmcraft_time::Tick,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UiState {
#[serde(default)]
@ -408,6 +421,9 @@ pub struct UiState {
/// monitor (Esc or a click elsewhere disarms it). Never saved.
#[serde(skip)]
pub eyedropper: Option<Eyedropper>,
/// Effect Controls: the selected keyframes, highlighted in the keyframe lane (a click selects one).
#[serde(default)]
pub keyframe_selection: Vec<KeyframeRef>,
/// Essential Sound sub-tab: "Edit" or "Browse".
#[serde(default)]
pub essential_sound_tab: String,
@ -904,6 +920,7 @@ impl Default for UiState {
show_scopes: false,
status: String::new(),
eyedropper: None,
keyframe_selection: Vec::new(),
essential_sound_tab: "Edit".into(),
tts: Default::default(),
export: Default::default(),

View File

@ -225,3 +225,36 @@ fn effect_controls_ruler_moves_the_playhead() {
assert!(d.find("effectControls.playhead").is_none(), "no handle while the playhead is past the clip");
d.rect("effectControls.ruler");
}
/// #412: Scale and Position both have a keyframe at the same time. A click on Scale's keyframe in
/// the Effect Controls lane selects that keyframe only (every keyframe under the playhead, which
/// the click moves there, used to light up); a keyframe that is gone drops out of the selection.
#[test]
fn clicking_a_lane_keyframe_selects_only_that_keyframe() {
let (mut d, clip) = Driver::demo();
let t0 = d.seek(1.0);
for param in ["scale", "position"] {
d.exec("effects.addKeyframe", json!({"clip": clip.id, "effect": "motion", "param": param}));
}
let time = d.scale(&clip).0[0];
let motion = {
let s = &d.harness.state().session;
let it = s.active_sequence().unwrap().find_item(ClipId(clip.id)).unwrap().1;
it.effects.iter().position(|e| e.effect == "motion").unwrap()
};
let selection = |d: &mut Driver| d.ok("ui.inspect", json!({}))["ui"]["keyframe_selection"].clone();
d.seek(2.0);
assert_eq!(selection(&mut d), json!([]), "nothing selected yet");
d.click(&format!("effectControls.motion.scale.keyframe.{time}"));
assert_eq!(selection(&mut d), json!([{"clip": clip.id, "effect": motion, "param": "scale", "time": time}]), "only Scale's keyframe");
assert_eq!(d.playhead(), t0, "the click still moves the playhead to the keyframe");
d.shot("keyframes-select-one");
d.click(&format!("effectControls.motion.position.keyframe.{time}"));
assert_eq!(selection(&mut d), json!([{"clip": clip.id, "effect": motion, "param": "position", "time": time}]), "a click replaces the selection");
// the diamond removes the keyframe at the playhead, the selected one: the selection empties
d.click("effectControls.motion.position.addKeyframe");
assert_eq!(selection(&mut d), json!([]), "a deleted keyframe is not selected");
}

View File

@ -29,7 +29,7 @@ Methods (handlers in `crates/ui-egui/src/control.rs`):
| `engine.commands` / `ui.menu.list` | – | command registry / menu tree |
| `ui.inspect` | – | UI state (tool, workspace, dock, timeline view, playback, fps…) |
| `ui.elements` | `{prefix?}` | every on-screen interactive element: id, label, rect |
| `ui.set` | `{tool, workspace, mode, theme, focused, playbackRes, timeline:{pps,scroll,fit}, program:{…}, source:{…}, clipDialog:{param: value}, menuDialog:{param: value}, panels:{scopes, timecode, reference, events, progress}, export:{…}}` | `program` / `source` merge fields into the monitor view state (`res`, `paused_res`, `high_quality`, `display`, `zoom`, `pan`, `show_rulers`, `show_guides`, `lock_guides`, `snap`, `guides`, `compare_ref`, …; see [monitors.md](monitors.md)); `clipDialog` sets fields of the open Edit / Clip / File dialog (Paste Attributes, Make Subclip, Frame Hold Options, …); `menuDialog` those of the M3.11 dialogs (Find, Create Search Bin, Project Settings, Scene Edit Detection, Simplify Sequence, Automate to Sequence…; see `panels::menu_dialogs`); `panels` merges into the panel settings (`ui.inspect` → `ui.panels`; see `panels::panel_state`): Lumetri Scopes `{shown: ["vectorscopeYuv"|"vectorscopeHls"|"histogram"|"parade"|"waveform"], waveformType, paradeType, colorSpace, brightness, scale, clamp, targets}`, Timecode `{rows: [{source, mode, display}], showName}`, Reference Monitor `{ganged, time, display: "composite"|"scopes", scopes}`, Events `{level, selected}`, Progress `{showFinished}` `export` deep-merges into Export mode state (`preset` — applied first —, `settings` (ExportSettings JSON), `fileName`, `location`, `range`, `customStart`, `customEnd`, `openSections`, `manager` (Preset Manager: `query`, `favoritesOnly`, `selected`, `saveName`), `quickOpen`, `quickPath`, `quickPreset`); `program` / `source` merge fields into the monitor view state (`res`, `paused_res`, `high_quality`, `display`, `zoom`, `pan`, `show_rulers`, `show_guides`, `lock_guides`, `snap`, `guides`, `compare_ref`, …; see [monitors.md](monitors.md)); `clipDialog` sets fields of the open Edit / Clip / File dialog (Paste Attributes, Make Subclip, Frame Hold Options, …); `menuDialog` those of the M3.11 dialogs (Find, Create Search Bin, Project Settings, Scene Edit Detection, Simplify Sequence, Automate to Sequence…; see `panels::menu_dialogs`); `projectPanel` merges into the Project panel's view state (`bin` shown in place, `tabs` (bins in a tab or window: `{bin, floating, view, iconSize, nav}`), `activeTab`, `selectedBin`, `rename: {item|bin, text}`, `dialog` (`{"metadataDisplay": {columns, filter}}`, `{"savePresetAs": {name}}`, `{"managePresets": {selected, name}}`, `{"freeformOptions": {options}}`, `{"saveArrangement": {name, bin}}`)); `mediaBrowser` into the Media Browser's (`expanded`, `pathEdit`, `editColumns`, `treeWidth`). View settings, columns and presets are engine commands (`project.view.set`, `project.columns.set`, `project.viewPreset.*`, `mediaBrowser.settings`) |
| `ui.set` | `{tool, workspace, mode, theme, focused, playbackRes, timeline:{pps,scroll,fit}, program:{…}, source:{…}, clipDialog:{param: value}, menuDialog:{param: value}, panels:{scopes, timecode, reference, events, progress}, export:{…}, keyframeSelection:[…]}` | `keyframeSelection` replaces the Effect Controls keyframe selection (`[{clip, effect, param, mask?, time}]`, `time` in media ticks; read back as `ui.inspect` → `ui.keyframe_selection`; a click on a lane keyframe selects just that one); `program` / `source` merge fields into the monitor view state (`res`, `paused_res`, `high_quality`, `display`, `zoom`, `pan`, `show_rulers`, `show_guides`, `lock_guides`, `snap`, `guides`, `compare_ref`, …; see [monitors.md](monitors.md)); `clipDialog` sets fields of the open Edit / Clip / File dialog (Paste Attributes, Make Subclip, Frame Hold Options, …); `menuDialog` those of the M3.11 dialogs (Find, Create Search Bin, Project Settings, Scene Edit Detection, Simplify Sequence, Automate to Sequence…; see `panels::menu_dialogs`); `panels` merges into the panel settings (`ui.inspect` → `ui.panels`; see `panels::panel_state`): Lumetri Scopes `{shown: ["vectorscopeYuv"|"vectorscopeHls"|"histogram"|"parade"|"waveform"], waveformType, paradeType, colorSpace, brightness, scale, clamp, targets}`, Timecode `{rows: [{source, mode, display}], showName}`, Reference Monitor `{ganged, time, display: "composite"|"scopes", scopes}`, Events `{level, selected}`, Progress `{showFinished}` `export` deep-merges into Export mode state (`preset` — applied first —, `settings` (ExportSettings JSON), `fileName`, `location`, `range`, `customStart`, `customEnd`, `openSections`, `manager` (Preset Manager: `query`, `favoritesOnly`, `selected`, `saveName`), `quickOpen`, `quickPath`, `quickPreset`); `program` / `source` merge fields into the monitor view state (`res`, `paused_res`, `high_quality`, `display`, `zoom`, `pan`, `show_rulers`, `show_guides`, `lock_guides`, `snap`, `guides`, `compare_ref`, …; see [monitors.md](monitors.md)); `clipDialog` sets fields of the open Edit / Clip / File dialog (Paste Attributes, Make Subclip, Frame Hold Options, …); `menuDialog` those of the M3.11 dialogs (Find, Create Search Bin, Project Settings, Scene Edit Detection, Simplify Sequence, Automate to Sequence…; see `panels::menu_dialogs`); `projectPanel` merges into the Project panel's view state (`bin` shown in place, `tabs` (bins in a tab or window: `{bin, floating, view, iconSize, nav}`), `activeTab`, `selectedBin`, `rename: {item|bin, text}`, `dialog` (`{"metadataDisplay": {columns, filter}}`, `{"savePresetAs": {name}}`, `{"managePresets": {selected, name}}`, `{"freeformOptions": {options}}`, `{"saveArrangement": {name, bin}}`)); `mediaBrowser` into the Media Browser's (`expanded`, `pathEdit`, `editColumns`, `treeWidth`). View settings, columns and presets are engine commands (`project.view.set`, `project.columns.set`, `project.viewPreset.*`, `mediaBrowser.settings`) |
| `ui.panel.show` / `ui.panel.close` | `{panel}` | |
| `ui.click` / `ui.move` | `{id}` or `{x,y}`, `button`, `count`, `modifiers` | synthetic pointer input |
| `ui.drag` | `{from, to, steps, modifiers}` | press–move–release |