diff --git a/lelab/record.py b/lelab/record.py index f3a54b09..9159aace 100644 --- a/lelab/record.py +++ b/lelab/record.py @@ -23,6 +23,7 @@ from pydantic import BaseModel from lerobot.configs.dataset import DatasetRecordConfig +from lerobot.configs.video import RGBEncoderConfig from lerobot.datasets import LeRobotDataset from lerobot.robots.so_follower import SO101FollowerConfig @@ -183,6 +184,10 @@ def create_record_config(request: RecordingRequest) -> RecordConfig: tags=with_lelab_tag(request.tags) if request.push_to_hub else None, private=request.private, streaming_encoding=request.streaming_encoding, + # libsvtav1 (lerobot's default) is CPU-heavy enough on this machine to starve + # the motor bus read loop mid-recording ("no status packet" ConnectionError, + # see 2026-07-15 crashes). h264/ultrafast measured ~11x faster to encode here. + rgb_encoder=RGBEncoderConfig(vcodec="h264", preset="ultrafast"), ) # Create the main record config @@ -309,11 +314,15 @@ def recording_worker(): "robot_type": getattr(dataset.meta, "robot_type", "Unknown robot"), } except Exception as e: - logger.exception("Recording session failed") + logger.exception("Recording session failed: %r", e) current_phase = "error" if recording_start_time: session_end_elapsed_seconds = int(time.time() - recording_start_time) - last_recording_info = {"success": False, "error": str(e)} + last_recording_info = { + "success": False, + "error": str(e) or repr(e), + "dataset_repo_id": request.dataset_repo_id, + } finally: if current_phase != "error": current_phase = "completed" @@ -419,7 +428,11 @@ def handle_recording_status() -> dict[str, Any]: "rerecord_episode": recording_active and current_phase == "recording", # Only during recording phase }, - "message": "Recording session failed with error - check logs" + "message": ( + f"Recording failed: {last_recording_info['error']}" + if last_recording_info and last_recording_info.get("error") + else "Recording session failed with error - check logs" + ) if current_phase == "error" else ( "Recording session has ended - stop polling" diff --git a/lelab/server.py b/lelab/server.py index 519e0c72..9e56de6a 100644 --- a/lelab/server.py +++ b/lelab/server.py @@ -36,6 +36,15 @@ from starlette.responses import Response from starlette.types import Scope +# Windows consoles often use a legacy code page (cp1252) that can't encode the +# emoji used in log/print output; an unhandled UnicodeEncodeError inside an +# endpoint then turns into a 500 and hides the real error from the frontend. +# Replace unencodable characters instead of crashing. +for _stream in (sys.stdout, sys.stderr): + if _stream is not None and hasattr(_stream, "reconfigure"): + with contextlib.suppress(Exception): + _stream.reconfigure(errors="replace") + from . import datasets as dataset_browser # Import our custom calibration functionality @@ -998,6 +1007,18 @@ def _windows_cameras() -> list[dict[str, Any]]: frontend match each index to the browser's ``MediaDeviceInfo.label`` for the live preview. Falls back to generic names if pygrabber is unavailable. """ + # pygrabber uses COM, which must be initialized per-thread. FastAPI serves + # sync endpoints from a thread pool whose threads never call CoInitialize, + # so enumeration fails intermittently depending on which thread handles the + # request. Initialize COM here and balance with CoUninitialize. + import ctypes + + com_initialized = False + try: + hr = ctypes.windll.ole32.CoInitialize(None) + com_initialized = hr in (0, 1) # S_OK or S_FALSE (already initialized) + except Exception: + pass try: from pygrabber.dshow_graph import FilterGraph @@ -1007,6 +1028,9 @@ def _windows_cameras() -> list[dict[str, Any]]: import cv2 return _generic_cv2_cameras(cv2.CAP_DSHOW) + finally: + if com_initialized: + ctypes.windll.ole32.CoUninitialize() return [{"index": i, "name": name, "available": True} for i, name in enumerate(names)]