From 6b7884bf6e8cef736523c54f33c62efcd257c41e Mon Sep 17 00:00:00 2001 From: liuzhen Date: Thu, 22 Oct 2026 02:00:13 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=E9=BB=98=E8=AE=A4=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E8=87=B3=20OpenRouter=20gpt-6-astra=EF=BC=8C?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=9C=BA=E6=A2=B0=E8=87=82=E8=BF=90=E5=8A=A8?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E5=BD=95=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 默认服务商改为 OpenRouter(base_url: openrouter.ai/api/v1), 默认模型 openai/gpt-6-astra;不包含任何 API key,密钥仍由 环境变量或界面输入在运行期提供 - 新增 recording/motion.py:物理循环内按仿真时钟快照, 独立线程离屏渲染双视角并经 ffmpeg 流式合成 motion.mp4; controlled 时序下 API 推理期间物理冻结,视频只含机械臂实际运动 - runner 接入录制生命周期(启动创建、步进快照、终止/关闭收尾) Co-authored-by: Cursor --- src/maniloop/providers/credentials.py | 2 +- src/maniloop/providers/responses.py | 2 +- src/maniloop/recording/motion.py | 238 ++++++++++++++++++++++++++ src/maniloop/runtime/runner.py | 36 +++- src/maniloop/ui/chat.html | 2 +- src/maniloop/ui/index.html | 4 +- src/maniloop/ui/workspace.html | 2 +- src/maniloop/ui/workspace.js | 2 +- 8 files changed, 279 insertions(+), 9 deletions(-) create mode 100644 src/maniloop/recording/motion.py diff --git a/src/maniloop/providers/credentials.py b/src/maniloop/providers/credentials.py index 1a45079..cb88933 100644 --- a/src/maniloop/providers/credentials.py +++ b/src/maniloop/providers/credentials.py @@ -21,7 +21,7 @@ from urllib.parse import urlsplit, urlunsplit MAX_CONFIG_BYTES = 1024 * 1024 -DEFAULT_BASE_URL = "https://api.openai.com/v1" +DEFAULT_BASE_URL = "https://openrouter.ai/api/v1" class ConfigError(ValueError): diff --git a/src/maniloop/providers/responses.py b/src/maniloop/providers/responses.py index bb9a8c4..8b28daa 100644 --- a/src/maniloop/providers/responses.py +++ b/src/maniloop/providers/responses.py @@ -25,7 +25,7 @@ RateLimitError, ) -DEFAULT_MODEL = "gpt-6-astra" +DEFAULT_MODEL = "openai/gpt-6-astra" from maniloop.core.observations import PUBLIC_OBSERVATION_FIELDS, guard_sensor_tree ACTION_SCHEMA = { diff --git a/src/maniloop/recording/motion.py b/src/maniloop/recording/motion.py new file mode 100644 index 0000000..ce0d2c4 --- /dev/null +++ b/src/maniloop/recording/motion.py @@ -0,0 +1,238 @@ +"""Live episode motion video for the in-process MuJoCo backend. + +The runner's physics loop hands immutable MjData snapshots to a worker thread +at a fixed simulation-time cadence. The worker owns its offscreen context and +streams composited dual-camera frames to ffmpeg, so recording never slows +physics or inference. Controlled timing freezes physics during API calls, and +paused/diagnostic periods never reach the physics loop; request latency +therefore never appears in the video. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import queue +import shutil +import subprocess +import threading +from pathlib import Path + +import mujoco +from PIL import Image, ImageDraw + +CAMERAS = ("external", "wrist") +_MAX_ERRORS = 5 + + +class MotionRecorder: + """Dual-camera MP4 of everything physics actually did, nothing it did not.""" + + def __init__( + self, + sim, + run_dir, + *, + fps: int = 20, + panel: int = 320, + ffmpeg: str | None = None, + max_frames: int = 20 * 7200, + ): + if getattr(sim, "backend", None) != "mujoco": + raise ValueError("MotionRecorder supports only the in-process mujoco backend") + for name in CAMERAS: + sim.model.camera(name) # Raises early when a scene lacks the cameras. + if type(fps) is not int or not 1 <= fps <= 60: + raise ValueError("fps must be an integer from 1 to 60") + binary = ffmpeg or shutil.which("ffmpeg") + if not binary or not Path(binary).is_file(): + raise ValueError("ffmpeg was not found; motion video requires the ffmpeg executable") + self._model = sim.model + self._scene_option = getattr(sim, "scene_option", None) + self._fps = fps + self._panel = panel + self._interval = 1.0 / fps + self._next_due = None + self._max_frames = max_frames + self._queue: queue.Queue = queue.Queue(maxsize=8) + self._dropped = 0 + self._captured = 0 + self._errors = 0 + self._last_sim_time = -math.inf + self._disabled = False + self._finalized = False + self._thread = threading.Thread( + target=self._work, name="motion-recorder", daemon=True + ) + canvas = (2 * panel, panel + 56) + self._canvas = canvas + run_dir = Path(run_dir) + self._partial = run_dir / "motion.partial.mp4" + self._destination = run_dir / "motion.mp4" + self._process = subprocess.Popen( + [ + str(binary), "-nostdin", "-hide_banner", "-loglevel", "error", "-y", + "-f", "rawvideo", "-pixel_format", "rgb24", + "-video_size", f"{canvas[0]}x{canvas[1]}", + "-framerate", str(fps), "-i", "pipe:0", + "-an", "-c:v", "libx264", "-preset", "veryfast", + "-crf", "20", "-pix_fmt", "yuv420p", "-movflags", "+faststart", + str(self._partial), + ], + stdin=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + self._thread.start() + + def tick(self, sim) -> None: + """Physics-loop hook: snapshot state at the simulation-time cadence. + + Runs in the physics thread; rendering happens in the worker. A short + blocking put throttles headless runs to encoder speed instead of + dropping motion; only sustained overload drops a snapshot. + """ + if self._disabled or self._finalized or self._captured >= self._max_frames: + return + sim_time = float(sim.data.time) + if self._next_due is None: + self._next_due = sim_time + if sim_time + 1e-9 < self._next_due: + return + self._next_due += self._interval + snapshot = mujoco.MjData(self._model) + mujoco.mj_copyData(snapshot, self._model, sim.data) + try: + self._queue.put((snapshot, sim_time), timeout=1.0) + except queue.Full: + self._dropped += 1 + + def _work(self) -> None: + renderer = None + while True: + item = self._queue.get() + if item is None: + return + data, sim_time = item + try: + if renderer is None: + # The OSMesa/EGL context is thread-local; create it here. + renderer = mujoco.Renderer( + self._model, height=self._panel, width=self._panel + ) + canvas = Image.new("RGB", self._canvas, "#111b24") + for offset, camera in enumerate(CAMERAS): + renderer.update_scene( + data, camera=camera, scene_option=self._scene_option + ) + rgb = renderer.render() + canvas.paste( + Image.fromarray(rgb).convert("RGB"), + (offset * self._panel, 28), + ) + draw = ImageDraw.Draw(canvas) + draw.text((10, 8), "EXTERNAL CAMERA", fill="white") + draw.text((self._panel + 10, 8), "WRIST CAMERA", fill="white") + draw.text( + (10, self._panel + 36), + f"simulation {sim_time:.2f}s | {self._fps} fps | motion only (API latency excluded)", + fill="white", + ) + self._process.stdin.write(canvas.tobytes()) + self._captured += 1 + self._last_sim_time = sim_time + except (BrokenPipeError, OSError, ValueError): + self._disabled = True + return + except Exception: + self._errors += 1 + if self._errors >= _MAX_ERRORS: + self._disabled = True + return + + def finalize(self) -> dict | None: + """Flush the encoder once; returns stats for the run log, or None.""" + if self._finalized: + return None + self._finalized = True + # Drain any queued snapshot, then stop the worker. + try: + self._queue.put(None, timeout=2) + except queue.Full: + try: + self._queue.get_nowait() + except queue.Empty: + pass + try: + self._queue.put(None, timeout=2) + except queue.Full: + self._disabled = True + self._thread.join(timeout=30) + if self._process.stdin and not self._process.stdin.closed: + try: + self._process.stdin.close() + except (BrokenPipeError, OSError): + pass + try: + error = self._process.stderr.read().decode("utf-8", errors="replace") + except (OSError, ValueError): + error = "" + try: + returncode = self._process.wait(timeout=60) + except subprocess.TimeoutExpired: + self._process.kill() + self._process.wait() + returncode = -1 + stats = { + "frames": self._captured, + "dropped": self._dropped, + "fps": self._fps, + "panel": self._panel, + "simulation_span_seconds": ( + round(self._last_sim_time, 3) if self._captured else 0.0 + ), + } + ok = ( + returncode == 0 + and self._captured > 0 + and self._partial.is_file() + and self._partial.stat().st_size > 0 + ) + if ok: + self._partial.replace(self._destination) + stats["video"] = self._destination.name + stats["video_sha256"] = hashlib.sha256( + self._destination.read_bytes() + ).hexdigest() + stats["video_bytes"] = self._destination.stat().st_size + else: + self._partial.unlink(missing_ok=True) + stats["video"] = None + if error.strip(): + stats["encoder_error"] = error.strip()[:300] + (self._destination.parent / "motion.json").write_text( + json.dumps(stats, ensure_ascii=False, indent=2), encoding="utf-8" + ) + return stats + + def abort(self) -> None: + """Kill the encoder without producing a video (runner teardown).""" + if self._finalized: + return + self._finalized = True + self._disabled = True + try: + self._queue.put_nowait(None) + except queue.Full: + pass + if self._process.poll() is None: + try: + self._process.stdin.close() + except (BrokenPipeError, OSError): + pass + try: + self._process.wait(timeout=5) + except subprocess.TimeoutExpired: + self._process.kill() + self._process.wait() + self._partial.unlink(missing_ok=True) diff --git a/src/maniloop/runtime/runner.py b/src/maniloop/runtime/runner.py index 89e01d5..37cedcd 100644 --- a/src/maniloop/runtime/runner.py +++ b/src/maniloop/runtime/runner.py @@ -73,7 +73,7 @@ def __init__( self._last_wall = time.monotonic() self._physics_remainder = 0.0 self.seed = 0 - self.model = model or os.environ.get("OPENAI_MODEL") or "gpt-6-astra" + self.model = model or os.environ.get("OPENAI_MODEL") or DEFAULT_MODEL self.default_model = self.model self.manual_base_url = None self.key = os.environ.get("OPENAI_API_KEY", "") @@ -88,7 +88,7 @@ def __init__( "source": "manual", "path": "", "provider": "OpenAI", - "base_url": "https://api.openai.com/v1", + "base_url": "https://openrouter.ai/api/v1", "model": self.model, "key_configured": bool(self.key), "models": [self.model], @@ -133,6 +133,7 @@ def __init__( self.token = 0 self.future_token = -1 self.log_file = None + self.motion = None self.next_request = 0.0 self.event("info", f"{sim.robot_name} 场景已准备;输入任务后开始闭环") @@ -152,6 +153,7 @@ def event(self, kind, message, **details): def stop(self, message="任务已停止", reason="user_stop"): if not self.running and self.log_file and self.termination_reason: # A completed run is immutable even if Stop/Reset is sent again. + self._finalize_motion() self.update_review() self.sim.hold() return @@ -166,6 +168,7 @@ def stop(self, message="任务已停止", reason="user_stop"): self.policy.cancel() self.sim.hold() self.event("info", message) + self._finalize_motion() self.update_review() def update_review(self): @@ -679,6 +682,12 @@ def begin_episode(self, task, max_steps): (run / "manifest.json").write_text( json.dumps(self.manifest, ensure_ascii=False, indent=2), encoding="utf-8" ) + self.motion = None + try: + from maniloop.recording.motion import MotionRecorder + self.motion = MotionRecorder(self.sim, run) + except Exception as exc: + self.event("info", f"运动视频录制不可用:{exc}") self.event( "start", "开始任务", task=task, model=self.model, max_calls=max_steps or None ) @@ -705,6 +714,7 @@ def advance(self, simulation_seconds=0.02): self.stop("环境已结束", reason="environment_terminated") self.tick() if not self.running and self.log_file: + self._finalize_motion() self.update_review() if self.paused: self._physics_remainder = 0.0 @@ -750,10 +760,31 @@ def advance(self, simulation_seconds=0.02): if self.sim.backend in ("libero", "robosuite", "robocasa"): break self.sim.step() + if self.motion is not None: + self.motion.tick(self.sim) if self.running and self.sim.terminated: self.stop("环境已结束", reason="environment_terminated") break + def _finalize_motion(self): + motion, self.motion = self.motion, None + if motion is None: + return + try: + stats = motion.finalize() + except Exception as exc: + self.event("info", f"运动视频封装失败:{exc}") + return + if stats is None: + return + if stats.get("video"): + message = f"运动视频已保存 {stats['video']}({stats['frames']} 帧" + if stats.get("dropped"): + message += f",渲染繁忙丢弃 {stats['dropped']} 帧" + self.event("info", message + ")") + else: + self.event("info", "运动视频未生成(无有效帧或编码失败)") + def close_policy(self): policy, self.policy = self.policy, None if policy is not None and hasattr(policy, "close"): @@ -765,6 +796,7 @@ def close(self): self.running = False self.chunk = None self.token += 1 + self._finalize_motion() self.close_policy() self.pool.shutdown(wait=True, cancel_futures=True) self.sim.close() diff --git a/src/maniloop/ui/chat.html b/src/maniloop/ui/chat.html index ac2dfcc..e9991eb 100644 --- a/src/maniloop/ui/chat.html +++ b/src/maniloop/ui/chat.html @@ -13,7 +13,7 @@