any4robot 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. any4robot-0.1.0/PKG-INFO +81 -0
  2. any4robot-0.1.0/any4robot/__init__.py +11 -0
  3. any4robot-0.1.0/any4robot/combine/__init__.py +33 -0
  4. any4robot-0.1.0/any4robot/conversion/__init__.py +17 -0
  5. any4robot-0.1.0/any4robot/editing/__init__.py +5 -0
  6. any4robot-0.1.0/any4robot/formats/lerobot_v2_1.py +108 -0
  7. any4robot-0.1.0/any4robot/gui/__init__.py +40 -0
  8. any4robot-0.1.0/any4robot/gui/constants.py +55 -0
  9. any4robot-0.1.0/any4robot/gui/controls.py +265 -0
  10. any4robot-0.1.0/any4robot/gui/data_handler.py +152 -0
  11. any4robot-0.1.0/any4robot/gui/plot_component.py +166 -0
  12. any4robot-0.1.0/any4robot/gui/video_component.py +143 -0
  13. any4robot-0.1.0/any4robot/gui/viewer.py +349 -0
  14. any4robot-0.1.0/any4robot/lerobot.py +7 -0
  15. any4robot-0.1.0/any4robot/lerobot_editor/__init__.py +20 -0
  16. any4robot-0.1.0/any4robot/lerobot_editor/cli.py +529 -0
  17. any4robot-0.1.0/any4robot/lerobot_editor/constants.py +167 -0
  18. any4robot-0.1.0/any4robot/lerobot_editor/core.py +213 -0
  19. any4robot-0.1.0/any4robot/lerobot_editor/display.py +320 -0
  20. any4robot-0.1.0/any4robot/lerobot_editor/file_utils.py +222 -0
  21. any4robot-0.1.0/any4robot/lerobot_editor/metadata.py +267 -0
  22. any4robot-0.1.0/any4robot/lerobot_editor/operations.py +757 -0
  23. any4robot-0.1.0/any4robot/qualitycheck/__init__.py +5 -0
  24. any4robot-0.1.0/any4robot/version.py +1 -0
  25. any4robot-0.1.0/any4robot.egg-info/PKG-INFO +81 -0
  26. any4robot-0.1.0/any4robot.egg-info/SOURCES.txt +32 -0
  27. any4robot-0.1.0/any4robot.egg-info/dependency_links.txt +1 -0
  28. any4robot-0.1.0/any4robot.egg-info/requires.txt +10 -0
  29. any4robot-0.1.0/any4robot.egg-info/top_level.txt +1 -0
  30. any4robot-0.1.0/pyproject.toml +46 -0
  31. any4robot-0.1.0/readme.md +51 -0
  32. any4robot-0.1.0/setup.cfg +4 -0
  33. any4robot-0.1.0/tests/test_lerobot_edit.py +14 -0
  34. any4robot-0.1.0/vendor/lero/LICENSE +186 -0
