1
0
forked from IQ.Lvbs/IQ.Pilot

IQ.Pilot Prebuilt Release @ ab07000

This commit is contained in:
IQ.Lvbs history cleanup
2026-08-22 23:42:42 -05:00
commit 9f9c9a70cc
3729 changed files with 778697 additions and 0 deletions

753
tools/iqpilot/mici_preview.py Executable file
View File

@@ -0,0 +1,753 @@
#!/usr/bin/env python3
"""
╔══════════════════════════════════════════════════════════════════════════╗
║ IQ.Pilot MICI UI Preview Tool ║
║ ────────────────────────────────────────────────────────────────────── ║
║ Renders any MICI layout/widget at 536×240 on your Mac desktop so you ║
║ can visually inspect and iterate without a physical comma 4. ║
║ ║
║ MODES ║
║ screenshot — render N frames, save PNG(s), open in Preview ║
║ video — render N seconds to MP4 via ffmpeg, open in QuickTime ║
║ live — interactive window, hot-reload on file-save ║
║ ║
║ USAGE ║
║ python tools/iqpilot/mici_preview.py [OPTIONS] ║
║ ║
║ OPTIONS ║
║ --panel PANEL Panel to render: steering, visuals, display, ║
║ software, cruise, trips, osm, models, toggles, ║
║ device, developer, home, settings (default: steering)║
║ --mode MODE screenshot | video | live (default: screenshot) ║
║ --frames N Frames to settle before screenshot (default: 90) ║
║ --shots N Number of screenshots to take (default: 1) ║
║ --duration S Video duration in seconds (default: 4) ║
║ --fps N Render FPS (default: 60) ║
║ --scale F Window scale multiplier (2.0 = 1072×480 window) ║
║ (default: 2.5) ║
║ --out PATH Output file/dir (default: /tmp/mici_preview/) ║
║ --open Open output file(s) after capture (default: True) ║
║ --mock Use mock UI state (no real params needed) ║
║ ║
║ EXAMPLES ║
║ # Screenshot the steering panel (scaled 2.5×, opens in Preview) ║
║ python tools/iqpilot/mici_preview.py --panel steering ║
║ ║
║ # 4-second video of the neon glow animation ║
║ python tools/iqpilot/mici_preview.py --panel steering --mode video ║
║ ║
║ # Live interactive window with hot-reload ║
║ python tools/iqpilot/mici_preview.py --panel visuals --mode live ║
║ ║
║ # Multiple panels in one go (screenshots) ║
║ python tools/iqpilot/mici_preview.py --panel all ║
╚══════════════════════════════════════════════════════════════════════════╝
"""
import argparse
import importlib
import os
import subprocess
import sys
import time
import queue
import threading
from pathlib import Path
# ── Project root on sys.path ──────────────────────────────────────────────
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
# Force MICI mode (no BIG UI)
os.environ.setdefault("BIG", "0")
os.environ.setdefault("IQPILOT_UI", "1")
# Use offscreen mode for screenshot/video (no FPS cap → fast capture)
# Live mode overrides this below.
import pyray as rl # noqa: E402 — must come after env setup
# ── MICI canvas dimensions ─────────────────────────────────────────────────
MICI_W = 536
MICI_H = 240
# ── Output directory ───────────────────────────────────────────────────────
DEFAULT_OUT = Path("/tmp/mici_preview")
# ── Color palette for the preview chrome ──────────────────────────────────
CHROME_BG = rl.Color(18, 18, 22, 255) # dark bg behind the MICI canvas
LABEL_COLOR = rl.Color(160, 160, 160, 200) # panel label text
# ── All available panels ───────────────────────────────────────────────────
ALL_PANELS = [
"steering", "visuals", "display", "software", "cruise",
"trips", "osm", "models", "toggles", "device", "developer", "home",
]
# ─────────────────────────────────────────────────────────────────────────────
# Mock UI state so we can run without a live comma process
# ─────────────────────────────────────────────────────────────────────────────
def _patch_mock_state():
"""Replace real ui_state and Params with lightweight mocks."""
import types
# ── Shared in-memory store ────────────────────────────────────────────────
_store: dict = {}
class MockParams:
"""
Drop-in Params mock that also supports `Params | None` type expressions
at module import time by implementing __or__ / __ror__ on the class itself.
"""
# Allow `Params | None` as a runtime type-union expression
def __class_getitem__(cls, item):
return cls
def __or__(cls, other):
import types as _t
return _t.UnionType if hasattr(_t, 'UnionType') else object
__ror__ = __or__
def __init__(self, _=None, **__):
pass # ignore any constructor args (real Params accepts path kwarg)
def get(self, key, default=None, return_default=False):
val = _store.get(key, default)
# Real Params.get() returns str or None; convert bools → str
if isinstance(val, bool):
return str(int(val))
return val
def get_bool(self, key, default=False):
val = _store.get(key, default)
if isinstance(val, str):
return val not in ('', '0', 'false', 'False', 'None')
return bool(val)
def put(self, key, val):
_store[key] = val
def put_bool(self, key, val):
_store[key] = bool(val)
def put_nonblocking(self, key, val):
_store[key] = val
# Seed some sensible defaults so widgets render realistically
mp = MockParams()
# ── Steering ────────────────────────────────────────────────────────────
mp.put("AolEnabled", False)
mp.put("AolSteeringMode", 0) # 0=remain active, 1=pause, 2=disengage
mp.put("AolMainCruiseAllowed", True)
mp.put("AolUnifiedEngagementMode", False)
mp.put("NeuralNetworkFeedForward", False)
mp.put("IQLaneChangeTimer", 0) # nudge
mp.put("IQLaneChangeBsmDelay", False)
# ── Visuals (correct param keys matching visuals.py) ─────────────────────
mp.put("IQBlindSpotAlerts", True)
mp.put("IQSteerEffortArc", True)
mp.put("IQRoadNameOverlay", True)
mp.put("IQBlinkerIndicators", True)
mp.put("IQAccelMeter", False)
mp.put("IQLeadReadouts", 0) # 0=off
mp.put("IQDevUIInfo", 0) # 0=off
mp.put("AlphaLongitudinalEnabled", False) # real param; gates ChevronInfo
# ── Display ───────────────────────────────────────────────────────────────
mp.put("OnroadScreenOffBrightness", 0) # 0=auto
mp.put("OnroadScreenOffTimer", 60) # 1m
mp.put("InteractivityTimeout", 0) # default
# ── Software ──────────────────────────────────────────────────────────────
mp.put("DisableUpdates", False)
mp.put("GitBranch", "master-mici")
mp.put("Version", "IQ.Pilot 0.9.5-mici")
# ── Models ────────────────────────────────────────────────────────────────
mp.put("IQLiveSteerDelay", False)
mp.put("IQLaneTurnDesire", False)
mp.put("IQLaneTurnValue", "19.0")
# ── Cruise ────────────────────────────────────────────────────────────────
mp.put("ExperimentalMode", False)
mp.put("IQDynamicMode", False)
mp.put("LongitudinalPersonality", 1) # 0=aggressive,1=standard,2=relaxed,3=stock
mp.put("IQSpeedAssistMode", 0) # 0=off
# ── Misc / system ─────────────────────────────────────────────────────────
mp.put("IsMetric", False)
mp.put("UIAccentColor", "#00FFF5") # default neon cyan
# Inject mock into common.params
# IMPORTANT: inject the CLASS (not an instance) so that `Params | None`
# type-union expressions in downstream modules work at import time.
mock_module = types.ModuleType("openpilot.common.params")
mock_module.Params = MockParams
sys.modules["openpilot.common.params"] = mock_module
# ── Mock ui_state (both the iqpilot layer and the top-level selfdrive layer) ─
class MockCP:
enableBsm = True
openpilotLongitudinalControl = True
alphaLongitudinalAvailable = False
# Minimal mock for ui_state.sm — returns empty/default objects for any key
class _MockBundle:
internalName = "mock-model"
displayName = "Mock Model"
index = 0
status = None # not downloading
models = []
overrides = []
class _MockModelManager:
availableBundles = []
activeBundle = _MockBundle()
selectedBundle = None # None = not downloading
class _MockSM:
"""Minimal SubMaster-like dict that returns sensible defaults."""
_data = {
"iqModelManager": _MockModelManager(),
}
def __getitem__(self, key):
return self._data.get(key, type("Empty", (), {"enabled": False})())
@property
def updated(self):
return type("U", (), {"__getitem__": lambda s, k: False})()
@property
def alive(self):
return type("A", (), {"__getitem__": lambda s, k: True})()
@property
def valid(self):
return type("V", (), {"__getitem__": lambda s, k: True})()
@property
def frame(self): return 0
def __contains__(self, key): return True
class MockUIState:
CP = MockCP()
params = mp
sm = _MockSM()
started = False
ignition = False
is_metric = False
has_longitudinal_control = True
always_on_dm = False
recording_audio = False
personality = 1 # standard
custom_interactive_timeout = 0
light_sensor = -1.0
is_release = False
# IQ-specific extras
aol_enabled = False
aol_state = 0
def is_offroad(self): return True
def is_onroad(self): return False
def add_offroad_transition_callback(self, cb): pass
def add_engaged_transition_callback(self, cb): pass
def update_params(self): pass
@property
def engaged(self): return False
_mock_ui_state = MockUIState()
# openpilot.selfdrive.ui.ui_state (base openpilot layer — hosts the IQ UI state classes/enums)
from enum import Enum, IntEnum
class _UIStatus(Enum):
DISENGAGED = "disengaged"
ENGAGED = "engaged"
OVERRIDE = "override"
LAT_ONLY = "lat_only"
LONG_ONLY = "long_only"
class _OnroadTimerStatus(Enum):
NONE = 0
PAUSE = 1
RESUME = 2
class _OnroadBrightness(IntEnum):
AUTO = 0
AUTO_DARK = 1
mock_base_ui_mod = types.ModuleType("openpilot.selfdrive.ui.ui_state")
mock_base_ui_mod.ui_state = _mock_ui_state
mock_base_ui_mod.UIStatus = _UIStatus
mock_base_ui_mod.OnroadTimerStatus = _OnroadTimerStatus
mock_base_ui_mod.OnroadBrightness = _OnroadBrightness
mock_base_ui_mod.device = type("MockDevice", (), {"awake": True})()
sys.modules["openpilot.selfdrive.ui.ui_state"] = mock_base_ui_mod
return mp
# ─────────────────────────────────────────────────────────────────────────────
# Panel loader
# ─────────────────────────────────────────────────────────────────────────────
def load_panel(name: str):
"""
Instantiate a MICI panel/layout by name.
Returns a Widget instance with set_rect() already called.
"""
rect = rl.Rectangle(0, 0, MICI_W, MICI_H)
layouts = {
"steering": ("openpilot.iqpilot.ui.mici.layouts.steering", "SteeringLayoutMici"),
"visuals": ("openpilot.iqpilot.ui.mici.layouts.visuals", "VisualsLayoutMici"),
"display": ("openpilot.iqpilot.ui.mici.layouts.display", "DisplayLayoutMici"),
"software": ("openpilot.iqpilot.ui.mici.layouts.software", "SoftwareLayoutMici"),
"cruise": ("openpilot.iqpilot.ui.mici.layouts.cruise", "CruiseLayoutMici"),
"trips": ("openpilot.iqpilot.ui.mici.layouts.trips", "TripsLayoutMici"),
"osm": ("openpilot.iqpilot.ui.mici.layouts.osm", "OSMLayoutMici"),
"models": ("openpilot.iqpilot.ui.mici.layouts.models", "ModelsLayoutMici"),
"toggles": ("openpilot.selfdrive.ui.mici.layouts.settings.toggles", "TogglesLayoutMici"),
"device": ("openpilot.selfdrive.ui.mici.layouts.settings.device", "DeviceLayoutMici"),
"developer":("openpilot.selfdrive.ui.mici.layouts.settings.developer","DeveloperLayoutMici"),
"home": ("openpilot.selfdrive.ui.mici.layouts.home", "MiciHomeLayout"),
"settings": ("openpilot.iqpilot.ui.mici.layouts.settings", "IQMiciSettingsLayout"),
}
if name not in layouts:
raise ValueError(f"Unknown panel '{name}'. Choose from: {', '.join(layouts)}")
# All IQ.Pilot MICI layout panels accept an optional back_callback
NEEDS_BACK_CB = {
"steering", "visuals", "display", "software", "cruise",
"trips", "osm", "models", "toggles", "device", "developer",
}
mod_path, cls_name = layouts[name]
mod = importlib.import_module(mod_path)
cls = getattr(mod, cls_name)
if name in NEEDS_BACK_CB:
widget = cls(back_callback=lambda: None)
else:
widget = cls()
widget.set_rect(rect)
widget.show_event()
return widget
# ─────────────────────────────────────────────────────────────────────────────
# Rendering helpers
# ─────────────────────────────────────────────────────────────────────────────
def _draw_chrome(panel_name: str, scale: float, canvas_x: int, canvas_y: int):
"""Draw the preview window chrome: background, label, dimension hint."""
# Nothing to draw outside the canvas — the window IS the canvas (+ padding)
pass
def _render_frame(widget, render_tex: rl.RenderTexture, canvas_x: int, canvas_y: int, scale: float, panel_name: str):
"""
Render one frame:
1. Draw the MICI widget into render_tex (536×240 offscreen)
2. Blit the texture into the window at the correct position + scale
3. Draw chrome overlays (panel label, grid, etc.)
"""
# Draw into the MICI-sized render texture
rl.begin_texture_mode(render_tex)
rl.clear_background(rl.Color(0, 0, 0, 255))
widget.render(rl.Rectangle(0, 0, MICI_W, MICI_H))
rl.end_texture_mode()
# Blit to screen (flip Y because OpenGL textures are upside-down)
src = rl.Rectangle(0, 0, MICI_W, -MICI_H) # negative H = flip
dst = rl.Rectangle(canvas_x, canvas_y, MICI_W * scale, MICI_H * scale)
rl.draw_texture_pro(render_tex.texture, src, dst, rl.Vector2(0, 0), 0, rl.WHITE)
# Panel name label bottom-left
rl.draw_text(panel_name.upper(), canvas_x + 6, canvas_y + int(MICI_H * scale) + 6,
14, rl.Color(120, 120, 120, 180))
# Dimension hint bottom-right
hint = f"{MICI_W}×{MICI_H} (×{scale:.1f})"
hint_w = rl.measure_text(hint, 12)
win_w = rl.get_screen_width()
rl.draw_text(hint, win_w - hint_w - 8, canvas_y + int(MICI_H * scale) + 6,
12, rl.Color(80, 80, 80, 160))
# ─────────────────────────────────────────────────────────────────────────────
# Screenshot mode
# ─────────────────────────────────────────────────────────────────────────────
def run_screenshot(panel_name: str, args, out_dir: Path) -> list[Path]:
"""
Render `args.frames` frames (so animations settle), then take `args.shots`
screenshots 0.5s apart. Returns list of saved PNG paths.
Uses gui_app.init_window() so fonts are loaded correctly, and the SCALE
env var (set to args.scale in main()) controls window size.
"""
from openpilot.system.ui.lib.application import gui_app
gui_app.init_window(f"MICI Preview — {panel_name}", fps=args.fps)
# Offscreen MICI-resolution render texture
render_tex = rl.load_render_texture(MICI_W, MICI_H)
# Load widget AFTER window/fonts are initialized
widget = load_panel(panel_name)
saved: list[Path] = []
frame = 0
shots_taken = 0
next_shot_frame = args.frames # first shot after settle
# Display at native scaled window coords (0,0 → screen size)
win_w = rl.get_screen_width()
win_h = rl.get_screen_height()
while not rl.window_should_close() and shots_taken < args.shots:
rl.begin_drawing()
rl.clear_background(CHROME_BG)
# Render widget into the MICI-sized texture, then blit full-window
rl.begin_texture_mode(render_tex)
rl.clear_background(rl.BLACK)
widget.render(rl.Rectangle(0, 0, MICI_W, MICI_H))
rl.end_texture_mode()
# Blit the render texture to fill the whole window (Y-flipped)
src = rl.Rectangle(0, 0, MICI_W, -MICI_H)
dst = rl.Rectangle(0, 0, win_w, win_h)
rl.draw_texture_pro(render_tex.texture, src, dst, rl.Vector2(0, 0), 0, rl.WHITE)
# Settle progress bar
if frame < args.frames:
pct = frame / args.frames
rl.draw_rectangle(0, win_h - 3, int(win_w * pct), 3, rl.Color(0, 255, 245, 140))
rl.draw_text(f"settling {frame}/{args.frames}", 6, 6, 11, rl.Color(100, 100, 100, 160))
rl.end_drawing()
frame += 1
if frame == next_shot_frame:
# Read from render texture (native MICI res, no padding to crop)
img = rl.load_image_from_texture(render_tex.texture)
rl.image_flip_vertical(img)
# Save at 2× native for clarity on Retina displays
rl.image_resize(img, MICI_W * 2, MICI_H * 2)
out_dir.mkdir(parents=True, exist_ok=True)
ts = int(time.time() * 1000)
path = out_dir / f"mici_{panel_name}_{shots_taken + 1:02d}_{ts}.png"
rl.export_image(img, str(path))
rl.unload_image(img)
saved.append(path)
print(f" 📸 saved: {path}")
shots_taken += 1
next_shot_frame += int(args.fps * 0.5) # 0.5s between shots
rl.unload_render_texture(render_tex)
rl.close_window()
return saved
# ─────────────────────────────────────────────────────────────────────────────
# Video mode
# ─────────────────────────────────────────────────────────────────────────────
def run_video(panel_name: str, args, out_dir: Path) -> Path:
"""
Render args.duration seconds at args.fps, pipe raw RGBA frames to ffmpeg → MP4.
Returns the output MP4 path.
"""
from openpilot.system.ui.lib.application import gui_app
out_dir.mkdir(parents=True, exist_ok=True)
ts = int(time.time())
mp4_path = out_dir / f"mici_{panel_name}_{ts}.mp4"
# Launch ffmpeg to accept raw RGBA frames at MICI native resolution
ffmpeg = subprocess.Popen([
"ffmpeg", "-v", "warning", "-nostats",
"-f", "rawvideo", "-pix_fmt", "rgba",
"-s", f"{MICI_W}x{MICI_H}",
"-r", str(args.fps),
"-i", "pipe:0",
"-vf", "vflip,format=yuv420p",
"-c:v", "libx264", "-preset", "fast", "-crf", "18",
"-y", str(mp4_path),
], stdin=subprocess.PIPE)
# Write-queue so rendering doesn't block on ffmpeg
frame_queue: queue.Queue = queue.Queue(maxsize=args.fps * 2)
stop_event = threading.Event()
def _writer():
while not stop_event.is_set() or not frame_queue.empty():
try:
data = frame_queue.get(timeout=0.1)
ffmpeg.stdin.write(data)
except queue.Empty:
pass
ffmpeg.stdin.close()
writer_thread = threading.Thread(target=_writer, daemon=True)
writer_thread.start()
gui_app.init_window(f"MICI Preview (recording) — {panel_name}", fps=args.fps)
render_tex = rl.load_render_texture(MICI_W, MICI_H)
widget = load_panel(panel_name)
win_w = rl.get_screen_width()
win_h = rl.get_screen_height()
total_frames = int(args.fps * args.duration)
frame = 0
settle = min(args.frames, total_frames // 4)
print(f" 🎬 recording {args.duration}s at {args.fps}fps → {mp4_path.name}")
while not rl.window_should_close() and frame < total_frames:
rl.begin_drawing()
rl.clear_background(CHROME_BG)
rl.begin_texture_mode(render_tex)
rl.clear_background(rl.BLACK)
widget.render(rl.Rectangle(0, 0, MICI_W, MICI_H))
rl.end_texture_mode()
src = rl.Rectangle(0, 0, MICI_W, -MICI_H)
dst = rl.Rectangle(0, 0, win_w, win_h)
rl.draw_texture_pro(render_tex.texture, src, dst, rl.Vector2(0, 0), 0, rl.WHITE)
# REC progress bar
pct = frame / total_frames
rl.draw_rectangle(0, win_h - 3, int(win_w * pct), 3, rl.Color(255, 80, 80, 180))
rl.draw_text(f"REC {frame}/{total_frames}", 6, 6, 11, rl.Color(255, 80, 80, 200))
rl.end_drawing()
# Queue raw RGBA from MICI-res texture for ffmpeg
if frame >= settle:
import ctypes
img = rl.load_image_from_texture(render_tex.texture)
colors = rl.load_image_colors(img)
raw = bytes(ctypes.string_at(colors, MICI_W * MICI_H * 4))
rl.unload_image_colors(colors)
rl.unload_image(img)
try:
frame_queue.put(raw, timeout=1.0)
except queue.Full:
pass
frame += 1
rl.unload_render_texture(render_tex)
rl.close_window()
stop_event.set()
writer_thread.join(timeout=10)
ffmpeg.wait(timeout=15)
print(f" ✅ video saved: {mp4_path}")
return mp4_path
# ─────────────────────────────────────────────────────────────────────────────
# Live / hot-reload mode
# ─────────────────────────────────────────────────────────────────────────────
def run_live(panel_name: str, args, mock_params):
"""
Interactive window with hot-reload.
Watches the source file of the selected panel; re-imports it on save.
Press S to take a screenshot, R to force reload, Q/Esc to quit.
"""
from openpilot.system.ui.lib.application import gui_app
gui_app.init_window(f"MICI Live — {panel_name} [S=shot R=reload Q=quit]", fps=args.fps)
render_tex = rl.load_render_texture(MICI_W, MICI_H)
win_w = rl.get_screen_width()
win_h = rl.get_screen_height()
widget = load_panel(panel_name)
last_mtime: dict[str, float] = {}
def _watch_paths() -> list[Path]:
"""Files to watch for changes (panel module + shared theme/button)."""
mods = [
ROOT / "selfdrive/ui/iqpilot/mici/layouts" / f"{panel_name}.py",
ROOT / "selfdrive/ui/iqpilot/theme.py",
ROOT / "selfdrive/ui/mici/widgets/button.py",
]
return [p for p in mods if p.exists()]
def _needs_reload() -> bool:
for p in _watch_paths():
mtime = p.stat().st_mtime
if last_mtime.get(str(p), 0) != mtime:
last_mtime[str(p)] = mtime
return True
return False
def _reload():
nonlocal widget
print(" 🔄 reloading...")
# Invalidate cached modules so importlib picks up changes
prefix = "openpilot.iqpilot.ui.mici.layouts"
for key in list(sys.modules.keys()):
if key.startswith(prefix) or key == "openpilot.iqpilot.ui.theme":
del sys.modules[key]
try:
widget = load_panel(panel_name)
print(" ✅ reloaded OK")
except Exception as e:
print(f" ❌ reload error: {e}")
# Seed mtimes
for p in _watch_paths():
last_mtime[str(p)] = p.stat().st_mtime
out_dir = Path(args.out)
shot_count = 0
while not rl.window_should_close():
# Check for file changes
if _needs_reload():
_reload()
# Key bindings
if rl.is_key_pressed(rl.KeyboardKey.KEY_Q) or rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
break
if rl.is_key_pressed(rl.KeyboardKey.KEY_R):
_reload()
if rl.is_key_pressed(rl.KeyboardKey.KEY_S):
# Take screenshot from render texture (native MICI res)
img = rl.load_image_from_texture(render_tex.texture)
rl.image_flip_vertical(img)
rl.image_resize(img, MICI_W * 2, MICI_H * 2)
out_dir.mkdir(parents=True, exist_ok=True)
ts = int(time.time() * 1000)
path = out_dir / f"mici_{panel_name}_live_{shot_count:03d}_{ts}.png"
rl.export_image(img, str(path))
rl.unload_image(img)
shot_count += 1
print(f" 📸 screenshot saved: {path}")
if args.open:
subprocess.Popen(["open", str(path)])
rl.begin_drawing()
rl.clear_background(CHROME_BG)
# Render widget into MICI-sized texture, blit to full window
rl.begin_texture_mode(render_tex)
rl.clear_background(rl.BLACK)
widget.render(rl.Rectangle(0, 0, MICI_W, MICI_H))
rl.end_texture_mode()
src = rl.Rectangle(0, 0, MICI_W, -MICI_H)
dst = rl.Rectangle(0, 0, win_w, win_h)
rl.draw_texture_pro(render_tex.texture, src, dst, rl.Vector2(0, 0), 0, rl.WHITE)
# Status hint overlay
watch_files = _watch_paths()
hint = f"watching {len(watch_files)} file(s) | S=shot R=reload Q=quit"
rl.draw_text(hint, 6, win_h - 18, 11, rl.Color(80, 80, 80, 160))
rl.end_drawing()
rl.unload_render_texture(render_tex)
rl.close_window()
# ─────────────────────────────────────────────────────────────────────────────
# Entry point
# ─────────────────────────────────────────────────────────────────────────────
def parse_args():
p = argparse.ArgumentParser(
description="IQ.Pilot MICI UI Preview Tool",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
p.add_argument("--panel", default="steering",
help="Panel name or 'all'")
p.add_argument("--mode", default="screenshot",
choices=["screenshot", "video", "live"],
help="Capture mode")
p.add_argument("--frames", type=int, default=90,
help="Settle frames before screenshot")
p.add_argument("--shots", type=int, default=1,
help="Number of screenshots")
p.add_argument("--duration", type=float, default=4.0,
help="Video duration in seconds")
p.add_argument("--fps", type=int, default=60,
help="Render FPS")
p.add_argument("--scale", type=float, default=2.5,
help="Window scale multiplier (1.0 = native 536×240)")
p.add_argument("--out", default=str(DEFAULT_OUT),
help="Output directory")
p.add_argument("--open", action="store_true", default=True,
help="Open output after capture (macOS)")
p.add_argument("--no-open", dest="open", action="store_false")
p.add_argument("--mock", action="store_true", default=True,
help="Use mock UI state (no live process needed)")
p.add_argument("--no-mock", dest="mock", action="store_false")
p.add_argument("--accent", default=None,
help="Override neon accent color, e.g. '#FF6B00'")
return p.parse_args()
def main():
args = parse_args()
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
# Set SCALE env var BEFORE gui_app module is imported (it reads it at import time)
os.environ["SCALE"] = str(args.scale)
# Apply mock state early (before any widget imports)
mock_params = None
if args.mock:
print(" 🎭 using mock UI state (--no-mock to use live params)")
mock_params = _patch_mock_state()
# Override accent color if requested
if args.accent:
if mock_params:
mock_params.put("UIAccentColor", args.accent)
print(f" 🎨 accent color: {args.accent}")
panels = ALL_PANELS if args.panel == "all" else [args.panel]
mode = args.mode
if mode == "live" and len(panels) > 1:
print(" ⚠️ live mode only supports a single panel. Using first panel.")
panels = panels[:1]
all_outputs: list[Path] = []
for panel_name in panels:
print(f"\n{mode.upper()} — panel: {panel_name}")
try:
if mode == "live":
run_live(panel_name, args, mock_params)
elif mode == "screenshot":
saved = run_screenshot(panel_name, args, out_dir)
all_outputs.extend(saved)
elif mode == "video":
mp4 = run_video(panel_name, args, out_dir)
all_outputs.append(mp4)
except Exception as e:
print(f" ❌ failed for panel '{panel_name}': {e}")
import traceback; traceback.print_exc()
if rl.is_window_ready():
rl.close_window()
# Open all outputs at once
if args.open and all_outputs:
print(f"\n 📂 opening {len(all_outputs)} output(s)...")
subprocess.Popen(["open"] + [str(p) for p in all_outputs])
print("\n ✨ done\n")
if __name__ == "__main__":
main()