diff --git a/.gitignore b/.gitignore index 69061763a..17b7d5c41 100644 --- a/.gitignore +++ b/.gitignore @@ -208,3 +208,9 @@ scripts/benchmark/rl/reports/* .worktrees/ # Local gym project workspace /gym_project/ +.gen_sim/ + +# Local Gradio UI dependencies, generated Articraft records, and bytecode +/embodichain/gen_sim/gradio_ui/.articraft/ +/embodichain/gen_sim/gradio_ui/.gen_sim/ +/embodichain/gen_sim/gradio_ui/__pycache__/ diff --git a/embodichain/gen_sim/.env.example b/embodichain/gen_sim/.env.example new file mode 100644 index 000000000..ba46bef48 --- /dev/null +++ b/embodichain/gen_sim/.env.example @@ -0,0 +1,43 @@ +# Shared GenSim configuration +# Copy this file to .env and set deployment-specific values. Values exported +# by the shell, container, or CI environment take precedence over this file. + +# Common OpenAI-compatible LLM endpoint used by Scene Engine. +OPENAI_API_KEY="" +OPENAI_MODEL="" +OPENAI_BASE_URL="" +SCENE_ENGINE_OPENAI_DEFAULT_QUERY="{}" +OPENAI_MAX_ATTEMPTS=3 + +# Scene Engine services. +SCENE_ENGINE_IMAGE_SEGMENTATION_BASE_URL="" +SCENE_ENGINE_IMAGE_SEGMENTATION_TIMEOUT_S=30 +SCENE_ENGINE_IMAGE_SEGMENTATION_MAX_ATTEMPTS=3 +SCENE_ENGINE_IMAGE_SEGMENTATION_HEALTH_PATH="/health" +SCENE_ENGINE_IMAGE_SEGMENTATION_SINGLE_OBJECT_PATH="/predict" +SCENE_ENGINE_GEOMETRY_GENERATION_BASE_URL="" +SCENE_ENGINE_GEOMETRY_GENERATION_TIMEOUT_S=600 +SCENE_ENGINE_GEOMETRY_GENERATION_MAX_ATTEMPTS=3 +SCENE_ENGINE_GEOMETRY_GENERATION_HEALTH_PATH="/health" +SCENE_ENGINE_GEOMETRY_GENERATION_OBJECTS_PATH="/generate_multiple_objects" + +# Gradio application and local workbench settings. The EmbodiChain repository +# root is derived automatically from the installed source tree. +GRADIO_SERVER_NAME="127.0.0.1" +GRADIO_SERVER_PORT=7860 +# Both values are required when GRADIO_SERVER_NAME is not a loopback address. +GRADIO_AUTH_USERNAME="" +GRADIO_AUTH_PASSWORD="" +SCENE_ENGINE_VISER_PORT=8080 +ARTICRAFT_VISER_PORT=8081 +ACTION_ENGINE_VISER_PORT=8082 +ARTICRAFT_ROOT="" +ARTICRAFT_REPOSITORY_URL="https://github.com/mattzh72/articraft.git" +ARTICRAFT_CONDA_ENV="articraft" +ARTICRAFT_OUTPUT_ROOT="" + +# Optional SimReady endpoint. These values are mapped to OPENAI_* only for +# SimReady subprocesses, leaving Scene Engine settings unchanged. +SIMREADY_OPENAI_API_KEY="" +SIMREADY_OPENAI_MODEL="" +SIMREADY_OPENAI_BASE_URL="" diff --git a/embodichain/gen_sim/env.py b/embodichain/gen_sim/env.py new file mode 100644 index 000000000..aee913a25 --- /dev/null +++ b/embodichain/gen_sim/env.py @@ -0,0 +1,108 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Load the shared GenSim environment configuration.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import MutableMapping + +__all__ = ["find_gen_sim_env_file", "get_embodichain_root", "load_gen_sim_env"] + + +def get_embodichain_root() -> Path: + """Return the repository containing the installed GenSim source tree. + + The Gradio app always launches its local tools from this directory. It is + derived from this module instead of a machine-specific dotenv value, so a + checkout continues to work after it is moved or cloned elsewhere. + """ + return Path(__file__).resolve().parents[2] + + +def find_gen_sim_env_file() -> Path | None: + """Return the configured shared ``.env`` file path. + + ``EMBODICHAIN_ENV_FILE`` is useful for deployments that keep secrets outside + the source tree. Otherwise GenSim uses ``embodichain/gen_sim/.env``. + + Returns: + The configured or default dotenv path, or ``None`` when neither exists. + """ + configured_path = os.environ.get("EMBODICHAIN_ENV_FILE") + if configured_path: + return Path(configured_path).expanduser().resolve() + default_path = Path(__file__).resolve().parent / ".env" + if default_path.is_file(): + return default_path + return None + + +def load_gen_sim_env(env: MutableMapping[str, str] | None = None) -> Path | None: + """Load missing variables from the shared GenSim ``.env`` file. + + Existing process environment variables are never overwritten so container, + CI, and shell-provided settings retain precedence over the local file. + + Args: + env: Environment mapping to populate. Defaults to :data:`os.environ`. + + Returns: + The loaded path, or ``None`` when no local ``.env`` file exists. + + Raises: + ValueError: If the file contains an invalid ``KEY=VALUE`` entry. + """ + target_env = os.environ if env is None else env + env_path = find_gen_sim_env_file() + if env_path is None or not env_path.is_file(): + return None + + for line_number, raw_line in enumerate( + env_path.read_text(encoding="utf-8").splitlines(), start=1 + ): + parsed = _parse_env_line(raw_line) + if parsed is None: + continue + key, value = parsed + if not key.isidentifier(): + raise ValueError( + f"Invalid environment variable name at {env_path}:{line_number}: {key!r}" + ) + target_env.setdefault(key, value) + return env_path + + +def _parse_env_line(line: str) -> tuple[str, str] | None: + """Parse one conventional dotenv line without requiring a third-party package.""" + stripped = line.strip() + if not stripped or stripped.startswith("#"): + return None + if stripped.startswith("export "): + stripped = stripped.removeprefix("export ").lstrip() + if "=" not in stripped: + raise ValueError(f"Expected KEY=VALUE entry, got: {line!r}") + + key, value = stripped.split("=", maxsplit=1) + key = key.strip() + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + value = value[1:-1] + elif " #" in value: + value = value.split(" #", maxsplit=1)[0].rstrip() + return key, value diff --git a/embodichain/gen_sim/gradio_ui/app_articraft.py b/embodichain/gen_sim/gradio_ui/app_articraft.py new file mode 100644 index 000000000..f1907845b --- /dev/null +++ b/embodichain/gen_sim/gradio_ui/app_articraft.py @@ -0,0 +1,1137 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Codex-backed Articraft generation for the Asset engine. + +The integration uses Articraft's external-agent workflow: Articraft owns +record creation and validation while Codex authors the generated model. All +mutable run data is kept under ``ARTICRAFT_OUTPUT_ROOT``. +""" + +from __future__ import annotations + +import atexit +import os +import queue +import json +import shutil +import html +import socket +import subprocess +import sys +import threading +import time +import uuid +from collections.abc import Iterator +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import gradio as gr + +from app_env import ( + ARTICRAFT_CONDA_ENV, + ARTICRAFT_OUTPUT_ROOT, + ARTICRAFT_REPOSITORY_URL, + ARTICRAFT_ROOT, + ARTICRAFT_VISER_PORT, + EMBODICHAIN_ROOT, + validate_gradio_artifact_root, +) +from app_processes import ( + SessionProcessRegistry, + build_codex_env, + build_pipeline_env, + get_request_session_id, + kill_process_group, + read_process_output, + redact_sensitive_text, + register_managed_process, + start_pipeline, + terminate_process_group, +) +from embodichain.gen_sim.env import find_gen_sim_env_file + +__all__ = [ + "build_articraft_panel", + "cleanup_articraft_session", + "configure_articraft_environment", + "generate_articraft_asset", + "reset_articraft_asset", + "stop_articraft_viser_preview", +] + +_VISER_START_TIMEOUT_SECONDS = 15.0 +_ARTICRAFT_PYTHON_VERSION = "3.12" +_CONDA_ENVIRONMENT_SETUP_TIMEOUT_SECONDS = 1_200 +_articraft_environment_lock = threading.Lock() +_articraft_runs = SessionProcessRegistry() +_ARTICRAFT_IDLE_PREVIEW = ( + "
" + "The interactive Viser articulation preview will appear here after generation." + "
" +) + + +def _run_articraft_generation_check( + command: list[str], *, session_id: str, token: str, timeout: int +) -> subprocess.CompletedProcess[str] | None: + """Run one Articraft CLI gate so Reset can stop its whole process group.""" + process = register_managed_process( + subprocess.Popen( + command, + cwd=ARTICRAFT_ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + start_new_session=True, + env=build_pipeline_env(), + ) + ) + if not _articraft_runs.attach(session_id, token, process): + terminate_process_group(process) + return None + try: + stdout, _ = process.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + terminate_process_group(process) + raise + finally: + _articraft_runs.finish(session_id, token, process) + if not _articraft_runs.is_active(session_id, token): + return None + return subprocess.CompletedProcess( + command, + process.returncode, + redact_sensitive_text(stdout or ""), + ) + + +def reset_articraft_asset(request: gr.Request) -> tuple[Any, ...]: + """Clear Articraft state and stop only the requesting session's processes. + + Args: + request: Gradio request for the browser session initiating Reset. + + Returns: + Reset values for all Articraft panel widgets. + """ + session_id = get_request_session_id(request) + cleanup_articraft_session(session_id) + return ( + "**Environment:** not checked.", + "", + None, + None, + "", + "**Status:** waiting for a description.", + "", + _ARTICRAFT_IDLE_PREVIEW, + ) + + +def cleanup_articraft_session(session_id: str) -> None: + """Stop Articraft generation and preview processes for one session. + + Args: + session_id: Stable Gradio session identifier. + """ + _articraft_runs.reset(session_id, force=True) + stop_articraft_viser_preview(session_id, force=True) + + +def _command_path(name: str) -> str | None: + """Resolve commands even when Gradio did not inherit an interactive PATH.""" + configured = os.environ.get(f"{name.upper()}_EXE") + return configured or shutil.which(name) + + +def _conda_path() -> str | None: + configured = os.environ.get("CONDA_EXE") + if configured and Path(configured).is_file(): + return configured + return _command_path("conda") + + +def _conda_command(*args: str) -> list[str]: + conda = _conda_path() + if not conda: + raise RuntimeError("Conda was not found. Set CONDA_EXE before starting Gradio.") + return [conda, "run", "--no-capture-output", "-n", ARTICRAFT_CONDA_ENV, *args] + + +def _articraft_cli_command(*args: str) -> list[str]: + """Run the CLI from the checked-out source without installing it with pip.""" + return _conda_command("python", "-m", "cli.main", *args) + + +def _articraft_conda_environment_exists() -> bool: + """Check only for the named Conda environment, not package installation.""" + conda = _conda_path() + if not conda: + return False + try: + result = subprocess.run( + [conda, "env", "list", "--json"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=30, + check=False, + ) + if result.returncode: + return False + environments = json.loads(result.stdout or "{}").get("envs", []) + return any(Path(path).name == ARTICRAFT_CONDA_ENV for path in environments) + except (OSError, json.JSONDecodeError, TypeError): + return False + + +def _ensure_articraft_conda_environment() -> tuple[bool, str]: + """Create and populate the Articraft Conda environment when it is absent. + + Articraft currently supports Python 3.11 and 3.12, while the Gradio process + can use a different interpreter. The setup therefore creates an isolated + Python 3.12 environment and installs the checked-out project's runtime + dependencies into it. + + Returns: + Whether the environment is ready and a status message suitable for the + Gradio configuration panel. + """ + conda = _conda_path() + if not conda: + return False, "Conda is not on PATH. Set CONDA_EXE to the conda executable." + + with _articraft_environment_lock: + if _articraft_conda_environment_exists(): + return True, f"Conda environment: {ARTICRAFT_CONDA_ENV} (already exists)" + + create_command = [ + conda, + "create", + "--yes", + "--name", + ARTICRAFT_CONDA_ENV, + f"python={_ARTICRAFT_PYTHON_VERSION}", + "pip", + ] + try: + created = subprocess.run( + create_command, + cwd=ARTICRAFT_ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=_CONDA_ENVIRONMENT_SETUP_TIMEOUT_SECONDS, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return False, f"Unable to create Conda environment: {exc}" + if created.returncode and not _articraft_conda_environment_exists(): + return False, ( + "Conda environment creation failed: " + f"{_short_output(created, limit=3000)}" + ) + + for install_args, description in ( + ( + ["python", "-m", "pip", "install", "--upgrade", "pip"], + "upgrade pip", + ), + (["python", "-m", "pip", "install", "."], "install Articraft dependencies"), + ): + install_command = [ + conda, + "run", + "--no-capture-output", + "--name", + ARTICRAFT_CONDA_ENV, + *install_args, + ] + try: + installed = subprocess.run( + install_command, + cwd=ARTICRAFT_ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=_CONDA_ENVIRONMENT_SETUP_TIMEOUT_SECONDS, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return False, f"Unable to {description}: {exc}" + if installed.returncode: + return ( + False, + f"Unable to {description}: {_short_output(installed, limit=3000)}", + ) + + return True, ( + f"Created Conda environment: {ARTICRAFT_CONDA_ENV} " + f"(Python {_ARTICRAFT_PYTHON_VERSION})" + ) + + +def _run_check( + command: list[str], *, timeout: int = 45 +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, + cwd=ARTICRAFT_ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=timeout, + check=False, + ) + + +def _short_output( + result: subprocess.CompletedProcess[str], *, limit: int = 1800 +) -> str: + output = (result.stdout or "").strip() + return output[-limit:] if len(output) > limit else (output or "(no output)") + + +def _check_requirements() -> tuple[list[str], list[str], str | None]: + """Return diagnostics and the Codex executable, without creating an asset.""" + errors: list[str] = [] + details: list[str] = [] + isolation_error = _articraft_isolation_error() + if isolation_error: + errors.append(isolation_error) + elif not ( + ARTICRAFT_ROOT.is_dir() + and (ARTICRAFT_ROOT / ".git").exists() + and (ARTICRAFT_ROOT / "pyproject.toml").is_file() + ): + errors.append(f".articraft checkout is not ready: {ARTICRAFT_ROOT}") + if not _conda_path(): + errors.append("Conda is not on PATH. Set CONDA_EXE to the conda executable.") + elif not _articraft_conda_environment_exists(): + errors.append(f"Conda environment not found: {ARTICRAFT_CONDA_ENV}") + else: + details.append(f"Conda environment: {ARTICRAFT_CONDA_ENV}") + + codex = _command_path("codex") + if not codex: + errors.append("Codex CLI is not on PATH. Install it or set CODEX_EXE.") + elif not errors: + try: + result = _run_check([codex, "--version"]) + if result.returncode: + errors.append(f"Codex CLI check failed: {_short_output(result)}") + else: + details.append(f"Codex: {_short_output(result, limit=120)}") + except Exception as exc: + errors.append(f"Codex CLI check failed: {exc}") + + if not errors: + details.append(f".articraft checkout: {ARTICRAFT_ROOT}") + return details, errors, codex + + +def _prepare_articraft_checkout() -> tuple[bool, str]: + """Clone the configured checkout when absent, without overwriting a directory.""" + if isolation_error := _articraft_isolation_error(): + return False, isolation_error + if ARTICRAFT_ROOT.exists(): + if (ARTICRAFT_ROOT / ".git").exists() and ( + ARTICRAFT_ROOT / "pyproject.toml" + ).is_file(): + return True, f".articraft checkout: {ARTICRAFT_ROOT}" + return ( + False, + f"{ARTICRAFT_ROOT} exists but is not an Articraft Git checkout; it was left untouched.", + ) + + git = _command_path("git") + if not git: + return False, "Git is not on PATH, so .articraft cannot be cloned." + try: + ARTICRAFT_ROOT.parent.mkdir(parents=True, exist_ok=True) + clone = subprocess.run( + [git, "clone", ARTICRAFT_REPOSITORY_URL, str(ARTICRAFT_ROOT)], + cwd=ARTICRAFT_ROOT.parent, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=300, + check=False, + ) + except Exception as exc: + return False, f"Unable to clone Articraft: {exc}" + if clone.returncode: + return False, f"Articraft clone failed: {_short_output(clone, limit=3000)}" + return True, f"Cloned .articraft from {ARTICRAFT_REPOSITORY_URL}" + + +def _articraft_isolation_error() -> str | None: + """Return an error when Codex roots could contain deployment secrets.""" + checkout = ARTICRAFT_ROOT.expanduser().resolve() + repository = EMBODICHAIN_ROOT.resolve() + if checkout == repository or repository.is_relative_to(checkout): + return ( + "ARTICRAFT_ROOT must be a dedicated nested or external Git checkout, " + "not the EmbodiChain repository or one of its parents." + ) + env_path = find_gen_sim_env_file() + if env_path is not None and env_path.resolve().is_relative_to(checkout): + return "ARTICRAFT_ROOT must not contain the shared GenSim dotenv file." + try: + output_root = validate_gradio_artifact_root(ARTICRAFT_OUTPUT_ROOT) + except ValueError as exc: + return str(exc) + if env_path is not None and env_path.resolve().is_relative_to(output_root): + return "ARTICRAFT_OUTPUT_ROOT must not contain the shared GenSim dotenv file." + return None + + +def configure_articraft_environment() -> str: + """Clone the checkout, prepare its Conda environment, and verify Codex.""" + checkout_ready, checkout_message = _prepare_articraft_checkout() + if not checkout_ready: + return "**Articulation is not ready.**\n\n- " + checkout_message + environment_ready, environment_message = _ensure_articraft_conda_environment() + if not environment_ready: + return "**Articulation is not ready.**\n\n- " + environment_message + try: + for directory in ( + ARTICRAFT_OUTPUT_ROOT, + ARTICRAFT_OUTPUT_ROOT / "runs", + ARTICRAFT_OUTPUT_ROOT / "exports", + ): + directory.mkdir(parents=True, exist_ok=True) + except Exception as exc: + return f"**Unable to prepare the shared Articulation output folder:** `{exc}`" + details, errors, _ = _check_requirements() + if errors: + return "**Articulation is not ready.**\n\n" + "\n".join( + f"- {error}" for error in errors + ) + details.insert(0, checkout_message) + details.insert(1, environment_message) + details.extend( + ( + f"Shared output: `{ARTICRAFT_OUTPUT_ROOT}`", + "Generation runs the `.articraft` checkout directly with `conda run`; no `pip install -e .` is required.", + ) + ) + return "**Articulation is ready.**\n\n" + "\n".join( + f"- {detail}" for detail in details + ) + + +def _record_id() -> str: + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + # Articraft validates external IDs against the required ``rec_`` prefix. + return f"rec_ui_articraft_{timestamp}_{uuid.uuid4().hex[:8]}" + + +def _copy_reference_image(value: Any, run_root: Path) -> Path | None: + if not value: + return None + source = Path(str(value)) + if not source.is_file(): + raise ValueError( + "The reference image is no longer available; please upload it again." + ) + suffix = source.suffix.lower() or ".png" + if suffix not in {".png", ".jpg", ".jpeg", ".webp"}: + raise ValueError("Reference image must be PNG, JPG, JPEG, or WEBP.") + target = run_root / f"reference{suffix}" + shutil.copy2(source, target) + return target + + +def _active_model_path(record_dir: Path) -> Path: + candidates = sorted(record_dir.glob("revisions/*/model.py")) + if len(candidates) != 1: + raise FileNotFoundError( + f"Expected one active model.py in {record_dir}, found {len(candidates)}." + ) + return candidates[0] + + +def _make_result_bundle(record_id: str) -> tuple[Path, Path]: + materialized = ( + ARTICRAFT_OUTPUT_ROOT / "data" / "cache" / "record_materialization" / record_id + ) + if not (materialized / "model.urdf").is_file(): + raise FileNotFoundError( + "Articraft completed without a compiled model.urdf output." + ) + exports_root = ARTICRAFT_OUTPUT_ROOT / "exports" + exports_root.mkdir(parents=True, exist_ok=True) + archive = Path( + shutil.make_archive( + (exports_root / record_id).as_posix(), + "zip", + root_dir=materialized, + ) + ) + return materialized, archive + + +def _articraft_viser_iframe(record_id: str, port: int) -> str: + """Embed the Articulation Viser service through the Gradio page hostname.""" + srcdoc = ( + "" + ) + escaped_record_id = html.escape(record_id) + return ( + "
Viser articulation preview: " + f"{escaped_record_id}" + f"" + "
" + ) + + +class _ArticraftViserPreview: + """Own an isolated Articraft Viser process for each Gradio session.""" + + def __init__(self, preferred_port: int) -> None: + self._preferred_port = preferred_port + self._lock = threading.Lock() + self._processes: dict[str, tuple[subprocess.Popen[str], int]] = {} + + def start(self, session_id: str, urdf_path: Path, record_id: str) -> str: + """Replace one session's preview with a verified preview of one URDF.""" + if not urdf_path.is_file(): + raise FileNotFoundError(f"Compiled URDF is missing: {urdf_path}") + + with self._lock: + previous = self._processes.pop(session_id, None) + if previous is not None: + terminate_process_group(previous[0]) + port = self._select_available_port() + process = start_pipeline(self._command(urdf_path, port)) + if not self._wait_until_owned(process, port): + terminate_process_group(process) + raise RuntimeError("New Articraft Viser preview did not bind its port.") + self._processes[session_id] = (process, port) + return _articraft_viser_iframe(record_id, port) + + def stop(self, session_id: str | None = None, *, force: bool = False) -> None: + """Stop one session's preview, or every preview during shutdown.""" + with self._lock: + if session_id is None: + processes = tuple( + process for process, _port in self._processes.values() + ) + self._processes.clear() + else: + current = self._processes.pop(session_id, None) + processes = () if current is None else (current[0],) + stop_process = kill_process_group if force else terminate_process_group + for process in processes: + stop_process(process) + + def _command(self, urdf_path: Path, port: int) -> list[str]: + return [ + sys.executable, + str(Path(__file__).with_name("app_media.py")), + "--asset_path", + str(urdf_path), + "--asset_type", + "articulation", + "--headless", + "--viser", + "--viser-host", + "0.0.0.0", + "--viser-port", + str(port), + ] + + def _wait_until_owned(self, process: subprocess.Popen[str], port: int) -> bool: + deadline = time.monotonic() + _VISER_START_TIMEOUT_SECONDS + while time.monotonic() < deadline: + if process.poll() is not None: + return False + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + return True + except OSError: + pass + time.sleep(0.25) + return False + + def _select_available_port(self) -> int: + if self._port_is_available(self._preferred_port): + return self._preferred_port + probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + probe.bind(("0.0.0.0", 0)) + return int(probe.getsockname()[1]) + finally: + probe.close() + + @staticmethod + def _port_is_available(port: int) -> bool: + probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + probe.bind(("0.0.0.0", port)) + except OSError: + return False + finally: + probe.close() + return True + + +_articraft_viser_preview = _ArticraftViserPreview(ARTICRAFT_VISER_PORT) + + +def stop_articraft_viser_preview( + session_id: str | None = None, + *, + force: bool = False, +) -> None: + """Stop the Viser subprocess currently owned by the Articraft panel. + + The preview runs independently from Gradio so it can be embedded through an + iframe. Expose its cleanup explicitly so application shutdown can release + the dedicated port instead of leaving an orphaned Viser server behind. + + Args: + session_id: Optional owning Gradio session. ``None`` stops all previews. + force: Whether to immediately send ``SIGKILL`` for interactive cleanup. + """ + _articraft_viser_preview.stop(session_id, force=force) + + +atexit.register(stop_articraft_viser_preview) + + +def _start_articraft_viser_preview( + session_id: str, materialized: Path, record_id: str +) -> str: + """Load the compiled URDF as an articulation and expose it through Viser.""" + return _articraft_viser_preview.start( + session_id, + materialized / "model.urdf", + record_id, + ) + + +def _external_check_is_unsupported(result: subprocess.CompletedProcess[str]) -> bool: + """Recognize the older Articraft CLI, which has no ``external check``.""" + output = (result.stdout or "").lower() + return "invalid choice: 'check'" in output and "external" in output + + +def _compile_report_failures(record_id: str) -> list[str]: + """Read blocking QC/test signals from the older CLI's compile report.""" + report_path = ( + ARTICRAFT_OUTPUT_ROOT + / "data" + / "cache" + / "record_materialization" + / record_id + / "compile_report.json" + ) + try: + report = json.loads(report_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return [f"Compile report is unavailable: {report_path}"] + bundle = report.get("signal_bundle") if isinstance(report, dict) else None + signals = bundle.get("signals") if isinstance(bundle, dict) else None + if not isinstance(signals, list): + return ["Compile report contains no validation signals."] + failures: list[str] = [] + for signal in signals: + if not isinstance(signal, dict): + continue + if signal.get("severity") == "failure" or signal.get("blocking") is True: + failures.append( + str( + signal.get("summary") + or signal.get("code") + or "Unnamed validation failure" + ) + ) + return failures + + +def _build_codex_prompt( + *, + prompt: str, + record_id: str, + record_dir: Path, + model_path: Path, + reference_image: Path | None, +) -> str: + image_note = ( + f"A reference image is attached and also copied at {reference_image}. Use it as visual reference." + if reference_image + else "No reference image was supplied." + ) + return f"""You are the Codex external author for one Articraft articulated 3D asset. + +User request: +{prompt} + +{image_note} + +The Articraft source repository is {ARTICRAFT_ROOT}. The shared UI output/storage root is +{ARTICRAFT_OUTPUT_ROOT}. Articraft has already created this external workbench record: +record_id={record_id} +record_dir={record_dir} +active_model={model_path} + +Codex itself is launched from the Gradio environment, not the Articraft Conda environment. +For every Articraft CLI invocation, use this command prefix: + +{_conda_path()} run --no-capture-output -n {ARTICRAFT_CONDA_ENV} python -m cli.main + +Follow EXTERNAL_AGENT_DATA.md exactly. Read the design and link-naming guidance it references, +then use relevant SDK docs/examples. Edit only the active model.py for this record. Do not create +record folders or metadata manually, edit unrelated records, commit/push, or promote this +workbench record to the dataset. + +Create a realistic mechanically meaningful articulated object matching the request. Use semantic +parts, visible plausible joints, appropriate materials, and prompt-specific run_tests(). Iterate +until this succeeds: + +{_conda_path()} run --no-capture-output -n {ARTICRAFT_CONDA_ENV} python -m cli.main external --repo-root {ARTICRAFT_OUTPUT_ROOT} check {record_id} + +Then run: + +{_conda_path()} run --no-capture-output -n {ARTICRAFT_CONDA_ENV} python -m cli.main external --repo-root {ARTICRAFT_OUTPUT_ROOT} finalize {record_id} + +The Gradio app packages the compiled URDF and meshes after you finish. In your final response, +briefly state the articulation mechanisms and validation result.""" + + +def generate_articraft_asset( + prompt_value: str, + image_value: Any, + request: gr.Request, +) -> Iterator[tuple[Any, ...]]: + """Initialize a record, let Codex author it, and expose one result bundle. + + Args: + prompt_value: Requested articulated-object description. + image_value: Optional Gradio reference-image value. + request: Gradio request identifying the owning browser session. + + Yields: + Updated artifact, status, log, and Viser preview values for the panel. + """ + session_id = get_request_session_id(request) + token = _articraft_runs.begin(session_id) + prompt = (prompt_value or "").strip() + if not prompt: + if _articraft_runs.is_active(session_id, token): + yield None, "", "**Input error:** enter a description of the articulated object.", "", "" + return + + details, errors, codex = _check_requirements() + if errors or not codex: + message = ( + "\n".join(f"- {error}" for error in errors) or "Codex CLI is unavailable." + ) + if _articraft_runs.is_active(session_id, token): + yield None, "", f"**Articulation is not ready.**\n\n{message}", "", "" + return + + record_id = _record_id() + run_root = ARTICRAFT_OUTPUT_ROOT / "runs" / record_id + record_dir = ARTICRAFT_OUTPUT_ROOT / "data" / "records" / record_id + log_lines = [*details, f"Shared output: {ARTICRAFT_OUTPUT_ROOT}"] + try: + run_root.mkdir(parents=True, exist_ok=False) + reference_image = _copy_reference_image(image_value, run_root) + init_command = _articraft_cli_command( + "external", + "--repo-root", + str(ARTICRAFT_OUTPUT_ROOT), + "init", + "--agent", + "codex", + "--record-id", + record_id, + prompt, + ) + log_lines.append("$ " + " ".join(init_command[:-1]) + " ") + initialized = _run_articraft_generation_check( + init_command, + session_id=session_id, + token=token, + timeout=90, + ) + if initialized is None: + return + log_lines.append(_short_output(initialized, limit=4000)) + if initialized.returncode: + yield None, "", "**Articraft record initialization failed.**", "\n".join( + log_lines + ), "" + return + model_path = _active_model_path(record_dir) + except Exception as exc: + if _articraft_runs.is_active(session_id, token): + yield None, "", f"**Setup failed:** {exc}", "\n".join(log_lines), "" + return + + if not _articraft_runs.is_active(session_id, token): + return + + final_message = run_root / "codex_final_message.txt" + codex_command = [ + codex, + "exec", + "--sandbox", + "workspace-write", + "--ephemeral", + "--ignore-user-config", + "--ignore-rules", + "-c", + 'web_search="disabled"', + "--color", + "never", + "-C", + str(ARTICRAFT_ROOT), + "--add-dir", + str(ARTICRAFT_OUTPUT_ROOT), + "--output-last-message", + str(final_message), + ] + if reference_image: + codex_command.extend(["--image", str(reference_image)]) + codex_command.append( + _build_codex_prompt( + prompt=prompt, + record_id=record_id, + record_dir=record_dir, + model_path=model_path, + reference_image=reference_image, + ) + ) + log_lines.append("$ codex exec --sandbox workspace-write …") + yield None, record_dir.as_posix(), "**Codex is generating and validating the Articraft model…**", "\n".join( + log_lines + ), "" + + try: + process = register_managed_process( + subprocess.Popen( + codex_command, + cwd=ARTICRAFT_ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + start_new_session=True, + env=build_codex_env(), + ) + ) + if not _articraft_runs.attach(session_id, token, process): + terminate_process_group(process) + return + except Exception as exc: + if _articraft_runs.is_active(session_id, token): + yield None, record_dir.as_posix(), f"**Codex could not start:** {exc}", "\n".join( + log_lines + ), "" + return + + output_queue: queue.Queue[str] = queue.Queue() + reader = threading.Thread( + target=read_process_output, + args=(process, output_queue), + kwargs={"redact_sensitive": True}, + daemon=True, + ) + reader.start() + while process.poll() is None: + if not _articraft_runs.is_active(session_id, token, process): + return + try: + while True: + log_lines.append(output_queue.get_nowait()) + except queue.Empty: + pass + yield None, record_dir.as_posix(), "**Codex is generating and validating the Articraft model…**", "\n".join( + log_lines[-240:] + ), "" + time.sleep(0.75) + try: + reader.join(timeout=2) + try: + while True: + log_lines.append(output_queue.get_nowait()) + except queue.Empty: + pass + finally: + _articraft_runs.finish(session_id, token, process) + + if not _articraft_runs.is_active(session_id, token): + return + + if final_message.is_file(): + final_text = final_message.read_text(encoding="utf-8", errors="replace").strip() + if final_text: + log_lines.append( + "\nCodex final response:\n" + redact_sensitive_text(final_text) + ) + if process.returncode: + yield None, record_dir.as_posix(), f"**Codex generation failed** (exit code {process.returncode}).", "\n".join( + log_lines[-300:] + ), "" + return + + # Do not rely solely on Codex's final message: independently run the + # external validation and finalize gates before exposing an output bundle. + check_command = _articraft_cli_command( + "external", + "--repo-root", + str(ARTICRAFT_OUTPUT_ROOT), + "check", + record_id, + ) + log_lines.append("$ " + " ".join(check_command)) + yield ( + None, + record_dir.as_posix(), + "**Codex finished. Articraft is running the final validation gate…**", + "\n".join(log_lines[-300:]), + "", + ) + try: + checked = _run_articraft_generation_check( + check_command, + session_id=session_id, + token=token, + timeout=300, + ) + if checked is None: + return + log_lines.append(_short_output(checked, limit=5000)) + except Exception as exc: + yield ( + None, + record_dir.as_posix(), + f"**Final Articraft validation could not run:** {exc}", + "\n".join(log_lines[-300:]), + "", + ) + return + if checked.returncode: + if not _external_check_is_unsupported(checked): + yield ( + None, + record_dir.as_posix(), + "**Articraft validation failed; no output bundle was published.**", + "\n".join(log_lines[-300:]), + "", + ) + return + # The older CLI reports external init/finalize/categories only. Its + # equivalent strict model validation is the top-level compile command. + compile_command = _articraft_cli_command( + "compile", + "--repo-root", + str(ARTICRAFT_OUTPUT_ROOT), + "--target", + "full", + "--validate", + "--strict-geom-qc", + record_id, + ) + log_lines.append( + "external check is unavailable; falling back to compile --validate." + ) + log_lines.append("$ " + " ".join(compile_command)) + yield ( + None, + record_dir.as_posix(), + "**Using this Articraft version's compile validation gate…**", + "\n".join(log_lines[-300:]), + "", + ) + try: + compiled = _run_articraft_generation_check( + compile_command, + session_id=session_id, + token=token, + timeout=300, + ) + if compiled is None: + return + log_lines.append(_short_output(compiled, limit=5000)) + except Exception as exc: + yield ( + None, + record_dir.as_posix(), + f"**Fallback Articraft validation could not run:** {exc}", + "\n".join(log_lines[-300:]), + "", + ) + return + if compiled.returncode: + yield ( + None, + record_dir.as_posix(), + "**Articraft validation failed; no output bundle was published.**", + "\n".join(log_lines[-300:]), + "", + ) + return + failures = _compile_report_failures(record_id) + if failures: + log_lines.append("Blocking compile-report failures: " + "; ".join(failures)) + yield ( + None, + record_dir.as_posix(), + "**Articraft validation found blocking model defects; no output bundle was published.**", + "\n".join(log_lines[-300:]), + "", + ) + return + + finalize_command = _articraft_cli_command( + "external", + "--repo-root", + str(ARTICRAFT_OUTPUT_ROOT), + "finalize", + record_id, + ) + log_lines.append("$ " + " ".join(finalize_command)) + try: + finalized = _run_articraft_generation_check( + finalize_command, + session_id=session_id, + token=token, + timeout=300, + ) + if finalized is None: + return + log_lines.append(_short_output(finalized, limit=5000)) + except Exception as exc: + yield ( + None, + record_dir.as_posix(), + f"**Articraft finalization could not run:** {exc}", + "\n".join(log_lines[-300:]), + "", + ) + return + if finalized.returncode: + yield ( + None, + record_dir.as_posix(), + "**Articraft finalization failed; no output bundle was published.**", + "\n".join(log_lines[-300:]), + "", + ) + return + + if not _articraft_runs.is_active(session_id, token): + return + try: + materialized, archive = _make_result_bundle(record_id) + status = ( + "**Articraft generation completed and passed the Codex validation workflow.**\n\n" + f"- Record: `{record_dir}`\n- Compiled output: `{materialized}`\n- Downloadable bundle: `{archive}`" + ) + try: + preview_html = _start_articraft_viser_preview( + session_id, + materialized, + record_id, + ) + status += "\n- Interactive Viser preview: ready" + except Exception as exc: + preview_html = "" + status += f"\n- Interactive Viser preview could not start: `{exc}`" + log_lines.append(f"Viser preview failed: {exc}") + yield archive.as_posix(), record_dir.as_posix(), status, "\n".join( + log_lines[-300:] + ), preview_html + except Exception as exc: + yield None, record_dir.as_posix(), f"**Codex finished, but result packaging failed:** {exc}", "\n".join( + log_lines[-300:] + ), "" + + +def build_articraft_panel() -> None: + """Render the Articraft tab inside the Asset engine.""" + gr.Markdown( + "### Articulation\n" + "Generate an articulated object from text and an optional reference image. Codex writes and validates the Articraft model; only submit trusted requests." + ) + with gr.Row(): + configure_button = gr.Button("Configure Articulation & check Codex") + generate_button = gr.Button("Generate articulation", variant="primary") + reset_button = gr.Button("Reset Articulation", variant="stop") + environment_status = gr.Markdown("**Environment:** not checked.") + with gr.Row(): + prompt = gr.Textbox( + label="Articulated object description", + lines=5, + placeholder="e.g. A countertop toaster oven with a hinged door and rotating temperature knob.", + ) + image = gr.Image( + label="Optional reference image", + type="filepath", + image_mode="RGB", + sources=["upload"], + ) + with gr.Row(): + output_file = gr.File( + label="Compiled Articulation result bundle (.zip)", interactive=False + ) + record_folder = gr.Textbox( + label="Articulation record folder", interactive=False + ) + articulation_preview = gr.HTML(_ARTICRAFT_IDLE_PREVIEW) + generation_status = gr.Markdown("**Status:** waiting for a description.") + generation_log = gr.Textbox( + label="Codex / Articraft log", lines=14, interactive=False + ) + + configure_button.click( + configure_articraft_environment, outputs=[environment_status], queue=False + ) + generate_button.click( + generate_articraft_asset, + inputs=[prompt, image], + outputs=[ + output_file, + record_folder, + generation_status, + generation_log, + articulation_preview, + ], + ) + reset_button.click( + reset_articraft_asset, + outputs=[ + environment_status, + prompt, + image, + output_file, + record_folder, + generation_status, + generation_log, + articulation_preview, + ], + queue=False, + ) diff --git a/embodichain/gen_sim/gradio_ui/app_asset_engine.py b/embodichain/gen_sim/gradio_ui/app_asset_engine.py new file mode 100644 index 000000000..b3bd2a7be --- /dev/null +++ b/embodichain/gen_sim/gradio_ui/app_asset_engine.py @@ -0,0 +1,369 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Standalone SimReady asset-engine workflow used by the engine workspace. + +The upstream SimReady CLI works on a directory, while Gradio uploads files. +This adapter creates an isolated directory for every run, keeps material +sidecars together with the mesh, and exposes GLB previews before and after +processing. It deliberately has no DexSim dependency. +""" + +from __future__ import annotations + +import queue +import shutil +import sys +import time +import uuid +from collections.abc import Iterator +from pathlib import Path +from typing import Any, Iterable + +import gradio as gr +import trimesh + +from app_articraft import build_articraft_panel, cleanup_articraft_session +from app_config import GEN_SIM_ASSET_ROOT, SIMREADY_MESH_SUFFIXES +from app_processes import ( + SessionProcessRegistry, + get_request_session_id, + read_process_output, + start_pipeline, + terminate_process_group, +) + +__all__ = [ + "build_asset_engine_panel", + "cleanup_asset_engine_session", + "prepare_asset_input_preview", + "reset_simready_asset", + "run_simready_asset", +] + +_simready_runs = SessionProcessRegistry() +_SIMREADY_IDLE_STATUS = "**Status:** waiting for an asset." + + +def reset_simready_asset( + request: gr.Request, +) -> tuple[None, str, None, None, None, str, str]: + """Clear SimReady widgets and stop only the requesting session's run. + + Args: + request: Gradio request for the browser session initiating Reset. + + Returns: + Reset values for the SimReady panel widgets. + """ + _simready_runs.reset(get_request_session_id(request), force=True) + return None, "rigid_object", None, None, None, _SIMREADY_IDLE_STATUS, "" + + +def cleanup_asset_engine_session(request: gr.Request) -> None: + """Stop Asset-engine subprocesses owned by a disconnected session. + + Args: + request: Gradio request for the disconnecting browser session. + """ + session_id = get_request_session_id(request) + _simready_runs.reset(session_id, force=True) + cleanup_articraft_session(session_id) + + +def _as_paths(value: Any) -> list[Path]: + if value is None: + return [] + values: Iterable[Any] = value if isinstance(value, (list, tuple)) else [value] + paths: list[Path] = [] + for item in values: + if isinstance(item, str): + paths.append(Path(item)) + elif isinstance(item, dict) and item.get("path"): + paths.append(Path(item["path"])) + return [path for path in paths if path.is_file()] + + +def _mesh_path(paths: Iterable[Path]) -> Path: + meshes = [path for path in paths if path.suffix.lower() in SIMREADY_MESH_SUFFIXES] + if not meshes: + supported = ", ".join(sorted(SIMREADY_MESH_SUFFIXES)) + raise ValueError( + f"Upload one mesh file ({supported}) and optional material files." + ) + return meshes[0] + + +def _safe_copy_uploads(upload_paths: list[Path], destination: Path) -> Path: + destination.mkdir(parents=True, exist_ok=False) + copied: list[Path] = [] + for index, source in enumerate(upload_paths): + # Upload file names are untrusted. Keep only their basename and avoid + # collisions without ever interpreting a supplied relative path. + name = source.name or f"upload_{index}" + target = destination / name + if target.exists(): + target = destination / f"{target.stem}_{index}{target.suffix}" + shutil.copy2(source, target) + copied.append(target) + return _mesh_path(copied) + + +def _export_preview(mesh_path: Path, destination: Path) -> Path: + """Convert every supported mesh type to GLB for one consistent viewer.""" + loaded = trimesh.load(mesh_path, force="scene", process=False) + if isinstance(loaded, trimesh.Trimesh): + scene = trimesh.Scene(loaded) + elif isinstance(loaded, trimesh.Scene): + scene = loaded + else: + raise ValueError(f"Unsupported mesh payload: {type(loaded)!r}") + if not scene.geometry: + raise ValueError("The uploaded asset contains no renderable geometry.") + destination.parent.mkdir(parents=True, exist_ok=True) + scene.export(destination) + return destination + + +def prepare_asset_input_preview(upload_value: Any): + """Validate an upload and return a normalized GLB preview without running SimReady.""" + try: + source = _mesh_path(_as_paths(upload_value)) + preview = GEN_SIM_ASSET_ROOT / "previews" / f"{uuid.uuid4().hex}.glb" + _export_preview(source, preview) + return ( + preview.as_posix(), + "**Asset input ready.** Review the model, then run SimReady.", + ) + except Exception as exc: + return None, f"**Input error:** {exc}" + + +def _find_simready_output(output_root: Path) -> Path: + candidates = sorted( + output_root.rglob("asset_simready.glb"), + key=lambda path: path.stat().st_mtime_ns, + reverse=True, + ) + if not candidates: + candidates = sorted( + output_root.rglob("asset_simready.obj"), + key=lambda path: path.stat().st_mtime_ns, + reverse=True, + ) + if not candidates: + raise FileNotFoundError( + "SimReady completed without asset_simready.glb or asset_simready.obj." + ) + return candidates[0] + + +def run_simready_asset( + upload_value: Any, + category: str, + request: gr.Request, +) -> Iterator[tuple[Any, ...]]: + """Run one upstream SimReady job and stream concise subprocess progress. + + Args: + upload_value: Gradio upload value containing the asset and sidecars. + category: SimReady asset category. + request: Gradio request identifying the owning browser session. + + Yields: + Updated preview, output, status, and log values for the panel. + """ + session_id = get_request_session_id(request) + token = _simready_runs.begin(session_id) + category = (category or "").strip() + if not category: + if _simready_runs.is_active(session_id, token): + yield None, None, None, "**Input error:** enter an asset category.", "" + return + try: + uploads = _as_paths(upload_value) + _mesh_path(uploads) + run_root = GEN_SIM_ASSET_ROOT / "runs" / uuid.uuid4().hex + input_dir = run_root / "input" + output_root = run_root / "output" + source_mesh = _safe_copy_uploads(uploads, input_dir) + input_preview = _export_preview(source_mesh, run_root / "input_preview.glb") + except Exception as exc: + if _simready_runs.is_active(session_id, token): + yield None, None, None, f"**Input error:** {exc}", "" + return + + command = [ + sys.executable, + "-m", + "embodichain.gen_sim.simready_pipeline.cli.start", + "--input_dir", + str(input_dir), + "--output_root", + str(output_root), + "--category", + category, + ] + log_lines = ["$ " + " ".join(command)] + if not _simready_runs.is_active(session_id, token): + return + yield input_preview.as_posix(), None, None, "**SimReady is running…**", "\n".join( + log_lines + ) + + try: + process = start_pipeline(command, use_simready_llm=True) + except Exception as exc: + if _simready_runs.is_active(session_id, token): + yield input_preview.as_posix(), None, None, f"**Pipeline start failed:** {exc}", "\n".join( + log_lines + ) + return + + if not _simready_runs.attach(session_id, token, process): + terminate_process_group(process) + return + + try: + output_queue: queue.Queue[str] = queue.Queue() + reader = threading.Thread( + target=read_process_output, args=(process, output_queue), daemon=True + ) + reader.start() + while process.poll() is None: + if not _simready_runs.is_active(session_id, token, process): + return + try: + while True: + log_lines.append(output_queue.get_nowait()) + except queue.Empty: + pass + # Keep the browser responsive while the Blender/LLM stages run. + yield input_preview.as_posix(), None, None, "**SimReady is running…**", "\n".join( + log_lines[-160:] + ) + time.sleep(0.5) + reader.join(timeout=1) + try: + while True: + log_lines.append(output_queue.get_nowait()) + except queue.Empty: + pass + if not _simready_runs.is_active(session_id, token, process): + return + + if process.returncode != 0: + yield input_preview.as_posix(), None, None, f"**SimReady failed** (exit code {process.returncode}).", "\n".join( + log_lines[-220:] + ) + return + try: + result = _find_simready_output(output_root) + preview = ( + result + if result.suffix.lower() == ".glb" + else _export_preview(result, run_root / "output_preview.glb") + ) + yield input_preview.as_posix(), preview.as_posix(), result.as_posix(), "**SimReady completed.**", "\n".join( + log_lines[-220:] + ) + except Exception as exc: + yield input_preview.as_posix(), None, None, f"**Output error:** {exc}", "\n".join( + log_lines[-220:] + ) + finally: + _simready_runs.finish(session_id, token, process) + + +def build_asset_engine_panel() -> dict[str, Any]: + """Create the Asset-engine panel and return its event endpoints.""" + with gr.Column(visible=True) as panel: + gr.Markdown( + "## Asset engine\nConvert an existing mesh with SimReady, or generate a new articulated asset through Articraft and Codex. DexSim is not started in this engine." + ) + with gr.Tabs(): + with gr.Tab("SimReady"): + with gr.Row(): + uploads = gr.File( + label="3D asset and optional material files", + file_count="multiple", + type="filepath", + file_types=[ + ".glb", + ".gltf", + ".obj", + ".ply", + ".stl", + ".mtl", + ".png", + ".jpg", + ".jpeg", + ".webp", + ".bin", + ], + ) + category = gr.Textbox( + label="Asset category", + value="rigid_object", + placeholder="e.g. cup, chair, bottle", + ) + with gr.Row(): + input_model = gr.Model3D( + label="Input asset preview", + height=440, + clear_color=(0.94, 0.94, 0.94, 1.0), + ) + output_model = gr.Model3D( + label="SimReady asset preview", + height=440, + clear_color=(0.94, 0.94, 0.94, 1.0), + ) + with gr.Row(): + run_button = gr.Button("Run SimReady", variant="primary") + reset_button = gr.Button("Reset SimReady", variant="stop") + output_file = gr.File( + label="SimReady asset output", interactive=False + ) + status = gr.Markdown(_SIMREADY_IDLE_STATUS) + log = gr.Textbox(label="Pipeline log", lines=10, interactive=False) + with gr.Tab("Articulation"): + build_articraft_panel() + + uploads.change( + prepare_asset_input_preview, + inputs=[uploads], + outputs=[input_model, status], + queue=False, + ) + run_button.click( + run_simready_asset, + inputs=[uploads, category], + outputs=[input_model, output_model, output_file, status, log], + ) + reset_button.click( + reset_simready_asset, + outputs=[ + uploads, + category, + input_model, + output_model, + output_file, + status, + log, + ], + queue=False, + ) + return {"panel": panel} diff --git a/embodichain/gen_sim/gradio_ui/app_commands.py b/embodichain/gen_sim/gradio_ui/app_commands.py new file mode 100644 index 000000000..1c14861b2 --- /dev/null +++ b/embodichain/gen_sim/gradio_ui/app_commands.py @@ -0,0 +1,67 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""CLI command builder for the Action engine.""" + +from __future__ import annotations + +import sys + +from app_config import ( + AGENT_CONFIG, + COMMANDS, + FAST_GYM_CONFIG, + ROBOT_PROFILE_FRANKA, + ROBOT_PROFILE_UR5, + ROBOT_PROFILE_UR10, + SCENE_ID, +) + +__all__ = ["build_run_agent_command"] + + +def _robot_profile_cli_value(robot_profile: str | None) -> str | None: + return { + ROBOT_PROFILE_FRANKA: "franka", + ROBOT_PROFILE_UR5: "dual_ur5", + ROBOT_PROFILE_UR10: "dual_ur10", + }.get(robot_profile) + + +def build_run_agent_command( + *, + robot_profile: str | None = None, + supports_robot_profile: bool = False, +) -> list[str]: + """Build the DexSim command for the existing ``current`` Gym scene.""" + agent = COMMANDS["agent"] + command = [ + sys.executable, + "-m", + agent["module"], + "--task_name", + SCENE_ID, + "--gym_config", + str(FAST_GYM_CONFIG), + "--agent_config", + str(AGENT_CONFIG), + *agent["base_args"], + "--num_envs", + agent["single_num_envs"], + ] + if supports_robot_profile and (profile := _robot_profile_cli_value(robot_profile)): + command.extend(["--robot-profile", profile]) + return command diff --git a/embodichain/gen_sim/gradio_ui/app_config.py b/embodichain/gen_sim/gradio_ui/app_config.py new file mode 100644 index 000000000..06f696e01 --- /dev/null +++ b/embodichain/gen_sim/gradio_ui/app_config.py @@ -0,0 +1,98 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Static settings for the engine-only Gradio application.""" + +from __future__ import annotations + +from pathlib import Path + +import app_env + +APP_ROOT = Path(__file__).resolve().parent +ASSETS_DIR = APP_ROOT / "assets" +DEXFORCE_LOGO = ASSETS_DIR / "dexforce.png" +GEN_SIM_ROOT = APP_ROOT / ".gen_sim" +GEN_SIM_ASSET_ROOT = GEN_SIM_ROOT / "assets" +GEN_SIM_SCENE_ROOT = GEN_SIM_ROOT / "scenes" + +SCENE_ID = "current" +GYM_PROJECT_ROOT = app_env.EMBODICHAIN_ROOT / "gym_project" +ACTION_AGENT_ROOT = GYM_PROJECT_ROOT / "action_agent_pipeline" +CONFIG_DIR = ACTION_AGENT_ROOT / "configs" / SCENE_ID +FAST_GYM_CONFIG = CONFIG_DIR / "fast_gym_config.json" +AGENT_CONFIG = CONFIG_DIR / "agent_config.json" + +OUTPUTS_DIR = app_env.EMBODICHAIN_ROOT / "outputs" +VIDEO_SUFFIXES = {".mp4", ".avi", ".mov", ".mkv", ".webm"} + +PROCESS_STOP_TIMEOUT_S = 8.0 +DEFAULT_CONCURRENCY_LIMIT = 1 + +DEBUG_ENGINE_ASSET = "asset_engine" +DEBUG_ENGINE_SCENE = "scene_engine" +DEBUG_ENGINE_ACTION = "action_engine" +DEBUG_ENGINES = ( + (DEBUG_ENGINE_ASSET, "Asset_engine"), + (DEBUG_ENGINE_SCENE, "Scene_engine"), + (DEBUG_ENGINE_ACTION, "Action_engine"), +) + +SIMREADY_MESH_SUFFIXES = {".glb", ".gltf", ".obj", ".ply", ".stl"} + +LANGUAGE_EN = "en" +UI_TEXT = { + LANGUAGE_EN: { + "robot": "Robot", + "input_image": "Input image", + "single_video_preview": "DexSim Video Preview", + "current_task": "Current task", + "progress": "Progress", + } +} + +ROBOT_PROFILE_FRANKA = "Franka" +ROBOT_PROFILE_UR5 = "UR5" +ROBOT_PROFILE_UR10 = "UR10" +ROBOT_PROFILES = [ROBOT_PROFILE_FRANKA, ROBOT_PROFILE_UR5, ROBOT_PROFILE_UR10] +DEFAULT_ROBOT_PROFILE = ROBOT_PROFILE_UR5 + +COMMANDS = { + "agent": { + "module": "embodichain.gen_sim.action_agent_pipeline.cli.run_agent", + "help_args": ("--help",), + "base_args": ("--regenerate", "--renderer", "fast-rt"), + "single_num_envs": "1", + }, + "scene_engine": { + "module": "embodichain", + "base_args": ("scene-engine",), + "preview_script": "embodichain/gen_sim/scene_engine/cli/preview.py", + }, +} + +PHASE_DEFINITIONS = { + "idle": (0, "Idle"), + "received": (5, "Input received"), + "started": (10, "Scene generation started"), + "scene_intake": (20, "Scene understanding"), + "relations": (35, "Scene segmentation"), + "asset_generation": (55, "Geometry generation"), + "gym_export": (75, "Scene export"), + "preview": (90, "Preview generation"), + "complete": (100, "Complete"), + "failed": (100, "Failed"), +} diff --git a/embodichain/gen_sim/gradio_ui/app_env.py b/embodichain/gen_sim/gradio_ui/app_env.py new file mode 100644 index 000000000..cbba7de1f --- /dev/null +++ b/embodichain/gen_sim/gradio_ui/app_env.py @@ -0,0 +1,216 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Environment-backed deployment settings for the Gradio application.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +from embodichain.gen_sim.env import get_embodichain_root, load_gen_sim_env + +__all__ = [ + "ACTION_ENGINE_VISER_PORT", + "ARTICRAFT_CONDA_ENV", + "ARTICRAFT_OUTPUT_ROOT", + "ARTICRAFT_REPOSITORY_URL", + "ARTICRAFT_ROOT", + "ARTICRAFT_VISER_PORT", + "DIRECT_NO_PROXY_VALUE", + "EMBODICHAIN_ROOT", + "GRADIO_AUTH_PASSWORD", + "GRADIO_AUTH_USERNAME", + "PROXY_ENV_KEYS", + "SCENE_ENGINE_VISER_PORT", + "SERVER_NAME", + "SERVER_PORT", + "SIMREADY_OPENAI_API_KEY", + "SIMREADY_OPENAI_BASE_URL", + "SIMREADY_OPENAI_MODEL", + "build_gradio_allowed_paths", + "build_gradio_blocked_paths", + "configure_direct_network_env", + "configure_simready_llm_env", + "get_gradio_auth", + "validate_gradio_artifact_root", +] + +load_gen_sim_env() + +APP_ROOT = Path(__file__).resolve().parent +GEN_SIM_ROOT = APP_ROOT / ".gen_sim" +PROXY_ENV_KEYS = ( + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "FTP_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "ftp_proxy", +) +DIRECT_NO_PROXY_VALUE = "*" + + +def _getenv(name: str, default: str) -> str: + """Read a non-empty shared ``.env`` value, falling back to ``default``.""" + return os.environ.get(name) or default + + +# The repository root must follow this checkout, not a machine-specific .env +# value. Its path is shared with child processes through their working +# directory, so deriving it once here keeps every Debug workflow relocatable. +EMBODICHAIN_ROOT = get_embodichain_root() +ARTICRAFT_ROOT = Path( + _getenv("ARTICRAFT_ROOT", str(APP_ROOT / ".articraft")) +).expanduser() +ARTICRAFT_REPOSITORY_URL = _getenv( + "ARTICRAFT_REPOSITORY_URL", "https://github.com/mattzh72/articraft.git" +) +ARTICRAFT_CONDA_ENV = _getenv("ARTICRAFT_CONDA_ENV", "articraft") +ARTICRAFT_OUTPUT_ROOT = Path( + _getenv("ARTICRAFT_OUTPUT_ROOT", str(GEN_SIM_ROOT / "articraft")) +).expanduser() +SCENE_ENGINE_VISER_PORT = int(_getenv("SCENE_ENGINE_VISER_PORT", "8080")) +ARTICRAFT_VISER_PORT = int(_getenv("ARTICRAFT_VISER_PORT", "8081")) +ACTION_ENGINE_VISER_PORT = int(_getenv("ACTION_ENGINE_VISER_PORT", "8082")) +SERVER_NAME = _getenv("GRADIO_SERVER_NAME", "127.0.0.1") +SERVER_PORT = int(_getenv("GRADIO_SERVER_PORT", "7860")) +GRADIO_AUTH_USERNAME = _getenv("GRADIO_AUTH_USERNAME", "") +GRADIO_AUTH_PASSWORD = _getenv("GRADIO_AUTH_PASSWORD", "") +SIMREADY_OPENAI_API_KEY = _getenv("SIMREADY_OPENAI_API_KEY", "") +SIMREADY_OPENAI_MODEL = _getenv("SIMREADY_OPENAI_MODEL", "") +SIMREADY_OPENAI_BASE_URL = _getenv("SIMREADY_OPENAI_BASE_URL", "") + + +def configure_direct_network_env(env: Any = None) -> None: + """Disable proxy inheritance for local pipeline and Gradio processes.""" + if env is None: + env = os.environ + for key in PROXY_ENV_KEYS: + env.pop(key, None) + env["NO_PROXY"] = DIRECT_NO_PROXY_VALUE + env["no_proxy"] = DIRECT_NO_PROXY_VALUE + env.setdefault("GRADIO_ANALYTICS_ENABLED", "False") + + +def configure_simready_llm_env(env: Any = None) -> None: + """Map app-level SimReady settings to the upstream CLI's environment.""" + if env is None: + env = os.environ + configured_values = { + "OPENAI_API_KEY": SIMREADY_OPENAI_API_KEY, + "OPENAI_MODEL": SIMREADY_OPENAI_MODEL, + "OPENAI_BASE_URL": SIMREADY_OPENAI_BASE_URL, + } + for key, value in configured_values.items(): + if value: + env[key] = value + + +def get_gradio_auth( + server_name: str = SERVER_NAME, + username: str = GRADIO_AUTH_USERNAME, + password: str = GRADIO_AUTH_PASSWORD, +) -> tuple[str, str] | None: + """Validate deployment exposure and return Gradio credentials. + + Args: + server_name: Interface address used by the Gradio server. + username: Optional HTTP basic-auth username. + password: Optional HTTP basic-auth password. + + Returns: + A ``(username, password)`` tuple, or ``None`` for a local-only server. + + Raises: + ValueError: If credentials are incomplete or a non-loopback server has + no authentication configured. + """ + has_username = bool(username) + has_password = bool(password) + if has_username != has_password: + raise ValueError( + "Set both GRADIO_AUTH_USERNAME and GRADIO_AUTH_PASSWORD, or neither." + ) + if has_username and has_password: + return username, password + if server_name.strip().lower() not in {"127.0.0.1", "localhost", "::1"}: + raise ValueError( + "A non-loopback GRADIO_SERVER_NAME requires Gradio authentication." + ) + return None + + +def build_gradio_allowed_paths(*roots: Path) -> list[str]: + """Resolve the explicit static and generated roots Gradio may serve. + + Args: + *roots: Static-resource or generated-artifact directories. + + Returns: + Sorted, de-duplicated absolute path strings. + """ + return sorted({str(path.expanduser().resolve()) for path in roots}) + + +def build_gradio_blocked_paths(env_path: Path | None) -> list[str]: + """Resolve repository metadata and dotenv paths Gradio must never serve. + + Args: + env_path: Active shared dotenv path, if one exists. + + Returns: + Sorted, de-duplicated absolute path strings. + """ + blocked = { + EMBODICHAIN_ROOT / ".env", + EMBODICHAIN_ROOT / ".git", + EMBODICHAIN_ROOT / "embodichain" / "gen_sim" / ".env", + } + if env_path is not None: + blocked.add(env_path) + return build_gradio_allowed_paths(*blocked) + + +def validate_gradio_artifact_root(root: Path) -> Path: + """Reject an artifact setting broad enough to expose the repository. + + Args: + root: Configured directory containing generated artifacts. + + Returns: + The normalized artifact directory. + + Raises: + ValueError: If the directory is the repository or one of its ancestors. + """ + resolved_root = root.expanduser().resolve() + repository = EMBODICHAIN_ROOT.resolve() + if resolved_root == repository or repository.is_relative_to(resolved_root): + raise ValueError( + "ARTICRAFT_OUTPUT_ROOT must be a dedicated artifact directory, not " + "the EmbodiChain repository or one of its parents." + ) + return resolved_root + + +# Gradio imports its HTTP client during application module loading. Remove the +# same unsupported or credential-bearing proxies that child pipelines exclude +# before importing any view module. +configure_direct_network_env() diff --git a/embodichain/gen_sim/gradio_ui/app_media.py b/embodichain/gen_sim/gradio_ui/app_media.py new file mode 100644 index 000000000..417498de7 --- /dev/null +++ b/embodichain/gen_sim/gradio_ui/app_media.py @@ -0,0 +1,100 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Media helpers used by the Action and Articulation engines.""" + +from __future__ import annotations + +import argparse +from collections.abc import Sequence +from pathlib import Path + +from app_config import OUTPUTS_DIR, VIDEO_SUFFIXES + +__all__ = [ + "articraft_viser_preview_cli", + "latest_audience_output_video", + "run_articraft_viser_preview", +] + + +def _collect_audience_output_videos() -> list[Path]: + if not OUTPUTS_DIR.is_dir(): + return [] + videos = [ + path + for path in OUTPUTS_DIR.rglob("*") + if path.is_file() and path.suffix.lower() in VIDEO_SUFFIXES + ] + audience_videos = [ + path + for path in videos + if "audience" in path.relative_to(OUTPUTS_DIR).as_posix().lower() + ] + return audience_videos or videos + + +def latest_audience_output_video(min_mtime_ns: int | None = None) -> Path | None: + """Return the newest DexSim video created after the requested time.""" + latest_path: Path | None = None + latest_mtime = -1 + for path in _collect_audience_output_videos(): + try: + mtime = path.stat().st_mtime_ns + except OSError: + continue + if min_mtime_ns is not None and mtime < min_mtime_ns: + continue + if mtime > latest_mtime: + latest_path = path + latest_mtime = mtime + return latest_path + + +def run_articraft_viser_preview(args: argparse.Namespace) -> None: + """Load an Articraft URDF and publish its initial topology to Viser.""" + from embodichain.lab.scripts import preview_asset + from embodichain.lab.sim.sim_manager import SimulationManager + from embodichain.utils.logger import log_info + + sim = SimulationManager(preview_asset.build_sim_cfg(args)) + try: + if args.env_map: + log_info(f"Setting environment map: {args.env_map} ...", color="green") + sim.set_indirect_lighting(args.env_map) + + assets = preview_asset.load_assets(sim, args) + log_info(f"Loaded {len(assets)} asset(s) successfully.", color="green") + if args.viser: + sim.start_visualization() + sim.notify_visualization_topology_changed() + sim.capture_visualization_safely(force=True) + preview_asset._run_preview_mode(sim, assets, args) + finally: + log_info("Destroying simulation ...", color="green") + sim.destroy() + + +def articraft_viser_preview_cli(argv: Sequence[str] | None = None) -> None: + """Run the Articraft-aware variant of the generic preview-asset CLI.""" + from embodichain.lab.scripts import preview_asset + + parser = preview_asset._create_parser() + run_articraft_viser_preview(parser.parse_args(argv)) + + +if __name__ == "__main__": + articraft_viser_preview_cli() diff --git a/embodichain/gen_sim/gradio_ui/app_processes.py b/embodichain/gen_sim/gradio_ui/app_processes.py new file mode 100644 index 000000000..3744fb219 --- /dev/null +++ b/embodichain/gen_sim/gradio_ui/app_processes.py @@ -0,0 +1,555 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Subprocess execution and lifecycle management for the engine workspace.""" + +from __future__ import annotations + +import os +import queue +import signal +import subprocess +import sys +import threading +import time +import uuid +from pathlib import Path + +from app_config import COMMANDS, PROCESS_STOP_TIMEOUT_S +from app_env import ( + EMBODICHAIN_ROOT, + configure_direct_network_env, + configure_simready_llm_env, +) + +__all__ = [ + "SessionProcessRegistry", + "build_codex_env", + "build_pipeline_env", + "build_run_agent_command", + "force_stop_all_child_processes", + "get_request_session_id", + "kill_process_group", + "read_process_output", + "register_managed_process", + "run_agent_cli_supports_robot_profile", + "start_pipeline", + "terminate_process_group", + "redact_sensitive_text", +] + +_RUN_AGENT_SUPPORTS_ROBOT_PROFILE: bool | None = None +_managed_processes: dict[int, subprocess.Popen[str]] = {} +_managed_processes_lock = threading.Lock() +_shutdown_requested = False +_PROCESS_KILL_REAP_TIMEOUT_S = 0.1 +_CODEX_ENV_ALLOWLIST = { + "CODEX_HOME", + "COLORTERM", + "HOME", + "LANG", + "LC_ALL", + "LC_CTYPE", + "LOGNAME", + "PATH", + "REQUESTS_CA_BUNDLE", + "SHELL", + "SSL_CERT_DIR", + "SSL_CERT_FILE", + "TEMP", + "TERM", + "TMP", + "TMPDIR", + "USER", + "XDG_CACHE_HOME", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", +} +_SENSITIVE_ENV_MARKERS = ( + "API_KEY", + "CREDENTIAL", + "PASSWORD", + "SECRET", + "TOKEN", +) + + +def get_request_session_id(request: object) -> str: + """Return the stable session identifier supplied by Gradio. + + Args: + request: Gradio request object injected into an event callback. + + Returns: + The non-empty Gradio session hash. + + Raises: + RuntimeError: If the callback was invoked without a session hash. + """ + session_id = getattr(request, "session_hash", None) + if not isinstance(session_id, str) or not session_id: + raise RuntimeError("This operation requires an active Gradio session.") + return session_id + + +class SessionProcessRegistry: + """Track one replaceable subprocess for each Gradio session. + + A registry instance belongs to one workflow, such as SimReady or Articraft. + Resetting one session can therefore never invalidate or terminate another + session's run. + """ + + def __init__(self) -> None: + self._lock = threading.Lock() + self._runs: dict[str, tuple[str, subprocess.Popen[str] | None]] = {} + + def begin(self, session_id: str) -> str: + """Start a new logical run for one session. + + Args: + session_id: Stable Gradio session identifier. + + Returns: + A new ownership token for the run. + """ + token = uuid.uuid4().hex + with self._lock: + previous = self._runs.get(session_id) + self._runs[session_id] = (token, None) + if previous is not None and previous[1] is not None: + terminate_process_group(previous[1]) + return token + + def is_active( + self, + session_id: str, + token: str, + process: subprocess.Popen[str] | None = None, + ) -> bool: + """Return whether a run still owns its session slot. + + Args: + session_id: Stable Gradio session identifier. + token: Token returned by :meth:`begin`. + process: Optional process that must also match the registered child. + + Returns: + ``True`` when the token and optional process still match. + """ + with self._lock: + current = self._runs.get(session_id) + return ( + current is not None + and current[0] == token + and (process is None or current[1] is process) + ) + + def attach( + self, + session_id: str, + token: str, + process: subprocess.Popen[str], + ) -> bool: + """Attach a subprocess to an active session run. + + Args: + session_id: Stable Gradio session identifier. + token: Token returned by :meth:`begin`. + process: Managed subprocess started for the run. + + Returns: + ``True`` if the token still owns the session slot. + """ + with self._lock: + current = self._runs.get(session_id) + if current is None or current[0] != token: + return False + self._runs[session_id] = (token, process) + return True + + def finish( + self, + session_id: str, + token: str, + process: subprocess.Popen[str], + ) -> None: + """Clear a finished subprocess while keeping its logical run active. + + Args: + session_id: Stable Gradio session identifier. + token: Token returned by :meth:`begin`. + process: Subprocess that has finished. + """ + with self._lock: + current = self._runs.get(session_id) + if current == (token, process): + self._runs[session_id] = (token, None) + + def reset(self, session_id: str, *, force: bool = False) -> None: + """Invalidate and stop only one session's process. + + Args: + session_id: Stable Gradio session identifier. + force: Whether to send ``SIGKILL`` immediately instead of allowing + a graceful shutdown period. + """ + with self._lock: + current = self._runs.pop(session_id, None) + if current is not None and current[1] is not None: + stop_process = kill_process_group if force else terminate_process_group + stop_process(current[1]) + + def reset_all(self) -> None: + """Invalidate and terminate every process tracked by this registry.""" + with self._lock: + runs = tuple(self._runs.values()) + self._runs.clear() + for _token, process in runs: + if process is not None: + terminate_process_group(process) + + +def run_agent_cli_supports_robot_profile() -> bool: + global _RUN_AGENT_SUPPORTS_ROBOT_PROFILE + if _RUN_AGENT_SUPPORTS_ROBOT_PROFILE is not None: + return _RUN_AGENT_SUPPORTS_ROBOT_PROFILE + try: + result = subprocess.run( + [ + sys.executable, + "-m", + COMMANDS["agent"]["module"], + *COMMANDS["agent"]["help_args"], + ], + cwd=EMBODICHAIN_ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=build_pipeline_env(), + timeout=20, + ) + help_text = (result.stdout or "").lower() + _RUN_AGENT_SUPPORTS_ROBOT_PROFILE = "--robot-profile" in help_text + except Exception: + _RUN_AGENT_SUPPORTS_ROBOT_PROFILE = False + return _RUN_AGENT_SUPPORTS_ROBOT_PROFILE + + +def build_run_agent_command(*, robot_profile: str | None = None) -> list[str]: + """Build the Action-engine command for the existing current scene.""" + from app_commands import build_run_agent_command as build_command + + return build_command( + robot_profile=robot_profile, + supports_robot_profile=run_agent_cli_supports_robot_profile(), + ) + + +def start_pipeline( + command: list[str], *, use_simready_llm: bool = False +) -> subprocess.Popen[str]: + """Start a managed pipeline subprocess with its scoped dotenv settings. + + Args: + command: Command and arguments to execute. + use_simready_llm: Whether to map the dotenv ``SIMREADY_OPENAI_*`` values + to the upstream SimReady CLI's ``OPENAI_*`` variable names. + + Returns: + The registered subprocess. + """ + env = build_pipeline_env(use_simready_llm=use_simready_llm) + env["PYTHONUNBUFFERED"] = "1" + return register_managed_process( + subprocess.Popen( + command, + cwd=EMBODICHAIN_ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + start_new_session=True, + env=env, + ) + ) + + +def build_pipeline_env(*, use_simready_llm: bool = False) -> dict[str, str]: + """Build a child environment from the shared GenSim dotenv configuration. + + Args: + use_simready_llm: Whether to apply the SimReady-specific LLM mapping. + + Returns: + A copy of the loaded process environment configured for the child. + """ + env = os.environ.copy() + configure_direct_network_env(env) + if use_simready_llm: + configure_simready_llm_env(env) + return env + + +def build_codex_env() -> dict[str, str]: + """Build a credential-minimized environment for user-directed Codex runs. + + The Codex CLI may still use its own login state through ``CODEX_HOME`` or + the normal user configuration directory, but GenSim service credentials + and dotenv-specific settings are not inherited by the command sandbox. + + Returns: + An allowlisted child-process environment. + + .. attention:: + Deployments that authenticate Codex exclusively through + ``OPENAI_API_KEY`` must use ``codex login`` or another isolated Codex + credential store instead. Passing the server key to a user-directed + process would recreate the disclosure boundary this function removes. + """ + return { + key: value + for key, value in os.environ.items() + if key in _CODEX_ENV_ALLOWLIST and value + } + + +def redact_sensitive_text(text: str) -> str: + """Replace known environment credential values in UI-bound output. + + Args: + text: Subprocess output or a final message that may contain credentials. + + Returns: + Text with non-trivial sensitive environment values replaced. + """ + redacted = text + for key, value in os.environ.items(): + upper_key = key.upper() + if ( + value + and len(value) >= 4 + and any(marker in upper_key for marker in _SENSITIVE_ENV_MARKERS) + ): + redacted = redacted.replace(value, "[REDACTED]") + return redacted + + +def register_managed_process( + process: subprocess.Popen[str], +) -> subprocess.Popen[str]: + """Register a UI-owned subprocess for application-shutdown cleanup. + + Processes must be registered immediately after they are created. If Gradio + shutdown has already begun, the new process is stopped before this function + returns so a callback cannot leave an orphan behind. + """ + with _managed_processes_lock: + if not _shutdown_requested: + _managed_processes[process.pid] = process + return process + + terminate_process_group(process) + return process + + +def _unregister_managed_process(process: subprocess.Popen[str]) -> None: + with _managed_processes_lock: + _managed_processes.pop(process.pid, None) + + +def _child_process_ids(parent_pid: int) -> set[int]: + """Return a snapshot of every descendant of ``parent_pid`` on POSIX.""" + try: + result = subprocess.run( + ["ps", "-eo", "pid=,ppid="], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + timeout=2, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return set() + + children_by_parent: dict[int, set[int]] = {} + for line in result.stdout.splitlines(): + fields = line.split() + if len(fields) != 2 or not all(field.isdecimal() for field in fields): + continue + pid, ppid = (int(field) for field in fields) + children_by_parent.setdefault(ppid, set()).add(pid) + + descendants: set[int] = set() + pending = list(children_by_parent.get(parent_pid, set())) + while pending: + pid = pending.pop() + if pid in descendants: + continue + descendants.add(pid) + pending.extend(children_by_parent.get(pid, set())) + return descendants + + +def _force_stop_process_ids(process_ids: set[int]) -> None: + """Stop unregistered child PIDs, escalating from SIGTERM to SIGKILL.""" + process_ids.discard(os.getpid()) + for pid in process_ids: + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + continue + except PermissionError: + continue + + deadline = time.monotonic() + PROCESS_STOP_TIMEOUT_S + remaining = set(process_ids) + while remaining and time.monotonic() < deadline: + remaining = {pid for pid in remaining if _process_is_running(pid)} + if remaining: + time.sleep(0.1) + + for pid in remaining: + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + continue + except PermissionError: + continue + + +def _process_is_running(pid: int) -> bool: + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + try: + status = Path(f"/proc/{pid}/stat").read_text().rsplit(")", maxsplit=1)[1] + except (FileNotFoundError, IndexError, PermissionError): + return True + return not status.lstrip().startswith("Z") + + +def force_stop_all_child_processes() -> None: + """Force-stop every subprocess owned by the Gradio application. + + Registered processes are stopped by their isolated process groups, which + also stops their descendants. A second descendant scan catches short-lived + or legacy subprocesses that were not registered explicitly. + """ + global _shutdown_requested + with _managed_processes_lock: + _shutdown_requested = True + managed_processes = tuple(_managed_processes.values()) + child_process_ids = _child_process_ids(os.getpid()) + + for process in managed_processes: + terminate_process_group(process) + + _force_stop_process_ids(child_process_ids) + + +def terminate_process_group(process: subprocess.Popen[str]) -> None: + """Gracefully stop a process group, escalating after the shutdown timeout.""" + try: + if process.poll() is not None: + return + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + return + except Exception: + process.terminate() + + deadline = time.monotonic() + PROCESS_STOP_TIMEOUT_S + while time.monotonic() < deadline: + if process.poll() is not None: + return + time.sleep(0.2) + + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + return + except Exception: + process.kill() + finally: + _unregister_managed_process(process) + + +def kill_process_group(process: subprocess.Popen[str]) -> None: + """Immediately send ``SIGKILL`` to a UI-owned subprocess group. + + This path is intended for interactive Reset and Stop actions whose contract + is to discard the active run. Application shutdown continues to use + :func:`terminate_process_group` so child processes retain a graceful cleanup + window. + + Args: + process: Group-leading subprocess created with ``start_new_session``. + """ + try: + if process.poll() is not None: + return + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + return + except Exception: + process.kill() + try: + process.wait(timeout=_PROCESS_KILL_REAP_TIMEOUT_S) + except subprocess.TimeoutExpired: + # SIGKILL has already been delivered. Do not hold the UI callback + # open for an uninterruptible process; its monitor can reap it. + pass + finally: + _unregister_managed_process(process) + + +def read_process_output( + process: subprocess.Popen[str], + output_queue: queue.Queue[str], + log_path: Path | None = None, + *, + redact_sensitive: bool = False, +) -> None: + """Forward merged subprocess output to the UI queue and an optional log. + + Args: + process: Child process whose merged stdout should be consumed. + output_queue: Destination for individual output lines. + log_path: Optional file receiving the same output. + redact_sensitive: Whether to redact known environment credentials before + forwarding or persisting each line. + """ + if process.stdout is None: + return + log_file = log_path.open("a", encoding="utf-8") if log_path is not None else None + try: + for line in process.stdout: + output_line = redact_sensitive_text(line) if redact_sensitive else line + output_queue.put(output_line.rstrip()) + if log_file is not None: + log_file.write(output_line) + if not output_line.endswith("\n"): + log_file.write("\n") + log_file.flush() + finally: + if log_file is not None: + log_file.close() diff --git a/embodichain/gen_sim/gradio_ui/app_services.py b/embodichain/gen_sim/gradio_ui/app_services.py new file mode 100644 index 000000000..a3956eb4a --- /dev/null +++ b/embodichain/gen_sim/gradio_ui/app_services.py @@ -0,0 +1,28 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Compatibility facade for application services. + +The executable workflow lives in :mod:`app_workflows`; the Gradio view lives +in :mod:`app_ui`. Keep this module small so existing imports remain valid +while callers move to the focused modules. +""" + +from __future__ import annotations + +from app_ui import build_app + +__all__ = ["build_app"] diff --git a/embodichain/gen_sim/gradio_ui/app_state.py b/embodichain/gen_sim/gradio_ui/app_state.py new file mode 100644 index 000000000..d16c68b05 --- /dev/null +++ b/embodichain/gen_sim/gradio_ui/app_state.py @@ -0,0 +1,99 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Per-session runtime state for the Scene and Action engines.""" + +from __future__ import annotations + +import threading +from collections import deque +from dataclasses import dataclass, field +from pathlib import Path + +from app_config import PHASE_DEFINITIONS + +__all__ = [ + "PHASES", + "Phase", + "RuntimeState", + "SessionRuntimeRegistry", + "runtime_lock", + "runtime_registry", + "set_runtime_phase_locked", +] + + +@dataclass(frozen=True) +class Phase: + progress: int + label: str + + +PHASES = {key: Phase(*value) for key, value in PHASE_DEFINITIONS.items()} + + +@dataclass +class RuntimeState: + """Mutable Scene/Action UI state owned by one Gradio session.""" + + is_busy: bool = False + scene_engine_is_running: bool = False + phase_key: str = "idle" + status: str = "Idle." + task_text: str = "" + image_path: Path | None = None + video_path: Path | None = None + last_sent_video_signature: tuple[str, int] | None = None + last_error: str | None = None + log_lines: deque[str] = field(default_factory=deque) + + +class SessionRuntimeRegistry: + """Own one Scene/Action UI runtime for each Gradio session hash.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._states: dict[str, RuntimeState] = {} + + def get(self, session_id: str) -> RuntimeState: + """Return the existing state for a session, creating it when absent. + + Args: + session_id: Stable Gradio session identifier. + + Returns: + Runtime state owned exclusively by ``session_id``. + """ + with self._lock: + return self._states.setdefault(session_id, RuntimeState()) + + def reset(self, session_id: str) -> None: + """Discard only one session's UI runtime state. + + Args: + session_id: Stable Gradio session identifier. + """ + with self._lock: + self._states.pop(session_id, None) + + +runtime_registry = SessionRuntimeRegistry() +runtime_lock = threading.Lock() + + +def set_runtime_phase_locked(runtime: RuntimeState, new_phase_key: str) -> None: + """Set the current UI phase while the caller holds ``runtime_lock``.""" + runtime.phase_key = new_phase_key diff --git a/embodichain/gen_sim/gradio_ui/app_ui.py b/embodichain/gen_sim/gradio_ui/app_ui.py new file mode 100644 index 000000000..2869bc46a --- /dev/null +++ b/embodichain/gen_sim/gradio_ui/app_ui.py @@ -0,0 +1,300 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Gradio layout and event bindings for the engine workspace. + +The workflow layer supplies all callbacks; this module only owns presentation +and wires components to those callbacks. +""" + +from __future__ import annotations + +import gradio as gr + +from app_asset_engine import build_asset_engine_panel, cleanup_asset_engine_session +from app_config import ( + DEBUG_ENGINE_ACTION, + DEBUG_ENGINE_ASSET, + DEBUG_ENGINE_SCENE, + DEBUG_ENGINES, + DEFAULT_ROBOT_PROFILE, + DEXFORCE_LOGO, + LANGUAGE_EN, + ROBOT_PROFILES, + UI_TEXT, +) +from app_processes import get_request_session_id +from app_workflows import ( + cleanup_workflow_session, + format_status, + preview_saved_scene, + refresh_saved_scenes, + reset_scene_engine, + run_action_engine_from_current, + run_scene_engine, + stop_action_engine, + ui_snapshot, +) + +__all__ = ["build_app"] + + +def select_engine(selected_engine: str): + """Show the selected engine panel without starting a pipeline.""" + button_updates = tuple( + gr.update(variant="primary" if engine == selected_engine else "secondary") + for engine, _ in DEBUG_ENGINES + ) + return ( + *button_updates, + gr.update(visible=selected_engine == DEBUG_ENGINE_ASSET), + gr.update(visible=selected_engine == DEBUG_ENGINE_SCENE), + gr.update(visible=selected_engine == DEBUG_ENGINE_ACTION), + ) + + +def action_engine_snapshot(request: gr.Request) -> tuple[object, ...]: + """Adapt this session's runtime snapshot to the Action status widgets.""" + session_id = get_request_session_id(request) + video, task, progress, status, _initial, _edited, _objects = ui_snapshot(session_id) + return video, task, progress, status + + +def run_action_engine_panel( + task_text: str, + robot_profile: str | None, + request: gr.Request, +) -> tuple[object, ...]: + """Run the Action engine and return its latest UI snapshot.""" + video, task, progress, status, _initial, _edited, _objects = ( + run_action_engine_from_current(task_text, robot_profile, request) + ) + return video, task, progress, status + + +def cleanup_app_session(request: gr.Request) -> None: + """Stop every engine process owned by a disconnected Gradio session.""" + cleanup_workflow_session(request) + cleanup_asset_engine_session(request) + + +def build_app() -> gr.Blocks: + """Build the engine-only Gradio application.""" + with gr.Blocks(title="EmbodiChain Gradio") as app: + if DEXFORCE_LOGO.is_file(): + with gr.Row(equal_height=True): + gr.Image( + value=str(DEXFORCE_LOGO), + show_label=False, + container=False, + height=58, + width=183, + ) + + with gr.Row(): + asset_engine_button = gr.Button("Asset_engine", variant="primary") + scene_engine_button = gr.Button("Scene_engine", variant="secondary") + action_engine_button = gr.Button("Action_engine", variant="secondary") + + asset_engine = build_asset_engine_panel() + with gr.Column(visible=False) as scene_engine_panel: + gr.Markdown( + "## Scene engine\n" + "Upload one image to generate a Scene Engine export. " + "The resulting Viser page is shown below." + ) + with gr.Row(): + with gr.Column(scale=1): + scene_image = gr.Image( + label=UI_TEXT[LANGUAGE_EN]["input_image"], + sources=["upload", "webcam"], + type="filepath", + format="png", + height=300, + ) + with gr.Row(): + scene_run = gr.Button("Generate scene", variant="primary") + scene_reset = gr.Button("Reset Scene Engine", variant="stop") + with gr.Column(scale=2): + scene_progress = gr.Slider( + 0, + 100, + value=0, + step=1, + label=UI_TEXT[LANGUAGE_EN]["progress"], + interactive=False, + ) + scene_status = gr.Markdown(format_status("Idle.")) + scene_output = gr.Textbox( + label="Scene output directory (hash-named)", + interactive=False, + ) + scene_preview = gr.HTML( + "
" + "The Viser preview will appear here after generation." + "
" + ) + + with gr.Column(visible=False) as action_engine_panel: + gr.Markdown( + "## Action engine\n" + "Select a generated Scene Engine export to inspect it in Viser. " + "Scene selection is currently independent from DexSim execution." + ) + with gr.Row(): + with gr.Column(scale=1): + action_scene_list = gr.Dropdown( + choices=[], + value=None, + label="Generated scenes", + info="Complete scenes stored under .gen_sim/scenes.", + ) + action_scene_refresh = gr.Button("Refresh scenes") + action_scene_status = gr.Markdown( + "**Scene list:** open Action engine or refresh to load scenes." + ) + action_task = gr.Textbox( + label="Task description", + placeholder="e.g. Put the bottle on the table", + ) + action_robot = gr.Radio( + choices=ROBOT_PROFILES, + value=DEFAULT_ROBOT_PROFILE, + label=UI_TEXT[LANGUAGE_EN]["robot"], + ) + with gr.Row(): + action_run = gr.Button("Run DexSim", variant="primary") + action_stop = gr.Button( + "Stop Action Engine", + variant="stop", + ) + with gr.Column(scale=2): + action_scene = gr.HTML( + "
" + "Select a generated scene to preview it." + "
" + ) + action_video = gr.Video( + label=UI_TEXT[LANGUAGE_EN]["single_video_preview"], + height=320, + autoplay=True, + loop=True, + ) + action_current_task = gr.Textbox( + label=UI_TEXT[LANGUAGE_EN]["current_task"], + interactive=False, + ) + action_progress = gr.Slider( + 0, + 100, + value=0, + step=1, + label=UI_TEXT[LANGUAGE_EN]["progress"], + interactive=False, + ) + action_status = gr.Markdown( + format_status("Load or generate a scene first.") + ) + action_refresh_timer = gr.Timer(2.0) + + for engine, button in zip( + (engine for engine, _label in DEBUG_ENGINES), + (asset_engine_button, scene_engine_button, action_engine_button), + ): + button.click( + select_engine, + inputs=[gr.State(engine)], + outputs=[ + asset_engine_button, + scene_engine_button, + action_engine_button, + asset_engine["panel"], + scene_engine_panel, + action_engine_panel, + ], + queue=False, + ) + + action_engine_button.click( + refresh_saved_scenes, + inputs=[action_scene_list], + outputs=[action_scene_list, action_scene_status], + queue=False, + ) + + scene_run.click( + run_scene_engine, + inputs=[scene_image], + outputs=[scene_progress, scene_status, scene_output, scene_preview], + ) + scene_reset.click( + reset_scene_engine, + outputs=[ + scene_image, + scene_progress, + scene_status, + scene_output, + scene_preview, + ], + queue=False, + ) + action_scene_refresh.click( + refresh_saved_scenes, + inputs=[action_scene_list], + outputs=[action_scene_list, action_scene_status], + queue=False, + ) + action_scene_list.change( + preview_saved_scene, + inputs=[action_scene_list], + outputs=[action_scene, action_scene_status], + ) + action_run.click( + run_action_engine_panel, + inputs=[action_task, action_robot], + outputs=[ + action_video, + action_current_task, + action_progress, + action_status, + ], + ) + action_stop.click( + stop_action_engine, + outputs=[ + action_scene, + action_scene_status, + action_video, + action_current_task, + action_progress, + action_status, + ], + queue=False, + ) + action_refresh_timer.tick( + action_engine_snapshot, + outputs=[ + action_video, + action_current_task, + action_progress, + action_status, + ], + queue=False, + ) + + app.unload(cleanup_app_session) + + return app diff --git a/embodichain/gen_sim/gradio_ui/app_workflows.py b/embodichain/gen_sim/gradio_ui/app_workflows.py new file mode 100644 index 000000000..70df77e40 --- /dev/null +++ b/embodichain/gen_sim/gradio_ui/app_workflows.py @@ -0,0 +1,905 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Scene- and Action-engine workflows for the Gradio workspace.""" + +from __future__ import annotations + +import hashlib +import html +import importlib.util +import io +import json +import queue +import shutil +import socket +import subprocess +import sys +import threading +import time +from collections.abc import Iterator +from pathlib import Path + +import gradio as gr +import numpy as np +from PIL import Image, ImageOps + +from app_config import ( + AGENT_CONFIG, + COMMANDS, + GEN_SIM_ROOT, + GEN_SIM_SCENE_ROOT, + FAST_GYM_CONFIG, +) +from app_env import ( + ACTION_ENGINE_VISER_PORT, + SCENE_ENGINE_VISER_PORT, + configure_direct_network_env, +) +from app_media import latest_audience_output_video +from app_processes import ( + SessionProcessRegistry, + build_run_agent_command, + get_request_session_id, + read_process_output, + start_pipeline, + terminate_process_group, +) +from app_state import ( + PHASES, + Phase, + RuntimeState, + runtime_lock, + runtime_registry, + set_runtime_phase_locked, +) + +__all__ = [ + "cleanup_workflow_session", + "format_status", + "preview_saved_scene", + "refresh_saved_scenes", + "reset_scene_engine", + "run_action_engine_from_current", + "run_scene_engine", + "stop_action_engine", + "ui_snapshot", +] + +configure_direct_network_env() + +_scene_runs = SessionProcessRegistry() +_action_runs = SessionProcessRegistry() +_action_preview_runs = SessionProcessRegistry() +_preview_start_lock = threading.Lock() + +_ACTION_IDLE_PREVIEW = ( + "
" + "Select a generated scene to preview it." + "
" +) + + +def _drain_output_queue(output_queue: queue.Queue[str]) -> list[str]: + lines: list[str] = [] + while True: + try: + lines.append(output_queue.get_nowait()) + except queue.Empty: + return lines + + +def _scene_engine_phase_from_log(line: str, current_key: str) -> str: + """Map Scene Engine stage names to the shared progress UI.""" + text = line.lower() + mapping = ( + ("scene understanding", "scene_intake"), + ("scene segmentation", "relations"), + ("coarse layout", "asset_generation"), + ("scene export", "gym_export"), + ) + current_progress = PHASES.get(current_key, PHASES["idle"]).progress + for needle, phase_key in mapping: + if needle in text and PHASES[phase_key].progress > current_progress: + return phase_key + return current_key + + +def _scene_engine_updates( + runtime: RuntimeState, + output_root: Path | None = None, + preview_html: str | None = None, +) -> tuple[int, str, str | None, str]: + with runtime_lock: + phase = PHASES.get(runtime.phase_key, PHASES["idle"]) + status = format_status( + runtime.status, + phase=phase, + busy=runtime.is_busy, + last_error=runtime.last_error, + ) + return ( + phase.progress, + status, + output_root.as_posix() if output_root is not None else None, + preview_html or "", + ) + + +def _prepare_scene_engine_input( + image_value: str | np.ndarray | Image.Image, +) -> tuple[str, Path, Path]: + """Normalize an uploaded image and store it under a stable content hash.""" + if image_value is None: + raise ValueError("Please upload an image first.") + if isinstance(image_value, str): + image = Image.open(image_value) + elif isinstance(image_value, np.ndarray): + image = Image.fromarray(image_value) + elif isinstance(image_value, Image.Image): + image = image_value + else: + raise TypeError(f"Unsupported image input type: {type(image_value)!r}") + + normalized = ImageOps.exif_transpose(image).convert("RGB") + image_bytes = io.BytesIO() + normalized.save(image_bytes, format="PNG") + scene_hash = hashlib.sha256(image_bytes.getvalue()).hexdigest()[:16] + output_root = GEN_SIM_SCENE_ROOT / scene_hash + output_root.mkdir(parents=True, exist_ok=True) + image_path = output_root / "input.png" + image_path.write_bytes(image_bytes.getvalue()) + return scene_hash, output_root, image_path + + +def _wait_for_viser(port: int, process: subprocess.Popen[str]) -> bool: + deadline = time.monotonic() + 15.0 + while time.monotonic() < deadline: + if process.poll() is not None: + return False + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + return True + except OSError: + time.sleep(0.25) + return False + + +def _select_available_port(preferred_port: int) -> int: + """Return the preferred Viser port, or an ephemeral port when occupied.""" + for port in (preferred_port, 0): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: + try: + listener.bind(("127.0.0.1", port)) + except OSError: + continue + return int(listener.getsockname()[1]) + raise RuntimeError("Could not allocate a local Viser port.") + + +def _viser_iframe(port: int, scene_hash: str) -> str: + srcdoc = ( + "" + ) + return ( + "
Viser preview: " + f"{html.escape(scene_hash)}" + f"
" + ) + + +def _saved_scene_root(scene_name: str) -> Path: + """Resolve a scene-list value without allowing paths outside the store.""" + if not scene_name or Path(scene_name).name != scene_name: + raise ValueError("Select a valid generated scene.") + scene_store = GEN_SIM_SCENE_ROOT.resolve() + scene_root = (scene_store / scene_name).resolve() + if scene_root.parent != scene_store: + raise ValueError("Selected scene must stay within the generated scene store.") + config_path = scene_root / "scene_export" / "scene_config.json" + if not config_path.is_file(): + raise FileNotFoundError(f"Scene export is incomplete: {config_path}") + try: + scene_config = json.loads(config_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"Scene config is invalid: {config_path}") from exc + if not isinstance(scene_config, dict) or scene_config.get("format") != ( + "embodichain.scene-export/v1" + ): + raise ValueError(f"Unsupported scene export: {config_path}") + return scene_root + + +def saved_scene_choices() -> list[tuple[str, str]]: + """List complete Scene Engine exports, newest first.""" + if not GEN_SIM_SCENE_ROOT.is_dir(): + return [] + choices: list[tuple[int, str, str]] = [] + for scene_root in GEN_SIM_SCENE_ROOT.iterdir(): + if not scene_root.is_dir(): + continue + config_path = scene_root / "scene_export" / "scene_config.json" + if not config_path.is_file(): + continue + try: + scene_config = json.loads(config_path.read_text(encoding="utf-8")) + if not isinstance(scene_config, dict) or scene_config.get("format") != ( + "embodichain.scene-export/v1" + ): + continue + scene_id = scene_config.get("scene_id") + label = ( + f"{scene_root.name} · {scene_id}" + if isinstance(scene_id, str) and scene_id + else scene_root.name + ) + modified_ns = config_path.stat().st_mtime_ns + except (OSError, json.JSONDecodeError): + continue + choices.append((modified_ns, label, scene_root.name)) + choices.sort(reverse=True) + return [(label, value) for _modified_ns, label, value in choices] + + +def refresh_saved_scenes(selected_scene: str | None = None): + """Refresh the Action-engine scene list without selecting a scene implicitly.""" + choices = saved_scene_choices() + values = {value for _label, value in choices} + value = selected_scene if selected_scene in values else None + status = ( + f"**Scene list:** {len(choices)} generated scene(s) available." + if choices + else "**Scene list:** no complete generated scenes found." + ) + return gr.update(choices=choices, value=value), status + + +def preview_saved_scene( + scene_name: str | None, + request: gr.Request, +) -> tuple[str, str]: + """Start a session-owned Viser preview for one saved scene. + + Args: + scene_name: Hash-named generated scene selected in the Action panel. + request: Gradio request carrying the owning session hash. + + Returns: + Preview iframe HTML and a human-readable preview status. + """ + session_id = get_request_session_id(request) + if not scene_name: + return _ACTION_IDLE_PREVIEW, "**Scene preview:** no scene selected." + + try: + scene_root = _saved_scene_root(scene_name) + except (ValueError, FileNotFoundError) as exc: + return _ACTION_IDLE_PREVIEW, f"**Scene preview error:** {exc}" + + with runtime_lock: + runtime = runtime_registry.get(session_id) + if runtime.scene_engine_is_running: + return ( + _ACTION_IDLE_PREVIEW, + "**Scene preview:** Scene Engine is still running.", + ) + token = _action_preview_runs.begin(session_id) + + with _preview_start_lock: + port = _select_available_port(ACTION_ENGINE_VISER_PORT) + preview_command = [ + sys.executable, + COMMANDS["scene_engine"]["preview_script"], + "--output_root", + str(scene_root), + "--viser", + "--viser-host", + "0.0.0.0", + "--viser-port", + str(port), + ] + try: + preview_process = start_pipeline(preview_command) + except Exception as exc: + return _ACTION_IDLE_PREVIEW, f"**Scene preview error:** {exc}" + + if not _action_preview_runs.attach(session_id, token, preview_process): + terminate_process_group(preview_process) + return _ACTION_IDLE_PREVIEW, "**Scene preview:** request was superseded." + if not _wait_for_viser(port, preview_process): + terminate_process_group(preview_process) + _action_preview_runs.finish(session_id, token, preview_process) + return _ACTION_IDLE_PREVIEW, "**Scene preview error:** Viser did not start." + + if not _action_preview_runs.is_active(session_id, token, preview_process): + terminate_process_group(preview_process) + return _ACTION_IDLE_PREVIEW, "**Scene preview:** request was superseded." + + return ( + _viser_iframe(port, scene_name), + f"**Scene preview:** `{scene_name}` is ready.", + ) + + +def reset_scene_engine( + request: gr.Request, +) -> tuple[None, int, str, str, str]: + """Reset only the requesting session's Scene Engine state and processes. + + Args: + request: Gradio request carrying the owning session hash. + + Returns: + Reset values for the Scene Engine input, progress, status, output, and + preview widgets. + """ + session_id = get_request_session_id(request) + with runtime_lock: + runtime = runtime_registry.get(session_id) + owns_runtime = runtime.scene_engine_is_running + action_running = runtime.is_busy and not owns_runtime + if owns_runtime: + runtime.is_busy = False + if not action_running: + set_runtime_phase_locked(runtime, "idle") + runtime.status = "Scene Engine reset." + runtime.last_error = None + runtime.log_lines.clear() + runtime.image_path = None + runtime.scene_engine_is_running = False + _scene_runs.reset(session_id, force=True) + + message = ( + "Scene Engine reset." + if not action_running + else "Scene Engine preview reset; Action Engine is still running." + ) + return ( + None, + PHASES["idle"].progress, + format_status(message), + "", + "
" + "The Viser preview will appear here after generation." + "
", + ) + + +def run_scene_engine( + image_value: str | np.ndarray | Image.Image, + request: gr.Request, +) -> Iterator[tuple[int, str, str | None, str]]: + """Generate one scene for the requesting Gradio session. + + Args: + image_value: Uploaded image path, array, or PIL image. + request: Gradio request carrying the owning session hash. + + Yields: + Progress, status, output directory, and Viser preview updates. + """ + session_id = get_request_session_id(request) + output_root: Path | None = None + preview_html = "" + with runtime_lock: + runtime = runtime_registry.get(session_id) + if runtime.is_busy: + runtime.status = "Another engine is already running in this session." + runtime.last_error = runtime.status + busy_message = runtime.status + else: + token = _scene_runs.begin(session_id) + runtime.is_busy = True + runtime.scene_engine_is_running = True + set_runtime_phase_locked(runtime, "received") + runtime.status = "Preparing Scene Engine input." + runtime.last_error = None + runtime.log_lines.clear() + busy_message = None + + if busy_message is not None: + yield _scene_engine_updates(runtime, output_root, preview_html) + return + + try: + scene_hash, output_root, image_path = _prepare_scene_engine_input(image_value) + except Exception as exc: + with runtime_lock: + if not _scene_runs.is_active(session_id, token): + return + runtime.is_busy = False + runtime.scene_engine_is_running = False + set_runtime_phase_locked(runtime, "failed") + runtime.status = f"Input error: {exc}" + runtime.last_error = str(exc) + yield _scene_engine_updates(runtime, output_root, preview_html) + return + + with runtime_lock: + if not _scene_runs.is_active(session_id, token): + return + runtime.status = f"Image saved. Generating Scene Engine output {scene_hash}." + runtime.image_path = image_path + + command = [ + sys.executable, + "-m", + COMMANDS["scene_engine"]["module"], + *COMMANDS["scene_engine"]["base_args"], + "--image", + str(image_path), + "--output_root", + str(output_root), + ] + scene_engine_log = output_root / "scene_engine.log" + scene_engine_log.write_text("$ " + " ".join(command) + "\n", encoding="utf-8") + with runtime_lock: + runtime.log_lines.append("$ " + " ".join(command)) + yield _scene_engine_updates(runtime, output_root, preview_html) + + try: + process = start_pipeline(command) + except Exception as exc: + with runtime_lock: + if not _scene_runs.is_active(session_id, token): + return + runtime.is_busy = False + runtime.scene_engine_is_running = False + set_runtime_phase_locked(runtime, "failed") + runtime.status = f"Scene Engine start failed: {exc}" + runtime.last_error = str(exc) + yield _scene_engine_updates(runtime, output_root, preview_html) + return + + if not _scene_runs.attach(session_id, token, process): + terminate_process_group(process) + return + output_queue: queue.Queue[str] = queue.Queue() + reader = threading.Thread( + target=read_process_output, + args=(process, output_queue, scene_engine_log), + daemon=True, + ) + with runtime_lock: + if not _scene_runs.is_active(session_id, token, process): + terminate_process_group(process) + return + set_runtime_phase_locked(runtime, "started") + runtime.status = "Scene Engine generation started." + reader.start() + + while process.poll() is None: + drained = _drain_output_queue(output_queue) + with runtime_lock: + if not _scene_runs.is_active(session_id, token, process): + return + for line in drained: + runtime.log_lines.append(line) + set_runtime_phase_locked( + runtime, + _scene_engine_phase_from_log(line, runtime.phase_key), + ) + if (output_root / "scene_export" / "scene_config.json").is_file(): + set_runtime_phase_locked(runtime, "gym_export") + runtime.status = PHASES[runtime.phase_key].label + "." + yield _scene_engine_updates(runtime, output_root, preview_html) + time.sleep(0.5) + + reader.join(timeout=1.0) + with runtime_lock: + if not _scene_runs.is_active(session_id, token, process): + return + for line in _drain_output_queue(output_queue): + runtime.log_lines.append(line) + set_runtime_phase_locked( + runtime, + _scene_engine_phase_from_log(line, runtime.phase_key), + ) + _scene_runs.finish(session_id, token, process) + + scene_export = output_root / "scene_export" / "scene_config.json" + if process.returncode != 0 or not scene_export.is_file(): + detail = ( + f"Scene Engine exited with code {process.returncode}." + if process.returncode != 0 + else f"Scene Engine did not create {scene_export}." + ) + with runtime_lock: + if not _scene_runs.is_active(session_id, token): + return + runtime.is_busy = False + runtime.scene_engine_is_running = False + set_runtime_phase_locked(runtime, "failed") + runtime.status = detail + runtime.last_error = detail + yield _scene_engine_updates(runtime, output_root, preview_html) + return + + preview_error: str | None = None + with _preview_start_lock: + port = _select_available_port(SCENE_ENGINE_VISER_PORT) + preview_command = [ + sys.executable, + COMMANDS["scene_engine"]["preview_script"], + "--output_root", + str(output_root), + "--viser", + "--viser-host", + "0.0.0.0", + "--viser-port", + str(port), + ] + try: + preview_process = start_pipeline(preview_command) + except Exception as exc: + preview_error = f"Viser preview start failed: {exc}" + else: + if not _scene_runs.attach(session_id, token, preview_process): + terminate_process_group(preview_process) + return + with runtime_lock: + runtime.log_lines.append("$ " + " ".join(preview_command)) + set_runtime_phase_locked(runtime, "preview") + runtime.status = "Starting Viser preview..." + + if not _wait_for_viser(port, preview_process): + terminate_process_group(preview_process) + _scene_runs.finish(session_id, token, preview_process) + preview_error = "Viser preview did not start." + + if preview_error is not None: + with runtime_lock: + if not _scene_runs.is_active(session_id, token): + return + runtime.is_busy = False + runtime.scene_engine_is_running = False + set_runtime_phase_locked(runtime, "failed") + runtime.status = preview_error + runtime.last_error = preview_error + yield _scene_engine_updates(runtime, output_root, preview_html) + return + + preview_html = _viser_iframe(port, scene_hash) + with runtime_lock: + if not _scene_runs.is_active(session_id, token, preview_process): + terminate_process_group(preview_process) + return + runtime.is_busy = False + runtime.scene_engine_is_running = False + set_runtime_phase_locked(runtime, "complete") + runtime.status = "Scene generated successfully. Viser preview is ready." + runtime.last_error = None + yield _scene_engine_updates(runtime, output_root, preview_html) + + +def _action_scene_is_available() -> bool: + return FAST_GYM_CONFIG.is_file() and AGENT_CONFIG.is_file() + + +def _action_agent_cli_is_available() -> bool: + try: + return importlib.util.find_spec(COMMANDS["agent"]["module"]) is not None + except (ImportError, ModuleNotFoundError, ValueError): + return False + + +def run_action_engine_from_current( + task_text: str, + robot_profile: str | None, + request: gr.Request, +) -> tuple[object, ...]: + """Launch DexSim for the requesting Gradio session. + + Args: + task_text: Natural-language task for the action agent. + robot_profile: Optional robot selection exposed by the UI. + request: Gradio request carrying the owning session hash. + + Returns: + Current Action Engine widget values for the session. + """ + session_id = get_request_session_id(request) + task_text = (task_text or "").strip() + with runtime_lock: + runtime = runtime_registry.get(session_id) + if not task_text: + failure = "Enter a task description first." + elif not _action_scene_is_available(): + failure = "Current Gym scene/config is unavailable." + elif runtime.is_busy: + failure = "Another engine is already running in this session." + elif not _action_agent_cli_is_available(): + failure = "Action-agent CLI is unavailable in this environment." + else: + failure = None + token = _action_runs.begin(session_id) + runtime.is_busy = True + runtime.task_text = task_text + runtime.status = "Starting DexSim action simulation..." + runtime.last_error = None + runtime.log_lines.clear() + set_runtime_phase_locked(runtime, "started") + + if failure is not None: + runtime.status = failure + runtime.last_error = failure + + if failure is None: + error = _launch_current_simulation( + session_id, + runtime, + token, + robot_profile=robot_profile, + ) + if error: + with runtime_lock: + if _action_runs.is_active(session_id, token): + runtime.is_busy = False + set_runtime_phase_locked(runtime, "failed") + runtime.status = error + runtime.last_error = error + return ui_snapshot(session_id) + + +def stop_action_engine(request: gr.Request) -> tuple[object, ...]: + """Stop only the requesting session's Action processes and reset its UI. + + The DexSim process group includes any child processes it launches. The + separately managed Action scene preview is also stopped. Other sessions and + the requesting session's Scene and Asset processes remain untouched. + + Args: + request: Gradio request for the browser session initiating Stop. + + Returns: + Reset values for the Action preview, status, video, task, and progress + widgets. + """ + session_id = get_request_session_id(request) + with runtime_lock: + runtime = runtime_registry.get(session_id) + _action_runs.reset(session_id, force=True) + _action_preview_runs.reset(session_id, force=True) + if not runtime.scene_engine_is_running: + runtime.is_busy = False + set_runtime_phase_locked(runtime, "idle") + runtime.status = "Action Engine stopped." + runtime.last_error = None + runtime.log_lines.clear() + runtime.task_text = "" + runtime.video_path = None + runtime.last_sent_video_signature = None + + return ( + _ACTION_IDLE_PREVIEW, + "**Scene preview:** Action Engine stopped.", + None, + "", + PHASES["idle"].progress, + format_status("Action Engine stopped."), + ) + + +def cleanup_workflow_session(request: gr.Request) -> None: + """Stop Scene and Action processes owned by a disconnected session. + + Args: + request: Gradio unload request carrying the disconnected session hash. + """ + session_id = get_request_session_id(request) + with runtime_lock: + _scene_runs.reset(session_id, force=True) + _action_runs.reset(session_id, force=True) + _action_preview_runs.reset(session_id, force=True) + runtime_registry.reset(session_id) + + +def _launch_current_simulation( + session_id: str, + runtime: RuntimeState, + token: str, + *, + robot_profile: str | None = None, +) -> str | None: + command = build_run_agent_command(robot_profile=robot_profile) + started_at_ns = time.time_ns() + try: + process = start_pipeline(command) + except Exception as exc: + return f"DexSim launch failed: {exc}" + + output_queue: queue.Queue[str] = queue.Queue() + reader = threading.Thread( + target=read_process_output, + args=(process, output_queue), + daemon=True, + ) + monitor = threading.Thread( + target=_monitor_simulation, + args=(session_id, runtime, token, process, output_queue, reader, started_at_ns), + daemon=True, + ) + + if not _action_runs.attach(session_id, token, process): + terminate_process_group(process) + return None + with runtime_lock: + if not _action_runs.is_active(session_id, token, process): + terminate_process_group(process) + return None + runtime.log_lines.append("$ " + " ".join(command)) + + reader.start() + monitor.start() + return None + + +def _monitor_simulation( + session_id: str, + runtime: RuntimeState, + token: str, + process: subprocess.Popen[str], + output_queue: queue.Queue[str], + reader: threading.Thread, + started_at_ns: int, +) -> None: + while process.poll() is None: + _append_simulation_logs( + session_id, + runtime, + token, + process, + _drain_output_queue(output_queue), + ) + if not _action_runs.is_active(session_id, token, process): + return + time.sleep(0.5) + + reader.join(timeout=1.0) + _append_simulation_logs( + session_id, + runtime, + token, + process, + _drain_output_queue(output_queue), + ) + if not _action_runs.is_active(session_id, token, process): + return + source_video = latest_audience_output_video(min_mtime_ns=started_at_ns) + display_video: Path | None = None + if source_video is not None: + destination = GEN_SIM_ROOT / "action_videos" / token / source_video.name + try: + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_video, destination) + display_video = destination + except OSError as exc: + _append_simulation_logs( + session_id, + runtime, + token, + process, + [f"Could not copy the simulation preview into the workspace: {exc}"], + ) + + with runtime_lock: + if not _action_runs.is_active(session_id, token, process): + return + runtime.is_busy = False + runtime.video_path = display_video + if process.returncode == 0: + set_runtime_phase_locked(runtime, "complete") + runtime.status = "DexSim simulation finished successfully." + runtime.last_error = None + if display_video is None: + runtime.log_lines.append("No simulation preview video was found.") + else: + set_runtime_phase_locked(runtime, "failed") + runtime.status = f"DexSim exited with return code {process.returncode}." + runtime.last_error = runtime.status + _action_runs.finish(session_id, token, process) + + +def _append_simulation_logs( + session_id: str, + runtime: RuntimeState, + token: str, + process: subprocess.Popen[str], + lines: list[str], +) -> None: + if not lines: + return + with runtime_lock: + if _action_runs.is_active(session_id, token, process): + runtime.log_lines.extend(lines) + + +def ui_snapshot( + session_id: str, + extra_status: str | None = None, +) -> tuple[object, ...]: + """Return Action-engine widget values for one Gradio session. + + Args: + session_id: Stable Gradio session identifier. + extra_status: Optional text appended to the stored status. + + Returns: + Video, task, progress, status, and compatibility placeholder values. + """ + with runtime_lock: + runtime = runtime_registry.get(session_id) + phase = PHASES.get(runtime.phase_key, PHASES["idle"]) + video_value = None + video_signature = None + if runtime.video_path and runtime.video_path.is_file(): + video_value = runtime.video_path.as_posix() + video_signature = (video_value, runtime.video_path.stat().st_mtime_ns) + if video_signature != runtime.last_sent_video_signature: + runtime.last_sent_video_signature = video_signature + video_update = video_value + else: + video_update = gr.update() + task_text = runtime.task_text + status_text = runtime.status + if extra_status: + status_text = f"{status_text}\n{extra_status}" + busy = runtime.is_busy + last_error = runtime.last_error + + return ( + video_update, + task_text, + phase.progress, + format_status( + status_text, + phase=phase, + busy=busy, + last_error=last_error, + ), + None, + None, + None, + ) + + +def format_status( + status_text: str, + *, + phase: Phase | None = None, + busy: bool = False, + last_error: str | None = None, +) -> str: + """Format an engine status for display in Gradio.""" + if phase is None: + phase = PHASES["idle"] + state = "running" if busy else "ready" + parts = [ + f"**State:** {state}", + f"**Phase:** {phase.progress}% - {phase.label}", + f"**Status:** {status_text}", + ] + if last_error: + escaped_error = last_error.replace("`", "'") + if "\n" in escaped_error: + parts.append(f"**Last error:**\n```text\n{escaped_error}\n```") + else: + parts.append(f"**Last error:** `{escaped_error}`") + return "\n\n".join(parts) diff --git a/embodichain/gen_sim/gradio_ui/assets/dexforce.png b/embodichain/gen_sim/gradio_ui/assets/dexforce.png new file mode 100644 index 000000000..6a8e11b00 Binary files /dev/null and b/embodichain/gen_sim/gradio_ui/assets/dexforce.png differ diff --git a/embodichain/gen_sim/gradio_ui/gradio_app.py b/embodichain/gen_sim/gradio_ui/gradio_app.py new file mode 100644 index 000000000..17d0b79d1 --- /dev/null +++ b/embodichain/gen_sim/gradio_ui/gradio_app.py @@ -0,0 +1,105 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Application entry point. + +The UI callbacks and pipeline services are intentionally kept out of this +module; this file only validates configuration and launches the application. +""" + +from __future__ import annotations + +import signal + +from app_config import ( + ASSETS_DIR, + DEFAULT_CONCURRENCY_LIMIT, + GEN_SIM_ROOT, +) +from app_env import ( + ARTICRAFT_OUTPUT_ROOT, + EMBODICHAIN_ROOT, + SERVER_NAME, + SERVER_PORT, + build_gradio_allowed_paths, + build_gradio_blocked_paths, + get_gradio_auth, + validate_gradio_artifact_root, +) +from app_processes import force_stop_all_child_processes +from app_services import build_app +from embodichain.gen_sim.env import find_gen_sim_env_file + +__all__ = ["main"] + + +def _stop_child_processes() -> None: + """Force-stop UI-owned subprocesses without masking app shutdown.""" + try: + force_stop_all_child_processes() + except Exception: + # Shutdown must not be blocked by an already-exited preview process. + pass + + +def _handle_shutdown_signal(signum: int, _frame: object) -> None: + """Terminate UI subprocesses before leaving the Gradio process.""" + _stop_child_processes() + if signum == signal.SIGINT: + raise KeyboardInterrupt + raise SystemExit(128 + signum) + + +def _install_shutdown_handlers() -> None: + """Install cleanup-aware handlers for the normal Gradio stop signals.""" + signal.signal(signal.SIGINT, _handle_shutdown_signal) + signal.signal(signal.SIGTERM, _handle_shutdown_signal) + + +def _allowed_paths() -> list[str]: + """Return only static assets and workspace-generated artifact roots.""" + return build_gradio_allowed_paths( + ASSETS_DIR, + GEN_SIM_ROOT, + validate_gradio_artifact_root(ARTICRAFT_OUTPUT_ROOT), + ) + + +def _blocked_paths() -> list[str]: + """Return source-control and dotenv paths that Gradio must never serve.""" + return build_gradio_blocked_paths(find_gen_sim_env_file()) + + +def main() -> None: + if not EMBODICHAIN_ROOT.is_dir(): + raise FileNotFoundError(f"EmbodiChain root not found: {EMBODICHAIN_ROOT}") + app = build_app() + app.queue(default_concurrency_limit=DEFAULT_CONCURRENCY_LIMIT) + _install_shutdown_handlers() + try: + app.launch( + server_name=SERVER_NAME, + server_port=SERVER_PORT, + auth=get_gradio_auth(), + allowed_paths=_allowed_paths(), + blocked_paths=_blocked_paths(), + ) + finally: + _stop_child_processes() + + +if __name__ == "__main__": + main() diff --git a/embodichain/gen_sim/gradio_ui/gradio_visualization_architecture.md b/embodichain/gen_sim/gradio_ui/gradio_visualization_architecture.md new file mode 100644 index 000000000..ce3a41696 --- /dev/null +++ b/embodichain/gen_sim/gradio_ui/gradio_visualization_architecture.md @@ -0,0 +1,243 @@ +# Gradio 可视化系统架构 + +本文档以当前代码为准,描述 Gradio 中的三个引擎,以及它们与 EmbodiChain、SimReady、Articraft 和 DexSim 的边界。`gradio_app.py` 只负责启动;界面、资产工作流、场景工作流和进程管理分散在专用模块中。 + +## 架构总览 + +```text +gradio_app.py + │ 启动、队列、allowed_paths + ▼ +app_services.py(兼容门面) + ▼ +app_ui.py ───────────► app_asset_engine.py ───► SimReady CLI + │ 布局、引擎选择和事件绑定 │ │ + │ │ └──────────► app_articraft.py ───► Articraft CLI + Codex CLI + ▼ +app_workflows.py ────► EmbodiChain Scene Engine CLI + Viser + │ └► action-agent `run_agent` + DexSim + ├──────────────► app_commands.py 命令构造 + ├──────────────► app_processes.py 子进程、环境、日志和阶段检测 + ├──────────────► app_state.py 共享 RuntimeState、锁和计时 + ├──────────────► app_media.py 视频、数据集预览和日志归档 + └──────────────► app_config.py UI 常量、路径推导和命令定义 + └──────────────► app_env.py 部署配置读取 + └──────────► ../.env 部署路径、端口和服务凭据 +``` + +| 模块 | 职责 | +| --- | --- | +| `gradio_app.py` | 唯一启动入口;校验 `EMBODICHAIN_ROOT`,创建 Blocks,设置队列和本地文件访问路径。 | +| `app_ui.py` | 顶部图标、引擎面板切换和回调绑定;不实现 pipeline。 | +| `app_asset_engine.py` | SimReady 上传适配、输入/输出 GLB 预览、处理日志,以及 Asset engine 的 Articraft 标签页。 | +| `app_articraft.py` | Articraft checkout/环境检查、外部记录创建、Codex 生成与校验、URDF bundle 和 Viser 关节预览。 | +| `app_workflows.py` | Scene Engine 工作流、Action Engine 的会话状态、Viser 预览和 DexSim。 | +| `app_processes.py` | 子进程环境、进程组终止、stdout 读取和 pipeline 阶段检测。 | +| `app_state.py` | `RuntimeState`、互斥锁、进度阶段、运行 token 和耗时统计。 | +| `app_commands.py` | Action engine 的 `run_agent` 参数构造。 | +| `app_media.py` | DexSim 观众视频发现和 Articraft Viser CLI 适配。 | +| `app_config.py` | UI 文案、引擎模式、路径推导和 CLI 固定参数。 | +| `app_env.py` | 从 `.env` 读取 Gradio、Articraft 和 SimReady 的部署值,并保留未配置时的默认值。 | +| `../.env` | Gradio 与 Scene Engine 共用的路径、端口、LLM 和服务端点配置;不提交凭据。 | + +## 启动、路径和网络环境 + +从本项目目录启动: + +```bash +conda run -n embodichain python gradio_app.py +``` + +| 变量 | 默认值 | 用途 | +| --- | --- | --- | +| EmbodiChain root | 自动从 `embodichain/gen_sim/env.py` 的源码位置推导 | EmbodiChain 根目录;不再从 `.env` 配置。 | +| `GRADIO_SERVER_NAME` | `127.0.0.1` | Gradio 监听地址;非回环地址必须启用认证。 | +| `GRADIO_SERVER_PORT` | `7860` | Gradio 监听端口。 | +| `GRADIO_AUTH_USERNAME` | 空 | 非本机部署的 Gradio 用户名。 | +| `GRADIO_AUTH_PASSWORD` | 空 | 非本机部署的 Gradio 密码。 | +| `SCENE_ENGINE_VISER_PORT` | `8080` | Scene Engine 的首选 Viser 端口;占用时为会话分配其他可用端口。 | +| `ARTICRAFT_VISER_PORT` | `8081` | Articraft 关节预览的首选 Viser 端口;占用时为会话分配其他可用端口。 | +| `ACTION_ENGINE_VISER_PORT` | `8082` | Action Engine 已保存场景预览的首选 Viser 端口;占用时为会话分配其他可用端口。 | +| `ARTICRAFT_ROOT` | `<项目>/.articraft` | Articraft checkout。 | +| `ARTICRAFT_CONDA_ENV` | `articraft` | 运行 Articraft CLI 的 Conda 环境。 | +| `ARTICRAFT_OUTPUT_ROOT` | `<项目>/.gen_sim/articraft` | Articraft 记录、运行日志和导出 bundle。 | + +`app.launch()` 仅开放 UI 静态资源、`.gen_sim/` 生成物和配置的 Articraft 输出目录,并显式禁止 `.env`、`.git/` 等敏感路径。pipeline 子进程由 `build_pipeline_env()` 从共享 `.env` 创建环境:它清除代理变量、设置 `NO_PROXY=no_proxy=*` 并关闭 Gradio analytics。只有 SimReady 子进程会额外把非空的 `SIMREADY_OPENAI_*` 映射为其上游 CLI 需要的 `OPENAI_*`;Scene Engine、DexSim、Viser 和 Articraft 直接继承 `.env` 中的原始配置。Codex 作为用户指令驱动的子进程,使用独立登录状态和最小化环境,不继承 GenSim 服务凭据。 + +## 页面与引擎 + +页面顶部保留 DexForce 图标,并直接显示 `Asset_engine`、`Scene_engine`、`Action_engine` 三个入口,不再提供模式切换。它们的实际输入和产物并不完全相同: + +| Engine | 输入 | 预览/下载 | 实际产物 | 是否启动 DexSim | +| --- | --- | --- | --- | --- | +| Asset engine / SimReady | 一个网格、可选材质附件、类别 | 输入 GLB、SimReady GLB、原始输出下载 | `.gen_sim/assets/runs//` | 否 | +| Asset engine / Articulation | 文字、可选参考图 | URDF articulation 的 Viser、zip 下载 | `.gen_sim/articraft/` | 否 | +| Scene engine | 一张图片 | Scene Engine 的 Viser | `.gen_sim/scenes//` | 否 | +| Action engine | 已生成场景列表、任务、机器人 | 选中场景的 Viser 和 DexSim 视频 | 场景预览来自 `.gen_sim/scenes/`;DexSim 暂沿用现有命令 | 是 | + +因此,Scene engine 是独立的图像条件场景生成器;它不会提升、复制或转换输出到 `gym_project/current`。Action engine 只消费已有的 `current` Gym 场景。界面中的 “Scene engine” 文案表达的是所需场景类型,并不意味着独立 Scene Engine 输出已自动连到 Action engine。 + +Scene/Action 与 Asset engine 使用同一会话边界:Gradio 回调从 `request.session_hash` 取得会话 ID。每个 ID 拥有独立的 `RuntimeState`,Scene 生成/Viser、Action DexSim 和 Action Viser 分别由以该 ID 为键的进程 registry 管理。Reset、Stop、同会话新任务替换及页面卸载只会终止该会话的进程;其他浏览器会话的进程和 UI 状态不受影响。 + +## Asset engine + +### SimReady:单资产目录适配 + +SimReady CLI 接收目录,而 Gradio 接收上传文件。上传文件会复制到隔离目录,文件名只保留 basename,重名追加序号,避免上传路径或重名影响处理: + +```text +mesh + sidecar files + → .gen_sim/assets/runs//input/ + → trimesh 导出 input_preview.glb + → SimReady CLI + → output/**/asset_simready.glb(优先)或 asset_simready.obj + → GLB 预览 + 原始文件下载 +``` + +主网格支持 `.glb`、`.gltf`、`.obj`、`.ply`、`.stl`;可一并上传 `.mtl`、纹理和 `.bin` 等附件。执行命令为: + +```bash +python -m embodichain.gen_sim.simready_pipeline.cli.start \ + --input_dir \ + --output_root \ + --category +``` + +处理函数以 generator 持续返回最近的 stdout;完成时优先预览 `asset_simready.glb`,只有 OBJ 时再转为 GLB。此路径不依赖 DexSim。 + +`Reset SimReady` 会清空当前浏览器会话的上传、类别、预览、下载项和日志,并仅按进程组终止该会话正在运行的 SimReady CLI 及其子进程。 + +### Articulation:Articraft + Codex + +Articulation 标签页根据文本和可选参考图生成一个可下载的 articulated asset。先点击环境检查:若 `ARTICRAFT_ROOT` 不存在,应用会 clone `ARTICRAFT_REPOSITORY_URL`;随后检查 Conda、指定的 Articraft 环境和 Codex CLI。该操作会创建 checkout 和 `.gen_sim/articraft/` 中的输出目录,现有的非 Articraft 目录不会被覆盖。 + +生成流程: + +```text +description + optional image + → Articraft external init(创建 rec_ui_articraft_* 记录) + → 启动 Codex CLI,仅授权编辑该记录的 active model.py + → Articraft external check + └─ 旧版 CLI 无 check 时:compile --validate --strict-geom-qc + compile_report + → Articraft external finalize + → materialized model.urdf + meshes + → exports/.zip + Viser articulation preview +``` + +产物、记录和参考图均在 `ARTICRAFT_OUTPUT_ROOT` 下,不能直接当作 Action engine 的 Gym 场景或 SimReady 资产;若要进入后续仿真,需要另行定义并实现转换/导入流程。Articraft Viser 按 Gradio 会话管理预览进程:首先尝试 `ARTICRAFT_VISER_PORT`,若已被占用则分配其他可用端口,不会查找或发送信号给占用端口的外部进程。 + +`Reset Articulation` 会清空当前会话的描述、参考图、记录与下载结果,终止该会话的 Articraft/Codex 命令进程组,并关闭该会话启动的 Viser。 + +## 独立 Scene engine 和 Viser + +Scene engine 只接收图像。上传图像会先进行 EXIF 归正并转为 RGB PNG,以 PNG 字节的 SHA-256 前 16 位作为目录名;相同图像会复用同一目录: + +```text +image + → .gen_sim/scenes//input.png + → python -m embodichain scene-engine + --image + --output_root + → /scene_export/scene_config.json + → preview.py --viser --viser-host 0.0.0.0 --viser-port + → Gradio iframe +``` + +当 `scene_export/scene_config.json` 存在且生成进程返回成功时,应用才启动 Viser。iframe 使用 Gradio 页面当前的协议和主机名转向 Viser 端口,因此从其他设备访问时,浏览器必须能访问该端口。每个会话优先使用 `SCENE_ENGINE_VISER_PORT`,占用时选择其他可用端口;同会话的新 Scene Engine 任务会终止该会话旧的 Scene Viser,不会终止其他会话的预览。输出目录会显示在 UI 中,便于检查 hash 命名的场景导出。 + +`Reset Scene Engine` 会清空当前会话的图像、进度、输出目录和 iframe,并终止该会话当前生成命令与 Scene Viser 的进程组;registry 运行 token 会使已经失效的生成器停止回写界面。 + +## Action engine:Gym 场景契约 + +Action engine 不接收裸 GLB。普通 GLB 只有渲染数据,而 DexSim 还需要碰撞、物理参数、初始位姿、资源相对路径和 action 配置。当前实现的前置条件是: + +```text +gym_project/current/gym_export/ +gym_project/action_agent_pipeline/configs/current/fast_gym_config.json +gym_project/action_agent_pipeline/configs/current/agent_config.json +``` + +进入 Action engine 或点击 `Refresh scenes` 会扫描 `.gen_sim/scenes/`,只列出包含 `scene_export/scene_config.json` 的完整场景。列表不会自动选中场景;用户显式选择后,右侧通过 Viser 展示该场景。当前场景选择只负责可视化,尚未传递给 DexSim 命令。 + +点击 `Run DexSim` 仍会检查任务、现有 `current` Gym/action 配置、运行占用和可导入的 `embodichain.gen_sim.action_agent_pipeline.cli.run_agent`,再以当前配置调用 `run_agent`。 + +运行命令的核心参数为: + +```bash +python -m embodichain.gen_sim.action_agent_pipeline.cli.run_agent \ + --task_name current \ + --gym_config <.../fast_gym_config.json> \ + --agent_config <.../agent_config.json> \ + --regenerate --renderer fast-rt --num_envs 1 +``` + +`--robot-profile` 仅在通过 `run_agent --help` 探测到该参数时加入。DexSim 完成后会寻找本次运行产生的 audience 视频并显示在 Action engine 中。 + +`Stop Action Engine` 仅重置当前会话的 Action 进程 registry,终止该会话的 DexSim 进程组和已保存场景 Viser。它不终止同会话的 Scene Engine,也不影响其他浏览器会话。 + +## 会话状态、并发和进度 + +Scene engine 和 Action engine 的 UI 状态存放在 `SessionRuntimeRegistry`,键为 `request.session_hash`。`RuntimeState` 只包含当前会话的输入、预览、日志和阶段,不再保存服务器级全局进程引用。Scene 生成/Viser、Action DexSim 和 Action Viser 有独立的 `SessionProcessRegistry`;每个 registry 的 token 用于丢弃同会话内过期线程的更新。SimReady 和 Articraft 使用相同的会话键。Reset、Stop 和页面卸载只清理所属会话,并直接向该会话所属进程组发送 `SIGKILL`,不等待交互式任务优雅退出。Gradio 应用正常关闭仍统一清理全部已注册子进程,并保留 `SIGTERM` 宽限期后再升级为 `SIGKILL` 的原有逻辑。 + +`app.queue(default_concurrency_limit=1)` 将队列中的高成本回调串行化。Action engine 的 `Timer(2.0)` 通过当前请求的 `session_hash` 只读取该会话状态。Asset/Articraft 使用各自的会话状态,但仍会受 Gradio 队列限制。 + +共享阶段如下;独立 Scene Engine 将其日志映射到相同的进度条: + +```text +idle → received → started → scene_intake → relations +→ asset_generation → gym_export → config → preview → complete + └──────────────→ failed +``` + +## 环境前置条件与验证 + +SimReady 需要 Blender、trimesh、LLM 配置以及可导入的: + +```text +embodichain.gen_sim.simready_pipeline.cli.start +``` + +SimReady 的 OpenAI-compatible 设置来自环境变量,且不应写入 Git: + +```bash +export SIMREADY_OPENAI_API_KEY='' +export SIMREADY_OPENAI_MODEL='' +export SIMREADY_OPENAI_BASE_URL='' +``` + +Action engine 只需要 action-agent 的运行模块: + +```text +embodichain.gen_sim.action_agent_pipeline.cli.run_agent +``` + +独立 Scene engine 还需要: + +```text +python -m embodichain scene-engine +embodichain/gen_sim/scene_engine/cli/preview.py +.env +``` + +Articulation 还需要 Git(首次 clone)、Conda、`ARTICRAFT_CONDA_ENV` 和已通过独立凭据存储完成登录的 Codex CLI。Codex 子进程不继承 `.env` 中的 API key、token 或密码,输出在返回浏览器前还会按已知敏感环境值脱敏。生成请求会交给本机 Codex CLI 执行,因此默认只在本机可信工作台中使用。 + +每次修改后至少执行: + +```bash +python -m py_compile \ + gradio_app.py app_config.py app_env.py app_state.py app_commands.py \ + app_processes.py app_media.py app_workflows.py app_ui.py \ + app_asset_engine.py app_articraft.py app_services.py + +env -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY \ + -u http_proxy -u https_proxy -u all_proxy \ + conda run -n embodichain python -c \ + "from app_ui import build_app; assert build_app() is not None" +``` + +手动检查: + +1. SimReady 上传简单网格后能显示输入预览;执行后显示 SimReady 输出或明确错误。 +2. Articulation 环境检查能报告 checkout、Conda 和 Codex 状态;成功生成后有 zip、记录目录和 Viser 或明确的预览错误。 +3. Scene engine 从图像生成 `scene_export/scene_config.json`,并以 `8080` 为首选端口显示 Viser;它不应改写 `gym_project/current`。 +4. Action engine 在没有 `current` Gym/action 配置或缺少 CLI 时给出预检错误。 diff --git a/pyproject.toml b/pyproject.toml index ae17152e6..aae8fd9af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ dependencies = [ [project.optional-dependencies] gensim = [ "bpy", + "gradio>=6.17.3,<6.18", "pyrender==0.1.45", "requests", "Pillow", diff --git a/tests/gen_sim/__init__.py b/tests/gen_sim/__init__.py new file mode 100644 index 000000000..355d915ff --- /dev/null +++ b/tests/gen_sim/__init__.py @@ -0,0 +1,17 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +from __future__ import annotations diff --git a/tests/gen_sim/gradio_ui/__init__.py b/tests/gen_sim/gradio_ui/__init__.py new file mode 100644 index 000000000..355d915ff --- /dev/null +++ b/tests/gen_sim/gradio_ui/__init__.py @@ -0,0 +1,17 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +from __future__ import annotations diff --git a/tests/gen_sim/gradio_ui/test_app_articraft.py b/tests/gen_sim/gradio_ui/test_app_articraft.py new file mode 100644 index 000000000..bfc68e1ca --- /dev/null +++ b/tests/gen_sim/gradio_ui/test_app_articraft.py @@ -0,0 +1,59 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +from __future__ import annotations + +import socket +import sys +from pathlib import Path + +import pytest + +GRADIO_UI_ROOT = ( + Path(__file__).resolve().parents[3] / "embodichain" / "gen_sim" / "gradio_ui" +) +sys.path.insert(0, str(GRADIO_UI_ROOT)) + +import app_articraft # noqa: E402 + + +def test_preview_selects_another_port_when_preferred_port_is_occupied() -> None: + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.bind(("127.0.0.1", 0)) + listener.listen() + occupied_port = int(listener.getsockname()[1]) + previews = app_articraft._ArticraftViserPreview(occupied_port) + try: + selected_port = previews._select_available_port() + finally: + listener.close() + + assert selected_port != occupied_port + assert selected_port > 0 + + +def test_codex_checkout_cannot_be_the_embodichain_repository( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + app_articraft, + "ARTICRAFT_ROOT", + app_articraft.EMBODICHAIN_ROOT, + ) + + assert "dedicated nested or external Git checkout" in ( + app_articraft._articraft_isolation_error() or "" + ) diff --git a/tests/gen_sim/gradio_ui/test_app_env.py b/tests/gen_sim/gradio_ui/test_app_env.py new file mode 100644 index 000000000..27231eb51 --- /dev/null +++ b/tests/gen_sim/gradio_ui/test_app_env.py @@ -0,0 +1,72 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +GRADIO_UI_ROOT = ( + Path(__file__).resolve().parents[3] / "embodichain" / "gen_sim" / "gradio_ui" +) +sys.path.insert(0, str(GRADIO_UI_ROOT)) + +import app_env # noqa: E402 + + +def test_local_gradio_server_does_not_require_authentication() -> None: + assert app_env.get_gradio_auth("127.0.0.1", "", "") is None + + +def test_remote_gradio_server_requires_authentication() -> None: + with pytest.raises(ValueError, match="requires Gradio authentication"): + app_env.get_gradio_auth("0.0.0.0", "", "") + + +def test_remote_gradio_server_accepts_complete_credentials() -> None: + assert app_env.get_gradio_auth("0.0.0.0", "workspace", "secret") == ( + "workspace", + "secret", + ) + + +def test_partial_gradio_credentials_are_rejected() -> None: + with pytest.raises(ValueError, match="Set both"): + app_env.get_gradio_auth("127.0.0.1", "workspace", "") + + +def test_gradio_file_access_excludes_repository_and_blocks_dotenv( + tmp_path: Path, +) -> None: + generated_root = tmp_path / "generated" + static_root = tmp_path / "static" + external_env = tmp_path / "deployment.env" + + allowed = app_env.build_gradio_allowed_paths(generated_root, static_root) + blocked = app_env.build_gradio_blocked_paths(external_env) + + assert str(app_env.EMBODICHAIN_ROOT.resolve()) not in allowed + assert str(generated_root.resolve()) in allowed + assert str(static_root.resolve()) in allowed + assert str(external_env.resolve()) in blocked + assert str((app_env.EMBODICHAIN_ROOT / ".git").resolve()) in blocked + + +def test_repository_cannot_be_configured_as_artifact_root() -> None: + with pytest.raises(ValueError, match="dedicated artifact directory"): + app_env.validate_gradio_artifact_root(app_env.EMBODICHAIN_ROOT) diff --git a/tests/gen_sim/scene_engine/__init__.py b/tests/gen_sim/scene_engine/__init__.py new file mode 100644 index 000000000..355d915ff --- /dev/null +++ b/tests/gen_sim/scene_engine/__init__.py @@ -0,0 +1,17 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +from __future__ import annotations diff --git a/tests/gen_sim/scene_engine/test_scene_engine_config.py b/tests/gen_sim/scene_engine/test_config.py similarity index 100% rename from tests/gen_sim/scene_engine/test_scene_engine_config.py rename to tests/gen_sim/scene_engine/test_config.py diff --git a/tests/gen_sim/simready_pipeline/__init__.py b/tests/gen_sim/simready_pipeline/__init__.py new file mode 100644 index 000000000..355d915ff --- /dev/null +++ b/tests/gen_sim/simready_pipeline/__init__.py @@ -0,0 +1,17 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +from __future__ import annotations diff --git a/tests/gen_sim/test_gen_sim_env.py b/tests/gen_sim/test_gen_sim_env.py new file mode 100644 index 000000000..215a12813 --- /dev/null +++ b/tests/gen_sim/test_gen_sim_env.py @@ -0,0 +1,65 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from embodichain.gen_sim import env as gen_sim_env + + +def test_missing_default_env_file_is_optional( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + module_path = tmp_path / "embodichain" / "gen_sim" / "env.py" + monkeypatch.delenv("EMBODICHAIN_ENV_FILE", raising=False) + monkeypatch.setattr(gen_sim_env, "__file__", str(module_path)) + + assert gen_sim_env.find_gen_sim_env_file() is None + assert gen_sim_env.load_gen_sim_env({}) is None + + +def test_missing_configured_env_file_is_optional( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + env_path = tmp_path / "missing.env" + monkeypatch.setenv("EMBODICHAIN_ENV_FILE", str(env_path)) + + assert gen_sim_env.find_gen_sim_env_file() == env_path.resolve() + assert gen_sim_env.load_gen_sim_env({}) is None + + +def test_shell_values_take_precedence_over_dotenv( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + env_path = tmp_path / ".env" + env_path.write_text( + "OPENAI_MODEL=dotenv-model\nOPENAI_API_KEY=dotenv-key\n", + encoding="utf-8", + ) + monkeypatch.setenv("EMBODICHAIN_ENV_FILE", str(env_path)) + target_env = {"OPENAI_MODEL": "shell-model"} + + assert gen_sim_env.load_gen_sim_env(target_env) == env_path.resolve() + assert target_env == { + "OPENAI_MODEL": "shell-model", + "OPENAI_API_KEY": "dotenv-key", + }