@@ -0,0 +1,81 @@
1
+ Metadata-Version: 2.4
2
+ Name: any4robot
3
+ Version: 0.1.0
4
+ Summary: Foundation dataset tooling for VLA model training
5
+ Author: Any4Robot Contributors
6
+ License-Expression: Apache-2.0
7
+ Keywords: robotics,dataset,lerobot,vla,data-tools
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Science/Research
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.8
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Python: >=3.8
19
+ Description-Content-Type: text/markdown
20
+ License-File: vendor/lero/LICENSE
21
+ Requires-Dist: pandas>=1.5.0
22
+ Requires-Dist: pyarrow>=10.0.0
23
+ Provides-Extra: gui
24
+ Requires-Dist: matplotlib>=3.5.0; extra == "gui"
25
+ Requires-Dist: opencv-python>=4.5.0; extra == "gui"
26
+ Requires-Dist: Pillow>=8.0.0; extra == "gui"
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
29
+ Dynamic: license-file
30
+
31
+ # Any4Robot
32
+
33
+ `any4robot` provides dataset conversion and management utilities for training VLA / WM / VAM robot control models. Data preparation is crucial for robot model development. Any4Robot provides a standardized, verified way to produce the right data so you can focus on model design.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install any4robot
39
+ ```
40
+
41
+ ## Quick start (LeRobot editor)
42
+
43
+ ```python
44
+ from any4robot import LeRobotDatasetEditor
45
+
46
+ editor = LeRobotDatasetEditor("/path/to/lerobot-dataset")
47
+ editor.list_episodes(0, 5)
48
+ editor.delete_episode(12, dry_run=True)
49
+ ```
50
+
51
+ ## Combine datasets
52
+
53
+ ```python
54
+ from any4robot.combine import merge_lerobot_datasets
55
+
56
+ merge_lerobot_datasets(
57
+ ["/path/to/dataset_a", "/path/to/dataset_b"],
58
+ "/path/to/output_dataset",
59
+ )
60
+ ```
61
+
62
+ ## Quality check (LeRobot v2.1 layout)
63
+
64
+ ```python
65
+ from any4robot.qualitycheck import validate_lerobot_v2_1
66
+
67
+ result = validate_lerobot_v2_1("/path/to/lerobot-dataset")
68
+ print(result)
69
+ ```
70
+
71
+ ## Modules
72
+
73
+ - `any4robot.editing`: LeRobot dataset editing helpers (delete episode, copy episode, list episodes)
74
+ - `any4robot.combine`: Merge multiple LeRobot datasets into a single dataset
75
+ - `any4robot.qualitycheck`: Validate datasets (LeRobot v2.1 layout)
76
+ - `any4robot.conversion`: Placeholder for future dataset conversion pipelines
77
+
78
+ ## Notes
79
+
80
+ - The LeRobot editor code is copied from `vendor/lero` and organized under `any4robot/lerobot_editor`.
81
+ - Optional GUI dependencies are available with `pip install any4robot[gui]`.
@@ -0,0 +1,11 @@
1
+ """Any4Robot: dataset tooling foundation for VLA training workflows."""
2
+
3
+ from .lerobot import LeRobotDatasetEditor, DatasetOperations, MetadataManager
4
+ from .version import __version__
5
+
6
+ __all__ = [
7
+ "LeRobotDatasetEditor",
8
+ "DatasetOperations",
9
+ "MetadataManager",
10
+ "__version__",
11
+ ]
@@ -0,0 +1,33 @@
1
+ """Dataset combination utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Dict, Iterable, Optional
7
+
8
+ from any4robot.lerobot import DatasetOperations
9
+
10
+
11
+ def merge_lerobot_datasets(
12
+ source_datasets: Iterable[str | Path],
13
+ output_path: str | Path,
14
+ task_mapping: Optional[Dict[str, str]] = None,
15
+ dry_run: bool = False,
16
+ ) -> bool:
17
+ """
18
+ Merge multiple LeRobot datasets into a single dataset.
19
+
20
+ This delegates to the LERO DatasetOperations merge implementation.
21
+ """
22
+ source_paths = [Path(p) for p in source_datasets]
23
+ output = Path(output_path)
24
+
25
+ if not source_paths:
26
+ raise ValueError("source_datasets must contain at least one dataset path")
27
+
28
+ # Use the first dataset path to bootstrap operations for merging.
29
+ ops = DatasetOperations(source_paths[0])
30
+ return ops.merge_datasets(source_paths, output, task_mapping=task_mapping, dry_run=dry_run)
31
+
32
+
33
+ __all__ = ["merge_lerobot_datasets"]
@@ -0,0 +1,17 @@
1
+ """Conversion utilities (placeholder for future formats)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+
8
+ def convert_to_lerobot_v2_1(source_path: str | Path, output_path: str | Path, dry_run: bool = False) -> None:
9
+ """
10
+ Placeholder for converting datasets into LeRobot v2.1 format.
11
+
12
+ This function intentionally raises until a concrete converter is implemented.
13
+ """
14
+ raise NotImplementedError("Conversion pipeline is not yet implemented")
15
+
16
+
17
+ __all__ = ["convert_to_lerobot_v2_1"]
@@ -0,0 +1,5 @@
1
+ """Editing utilities for LeRobot datasets."""
2
+
3
+ from any4robot.lerobot import LeRobotDatasetEditor
4
+
5
+ __all__ = ["LeRobotDatasetEditor"]
@@ -0,0 +1,108 @@
1
+ """LeRobot dataset v2.1 format validation helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any, Dict, Optional
8
+
9
+ from any4robot.lerobot_editor.constants import (
10
+ DATA_DIR,
11
+ EPISODES_FILE,
12
+ INFO_FILE,
13
+ META_DIR,
14
+ TASKS_FILE,
15
+ )
16
+ from any4robot.lerobot_editor.file_utils import FileSystemManager
17
+
18
+
19
+ REQUIRED_META_FILES = (INFO_FILE, EPISODES_FILE, TASKS_FILE)
20
+
21
+
22
+ def _read_json(path: Path) -> Dict[str, Any]:
23
+ with path.open("r", encoding="utf-8") as handle:
24
+ return json.load(handle)
25
+
26
+
27
+ def validate_lerobot_v2_1(dataset_path: str | Path, max_episodes: Optional[int] = None) -> Dict[str, Any]:
28
+ """
29
+ Validate a dataset against the LeRobot v2.1 directory layout.
30
+
31
+ Args:
32
+ dataset_path: Dataset root directory.
33
+ max_episodes: Optional cap on number of episodes to validate.
34
+ """
35
+ root = Path(dataset_path)
36
+ result: Dict[str, Any] = {
37
+ "valid": True,
38
+ "errors": [],
39
+ "warnings": [],
40
+ "checked_episodes": 0,
41
+ "dataset_path": str(root),
42
+ }
43
+
44
+ if not root.exists():
45
+ result["valid"] = False
46
+ result["errors"].append(f"Dataset path does not exist: {root}")
47
+ return result
48
+
49
+ meta_path = root / META_DIR
50
+ data_path = root / DATA_DIR
51
+
52
+ if not meta_path.exists():
53
+ result["valid"] = False
54
+ result["errors"].append(f"Missing meta directory: {meta_path}")
55
+ return result
56
+
57
+ missing_meta = [name for name in REQUIRED_META_FILES if not (meta_path / name).exists()]
58
+ if missing_meta:
59
+ result["valid"] = False
60
+ for name in missing_meta:
61
+ result["errors"].append(f"Missing meta file: {meta_path / name}")
62
+ return result
63
+
64
+ if not data_path.exists():
65
+ result["valid"] = False
66
+ result["errors"].append(f"Missing data directory: {data_path}")
67
+ return result
68
+
69
+ try:
70
+ info = _read_json(meta_path / INFO_FILE)
71
+ except Exception as exc:
72
+ result["valid"] = False
73
+ result["errors"].append(f"Failed to read info.json: {exc}")
74
+ return result
75
+
76
+ if "features" not in info:
77
+ result["warnings"].append("info.json missing 'features' field")
78
+
79
+ manager = FileSystemManager(root)
80
+ video_features = info.get("features", {})
81
+ episodes_path = meta_path / EPISODES_FILE
82
+
83
+ with episodes_path.open("r", encoding="utf-8") as handle:
84
+ for line in handle:
85
+ if not line.strip():
86
+ continue
87
+ episode = json.loads(line)
88
+ episode_index = episode.get("episode_index")
89
+
90
+ if episode_index is None:
91
+ result["valid"] = False
92
+ result["errors"].append("Episode entry missing episode_index")
93
+ break
94
+
95
+ paths = manager.get_episode_file_paths(int(episode_index), video_features)
96
+ if not paths["data"].exists():
97
+ result["valid"] = False
98
+ result["errors"].append(f"Missing data file for episode {episode_index}: {paths['data']}")
99
+ break
100
+
101
+ result["checked_episodes"] += 1
102
+ if max_episodes and result["checked_episodes"] >= max_episodes:
103
+ break
104
+
105
+ return result
106
+
107
+
108
+ __all__ = ["validate_lerobot_v2_1"]
@@ -0,0 +1,40 @@
1
+ """
2
+ GUI module for LERO - LeRobot dataset Operations toolkit
3
+
4
+ This module provides GUI components for viewing and editing LeRobot datasets.
5
+ """
6
+
7
+ try:
8
+ from .viewer import EpisodeGUIViewer, launch_episode_viewer
9
+ from . import viewer
10
+ from . import video_component
11
+ from . import plot_component
12
+ from . import controls
13
+ from . import data_handler
14
+
15
+ # Create aliases for backward compatibility and test expectations
16
+ video_component.VideoComponent = video_component.VideoDisplayComponent
17
+ plot_component.PlotComponent = plot_component.JointPlotComponent
18
+
19
+ __all__ = [
20
+ 'EpisodeGUIViewer',
21
+ 'launch_episode_viewer',
22
+ 'viewer',
23
+ 'video_component',
24
+ 'plot_component',
25
+ 'controls',
26
+ 'data_handler'
27
+ ]
28
+
29
+ except ImportError as e:
30
+ # GUI dependencies not available
31
+ error_message = f"GUI dependencies not available: {e}"
32
+
33
+ def launch_episode_viewer(*args, **kwargs):
34
+ raise ImportError(error_message)
35
+
36
+ class EpisodeGUIViewer:
37
+ def __init__(self, *args, **kwargs):
38
+ raise ImportError(error_message)
39
+
40
+ __all__ = ['EpisodeGUIViewer', 'launch_episode_viewer']
@@ -0,0 +1,55 @@
1
+ """
2
+ Constants and configuration for the GUI components.
3
+ """
4
+
5
+ # GUI Layout Constants
6
+ WINDOW_WIDTH = 1400
7
+ WINDOW_HEIGHT = 900
8
+ MAX_VIDEO_SIZE = 300
9
+
10
+ # Playback Constants
11
+ DEFAULT_PLAYBACK_SPEED = 1.0
12
+ MIN_PLAYBACK_SPEED = 0.1
13
+ MAX_PLAYBACK_SPEED = 10.0
14
+ BASE_FPS = 60 # Target FPS for playback
15
+ MIN_DELAY_MS = 1 # Minimum delay between frames in milliseconds
16
+ VIDEO_UPDATE_SPEED_THRESHOLD = 2.0 # Skip video updates above this speed
17
+
18
+ # Font Settings
19
+ DEFAULT_FONT_SIZE = 12
20
+ LABEL_FONT = ('TkDefaultFont', 12, 'bold')
21
+ PLOT_TITLE_FONT_SIZE = 14
22
+ PLOT_LABEL_FONT_SIZE = 12
23
+ PLOT_TICK_FONT_SIZE = 10
24
+ LEGEND_FONT_SIZE = 10
25
+
26
+ # Robot Joint Constants
27
+ SO100_JOINT_NAMES = [
28
+ 'main_shoulder_pan',
29
+ 'main_shoulder_lift',
30
+ 'main_elbow_flex',
31
+ 'main_wrist_flex',
32
+ 'main_wrist_roll',
33
+ 'main_gripper'
34
+ ]
35
+
36
+ MAX_JOINTS_DISPLAY = 6
37
+
38
+ # Plot Colors for Joint Angles
39
+ JOINT_COLORS = [
40
+ '#1f77b4', # Blue
41
+ '#ff7f0e', # Orange
42
+ '#2ca02c', # Green
43
+ '#d62728', # Red
44
+ '#9467bd', # Purple
45
+ '#8c564b' # Brown
46
+ ]
47
+
48
+ # UI Icons/Emojis
49
+ ICON_CHART = "📊"
50
+ ICON_TARGET = "🎯"
51
+ ICON_FILM = "🎬"
52
+
53
+ # Data Extraction Keywords
54
+ JOINT_KEYWORDS = ['joint', 'position', 'angle', 'state']
55
+ NUMERIC_DTYPES = ['float64', 'float32', 'int64', 'int32']
@@ -0,0 +1,265 @@
1
+ """
2
+ Control panel components for the GUI.
3
+ """
4
+
5
+ import tkinter as tk
6
+ from tkinter import ttk
7
+ from typing import Callable, Optional
8
+ from .constants import (
9
+ MIN_PLAYBACK_SPEED,
10
+ MAX_PLAYBACK_SPEED,
11
+ DEFAULT_PLAYBACK_SPEED,
12
+ LABEL_FONT
13
+ )
14
+
15
+
16
+ class ControlPanel:
17
+ """Main control panel containing episode selection and playback controls."""
18
+
19
+ def __init__(self, parent_frame: ttk.Frame, total_episodes: int):
20
+ """
21
+ Initialize the control panel.
22
+
23
+ Args:
24
+ parent_frame: Parent frame to contain controls
25
+ total_episodes: Total number of episodes in dataset
26
+ """
27
+ self.parent_frame = parent_frame
28
+ self.total_episodes = total_episodes
29
+
30
+ # Control variables
31
+ self.episode_var = tk.StringVar()
32
+ self.episode_info_var = tk.StringVar()
33
+ self.frame_var = tk.StringVar()
34
+ self.speed_var = tk.DoubleVar(value=DEFAULT_PLAYBACK_SPEED)
35
+
36
+ # Control widgets
37
+ self.play_button: Optional[ttk.Button] = None
38
+ self.speed_label: Optional[ttk.Label] = None
39
+
40
+ # Callbacks (to be set by parent)
41
+ self.on_load_episode: Optional[Callable] = None
42
+ self.on_toggle_playback: Optional[Callable] = None
43
+ self.on_stop_playback: Optional[Callable] = None
44
+ self.on_step_forward: Optional[Callable] = None
45
+ self.on_step_backward: Optional[Callable] = None
46
+ self.on_speed_change: Optional[Callable] = None
47
+
48
+ self._setup_controls()
49
+
50
+ def _setup_controls(self) -> None:
51
+ """Setup all control widgets."""
52
+ control_frame = ttk.LabelFrame(self.parent_frame, text="Controls", padding=10)
53
+ control_frame.pack(fill=tk.X, pady=(0, 10))
54
+
55
+ self._setup_episode_controls(control_frame)
56
+ self._setup_playback_controls(control_frame)
57
+
58
+ def _setup_episode_controls(self, parent: ttk.Frame) -> None:
59
+ """Setup episode selection controls."""
60
+ episode_frame = ttk.Frame(parent)
61
+ episode_frame.pack(fill=tk.X, pady=(0, 5))
62
+
63
+ # Episode selection
64
+ ttk.Label(episode_frame, text="Episode:").pack(side=tk.LEFT)
65
+
66
+ episode_spinbox = ttk.Spinbox(
67
+ episode_frame,
68
+ from_=0,
69
+ to=max(0, self.total_episodes - 1),
70
+ textvariable=self.episode_var,
71
+ width=10,
72
+ command=self._on_episode_spinbox_change
73
+ )
74
+ episode_spinbox.pack(side=tk.LEFT, padx=(5, 10))
75
+
76
+ ttk.Button(
77
+ episode_frame,
78
+ text="Load Episode",
79
+ command=self._on_load_episode_button
80
+ ).pack(side=tk.LEFT)
81
+
82
+ # Episode info display
83
+ episode_info_label = ttk.Label(
84
+ episode_frame,
85
+ textvariable=self.episode_info_var,
86
+ font=LABEL_FONT
87
+ )
88
+ episode_info_label.pack(side=tk.LEFT, padx=(20, 0))
89
+
90
+ def _setup_playback_controls(self, parent: ttk.Frame) -> None:
91
+ """Setup playback control widgets."""
92
+ playback_frame = ttk.Frame(parent)
93
+ playback_frame.pack(fill=tk.X, pady=(5, 0))
94
+
95
+ # Playback buttons
96
+ self.play_button = ttk.Button(
97
+ playback_frame,
98
+ text="Play",
99
+ command=self._on_toggle_playback_button
100
+ )
101
+ self.play_button.pack(side=tk.LEFT)
102
+
103
+ ttk.Button(
104
+ playback_frame,
105
+ text="Stop",
106
+ command=self._on_stop_playback_button
107
+ ).pack(side=tk.LEFT, padx=(5, 0))
108
+
109
+ ttk.Button(
110
+ playback_frame,
111
+ text="Step Back",
112
+ command=self._on_step_backward_button
113
+ ).pack(side=tk.LEFT, padx=(5, 0))
114
+
115
+ ttk.Button(
116
+ playback_frame,
117
+ text="Step Forward",
118
+ command=self._on_step_forward_button
119
+ ).pack(side=tk.LEFT, padx=(5, 0))
120
+
121
+ # Speed control
122
+ self._setup_speed_control(playback_frame)
123
+
124
+ # Frame info display
125
+ frame_info_label = ttk.Label(
126
+ playback_frame,
127
+ textvariable=self.frame_var,
128
+ font=LABEL_FONT
129
+ )
130
+ frame_info_label.pack(side=tk.RIGHT)
131
+
132
+ def _setup_speed_control(self, parent: ttk.Frame) -> None:
133
+ """Setup speed control widgets."""
134
+ ttk.Label(parent, text="Speed:").pack(side=tk.LEFT, padx=(20, 5))
135
+
136
+ speed_scale = ttk.Scale(
137
+ parent,
138
+ from_=MIN_PLAYBACK_SPEED,
139
+ to=MAX_PLAYBACK_SPEED,
140
+ variable=self.speed_var,
141
+ orient=tk.HORIZONTAL,
142
+ length=120,
143
+ command=self._on_speed_scale_change
144
+ )
145
+ speed_scale.pack(side=tk.LEFT)
146
+
147
+ self.speed_label = ttk.Label(parent, text="1.0x")
148
+ self.speed_label.pack(side=tk.LEFT, padx=(5, 0))
149
+
150
+ # Callback wrappers
151
+ def _on_episode_spinbox_change(self):
152
+ """Handle episode spinbox change."""
153
+ pass # Called by spinbox, but we use the button instead
154
+
155
+ def _on_load_episode_button(self):
156
+ """Handle load episode button click."""
157
+ if self.on_load_episode:
158
+ self.on_load_episode()
159
+
160
+ def _on_toggle_playback_button(self):
161
+ """Handle play/pause button click."""
162
+ if self.on_toggle_playback:
163
+ self.on_toggle_playback()
164
+
165
+ def _on_stop_playback_button(self):
166
+ """Handle stop button click."""
167
+ if self.on_stop_playback:
168
+ self.on_stop_playback()
169
+
170
+ def _on_step_forward_button(self):
171
+ """Handle step forward button click."""
172
+ if self.on_step_forward:
173
+ self.on_step_forward()
174
+
175
+ def _on_step_backward_button(self):
176
+ """Handle step backward button click."""
177
+ if self.on_step_backward:
178
+ self.on_step_backward()
179
+
180
+ def _on_speed_scale_change(self, value):
181
+ """Handle speed scale change."""
182
+ if self.on_speed_change:
183
+ self.on_speed_change(value)
184
+
185
+ # Public methods for updating display
186
+ def update_play_button_text(self, text: str) -> None:
187
+ """Update the play button text."""
188
+ if self.play_button:
189
+ self.play_button.configure(text=text)
190
+
191
+ def update_speed_display(self, speed_text: str) -> None:
192
+ """Update the speed display label."""
193
+ if self.speed_label:
194
+ self.speed_label.configure(text=speed_text)
195
+
196
+ def update_episode_info(self, info_text: str) -> None:
197
+ """Update the episode information display."""
198
+ self.episode_info_var.set(info_text)
199
+
200
+ def update_frame_info(self, frame_text: str) -> None:
201
+ """Update the frame information display."""
202
+ self.frame_var.set(frame_text)
203
+
204
+ def get_episode_index(self) -> int:
205
+ """Get the currently selected episode index."""
206
+ try:
207
+ return int(self.episode_var.get())
208
+ except ValueError:
209
+ return 0
210
+
211
+ def get_playback_speed(self) -> float:
212
+ """Get the current playback speed."""
213
+ return self.speed_var.get()
214
+
215
+
216
+ class TimelineComponent:
217
+ """Timeline scrubber component for frame navigation."""
218
+
219
+ def __init__(self, parent_frame: ttk.Frame):
220
+ """
221
+ Initialize the timeline component.
222
+
223
+ Args:
224
+ parent_frame: Parent frame to contain the timeline
225
+ """
226
+ self.parent_frame = parent_frame
227
+ self.timeline_scale: Optional[ttk.Scale] = None
228
+ self.on_timeline_change: Optional[Callable] = None
229
+
230
+ self._setup_timeline()
231
+
232
+ def _setup_timeline(self) -> None:
233
+ """Setup the timeline scrubber."""
234
+ timeline_frame = ttk.LabelFrame(self.parent_frame, text="Timeline", padding=10)
235
+ timeline_frame.pack(fill=tk.X, pady=(10, 0))
236
+
237
+ self.timeline_scale = ttk.Scale(
238
+ timeline_frame,
239
+ from_=0,
240
+ to=100,
241
+ orient=tk.HORIZONTAL,
242
+ command=self._on_timeline_scale_change
243
+ )
244
+ self.timeline_scale.pack(fill=tk.X)
245
+
246
+ def _on_timeline_scale_change(self, value):
247
+ """Handle timeline scale change."""
248
+ if self.on_timeline_change:
249
+ self.on_timeline_change(value)
250
+
251
+ def set_range(self, max_frame: int) -> None:
252
+ """Set the maximum range of the timeline."""
253
+ if self.timeline_scale:
254
+ self.timeline_scale.configure(to=max_frame)
255
+
256
+ def set_position(self, frame: int) -> None:
257
+ """Set the current position of the timeline."""
258
+ if self.timeline_scale:
259
+ self.timeline_scale.set(frame)
260
+
261
+ def get_position(self) -> float:
262
+ """Get the current position of the timeline."""
263
+ if self.timeline_scale:
264
+ return self.timeline_scale.get()
265
+ return 0.0