#614: Head, Head and Tail and Continuous video thumbnails in the Timeline

Problem: a video clip only ever showed its first frame. Show Video
Thumbnails was a plain on/off switch, so a wide clip at high zoom stayed a
flat colour after its head.

Fix: the wrench menu keeps Show Video Thumbnails and adds Head (the
default, as before), Head and Tail and Continuous under it. Continuous
tiles aspect-correct frames across the clip on a grid anchored at its head,
only where the panel shows them (at most 256 per clip), each frame the
source time under the tile's left edge (speed, reverse, frame holds,
multicam angles), requested at thumbnail priority through the existing
frame cache. A clip too narrow for the tiles falls back to its head. The
mode is UI state (`thumbnail_mode`, serde default Head, so saved settings
keep working) and `ui.set {timeline: {thumbnails}}` sets it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76 2026-10-10 19:08:48 +02:00
parent 98ba9acbce
commit c85d0ac973
8 changed files with 191 additions and 23 deletions

View File

@ -27,7 +27,7 @@ use serde_json::{Value, json};
use crate::FilmcraftApp;
use crate::dock::PanelKind;
use crate::state::{Mode, PlaybackRes, Tool};
use crate::state::{Mode, PlaybackRes, ThumbnailMode, Tool};
/// Prefix marking errors that may resolve after another frame.
pub const RETRY: &str = "\u{1}";
@ -189,6 +189,18 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest)
if let Some(v) = tl.get("audioTrackHeight").and_then(Value::as_f64) {
app.ui.timeline.audio_track_h = v as f32;
}
// Show Video Thumbnails: "off" or a mode ("head", "headAndTail", "continuous")
if let Some(m) = tl.get("thumbnails").and_then(Value::as_str) {
if m.eq_ignore_ascii_case("off") {
app.ui.timeline.show_thumbnails = false;
} else {
let Some(mode) = ThumbnailMode::from_name(m) else {
return err(format!("unknown thumbnail mode `{m}` (off, head, headAndTail, continuous)"));
};
app.ui.timeline.show_thumbnails = true;
app.ui.timeline.thumbnail_mode = mode;
}
}
}
if let Some(q) = s("effectsSearch") {
app.ui.effects_search = q.to_string();

View File

@ -1215,6 +1215,9 @@
Caption track options Opciones de pista de subtítulos
Timeline Display Settings Ajustes de visualización de la línea de tiempo
Show Video Thumbnails Mostrar miniaturas de video
Head Cabeza
Head and Tail Cabeza y cola
Continuous Continuas
Show Audio Waveform Mostrar forma de onda de audio
Expand All Tracks Expandir todas las pistas
Minimize All Tracks Minimizar todas las pistas

Can't render this file because it contains an unexpected character in line 3 and column 95.

View File

@ -1215,6 +1215,9 @@
Caption track options キャプショントラックのオプション
Timeline Display Settings タイムライン表示設定
Show Video Thumbnails ビデオのサムネイルを表示
Head 先頭
Head and Tail 先頭と末尾
Continuous 連続
Show Audio Waveform オーディオ波形を表示
Expand All Tracks すべてのトラックを展開
Minimize All Tracks すべてのトラックを最小化

Can't render this file because it contains an unexpected character in line 11 and column 59.

View File

@ -1208,6 +1208,9 @@
Caption track options 字幕轨道选项
Timeline Display Settings 时间轴显示设置
Show Video Thumbnails 显示视频缩略图
Head 首帧
Head and Tail 首尾帧
Continuous 连续
Show Audio Waveform 显示音频波形
Expand All Tracks 展开所有轨道
Minimize All Tracks 最小化所有轨道

Can't render this file because it contains an unexpected character in line 3 and column 95.

View File

@ -19,7 +19,7 @@ use serde_json::{Value, json};
use crate::FilmcraftApp;
use crate::icons::{self, Icon};
use crate::state::{TimelineView, Tool};
use crate::state::{ThumbnailMode, TimelineView, Tool};
use crate::theme::Tokens;
use super::timeline_hit::{EdgeKind, Grab, edge_geometry, grab_at};
@ -604,6 +604,36 @@ fn in_range(app: &FilmcraftApp, it: &TrackItem) -> bool {
it.start < b && it.end() > a
}
/// Most thumbnails one clip draws: a long clip far zoomed in only shows the visible ones anyway.
const MAX_THUMBNAIL_TILES: usize = 256;
/// The video thumbnails of a clip whose picture area spans `x0..x1` (points), for frames `tile_w`
/// wide: the left edge of each tile and the offset into the clip (`dur` long, frames of `frame`)
/// whose picture it shows. Only tiles that meet `visible` (the panel's x range) are listed.
/// Continuous tiles sit on a grid anchored at the clip's head, so scrolling only reveals new tiles
/// and the others keep their frames. A clip too narrow for more than one tile shows its head.
pub fn thumbnail_tiles(mode: ThumbnailMode, x0: f32, x1: f32, tile_w: f32, visible: (f32, f32), dur: Tick, frame: Tick) -> Vec<(f32, Tick)> {
let width = x1 - x0;
if !(x0.is_finite() && width.is_finite() && width > 0.0 && tile_w.is_finite() && tile_w >= 1.0) {
return Vec::new();
}
let last = (dur - frame).max(Tick::ZERO);
let offset_at = |x: f32| Tick(((x - x0) as f64 / width as f64 * dur.0 as f64).round() as i64).clamp(Tick::ZERO, last);
let shown = |x: f32| x < visible.1 && x + tile_w > visible.0;
let head = (x0, Tick::ZERO);
let tiles = match mode {
ThumbnailMode::Head => vec![head],
ThumbnailMode::HeadAndTail if width >= 2.0 * tile_w => vec![head, (x1 - tile_w, last)],
ThumbnailMode::HeadAndTail => vec![head],
ThumbnailMode::Continuous => {
let first = ((visible.0 - x0).max(0.0) / tile_w).floor() as usize;
let end = ((visible.1.min(x1) - x0) / tile_w).ceil().max(1.0) as usize;
(first..end).take(MAX_THUMBNAIL_TILES).map(|k| x0 + k as f32 * tile_w).map(|x| (x, offset_at(x))).collect()
}
};
tiles.into_iter().filter(|(x, _)| shown(*x)).collect()
}
#[allow(clippy::too_many_arguments)]
fn draw_clip(
app: &mut FilmcraftApp,
@ -632,7 +662,8 @@ fn draw_clip(
p.line_segment([pos2(body.min.x, body.min.y + 0.5), pos2(body.max.x, body.min.y + 0.5)], Stroke::new(1.0, lighten(fill, 0.15)));
let name_h = 16.0;
let w = body.width();
// head thumbnail (video): left-aligned, aspect-correct, below the name band
// video thumbnails, aspect-correct, below the name band: the head frame, head and tail, or
// frames side by side across the clip (Show Video Thumbnails in the wrench menu)
if kind == TrackKind::Video && app.ui.timeline.show_thumbnails && body.height() > 28.0 && w > 20.0 && it.enabled {
let th = Rect::from_min_max(pos2(body.min.x + 1.0, body.min.y + name_h), pos2(body.max.x - 1.0, body.max.y - 1.0));
let aspect = app.session.project.item(it.item).and_then(|pi| match &pi.kind {
@ -640,24 +671,28 @@ fn draw_clip(
filmcraft_project::ItemKind::Sequence(s) => Some(s.settings.width as f32 / s.settings.height as f32),
_ => None,
});
if let Some(aspect) = aspect {
let tw = (th.height() * aspect).min(th.width());
let mt = rate.snap(it.source_in);
// a multi-camera clip shows its angle's clip
let (thumb_item, mt) = app
.session
.project
.sequence(it.item)
.and_then(|q| {
let ti = q.angle_video_track_index(it.multicam_angle(q)?)?;
let inner = q.video_tracks[ti].item_at(mt)?;
Some((inner.item, inner.source_time_at(mt)))
})
.unwrap_or((it.item, mt));
if let Some((tex, _)) = app.thumbnail(ctx, thumb_item, mt, 160) {
let r = Rect::from_min_size(th.min, vec2(tw, th.height()));
let cp = p.with_clip_rect(th.intersect(p.clip_rect()));
cp.image(tex, r, Rect::from_min_max(pos2(0.0, 0.0), pos2((tw / (th.height() * aspect)).min(1.0), 1.0)), Color32::WHITE);
if let Some(aspect) = aspect.filter(|a| a.is_finite() && *a > 0.0) {
let tw = th.height() * aspect;
let cp = p.with_clip_rect(th.intersect(p.clip_rect()));
let visible = (cp.clip_rect().min.x, cp.clip_rect().max.x);
let mode = app.ui.timeline.thumbnail_mode;
for (x, offset) in thumbnail_tiles(mode, th.min.x, th.max.x, tw, visible, it.duration, rate.frame_duration()) {
let mt = rate.snap(it.source_time_at(it.start + offset));
// a multi-camera clip shows its angle's clip
let (thumb_item, mt) = app
.session
.project
.sequence(it.item)
.and_then(|q| {
let ti = q.angle_video_track_index(it.multicam_angle(q)?)?;
let inner = q.video_tracks.get(ti)?.item_at(mt)?;
Some((inner.item, inner.source_time_at(mt)))
})
.unwrap_or((it.item, mt));
if let Some((tex, _)) = app.thumbnail(ctx, thumb_item, mt, 160) {
let r = Rect::from_min_size(pos2(x, th.min.y), vec2(tw, th.height()));
cp.image(tex, r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
}
}
}
@ -1179,7 +1214,20 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc
}
if key == "settings" {
egui::Popup::menu(&resp).show(|ui| {
ui.checkbox(&mut app.ui.timeline.show_thumbnails, tl!("Show Video Thumbnails"));
let c = ui.checkbox(&mut app.ui.timeline.show_thumbnails, tl!("Show Video Thumbnails"));
app.auto.add("timeline.settings.showThumbnails", c.rect, "Show Video Thumbnails");
let on = app.ui.timeline.show_thumbnails;
ui.indent("timeline.settings.thumbnails", |ui| {
for (mode, label) in
[(ThumbnailMode::Head, tl!("Head")), (ThumbnailMode::HeadAndTail, tl!("Head and Tail")), (ThumbnailMode::Continuous, tl!("Continuous"))]
{
let r = ui.add_enabled(on, egui::RadioButton::new(app.ui.timeline.thumbnail_mode == mode, label));
app.auto.add(&format!("timeline.settings.thumbnails.{}", mode.name()), r.rect, label);
if r.clicked() {
app.ui.timeline.thumbnail_mode = mode;
}
}
});
ui.checkbox(&mut app.ui.timeline.show_waveforms, tl!("Show Audio Waveform"));
ui.separator();
let mut te = app.session.state.show_through_edits;
@ -2524,3 +2572,47 @@ mod chain_starts_tests {
assert_eq!(chain_starts(Tick(5), &[Tick(-3), Tick(4)]), vec![Tick(5), Tick(5)]);
}
}
#[cfg(test)]
mod thumbnail_tiles_tests {
use super::*;
const S: Tick = Tick(TICKS_PER_SECOND);
const FRAME: Tick = Tick(TICKS_PER_SECOND / 25);
#[test]
fn continuous_thumbnails_tile_the_visible_part_of_the_clip() {
// a 10 s clip 1000 pt wide, 80 pt tiles, the panel showing x 250..650
let tiles = thumbnail_tiles(ThumbnailMode::Continuous, 0.0, 1000.0, 80.0, (250.0, 650.0), Tick(10 * S.0), FRAME);
let xs: Vec<f32> = tiles.iter().map(|t| t.0).collect();
assert_eq!(xs, [240.0, 320.0, 400.0, 480.0, 560.0, 640.0]);
// each tile shows the clip time under its left edge (100 pt per second)
assert_eq!(tiles[0].1, Tick(24 * S.0 / 10));
assert_eq!(tiles[5].1, Tick(64 * S.0 / 10));
// scrolling 30 pt moves the tiles with the clip: the ones still shown keep their frames
let scrolled = thumbnail_tiles(ThumbnailMode::Continuous, -30.0, 970.0, 80.0, (250.0, 650.0), Tick(10 * S.0), FRAME);
assert_eq!(scrolled.first(), Some(&(210.0, tiles[0].1)));
assert!(scrolled.iter().all(|(x, t)| tiles.iter().any(|(x0, t0)| *x0 - 30.0 == *x && t0 == t)));
// the last tile never shows a time past the clip's last frame
let end = thumbnail_tiles(ThumbnailMode::Continuous, 0.0, 1000.0, 80.0, (0.0, 2000.0), Tick(10 * S.0), FRAME);
assert_eq!(end.len(), 13);
assert!(end.iter().all(|(_, t)| *t <= Tick(10 * S.0) - FRAME));
// a clip far zoomed in asks for a bounded number of frames
let huge = thumbnail_tiles(ThumbnailMode::Continuous, -1.0e9, 1.0e9, 1.0, (-2.0e9, 2.0e9), Tick(10 * S.0), FRAME);
assert_eq!(huge.len(), MAX_THUMBNAIL_TILES);
}
#[test]
fn head_and_tail_thumbnails_need_room_for_two_frames() {
let dur = Tick(4 * S.0);
assert_eq!(thumbnail_tiles(ThumbnailMode::Head, 100.0, 500.0, 80.0, (0.0, 800.0), dur, FRAME), [(100.0, Tick::ZERO)]);
assert_eq!(thumbnail_tiles(ThumbnailMode::HeadAndTail, 100.0, 500.0, 80.0, (0.0, 800.0), dur, FRAME), [(100.0, Tick::ZERO), (420.0, dur - FRAME)]);
// narrower than two tiles: the head only, as in Continuous narrower than one tile
assert_eq!(thumbnail_tiles(ThumbnailMode::HeadAndTail, 100.0, 250.0, 80.0, (0.0, 800.0), dur, FRAME), [(100.0, Tick::ZERO)]);
assert_eq!(thumbnail_tiles(ThumbnailMode::Continuous, 100.0, 150.0, 80.0, (0.0, 800.0), dur, FRAME), [(100.0, Tick::ZERO)]);
// off screen or degenerate: nothing
assert!(thumbnail_tiles(ThumbnailMode::Head, 900.0, 1300.0, 80.0, (0.0, 800.0), dur, FRAME).is_empty());
assert!(thumbnail_tiles(ThumbnailMode::Continuous, 0.0, 400.0, f32::NAN, (0.0, 800.0), dur, FRAME).is_empty());
assert!(thumbnail_tiles(ThumbnailMode::Continuous, 0.0, 0.0, 80.0, (0.0, 800.0), dur, FRAME).is_empty());
}
}

View File

@ -190,6 +190,9 @@ pub struct TimelineView {
pub audio_track_h: f32,
pub header_w: f32,
pub show_thumbnails: bool,
/// Which frames a video clip shows while `show_thumbnails` is on (the wrench menu).
#[serde(default)]
pub thumbnail_mode: ThumbnailMode,
pub show_waveforms: bool,
/// Follow playhead during playback (page scroll).
pub follow: bool,
@ -225,6 +228,7 @@ impl Default for TimelineView {
audio_track_h: 56.0,
header_w: 204.0,
show_thumbnails: true,
thumbnail_mode: ThumbnailMode::Head,
show_waveforms: true,
follow: true,
fit_pending: true,
@ -234,6 +238,35 @@ impl Default for TimelineView {
}
}
/// Video thumbnails on timeline clips (Timeline wrench menu ▸ Show Video Thumbnails).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ThumbnailMode {
/// The clip's first frame at its head.
#[default]
Head,
/// The first frame at the head and the last one at the tail.
HeadAndTail,
/// Frames side by side across the whole clip.
Continuous,
}
impl ThumbnailMode {
pub const ALL: [ThumbnailMode; 3] = [ThumbnailMode::Head, ThumbnailMode::HeadAndTail, ThumbnailMode::Continuous];
/// The name used by automation ids and `ui.set` (`timeline.thumbnails`).
pub fn name(self) -> &'static str {
match self {
ThumbnailMode::Head => "head",
ThumbnailMode::HeadAndTail => "headAndTail",
ThumbnailMode::Continuous => "continuous",
}
}
pub fn from_name(name: &str) -> Option<ThumbnailMode> {
Self::ALL.into_iter().find(|m| m.name().eq_ignore_ascii_case(name))
}
}
/// Monitor display mode (View ▸ Display Mode / the monitor's wrench menu). Multi-Camera is the
/// separate `MonitorView::multicam` switch.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]

View File

@ -263,3 +263,25 @@ fn the_scroll_bar_thumb_follows_the_pointer_and_reaches_both_ends() {
let (scroll, pps, ..) = d.view();
assert!(pps < 50.0 && (scroll - at).abs() < 1e-6, "{pps} {scroll}");
}
/// Show Video Thumbnails ▸ Continuous asks for frames across each video clip, not only its head;
/// `ui.set` switches the mode (and Off) and refuses an unknown one.
#[test]
fn continuous_video_thumbnails_ask_for_frames_across_each_clip() {
let mut d = Driver::demo();
d.ok("ui.set", json!({"timeline": {"thumbnails": "head", "pps": 50.0, "scroll": 0.0}}));
d.view();
d.frames(10);
let head = d.app().frames.stats().request_misses;
d.ok("ui.set", json!({"timeline": {"thumbnails": "continuous"}}));
d.frames(3);
let continuous = d.app().frames.stats().request_misses;
assert!(continuous >= head + 5, "continuous thumbnails queued {} more frames", continuous - head);
let tl = d.ok("ui.inspect", none())["ui"]["timeline"].clone();
assert_eq!((tl["show_thumbnails"].clone(), tl["thumbnail_mode"].clone()), (json!(true), json!("Continuous")));
d.ok("ui.set", json!({"timeline": {"thumbnails": "off"}}));
assert!(!d.app().ui.timeline.show_thumbnails);
let v = d.call("ui.set", json!({"timeline": {"thumbnails": "filmstrip"}}));
assert_eq!(v["ok"], json!(false), "{v}");
assert!(!d.app().ui.timeline.show_thumbnails);
}

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,thumbnails}, 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)); `timeline.thumbnails` is `off`, `head`, `headAndTail` or `continuous` (the wrench menu's Show Video Thumbnails); `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 |