graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: the playbar has previous and next frame buttons, which its menu can hide
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 14 ++++-
src/app.rs | 24 ++++++--
src/command.rs | 3 +
src/dialog.rs | 1 +
src/main.rs | 70 ++++++++++++++++++++++-
src/playbar.rs | 169 ++++++++++++++++++++++++++++++++++++++++++--------------
src/shortcut.rs | 2 +
src/window.rs | 1 +
8 files changed, 233 insertions(+), 51 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 742c9e3..c327627 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2751,7 +2751,8 @@ A right press on the playbar's plate (`over_playbar`) opens the sixth
`playbar_menu_rows` / `handle_playbar_menu_click` /
`run_playbar_menu_action`, `PlaybarMenuAction`. Rows: the transport's
commands by id (Play / Pause, Play / Pause Reverse, Go To Start Frame),
-the **Repeat Playback** switch with its mark, then three slider rows —
+the **Repeat Playback** and **Show Step Buttons** switches with their
+marks, then three slider rows —
**Playback Rate** (1–120 fps by one), **Start Frame** (1–999) and **End
Frame** (2–1000). An end moved past the other carries it a frame ahead,
and the playhead is kept inside the range. The rate is a setting,
@@ -2765,6 +2766,17 @@ share them without either being named there. `get_state` reports `fps`
in its `playbar` block. `the_playbar_menu_sets_the_rate_and_the_range`
drives it by pointer.
+**The step buttons** (since 2026-10-01): Previous Frame and Next Frame
+stand either side of the play button (|◁ ▷ ▷|), each a whole frame off
+the ROUNDED frame and inside the range, without pausing —
+`Playbar::step`, which the Left / Right chords (`frame_prev` /
+`frame_next`) share. Show Step Buttons (`toggle_playbar_step_buttons`,
+unbound, a switch in the palette too) takes them away and the track
+widens into their room; on by default, persisted as top-level
+`playbar_step_buttons` in state.kdl beside `playbar_repeat`, and reported
+as `step_buttons` in `get_state`'s playbar block.
+`the_playbar_step_buttons_step_and_can_be_hidden` is the test.
+
### Display mode: the viewport menu, and smooth shading
The viewport's right-click menu has two SUBMENUS (since 2026-09-29;
diff --git a/src/app.rs b/src/app.rs
index 7afb033..3ce737d 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2038,6 +2038,10 @@ pub struct DesignSettings {
/// Rate. Top-level like `playbar_repeat`, and for the same reason.
#[serde(default = "default_playbar_fps")]
pub playbar_fps: f32,
+ /// Whether the playbar shows its Previous / Next Frame buttons —
+ /// `toggle_playbar_step_buttons`. Top-level like `playbar_repeat`.
+ #[serde(default = "default_true")]
+ pub playbar_step_buttons: bool,
}
fn default_playbar_fps() -> f32 {
@@ -2053,6 +2057,7 @@ impl Default for DesignSettings {
gpu: default_gpu(),
playbar_repeat: true,
playbar_fps: default_playbar_fps(),
+ playbar_step_buttons: true,
}
}
}
@@ -3175,6 +3180,7 @@ impl State {
gpu: self.gpu_preference.clone(),
playbar_repeat: self.slots.playbar.inner().repeat,
playbar_fps: self.slots.playbar.inner().fps,
+ playbar_step_buttons: self.slots.playbar.inner().step_buttons,
};
settings.save();
self.last_design_mod_time = {
@@ -5748,6 +5754,7 @@ impl State {
command(&mut options, &mut actions, "frame_start");
row(&mut options, &mut actions, "-", PlaybarMenuAction::Separator);
command(&mut options, &mut actions, "toggle_playbar_repeat");
+ command(&mut options, &mut actions, "toggle_playbar_step_buttons");
row(&mut options, &mut actions, "-", PlaybarMenuAction::Separator);
row(&mut options, &mut actions, "Playback Rate", PlaybarMenuAction::FpsSlider);
row(&mut options, &mut actions, "Start Frame", PlaybarMenuAction::StartFrameSlider);
@@ -7590,6 +7597,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
});
slots.playbar.inner_mut().repeat = settings.playbar_repeat;
+ slots.playbar.inner_mut().step_buttons = settings.playbar_step_buttons;
let wire_style = cce_ui::widget::display::WireStyle::parse(&settings.viewport.node_wire_style);
slots.content.inner_mut().set_wire_style(wire_style);
slots.content2.inner_mut().set_wire_style(wire_style);
@@ -9759,14 +9767,17 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
pb.repeat = !pb.repeat;
settings_changed = true;
}
- // Whole-frame stepping off the ROUNDED current frame: during
- // playback the playhead sits between frames, and stepping from
- // the fractional value would land off the frame grid. The scene
- // rebuild follows from tick_frame's last_sim_frame diff.
+ Action::TogglePlaybarStepButtons => {
+ let pb = self.slots.playbar.inner_mut();
+ pb.step_buttons = !pb.step_buttons;
+ settings_changed = true;
+ }
+ // Whole-frame stepping (`Playbar::step`, which the playbar's
+ // own step buttons share). The scene rebuild follows from
+ // tick_frame's last_sim_frame diff.
Action::FrameNext | Action::FramePrev => {
let step = if action == Action::FrameNext { 1.0 } else { -1.0 };
- let pb = self.slots.playbar.inner_mut();
- pb.current_frame = (pb.current_frame.round() + step).clamp(pb.start_frame, pb.end_frame);
+ self.slots.playbar.inner_mut().step(step);
}
// Rewind: pause whatever is playing and land on the start frame.
// The scene rebuild follows from tick_frame's last_sim_frame
@@ -11610,6 +11621,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
self.default_project_setting = settings.default_project.clone();
self.gpu_preference = settings.gpu.clone();
self.slots.playbar.inner_mut().repeat = settings.playbar_repeat;
+ self.slots.playbar.inner_mut().step_buttons = settings.playbar_step_buttons;
self.slots.playbar.inner_mut().fps = settings.playbar_fps.clamp(1.0, 120.0);
self.square_viewport = settings.viewport.square;
self.grid_thickness = settings.viewport.grid_thickness;
diff --git a/src/command.rs b/src/command.rs
index 90d347b..178e554 100644
--- a/src/command.rs
+++ b/src/command.rs
@@ -250,6 +250,9 @@ pub const COMMANDS: &[Command] = &[
// (off). A setting rather than a transport key, so it ships unbound and
// carries a switch in the palette; persisted in state.kdl.
Command { id: "toggle_playbar_repeat", label: "Repeat Playback", context: Context::Playbar, run: Run::Key(Action::TogglePlaybarRepeat), default_chord: None },
+ // The Previous / Next Frame buttons either side of the play button: a
+ // switch in the playbar menu and the palette, saved with the settings.
+ Command { id: "toggle_playbar_step_buttons", label: "Show Step Buttons", context: Context::Playbar, run: Run::Key(Action::TogglePlaybarStepButtons), default_chord: None },
];
pub fn by_id(id: &str) -> Option<&'static Command> {
diff --git a/src/dialog.rs b/src/dialog.rs
index 5ae6f03..1ea8cda 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -2003,6 +2003,7 @@ impl State {
"show_parameters_pane" => self.show_parameters,
"show_playbar_pane" => self.show_playbar,
"toggle_playbar_repeat" => self.slots.playbar.inner().repeat,
+ "toggle_playbar_step_buttons" => self.slots.playbar.inner().step_buttons,
"toggle_snap" => self.viewer_tool.as_ref()?.snap.is_some(),
_ => return None,
})
diff --git a/src/main.rs b/src/main.rs
index 405115c..d272d90 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2603,7 +2603,7 @@ mod tests {
groups,
vec![
vec![A::Command("play_pause"), A::Command("play_pause_reverse"), A::Command("frame_start")],
- vec![A::Command("toggle_playbar_repeat")],
+ vec![A::Command("toggle_playbar_repeat"), A::Command("toggle_playbar_step_buttons")],
vec![A::FpsSlider, A::StartFrameSlider, A::EndFrameSlider],
vec![
A::Plate(crate::plate_menu::PlateMenuAction::Collapse),
@@ -2670,6 +2670,74 @@ mod tests {
let _ = fs::remove_dir_all(&dir);
}
+ /// The playbar's Previous / Next Frame buttons step a whole frame by
+ /// pointer, either side of the play button, and the playbar menu's
+ /// Show Step Buttons switch takes them away — the track widening into
+ /// their room — and saves the choice.
+ #[test]
+ fn the_playbar_step_buttons_step_and_can_be_hidden() {
+ use crate::app::PlaybarMenuAction as A;
+ use crate::slots::PLAYBAR_IDX;
+ use crate::window::{LocalPosition, WindowEvent};
+ use cce_ui::scene::layout::Rect;
+ use cce_ui::widget::{context_menu, ElementState, MouseButton};
+ let mut state = State::new(false);
+ state.resize(1600.0, 900.0, 1.0);
+ state.execute_menu_action("Show Playbar Pane");
+ state.rebuild_positions();
+ state.apply_layout();
+ let (px, py, pw, ph) = state.positions[PLAYBAR_IDX];
+ let rect = Rect { x: px, y: py, width: pw, height: ph };
+ assert!(state.slots.playbar.inner().step_buttons, "on by default");
+ let press = |state: &mut State, r: Rect| {
+ let (x, y) = (r.x + r.width * 0.5, r.y + r.height * 0.5);
+ state.handle_event(&WindowEvent::CursorMoved { position: LocalPosition { x: x as f64, y: y as f64 } });
+ state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left });
+ state.handle_event(&WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Left });
+ };
+ let pb = state.slots.playbar.inner();
+ let prev = pb.transport_button_rect(rect, -1).expect("a Previous Frame button");
+ let play = pb.transport_button_rect(rect, 0).expect("a play button");
+ let next = pb.transport_button_rect(rect, 1).expect("a Next Frame button");
+ assert!(prev.x + prev.width < play.x && play.x + play.width < next.x, "|< > >| left to right");
+
+ state.slots.playbar.inner_mut().current_frame = 10.4;
+ press(&mut state, next);
+ assert_eq!(state.slots.playbar.inner().current_frame, 11.0, "a whole frame on, off the rounded one");
+ press(&mut state, prev);
+ press(&mut state, prev);
+ assert_eq!(state.slots.playbar.inner().current_frame, 9.0);
+ assert!(!state.slots.playbar.inner().playing, "a step does not start playback");
+ state.slots.playbar.inner_mut().current_frame = 1.0;
+ press(&mut state, prev);
+ assert_eq!(state.slots.playbar.inner().current_frame, 1.0, "held inside the range");
+
+ // The menu's switch hides them, and is saved.
+ let (options, actions) = state.playbar_menu_rows();
+ let i = actions.iter().position(|a| *a == A::Command("toggle_playbar_step_buttons")).expect("a Step Buttons row");
+ assert!(options[i].starts_with("● "), "{}", options[i]);
+ state.handle_event(&WindowEvent::CursorMoved { position: LocalPosition { x: (play.x + 400.0) as f64, y: (py + ph * 0.5) as f64 } });
+ state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Right });
+ assert!(state.playbar_menu_open());
+ state.cursor_x = context_menu::x() + 20.0;
+ state.cursor_y = context_menu::row_y(i) + context_menu::ROW_H * 0.5;
+ state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left });
+ assert!(!state.playbar_menu_open());
+ let pb = state.slots.playbar.inner();
+ assert!(!pb.step_buttons);
+ assert!(pb.transport_button_rect(rect, -1).is_none() && pb.transport_button_rect(rect, 1).is_none());
+ assert_eq!(pb.transport_button_rect(rect, 0).unwrap().x, prev.x, "the play button takes the first place");
+ let kdl = fs::read_to_string(crate::app::DesignSettings::file_path()).expect("saved");
+ assert!(!crate::app::DesignSettings::from_kdl_str(&kdl).playbar_step_buttons, "persisted");
+
+ // Where Next Frame stood is the track now: a press there scrubs.
+ state.slots.playbar.inner_mut().current_frame = 50.0;
+ press(&mut state, next);
+ assert_ne!(state.slots.playbar.inner().current_frame, 51.0, "no step button there any more");
+ state.run_command("toggle_playbar_step_buttons");
+ assert!(state.slots.playbar.inner().step_buttons);
+ }
+
/// Camera Pivot Size is a slider under Show Camera Pivot, over 0–1:
/// the wheel steps a twentieth, re-bakes the marker and nothing else,
/// and saves.
diff --git a/src/playbar.rs b/src/playbar.rs
index 90fd708..681d176 100644
--- a/src/playbar.rs
+++ b/src/playbar.rs
@@ -27,15 +27,30 @@ pub struct Playbar {
/// this field is the one copy, read by the dialog's switch and
/// `save_settings`.
pub repeat: bool,
+ /// Whether the Previous Frame / Next Frame buttons flank the play
+ /// button — the playbar menu's Step Buttons switch
+ /// (`toggle_playbar_step_buttons`), persisted in state.kdl
+ /// (`playbar_step_buttons`). On by default. Off, the play button and
+ /// the track stand where they always did.
+ pub step_buttons: bool,
dragging: bool,
}
const PAD: f32 = 8.0;
/// Width of the play/pause button box (square-ish, clamped to pane height).
const BTN_W: f32 = 28.0;
+/// Space between two transport buttons.
+const BTN_GAP: f32 = 4.0;
/// Width reserved right of the track for the frame readout.
const READOUT_W: f32 = 110.0;
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Btn {
+ Prev,
+ Play,
+ Next,
+}
+
impl Playbar {
pub fn new() -> Adapted<Playbar> {
Adapted::new(Self {
@@ -46,6 +61,7 @@ impl Playbar {
end_frame: 240.0,
fps: 24.0,
repeat: true,
+ step_buttons: true,
dragging: false,
})
}
@@ -66,13 +82,47 @@ impl Playbar {
}
}
- fn button_rect(&self, rect: Rect) -> Rect {
+ /// Step one whole frame, off the ROUNDED current frame: during playback
+ /// the playhead sits between frames, and stepping from the fractional
+ /// value would land off the frame grid. The chords and the buttons
+ /// share it; neither pauses a playing timeline.
+ pub fn step(&mut self, by: f32) {
+ self.current_frame = (self.current_frame.round() + by).clamp(self.start_frame, self.end_frame);
+ }
+
+ /// The transport's square buttons, left to right: Previous Frame, Play,
+ /// Next Frame with the step buttons on, Play alone with them off.
+ fn button_rects(&self, rect: Rect) -> Vec<(Btn, Rect)> {
let s = (rect.height - 2.0 * PAD).max(12.0).min(BTN_W);
- Rect { x: rect.x + PAD, y: rect.y + (rect.height - s) * 0.5, width: s, height: s }
+ let y = rect.y + (rect.height - s) * 0.5;
+ let order: &[Btn] = if self.step_buttons { &[Btn::Prev, Btn::Play, Btn::Next] } else { &[Btn::Play] };
+ order
+ .iter()
+ .enumerate()
+ .map(|(i, b)| (*b, Rect { x: rect.x + PAD + i as f32 * (s + BTN_GAP), y, width: s, height: s }))
+ .collect()
+ }
+
+ fn button_at(&self, rect: Rect, x: f32, y: f32) -> Option<Btn> {
+ self.button_rects(rect)
+ .into_iter()
+ .find(|(_, b)| x >= b.x && x <= b.x + b.width && y >= b.y && y <= b.y + b.height)
+ .map(|(btn, _)| btn)
+ }
+
+ /// Where a transport button is in the pane, for tests: Previous Frame
+ /// is -1, Play 0 and Next Frame 1. `None` for a button not shown.
+ pub fn transport_button_rect(&self, rect: Rect, which: i32) -> Option<Rect> {
+ let want = match which {
+ -1 => Btn::Prev,
+ 0 => Btn::Play,
+ _ => Btn::Next,
+ };
+ self.button_rects(rect).into_iter().find(|(b, _)| *b == want).map(|(_, r)| r)
}
fn track_rect(&self, rect: Rect) -> Rect {
- let b = self.button_rect(rect);
+ let b = self.button_rects(rect).last().expect("the play button").1;
let x = b.x + b.width + PAD;
let h = (rect.height - 2.0 * PAD).max(8.0).min(16.0);
Rect {
@@ -138,38 +188,23 @@ impl Paint for Playbar {
let relief = cce_ui::layout::control_relief();
let accent = colors::highlight_primary_color();
- // Play/pause button: raised plate under the DE relief styling (the
- // Button transparent-fill degradation — edges only, the pane plate is
- // the face), flat outline otherwise.
- let b = self.button_rect(rect);
- let br = cce_ui::layout::button_corner_radius().min(b.width * 0.5);
- if relief {
- let depth = cce_ui::layout::bevel_width().min(b.height * 0.2);
- ctx.boss(b, (br, br, br, br), depth);
- } else {
- ctx.border(b, (br, br, br, br), [0.0; 4], [0.35, 0.35, 0.42, 0.9], 1.0);
- }
+ // The transport buttons: raised plates under the DE relief styling
+ // (the Button transparent-fill degradation — edges only, the pane
+ // plate is the face), flat outlines otherwise.
let icon = [0.85, 0.86, 0.90, 0.95];
- if self.playing {
- // Pause: two bars.
- let bw = b.width * 0.16;
- let bh = b.height * 0.44;
- let by = b.y + (b.height - bh) * 0.5;
- ctx.quad(Rect { x: b.x + b.width * 0.32 - bw * 0.5, y: by, width: bw, height: bh }, icon);
- ctx.quad(Rect { x: b.x + b.width * 0.68 - bw * 0.5, y: by, width: bw, height: bh }, icon);
- } else {
- // Play: triangle outline (no filled-triangle prim; the DE's line
- // aesthetic reads fine here).
- let (cx, cy) = (b.x + b.width * 0.54, b.y + b.height * 0.5);
- let r = b.width * 0.24;
- let (x0, y0) = (cx - r * 0.6, cy - r);
- let (x1, y1) = (cx - r * 0.6, cy + r);
- let (x2, y2) = (cx + r, cy);
- ctx.vector(x0, y0, x1, y1, 1.5, icon, Cap::Round);
- ctx.vector(x1, y1, x2, y2, 1.5, icon, Cap::Round);
- ctx.vector(x2, y2, x0, y0, 1.5, icon, Cap::Round);
+ for (btn, b) in self.button_rects(rect) {
+ let br = cce_ui::layout::button_corner_radius().min(b.width * 0.5);
+ if relief {
+ let depth = cce_ui::layout::bevel_width().min(b.height * 0.2);
+ ctx.boss(b, (br, br, br, br), depth);
+ } else {
+ ctx.border(b, (br, br, br, br), [0.0; 4], [0.35, 0.35, 0.42, 0.9], 1.0);
+ }
+ match btn {
+ Btn::Play => self.paint_play_icon(b, icon, ctx),
+ Btn::Prev | Btn::Next => paint_step_icon(b, btn == Btn::Next, icon, ctx),
+ }
}
-
// Timeline track: the toolkit's band (the one slider style) — a band the
// width of the track with its swell at the playhead, in its own shaded
// well, drawn by the Slider's painter.
@@ -233,6 +268,44 @@ impl Paint for Playbar {
}
}
+impl Playbar {
+ fn paint_play_icon(&self, b: Rect, icon: [f32; 4], ctx: &mut PaintCtx) {
+ if self.playing {
+ // Pause: two bars.
+ let bw = b.width * 0.16;
+ let bh = b.height * 0.44;
+ let by = b.y + (b.height - bh) * 0.5;
+ ctx.quad(Rect { x: b.x + b.width * 0.32 - bw * 0.5, y: by, width: bw, height: bh }, icon);
+ ctx.quad(Rect { x: b.x + b.width * 0.68 - bw * 0.5, y: by, width: bw, height: bh }, icon);
+ } else {
+ // Play: triangle outline (no filled-triangle prim; the DE's line
+ // aesthetic reads fine here).
+ let (cx, cy) = (b.x + b.width * 0.54, b.y + b.height * 0.5);
+ let r = b.width * 0.24;
+ let (x0, y0) = (cx - r * 0.6, cy - r);
+ let (x1, y1) = (cx - r * 0.6, cy + r);
+ let (x2, y2) = (cx + r, cy);
+ ctx.vector(x0, y0, x1, y1, 1.5, icon, Cap::Round);
+ ctx.vector(x1, y1, x2, y2, 1.5, icon, Cap::Round);
+ ctx.vector(x2, y2, x0, y0, 1.5, icon, Cap::Round);
+ }
+ }
+}
+
+/// A step button's icon: a triangle outline toward the step and a bar at
+/// its point, the transport's frame-step glyph (|◁ and ▷|).
+fn paint_step_icon(b: Rect, forward: bool, icon: [f32; 4], ctx: &mut PaintCtx) {
+ let dir = if forward { 1.0 } else { -1.0 };
+ let (cx, cy) = (b.x + b.width * 0.5, b.y + b.height * 0.5);
+ let r = b.width * 0.2;
+ let (x0, x1) = (cx - dir * r * 0.75, cx + dir * r * 0.65);
+ ctx.vector(x0, cy - r, x0, cy + r, 1.5, icon, Cap::Round);
+ ctx.vector(x0, cy + r, x1, cy, 1.5, icon, Cap::Round);
+ ctx.vector(x1, cy, x0, cy - r, 1.5, icon, Cap::Round);
+ let bx = x1 + dir * 1.5;
+ ctx.vector(bx, cy - r, bx, cy + r, 1.5, icon, Cap::Round);
+}
+
impl Input for Playbar {
fn is_dragging(&self) -> bool {
self.dragging
@@ -243,17 +316,27 @@ impl Input for Playbar {
match event {
Event::MouseButton { button: MouseButton::Left, state, x, y, .. } => match state {
ElementState::Pressed => {
- let b = self.button_rect(rect);
- if *x >= b.x && *x <= b.x + b.width && *y >= b.y && *y <= b.y + b.height {
- // The button is the FORWARD transport: playing (either
- // direction) pauses; paused starts forward. Reverse is
- // the Down-arrow chord's domain.
- if self.playing {
- self.playing = false;
- } else {
- self.begin(false);
+ match self.button_at(rect, *x, *y) {
+ // The play button is the FORWARD transport: playing
+ // (either direction) pauses; paused starts forward.
+ // Reverse is the Down-arrow chord's domain.
+ Some(Btn::Play) => {
+ if self.playing {
+ self.playing = false;
+ } else {
+ self.begin(false);
+ }
+ return true;
}
- return true;
+ Some(Btn::Prev) => {
+ self.step(-1.0);
+ return true;
+ }
+ Some(Btn::Next) => {
+ self.step(1.0);
+ return true;
+ }
+ None => {}
}
let t = self.track_rect(rect);
// A generous vertical band around the slim track.
diff --git a/src/shortcut.rs b/src/shortcut.rs
index 349534b..439bbf8 100644
--- a/src/shortcut.rs
+++ b/src/shortcut.rs
@@ -17,6 +17,8 @@ pub enum Action {
ToggleShowOccluded,
/// Whether playback wraps at the end of the frame range or stops there.
TogglePlaybarRepeat,
+ /// Whether the playbar shows its Previous / Next Frame buttons.
+ TogglePlaybarStepButtons,
/// The three point overlays on the visible scene — markers, index
/// numbers, normal whiskers. Global display settings reached from the
/// command palette; they were per-node `meta` child preferences until
diff --git a/src/window.rs b/src/window.rs
index 075abfe..98986b4 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -264,6 +264,7 @@ impl State {
"playing": pb.playing,
"reversed": pb.reversed,
"repeat": pb.repeat,
+ "step_buttons": pb.step_buttons,
"fps": pb.fps,
"start_frame": pb.start_frame.round() as i64,
"end_frame": pb.end_frame.round() as i64,