Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions lelab/record.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking, and the main discussion point. This changes the default encoder for every platform, not just Windows.

lerobot v0.6.0 already has better levers for this exact problem:

  • vcodec="auto" probes h264_videotoolbox / h264_nvenc / h264_vaapi / h264_qsv and falls back to libsvtav1. Hardware encoding is near zero CPU, which is the actual root cause, and it does not degrade Mac and Linux.
  • DatasetRecordConfig.encoder_threads caps encoder threads directly, which is the surgical fix for "encoder starves the motor bus read loop".

Risk worth checking before merge: RGBEncoderConfig.__post_init__ calls resolve_vcodec(), which raises ValueError: Unsupported video codec: h264 when the installed PyAV has no libx264 encoder. That throws inside create_record_config, so recording would die before starting on machines that work today. Please confirm on Linux and macOS with the pinned lerobot v0.6.0, not only on the Windows box.

Also, crf=30 and g=2 are inherited while preset="ultrafast" disables most compression tools, so datasets get noticeably larger. The PR measures encode speed (~11x) but not file size.

Last, the comment references "this machine" and "2026-07-15 crashes", which a future reader cannot verify. If the setting stays, expose it as a RecordingRequest field (same as video and streaming_encoding already are) or gate it on sys.platform == "win32".

)

# Create the main record config
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: logger.exception already emits the traceback, whose last line is the exception repr, so %r is redundant.

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),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

str(e) or repr(e) is a good catch for exceptions with an empty message. Keep this.

"dataset_repo_id": request.dataset_repo_id,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: this is an unintended regression. handle_get_dataset_info (line 479) short-circuits on last_recording_info.get("dataset_repo_id") == request.dataset_repo_id. Before this PR failure dicts had no dataset_repo_id, so it never matched and the on-disk dataset was loaded. Now a session that crashed after saving N episodes returns {"success": False, ...} and the partial dataset is never read, which is exactly the scenario this PR targets.

Also, this dict carries error but no message, unlike the other failure return at line 498, so the frontend reads undefined.

Suggest gating on success:

if last_recording_info and last_recording_info.get("success") \
        and last_recording_info.get("dataset_repo_id") == request.dataset_repo_id:

}
finally:
if current_phase != "error":
current_phase = "completed"
Expand Down Expand Up @@ -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": (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The global is read twice, guard then subscript, and handle_start_recording sets it to None at line 242. Narrow window, but the result is a 500 on /recording-status, which is the endpoint whose job is reporting errors. Bind once:

info = last_recording_info
error = info.get("error") if info else None

Then build the message above the status = {...} literal. The triple nested conditional inside a dict literal is hard to follow as written.

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"
Expand Down
24 changes: 24 additions & 0 deletions lelab/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: this statement sits between the stdlib imports and the local ones, so every from . import below it trips E402. ruff check reports 12 errors on this branch (base is clean), and quality.yml runs pre-commit on every PR, so CI will fail.

Suggest moving it into main() in lelab/scripts/lelab.py. Process wide stdio setup belongs at the entrypoint rather than as an import side effect, and it sidesteps E402 entirely.

if _stream is not None and hasattr(_stream, "reconfigure"):
with contextlib.suppress(Exception):
_stream.reconfigure(errors="replace")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: errors="replace" turns the emoji into ?. Adding encoding="utf-8" makes them render correctly on Windows Terminal and modern conhost.

Alternatively, dropping the emoji from the log lines in record.py (four 📡 lines around 414-419) removes the failure mode at the source.


from . import datasets as dataset_browser

# Import our custom calibration functionality
Expand Down Expand Up @@ -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:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix is right, including the hr in (0, 1) guard that correctly skips CoUninitialize on RPC_E_CHANGED_MODE.

Two things though: except Exception: pass swallows silently, and on macOS/Linux ctypes.windll raises AttributeError on every call (the existing tests do call _windows_cameras() off Windows). Suggest guarding on sys.platform == "win32" and logging at debug instead of swallowing.

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

Expand All @@ -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)]


Expand Down