diff --git a/.agents/skills/hhtools-agent/SKILL.md b/.agents/skills/hhtools-agent/SKILL.md new file mode 100644 index 00000000..0ce6f33d --- /dev/null +++ b/.agents/skills/hhtools-agent/SKILL.md @@ -0,0 +1,128 @@ +--- +name: hhtools-agent +description: "Run local HHTools human-to-humanoid (H2R) retargeting through the versioned MCP Agent interface: discover capabilities, register or inspect allowlisted motion and robot assets, preflight immutable smoke/full plans, pause for calibration, manage jobs, and review verified artifacts. Use for HHTools H2R execution, status, cancellation, retry, or result requests. Do not use for UI or solver-code edits, R2R, Batch, Interaction-Mesh, arbitrary filesystem access, remote service setup, or real-robot deployment." +--- + +# HHTools Agent + +Operate HHTools through its MCP tools and resources while preserving the service's asset, +plan, job, and artifact identities. Treat solver completion and motion quality as separate +claims. + +## Choose the workflow + +- For a new H2R run, follow the smoke-first workflow below. +- For an asset-only request, discover or register the asset, inspect it, and report the + structured inspection without starting a job. +- For an existing job with a known `job_id`, start with `get_job`; do not recreate its inputs or + submit another job. If an earlier start response was lost, recover only that caller-owned + submission with `lookup_job` using its exact recorded `plan_id` and idempotency key. +- For a status or result request, poll only that job and read only its job-scoped artifacts. +- For cancellation or retry, require an explicit user request and follow the lifecycle rules in + [errors and stops](references/errors-and-stops.md). + +If the HHTools MCP tools are unavailable, stop and explain that the local MCP integration must +be configured. Never substitute shell commands, the JSON CLI, REST calls, or direct filesystem +reads. The stdio server owns its service runtime and does not require `hhtools web` to be +running. Only one local runtime may own a given `save_dir`. The separate WebUI is used only +when a returned human `next_action` requests calibration; never request or read its session +token. + +## Run a new H2R job + +1. Call `get_capabilities`. Confirm the MCP feature, supported formats/backend, scheduler state, + allowlisted `asset_root_ids`, and robot readiness. Do not infer a GPU or backend that the + response does not report. +2. Resolve both content-addressed inputs. + - Prefer `search_assets` for an already registered motion or robot bundle. + - Register only with `register_asset_bundle` using a returned `root_id` and a portable + `relative_path`. Never pass or derive an absolute host path. + - Call `inspect_asset_bundle` with hash verification and parsing enabled for every selected + motion and robot bundle. Stop on `invalid`; surface warnings before continuing. + - Continue only when the motion inspection category is `plain_motion` and neither the + selected nor recommended backend is `interaction_mesh`. Stop on `object_interaction`, + `terrain_scene`, or Interaction-Mesh routing; this skill has no validated workflow for them. + - Select a supported `robot_id` from `list_robots` or the capability snapshot and pair it with + the inspected robot bundle's `asset_id`. Do not guess either identity. +3. Call `preflight_retarget` with a versioned `RetargetPreflightRequest`. Put + `run_mode: smoke` in `request.parameters`, use the currently supported + `output_policy: create_new`, and include the registered motion and robot asset IDs. Other + output policies are rejected in this phase. +4. Branch on the preflight `status`. + - `ready`: retain the returned immutable smoke `plan_id` and continue. + - `human_action_required`: pause and present every entry in `required_actions`. Stop or + disconnect the current stdio MCP runtime, ask the human to start the WebUI with the same + `save_dir`, and present the loopback calibration URL when supplied. After calibration, the + human must close the WebUI before MCP reconnects; then call capabilities again and perform + a new preflight. + - `rejected`: inspect the structured error and checks. Execute an `actor: agent` action only + when it matches the allowlisted action mapping below; otherwise stop and explain it. +5. Generate one caller-owned idempotency key for this logical submission. Call + `start_retarget(request={schema_version: "1.0", plan_id, idempotency_key})`; the nested request + contains only the ready plan identity and key. Persist the exact pair before submission. If the + transport result is ambiguous, call `lookup_job` with that pair before replaying the exact same + start request; never enumerate jobs or create a replacement key. +6. Poll with `get_job(job_id, after_revision=)`. Respect `poll_after_ms`; do not + busy-poll. Treat `queued` and `running` as nonterminal, and report queue/progress changes + without requesting large trajectories. +7. At terminal state, use `list_job_artifacts(job_id, ...)` for canonical membership, then read + `hhtools://jobs/{job_id}/artifacts/{artifact_id}` when one descriptor needs verification. Read + `hhtools://jobs/{job_id}/evaluation`, `/manifest`, and `/failures` only when relevant. + Resources expose verified structured reports or descriptors, not binary motion bytes. When the + user asks for an artifact file, call `export_artifact(job_id, artifact_id)`: it verifies and + materializes the file below the fixed `agent-exports` root and returns a portable receipt. Give + the receipt to the user; do not inspect private storage or request bytes in model context. +8. Inspect both `state` and `outcome`. `completed` alone is not quality approval. For a completed + job, present the evaluation and manifest and pause on `review_required`, `partial`, or + `rejected`. For `failed` or `cancelled`, follow the error rules and read failure/manifest + resources only when present. +9. Start a full run only after explicit user approval of the smoke evidence. Perform a new + preflight with `request.parameters.run_mode: full`, receive a different immutable full plan, + and submit it with a new idempotency key. Never promote or mutate the smoke plan. + +## Execute allowlisted agent actions + +The only automatic preflight recovery mapping is: + +| Returned action | MCP operation | Required behavior | +|---|---|---| +| `actor: agent`, `action: register_asset_bundle` | `register_asset_bundle` | Pass `next_action.parameters` unchanged as the tool arguments. It must contain exactly one `request` matching `AssetRegistrationRequest`. Inspect the returned robot bundle, replace `robot_asset_id` with its `asset_id`, and perform a new preflight. | + +Do not translate semantic action names, derive a host path, enumerate directories, or repair a +malformed action. If the action name, wrapper shape, `root_id`, or portable `relative_path` does +not validate against the live tool schema, stop and present the contract error. + +## Non-negotiable invariants + +| ID | Rule | +|---|---| +| `MCP_ONLY` | Use HHTools MCP tools/resources only; never fall back to shell, JSON CLI, REST, or direct service imports. | +| `ALLOWLISTED_ASSETS` | Asset registration accepts only a capability-advertised `root_id` plus normalized `relative_path`, never an arbitrary or absolute path. | +| `PLAIN_H2R_ONLY` | Start new jobs only for inspected `plain_motion` assets on a non-`interaction_mesh` route; stop on object interaction, terrain scenes, or Interaction-Mesh. | +| `PREFLIGHT_OWNS_MODE` | `run_mode` belongs in preflight `request.parameters`; `start_retarget` accepts only `plan_id` and `idempotency_key`. | +| `OUTPUT_CREATE_NEW` | Use `output_policy: create_new`; other output policies are unsupported in the current H2R Agent service. | +| `IDEMPOTENT_START` | Persist the exact plan and idempotency key, recover with `lookup_job`, and replay an ambiguous start only with that same plan and idempotency key; never create a second key for the same logical submission. | +| `IDEMPOTENT_RETRY` | Replay an ambiguous retry with the exact same parent job and retry idempotency key; never create a second child attempt. | +| `NEW_FULL_PLAN` | A full run requires explicit approval, a new full preflight, a new plan, and a new idempotency key. | +| `JOB_SCOPED_ARTIFACTS` | List, resolve, or export an artifact with both `job_id` and `artifact_id`; never trust or expose an unbound artifact identity. | +| `NO_BINARY_CONTEXT` | Keep binary motion, meshes, video, trajectories, and Base64 payloads out of tool arguments and model context; use `export_artifact` and its portable receipt for file delivery. | +| `HUMAN_GATES` | Pause for calibration and quality review; never guess calibration or equate `completed` with accepted motion quality. | +| `COOPERATIVE_CANCEL` | Running cancellation is a request checked at safe points; do not claim cancellation until the returned job state is terminal. | +| `HONEST_PROVENANCE` | Report only device and execution provenance present in capabilities or the manifest; never infer actual GPU use. | +| `SINGLE_RUNTIME_OWNER` | One local runtime may own a `save_dir`: disconnect stdio MCP before same-directory WebUI calibration, close WebUI before reconnecting MCP, then preflight again. | +| `LOCAL_BOUNDARY` | This skill covers local stdio only, with a loopback calibration UI. It provides no remote auth, multi-user isolation, worker resume, or real-robot deployment. | + +## Load references progressively + +- Read [contracts](references/contracts.md) before constructing an unfamiliar tool request, + selecting a schema resource, or interpreting an artifact. +- Read [errors and stops](references/errors-and-stops.md) for every non-ready preflight, + failed/partial/review-required job, cancellation, retry, hash failure, or ambiguous tool call. + +## Report the result + +Return a compact audit trail: selected asset IDs and robot ID, run mode and plan ID, job ID and +lineage, final state/outcome, evaluation verdict, canonical artifact IDs with hashes when +available, any artifact export receipt requested by the user, and any remaining human action. +Explicitly label unverified quality, unavailable actual-device provenance, and unsupported remote +or real-robot steps. diff --git a/.agents/skills/hhtools-agent/agents/openai.yaml b/.agents/skills/hhtools-agent/agents/openai.yaml new file mode 100644 index 00000000..b9d51483 --- /dev/null +++ b/.agents/skills/hhtools-agent/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "HHTools Agent" + short_description: "Run safe, preflighted HHTools H2R jobs" + default_prompt: "Use $hhtools-agent to preflight an H2R smoke run and stop for review before any full job." diff --git a/.agents/skills/hhtools-agent/references/contracts.md b/.agents/skills/hhtools-agent/references/contracts.md new file mode 100644 index 00000000..1b9e0aa9 --- /dev/null +++ b/.agents/skills/hhtools-agent/references/contracts.md @@ -0,0 +1,120 @@ +# HHTools Agent contract map + +Use the live MCP tool input/output schema as the runtime authority. These repository snapshots +explain the stable Agent v1 documents and are useful when a field, state, or resource is +unfamiliar. Load only the contracts needed for the current step. + +The architectural workflow and supported boundaries are documented in the +[Agent integration plan](../../../../docs/agent-integration-plan.md). + +## Tool and schema routing + +| MCP operation | Request contract | Success contract | +|---|---|---| +| `get_capabilities` | No request document | [capabilities](../../../../docs/schemas/agent/v1/capabilities.schema.json) | +| `list_robots` | No request document | [robot list](../../../../docs/schemas/agent/v1/robot-list-response.schema.json) | +| `register_asset_bundle` | [asset registration request](../../../../docs/schemas/agent/v1/asset-registration-request.schema.json) | [asset bundle](../../../../docs/schemas/agent/v1/asset-bundle.schema.json) | +| `search_assets` | Bounded scalar filters from the live tool schema | [asset search response](../../../../docs/schemas/agent/v1/asset-search-response.schema.json) | +| `inspect_asset_bundle` | `asset_id`, `verify_hashes`, and `parse_content` from the live tool schema | [asset inspection](../../../../docs/schemas/agent/v1/asset-inspection.schema.json) | +| `preflight_retarget` | [retarget preflight request](../../../../docs/schemas/agent/v1/retarget-preflight-request.schema.json) | [preflight response](../../../../docs/schemas/agent/v1/preflight-response.schema.json) | +| `start_retarget` | [job start request](../../../../docs/schemas/agent/v1/job-start-request.schema.json) | [agent job view](../../../../docs/schemas/agent/v1/agent-job-view.schema.json) | +| `lookup_job` | [job lookup request](../../../../docs/schemas/agent/v1/job-lookup-request.schema.json) | [agent job view](../../../../docs/schemas/agent/v1/agent-job-view.schema.json) | +| `get_job` / `cancel_job` | Scalar job identity and live tool fields | [agent job view](../../../../docs/schemas/agent/v1/agent-job-view.schema.json) | +| `retry_job` | [job retry request](../../../../docs/schemas/agent/v1/job-retry-request.schema.json) | [agent job view](../../../../docs/schemas/agent/v1/agent-job-view.schema.json) | +| `list_job_artifacts` | `job_id`, `limit`, and `offset` | [artifact list response](../../../../docs/schemas/agent/v1/artifact-list-response.schema.json) | +| `export_artifact` | Scalar `job_id` and `artifact_id` | [artifact export receipt](../../../../docs/schemas/agent/v1/artifact-export-receipt.schema.json) | + +Expected tool failures use the [API error](../../../../docs/schemas/agent/v1/api-error.schema.json) +contract rather than a prose-only exception. Inspect `code`, `retryable`, `stage`, `details`, and +`next_action`; do not recover from the human-readable message alone. + +## Executable next-action mapping + +`NextAction.action` is executable only when it has an exact mapping in this table: + +| `actor` | `action` | Tool | Parameter contract | +|---|---|---|---| +| `agent` | `register_asset_bundle` | `register_asset_bundle` | `parameters` is the complete tool argument object: `{"request": }`. Pass it unchanged. | + +The returned request contains only a capability-advertised `root_id` and normalized +`relative_path`; it never contains the installed preset's host path. After registration, inspect +the returned bundle and rerun preflight with its `asset_id`. An unknown action or malformed +parameter object is a stop condition, not permission to infer another tool or browse a root. + +## Read-only resources + +Use these exact URI shapes: + +```text +hhtools://capabilities +hhtools://schemas/agent/v1/{schema_name} +hhtools://robots/{robot_id} +hhtools://assets/{asset_id}/manifest +hhtools://plans/{plan_id} +hhtools://jobs/{job_id}/status +hhtools://jobs/{job_id}/manifest +hhtools://jobs/{job_id}/evaluation +hhtools://jobs/{job_id}/failures +hhtools://jobs/{job_id}/artifacts/{artifact_id} +``` + +For the schema resource, `{schema_name}` is the exact registry slug with no filename suffix—for +example, `capabilities` or `job-spec-v2`, never `capabilities.schema.json`. + +The report resources validate managed bytes before returning the versioned +[evaluation report](../../../../docs/schemas/agent/v1/evaluation-report.schema.json), +[failure report](../../../../docs/schemas/agent/v1/failure-report.schema.json), or +[job manifest](../../../../docs/schemas/agent/v1/job-manifest.schema.json). The job-scoped +artifact resource returns only a verified +[artifact descriptor](../../../../docs/schemas/agent/v1/artifact.schema.json). It does not stream +binary content. + +## Field placement and identity rules + +- Operational Agent v1 request and response envelopes use `schema_version: "1.0"` and reject + unknown fields. The audit-only JobSpec v2 embedded in a manifest is the explicit exception and + uses integer `schema_version: 2`. +- `register_asset_bundle` identifies a deployment-owned source with `root_id + relative_path`. + Backslashes, absolute paths, drive paths, `.` segments, and `..` traversal are not portable + registration inputs. +- `asset_id`, `plan_id`, `job_id`, and `artifact_id` are distinct identities. Never derive one + from a display name or host path. +- `run_mode` is `RetargetPreflightRequest.parameters.run_mode`. It is frozen in the returned + plan. [Job start](../../../../docs/schemas/agent/v1/job-start-request.schema.json) has no mode + override. +- Use `output_policy: create_new`. The current PreflightService rejects `overwrite` and + `fail_if_exists` as unsupported rather than treating them as user-selectable alternatives. +- An idempotency key binds one logical start request. Reuse it only with the exact same plan + when delivery of the response is uncertain. Persist that pair before calling `start_retarget`; + `lookup_job` accepts only the exact pair and recovers one submission without listing other jobs. +- `AgentJobView.artifacts` is compact and may contain only the first page. Use + `artifact_count` and `list_job_artifacts` for canonical pagination. +- Every artifact lookup requires the owning `job_id` and `artifact_id`; verify one descriptor by + reading its exact job-scoped resource URI. Binary data is never embedded as Base64. +- `export_artifact` is the MCP file-delivery boundary. It verifies canonical managed bytes, writes + them only below the service-configured `agent-exports` root, and returns a portable + `root_id + relative_path` receipt with size and SHA-256. It accepts no caller-selected host path + and exposes neither the private content-addressed store nor file bytes. + +## Audit-only public schemas + +The immutable [JobSpec v2](../../../../docs/schemas/agent/v1/job-spec-v2.schema.json), with integer +`schema_version: 2`, appears in the terminal manifest but is not a replacement for preflight. The +public REST/CLI legacy upgrade +contracts—[request](../../../../docs/schemas/agent/v1/legacy-job-upgrade-request.schema.json), +[response](../../../../docs/schemas/agent/v1/legacy-job-upgrade-response.schema.json), and +[receipt](../../../../docs/schemas/agent/v1/legacy-migration-receipt.schema.json)—remain useful for +audit interpretation, but the initial MCP surface has no legacy-upgrade tool. Do not fall back to +the CLI or manufacture a v2 document inside this skill. + +## Current boundary + +The MCP stdio process assembles the same transport-neutral application services directly; it is +not a REST client and does not need `hhtools web` running. A given `save_dir` has exactly one +local runtime owner. A returned loopback WebUI URL is solely for human calibration: disconnect +the stdio MCP owner, let the human run the WebUI against that same `save_dir`, close the WebUI +after calibration, reconnect MCP, and preflight again. Never request a WebUI session token or run +MCP and Web concurrently against the same directory. There is no authenticated remote MCP +transport, multi-user authorization, cross-process native-worker resume, or guaranteed actual-GPU +provenance in this phase. `lookup_job` can recover the persisted identity and truthful status of a +known submission; it cannot resume interrupted native execution. diff --git a/.agents/skills/hhtools-agent/references/errors-and-stops.md b/.agents/skills/hhtools-agent/references/errors-and-stops.md new file mode 100644 index 00000000..749a85fc --- /dev/null +++ b/.agents/skills/hhtools-agent/references/errors-and-stops.md @@ -0,0 +1,62 @@ +# HHTools Agent errors and stopping rules + +Treat structured status, `ApiError.code`, `retryable`, and `next_action` as the decision inputs. +Messages are explanations, not control flow. Never bypass a stop by changing solver parameters, +editing calibration, reading host files, or switching transports. + +## Stop and recovery matrix + +| Signal | Required action | Forbidden action | +|---|---|---| +| `MCP unavailable` | Stop and explain that the local HHTools MCP integration must be configured. | Do not invoke shell, JSON CLI, REST, or direct Python services as a fallback. | +| `RUNTIME_ALREADY_ACTIVE` | Stop and explain that another local runtime owns the same `save_dir`; have the human close that owner before reconnecting the intended runtime. | Do not bypass the lease, start MCP and Web together, or switch directories to hide the conflict. | +| `RUNTIME_LEASE_UNAVAILABLE` | Stop and present the runtime lease/storage error for human investigation. | Do not delete the lease file, disable locking, or continue without exclusive ownership. | +| `human_action_required` | Pause, present all `required_actions`, disconnect the stdio MCP owner, and ask the human to run the WebUI with the same `save_dir`; after calibration, close WebUI, reconnect MCP, and perform a new preflight. | Do not call `start_retarget`, run MCP and Web against the same directory, guess calibration values, or request a WebUI session token. | +| `CALIBRATION_REQUIRED` | Follow the actual human `required_actions` using the exclusive-runtime WebUI handoff, then reconnect MCP and preflight again. | Do not patch calibration, keep MCP and Web open together on one `save_dir`, or invent an action. | +| `CALIBRATION_MISMATCH` | Treat the preflight as rejected: stop and present the structured mismatch; follow `next_action` only if one is actually present. | Do not assume a human action exists, silently choose another reference, or automatically preflight again. | +| `ROBOT_ASSET_REQUIRED` / `ROBOT_BUNDLE_MISMATCH` with `actor: agent`, `action: register_asset_bundle` | Pass the returned `parameters` unchanged to the same-named MCP tool, inspect the registered bundle, use its `asset_id`, and perform a new preflight. | Do not derive a host path, search arbitrary directories, rename the action, or execute malformed/unmapped parameters. | +| `rejected` | Stop and explain the preflight checks and structured error. | Do not start a job or weaken validation to force a plan. | +| `PLAN_STALE` | Inspect the reason, resolve changed inputs, and perform a new preflight that yields a new plan and new start key. | Do not reuse the old plan or mutate its frozen parameters. | +| `QUEUE_FULL` / `SCHEDULER_UNAVAILABLE` | Respect `retryable`, `next_action`, and its polling advice; if replay is advised, retain the same logical submission key. | Do not busy-poll, generate many keys, or submit duplicate jobs. | +| `ambiguous start` | Call `lookup_job` with the exact recorded `plan_id` and idempotency key. Continue the recovered job when found; only on explicit `JOB_NOT_FOUND` replay `start_retarget` with that same pair. | Do not substitute `retry_job`, generate a new key, replay before lookup, or assume the first start failed. | +| `ambiguous retry` | Replay the exact same `retry_job` call with the same parent `job_id` and retry idempotency key, then inspect the returned child. | Do not generate a new key or create another child attempt. | +| `JOB_CONFLICT` | Stop and report that the idempotency key is already bound to a different plan or retry parent; reconcile the original logical request with the user. | Do not evade the conflict by inventing another key or submit a duplicate job. | +| `JOB_INTERRUPTED` | Explain that the prior process ended, then wait for explicit user approval before `retry_job`; the retry is a new whole-plan child attempt. | Do not claim resume, continue an active process, or retry automatically. | +| `JOB_CANCEL_UNSUPPORTED` / `INVALID_JOB_TRANSITION` | Report the current lifecycle state and available `next_action`; poll an active job only at the advised interval. | Do not force termination, mutate state, or conceal a late cancellation. | +| `cancel requested` | Poll until the service reports `cancelled` or another truthful terminal state; running native work cancels cooperatively at safe points. | Do not claim immediate cancellation merely because `cancel_job` returned. | +| `ARTIFACT_HASH_MISMATCH` | Stop artifact delivery, preserve the error, and ask the user whether to investigate or rerun. | Do not present the artifact as valid, skip verification, or read a host path directly. | +| `partial` | Read the failure and evaluation reports, summarize successful and failed portions, and pause for the user's decision. | Do not label the whole result successful or automatically retry a subset; retry is whole-plan only. | +| `review_required` | Read and present the evaluation plus manifest, then pause for explicit quality approval. | Do not preflight or start a full run and do not equate `completed` with accepted quality. | +| `rejected outcome` | Present the evaluation evidence and stop. | Do not promote the result to full or real-robot use. | + +## Idempotency versus retry + +These are different operations: + +- Ambiguous start replay repeats the same logical submission with the same `plan_id` and + idempotency key because it is unknown whether the original response arrived. +- Ambiguous retry replay repeats the same `retry_job`, parent `job_id`, and retry idempotency key; + it does not authorize another child attempt. +- `retry_job` is allowed only for a terminal parent. It creates an auditable child attempt of the + same whole H2R plan and requires explicit user intent plus its own retry idempotency key. +- A changed run mode or changed input is neither replay nor retry. It requires a new preflight, + new plan, and new start key. + +Never interpret `retryable: true` as permission to spin. Follow `next_action` and +`poll_after_ms`, preserve the relevant key, and keep retries bounded and visible. + +## Terminal review + +Use both lifecycle and semantic outcome: + +| Job state | Outcome | Meaning | +|---|---|---| +| `completed` | `success` | Execution and automatic checks succeeded; still present smoke evidence before asking to run full. | +| `completed` | `review_required` | Execution completed, but a human must judge motion quality. | +| `completed` | `partial` | Some work failed; inspect the failure report and do not claim full success. | +| `completed` | `rejected` | Evaluation rejected the motion; stop. | +| `failed` | none | Read the structured error and failure/manifest resources when present. | +| `cancelled` | none | Cancellation reached a truthful terminal state; do not deliver it as a completed result. | + +Before any full preflight, show the smoke evaluation, output identity, and limitations and obtain +explicit approval. Never send an offline trajectory to a physical robot from this workflow. diff --git a/.gitignore b/.gitignore index c625754f..7aab2bd8 100644 --- a/.gitignore +++ b/.gitignore @@ -38,12 +38,42 @@ htmlcov/ .ruff_cache/ coverage.xml *.cover +coverage/ +**/.vitest/ # Tooling .uv/ .python-version # uv.lock is tracked — see README (reproducible installs for clone-and-run users). +# Internal collaboration notes (local only) +docs/electron-migration-plan.md +docs/hhtools-function-map-and-validation.md +docs/webui-p0-remediation.md +docs/webui-p1-remediation.md +docs/webui-ux-reform-plan.md +docs/electron-gui-workspace-plan.md +docs/web-electron-shared-ui-plan.md +docs/remote-workspace-development-plan.md +docs/agent-integration-plan.md +docs/electron-remote-gpu-plan.md +docs/fork-upstream-change-review.md + +# Electron desktop shell +desktop/node_modules/ +desktop/out/ +desktop/release/ +desktop/.runtime/ +desktop/.vite/ +desktop/test-results/ +desktop/playwright-report/ +!desktop/build/ +desktop/build/* +!desktop/build/installer.nsh + +# Vue renderer dependencies (compiled assets are packaged by Python). +hhtools/web/frontend/node_modules/ + # NVIDIA Warp / Newton kernel cache (auto-generated, machine-specific) .warp_cache/ warp_cache/ @@ -152,6 +182,7 @@ configs/robots/* *.avi # Temp +.tmp/ tmp/ scratch/ *.log diff --git a/G1_VISER_MARKDOWN_FIX.md b/G1_VISER_MARKDOWN_FIX.md new file mode 100644 index 00000000..61fcb8c3 --- /dev/null +++ b/G1_VISER_MARKDOWN_FIX.md @@ -0,0 +1,141 @@ +# G1:Viser GUI Markdown 渲染失败——修复记录 + +> **日期**:2026-08-31 +> **来源**:`GUI_WEBUI_TEST_REPORT.md` · Bug G1(P1) +> **状态**:已修复并完成回归验证 +> **范围**:仅修改 Viser GUI 的内容渲染兼容层与 Clip info 展示,不修改标定、IK、retarget 或资产加载算法 + +--- + +## 1. 问题现象 + +旧版 Viser GUI 启动后会出现以下现象: + +- 浏览器 console 报 React error #62; +- 多处说明或状态区域显示 `Markdown Failed to Render`; +- 首次打开页面时部分区域可能正常,但搜索、加载机器人、校准或 retarget 等操作更新内容后再次报错; +- Clip info 的名称、帧数、FPS 和 Bones 数据看起来不可见。 + +这里实际包含两个独立问题:Markdown/MDX 兼容问题,以及禁用状态文本的对比度问题。 + +## 2. 根因 + +### 2.1 字符串形式的 `style` 与旧版 Viser 的 MDX 管道不兼容 + +项目锁文件当前使用 Viser 1.0.26。该版本通过 MDX `evaluate()` 渲染 Markdown。源码中存在如下 HTML: + +```html +... +``` + +MDX 将字符串形式的 `style` 传给 React,而 React DOM 要求 `style` 是样式对象,因此触发 React error #62。 + +此外,部分动态内容包含 `
`。在 MDX/JSX 语境中它需要写成自闭合的 `
`,否则机器人加载后的确认对话框会产生新的 MDX 解析错误。 + +### 2.2 原方案只包裹创建操作,无法覆盖动态更新 + +`hhtools/viewer/app.py` 原来有: + +- 15 处 Markdown 创建调用; +- 42 处通过 `.content = ...` 执行的动态更新。 + +仅把 `add_markdown()` 替换为创建 wrapper,只能清理第一次传入的内容。后续直接赋值给 `.content` 时仍会绕过 wrapper,使问题在交互后复发。 + +### 2.3 Clip info 是独立的低对比度问题 + +Clip info 原先使用四个 `add_text(..., disabled=True)` 控件。数据加载与更新链路是正常的,但 Viser/Mantine 会降低 disabled 控件的不透明度,导致文字和背景的对比度过低,看起来像是没有值。 + +因此,Markdown wrapper 本身不能修复 Clip info。 + +## 3. 实际修复 + +### 3.1 建立统一的 Viser Markdown 兼容边界 + +新增 `hhtools/viewer/markdown_compat.py`,提供三个显式接口: + +- `sanitize_markdown_for_viser()`:仅清除标签中的字符串 `style=` 属性,并把 `
` 规范化为 `
`; +- `add_safe_markdown()`:清理首次内容后调用 Viser `add_markdown()`; +- `set_safe_markdown()`:清理动态内容后再更新真实 handle 的 `.content`。 + +该兼容层有意保持最小范围: + +- 不改普通 Markdown 和普通文本; +- 不误删 `data-style`; +- 不误删正文中的 `style='...'` 字样; +- 保留 MDX 合法的 `style={{...}}` 与 `style={styleObject}` 表达式; +- 返回真实 Viser handle,不引入代理对象,避免改变事件、可见性与生命周期行为。 + +### 3.2 同时覆盖首次创建和动态更新 + +`hhtools/viewer/app.py` 中所有 Markdown 创建都改用 `add_safe_markdown()`,所有 `.content` 更新都改用 `set_safe_markdown()`。 + +源内容中的样式字符串暂时保留。这样兼容逻辑集中在边界层,将来升级到不再受此问题影响的 Viser 渲染管道时,可以单独移除兼容层,而不需要反向恢复每段 UI 文本。 + +### 3.3 将 Clip info 改为只读 Markdown 摘要 + +四个 disabled 文本输入框被替换为一个只读、无内联样式的 Markdown 摘要,继续显示相同数据: + +- Name; +- Frames · FPS · Bones; +- Up axis(source → view); +- Persisted 状态。 + +原有加载、缓存和数据计算路径不变,只调整最终展示控件。 + +## 4. 回归保护 + +新增 `tests/viewer/test_markdown_compat.py`,覆盖: + +- 单引号、双引号、大小写和无引号的字符串 `style`; +- 同一标签多个 `style`; +- `
` 自闭合规范化; +- 普通 Markdown、普通文本、`data-style` 和合法 MDX style 表达式保持不变; +- 首次创建和动态更新都会经过清理; +- 关键字参数会原样转交给 Viser; +- 清理函数幂等; +- AST 静态守卫禁止兼容层之外的 viewer 模块再出现直接 `add_markdown()` 或直接 `.content = ...`。 + +运行方式: + +```powershell +.\.venv\Scripts\python.exe -m pytest -q tests\viewer\test_markdown_compat.py +``` + +本次结果:针对性测试 `15 passed`;全量测试 `828 passed, 6 skipped`。 + +## 5. 手动验证路径 + +在真实 Viser 1.0.26 页面中完成以下回归: + +1. 打开 GUI,确认没有 `Markdown Failed to Render`; +2. 选择 `Xsens_mocap · stand`,确认 Clip info 显示名称、帧数、FPS、Bones 与轴信息; +3. 切换到 Robot,选择 `G1 29dof · g1_29dof`; +4. 加载机器人,覆盖进度与机器人状态的动态 Markdown 更新; +5. 打开已有标定确认对话框,确认带换行的内容可正常渲染; +6. 检查上述操作之后没有新增浏览器错误。 + +上述路径已在 Viser 1.0.26 的真实页面完成,操作时间点之后的浏览器错误记录为 0。 + +## 6. 版本说明 + +- `uv.lock` 当前固定 Viser 1.0.26,但 `pyproject.toml` 写的是 `viser>=0.2`,不同安装方式仍可能解析到不同版本; +- Viser 1.1.0 仍使用相关 MDX 渲染路径,单纯升级到 1.1.0 不能作为本问题的修复; +- Viser 尚未发布的 main 分支已经更换 Markdown 管道,但不建议仅为此问题直接依赖 main; +- `style={{...}}` 是合法的 MDX/JSX 表达式,兼容层会保留它; +- Python 项目可以通过完整 Git commit SHA 锁定 VCS dependency;这与本次本地兼容修复互不冲突。 + +## 7. 修改文件 + +| 文件 | 作用 | +|---|---| +| `hhtools/viewer/markdown_compat.py` | Viser Markdown 创建与更新的统一兼容边界 | +| `hhtools/viewer/app.py` | 接入安全创建/更新,并修复 Clip info 的低对比度展示 | +| `tests/viewer/test_markdown_compat.py` | 动态路径、边界行为与防回归静态检查 | +| `G1_VISER_MARKDOWN_FIX.md` | 将原“待实施方案”修订为本修复记录 | + +## 8. 参考 + +- [React error #62](https://react.dev/errors/62) +- [Viser 未发布 Markdown 管道变更 08c9378](https://github.com/viser-project/viser/commit/08c9378944ab1b70486499483b7ff4415c8fb54c) +- [MDX expressions](https://mdxjs.com/docs/what-is-mdx/#expressions) +- [pip VCS support](https://pip.pypa.io/en/latest/topics/vcs-support/) diff --git a/GUI_WEBUI_TEST_REPORT.md b/GUI_WEBUI_TEST_REPORT.md new file mode 100644 index 00000000..87f2355b --- /dev/null +++ b/GUI_WEBUI_TEST_REPORT.md @@ -0,0 +1,101 @@ +# hhtools GUI / WebUI 实测 Bug 报告 + +> **测试日期**:2026-08-31(续 AGENT_API_TEST_REPORT.md) +> **测试人**:AI agent(Playwright 真实驱动浏览器) +> **测试范围**:WebUI(three.js,127.0.0.1:8009)、Viser 旧版 GUI(`hhtools ui`,127.0.0.1:8008) +> **环境**:Windows 11,RTX 5060(但 venv 为 CPU-only torch),hhtools 0.1.0.dev0 + +## 一句话结论 + +**WebUI(three.js)质量非常高**——全流程实测零 JS 报错,功能链路完整;Viser 旧版 GUI 曾发现 1 个影响信息展示的真 bug,**现已修复**;另有 1 个跨端的误导性状态文案。 + +--- + +## Bug 清单 + +### ~~🔴 Bug G1(P1)Viser GUI Markdown 渲染失败(React error #62)~~ 【已修复】 + +**原现象**:Viser 页面会出现 `Markdown Failed to Render`,console 报 React error #62。部分内容在首次渲染时正常,但在搜索、进度、机器人加载、校准或 retarget 更新后再次失败。 + +**复核结论(2026-08-31)**: + +- Viser 1.0.26 的 MDX 管道会把字符串形式的 HTML `style` 传给 React,触发 error #62。 +- 原建议只包裹 `add_markdown()` 不完整:应用还有大量后续 `.content` 动态更新,同样需要经过兼容边界。 +- Clip info 并非 Markdown 故障。原 disabled 文本输入框的对比度过低,数据实际存在但看起来不可见。 + +**修复**: + +- 新增统一 Markdown 兼容层,同时清理首次创建和每次动态更新中的字符串 `style=`,并规范化 MDX 中的 `
`。 +- 保留合法的 `style={{...}}`/`style={styleObject}` 表达式、普通文本和 `data-style`。 +- Clip info 改为只读 Markdown 摘要,不改变加载和数据计算路径。 +- 增加动态路径单元测试与 AST 静态守卫,防止直接 `add_markdown()` 或 `.content = ...` 回归。 + +**回归验证**:加载 `Xsens_mocap · stand`,切换到 G1 29dof 并加载机器人、打开已有标定确认框后,Clip info 与状态内容均可见,且没有新增浏览器错误。完整记录见 `G1_VISER_MARKDOWN_FIX.md`。 + +--- + +### ~~🟠 Bug G2(P2)"GPU×N" 状态文案在纯 CPU 环境谎称 GPU~~ 【误报,已撤销】 + +**复核结论(2026-08-31):不是 bug,原判断有误。** + +原推断链条"torch.cuda.is_available() == False → 求解跑在 CPU"不成立:Newton IK 求解器跑在 **NVIDIA Warp** 上,而 Warp 有独立的 CUDA 栈,不依赖 PyTorch。实测: + +``` +>>> import warp as wp; wp.init(); wp.get_device() +warp device: cuda:0 # NVIDIA GeForce RTX 5060 (8 GiB, sm_120) +is_cuda: True +devices: ['cpu', 'cuda:0'] +``` + +即本机 IK 确实在 GPU 上执行,"GPU×2"、"GPU-parallel Newton" 文案均**如实**。代码中的 `_warp_device_is_cuda()`(pipeline.py:198)也已做了真实设备判定。**无需修复。**原报告保留此条作为复核记录。 + +--- + +### 🔵 Bug G3(P3)上传失败无主动提示,只藏在折叠的任务历史里 + +**现象**:上传垃圾文件 `fake.bvh`,服务端正确拒绝(job error:`could not load fake.bvh`),但主界面**无任何 toast/弹窗/角标变化**;只有手动展开底部 Task History 才能看到失败记录(带 Retry / Duplicate & Edit)。 + +**影响**:用户上传坏文件后以为"没反应",可能反复重试。建议失败时给一个 toast 或让任务面板自动展开/角标变红。 + +--- + +### 🔵 Bug G4(P3)THREE.Clock 弃用警告 + +WebUI console 唯一一条消息: + +``` +[WARNING] THREE.Clock: This module has been deprecated. Please use THREE.Timer instead. +``` + +无功能影响,升级 three.js 后顺手改掉即可。 + +--- + +## 实测通过的功能(WebUI) + +| 流程 | 结果 | +|---|---| +| 动作库浏览/搜索/加载(GLB、BVH、NPZ) | ✅ cranberry(GLB)、walk/stand(BVH)、AMASS(NPZ)均加载播放正常 | +| 3D 舞台渲染 | ✅ 截图采样 2115 种颜色,骨架/网格正常 | +| 机器人加载(6 台内置) | ✅ G1 29dof 加载,关节滑块面板正常 | +| 标定自动匹配 | ✅ Xsens walk + G1 → 自动加载 `retarget_calibration_xsens_mocap.yaml`,无标定横幅 | +| 标定模式触发 | ✅ 无标定的 tiny+G1 → 正确进入标定模式并提示对齐 | +| **H2R 全流程** | ✅ 2502 帧 IK 求解 → 完成 2487 帧 @239fps → 评估(平均 13.2cm / P95 46.9cm / 接触一致率 98% / 滑移 6.9cm/s)→ 下载 878KB CSV(2493 行,格式正确) | +| **Batch 全流程** | ✅ 库选 2 clip → G1 兼容性检查 → 并行求解 → 2 成功 → ZIP(stand.pkl 397KB + walk.pkl 359KB)自动下载 | +| **Data Analysis** | ✅ 27 clip 全库分析:质量带(ok 18 / warn 7 / bad 2)、动态带、12 种标签、20+ 指标直方图、刷选联动 | +| 任务历史持久化 | ✅ 失败/成功记录带时间戳、Retry、Duplicate & Edit | +| 错误路径:垃圾文件 | ✅ 干净拒绝 `could not load fake.bvh` | +| 错误路径:退化但合法的 BVH(1 关节 2 帧) | ✅ 正常加载不崩 | +| 错误路径:标定保存缺 motion_token | ✅ 明确报错"requires a loaded Motion — pass the clip whose frame-0 skeleton matches calibration" | +| **整个 WebUI 会话 JS 报错数** | ✅ **0**(仅 G4 一条弃用警告) | + +## 实测通过的功能(Viser 旧版 GUI) + +- 库索引(14 个数据集文件夹)、clip 加载(BVH)、播放/暂停/速度/时间轴均正常 +- G1 修复后,clip info 可正常显示名称、帧数、FPS、Bones、坐标轴与持久化状态 + +## 未覆盖(需要真实素材/环境,非 bug) + +- **标定保存的完整闭环**:滑块是受控组件,自动化合成事件驱动不了,需要真人拖一下再点 Save 验证;保存接口的参数校验已单独验证通过 +- **Video → Motion**:需要真实视频文件 + GVHMR 环境(本机未装,"Start GVHMR" 正确保持禁用) +- **R2R 完整求解**:需要机器人轨迹源文件(面板/向导渲染正常) diff --git a/NOTICE b/NOTICE index 4478c238..74c08701 100644 --- a/NOTICE +++ b/NOTICE @@ -19,6 +19,17 @@ This product includes software developed at: authored for Apache-2.0 compatibility. https://github.com/facebookresearch/ai4animationpy + - Tailwind Labs, Inc.: Heroicons (MIT). The workspace drawer uses the official + 24px Outline chevron-left and chevron-right SVG paths. + https://github.com/tailwindlabs/heroicons + +Robot Library identification thumbnails are generated from official robot +models. Per-file provenance, modification notices, and applicable license +texts are recorded in +``hhtools/web/frontend/public/robot-icons/ATTRIBUTION.md``. Product names and +trademarks belong to their respective owners; inclusion does not imply +endorsement. + Third-party components (installed as dependencies, not vendored): - smplx (Apache-2.0) - https://github.com/vchoutas/smplx diff --git a/README.md b/README.md index bb1c128c..ffc0ab80 100644 --- a/README.md +++ b/README.md @@ -32,17 +32,107 @@ We welcome suggestions and ideas — please open an issue or discussion anytime. --- -## Quick start +## Install and run + +hhtools has three user-facing modes. They share the same motion, robot, and retargeting core, but +their installation and launch paths are intentionally separate: + +| Mode | Best for | Launch | +|------|----------|--------| +| **Terminal (CLI/TUI workflow)** | Batch jobs, servers, SSH, and automation | `uv run hhtools ...` | +| **WebUI** | Browser-based visualization and interactive workflows | `uv run hhtools web` | +| **Desktop GUI (`.deb`)** | Standalone Ubuntu desktop use | Application menu or `hhtools-desktop` | + +### Source checkout: Terminal or WebUI + +Clone the repository and use a uv-managed Python 3.12 environment: ```bash git clone https://github.com/Roboparty/human-humanoid-tools.git cd human-humanoid-tools curl -LsSf https://astral.sh/uv/install.sh | sh # if needed -uv sync --extra all +uv python install 3.12 +``` + +For the terminal command set: + +```bash +uv sync --locked --managed-python --python 3.12 +uv run hhtools --help +``` + +Install only the extras required by your workflow. To provision every optional terminal format, +viewer, robot, and retargeting integration, use: + +```bash +uv sync --locked --managed-python --python 3.12 --extra all +``` + +For the browser WebUI: + +```bash +uv sync --locked --managed-python --python 3.12 --extra web --extra retarget uv run hhtools web ``` -Open `http://127.0.0.1:8009`. +Open `http://127.0.0.1:8009`. For a preview-only WebUI without Newton IK, omit `--extra retarget`. +If a required WebUI package is absent, startup exits with the missing package names and the exact +recovery command instead of a Python import traceback. + +### Standalone Ubuntu desktop GUI (`.deb`) + +The Debian package includes Electron, the WebUI, and an isolated Python runtime. End users do not +need to install Python, uv, Node.js, or the repository source: + +```bash +sudo apt install ./hhtools-0.1.0-x64.deb +hhtools-desktop +``` + +You can also launch **Human-Humanoid Tools** from the application menu. See the +[`desktop/README.md` Linux package section](desktop/README.md#linux-package) to build the `.deb`; +`npm run dev` is the development path, not the end-user installation path. + +### Frontend development + +The WebUI and Electron GUI use one React + TypeScript renderer from +`hhtools/web/frontend`; Electron-specific operations are exposed through the typed host service, +so UI components are not forked. The source layout follows a VS Code-style separation: +`base/` contains lifecycle utilities, `platform/` contains host and event boundaries, and +`workbench/` composes reusable panels, workflows, and services. Tailwind CSS supplies tokens and +utilities, while project-owned shadcn/ui primitives live in `src/components/ui`. + +```bash +cd hhtools/web/frontend +npm install +npm run typecheck +npm test +npm run build +``` + +The production build is written to `hhtools/web/static`, which is served unchanged by both +FastAPI and Electron. The existing Three.js/IK workflow runtime is currently loaded through a +documented compatibility service; new UI state and components should stay in the React workbench +instead of adding new direct DOM manipulation. + +Web jobs are unlimited by default. To enable FIFO admission control on a shared or +memory-constrained GPU, set positive concurrency and an optional queue capacity: + +```bash +uv run hhtools web --max-running-jobs 1 --max-queued-jobs 32 +``` + +`0` means unlimited for both options; the queue setting only applies when running concurrency is +limited. The same settings are available as `HHTOOLS_MAX_RUNNING_JOBS` and +`HHTOOLS_MAX_QUEUED_JOBS` (including in the Electron sidecar). +They can also be edited under **Settings → Background-job scheduling** from local Web/Electron +or an SSH loopback tunnel; ordinary remote-browser sessions are shown read-only until authenticated +remote administration is implemented. Saving hot-applies the limits without restarting Python or Electron: lower running limits grandfather active jobs, +while higher limits immediately promote FIFO waiters. The backend persists the values in the +platform user-config directory; `HHTOOLS_WEB_SETTINGS_PATH` selects another file. Explicit CLI +or environment values remain startup overrides and will win again on the next launch. +The cap applies to scheduled Web jobs, not the optional Warp/Newton robot prewarm thread, so it +is admission control rather than a process-wide GPU concurrency guarantee. | Panel | Flow | |-------|------| @@ -50,6 +140,27 @@ Open `http://127.0.0.1:8009`. | **Robot → Robot** | Source robot + trajectory → target URDF → calibrate → retarget / batch ZIP | | **Dataset analysis** | Drop a folder → analyze → explore tags & scatter → export subset | +### GVHMR interoperability + +Install and run [GVHMR](https://github.com/zju3dv/GVHMR) separately using its upstream instructions. +hhtools does not provide a second GVHMR Debian package and does not bundle its source, checkpoints, +or licensed body models. Drag the generated `hmr4d_results.pt` into **Motion** (or select it with the +file picker) to preview it, register it in the Motion Library, and use it as the source of a +**Motion → Robot** workflow. +The conversion still needs a locally licensed SMPL-family model; if it is not in an existing +hhtools search path, point `HHTOOLS_BODY_MODELS` at that model directory. + +For a terminal-only workflow, convert a GVHMR output directory to hhtools' unified Motion format: + +```bash +hhtools import run --dataset gvhmr --root /path/to/gvhmr/output --out /path/to/motions +``` + +The existing manual Docker bridge is still available for an already prepared runtime through +`HHTOOLS_GVHMR_ROOT`, `HHTOOLS_GVHMR_IMAGE`, `HHTOOLS_GVHMR_BODY_MODELS`, and the optional +`HHTOOLS_GVHMR_TIMEOUT_SECONDS`. These settings only connect hhtools to external resources; they do +not install or download GVHMR. + Robot tuning: edit [`configs/robots/unitree_g1/`](configs/robots/unitree_g1/) or uploaded `~/.config/hhtools/robots//robot.yaml`; run `hhtools robot validate `. Details in [framework.md](framework.md). ### CLI (batch / no Web UI) diff --git a/README_cn.md b/README_cn.md index 06734005..316ef479 100644 --- a/README_cn.md +++ b/README_cn.md @@ -32,17 +32,101 @@ --- -## 快速开始 +## 安装与启动 + +hhtools 有三种面向用户的运行方式。它们共享同一套动作、机器人与重映射核心,但安装和启动 +入口彼此独立: + +| 方式 | 适用场景 | 启动入口 | +|------|----------|----------| +| **终端(CLI/TUI 工作流)** | 批处理、服务器、SSH 与自动化 | `uv run hhtools ...` | +| **WebUI** | 浏览器中的可视化与交互工作流 | `uv run hhtools web` | +| **桌面 GUI(`.deb`)** | Ubuntu 桌面独立使用 | 应用菜单或 `hhtools-desktop` | + +### 源码安装:终端或 WebUI + +克隆仓库,并使用 uv 管理的 Python 3.12 环境: ```bash git clone https://github.com/Roboparty/human-humanoid-tools.git cd human-humanoid-tools curl -LsSf https://astral.sh/uv/install.sh | sh # 若未安装 -uv sync --extra all +uv python install 3.12 +``` + +只使用终端命令时: + +```bash +uv sync --locked --managed-python --python 3.12 +uv run hhtools --help +``` + +请按实际工作流安装额外依赖。如果需要所有可选的终端格式、查看器、机器人和重映射集成,使用: + +```bash +uv sync --locked --managed-python --python 3.12 --extra all +``` + +使用浏览器 WebUI 时: + +```bash +uv sync --locked --managed-python --python 3.12 --extra web --extra retarget uv run hhtools web ``` -浏览器打开 `http://127.0.0.1:8009`。 +浏览器打开 `http://127.0.0.1:8009`。如果只需要预览、不使用 Newton IK,可省略 +`--extra retarget`。缺少 WebUI 必需包时,启动程序会列出缺失包及准确的修复命令,不再直接显示 +Python import traceback。 + +### Ubuntu 独立桌面 GUI(`.deb`) + +Debian 安装包已经包含 Electron、WebUI 和隔离的 Python runtime。普通用户无需安装 Python、 +uv、Node.js 或仓库源码: + +```bash +sudo apt install ./hhtools-0.1.0-x64.deb +hhtools-desktop +``` + +也可以从应用菜单启动 **Human-Humanoid Tools**。构建 `.deb` 的步骤见 +[`desktop/README.md` 的 Linux package 章节](desktop/README.md#linux-package);`npm run dev` +属于开发启动方式,不是最终用户的安装方式。 + +### 前端开发 + +WebUI 与 Electron GUI 共用 `hhtools/web/frontend` 中同一套 React + TypeScript renderer; +Electron 专属能力通过带类型的 host service 暴露,不复制两套 UI 组件。源码按类似 VS Code 的 +职责分层组织:`base/` 放生命周期基础设施,`platform/` 放宿主与事件边界,`workbench/` 负责 +组合可复用面板、工作流与服务。Tailwind CSS 提供 token 和工具类,项目维护的 shadcn/ui +基础组件位于 `src/components/ui`。 + +```bash +cd hhtools/web/frontend +npm install +npm run typecheck +npm test +npm run build +``` + +生产构建写入 `hhtools/web/static`,FastAPI 与 Electron 原样复用这份产物。现有 Three.js/IK +工作流运行时暂时通过有明确说明的兼容服务加载;新的 UI 状态与组件应放在 React workbench, +不要继续增加直接 DOM 操作。 + +Web 后台任务默认不限制并发。共享服务器或显存紧张时,可以显式启用 FIFO 调度: + +```bash +uv run hhtools web --max-running-jobs 1 --max-queued-jobs 32 +``` + +两个参数的 `0` 都表示不限;只有运行并发为正数时,等待队列设置才生效。也可以使用 +`HHTOOLS_MAX_RUNNING_JOBS` 和 `HHTOOLS_MAX_QUEUED_JOBS` 环境变量,Electron sidecar 同样支持。 +也可以从本机 Web/Electron 或 SSH 本地回环隧道,在 **设置 → 后台任务调度** 中直接修改; +在未实现远程管理鉴权前,普通远程浏览器会显示为只读。保存会热更新调度器,无需重启 Python 或 +Electron:降低并发不会中断正在运行的任务,提高上限会立即按 FIFO 补跑等待任务。后端会将 +配置写入平台用户配置目录,也可用 `HHTOOLS_WEB_SETTINGS_PATH` 指定文件。显式 CLI/环境变量 +仍是启动覆盖项,只要保留这些覆盖项,下次启动时就会再次覆盖 GUI 保存值。 +该上限只约束调度器管理的 Web Job,不包含选择机器人时可选的 Warp/Newton 预热线程, +因此它是任务准入控制,并非整个进程的严格 GPU 并发上限。 | 面板 | 流程 | |------|------| @@ -50,6 +134,26 @@ uv run hhtools web | **Robot → Robot** | 源机器人 + 轨迹 → 目标 URDF → 标定 → 单条/批量导出 | | **数据集可视化分析** | 拖入文件夹 → 分析 → 标签/散点探索 → 导出子集 | +### GVHMR 接口 + +请按照 [GVHMR 上游说明](https://github.com/zju3dv/GVHMR)自行安装和运行。hhtools 不提供第二个 +GVHMR Debian 安装包,也不捆绑 GVHMR 源码、checkpoint 或需要单独授权的人体模型。将 GVHMR +生成的 `hmr4d_results.pt` 拖入 **Motion**(或用文件选择器打开),即可预览、登记到动作资源库, +并作为 **Motion → Robot** 的源动作继续重映射。 +转换时仍需要本地已授权的 SMPL 系人体模型;如果它不在 hhtools 默认搜索路径中, +请用 `HHTOOLS_BODY_MODELS` 指向该模型目录。 + +纯命令行用户也可直接将 GVHMR 输出目录转成 hhtools 统一 Motion 格式: + +```bash +hhtools import run --dataset gvhmr --root /path/to/gvhmr/output --out /path/to/motions +``` + +已经自行准备好 Docker 运行环境的用户仍可使用现有手动接口:设置 +`HHTOOLS_GVHMR_ROOT`、`HHTOOLS_GVHMR_IMAGE`、`HHTOOLS_GVHMR_BODY_MODELS`,并可选设置 +`HHTOOLS_GVHMR_TIMEOUT_SECONDS`。这些变量只负责把 hhtools 连接到外部资源,不会安装或下载 +GVHMR。 + 参数调优:改 [`configs/robots/unitree_g1/`](configs/robots/unitree_g1/) 或 `~/.config/hhtools/robots/<名称>/robot.yaml`,运行 `hhtools robot validate <名称>`。原理见 [framework.md](framework.md)。 ### CLI(批量 / 不走 Web) diff --git a/architecture.html b/architecture.html new file mode 100644 index 00000000..6adbc582 --- /dev/null +++ b/architecture.html @@ -0,0 +1,331 @@ + + + + + +hhtools 架构图 + + + +
+

hhtools 架构图

+

human-humanoid-tools · 人体动作 → 人形机器人 retargeting · 分层规则:下层永不 import 上层,重依赖全部 lazy import

+ + +
+ LAYER 5 · 用户接口 + 两个 UI 共享同一后端,数据不出本机 +
+
+
CLI(Typer)
+
web / ui / retarget / convert / import / robot / bodymodel
+ hhtools <cmd> +
+
+
Web UI ⭐
+
FastAPI(4269 行, 47 路由)+ three.js SPA · 中文界面 · 离线 vendor
+ 推荐入口 hhtools web +
+
+
Viser Viewer
+
app.py 5533 行 · 旧版 3D 查看器(library/anatomy/cache 被 web 复用)
+ legacy · hhtools ui +
+
+
Analysis 分析
+
metrics → tags → embedding → FPS 多样性子集(LIMMT/GQS 风格)
+ /api/dataset/* +
+
+
+ +
▲ ▲ ▲
+
调用 pipeline · 共享 calibration + scaler
+ + +
+ LAYER 4 · RETARGET + 按 clip 内容选后端 +
+
+
Backend A · Newton IK
+
newton_basic · scaler → feet stabilizer → Warp IK → joint clamp · CUDA-graph
+ GPU · 骨架类 clip(mimic) +
+
+
Backend B · Interaction Mesh
+
interaction_mesh · Laplacian + MPC/SQP(MuJoCo + OSQP 硬非穿透)
+ 人+物/地形 clip +
+
+
Calibration 标定(共享)
+
robot↔human 一次性对齐 · 每 robot×format 一个 YAML · UI 可交互编辑
+ calibration.py 2032 行 +
+
+
R2R 机器人→机器人
+
robot_to_robot.py · 源轨迹 FK → canonical → 新机器人 IK
+ 916 行 +
+
+
+ +
▲ ▲ ▲
+
load_motion() / load_robot() → 统一 Motion IR
+ + +
+ LAYER 3 · IO + ROBOT + 注册表扩展点 +
+
+
IO 格式适配
+
BVH(多方言)/ GLB / 统一 NPZ v1 / PARC pkl / RobotCSV
+ register_loader +
+
+
数据集适配 10+
+
AMASS · Motion-X · OMOMO · PHUMA · GVHMR · LAFAN · SOMA · Xsens · holosoma · parc_ms
+ @register_dataset +
+
+
Robot 机器人接入
+
URDF 权威 + robot.yaml · scaffold 零配置 · urdf_normalize 修厂商 quirk
+ yourdfpy/mujoco lazy +
+
+
Kinematics 推断
+
ik_map 启发式推断 + 解剖验证 · joint_scales · foot/arm geometry
+ validate / repair +
+
+
+ +
+
SMPL 前向 → Motion(权重需用户自备,MPI 许可)
+ + +
+ LAYER 2 · BODY MODELS + smplx/torch lazy +
+
+
SmplxEngine
+
SMPL(24j)/ SMPL-H(52j)/ SMPL-X · 批量 forward → Motion · lru_cache
+ torch lazy +
+
+
SmplMotionParams
+
统一 SMPL 家族 IR:root_orient / body_pose / betas / trans / 手/表情
+ params.py +
+
+
chumpy 兼容补丁
+
让 MPI 老权重在 Py3.12+ 可 unpickle
+ compat.py +
+
+
+ +
+
一切汇聚于此 · 纯 NumPy,零重依赖
+ + +
+ LAYER 1 · CORE(纯 NumPy) + Z-up · +X forward · 米制 · xyzw 四元数 +
+
+
Motion(核心 IR)
+
全局位置 (F,J,3) + 四元数 (F,J,4) + 层级 + SceneObject + TerrainHeightfield
+ 单一中间表示 +
+
+
Skeleton / Hierarchy
+
骨骼拓扑 + 参考静止姿态 · FK 前向运动学 · AnimationBuffer
+ 纯数据 +
+
+
math 数学库
+
quaternion / rotation / transform / vector 批量运算
+ NumPy only +
+
+
工具
+
resample(SLERP)· grounding 地面检测 · simplify 骨骼裁剪 · LBS 蒙皮
+ 纯函数 +
+
+
+ + +
+
+

📦 持久化 / 存储

+
    +
  • 统一 NPZ v1 — 磁盘标准 IR
  • +
  • robot.yaml — 机器人唯一可编辑配置
  • +
  • retarget_calibration_*.yaml — 标定结果
  • +
  • RobotCSV — retarget 输出轨迹
  • +
  • PARC MSFileData pkl — 可训练导出
  • +
+
+
+

🔌 外部输入(用户提供)

+
    +
  • SMPL / SMPL-H / SMPL-X 权重(MPI 许可,不附带)
  • +
  • 机器人 URDF + meshes(web 上传或 CLI scaffold)
  • +
  • 动作数据:BVH / GLB / NPZ / NPY / PTK
  • +
  • NVIDIA newton(Retarget GPU 后端,手动安装)
  • +
+
+
+

🚀 部署形态

+
    +
  • hhtools web → 127.0.0.1:8009(当前)
  • +
  • hhtools ui → Viser 本地查看器(旧)
  • +
  • CLI 批处理:scripts/batch_*.py
  • +
  • 候选:Electron 壳 + Python 子进程(评估中)
  • +
+
+
+ +
+ UI 层 + Retarget 层 + IO/Robot 层 + Body Models 层 + Core 层 + 存储/外部输入 +
+
+ + diff --git a/desktop/README.md b/desktop/README.md new file mode 100644 index 00000000..a40e6b83 --- /dev/null +++ b/desktop/README.md @@ -0,0 +1,208 @@ +# Human-Humanoid Tools + +This directory contains the standalone Electron GUI for the existing FastAPI and three.js WebUI. +The desktop app keeps the current HTTP routes and Python business logic while supervising its own +local Python sidecar. + +## Development + +Prerequisites: + +- Node.js 22.12 or newer +- The repository `.venv` with the hhtools Web dependencies installed +- A working browser version of `uv run hhtools web` + +From this directory: + +```powershell +npm install +npm run dev +``` + +On Linux, the equivalent commands are: + +```bash +npm install +npm run dev +``` + +The shell discovers the repository by walking upward from the current app path. Override runtime +locations when needed: + +```powershell +$env:HHTOOLS_REPO_ROOT = 'C:\path\to\human-humanoid-tools' +$env:HHTOOLS_PYTHON = 'C:\path\to\python.exe' +npm run dev +``` + +```bash +HHTOOLS_REPO_ROOT=/path/to/human-humanoid-tools \ +HHTOOLS_PYTHON=/path/to/python3 npm run dev +``` + +Optional data path overrides are `HHTOOLS_SOURCE_ROOT`, `HHTOOLS_SAVE_DIR`, +`HHTOOLS_CACHE_DIR`, and `HHTOOLS_LOG_DIR`. + +Background-job admission is optional. Both settings have a factory default of `0`, preserving +unlimited concurrency for expert users: + +```powershell +$env:HHTOOLS_MAX_RUNNING_JOBS = '1' +$env:HHTOOLS_MAX_QUEUED_JOBS = '32' +npm run dev +``` + +A positive running value enables the FIFO waiting queue. A queued value of `0` means unlimited +waiting; it has no effect while running is `0`. The same values are editable in local Electron +under **Settings → Background-job scheduling**; Save persists and hot-applies them without restarting the sidecar +or Electron. Active jobs are not interrupted. Explicit environment values remain startup +overrides and win again on the next launch. `HHTOOLS_WEB_SETTINGS_PATH` can redirect the +persistent JSON file for portable installs and isolated tests. +The Electron environment filter forwards these three named settings, not arbitrary variables or +secrets. +This cap covers scheduled Web jobs, not the optional Warp/Newton robot prewarm thread; it is not +a process-wide GPU concurrency guarantee. + +## Verification + +```powershell +npm run typecheck +npm test +npm run test:e2e +npm run dist:win +npm run dist:linux +``` + +`test:e2e` builds and launches the real Electron application, checks the existing WebUI, captures +a screenshot, closes the app, and verifies that the supervised Python process exits. + +## Windows package + +`npm run dist:win` performs three steps: + +1. Build the Electron main and preload processes. +2. Stage an isolated CPython runtime, production Python packages, the hhtools source, tracked sample + motions, and any explicitly selected bundled robot assets under `desktop/.runtime`. +3. Build an assisted NSIS installer under `desktop/release`. + +The staging step reads the base interpreter from `.venv/pyvenv.cfg`; run `uv sync` before packaging. +`HHTOOLS_RUNTIME_PYTHON_HOME`, `HHTOOLS_RUNTIME_SITE_PACKAGES`, and +`HHTOOLS_BUNDLED_ROBOT_DIR` are packaging-time overrides. Source files are selected with +`git ls-files`, so ignored and untracked files are not included. For a verified `git archive` +extraction without `.git`, set `HHTOOLS_TRUST_SOURCE_ARCHIVE=1`; other unversioned source trees are +rejected rather than copied wholesale. + +Installed files use this shape: + +```text +Human-Humanoid Tools/ +├── Human-Humanoid Tools.exe +└── resources/ + ├── app.asar + └── runtime/ + ├── app/ # hhtools source, WebUI, configs, and bundled assets + └── python/ # isolated CPython and production dependencies +``` + +User-created motions, caches, logs, window state, and optional-component settings remain under +Electron's per-user data directory and are not removed by an application upgrade. + +## Linux package + +Build the Linux package on the oldest supported Linux distribution rather than cross-compiling it +from Windows. The staged Python runtime contains platform-specific native wheels, and building on +Ubuntu 22.04 keeps the resulting glibc requirement compatible with Ubuntu 22.04 or newer. A clean +Ubuntu 22.04 x86-64 builder can be prepared with: + +```bash +sudo apt update +sudo apt install -y build-essential git libarchive-tools + +# Install Node.js 22 and uv by the method used for the build host, then: +uv python install 3.12 +uv sync --locked --managed-python --python 3.12 --extra all +cd desktop +npm ci +npm run dist:linux +``` + +Allow at least 25--30 GiB of free disk space for the uv environment, staged runtime, Electron +working files, and final package. Native Torch, CUDA, Warp, MuJoCo, and Newton files make the Linux +runtime substantially larger than a normal Electron-only application. + +`dist:linux` builds the Electron bundles, stages the uv-managed CPython 3.12 installation and the +virtual environment's production packages, verifies imports using that staged interpreter, and +creates `release/hhtools-0.1.0-x64.deb`. The build intentionally rejects `/usr` and `/usr/local` as +Python homes: copying a distribution-managed Python tree is unsafe and generally not relocatable. +Recreate `.venv` after `uv python install 3.12` if this guard is triggered. + +Install and remove the package with the system package manager so its desktop entry and runtime +dependencies are handled normally: + +```bash +sudo apt install ./release/hhtools-0.1.0-x64.deb +sudo apt remove hhtools-desktop +``` + +For users who prefer `dpkg`, install the same package with: + +```bash +sudo dpkg -i ./release/hhtools-0.1.0-x64.deb +# dpkg does not download dependencies. Run this only if it reports missing packages: +sudo apt-get -f install +``` + +The Debian package declares its Electron/GTK runtime libraries, so `apt` and graphical package +installers resolve them automatically. The install-time message repeats the recovery command for +terminal installations; no system Python, pip environment, Torch, or Newton install is required. + +On a full Ubuntu desktop, double-click the `.deb` and open it with App Center. Minimal GNOME +installations need a graphical Debian-package handler such as GDebi. After installation, launch +**Human-Humanoid Tools** from the application menu or run `hhtools-desktop`. The separate +`hhtools` command invokes the bundled Python CLI from any working directory: + +```bash +hhtools --help +hhtools robot list +hhtools web +``` + +The launcher isolates the bundled Python runtime from user site-packages and active virtualenvs. +It also gives packaged `web`/`ui` commands read-only sample motions plus writable XDG data/cache +defaults; explicit CLI options and `HHTOOLS_SOURCE_ROOT`, `HHTOOLS_SAVE_DIR`, or +`HHTOOLS_CACHE_DIR` still take precedence. + +Robot files from `$HOME` or `$XDG_CONFIG_HOME` are never included implicitly. Set +`HHTOOLS_BUNDLED_ROBOT_DIR=/verified/robots` to include a reviewed robot library. The source must +contain one directory per robot and may not contain symbolic links; names that collide with tracked +robot directories are rejected instead of merged. The repository does not track the local built-in +robot library, so a clean builder intentionally produces a package without those extra robots. +The installed runtime is placed below the Electron application's `resources/runtime` directory and +uses `python/bin/python3`; no system Python is required when the application runs. + +### External GVHMR + +GVHMR is installed and run separately by the user; hhtools does not provide a second Debian package +and does not bundle GVHMR source, checkpoints, or licensed SMPL/SMPL-X files. Import the resulting +`hmr4d_results.pt` through **Motion** to preview it, add it to the Motion Library, or use it as the +source of a Human → Robot workflow. + +The existing manual Docker bridge remains available for users who already maintain that runtime. +Set `HHTOOLS_GVHMR_ROOT`, `HHTOOLS_GVHMR_IMAGE`, and `HHTOOLS_GVHMR_BODY_MODELS` before launching +the desktop application; `HHTOOLS_GVHMR_TIMEOUT_SECONDS` is optional. These variables point hhtools +at external resources and do not install or download them. + +## Runtime model + +1. Electron allocates a random `127.0.0.1` port and a per-launch session secret. +2. `SidecarSupervisor` starts `python -m hhtools.cli.desktop_sidecar`. +3. Electron waits for `/api/health`, injects the session header into requests, and only then shows + the existing WebUI. +4. Closing Electron stops the full Python process tree before the app exits. + +Packaged builds always prefer `resources/runtime`. Development builds continue to discover the +repository checkout and `.venv`; `HHTOOLS_REPO_ROOT` and `HHTOOLS_PYTHON` remain explicit developer +overrides. The sidecar still receives an allowlisted environment rather than Electron's complete +environment. Linux display/session and native-library variables such as `DISPLAY`, +`WAYLAND_DISPLAY`, `DBUS_SESSION_BUS_ADDRESS`, `XDG_RUNTIME_DIR`, `LD_LIBRARY_PATH`, `MUJOCO_GL`, +and `PYOPENGL_PLATFORM` are retained so GNOME, MuJoCo, and GPU runtimes can initialize normally. diff --git a/desktop/build/installer.nsh b/desktop/build/installer.nsh new file mode 100644 index 00000000..78da7da8 --- /dev/null +++ b/desktop/build/installer.nsh @@ -0,0 +1,55 @@ +!include "LogicLib.nsh" +!include "nsDialogs.nsh" + +!ifndef BUILD_UNINSTALLER +Var GvhmrCheckbox +Var GvhmrRequested + +; LCIDs are used directly because electron-builder includes this file before +; the symbolic NSIS language constants are available to custom scripts. +LangString GvhmrPageTitle 1033 "Optional video-to-motion" +LangString GvhmrPageTitle 2052 "可选的视频转动作组件" +LangString GvhmrPageSummary 1033 "The core GUI, retargeting tools, sample motions, and bundled robots work without GVHMR." +LangString GvhmrPageSummary 2052 "核心 GUI、动作重映射工具、示例动作和内置机器人均可在未安装 GVHMR 时使用。" +LangString GvhmrPageCheckbox 1033 "Configure GVHMR video-to-motion after installation" +LangString GvhmrPageCheckbox 2052 "安装完成后配置 GVHMR 视频转动作组件" +LangString GvhmrPageDetails 1033 "GVHMR remains separate because it requires Docker Desktop, licensed SMPL-X files, and roughly 22 GB of additional images and weights. Selecting this option opens the setup section in hhtools on first launch; it does not silently accept third-party licences." +LangString GvhmrPageDetails 2052 "GVHMR 作为可选组件单独安装,因为它需要 Docker Desktop、获得许可的 SMPL-X 文件,以及约 22 GB 的额外镜像和权重。选择此项后,hhtools 会在首次启动时打开配置页面;程序不会代替用户接受第三方许可协议。" + +!macro customPageAfterChangeDir + Page custom GvhmrPageCreate GvhmrPageLeave +!macroend + +Function GvhmrPageCreate + nsDialogs::Create 1018 + Pop $0 + ${If} $0 == error + Abort + ${EndIf} + + ${NSD_CreateLabel} 0 0 100% 18u "$(GvhmrPageTitle)" + Pop $0 + ${NSD_CreateLabel} 0 24u 100% 30u "$(GvhmrPageSummary)" + Pop $0 + ${NSD_CreateCheckbox} 0 62u 100% 24u "$(GvhmrPageCheckbox)" + Pop $GvhmrCheckbox + ${NSD_SetState} $GvhmrCheckbox ${BST_UNCHECKED} + ${NSD_CreateLabel} 16u 90u 94% 52u "$(GvhmrPageDetails)" + Pop $0 + + nsDialogs::Show +FunctionEnd + +Function GvhmrPageLeave + ${NSD_GetState} $GvhmrCheckbox $GvhmrRequested +FunctionEnd + +!macro customInstall + ${If} $GvhmrRequested == ${BST_CHECKED} + CreateDirectory "$LOCALAPPDATA\hhtools\installer" + FileOpen $0 "$LOCALAPPDATA\hhtools\installer\gvhmr.requested" w + FileWrite $0 "requested" + FileClose $0 + ${EndIf} +!macroend +!endif diff --git a/desktop/e2e/desktop-shell.spec.ts b/desktop/e2e/desktop-shell.spec.ts new file mode 100644 index 00000000..a171ea8a --- /dev/null +++ b/desktop/e2e/desktop-shell.spec.ts @@ -0,0 +1,543 @@ +import { _electron as electron, expect, test } from '@playwright/test' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import type { HHToolsDesktopApi } from '../src/shared/desktop-api' + +const desktopRoot = fileURLToPath(new URL('..', import.meta.url)) +const repositoryRoot = resolve(desktopRoot, '..') + +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +test('loads the existing WebUI and shuts down its Python sidecar', async ({}, testInfo) => { + const packagedExecutable = process.env.HHTOOLS_E2E_EXECUTABLE + const userDataDirectory = testInfo.outputPath('user-data') + const electronApp = await electron.launch({ + ...(packagedExecutable === undefined ? {} : { executablePath: packagedExecutable }), + args: [ + `--user-data-dir=${userDataDirectory}`, + ...(packagedExecutable === undefined ? [join(desktopRoot, 'out', 'main', 'index.js')] : []), + ], + cwd: desktopRoot, + env: { + ...process.env, + HHTOOLS_REPO_ROOT: repositoryRoot, + HHTOOLS_WEB_SETTINGS_PATH: testInfo.outputPath('web-settings.json'), + HHTOOLS_MOTION_LIBRARY_SETTINGS_PATH: testInfo.outputPath('motion-library-settings.json'), + XDG_CONFIG_HOME: testInfo.outputPath('config'), + ELECTRON_DISABLE_SECURITY_WARNINGS: 'true' + } + }) + + let backendPid: number | undefined + try { + const page = await electronApp.firstWindow({ timeout: 90_000 }) + const pageErrors: string[] = [] + page.on('pageerror', (error) => pageErrors.push(error.message)) + await page.evaluate(() => localStorage.setItem('hhtools.web.tutorial.v1.done', '1')) + + await expect(page).toHaveTitle('Human-Humanoid Tools') + await expect(page.locator('#app')).toBeVisible() + await expect(page.locator('#app')).toHaveClass(/workspace-shell/) + await expect(page.locator('#app')).toHaveClass(/electron-host/) + await expect(page.locator('#topbar .desktop-brand-name')).toHaveText('HHTOOLS') + await expect(page.locator('#topbar .ui-build')).toBeHidden() + await expect(page.locator('#topbar .command-palette-trigger')).toBeHidden() + await expect(page.locator('#motion-pill')).toBeHidden() + await expect(page.locator('#robot-pill')).toBeHidden() + await expect(page.locator('.desktop-menu-trigger')).toHaveCount(5) + await expect(page.locator('.nav-item[data-panel]')).toHaveCount(7) + await expect(page.locator('#stage-empty .big')).toContainText('Drop a motion here to preview') + + const stage = page.locator('#stage') + const viewMenu = page.locator('#view-hud') + await expect(viewMenu).toBeVisible() + await expect(viewMenu.locator('#tg-skeleton .lbl')).toHaveText('Skeleton') + const stageBounds = await stage.boundingBox() + const viewMenuBounds = await viewMenu.boundingBox() + expect(viewMenuBounds?.x).toBeCloseTo((stageBounds?.x ?? 0) + 12, 0) + expect(viewMenuBounds?.y).toBeCloseTo((stageBounds?.y ?? 0) + 12, 0) + + const topbarLayer = await page.locator('#topbar').evaluate((element) => + Number.parseInt(getComputedStyle(element).zIndex, 10) + ) + const stageToolsLayer = await page.locator('.stage-top-tools').evaluate((element) => + Number.parseInt(getComputedStyle(element).zIndex, 10) + ) + expect(topbarLayer).toBeGreaterThan(stageToolsLayer) + + await page.locator('[data-menu-trigger="file"]').click() + const fileMenu = page.locator('[data-menu-popup="file"]') + await expect(fileMenu).toBeVisible() + const fileMenuBounds = await fileMenu.boundingBox() + const overlapPoint = { + x: Math.max(fileMenuBounds?.x ?? 0, viewMenuBounds?.x ?? 0) + 12, + y: Math.max(fileMenuBounds?.y ?? 0, viewMenuBounds?.y ?? 0) + 12, + } + expect(overlapPoint.x).toBeLessThan(Math.min( + (fileMenuBounds?.x ?? 0) + (fileMenuBounds?.width ?? 0), + (viewMenuBounds?.x ?? 0) + (viewMenuBounds?.width ?? 0), + )) + expect(overlapPoint.y).toBeLessThan(Math.min( + (fileMenuBounds?.y ?? 0) + (fileMenuBounds?.height ?? 0), + (viewMenuBounds?.y ?? 0) + (viewMenuBounds?.height ?? 0), + )) + expect(await page.evaluate(({ x, y }) => + document.elementFromPoint(x, y)?.closest('[data-menu-popup="file"]') !== null, + overlapPoint)).toBe(true) + await page.screenshot({ path: testInfo.outputPath('desktop-file-menu.png'), fullPage: true }) + await page.keyboard.press('Escape') + + const jobPanel = page.locator('.docked-job-panel') + await expect(jobPanel.locator('.job-summary-title')).toHaveText('Tasks') + const collapsedJobBounds = await jobPanel.boundingBox() + const collapsedStageBounds = await stage.boundingBox() + expect(collapsedJobBounds?.height).toBeCloseTo(34, 0) + expect((collapsedJobBounds?.y ?? 0) + (collapsedJobBounds?.height ?? 0)) + .toBeCloseTo(await page.evaluate(() => window.innerHeight), 0) + + await page.locator('.job-drawer-summary').click() + await expect(jobPanel).toHaveClass(/open/) + await expect(page.locator('.job-panel-resizer')).toBeVisible() + await expect(jobPanel.getByText('Import Config', { exact: true })).toHaveCount(0) + const expandedJobBounds = await jobPanel.boundingBox() + const reducedStageBounds = await stage.boundingBox() + expect(expandedJobBounds?.height).toBeGreaterThanOrEqual(180) + expect(reducedStageBounds?.height).toBeLessThan((collapsedStageBounds?.height ?? 0) - 100) + await page.screenshot({ path: testInfo.outputPath('desktop-tasks-expanded.png'), fullPage: true }) + + await page.keyboard.press('Control+J') + await expect(jobPanel).not.toHaveClass(/open/) + + const expandedSidebar = await page.locator('#sidebar').boundingBox() + const expandedInspector = await page.locator('#inspector').boundingBox() + expect(expandedSidebar?.width).toBeCloseTo(208, 0) + expect(expandedInspector?.width).toBeCloseTo(360, 0) + + await expect(page.locator('.side-panel-head')).toHaveCount(0) + await expect(page.locator('.nav-group-label')).toHaveCount(0) + const motionPanel = page.locator('#inspector-body .panel.active') + await expect(motionPanel.locator(':scope > h2')).toHaveText('Motion') + await expect(page.locator('#stage-empty .big')).toHaveText('Drop a motion here to preview') + await expect(motionPanel.locator(':scope > .lead')).toHaveCount(0) + await expect(motionPanel.locator('#motion-assets-hint')).toHaveCount(0) + await expect(motionPanel.locator('.motion-import-card')).toHaveCount(0) + await expect(motionPanel.locator('.motion-profile-selector-content')).toHaveText([ + 'mimic', + 'intermimic', + 'meshmimic', + ]) + const motionProfileRadios = motionPanel.getByRole('radio') + const sharedMotionDropzone = motionPanel.locator('#motion-drop-shared') + await expect(motionProfileRadios).toHaveCount(3) + await expect(motionProfileRadios.first()).toBeChecked() + await expect(sharedMotionDropzone).toHaveCount(1) + await expect(sharedMotionDropzone).toHaveAttribute('data-profile', 'mimic') + await expect(sharedMotionDropzone).toHaveAttribute('aria-label', 'mimic import area') + await expect(sharedMotionDropzone.locator('.dz-title')).toHaveText('Drop a motion file or folder') + await expect(motionPanel.locator('#motion-pick-file')).toHaveText('Choose file') + await expect(motionPanel.locator('#motion-pick-file')).toBeVisible() + await expect(motionPanel.locator('#motion-pick-folder')).toHaveText('Choose folder') + await expect(motionPanel.locator('#motion-pick-folder')).toBeVisible() + expect((await sharedMotionDropzone.boundingBox())?.height).toBeLessThan(180) + + const motionLibrary = motionPanel.locator('#tour-motion-library') + await expect(motionLibrary).toHaveCount(1) + await expect(motionPanel.locator('.card#tour-motion-library')).toHaveCount(0) + await expect(motionLibrary.locator(':scope > h2')).toHaveText('Library') + await expect(motionLibrary.locator('.motion-library-count')).toHaveCount(0) + await expect(motionLibrary.locator('.motion-library-root-button')).toHaveText('Choose library directory') + await expect(motionLibrary.locator('.motion-library-filter')).toHaveCount(0) + await expect(motionLibrary.locator('#lib-folder')).toHaveCount(0) + const libraryCategory = motionLibrary.locator('#lib-category') + await expect(libraryCategory).toHaveAttribute('aria-label', 'Filter the library by motion type') + await expect(libraryCategory.locator('option')).toHaveText([ + 'All', + 'Motion', + 'Object interaction', + 'Terrain scene', + ]) + await expect(libraryCategory).toHaveValue('all') + await expect(motionLibrary.locator('.motion-library-list-frame')).toBeVisible() + await expect(motionLibrary.locator('.lr-category').first()).toHaveText('Motion') + const firstLibraryRow = motionLibrary.locator('.lib-row').first() + const firstLibraryLoad = firstLibraryRow.locator(':scope > .lr-load') + const firstLibraryAdd = firstLibraryRow.locator(':scope > .lr-add') + await expect(firstLibraryLoad).toHaveJSProperty('tagName', 'BUTTON') + await expect(firstLibraryAdd).toHaveJSProperty('tagName', 'BUTTON') + await expect(firstLibraryLoad).toHaveAttribute('aria-label', /^Load motion /) + await expect(firstLibraryAdd).toHaveAttribute('title', 'Add to basket') + await firstLibraryLoad.focus() + await expect(firstLibraryLoad).toBeFocused() + await expect(firstLibraryAdd).toBeVisible() + const [rootButtonBounds, categoryBounds] = await Promise.all([ + motionLibrary.locator('.motion-library-root-button').boundingBox(), + libraryCategory.boundingBox(), + ]) + expect(Math.abs((rootButtonBounds?.y ?? 0) - (categoryBounds?.y ?? 0))).toBeLessThanOrEqual(1) + await libraryCategory.selectOption('object') + await expect(motionLibrary.locator('.lr-category[data-category="object"]').first()).toBeVisible() + await expect(motionLibrary.locator('.lr-category:not([data-category="object"])')).toHaveCount(0) + await libraryCategory.selectOption('all') + + const libraryRows = motionLibrary.locator('.lib-row') + const initialLibraryRowCount = await libraryRows.count() + const librarySearch = motionLibrary.getByLabel('Search the Motion Library') + await librarySearch.fill('no-such-motion-clip-987654321') + await expect(libraryRows).toHaveCount(0) + await motionLibrary.getByRole('button', { name: 'Clear library search' }).click() + await expect(librarySearch).toHaveValue('') + await expect(librarySearch).toBeFocused() + await expect(libraryRows).toHaveCount(initialLibraryRowCount) + + const [motionHeadingStyle, libraryHeadingStyle] = await Promise.all([ + motionPanel.locator(':scope > h2').evaluate((element) => getComputedStyle(element).fontSize), + motionLibrary.locator(':scope > h2').evaluate((element) => getComputedStyle(element).fontSize), + ]) + expect(libraryHeadingStyle).toBe(motionHeadingStyle) + expect(await motionLibrary.locator('.motion-library-tools').evaluate((element) => + element.scrollWidth <= element.clientWidth + 1 + )).toBe(true) + const [libraryFrameBounds, inspectorBodyBounds] = await Promise.all([ + motionLibrary.locator('.motion-library-list-frame').boundingBox(), + page.locator('#inspector-body').boundingBox(), + ]) + expect(Math.abs( + ((inspectorBodyBounds?.y ?? 0) + (inspectorBodyBounds?.height ?? 0)) + - ((libraryFrameBounds?.y ?? 0) + (libraryFrameBounds?.height ?? 0)) + )).toBeLessThanOrEqual(20) + await page.screenshot({ path: testInfo.outputPath('desktop-motion-library.png'), fullPage: true }) + await page.screenshot({ path: testInfo.outputPath('desktop-motion-compact-default.png'), fullPage: true }) + + // Load a small checked-in fixture so locale changes cover the imperative + // Motion details renderer as well as Vue-owned controls and library rows. + await librarySearch.fill('LAFAN dance1_subject2') + const motionFixtureRow = motionLibrary.locator('.lib-row').first() + await expect(motionFixtureRow).toContainText('dance1_subject2') + await motionFixtureRow.locator('.lr-load').click() + const motionMetaCard = motionPanel.locator('#motion-meta-card') + const motionMetaLabels = motionMetaCard.locator('.meta-row > .k') + const motionValidation = motionMetaCard.locator('.validation-line') + const addLoadedMotion = motionMetaCard.locator('#add-to-basket') + await expect(motionMetaCard).toBeVisible() + await expect(motionMetaLabels).toHaveText([ + 'Format', + 'Frames', + 'Frame rate', + 'Duration', + 'Skeleton', + 'Body mesh', + ]) + await expect(motionValidation.first()).toContainText('Playable trajectory:') + await expect(addLoadedMotion).toHaveText('+ Add to batch basket') + await librarySearch.fill('') + await expect(libraryRows).toHaveCount(initialLibraryRowCount) + + await motionPanel.locator('.motion-profile-selector', { hasText: 'intermimic' }).click() + await expect(motionProfileRadios.nth(1)).toBeChecked() + await expect(sharedMotionDropzone).toHaveAttribute('data-profile', 'intermimic') + await expect(sharedMotionDropzone).toHaveAttribute('aria-label', 'intermimic import area') + await expect(sharedMotionDropzone.locator('.dz-title')) + .toHaveText('Drop a complete object-interaction motion folder') + await expect(motionPanel.locator('#motion-pick-file')).toBeHidden() + await expect(motionPanel.locator('#motion-pick-folder')).toHaveAttribute('data-pick', 'intermimic') + + const motionInfoTrigger = motionPanel.locator('.motion-import-info-trigger') + const motionUploadInfo = motionPanel.locator('#motion-upload-info') + await expect(motionInfoTrigger).toHaveCount(1) + await expect(motionInfoTrigger).toHaveAttribute('aria-label', 'View intermimic import instructions') + await expect(motionUploadInfo).toBeHidden() + await motionInfoTrigger.click() + await expect(motionInfoTrigger).toHaveAttribute('aria-expanded', 'true') + await expect(motionUploadInfo).toBeVisible() + await expect(motionUploadInfo).toContainText('Object-interaction motion · OMOMO') + await expect(motionUploadInfo).toContainText('/.pkl') + await page.screenshot({ path: testInfo.outputPath('desktop-motion-compact-upload-info.png'), fullPage: true }) + await page.keyboard.press('Escape') + await expect(motionUploadInfo).toBeHidden() + + // The native file dialog filters individual motion files, while directory + // pickers stay unfiltered so required object and terrain sidecars survive. + await motionPanel.locator('.motion-profile-selector', { hasText: /^mimic$/ }).click() + const motionFileChooserPromise = page.waitForEvent('filechooser') + await motionPanel.locator('#motion-pick-file').click() + const motionFileChooser = await motionFileChooserPromise + expect(await motionFileChooser.element().getAttribute('accept')) + .toBe('.bvh,.glb,.gltf,.npz,.npy,.pkl,.pt') + await motionFileChooser.setFiles({ + name: 'not-a-motion.txt', + mimeType: 'text/plain', + buffer: Buffer.from('unsupported motion content'), + }) + await expect(page.locator('#toast.err')).toContainText('未找到可识别的动作文件(mimic)') + await expect(page.locator('#toast.err')).not.toContainText('"detail"') + + await motionPanel.locator('.motion-profile-selector', { hasText: 'intermimic' }).click() + const intermimicFolderChooserPromise = page.waitForEvent('filechooser') + await motionPanel.locator('#motion-pick-folder').click() + const intermimicFolderChooser = await intermimicFolderChooserPromise + expect(await intermimicFolderChooser.element().evaluate((element) => { + const input = element as HTMLInputElement + return { accept: input.accept, directory: input.webkitdirectory } + })).toEqual({ accept: '', directory: true }) + await intermimicFolderChooser.element().evaluate((element) => { + element.dispatchEvent(new Event('change', { bubbles: true })) + }) + + const leftDrawerHandle = page.locator('#toggle-sidebar') + const rightDrawerHandle = page.locator('#toggle-inspector') + await expect(leftDrawerHandle.locator('svg')).toHaveAttribute('data-icon', 'chevron-left') + await expect(leftDrawerHandle).toHaveAttribute('aria-expanded', 'true') + await expect(rightDrawerHandle.locator('svg')).toHaveAttribute('data-icon', 'chevron-right') + await expect(rightDrawerHandle).toHaveAttribute('aria-expanded', 'true') + + const [topbarBounds, leftHandleBounds, rightHandleBounds, viewport] = await Promise.all([ + page.locator('#topbar').boundingBox(), + leftDrawerHandle.boundingBox(), + rightDrawerHandle.boundingBox(), + page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })), + ]) + expect(leftHandleBounds?.x).toBeCloseTo( + (expandedSidebar?.x ?? 0) + (expandedSidebar?.width ?? 0), + 0, + ) + expect((rightHandleBounds?.x ?? 0) + (rightHandleBounds?.width ?? 0)).toBeCloseTo( + expandedInspector?.x ?? 0, + 0, + ) + expect(leftHandleBounds?.height).toBeCloseTo(116, 0) + expect(rightHandleBounds?.height).toBeCloseTo(116, 0) + const drawerCenterY = ((topbarBounds?.y ?? 0) + (topbarBounds?.height ?? 0) + viewport.height) / 2 + expect((leftHandleBounds?.y ?? 0) + (leftHandleBounds?.height ?? 0) / 2).toBeCloseTo(drawerCenterY, 0) + expect((rightHandleBounds?.y ?? 0) + (rightHandleBounds?.height ?? 0) / 2).toBeCloseTo(drawerCenterY, 0) + + const handleStyleBeforeHover = await leftDrawerHandle.evaluate((element) => { + const style = getComputedStyle(element) + return { + backgroundColor: style.backgroundColor, + borderRadius: style.borderRadius, + borderTopWidth: style.borderTopWidth, + boxShadow: style.boxShadow, + color: style.color, + cursor: style.cursor, + } + }) + expect(handleStyleBeforeHover).toMatchObject({ + borderRadius: '0px', + borderTopWidth: '0px', + boxShadow: 'none', + cursor: 'pointer', + }) + await leftDrawerHandle.hover() + const handleStyleAfterHover = await leftDrawerHandle.evaluate((element) => { + const style = getComputedStyle(element) + return { + backgroundColor: style.backgroundColor, + boxShadow: style.boxShadow, + color: style.color, + } + }) + expect(handleStyleAfterHover).toEqual({ + backgroundColor: handleStyleBeforeHover.backgroundColor, + boxShadow: handleStyleBeforeHover.boxShadow, + color: handleStyleBeforeHover.color, + }) + + // The panel and its edge handle must track a resize without drawer-animation lag. + const sidebarResizer = page.locator('#resize-sidebar') + const sidebarResizerBounds = await sidebarResizer.boundingBox() + await page.mouse.move( + (sidebarResizerBounds?.x ?? 0) + (sidebarResizerBounds?.width ?? 0) / 2, + (sidebarResizerBounds?.y ?? 0) + 80, + ) + await page.mouse.down() + await page.mouse.move((sidebarResizerBounds?.x ?? 0) + 20, (sidebarResizerBounds?.y ?? 0) + 80) + await expect.poll(async () => (await page.locator('#sidebar').boundingBox())?.width ?? 0).toBeGreaterThan(220) + await expect.poll(async () => (await leftDrawerHandle.boundingBox())?.x ?? 0).toBeGreaterThan(220) + await page.mouse.move((sidebarResizerBounds?.x ?? 0) + (sidebarResizerBounds?.width ?? 0) / 2, (sidebarResizerBounds?.y ?? 0) + 80) + await page.mouse.up() + await expect.poll(async () => (await page.locator('#sidebar').boundingBox())?.width ?? 0).toBeCloseTo(208, 0) + + const stageBeforeLeftCollapse = await stage.boundingBox() + await leftDrawerHandle.click() + await expect(leftDrawerHandle.locator('svg')).toHaveAttribute('data-icon', 'chevron-right') + await expect(leftDrawerHandle).toHaveAttribute('aria-expanded', 'false') + await expect(page.locator('#sidebar')).toHaveAttribute('aria-hidden', 'true') + await expect.poll(async () => (await page.locator('#sidebar').boundingBox())?.width ?? 0).toBeCloseTo(0, 0) + const [stageWithoutSidebar, collapsedLeftHandle] = await Promise.all([ + stage.boundingBox(), + leftDrawerHandle.boundingBox(), + ]) + expect(stageWithoutSidebar?.width).toBeGreaterThan((stageBeforeLeftCollapse?.width ?? 0) + 200) + expect(collapsedLeftHandle?.x).toBeGreaterThanOrEqual(0) + expect((collapsedLeftHandle?.x ?? 0) + (collapsedLeftHandle?.width ?? 0)).toBeLessThanOrEqual(viewport.width) + await page.screenshot({ path: testInfo.outputPath('desktop-left-drawer-collapsed.png'), fullPage: true }) + await leftDrawerHandle.click() + await expect(leftDrawerHandle.locator('svg')).toHaveAttribute('data-icon', 'chevron-left') + await expect.poll(async () => (await page.locator('#sidebar').boundingBox())?.width ?? 0).toBeCloseTo(208, 0) + + const stageBeforeRightCollapse = await stage.boundingBox() + await rightDrawerHandle.click() + await expect(rightDrawerHandle.locator('svg')).toHaveAttribute('data-icon', 'chevron-left') + await expect(rightDrawerHandle).toHaveAttribute('aria-expanded', 'false') + await expect(page.locator('#inspector')).toHaveAttribute('aria-hidden', 'true') + await expect.poll(async () => (await page.locator('#inspector').boundingBox())?.width ?? 0).toBeCloseTo(0, 0) + const [stageWithoutInspector, collapsedRightHandle] = await Promise.all([ + stage.boundingBox(), + rightDrawerHandle.boundingBox(), + ]) + expect(stageWithoutInspector?.width).toBeGreaterThan((stageBeforeRightCollapse?.width ?? 0) + 340) + expect(collapsedRightHandle?.x).toBeGreaterThanOrEqual(0) + expect((collapsedRightHandle?.x ?? 0) + (collapsedRightHandle?.width ?? 0)).toBeLessThanOrEqual(viewport.width) + await page.screenshot({ path: testInfo.outputPath('desktop-right-drawer-collapsed.png'), fullPage: true }) + await rightDrawerHandle.click() + await expect(rightDrawerHandle.locator('svg')).toHaveAttribute('data-icon', 'chevron-right') + await expect.poll(async () => (await page.locator('#inspector').boundingBox())?.width ?? 0).toBeCloseTo(360, 0) + await page.screenshot({ path: testInfo.outputPath('desktop-drawers.png'), fullPage: true }) + + await page.locator('[data-menu-trigger="analysis"]').click() + const dataAnalysis = page.locator('.desktop-menu-item', { hasText: 'Data Analysis' }) + await expect(dataAnalysis).toBeEnabled() + await expect(dataAnalysis).toHaveAttribute('title', 'Analyze motion and robot trajectory datasets') + await page.screenshot({ path: testInfo.outputPath('desktop-analysis-menu.png'), fullPage: true }) + await page.keyboard.press('Escape') + + await page.locator('[data-menu-trigger="settings"]').click() + await page.locator('.desktop-menu-item', { hasText: 'Settings' }).click() + const settings = page.locator('.workspace-settings-dialog') + await expect(settings).toBeVisible() + await expect(settings.locator('.workspace-setting-row')).toHaveCount(7) + await expect(settings.locator('.workspace-library-root')).not.toHaveText('—') + await expect(settings.getByRole('button', { name: 'Choose directory' })).toBeEnabled() + await expect(settings.locator('.workspace-language-select')).toHaveValue('en') + const stateBeforeSettingsSave = await page.evaluate(() => + (window.hhtoolsDesktop as HHToolsDesktopApi).getRuntimeState() + ) + await expect(settings.locator('.workspace-max-running-jobs')).toBeEnabled() + await settings.locator('.workspace-max-running-jobs').fill('2') + await settings.locator('.workspace-max-queued-jobs').fill('32') + await settings.locator('.workspace-settings-save').click() + await expect(settings.getByText('Saved and applied immediately. No restart is required.')).toBeVisible() + const appliedSettings = await page.evaluate(async () => { + const [runtimeState, response] = await Promise.all([ + (window.hhtoolsDesktop as HHToolsDesktopApi).getRuntimeState(), + fetch('/api/settings/job-admission'), + ]) + return { runtimeState, scheduler: await response.json() } + }) + expect(appliedSettings.runtimeState.backendPid).toBe(stateBeforeSettingsSave.backendPid) + expect(appliedSettings.scheduler).toMatchObject({ + max_running_jobs: 2, + max_queued_jobs: 32, + }) + await settings.locator('.workspace-language-select').selectOption('zh-CN') + await expect(page.locator('#sidebar')).toHaveAttribute('aria-label', '导航') + await expect(page.locator('#inspector')).toHaveAttribute('aria-label', '控制面板') + await expect(page.locator('#sidebar .nav-item-label').first()).toHaveText('动作') + await expect(motionPanel.locator(':scope > h2')).toHaveText('动作') + await expect(page.locator('#stage-empty .big')).toHaveText('把动作拖到这里预览') + await expect(motionLibrary.locator(':scope > h2')).toHaveText('资源库') + await expect(motionLibrary.locator('.motion-library-root-button')).toHaveText('选择资源库目录') + await expect(libraryCategory).toHaveAttribute('aria-label', '按动作类型筛选资源库') + await expect(libraryCategory.locator('option')).toHaveText(['全部', '纯动作', '物体交互', '地形场景']) + await expect(sharedMotionDropzone).toHaveAttribute('aria-label', 'intermimic 上传区') + await expect(sharedMotionDropzone.locator('.dz-title')).toHaveText('拖入完整的物体交互动作文件夹') + await expect(motionPanel.locator('#motion-pick-folder')).toHaveText('选择文件夹') + await expect(motionLibrary.locator('.lr-category').first()).toHaveText('动作') + await expect(firstLibraryLoad).toHaveAttribute('aria-label', /^加载动作 /) + await expect(firstLibraryAdd).toHaveAttribute('title', '加入篮子') + await expect(motionMetaLabels).toHaveText(['格式', '帧数', '帧率', '时长', '骨骼', '身体 mesh']) + await expect(motionValidation.first()).toContainText('轨迹可播放:') + await expect(addLoadedMotion).toHaveText('+ 加入批量篮子') + await settings.locator('.workspace-language-select').selectOption('en') + await expect(page.locator('#sidebar')).toHaveAttribute('aria-label', 'Navigation') + await expect(page.locator('#inspector')).toHaveAttribute('aria-label', 'Inspector') + await expect(page.locator('#sidebar .nav-item-label').first()).toHaveText('Motion') + await expect(motionPanel.locator(':scope > h2')).toHaveText('Motion') + await expect(page.locator('#stage-empty .big')).toHaveText('Drop a motion here to preview') + await expect(motionLibrary.locator(':scope > h2')).toHaveText('Library') + await expect(sharedMotionDropzone).toHaveAttribute('aria-label', 'intermimic import area') + await expect(sharedMotionDropzone.locator('.dz-title')) + .toHaveText('Drop a complete object-interaction motion folder') + await expect(motionPanel.locator('#motion-pick-folder')).toHaveText('Choose folder') + await expect(motionLibrary.locator('.lr-category').first()).toHaveText('Motion') + await expect(firstLibraryLoad).toHaveAttribute('aria-label', /^Load motion /) + await expect(firstLibraryAdd).toHaveAttribute('title', 'Add to basket') + await expect(motionMetaLabels).toHaveText([ + 'Format', + 'Frames', + 'Frame rate', + 'Duration', + 'Skeleton', + 'Body mesh', + ]) + await expect(motionValidation.first()).toContainText('Playable trajectory:') + await expect(addLoadedMotion).toHaveText('+ Add to batch basket') + await page.screenshot({ path: testInfo.outputPath('desktop-settings.png'), fullPage: true }) + await settings.locator('.workspace-settings-reset').click() + await settings.locator('.workspace-settings-done').click() + await expect(settings).toBeHidden() + + await page.locator('[data-menu-trigger="settings"]').click() + await page.locator('.desktop-menu-item', { hasText: 'Settings' }).click() + await expect(settings.locator('.workspace-max-running-jobs')).toHaveValue('2') + await expect(settings.locator('.workspace-max-queued-jobs')).toHaveValue('32') + await settings.locator('.workspace-settings-done').click() + + await page.locator('[data-menu-trigger="settings"]').click() + await page.locator('.desktop-menu-item', { hasText: 'Dark Mode' }).click() + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark') + await page.locator('[data-menu-trigger="settings"]').click() + await page.locator('.desktop-menu-item', { hasText: 'Light Mode' }).click() + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light') + + const canvasBounds = await page.locator('#three-canvas').boundingBox() + expect(canvasBounds?.width).toBeGreaterThan(400) + expect(canvasBounds?.height).toBeGreaterThan(300) + + const toast = page.locator('#toast') + await expect(toast).toBeHidden() + await toast.evaluate((element) => { + element.textContent = 'Layout check' + element.classList.add('show') + }) + await expect(toast).toBeVisible() + // Electron pages do not expose Playwright's emulated viewport size. + const viewportHeight = await page.evaluate(() => window.innerHeight) + await expect.poll(async () => { + const bounds = await toast.boundingBox() + return (bounds?.y ?? 0) + (bounds?.height ?? 0) + }).toBeLessThanOrEqual(viewportHeight) + await toast.evaluate((element) => element.classList.remove('show')) + await expect(toast).toBeHidden() + + await expect + .poll(() => + page.evaluate(() => + (window.hhtoolsDesktop as HHToolsDesktopApi).getRuntimeState() + ) + ) + .toMatchObject({ appPhase: 'after-window-open', backendState: 'ready' }) + + const state = await page.evaluate(() => + (window.hhtoolsDesktop as HHToolsDesktopApi).getRuntimeState() + ) + backendPid = state.backendPid + expect(typeof backendPid).toBe('number') + expect(pageErrors).toEqual([]) + await page.screenshot({ path: testInfo.outputPath('desktop-home.png'), fullPage: true }) + } finally { + await electronApp.close() + } + + if (backendPid !== undefined) { + await expect.poll(() => processIsAlive(backendPid as number), { timeout: 10_000 }).toBe(false) + } +}) diff --git a/desktop/electron.vite.config.ts b/desktop/electron.vite.config.ts new file mode 100644 index 00000000..573766af --- /dev/null +++ b/desktop/electron.vite.config.ts @@ -0,0 +1,26 @@ +import { resolve } from 'node:path' + +import { defineConfig, externalizeDepsPlugin } from 'electron-vite' + +export default defineConfig({ + main: { + plugins: [externalizeDepsPlugin()], + build: { + rollupOptions: { + input: resolve(import.meta.dirname, 'src/main/index.ts') + } + } + }, + preload: { + plugins: [externalizeDepsPlugin()], + build: { + rollupOptions: { + input: resolve(import.meta.dirname, 'src/preload/index.ts'), + output: { + format: 'cjs', + entryFileNames: 'index.cjs' + } + } + } + } +}) diff --git a/desktop/package-lock.json b/desktop/package-lock.json new file mode 100644 index 00000000..0c5754ed --- /dev/null +++ b/desktop/package-lock.json @@ -0,0 +1,6175 @@ +{ + "name": "hhtools-desktop", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hhtools-desktop", + "version": "0.1.0", + "devDependencies": { + "@playwright/test": "1.62.1", + "@types/node": "24.10.1", + "electron": "43.4.1", + "electron-builder": "26.15.3", + "electron-vite": "5.0.0", + "typescript": "6.0.3", + "vite": "7.3.6", + "vitest": "4.1.11" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", + "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", + "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/fuses": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", + "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" + }, + "bin": { + "electron-fuses": "dist/bin.js" + } + }, + "node_modules/@electron/fuses/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/get": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", + "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", + "progress": "^2.0.3", + "semver": "^7.6.3", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=22.12.0" + }, + "optionalDependencies": { + "undici": "^7.24.4" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", + "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/rebuild": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.0.tgz", + "integrity": "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", + "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.3.1", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@noble/hashes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz", + "integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz", + "integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", + "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "tslib": "^2.8.1", + "webcrypto-core": "^1.9.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.5.tgz", + "integrity": "sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.5.tgz", + "integrity": "sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.5.tgz", + "integrity": "sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.5.tgz", + "integrity": "sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.5.tgz", + "integrity": "sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.5.tgz", + "integrity": "sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.5.tgz", + "integrity": "sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.5.tgz", + "integrity": "sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.5.tgz", + "integrity": "sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.5.tgz", + "integrity": "sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.5.tgz", + "integrity": "sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.5.tgz", + "integrity": "sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.5.tgz", + "integrity": "sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.5.tgz", + "integrity": "sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.5.tgz", + "integrity": "sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.5.tgz", + "integrity": "sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.5.tgz", + "integrity": "sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.5.tgz", + "integrity": "sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.5.tgz", + "integrity": "sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.5.tgz", + "integrity": "sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.5.tgz", + "integrity": "sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.5.tgz", + "integrity": "sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.5.tgz", + "integrity": "sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.5.tgz", + "integrity": "sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.5.tgz", + "integrity": "sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-builder-lib": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.3.tgz", + "integrity": "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "3.4.1", + "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "^4.0.4", + "@electron/universal": "2.0.3", + "@malept/flatpak-bundler": "^0.4.0", + "@noble/hashes": "^2.2.0", + "@peculiar/webcrypto": "^1.7.1", + "@types/fs-extra": "9.0.13", + "ajv": "^8.18.0", + "asn1js": "^3.0.10", + "async-exit-hook": "^2.0.1", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chromium-pickle-js": "^0.2.0", + "ci-info": "4.3.1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "26.15.3", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.2.5", + "pkijs": "^3.4.0", + "plist": "3.1.0", + "proper-lockfile": "^4.1.2", + "resedit": "^1.7.0", + "semver": "~7.7.3", + "tar": "^7.5.7", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0", + "unzipper": "^0.12.3", + "which": "^5.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "26.15.3", + "electron-builder-squirrel-windows": "26.15.3" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/app-builder-lib/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/app-builder-lib/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/app-builder-lib/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.18", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.18.tgz", + "integrity": "sha512-1iEmLEYSiE1SeBoAfPo/Mnx3PzfzHUkDK61ASkCpuk3YXugYLH5DYK1SzqV55F8FMI6s0F+/tCP7Polz1QRjxw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.3.tgz", + "integrity": "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.6", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", + "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-compare/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.15.3.tgz", + "integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "43.4.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-43.4.1.tgz", + "integrity": "sha512-5b+EuiwkgG5iRcsEL34rimgRpkYp15SsfZOa0pC5kXs0Tb82TH4n95rpQzTZa7yRCbA7tm0WoEbuBL6NaAhAcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" + }, + "bin": { + "electron": "cli.js", + "install-electron": "install.js" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "node_modules/electron-builder": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.15.3.tgz", + "integrity": "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "dmg-builder": "26.15.3", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz", + "integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "electron-winstaller": "5.4.0" + } + }, + "node_modules/electron-publish": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz", + "integrity": "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "aws4": "^1.13.2", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "form-data": "^4.0.5", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.412", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.412.tgz", + "integrity": "sha512-z4rMe3esBzlzovKHj4gxJnsCGZRK5l4baUvm+gCGJBPE+gsyUMKsuU9tnEUtI1dOebXz1ytAPGjvXhmQ7rIPwA==", + "dev": true, + "license": "ISC" + }, + "node_modules/electron-vite": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/electron-vite/-/electron-vite-5.0.0.tgz", + "integrity": "sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.4", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "cac": "^6.7.14", + "esbuild": "^0.25.11", + "magic-string": "^0.30.19", + "picocolors": "^1.1.1" + }, + "bin": { + "electron-vite": "bin/electron-vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@swc/core": "^1.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + } + } + }, + "node_modules/electron-winstaller": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", + "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" + } + }, + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/electron-winstaller/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "peer": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-winstaller/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-abi": { + "version": "4.33.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.33.0.tgz", + "integrity": "sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.6.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/node-api-version": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-gyp": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/undici": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pe-library": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", + "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkijs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", + "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@noble/hashes": "1.4.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/pkijs/node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", + "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/rollup": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.5.tgz", + "integrity": "sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.5", + "@rollup/rollup-android-arm64": "4.62.5", + "@rollup/rollup-darwin-arm64": "4.62.5", + "@rollup/rollup-darwin-x64": "4.62.5", + "@rollup/rollup-freebsd-arm64": "4.62.5", + "@rollup/rollup-freebsd-x64": "4.62.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.5", + "@rollup/rollup-linux-arm-musleabihf": "4.62.5", + "@rollup/rollup-linux-arm64-gnu": "4.62.5", + "@rollup/rollup-linux-arm64-musl": "4.62.5", + "@rollup/rollup-linux-loong64-gnu": "4.62.5", + "@rollup/rollup-linux-loong64-musl": "4.62.5", + "@rollup/rollup-linux-ppc64-gnu": "4.62.5", + "@rollup/rollup-linux-ppc64-musl": "4.62.5", + "@rollup/rollup-linux-riscv64-gnu": "4.62.5", + "@rollup/rollup-linux-riscv64-musl": "4.62.5", + "@rollup/rollup-linux-s390x-gnu": "4.62.5", + "@rollup/rollup-linux-x64-gnu": "4.62.5", + "@rollup/rollup-linux-x64-musl": "4.62.5", + "@rollup/rollup-openbsd-x64": "4.62.5", + "@rollup/rollup-openharmony-arm64": "4.62.5", + "@rollup/rollup-win32-arm64-msvc": "4.62.5", + "@rollup/rollup-win32-ia32-msvc": "4.62.5", + "@rollup/rollup-win32-x64-gnu": "4.62.5", + "@rollup/rollup-win32-x64-msvc": "4.62.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/tiny-async-pool": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", + "integrity": "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.5.0" + } + }, + "node_modules/tiny-async-pool/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unzipper": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.5.tgz", + "integrity": "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "11.3.1", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + } + }, + "node_modules/unzipper/node_modules/fs-extra": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", + "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/webcrypto-core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", + "integrity": "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/desktop/package.json b/desktop/package.json new file mode 100644 index 00000000..6b94d8a2 --- /dev/null +++ b/desktop/package.json @@ -0,0 +1,105 @@ +{ + "name": "hhtools-desktop", + "version": "0.1.0", + "private": true, + "description": "Standalone desktop GUI for human-humanoid-tools", + "desktopName": "hhtools", + "author": "hhtools contributors", + "homepage": "https://github.com/Roboparty/human-humanoid-tools", + "repository": { + "type": "git", + "url": "https://github.com/Roboparty/human-humanoid-tools.git" + }, + "type": "module", + "main": "out/main/index.js", + "scripts": { + "dev": "electron-vite dev", + "build": "electron-vite build", + "prepare:runtime": "node scripts/prepare-runtime.mjs", + "preview": "electron-vite preview", + "typecheck": "tsc --noEmit", + "test": "vitest run --config vitest.config.ts", + "test:e2e": "npm run build && playwright test --config playwright.config.ts", + "dist:win": "npm run build && npm run prepare:runtime && electron-builder --win nsis", + "dist:linux": "npm run build && npm run prepare:runtime && electron-builder --linux deb --x64" + }, + "devDependencies": { + "@playwright/test": "1.62.1", + "@types/node": "24.10.1", + "electron": "43.4.1", + "electron-builder": "26.15.3", + "electron-vite": "5.0.0", + "typescript": "6.0.3", + "vite": "7.3.6", + "vitest": "4.1.11" + }, + "build": { + "appId": "com.roboparty.hhtools.desktop.alpha", + "productName": "Human-Humanoid Tools", + "asar": true, + "electronDist": "node_modules/electron/dist", + "directories": { + "output": "release" + }, + "compression": "maximum", + "artifactName": "hhtools-${version}-${arch}-setup.${ext}", + "files": [ + "out/**/*", + "resources/icon.png", + "package.json" + ], + "extraResources": [ + { + "from": ".runtime", + "to": "runtime", + "filter": [ + "**/*" + ] + } + ], + "win": { + "icon": "resources/icon.ico", + "target": [ + "nsis" + ] + }, + "linux": { + "artifactName": "hhtools-${version}-${arch}.${ext}", + "category": "Science", + "executableName": "hhtools-desktop", + "icon": "resources/icon.png", + "syncDesktopName": true, + "target": [ + { + "target": "deb", + "arch": [ + "x64" + ] + } + ] + }, + "deb": { + "fpm": [ + "--before-install=scripts/linux-before-install.sh", + ".runtime/cli/hhtools=/usr/bin/hhtools" + ], + "maintainer": "hhtools contributors <53653538+Eleanor1018@users.noreply.github.com>", + "synopsis": "Desktop workflows for Human-Humanoid Tools" + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true, + "multiLanguageInstaller": true, + "installerLanguages": [ + "en_US", + "zh_CN" + ], + "displayLanguageSelector": true, + "include": "build/installer.nsh", + "installerIcon": "resources/icon.ico", + "uninstallerIcon": "resources/icon.ico", + "installerHeaderIcon": "resources/icon.ico", + "deleteAppDataOnUninstall": false + } + } +} diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts new file mode 100644 index 00000000..9acd1544 --- /dev/null +++ b/desktop/playwright.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: './e2e', + timeout: 120_000, + expect: { timeout: 15_000 }, + workers: 1, + reporter: 'line', + use: { + screenshot: 'only-on-failure', + trace: 'retain-on-failure' + } +}) diff --git a/desktop/resources/icon.ico b/desktop/resources/icon.ico new file mode 100644 index 00000000..abed9e9b Binary files /dev/null and b/desktop/resources/icon.ico differ diff --git a/desktop/resources/icon.png b/desktop/resources/icon.png new file mode 100644 index 00000000..1b7a5607 Binary files /dev/null and b/desktop/resources/icon.png differ diff --git a/desktop/resources/icon.svg b/desktop/resources/icon.svg new file mode 100644 index 00000000..59a47fb1 --- /dev/null +++ b/desktop/resources/icon.svg @@ -0,0 +1,13 @@ + + hhtools robot + + + + + + + + + + + diff --git a/desktop/scripts/hhtools-cli-launcher.sh b/desktop/scripts/hhtools-cli-launcher.sh new file mode 100644 index 00000000..e59c0c55 --- /dev/null +++ b/desktop/scripts/hhtools-cli-launcher.sh @@ -0,0 +1,55 @@ +#!/bin/sh + +# Keep this path in sync with productName in desktop/package.json. A packaging +# regression test guards the relationship so a future product rename cannot +# silently strand the CLI. +runtime_root='/opt/Human-Humanoid Tools/resources/runtime' +application_root="$runtime_root/app" +python_executable="$runtime_root/python/bin/python3" + +if [ ! -x "$python_executable" ] || [ ! -d "$application_root/hhtools" ]; then + printf '%s\n' 'hhtools: the bundled Python runtime is incomplete.' >&2 + printf 'Expected runtime: %s\n' "$runtime_root" >&2 + exit 1 +fi + +# The packaged CLI must use only its bundled modules and dependencies. In +# particular, ignore an active virtualenv and user site-packages so an unrelated +# Python installation cannot silently alter a packaged command. +unset PYTHONHOME VIRTUAL_ENV +export PYTHONPATH="$application_root" +export PYTHONNOUSERSITE=1 +export PYTHONDONTWRITEBYTECODE=1 +export PYTHONUTF8=1 +export PYTHONUNBUFFERED=1 + +# The source-tree CLI defaults are relative to a checkout. Supply writable, +# per-user defaults in the installed package while preserving explicit caller +# overrides and the caller's working directory. +if [ -z "${HHTOOLS_SOURCE_ROOT:-}" ]; then + HHTOOLS_SOURCE_ROOT="$application_root/assets/motions" + export HHTOOLS_SOURCE_ROOT +fi + +if [ -z "${HHTOOLS_SAVE_DIR:-}" ]; then + data_root=${XDG_DATA_HOME:-${HOME:+$HOME/.local/share}} + if [ -n "$data_root" ]; then + HHTOOLS_SAVE_DIR="$data_root/hhtools/save_npz" + export HHTOOLS_SAVE_DIR + fi +fi + +if [ -z "${HHTOOLS_CACHE_DIR:-}" ]; then + cache_root=${XDG_CACHE_HOME:-${HOME:+$HOME/.cache}} + if [ -n "$cache_root" ]; then + HHTOOLS_CACHE_DIR="$cache_root/hhtools" + export HHTOOLS_CACHE_DIR + fi +fi + +# Call the same Typer object declared by the Python package's console entry +# point. Executing hhtools.cli.main with `python -m` would first import it from +# hhtools.cli.__init__ and then execute it a second time, producing a noisy +# runpy warning even though the command succeeds. +exec "$python_executable" -c \ + 'from hhtools.cli.main import app; app(prog_name="hhtools")' "$@" diff --git a/desktop/scripts/linux-before-install.sh b/desktop/scripts/linux-before-install.sh new file mode 100644 index 00000000..0e839cdd --- /dev/null +++ b/desktop/scripts/linux-before-install.sh @@ -0,0 +1,23 @@ +#!/bin/sh + +# Releases before the split registered /usr/bin/hhtools as an alternative for +# the Electron binary. Remove only that exact legacy target before dpkg unpacks +# the new, package-owned Python CLI wrapper at the same path. Never touch a +# normal file or an alternative supplied by another installation. +legacy_gui='/opt/Human-Humanoid Tools/hhtools' + +if command -v update-alternatives >/dev/null 2>&1 && \ + update-alternatives --query hhtools 2>/dev/null | \ + grep -Fqx "Alternative: $legacy_gui"; then + update-alternatives --remove hhtools "$legacy_gui" || true +fi + +if [ -L /usr/bin/hhtools ] && \ + [ "$(readlink -f /usr/bin/hhtools 2>/dev/null)" = "$legacy_gui" ]; then + rm -f /usr/bin/hhtools +fi + +printf '%s\n' \ + 'Human-Humanoid Tools declares its Linux GUI libraries in this Debian package.' \ + 'When using dpkg directly, if it reports missing dependencies, run:' \ + ' sudo apt-get -f install' diff --git a/desktop/scripts/prepare-runtime.mjs b/desktop/scripts/prepare-runtime.mjs new file mode 100644 index 00000000..7b6fd4bf --- /dev/null +++ b/desktop/scripts/prepare-runtime.mjs @@ -0,0 +1,474 @@ +import { spawnSync } from 'node:child_process' +import { + cpSync, + chmodSync, + existsSync, + lstatSync, + mkdirSync, + readlinkSync, + realpathSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs' +import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { + assertPathInside, + assertRobotDestinationAvailable, + listApplicationSourceFiles, + resolveBundledRobotDirectory, +} from './runtime-staging-policy.mjs' + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)) +const desktopRoot = resolve(scriptDirectory, '..') +const repositoryRoot = resolve(desktopRoot, '..') +const runtimeRoot = resolve(desktopRoot, '.runtime') +const runtimePython = join(runtimeRoot, 'python') +const runtimeApplication = join(runtimeRoot, 'app') +const runtimeCli = join(runtimeRoot, 'cli') + +function fail(message) { + throw new Error(`[prepare-runtime] ${message}`) +} + +function assertSafeRuntimeTarget() { + if (dirname(runtimeRoot) !== desktopRoot || !runtimeRoot.endsWith(`${sep}.runtime`)) { + fail(`refusing to replace unexpected staging path: ${runtimeRoot}`) + } +} + +function readVirtualEnvironmentConfiguration() { + const path = join(repositoryRoot, '.venv', 'pyvenv.cfg') + if (!existsSync(path)) { + fail(`missing ${path}; run uv sync before packaging`) + } + + const values = {} + for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) { + const match = line.match(/^\s*([^#=]+?)\s*=\s*(.*?)\s*$/) + if (match) values[match[1].toLowerCase()] = match[2] + } + return { path, values } +} + +function existingRealPath(candidate, description) { + const resolved = resolve(candidate) + if (!existsSync(resolved)) fail(`${description} is missing: ${resolved}`) + return realpathSync(resolved) +} + +function normalizePythonHome(candidate) { + const resolved = existingRealPath(candidate, 'Python home') + // POSIX pyvenv.cfg files normally record the interpreter's bin directory as `home`. + // The relocatable runtime needs the installation root containing both bin/ and lib/. + return process.platform !== 'win32' && basename(resolved) === 'bin' + ? realpathSync(dirname(resolved)) + : resolved +} + +function assertPortablePythonHome(pythonHome) { + if (pythonHome === parse(pythonHome).root) { + fail(`refusing to stage a filesystem root as Python home: ${pythonHome}`) + } + if (process.platform !== 'win32' && ['/usr', '/usr/local'].includes(pythonHome)) { + fail( + `refusing to copy the system Python tree (${pythonHome}); ` + + 'run `uv python install 3.12` and recreate .venv with that managed interpreter', + ) + } +} + +function resolvePythonHome(configuration) { + const override = process.env.HHTOOLS_RUNTIME_PYTHON_HOME + if (override) { + const pythonHome = normalizePythonHome(override) + assertPortablePythonHome(pythonHome) + return pythonHome + } + + if (process.platform !== 'win32') { + const configuredExecutable = configuration.values.executable + if (configuredExecutable && existsSync(resolve(configuredExecutable))) { + const pythonHome = normalizePythonHome(dirname(resolve(configuredExecutable))) + assertPortablePythonHome(pythonHome) + return pythonHome + } + + const virtualEnvironmentPython = join(repositoryRoot, '.venv', 'bin', 'python') + if (existsSync(virtualEnvironmentPython)) { + const pythonHome = normalizePythonHome(dirname(realpathSync(virtualEnvironmentPython))) + assertPortablePythonHome(pythonHome) + return pythonHome + } + } + + const configuredHome = configuration.values.home + if (!configuredHome) fail(`unable to read the base Python path from ${configuration.path}`) + // uv exposes the unversioned interpreter directory as a Windows junction. + // Dereference it here so packaging works without Developer Mode/admin rights. + const pythonHome = normalizePythonHome(configuredHome) + assertPortablePythonHome(pythonHome) + return pythonHome +} + +function shouldCopyPythonBase(sourceRoot, sourcePath) { + const path = relative(sourceRoot, sourcePath).replaceAll('\\', '/') + if (!path) return true + if (/^Lib\/site-packages(?:\/|$)/i.test(path)) return false + if (/^lib(?:64)?\/python[^/]+\/site-packages(?:\/|$)/i.test(path)) return false + return !path.split('/').includes('__pycache__') && !path.endsWith('.pyc') +} + +const developmentPackagePrefixes = [ + '__editable__.hhtools-', + '__editable___hhtools_', + '_pytest', + 'a1_coverage.pth', + 'coverage', + 'mypy', + 'mypyc', + 'pytest', + 'pytest_cov', + 'ruff', +] + +function shouldCopySitePackage(sourceRoot, sourcePath) { + const path = relative(sourceRoot, sourcePath).replaceAll('\\', '/') + if (!path) return true + const [topLevel] = path.split('/') + if (developmentPackagePrefixes.some((prefix) => topLevel.startsWith(prefix))) return false + if (topLevel === '_virtualenv.pth' || topLevel === '_virtualenv.py') return false + return !path.split('/').includes('__pycache__') && !path.endsWith('.pyc') +} + +function shouldCopyApplication(sourcePath) { + const path = relative(repositoryRoot, sourcePath).replaceAll('\\', '/') + if (!path) return true + const segments = path.split('/') + if (segments.includes('__pycache__') || segments.includes('node_modules')) return false + if (segments.includes('.git') || segments.includes('.venv')) return false + if (path.endsWith('.pyc') || path.endsWith('.pyo') || path.endsWith('.map')) return false + if (/\/(SMPL|SMPLH|SMPLX)_[^/]+\.(npz|pkl)$/i.test(`/${path}`)) return false + return true +} + +function copyDirectory(source, destination, filter) { + if (!existsSync(source)) fail(`required directory is missing: ${source}`) + cpSync(source, destination, { + recursive: true, + force: true, + filter: (candidate) => filter(source, candidate), + // uv's POSIX distributions use relative links such as bin/python3 -> python3.12. + // Rewriting those links against the build host would make an installed runtime non-portable. + verbatimSymlinks: process.platform !== 'win32', + }) +} + +function assertRelocatableSymlinks(root) { + const canonicalRoot = realpathSync(root) + const pending = [root] + while (pending.length > 0) { + const current = pending.pop() + for (const entry of readdirSync(current, { withFileTypes: true })) { + const path = join(current, entry.name) + if (entry.isDirectory()) { + pending.push(path) + continue + } + if (!entry.isSymbolicLink()) continue + + const target = readlinkSync(path) + if (isAbsolute(target)) { + fail(`packaged runtime contains an absolute symlink: ${path} -> ${target}`) + } + const resolvedTarget = resolve(dirname(path), target) + assertPathInside( + root, + resolvedTarget, + `packaged runtime symlink escapes its root (${path} -> ${target})`, + ) + if (!existsSync(resolvedTarget)) { + fail(`packaged runtime contains a dangling symlink: ${path} -> ${target}`) + } + const canonicalTarget = realpathSync(resolvedTarget) + assertPathInside( + canonicalRoot, + canonicalTarget, + `packaged runtime symlink resolves outside its root (${path} -> ${target})`, + ) + } + } +} + +function assertNoSymlinks(root, description) { + if (lstatSync(root).isSymbolicLink()) { + fail(`${description} must not be a symbolic link: ${root}`) + } + + const pending = [root] + while (pending.length > 0) { + const current = pending.pop() + for (const entry of readdirSync(current, { withFileTypes: true })) { + const path = join(current, entry.name) + if (entry.isSymbolicLink()) { + fail(`${description} contains a symbolic link: ${path}`) + } + if (entry.isDirectory()) pending.push(path) + } + } +} + +function virtualEnvironmentPython() { + return process.platform === 'win32' + ? join(repositoryRoot, '.venv', 'Scripts', 'python.exe') + : join(repositoryRoot, '.venv', 'bin', 'python') +} + +function resolveSitePackages(configuration) { + const override = process.env.HHTOOLS_RUNTIME_SITE_PACKAGES + if (override) return existingRealPath(override, 'site-packages directory') + + const interpreter = virtualEnvironmentPython() + if (existsSync(interpreter)) { + const discovery = spawnSync( + interpreter, + ['-I', '-c', "import sysconfig; print(sysconfig.get_path('purelib'))"], + { cwd: repositoryRoot, encoding: 'utf8' }, + ) + const discovered = discovery.stdout?.trim().split(/\r?\n/).at(-1) + if (discovery.status === 0 && discovered && existsSync(discovered)) { + return realpathSync(discovered) + } + } + + const version = configuration.values.version_info?.match(/^(\d+\.\d+)/)?.[1] + const fallback = process.platform === 'win32' + ? join(repositoryRoot, '.venv', 'Lib', 'site-packages') + : version + ? join(repositoryRoot, '.venv', 'lib', `python${version}`, 'site-packages') + : undefined + if (!fallback) { + fail(`unable to discover site-packages using ${interpreter}`) + } + return existingRealPath(fallback, 'site-packages directory') +} + +function packagedPythonExecutable() { + const executable = process.platform === 'win32' + ? join(runtimePython, 'python.exe') + : join(runtimePython, 'bin', 'python3') + if (!existsSync(executable)) { + fail(`packaged Python executable is missing: ${executable}`) + } + return executable +} + +function stagedSitePackages(pythonExecutable) { + const discovery = spawnSync( + pythonExecutable, + ['-I', '-c', "import sysconfig; print(sysconfig.get_path('purelib'))"], + { cwd: runtimePython, encoding: 'utf8' }, + ) + const discovered = discovery.stdout?.trim().split(/\r?\n/).at(-1) + if (discovery.status !== 0 || !discovered) { + fail( + `unable to discover site-packages using staged Python:\n` + + `${discovery.stdout}\n${discovery.stderr}`, + ) + } + + const destination = resolve(discovered) + assertPathInside( + runtimePython, + destination, + 'staged Python reported site-packages outside its runtime root', + { allowRoot: false }, + ) + return destination +} + +const applicationInputs = [ + 'hhtools', + 'configs', + 'assets/reference_poses', + 'assets/motions', + 'docker/gvhmr', + 'LICENSE', + 'README.md', + 'pyproject.toml', +] + +function copyApplicationFiles(sourceFiles) { + let copied = 0 + let motionFileCount = 0 + for (const file of sourceFiles.files) { + const source = join(repositoryRoot, file) + let metadata + try { + metadata = lstatSync(source) + } catch { + fail(`source file recorded by ${sourceFiles.provenance} is missing: ${source}`) + } + if (metadata.isDirectory() || !shouldCopyApplication(source)) continue + + const destination = join(runtimeApplication, file) + mkdirSync(dirname(destination), { recursive: true }) + cpSync(source, destination, { + force: true, + verbatimSymlinks: process.platform !== 'win32', + }) + copied += 1 + if (file.replaceAll('\\', '/').startsWith('assets/motions/')) motionFileCount += 1 + } + return { copied, motionFileCount, provenance: sourceFiles.provenance } +} + +function stageLinuxCliLauncher() { + if (process.platform === 'win32') return null + + const source = join(desktopRoot, 'scripts', 'hhtools-cli-launcher.sh') + if (!existsSync(source)) fail(`missing Linux CLI launcher: ${source}`) + + const destination = join(runtimeCli, 'hhtools') + mkdirSync(runtimeCli, { recursive: true }) + cpSync(source, destination, { force: true }) + // FPM preserves this mode when mapping the staged file into /usr/bin. + chmodSync(destination, 0o755) + return destination +} + +function copyBundledRobots() { + const source = resolveBundledRobotDirectory(process.env) + if (!source) return { source: null, count: 0 } + + if (!existsSync(source)) fail(`bundled robot directory is missing: ${source}`) + const sourceMetadata = lstatSync(source) + if (sourceMetadata.isSymbolicLink()) fail(`bundled robot path is a symbolic link: ${source}`) + if (!sourceMetadata.isDirectory()) fail(`bundled robot path is not a directory: ${source}`) + assertNoSymlinks(source, 'bundled robot directory') + + const destination = join(runtimeApplication, 'configs', 'robots') + mkdirSync(destination, { recursive: true }) + let count = 0 + for (const entry of readdirSync(source, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name.startsWith('_')) continue + const robotSource = join(source, entry.name) + const robotDestination = join(destination, entry.name) + assertRobotDestinationAvailable(robotDestination, entry.name) + cpSync(robotSource, robotDestination, { + recursive: true, + force: true, + filter: (candidate) => shouldCopyApplication(candidate), + }) + count += 1 + } + return { source, count } +} + +function treeSummary(root) { + let files = 0 + let bytes = 0 + const pending = [root] + while (pending.length > 0) { + const current = pending.pop() + for (const entry of readdirSync(current, { withFileTypes: true })) { + const path = join(current, entry.name) + if (entry.isDirectory()) pending.push(path) + else if (entry.isFile()) { + files += 1 + bytes += statSync(path).size + } + } + } + return { files, bytes } +} + +assertSafeRuntimeTarget() +rmSync(runtimeRoot, { recursive: true, force: true }) +mkdirSync(runtimeApplication, { recursive: true }) + +const configuration = readVirtualEnvironmentConfiguration() +const pythonHome = resolvePythonHome(configuration) +const sitePackages = resolveSitePackages(configuration) + +console.log(`[prepare-runtime] Python: ${pythonHome}`) +copyDirectory(pythonHome, runtimePython, shouldCopyPythonBase) +assertRelocatableSymlinks(runtimePython) +const pythonExecutable = packagedPythonExecutable() +const packagedSitePackages = stagedSitePackages(pythonExecutable) +copyDirectory(sitePackages, packagedSitePackages, shouldCopySitePackage) + +const applicationSource = listApplicationSourceFiles( + repositoryRoot, + applicationInputs, + process.env, +) +if (applicationSource.provenance === 'trusted-archive') { + console.warn('[prepare-runtime] Trusting allowlisted files from a source archive without .git.') +} +const application = copyApplicationFiles(applicationSource) +const robots = copyBundledRobots() +const cliLauncher = stageLinuxCliLauncher() +assertRelocatableSymlinks(runtimeRoot) + +const verification = spawnSync( + pythonExecutable, + [ + '-c', + [ + 'import fastapi, hhtools, mujoco, newton, torch, warp', + 'from hhtools.web.server import create_app', + "print('runtime-ok', torch.__version__, mujoco.__version__)", + ].join('; '), + ], + { + cwd: runtimeApplication, + encoding: 'utf8', + env: { + ...process.env, + PYTHONNOUSERSITE: '1', + PYTHONDONTWRITEBYTECODE: '1', + PYTHONPATH: runtimeApplication, + PYTHONUTF8: '1', + }, + }, +) +if (verification.status !== 0) { + fail(`bundled Python import check failed:\n${verification.stdout}\n${verification.stderr}`) +} + +const summary = treeSummary(runtimeRoot) +const manifest = { + schemaVersion: 1, + createdAt: new Date().toISOString(), + platform: process.platform, + pythonHome, + sitePackages, + packagedSitePackages: relative(runtimeRoot, packagedSitePackages).replaceAll('\\', '/'), + applicationSourceProvenance: application.provenance, + applicationFileCount: application.copied, + motionFileCount: application.motionFileCount, + bundledRobotSource: robots.source, + bundledRobotCount: robots.count, + cliLauncher: cliLauncher === null + ? null + : relative(runtimeRoot, cliLauncher).replaceAll('\\', '/'), + files: summary.files, + bytes: summary.bytes, +} +writeFileSync( + join(runtimeRoot, 'runtime-manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n`, + 'utf8', +) + +console.log(verification.stdout.trim()) +console.log( + `[prepare-runtime] Ready: ${summary.files.toLocaleString()} files, ` + + `${(summary.bytes / 1024 / 1024).toFixed(1)} MiB, ${robots.count} bundled robots, ` + + `${application.motionFileCount} tracked motion files.`, +) diff --git a/desktop/scripts/runtime-staging-policy.d.mts b/desktop/scripts/runtime-staging-policy.d.mts new file mode 100644 index 00000000..b7782210 --- /dev/null +++ b/desktop/scripts/runtime-staging-policy.d.mts @@ -0,0 +1,24 @@ +export interface ApplicationSourceFiles { + provenance: 'git-tracked-worktree' | 'trusted-archive' + files: string[] +} + +export function listApplicationSourceFiles( + repositoryRoot: string, + inputs: string[], + env?: NodeJS.ProcessEnv +): ApplicationSourceFiles + +export function resolveBundledRobotDirectory( + env?: NodeJS.ProcessEnv, + cwd?: string +): string | null + +export function assertRobotDestinationAvailable(destination: string, robotName: string): void + +export function assertPathInside( + root: string, + candidate: string, + description: string, + options?: { allowRoot?: boolean } +): void diff --git a/desktop/scripts/runtime-staging-policy.mjs b/desktop/scripts/runtime-staging-policy.mjs new file mode 100644 index 00000000..757db819 --- /dev/null +++ b/desktop/scripts/runtime-staging-policy.mjs @@ -0,0 +1,85 @@ +import { execFileSync, spawnSync } from 'node:child_process' +import { existsSync, lstatSync, readdirSync, realpathSync } from 'node:fs' +import { isAbsolute, join, relative, resolve, sep } from 'node:path' + +function fail(message) { + throw new Error(`[prepare-runtime] ${message}`) +} + +function collectArchiveFiles(repositoryRoot, relativePath, output) { + const source = join(repositoryRoot, relativePath) + let metadata + try { + metadata = lstatSync(source) + } catch { + fail(`required archive input is missing: ${source}`) + } + if (!metadata.isDirectory()) { + output.push(relativePath.replaceAll('\\', '/')) + return + } + for (const entry of readdirSync(source, { withFileTypes: true })) { + collectArchiveFiles(repositoryRoot, join(relativePath, entry.name), output) + } +} + +/** Select application inputs from this checkout's own Git index. */ +export function listApplicationSourceFiles(repositoryRoot, inputs, env = process.env) { + const repositoryCheck = spawnSync( + 'git', + ['rev-parse', '--show-toplevel'], + { cwd: repositoryRoot, encoding: 'utf8' }, + ) + const reportedRoot = repositoryCheck.status === 0 + ? repositoryCheck.stdout.trim() + : undefined + const ownsGitIndex = reportedRoot + && existsSync(reportedRoot) + && realpathSync(reportedRoot) === realpathSync(repositoryRoot) + + if (ownsGitIndex) { + const output = execFileSync( + 'git', + ['ls-files', '-z', '--', ...inputs], + { cwd: repositoryRoot, encoding: 'buffer' }, + ) + return { + provenance: 'git-tracked-worktree', + files: output.toString('utf8').split('\0').filter(Boolean), + } + } + + if (env.HHTOOLS_TRUST_SOURCE_ARCHIVE !== '1') { + fail( + 'source tree has no matching Git index; package a checkout, or set ' + + 'HHTOOLS_TRUST_SOURCE_ARCHIVE=1 only for a verified `git archive` extraction', + ) + } + const files = [] + for (const input of inputs) collectArchiveFiles(repositoryRoot, input, files) + return { provenance: 'trusted-archive', files } +} + +/** Robot assets are package inputs only when the packager selects them explicitly. */ +export function resolveBundledRobotDirectory(env = process.env, cwd = process.cwd()) { + const configured = env.HHTOOLS_BUNDLED_ROBOT_DIR?.trim() + return configured ? resolve(cwd, configured) : null +} + +export function assertRobotDestinationAvailable(destination, robotName) { + if (existsSync(destination)) { + fail(`refusing to merge duplicate bundled robot: ${robotName}`) + } +} + +export function assertPathInside(root, candidate, description, { allowRoot = true } = {}) { + const escaped = relative(root, candidate) + if ( + (!allowRoot && !escaped) + || escaped === '..' + || escaped.startsWith(`..${sep}`) + || isAbsolute(escaped) + ) { + fail(`${description}: ${candidate}`) + } +} diff --git a/desktop/src/main/app-lifecycle.ts b/desktop/src/main/app-lifecycle.ts new file mode 100644 index 00000000..bb7e1ee8 --- /dev/null +++ b/desktop/src/main/app-lifecycle.ts @@ -0,0 +1,74 @@ +import type { AppPhase } from '../shared/runtime-state' + +type ShutdownJoiner = () => void | Promise + +const PHASE_ORDER: readonly AppPhase[] = [ + 'starting', + 'backend-starting', + 'ready', + 'after-window-open', + 'shutting-down' +] + +export interface ShutdownResult { + timedOut: boolean + failures: Array<{ name: string; reason: unknown }> +} + +export class AppLifecycle { + private currentPhase: AppPhase = 'starting' + private readonly shutdownJoiners = new Map() + + get phase(): AppPhase { + return this.currentPhase + } + + transition(next: AppPhase): void { + if (next === this.currentPhase) return + + const currentIndex = PHASE_ORDER.indexOf(this.currentPhase) + const nextIndex = PHASE_ORDER.indexOf(next) + + // Startup phases never move backward; backend recovery is a SidecarSupervisor concern. + if (nextIndex < currentIndex) { + throw new Error(`Invalid lifecycle transition: ${this.currentPhase} -> ${next}`) + } + this.currentPhase = next + } + + registerShutdownJoiner(name: string, joiner: ShutdownJoiner): () => void { + if (this.shutdownJoiners.has(name)) { + throw new Error(`Shutdown joiner already registered: ${name}`) + } + this.shutdownJoiners.set(name, joiner) + return () => this.shutdownJoiners.delete(name) + } + + async runShutdownJoiners(timeoutMs = 5_000): Promise { + this.transition('shutting-down') + + const failures: ShutdownResult['failures'] = [] + + // Joiners are independent, so one failure must not prevent the other resources from closing. + const work = Promise.all( + [...this.shutdownJoiners.entries()].map(async ([name, joiner]) => { + try { + await joiner() + } catch (reason) { + failures.push({ name, reason }) + } + }) + ) + + let timer: NodeJS.Timeout | undefined + + // Bound total shutdown time so an unresponsive child cannot trap Electron indefinitely. + const timeout = new Promise<'timeout'>((resolve) => { + timer = setTimeout(() => resolve('timeout'), timeoutMs) + }) + const result = await Promise.race([work.then(() => 'complete' as const), timeout]) + if (timer !== undefined) clearTimeout(timer) + + return { timedOut: result === 'timeout', failures } + } +} diff --git a/desktop/src/main/desktop-logger.ts b/desktop/src/main/desktop-logger.ts new file mode 100644 index 00000000..9183c500 --- /dev/null +++ b/desktop/src/main/desktop-logger.ts @@ -0,0 +1,48 @@ +import { createWriteStream, mkdirSync, type WriteStream } from 'node:fs' +import { join } from 'node:path' + +export interface LoggerLike { + info(message: string, details?: Record): void + warn(message: string, details?: Record): void + error(message: string, details?: Record): void + processOutput(stream: 'stdout' | 'stderr', text: string): void +} + +export class DesktopLogger implements LoggerLike { + readonly filePath: string + private readonly stream: WriteStream + + constructor(logDirectory: string) { + mkdirSync(logDirectory, { recursive: true }) + const stamp = new Date().toISOString().replaceAll(':', '-').replaceAll('.', '-') + this.filePath = join(logDirectory, `desktop-${stamp}.log`) + this.stream = createWriteStream(this.filePath, { encoding: 'utf8', flags: 'a' }) + } + + info(message: string, details?: Record): void { + this.write('info', message, details) + } + + warn(message: string, details?: Record): void { + this.write('warn', message, details) + } + + error(message: string, details?: Record): void { + this.write('error', message, details) + } + + processOutput(stream: 'stdout' | 'stderr', text: string): void { + for (const line of text.split(/\r?\n/)) { + if (line.length > 0) this.write(`sidecar:${stream}`, line) + } + } + + close(): Promise { + return new Promise((resolve) => this.stream.end(resolve)) + } + + private write(level: string, message: string, details?: Record): void { + const suffix = details === undefined ? '' : ` ${JSON.stringify(details)}` + this.stream.write(`${new Date().toISOString()} [${level}] ${message}${suffix}\n`) + } +} diff --git a/desktop/src/main/diagnostics-page.ts b/desktop/src/main/diagnostics-page.ts new file mode 100644 index 00000000..2e86e0c7 --- /dev/null +++ b/desktop/src/main/diagnostics-page.ts @@ -0,0 +1,37 @@ +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +export function diagnosticsDataUrl(details: { + title: string + message: string + stage: string + logPath?: string + pythonPath?: string +}): string { + const rows = [ + ['Startup stage', details.stage], + ['Python', details.pythonPath ?? 'Not resolved'], + ['Log file', details.logPath ?? 'Not created'] + ] + .map( + ([label, value]) => + `
${escapeHtml(label)}${escapeHtml(value)}
` + ) + .join('') + + const html = ` + +${escapeHtml(details.title)} +

${escapeHtml(details.title)}

${escapeHtml(details.message)}

${rows}
` + return `data:text/html;charset=UTF-8,${encodeURIComponent(html)}` +} diff --git a/desktop/src/main/graphics-mode.ts b/desktop/src/main/graphics-mode.ts new file mode 100644 index 00000000..64030303 --- /dev/null +++ b/desktop/src/main/graphics-mode.ts @@ -0,0 +1,61 @@ +/** + * Internal Linux graphics-mode selection. + * + * hhtools normally lets Chromium use the host GPU. If a real WebGL2 probe + * fails, the app relaunches once with the private argument below and uses the + * SwiftShader libraries bundled with Electron. Keeping the argument private + * avoids forcing software rendering on machines with a working GPU. + */ +export const SOFTWARE_RENDERING_ARGUMENT = '--hhtools-software-rendering' + +export interface CommandLineSwitches { + appendSwitch(name: string, value?: string): void +} + +export type GraphicsStartupAction = 'start' | 'relaunch' | 'fail' + +export function softwareRenderingRequested( + argv: readonly string[] = process.argv, + platform: NodeJS.Platform = process.platform +): boolean { + return platform === 'linux' && argv.includes(SOFTWARE_RENDERING_ARGUMENT) +} + +/** Must run before Electron's ready event so Chromium receives the GL flags. */ +export function configureGraphicsCommandLine( + commandLine: CommandLineSwitches, + argv: readonly string[] = process.argv, + platform: NodeJS.Platform = process.platform +): boolean { + const softwareRendering = softwareRenderingRequested(argv, platform) + if (softwareRendering) { + // SwANGLE is Chromium's supported software GLES path. `--disable-gpu` is + // intentionally not used because the WebUI itself requires WebGL2. Modern + // Chromium also requires an explicit opt-in before trusted content may use + // its lower-security software WebGL implementation. The switch is confined + // to this fallback launch; the BrowserWindow still uses the sandbox and is + // restricted to hhtools' authenticated localhost origin. + commandLine.appendSwitch('use-gl', 'angle') + commandLine.appendSwitch('use-angle', 'swiftshader') + commandLine.appendSwitch('enable-unsafe-swiftshader') + } + return softwareRendering +} + +export function decideGraphicsStartup( + webgl2Available: boolean, + softwareRendering: boolean, + platform: NodeJS.Platform = process.platform +): GraphicsStartupAction { + if (webgl2Available) return 'start' + if (platform === 'linux' && !softwareRendering) return 'relaunch' + return 'fail' +} + +/** Electron's relaunch API expects argv without the executable at index zero. */ +export function softwareRenderingRelaunchArgs(argv: readonly string[] = process.argv): string[] { + return [ + ...argv.slice(1).filter((argument) => argument !== SOFTWARE_RENDERING_ARGUMENT), + SOFTWARE_RENDERING_ARGUMENT + ] +} diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts new file mode 100644 index 00000000..7e42f3ac --- /dev/null +++ b/desktop/src/main/index.ts @@ -0,0 +1,380 @@ +/** + * Electron main-process bootstrap. + * + * Startup is intentionally linear: resolve the external Python runtime, create a + * secured localhost session, start and health-check the sidecar, load the existing + * WebUI, then reveal the window. Shutdown follows the reverse ownership chain. + */ +import { randomBytes } from 'node:crypto' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { app, BrowserWindow, dialog, session } from 'electron' + +import { DESKTOP_CHANNELS } from '../shared/desktop-api' +import type { RuntimeState } from '../shared/runtime-state' +import { AppLifecycle } from './app-lifecycle' +import { DesktopLogger } from './desktop-logger' +import { diagnosticsDataUrl } from './diagnostics-page' +import { + configureGraphicsCommandLine, + decideGraphicsStartup, + softwareRenderingRelaunchArgs +} from './graphics-mode' +import { registerDesktopHandlers } from './ipc/register-desktop-handlers' +import { createMainWindow } from './main-window' +import { findAvailablePort } from './network' +import { OptionalComponentStore, runGvhmrSetup } from './optional-components' +import { buildSidecarEnvironment, resolveRuntime, type RuntimeConfig } from './runtime-resolver' +import { configureDesktopSession } from './security/configure-session' +import { SidecarSupervisor, type SidecarSnapshot } from './sidecar-supervisor' +import { WindowStateStore } from './window-state-store' + +const lifecycle = new AppLifecycle() +// Chromium graphics switches are immutable after `ready`, so configure the +// second (software-rendered) launch as soon as Electron is imported. +const softwareRendering = configureGraphicsCommandLine(app.commandLine) + +interface GraphicsProbeResult { + webgl2: boolean + renderer?: string + error?: string +} + +let graphicsProbe: GraphicsProbeResult | undefined + +/** Probe WebGL2 before Python or the real Three.js WebUI starts. */ +async function probeWebGL2(): Promise { + const probeWindow = new BrowserWindow({ + width: 16, + height: 16, + show: false, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webSecurity: true + } + }) + + try { + await probeWindow.loadURL('data:text/html;charset=utf-8,hhtools graphics probe') + const result: unknown = await probeWindow.webContents.executeJavaScript(`(() => { + const canvas = document.createElement('canvas') + const context = canvas.getContext('webgl2', { failIfMajorPerformanceCaveat: false }) + if (context === null) return { webgl2: false } + + const debugInfo = context.getExtension('WEBGL_debug_renderer_info') + const renderer = debugInfo === null + ? context.getParameter(context.RENDERER) + : context.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) + return { webgl2: true, renderer: String(renderer) } + })()`, true) + + if ( + typeof result === 'object' && + result !== null && + 'webgl2' in result && + typeof result.webgl2 === 'boolean' + ) { + return { + webgl2: result.webgl2, + renderer: 'renderer' in result && typeof result.renderer === 'string' + ? result.renderer + : undefined + } + } + return { webgl2: false, error: 'The graphics probe returned an invalid result.' } + } catch (reason) { + return { + webgl2: false, + error: reason instanceof Error ? reason.message : String(reason) + } + } finally { + if (!probeWindow.isDestroyed()) probeWindow.destroy() + } +} + +async function prepareDesktopGraphics(): Promise { + graphicsProbe = await probeWebGL2() + const action = decideGraphicsStartup(graphicsProbe.webgl2, softwareRendering) + if (action === 'start') return true + + if (action === 'relaunch') { + console.warn('WebGL2 is unavailable; relaunching hhtools with software rendering.') + app.relaunch({ args: softwareRenderingRelaunchArgs() }) + app.exit(0) + return false + } + + const detail = graphicsProbe.error === undefined ? '' : ` ${graphicsProbe.error}` + throw new Error( + `WebGL2 is unavailable${softwareRendering ? ' with the bundled software renderer' : ''}.${detail}` + ) +} + +function desktopIconPath(): string { + return join(app.getAppPath(), 'resources', 'icon.png') +} + +// These references are process-wide because requestSingleInstanceLock() guarantees one owner. +let mainWindow: BrowserWindow | undefined +let supervisor: SidecarSupervisor | undefined +let logger: DesktopLogger | undefined +let runtime: RuntimeConfig | undefined +let backendOrigin: string | undefined +let allowQuit = false +let shutdownPromise: Promise | undefined +let crashDialogOpen = false +let optionalComponents: OptionalComponentStore | undefined + +function runtimeState(snapshot: SidecarSnapshot | undefined = supervisor?.snapshot): RuntimeState { + return { + appPhase: lifecycle.phase, + backendState: snapshot?.state ?? 'stopped', + backendOrigin, + backendPid: snapshot?.pid, + error: snapshot?.error + } +} + +function broadcastRuntimeState(snapshot?: SidecarSnapshot): void { + if (mainWindow !== undefined && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(DESKTOP_CHANNELS.runtimeStateChanged, runtimeState(snapshot)) + } +} + +async function offerCrashRecovery(snapshot: SidecarSnapshot): Promise { + if ( + crashDialogOpen || + lifecycle.phase === 'shutting-down' || + mainWindow === undefined || + mainWindow.isDestroyed() + ) { + return + } + crashDialogOpen = true + try { + const result = await dialog.showMessageBox(mainWindow, { + type: 'error', + title: 'Human-Humanoid Tools backend stopped', + message: 'The local Python backend stopped unexpectedly.', + detail: snapshot.error ?? 'See the desktop log for details.', + buttons: ['Restart backend', 'Close'], + defaultId: 0, + cancelId: 1 + }) + if (result.response === 0 && supervisor !== undefined) await supervisor.restart() + } finally { + crashDialogOpen = false + } +} + +async function startDesktop(): Promise { + app.setAppUserModelId('com.roboparty.hhtools.desktop.alpha') + const userData = app.getPath('userData') + + optionalComponents = new OptionalComponentStore({ + userData, + localAppData: process.env.LOCALAPPDATA, + env: process.env, + }) + runtime = resolveRuntime({ + appPath: app.getAppPath(), + cwd: process.cwd(), + userData, + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + }) + logger = new DesktopLogger(runtime.logDirectory) + logger.info('Desktop startup began', { repoRoot: runtime.repoRoot, bundled: runtime.bundled }) + logger.info('Graphics preflight passed', { + renderer: graphicsProbe?.renderer, + softwareRendering + }) + + const sidecarEnvironment = (): NodeJS.ProcessEnv => ({ + ...optionalComponents?.sidecarEnvironment(), + ...buildSidecarEnvironment(runtime!.repoRoot), + }) + + const port = await findAvailablePort() + const secret = randomBytes(32).toString('hex') + backendOrigin = `http://127.0.0.1:${port}` + + // Electron injects this per-launch secret below the renderer boundary. WebUI code never sees it. + configureDesktopSession(session.defaultSession, backendOrigin, secret) + + const preloadPath = join(dirname(fileURLToPath(import.meta.url)), '../preload/index.cjs') + const stateStore = new WindowStateStore(join(userData, 'window-state.json')) + const windowResult = createMainWindow({ + iconPath: desktopIconPath(), + preloadPath, + trustedOrigin: backendOrigin, + stateStore, + logger + }) + mainWindow = windowResult.window + + supervisor = new SidecarSupervisor( + { + command: runtime.pythonExecutable, + args: [ + '-m', + 'hhtools.cli.desktop_sidecar', + '--source', + runtime.sourceRoot, + '--save-dir', + runtime.saveDirectory, + '--cache', + runtime.cacheDirectory, + '--host', + '127.0.0.1', + '--port', + String(port) + ], + cwd: runtime.repoRoot, + env: { + ...sidecarEnvironment(), + // Use the environment rather than argv so the secret is absent from process listings. + HHTOOLS_DESKTOP_SESSION_SECRET: secret + }, + origin: backendOrigin, + sessionSecret: secret + }, + logger + ) + + const removeStateListener = supervisor.onStateChange((snapshot) => { + broadcastRuntimeState(snapshot) + if (snapshot.state === 'crashed' && lifecycle.phase === 'after-window-open') { + void offerCrashRecovery(snapshot) + } + }) + + // Every resource owned by Main registers one cleanup hook in the same shutdown coordinator. + lifecycle.registerShutdownJoiner('runtime-state-listener', removeStateListener) + lifecycle.registerShutdownJoiner('python-sidecar', () => supervisor?.stop()) + + const removeDesktopHandlers = registerDesktopHandlers({ + mainWindow, + trustedOrigin: backendOrigin, + getRuntimeState: () => runtimeState(), + getOptionalComponents: () => optionalComponents!.getState(), + restartBackend: async () => { + if (lifecycle.phase === 'shutting-down' || supervisor === undefined) { + throw new Error('The application is shutting down') + } + const snapshot = await supervisor.restart() + return runtimeState(snapshot) + }, + setupGvhmr: () => runGvhmrSetup({ + mainWindow: mainWindow!, + store: optionalComponents!, + onConfigured: async () => { + if (supervisor === undefined) return + supervisor.updateEnvironment({ + ...sidecarEnvironment(), + HHTOOLS_DESKTOP_SESSION_SECRET: secret, + }) + await supervisor.restart() + }, + }), + }) + lifecycle.registerShutdownJoiner('desktop-ipc', removeDesktopHandlers) + + lifecycle.transition('backend-starting') + broadcastRuntimeState() + + // start() resolves only after the authenticated /api/health endpoint answers successfully. + await supervisor.start() + lifecycle.transition('ready') + broadcastRuntimeState() + + await mainWindow.loadURL(backendOrigin) + await windowResult.readyToShow + + // Keeping the window hidden until backend and renderer are ready avoids a blank or error flash. + if (!mainWindow.isDestroyed()) mainWindow.show() + lifecycle.transition('after-window-open') + broadcastRuntimeState() + logger.info('Desktop window opened', { origin: backendOrigin }) +} + +async function showStartupFailure(reason: unknown): Promise { + const message = reason instanceof Error ? reason.message : String(reason) + logger?.error('Desktop startup failed', { error: message }) + + if (mainWindow === undefined || mainWindow.isDestroyed()) { + mainWindow = new BrowserWindow({ + width: 760, + height: 520, + show: false, + autoHideMenuBar: true, + icon: desktopIconPath(), + webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true } + }) + } + await mainWindow.loadURL( + diagnosticsDataUrl({ + title: 'Human-Humanoid Tools could not start', + message, + stage: lifecycle.phase, + logPath: logger?.filePath, + pythonPath: runtime?.pythonExecutable + }) + ) + if (!mainWindow.isDestroyed()) mainWindow.show() +} + +async function shutdown(): Promise { + // Electron can emit before-quit more than once; all callers share one shutdown operation. + if (shutdownPromise !== undefined) return shutdownPromise + shutdownPromise = (async () => { + const result = await lifecycle.runShutdownJoiners(7_000) + if (result.timedOut) logger?.warn('Desktop shutdown timed out') + for (const failure of result.failures) { + logger?.error('Shutdown joiner failed', { + name: failure.name, + error: failure.reason instanceof Error ? failure.reason.message : String(failure.reason) + }) + } + logger?.info('Desktop shutdown complete') + await logger?.close() + allowQuit = true + app.quit() + })() + return shutdownPromise +} + +const hasSingleInstanceLock = app.requestSingleInstanceLock() +if (!hasSingleInstanceLock) { + app.quit() +} else { + app.on('second-instance', () => { + if (mainWindow === undefined || mainWindow.isDestroyed()) return + if (mainWindow.isMinimized()) mainWindow.restore() + mainWindow.show() + mainWindow.focus() + }) + + app.whenReady() + .then(async () => { + if (!(await prepareDesktopGraphics())) return + await startDesktop() + }) + .catch((reason: unknown) => void showStartupFailure(reason)) + + app.on('window-all-closed', () => { + // Destroying the temporary WebGL probe leaves no windows as well. Do not + // turn that expected preflight event into an application shutdown. + if (mainWindow !== undefined) app.quit() + }) + app.on('before-quit', (event) => { + if (allowQuit) return + + // Delay the actual quit until the sidecar, IPC handlers, and log stream are closed. + event.preventDefault() + void shutdown() + }) +} diff --git a/desktop/src/main/ipc/register-desktop-handlers.ts b/desktop/src/main/ipc/register-desktop-handlers.ts new file mode 100644 index 00000000..72a12f3d --- /dev/null +++ b/desktop/src/main/ipc/register-desktop-handlers.ts @@ -0,0 +1,64 @@ +import { dialog, ipcMain, shell, type BrowserWindow } from 'electron' + +import { DESKTOP_CHANNELS } from '../../shared/desktop-api' +import type { GvhmrSetupResult, OptionalComponentsState } from '../../shared/desktop-api' +import type { RuntimeState } from '../../shared/runtime-state' +import { assertTrustedIpcSender } from './validate-ipc-sender' + +export function registerDesktopHandlers(options: { + mainWindow: BrowserWindow + trustedOrigin: string + getRuntimeState: () => RuntimeState + getOptionalComponents: () => OptionalComponentsState + restartBackend: () => Promise + setupGvhmr: () => Promise +}): () => void { + // Every handler applies the same WebContents, main-frame, and origin checks before doing work. + const trusted = (event: Electron.IpcMainInvokeEvent): void => + assertTrustedIpcSender(event, options.mainWindow, options.trustedOrigin) + + ipcMain.handle(DESKTOP_CHANNELS.getRuntimeState, (event) => { + trusted(event) + return options.getRuntimeState() + }) + ipcMain.handle(DESKTOP_CHANNELS.getOptionalComponents, (event) => { + trusted(event) + return options.getOptionalComponents() + }) + ipcMain.handle(DESKTOP_CHANNELS.restartBackend, async (event) => { + trusted(event) + return options.restartBackend() + }) + ipcMain.handle(DESKTOP_CHANNELS.setupGvhmr, async (event) => { + trusted(event) + return options.setupGvhmr() + }) + ipcMain.handle(DESKTOP_CHANNELS.selectDirectory, async (event) => { + trusted(event) + const result = await dialog.showOpenDialog(options.mainWindow, { + properties: ['openDirectory', 'createDirectory'] + }) + return result.canceled ? null : (result.filePaths[0] ?? null) + }) + ipcMain.handle(DESKTOP_CHANNELS.openExternal, async (event, value: unknown) => { + trusted(event) + if (typeof value !== 'string' || value.length > 2_048) { + throw new Error('Invalid external URL') + } + const url = new URL(value) + // Reject file:, shell:, and custom protocols before handing the URL to the operating system. + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error('Only HTTP(S) external URLs are allowed') + } + await shell.openExternal(url.toString()) + }) + + return () => { + ipcMain.removeHandler(DESKTOP_CHANNELS.getRuntimeState) + ipcMain.removeHandler(DESKTOP_CHANNELS.getOptionalComponents) + ipcMain.removeHandler(DESKTOP_CHANNELS.restartBackend) + ipcMain.removeHandler(DESKTOP_CHANNELS.setupGvhmr) + ipcMain.removeHandler(DESKTOP_CHANNELS.selectDirectory) + ipcMain.removeHandler(DESKTOP_CHANNELS.openExternal) + } +} diff --git a/desktop/src/main/ipc/validate-ipc-sender.ts b/desktop/src/main/ipc/validate-ipc-sender.ts new file mode 100644 index 00000000..fc599c2a --- /dev/null +++ b/desktop/src/main/ipc/validate-ipc-sender.ts @@ -0,0 +1,26 @@ +import type { BrowserWindow, IpcMainInvokeEvent } from 'electron' + +export function assertTrustedIpcSender( + event: IpcMainInvokeEvent, + mainWindow: BrowserWindow, + trustedOrigin: string +): void { + // IPC channel names are not an authorization boundary: validate the owning WebContents too. + if (mainWindow.isDestroyed() || event.sender !== mainWindow.webContents) { + throw new Error('Rejected IPC from an unknown WebContents') + } + if (event.senderFrame === null || event.senderFrame !== event.sender.mainFrame) { + // A compromised iframe must not inherit the main frame's desktop privileges. + throw new Error('Rejected IPC from a child frame') + } + + let senderOrigin: string + try { + senderOrigin = new URL(event.senderFrame.url).origin + } catch { + throw new Error('Rejected IPC with an invalid sender URL') + } + if (senderOrigin !== trustedOrigin) { + throw new Error('Rejected IPC from an untrusted origin') + } +} diff --git a/desktop/src/main/main-window.ts b/desktop/src/main/main-window.ts new file mode 100644 index 00000000..ab491f53 --- /dev/null +++ b/desktop/src/main/main-window.ts @@ -0,0 +1,94 @@ +import { BrowserWindow, screen, shell } from 'electron' + +import type { LoggerLike } from './desktop-logger' +import { waitForWindowReadiness } from './window-readiness' +import { WindowStateStore } from './window-state-store' + +export interface MainWindowResult { + window: BrowserWindow + readyToShow: Promise +} + +function isExternalHttpUrl(url: string): boolean { + try { + const parsed = new URL(url) + return parsed.protocol === 'http:' || parsed.protocol === 'https:' + } catch { + return false + } +} + +export function createMainWindow(options: { + iconPath: string + preloadPath: string + trustedOrigin: string + stateStore: WindowStateStore + logger: LoggerLike +}): MainWindowResult { + const displays = screen.getAllDisplays() + const primary = screen.getPrimaryDisplay() + const state = options.stateStore.load(displays, primary) + + const window = new BrowserWindow({ + x: state.x, + y: state.y, + width: state.width, + height: state.height, + minWidth: 1024, + minHeight: 700, + show: false, + autoHideMenuBar: true, + backgroundColor: '#ffffff', + icon: options.iconPath, + title: 'Human-Humanoid Tools', + webPreferences: { + // The WebUI is treated as untrusted web content and reaches desktop APIs only via preload. + preload: options.preloadPath, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webSecurity: true + } + }) + + if (state.maximized) window.maximize() + + const saveState = (): void => { + if (window.isDestroyed() || window.isMinimized()) return + + // Save normal bounds while maximized so the next restored window is not screen-sized. + const bounds = window.isMaximized() ? window.getNormalBounds() : window.getBounds() + options.stateStore.save({ ...bounds, maximized: window.isMaximized() }) + } + window.on('close', saveState) + + window.webContents.setWindowOpenHandler(({ url }) => { + // Never create arbitrary Electron child windows; normal web links belong in the OS browser. + if (isExternalHttpUrl(url)) void shell.openExternal(url) + return { action: 'deny' } + }) + window.webContents.on('will-navigate', (event, url) => { + try { + if (new URL(url).origin === options.trustedOrigin) return + } catch { + // Invalid or non-network navigation is denied below. + } + event.preventDefault() + if (isExternalHttpUrl(url)) void shell.openExternal(url) + }) + window.webContents.on('render-process-gone', (_event, details) => { + options.logger.error('Renderer process exited', { + reason: details.reason, + exitCode: details.exitCode + }) + }) + window.webContents.on('unresponsive', () => options.logger.warn('Renderer became unresponsive')) + window.webContents.on('did-fail-load', (_event, code, description, validatedUrl) => { + options.logger.error('Renderer failed to load', { code, description, url: validatedUrl }) + }) + + // `did-finish-load` is a necessary fallback on Wayland, where an initially + // hidden BrowserWindow can finish rendering without emitting ready-to-show. + const readyToShow = waitForWindowReadiness(window, window.webContents) + return { window, readyToShow } +} diff --git a/desktop/src/main/network.ts b/desktop/src/main/network.ts new file mode 100644 index 00000000..c440ae46 --- /dev/null +++ b/desktop/src/main/network.ts @@ -0,0 +1,19 @@ +import { createServer } from 'node:net' + +export function findAvailablePort(host = '127.0.0.1'): Promise { + return new Promise((resolve, reject) => { + const server = createServer() + server.unref() + server.once('error', reject) + server.listen(0, host, () => { + const address = server.address() + if (address === null || typeof address === 'string') { + server.close() + reject(new Error('Unable to allocate a localhost port')) + return + } + const { port } = address + server.close((error) => (error === undefined ? resolve(port) : reject(error))) + }) + }) +} diff --git a/desktop/src/main/optional-components.ts b/desktop/src/main/optional-components.ts new file mode 100644 index 00000000..4d2686af --- /dev/null +++ b/desktop/src/main/optional-components.ts @@ -0,0 +1,157 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' + +import { dialog, shell, type BrowserWindow } from 'electron' + +import type { + GvhmrOptionalComponentState, + GvhmrSetupResult, + OptionalComponentsState, +} from '../shared/desktop-api' + +const GVHMR_GUIDE_URL = 'https://github.com/zju3dv/GVHMR/blob/main/docs/INSTALL.md' +const GVHMR_ESTIMATED_ADDITIONAL_BYTES = 22 * 1024 * 1024 * 1024 + +interface OptionalComponentConfiguration { + schemaVersion: 1 + gvhmr?: { + requested?: boolean + root?: string + } +} + +function isGvhmrCheckout(path: string): boolean { + return existsSync(join(path, 'tools', 'demo', 'demo.py')) +} + +function readConfiguration(path: string): OptionalComponentConfiguration { + try { + const value = JSON.parse(readFileSync(path, 'utf8')) as OptionalComponentConfiguration + if (value.schemaVersion === 1) return value + } catch { + // Missing, truncated, or future config files fall back to a fresh v1 document. + } + return { schemaVersion: 1 } +} + +export class OptionalComponentStore { + private readonly path: string + private readonly installerMarker: string + private configuration: OptionalComponentConfiguration + + constructor(options: { + userData: string + localAppData?: string + env?: NodeJS.ProcessEnv + }) { + this.path = join(options.userData, 'optional-components.json') + const localAppData = options.localAppData ?? options.env?.LOCALAPPDATA + this.installerMarker = localAppData + ? join(localAppData, 'hhtools', 'installer', 'gvhmr.requested') + : join(options.userData, 'gvhmr.requested') + this.configuration = readConfiguration(this.path) + + if (existsSync(this.installerMarker)) { + this.configuration.gvhmr = { ...this.configuration.gvhmr, requested: true } + this.save() + rmSync(this.installerMarker, { force: true }) + } + } + + getState(env: NodeJS.ProcessEnv = process.env): OptionalComponentsState { + const configuredRoot = this.configuration.gvhmr?.root + const environmentRoot = env.HHTOOLS_GVHMR_ROOT + const conventionalRoot = process.platform === 'win32' ? 'C:\\GVHMR' : join(env.HOME ?? '', 'GVHMR') + const root = [environmentRoot, configuredRoot, conventionalRoot] + .filter((candidate): candidate is string => Boolean(candidate)) + .map((candidate) => resolve(candidate)) + .find(isGvhmrCheckout) + + return { + gvhmr: { + requested: this.configuration.gvhmr?.requested === true, + configured: root !== undefined, + root, + guideUrl: GVHMR_GUIDE_URL, + estimatedAdditionalBytes: GVHMR_ESTIMATED_ADDITIONAL_BYTES, + }, + } + } + + sidecarEnvironment(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const root = this.getState(env).gvhmr.root + return root && env.HHTOOLS_GVHMR_ROOT === undefined + ? { HHTOOLS_GVHMR_ROOT: root } + : {} + } + + configureGvhmr(root: string): GvhmrOptionalComponentState { + const resolved = resolve(root) + if (!isGvhmrCheckout(resolved)) { + throw new Error(`This folder is not an official GVHMR checkout: ${resolved}`) + } + this.configuration.gvhmr = { requested: false, root: resolved } + this.save() + return this.getState().gvhmr + } + + private save(): void { + const directory = dirname(this.path) + const temporaryPath = `${this.path}.tmp` + // mkdir is intentionally lazy so merely launching the app never writes a config. + mkdirSync(directory, { recursive: true }) + writeFileSync(temporaryPath, `${JSON.stringify(this.configuration, null, 2)}\n`, 'utf8') + renameSync(temporaryPath, this.path) + } +} + +export async function runGvhmrSetup(options: { + mainWindow: BrowserWindow + store: OptionalComponentStore + onConfigured: () => Promise +}): Promise { + const current = options.store.getState().gvhmr + const decision = await dialog.showMessageBox(options.mainWindow, { + type: 'info', + title: 'GVHMR video-to-motion', + message: 'Set up the optional GVHMR component', + detail: + 'GVHMR runs separately through Docker Desktop and requires official checkpoints plus ' + + 'licensed SMPL-X files. Choose an existing official checkout, or open the installation guide.', + buttons: ['Choose GVHMR folder', 'Open installation guide', 'Not now'], + defaultId: 0, + cancelId: 2, + }) + + if (decision.response === 1) { + await shell.openExternal(GVHMR_GUIDE_URL) + return { action: 'guide-opened', state: current } + } + if (decision.response !== 0) return { action: 'cancelled', state: current } + + const selection = await dialog.showOpenDialog(options.mainWindow, { + title: 'Choose the official GVHMR repository', + defaultPath: current.root, + properties: ['openDirectory'], + }) + const root = selection.filePaths[0] + if (selection.canceled || root === undefined) { + return { action: 'cancelled', state: options.store.getState().gvhmr } + } + + let state: GvhmrOptionalComponentState + try { + state = options.store.configureGvhmr(root) + } catch (reason) { + await dialog.showMessageBox(options.mainWindow, { + type: 'error', + title: 'GVHMR folder not recognized', + message: reason instanceof Error ? reason.message : String(reason), + detail: 'Choose the repository root containing tools/demo/demo.py.', + }) + return { action: 'cancelled', state: options.store.getState().gvhmr } + } + + await options.onConfigured() + return { action: 'configured', state } +} diff --git a/desktop/src/main/runtime-resolver.ts b/desktop/src/main/runtime-resolver.ts new file mode 100644 index 00000000..e9309c1d --- /dev/null +++ b/desktop/src/main/runtime-resolver.ts @@ -0,0 +1,187 @@ +/** Resolve either the bundled desktop runtime or a development checkout. */ +import { existsSync } from 'node:fs' +import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path' + +export interface RuntimeConfig { + repoRoot: string + pythonExecutable: string + sourceRoot: string + saveDirectory: string + cacheDirectory: string + logDirectory: string + bundled: boolean +} + +export interface ResolveRuntimeOptions { + appPath: string + cwd: string + userData: string + isPackaged?: boolean + resourcesPath?: string + env?: NodeJS.ProcessEnv + /** Override the host platform in deterministic resolver tests. */ + platform?: NodeJS.Platform +} + +function isRepositoryRoot(candidate: string): boolean { + return existsSync(join(candidate, 'pyproject.toml')) && existsSync(join(candidate, 'hhtools')) +} + +function walkForRepository(start: string): string | undefined { + let current = resolve(start) + while (true) { + if (isRepositoryRoot(current)) return current + const parent = dirname(current) + if (parent === current) return undefined + current = parent + } +} + +function bundledRuntime(options: ResolveRuntimeOptions): { + repoRoot: string + pythonExecutable: string +} | undefined { + if (!options.isPackaged || options.resourcesPath === undefined) return undefined + + const runtimeRoot = join(options.resourcesPath, 'runtime') + const repoRoot = join(runtimeRoot, 'app') + const pythonExecutable = + (options.platform ?? process.platform) === 'win32' + ? join(runtimeRoot, 'python', 'python.exe') + : join(runtimeRoot, 'python', 'bin', 'python3') + + if (!isRepositoryRoot(repoRoot)) { + throw new Error(`Bundled hhtools application files are missing: ${repoRoot}`) + } + if (!existsSync(pythonExecutable)) { + throw new Error(`Bundled Python runtime is missing: ${pythonExecutable}`) + } + return { repoRoot, pythonExecutable } +} + +function resolveRepositoryRoot(options: ResolveRuntimeOptions, env: NodeJS.ProcessEnv): string { + const configured = env.HHTOOLS_REPO_ROOT + if (configured !== undefined) { + // An explicit path is authoritative; fail early instead of silently using another checkout. + const resolved = resolve(configured) + if (!isRepositoryRoot(resolved)) { + throw new Error(`HHTOOLS_REPO_ROOT is not a hhtools checkout: ${resolved}`) + } + return resolved + } + + // Dev and unpacked builds normally live below the repository, so walking upward is enough. + for (const candidate of [options.cwd, options.appPath, dirname(options.appPath)]) { + const found = walkForRepository(candidate) + if (found !== undefined) return found + } + throw new Error('Unable to find the hhtools repository. Set HHTOOLS_REPO_ROOT.') +} + +function resolvePython( + repoRoot: string, + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform +): string { + if (env.HHTOOLS_PYTHON !== undefined) { + if (isAbsolute(env.HHTOOLS_PYTHON) && !existsSync(env.HHTOOLS_PYTHON)) { + throw new Error(`HHTOOLS_PYTHON does not exist: ${env.HHTOOLS_PYTHON}`) + } + return env.HHTOOLS_PYTHON + } + + const candidates = + platform === 'win32' + ? [join(repoRoot, '.venv', 'Scripts', 'python.exe')] + : [join(repoRoot, '.venv', 'bin', 'python')] + const localPython = candidates.find((candidate) => existsSync(candidate)) + return localPython ?? (platform === 'win32' ? 'python' : 'python3') +} + +export function resolveRuntime(options: ResolveRuntimeOptions): RuntimeConfig { + const env = options.env ?? process.env + const platform = options.platform ?? process.platform + const packaged = env.HHTOOLS_REPO_ROOT === undefined ? bundledRuntime(options) : undefined + const repoRoot = packaged?.repoRoot ?? resolveRepositoryRoot(options, env) + const pythonExecutable = + env.HHTOOLS_PYTHON ?? packaged?.pythonExecutable ?? resolvePython(repoRoot, env, platform) + + return { + repoRoot, + pythonExecutable, + sourceRoot: resolve(env.HHTOOLS_SOURCE_ROOT ?? join(repoRoot, 'assets', 'motions')), + saveDirectory: resolve(env.HHTOOLS_SAVE_DIR ?? join(options.userData, 'save_npz')), + // Keep Python's generated assets separate from Electron/Chromium's Cache directory. + cacheDirectory: resolve(env.HHTOOLS_CACHE_DIR ?? join(options.userData, 'hhtools-cache')), + logDirectory: resolve(env.HHTOOLS_LOG_DIR ?? join(options.userData, 'logs')), + bundled: packaged !== undefined + } +} + +const ENV_ALLOWLIST = new Set([ + 'APPDATA', + 'COMSPEC', + 'DBUS_SESSION_BUS_ADDRESS', + 'DISPLAY', + 'HOME', + 'HHTOOLS_MAX_QUEUED_JOBS', + 'HHTOOLS_MAX_RUNNING_JOBS', + 'HHTOOLS_MOTION_LIBRARY_ROOT', + 'HHTOOLS_MOTION_LIBRARY_SETTINGS_PATH', + 'HHTOOLS_GVHMR_BODY_MODELS', + 'HHTOOLS_GVHMR_IMAGE', + 'HHTOOLS_GVHMR_ROOT', + 'HHTOOLS_GVHMR_TIMEOUT_SECONDS', + 'HHTOOLS_ROBOT_DIR', + 'HHTOOLS_ROBOT_PATH', + 'HHTOOLS_WEB_SETTINGS_PATH', + 'LOCALAPPDATA', + 'LD_LIBRARY_PATH', + 'MUJOCO_GL', + 'NUMBER_OF_PROCESSORS', + 'PATH', + 'PATHEXT', + 'PROCESSOR_ARCHITECTURE', + 'PROGRAMDATA', + 'PYOPENGL_PLATFORM', + 'SYSTEMDRIVE', + 'SYSTEMROOT', + 'TEMP', + 'TMP', + 'USERPROFILE', + 'VIRTUAL_ENV', + 'WINDIR', + 'XDG_CONFIG_HOME', + 'XDG_DATA_HOME', + 'XDG_RUNTIME_DIR', + 'WAYLAND_DISPLAY', + 'XAUTHORITY' +]) + +export function buildSidecarEnvironment( + repoRoot: string, + source: NodeJS.ProcessEnv = process.env +): NodeJS.ProcessEnv { + const result: NodeJS.ProcessEnv = {} + + // Do not forward the entire Electron environment. Keep OS/runtime variables plus GPU toolchains. + for (const [key, value] of Object.entries(source)) { + if ( + value !== undefined && + (ENV_ALLOWLIST.has(key.toUpperCase()) || + key.toUpperCase().startsWith('CUDA_') || + key.toUpperCase().startsWith('NVIDIA_') || + key.toUpperCase().startsWith('CONDA_')) + ) { + result[key] = value + } + } + + // Import the working checkout and make Python logs deterministic and immediately visible. + result.PYTHONPATH = [repoRoot, source.PYTHONPATH].filter(Boolean).join(delimiter) + result.PYTHONDONTWRITEBYTECODE = '1' + result.PYTHONNOUSERSITE = '1' + result.PYTHONUTF8 = '1' + result.PYTHONUNBUFFERED = '1' + return result +} diff --git a/desktop/src/main/security/configure-session.ts b/desktop/src/main/security/configure-session.ts new file mode 100644 index 00000000..19d5e95a --- /dev/null +++ b/desktop/src/main/security/configure-session.ts @@ -0,0 +1,13 @@ +import type { Session } from 'electron' + +export function configureDesktopSession(session: Session, origin: string, secret: string): void { + // The current desktop feature set needs no camera, microphone, geolocation, or notifications. + session.setPermissionCheckHandler(() => false) + session.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)) + + // Inject authentication in Electron's network layer so renderer JavaScript cannot read the secret. + session.webRequest.onBeforeSendHeaders({ urls: [`${origin}/*`] }, (details, callback) => { + details.requestHeaders['X-HHTools-Session'] = secret + callback({ requestHeaders: details.requestHeaders }) + }) +} diff --git a/desktop/src/main/sidecar-supervisor.ts b/desktop/src/main/sidecar-supervisor.ts new file mode 100644 index 00000000..ba7b3e3b --- /dev/null +++ b/desktop/src/main/sidecar-supervisor.ts @@ -0,0 +1,283 @@ +import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process' +import { EventEmitter } from 'node:events' + +import type { SidecarState } from '../shared/runtime-state' +import type { LoggerLike } from './desktop-logger' + +export interface SidecarSnapshot { + state: SidecarState + origin: string + pid?: number + error?: string +} + +export interface SidecarConfig { + command: string + args: string[] + cwd: string + env: NodeJS.ProcessEnv + origin: string + sessionSecret: string + startTimeoutMs?: number + stopTimeoutMs?: number +} + +type SpawnProcess = (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess +type RequestProcessStop = (child: ChildProcess) => Promise +type KillProcessTree = (child: ChildProcess) => Promise + +export interface SidecarDependencies { + spawnProcess?: SpawnProcess + requestProcessStop?: RequestProcessStop + killProcessTree?: KillProcessTree + fetchHealth?: typeof fetch +} + +function messageFrom(reason: unknown): string { + return reason instanceof Error ? reason.message : String(reason) +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)) +} + +async function runTaskkill(child: ChildProcess, force: boolean): Promise { + if (child.pid === undefined) return + const args = ['/pid', String(child.pid), '/T'] + if (force) args.push('/F') + await new Promise((resolve) => { + const killer = spawn('taskkill', args, { windowsHide: true, stdio: 'ignore' }) + killer.once('error', () => resolve()) + killer.once('exit', () => resolve()) + }) +} + +async function defaultRequestProcessStop(child: ChildProcess): Promise { + if (process.platform === 'win32') { + // uv/venv launchers can create another Python process, so stop the whole Windows tree. + await runTaskkill(child, false) + } else { + child.kill('SIGTERM') + } +} + +async function defaultKillProcessTree(child: ChildProcess): Promise { + if (child.pid === undefined) return + if (process.platform !== 'win32') { + child.kill('SIGKILL') + return + } + await runTaskkill(child, true) +} + +export class SidecarSupervisor { + private readonly emitter = new EventEmitter() + private readonly spawnProcess: SpawnProcess + private readonly requestProcessStop: RequestProcessStop + private readonly killProcessTree: KillProcessTree + private readonly fetchHealth: typeof fetch + private child?: ChildProcess + + // A generation token prevents late events from an old process corrupting a restarted process. + private generation = 0 + private startPromise?: Promise + + // Expected exits are shutdowns; every other exit after spawn is reported as a crash. + private expectedExitGeneration?: number + private currentState: SidecarState = 'stopped' + private lastError?: string + + constructor( + private readonly config: SidecarConfig, + private readonly logger: LoggerLike, + dependencies: SidecarDependencies = {} + ) { + this.spawnProcess = dependencies.spawnProcess ?? spawn + this.requestProcessStop = dependencies.requestProcessStop ?? defaultRequestProcessStop + this.killProcessTree = dependencies.killProcessTree ?? defaultKillProcessTree + this.fetchHealth = dependencies.fetchHealth ?? fetch + } + + get snapshot(): SidecarSnapshot { + return { + state: this.currentState, + origin: this.config.origin, + pid: this.child?.pid, + error: this.lastError + } + } + + onStateChange(listener: (snapshot: SidecarSnapshot) => void): () => void { + this.emitter.on('state', listener) + return () => this.emitter.off('state', listener) + } + + updateEnvironment(environment: NodeJS.ProcessEnv): void { + if (this.currentState === 'starting' || this.currentState === 'stopping') { + throw new Error('Cannot replace the sidecar environment during a state transition') + } + // Optional components are configured after the window opens. Replacing the + // spawn environment lets restart() pick up those paths without relaunching Electron. + this.config.env = environment + } + + start(): Promise { + if (this.currentState === 'ready') return Promise.resolve(this.snapshot) + + // Coalesce concurrent startup requests into one child process and one readiness result. + if (this.startPromise !== undefined) return this.startPromise + + this.startPromise = this.startInternal().finally(() => { + this.startPromise = undefined + }) + return this.startPromise + } + + async restart(): Promise { + await this.stop() + return this.start() + } + + async stop(): Promise { + const child = this.child + if (child === undefined) { + this.setState('stopped') + return + } + + const generation = this.generation + this.expectedExitGeneration = generation + this.setState('stopping') + await this.requestProcessStop(child) + + const exited = await this.waitForExit(child, this.config.stopTimeoutMs ?? 5_000) + if (!exited) { + // Graceful termination gets a deadline; force-kill is only the final cleanup fallback. + this.logger.warn('Sidecar did not stop before timeout', { pid: child.pid }) + await this.killProcessTree(child) + await this.waitForExit(child, 1_000) + } + + if (generation === this.generation) { + this.child = undefined + this.setState('stopped') + } + } + + private async startInternal(): Promise { + const generation = ++this.generation + this.lastError = undefined + this.expectedExitGeneration = undefined + this.setState('starting') + this.logger.info('Starting Python sidecar', { origin: this.config.origin }) + + const child = this.spawnProcess(this.config.command, this.config.args, { + cwd: this.config.cwd, + env: this.config.env, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'] + }) + this.child = child + + child.stdout?.on('data', (chunk: Buffer | string) => + this.logger.processOutput('stdout', chunk.toString('utf8')) + ) + child.stderr?.on('data', (chunk: Buffer | string) => + this.logger.processOutput('stderr', chunk.toString('utf8')) + ) + child.on('error', (error) => this.logger.error('Sidecar process error', { error: error.message })) + child.once('exit', (code, signal) => this.handleExit(generation, code, signal)) + + const spawnFailure = new Promise((_resolve, reject) => { + child.once('error', reject) + }) + + try { + // A spawned PID is not sufficient: the WebUI is usable only after FastAPI is listening. + await Promise.race([this.waitForHealth(generation), spawnFailure]) + if (generation !== this.generation) throw new Error('Sidecar start was superseded') + this.setState('ready') + this.logger.info('Python sidecar is ready', { pid: child.pid, origin: this.config.origin }) + return this.snapshot + } catch (reason) { + this.lastError = messageFrom(reason) + this.logger.error('Python sidecar failed to start', { error: this.lastError }) + this.expectedExitGeneration = generation + if (child.exitCode === null) await this.requestProcessStop(child) + const exited = await this.waitForExit(child, 1_000) + if (!exited) await this.killProcessTree(child) + if (generation === this.generation) { + this.child = undefined + this.setState('crashed') + } + throw reason + } + } + + private async waitForHealth(generation: number): Promise { + const deadline = Date.now() + (this.config.startTimeoutMs ?? 60_000) + let attemptDelay = 100 + + while (Date.now() < deadline) { + if (generation !== this.generation) throw new Error('Sidecar health check was superseded') + if (this.child?.exitCode !== null) throw new Error('Sidecar exited before becoming ready') + + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 1_500) + try { + // Health checks pass through the same authentication guard as renderer requests. + const response = await this.fetchHealth(`${this.config.origin}/api/health`, { + headers: { 'X-HHTools-Session': this.config.sessionSecret }, + signal: controller.signal + }) + if (response.ok) return + } catch { + // Startup polling is expected to fail until uvicorn begins listening. + } finally { + clearTimeout(timer) + } + + await delay(attemptDelay) + + // Poll quickly at first, then cap retries to avoid needless CPU use during heavy imports. + attemptDelay = Math.min(1_000, Math.round(attemptDelay * 1.5)) + } + throw new Error('Timed out waiting for the Python sidecar health check') + } + + private handleExit(generation: number, code: number | null, signal: NodeJS.Signals | null): void { + // Ignore an exit event from a process that restart() has already superseded. + if (generation !== this.generation) return + this.child = undefined + + if (this.expectedExitGeneration === generation || this.currentState === 'stopping') { + this.setState('stopped') + return + } + + this.lastError = `Sidecar exited unexpectedly (code=${String(code)}, signal=${String(signal)})` + this.logger.error(this.lastError) + this.setState('crashed') + } + + private waitForExit(child: ChildProcess, timeoutMs: number): Promise { + if (child.exitCode !== null) return Promise.resolve(true) + return new Promise((resolve) => { + const timer = setTimeout(() => { + child.removeListener('exit', onExit) + resolve(false) + }, timeoutMs) + const onExit = (): void => { + clearTimeout(timer) + resolve(true) + } + child.once('exit', onExit) + }) + } + + private setState(next: SidecarState): void { + if (next === this.currentState) return + this.currentState = next + this.emitter.emit('state', this.snapshot) + } +} diff --git a/desktop/src/main/window-readiness.ts b/desktop/src/main/window-readiness.ts new file mode 100644 index 00000000..3a09d79b --- /dev/null +++ b/desktop/src/main/window-readiness.ts @@ -0,0 +1,26 @@ +import type { EventEmitter } from 'node:events' + +/** + * Resolve when Chromium says the window can paint or when the document has + * loaded. Some Wayland compositors do not emit Electron's `ready-to-show` for + * an initially hidden window, even though `did-finish-load` has fired. + */ +export function waitForWindowReadiness( + windowEvents: EventEmitter, + webContentsEvents: EventEmitter +): Promise { + return new Promise((resolve) => { + let settled = false + + const finish = (): void => { + if (settled) return + settled = true + windowEvents.removeListener('ready-to-show', finish) + webContentsEvents.removeListener('did-finish-load', finish) + resolve() + } + + windowEvents.once('ready-to-show', finish) + webContentsEvents.once('did-finish-load', finish) + }) +} diff --git a/desktop/src/main/window-state-store.ts b/desktop/src/main/window-state-store.ts new file mode 100644 index 00000000..8192ad48 --- /dev/null +++ b/desktop/src/main/window-state-store.ts @@ -0,0 +1,90 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' + +export interface RectangleLike { + x: number + y: number + width: number + height: number +} + +export interface DisplayLike { + workArea: RectangleLike +} + +export interface PersistedWindowState extends RectangleLike { + maximized: boolean +} + +const DEFAULT_WIDTH = 1440 +const DEFAULT_HEIGHT = 900 +const MIN_WIDTH = 1024 +const MIN_HEIGHT = 700 + +function isFiniteRectangle(value: Partial): value is RectangleLike { + return [value.x, value.y, value.width, value.height].every( + (item) => typeof item === 'number' && Number.isFinite(item) + ) +} + +function intersects(a: RectangleLike, b: RectangleLike): boolean { + const width = Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) + const height = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) + return width >= 80 && height >= 80 +} + +function centered(primary: DisplayLike): PersistedWindowState { + const width = Math.min(DEFAULT_WIDTH, primary.workArea.width) + const height = Math.min(DEFAULT_HEIGHT, primary.workArea.height) + return { + x: Math.round(primary.workArea.x + (primary.workArea.width - width) / 2), + y: Math.round(primary.workArea.y + (primary.workArea.height - height) / 2), + width, + height, + maximized: false + } +} + +export function normalizeWindowState( + candidate: Partial | undefined, + displays: DisplayLike[], + primary: DisplayLike +): PersistedWindowState { + if ( + candidate === undefined || + !isFiniteRectangle(candidate) || + candidate.width < MIN_WIDTH || + candidate.height < MIN_HEIGHT + ) { + return centered(primary) + } + + const state: PersistedWindowState = { + ...candidate, + maximized: (candidate as Partial).maximized === true + } + + // Reset windows saved on a disconnected monitor instead of reopening them off-screen. + return displays.some((display) => intersects(state, display.workArea)) ? state : centered(primary) +} + +export class WindowStateStore { + constructor(private readonly filePath: string) {} + + load(displays: DisplayLike[], primary: DisplayLike): PersistedWindowState { + let candidate: Partial | undefined + if (existsSync(this.filePath)) { + try { + candidate = JSON.parse(readFileSync(this.filePath, 'utf8')) as Partial + } catch { + candidate = undefined + } + } + return normalizeWindowState(candidate, displays, primary) + } + + save(state: PersistedWindowState): void { + mkdirSync(dirname(this.filePath), { recursive: true }) + writeFileSync(this.filePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8') + } +} diff --git a/desktop/src/preload/index.ts b/desktop/src/preload/index.ts new file mode 100644 index 00000000..10f15648 --- /dev/null +++ b/desktop/src/preload/index.ts @@ -0,0 +1,21 @@ +import { contextBridge, ipcRenderer } from 'electron' + +import { DESKTOP_CHANNELS, type HHToolsDesktopApi } from '../shared/desktop-api' +import type { RuntimeState } from '../shared/runtime-state' + +// Expose named operations only. The renderer never receives raw ipcRenderer or Node primitives. +const desktopApi: HHToolsDesktopApi = { + getRuntimeState: () => ipcRenderer.invoke(DESKTOP_CHANNELS.getRuntimeState), + getOptionalComponents: () => ipcRenderer.invoke(DESKTOP_CHANNELS.getOptionalComponents), + restartBackend: () => ipcRenderer.invoke(DESKTOP_CHANNELS.restartBackend), + setupGvhmr: () => ipcRenderer.invoke(DESKTOP_CHANNELS.setupGvhmr), + selectDirectory: () => ipcRenderer.invoke(DESKTOP_CHANNELS.selectDirectory), + openExternal: (url: string) => ipcRenderer.invoke(DESKTOP_CHANNELS.openExternal, url), + onRuntimeStateChanged: (listener: (state: RuntimeState) => void) => { + const wrapped = (_event: Electron.IpcRendererEvent, state: RuntimeState): void => listener(state) + ipcRenderer.on(DESKTOP_CHANNELS.runtimeStateChanged, wrapped) + return () => ipcRenderer.removeListener(DESKTOP_CHANNELS.runtimeStateChanged, wrapped) + } +} + +contextBridge.exposeInMainWorld('hhtoolsDesktop', desktopApi) diff --git a/desktop/src/shared/desktop-api.ts b/desktop/src/shared/desktop-api.ts new file mode 100644 index 00000000..2f73e865 --- /dev/null +++ b/desktop/src/shared/desktop-api.ts @@ -0,0 +1,44 @@ +import type { RuntimeState } from './runtime-state' + +export const DESKTOP_CHANNELS = { + getRuntimeState: 'hhtools:get-runtime-state', + getOptionalComponents: 'hhtools:get-optional-components', + restartBackend: 'hhtools:restart-backend', + setupGvhmr: 'hhtools:setup-gvhmr', + selectDirectory: 'hhtools:select-directory', + openExternal: 'hhtools:open-external', + runtimeStateChanged: 'hhtools:runtime-state-changed' +} as const + +export interface GvhmrOptionalComponentState { + requested: boolean + configured: boolean + root?: string + guideUrl: string + estimatedAdditionalBytes: number +} + +export interface OptionalComponentsState { + gvhmr: GvhmrOptionalComponentState +} + +export interface GvhmrSetupResult { + action: 'cancelled' | 'configured' | 'guide-opened' + state: GvhmrOptionalComponentState +} + +export interface HHToolsDesktopApi { + getRuntimeState(): Promise + getOptionalComponents(): Promise + restartBackend(): Promise + setupGvhmr(): Promise + selectDirectory(): Promise + openExternal(url: string): Promise + onRuntimeStateChanged(listener: (state: RuntimeState) => void): () => void +} + +declare global { + interface Window { + hhtoolsDesktop: HHToolsDesktopApi + } +} diff --git a/desktop/src/shared/runtime-state.ts b/desktop/src/shared/runtime-state.ts new file mode 100644 index 00000000..fba4ab46 --- /dev/null +++ b/desktop/src/shared/runtime-state.ts @@ -0,0 +1,16 @@ +export type AppPhase = + | 'starting' + | 'backend-starting' + | 'ready' + | 'after-window-open' + | 'shutting-down' + +export type SidecarState = 'stopped' | 'starting' | 'ready' | 'stopping' | 'crashed' + +export interface RuntimeState { + appPhase: AppPhase + backendState: SidecarState + backendOrigin?: string + backendPid?: number + error?: string +} diff --git a/desktop/tests/app-lifecycle.test.ts b/desktop/tests/app-lifecycle.test.ts new file mode 100644 index 00000000..52fff2ae --- /dev/null +++ b/desktop/tests/app-lifecycle.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest' + +import { AppLifecycle } from '../src/main/app-lifecycle' + +describe('AppLifecycle', () => { + it('allows forward-only phase transitions', () => { + const lifecycle = new AppLifecycle() + lifecycle.transition('backend-starting') + lifecycle.transition('ready') + + expect(lifecycle.phase).toBe('ready') + expect(() => lifecycle.transition('starting')).toThrow(/Invalid lifecycle transition/) + }) + + it('runs named shutdown joiners', async () => { + const lifecycle = new AppLifecycle() + const first = vi.fn() + const second = vi.fn(async () => undefined) + lifecycle.registerShutdownJoiner('first', first) + lifecycle.registerShutdownJoiner('second', second) + + const result = await lifecycle.runShutdownJoiners() + + expect(first).toHaveBeenCalledOnce() + expect(second).toHaveBeenCalledOnce() + expect(result).toEqual({ timedOut: false, failures: [] }) + expect(lifecycle.phase).toBe('shutting-down') + }) + + it('records a failed joiner without blocking the others', async () => { + const lifecycle = new AppLifecycle() + const completed = vi.fn() + lifecycle.registerShutdownJoiner('broken', () => { + throw new Error('failed') + }) + lifecycle.registerShutdownJoiner('completed', completed) + + const result = await lifecycle.runShutdownJoiners() + + expect(completed).toHaveBeenCalledOnce() + expect(result.failures).toHaveLength(1) + expect(result.failures[0]?.name).toBe('broken') + }) +}) diff --git a/desktop/tests/graphics-mode.test.ts b/desktop/tests/graphics-mode.test.ts new file mode 100644 index 00000000..d10288cb --- /dev/null +++ b/desktop/tests/graphics-mode.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + configureGraphicsCommandLine, + decideGraphicsStartup, + SOFTWARE_RENDERING_ARGUMENT, + softwareRenderingRelaunchArgs, + softwareRenderingRequested +} from '../src/main/graphics-mode' + +describe('Linux graphics mode', () => { + it('keeps the normal hardware path free of forced GL switches', () => { + const appendSwitch = vi.fn() + + expect(configureGraphicsCommandLine({ appendSwitch }, ['hhtools'], 'linux')).toBe(false) + expect(appendSwitch).not.toHaveBeenCalled() + }) + + it('selects SwANGLE only for the private Linux software-rendering launch', () => { + const appendSwitch = vi.fn() + const argv = ['hhtools', SOFTWARE_RENDERING_ARGUMENT] + + expect(softwareRenderingRequested(argv, 'linux')).toBe(true) + expect(configureGraphicsCommandLine({ appendSwitch }, argv, 'linux')).toBe(true) + expect(appendSwitch.mock.calls).toEqual([ + ['use-gl', 'angle'], + ['use-angle', 'swiftshader'], + ['enable-unsafe-swiftshader'] + ]) + }) + + it('does not inject Linux graphics switches on other platforms', () => { + const appendSwitch = vi.fn() + + expect( + configureGraphicsCommandLine( + { appendSwitch }, + ['hhtools.exe', SOFTWARE_RENDERING_ARGUMENT], + 'win32' + ) + ).toBe(false) + expect(appendSwitch).not.toHaveBeenCalled() + }) + + it('relaunches once after hardware WebGL2 fails and never loops in software mode', () => { + expect(decideGraphicsStartup(false, false, 'linux')).toBe('relaunch') + expect(decideGraphicsStartup(false, true, 'linux')).toBe('fail') + expect(decideGraphicsStartup(true, false, 'linux')).toBe('start') + expect(decideGraphicsStartup(true, true, 'linux')).toBe('start') + }) + + it('builds deduplicated Electron relaunch arguments', () => { + expect( + softwareRenderingRelaunchArgs([ + '/opt/Human-Humanoid Tools/hhtools', + '--example', + SOFTWARE_RENDERING_ARGUMENT + ]) + ).toEqual(['--example', SOFTWARE_RENDERING_ARGUMENT]) + }) +}) diff --git a/desktop/tests/linux-package-entrypoints.test.ts b/desktop/tests/linux-package-entrypoints.test.ts new file mode 100644 index 00000000..42b1581a --- /dev/null +++ b/desktop/tests/linux-package-entrypoints.test.ts @@ -0,0 +1,65 @@ +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' + +import { describe, expect, it } from 'vitest' + +interface DesktopPackage { + desktopName: string + build: { + productName: string + linux: { executableName: string } + deb: { fpm: string[]; afterInstall?: string; afterRemove?: string } + } +} + +const desktopRoot = resolve(import.meta.dirname, '..') +const packageMetadata = JSON.parse( + readFileSync(join(desktopRoot, 'package.json'), 'utf8') +) as DesktopPackage + +describe('Linux package entry points', () => { + it('keeps the desktop identity while separating GUI and CLI commands', () => { + expect(packageMetadata.desktopName).toBe('hhtools') + expect(packageMetadata.build.productName).toBe('Human-Humanoid Tools') + expect(packageMetadata.build.linux.executableName).toBe('hhtools-desktop') + + // The CLI is a real dpkg-owned file. The GUI keeps electron-builder's + // default post-install/remove hooks, including sandbox and AppArmor setup. + expect(packageMetadata.build.deb.fpm).toContain( + '.runtime/cli/hhtools=/usr/bin/hhtools' + ) + expect(packageMetadata.build.deb.afterInstall).toBeUndefined() + expect(packageMetadata.build.deb.afterRemove).toBeUndefined() + }) + + it('launches the bundled Python CLI without changing the caller environment', () => { + const launcher = readFileSync( + join(desktopRoot, 'scripts', 'hhtools-cli-launcher.sh'), + 'utf8' + ) + + expect(launcher).toContain( + `runtime_root='/opt/${packageMetadata.build.productName}/resources/runtime'` + ) + expect(launcher).toContain('export PYTHONPATH="$application_root"') + expect(launcher).toContain('unset PYTHONHOME VIRTUAL_ENV') + expect(launcher).toContain( + `'from hhtools.cli.main import app; app(prog_name="hhtools")' "$@"` + ) + expect(launcher).not.toMatch(/\n\s*cd\s/) + }) + + it('migrates only the exact legacy GUI alternative and explains dpkg recovery', () => { + const beforeInstall = readFileSync( + join(desktopRoot, 'scripts', 'linux-before-install.sh'), + 'utf8' + ) + + expect(packageMetadata.build.deb.fpm).toContain( + '--before-install=scripts/linux-before-install.sh' + ) + expect(beforeInstall).toContain("legacy_gui='/opt/Human-Humanoid Tools/hhtools'") + expect(beforeInstall).toContain('update-alternatives --remove hhtools "$legacy_gui"') + expect(beforeInstall).toContain('sudo apt-get -f install') + }) +}) diff --git a/desktop/tests/optional-components.test.ts b/desktop/tests/optional-components.test.ts new file mode 100644 index 00000000..a7f33c4a --- /dev/null +++ b/desktop/tests/optional-components.test.ts @@ -0,0 +1,62 @@ +import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + dialog: {}, + shell: {}, +})) + +import { OptionalComponentStore } from '../src/main/optional-components' + +function createGvhmrCheckout(root: string): string { + const checkout = join(root, 'GVHMR') + mkdirSync(join(checkout, 'tools', 'demo'), { recursive: true }) + writeFileSync(join(checkout, 'tools', 'demo', 'demo.py'), '', 'utf8') + return checkout +} + +describe('OptionalComponentStore', () => { + it('consumes the NSIS selection marker exactly once', () => { + const root = mkdtempSync(join(tmpdir(), 'hhtools-components-marker-')) + const userData = join(root, 'user-data') + const localAppData = join(root, 'local-app-data') + const marker = join(localAppData, 'hhtools', 'installer', 'gvhmr.requested') + mkdirSync(join(localAppData, 'hhtools', 'installer'), { recursive: true }) + writeFileSync(marker, '1\n', 'utf8') + + const store = new OptionalComponentStore({ userData, localAppData, env: {} }) + + expect(store.getState({}).gvhmr.requested).toBe(true) + expect(existsSync(marker)).toBe(false) + + const restored = new OptionalComponentStore({ userData, localAppData, env: {} }) + expect(restored.getState({}).gvhmr.requested).toBe(true) + }) + + it('persists a validated checkout and exposes it to the sidecar', () => { + const root = mkdtempSync(join(tmpdir(), 'hhtools-components-config-')) + const userData = join(root, 'user-data') + const checkout = createGvhmrCheckout(root) + const store = new OptionalComponentStore({ userData, env: {} }) + + expect(store.configureGvhmr(checkout)).toMatchObject({ + configured: true, + requested: false, + root: checkout, + }) + expect(store.sidecarEnvironment({})).toEqual({ HHTOOLS_GVHMR_ROOT: checkout }) + + const restored = new OptionalComponentStore({ userData, env: {} }) + expect(restored.getState({}).gvhmr.root).toBe(checkout) + }) + + it('rejects a folder that is not an official GVHMR checkout', () => { + const root = mkdtempSync(join(tmpdir(), 'hhtools-components-invalid-')) + const store = new OptionalComponentStore({ userData: join(root, 'user-data'), env: {} }) + + expect(() => store.configureGvhmr(root)).toThrow('not an official GVHMR checkout') + }) +}) diff --git a/desktop/tests/register-desktop-handlers.test.ts b/desktop/tests/register-desktop-handlers.test.ts new file mode 100644 index 00000000..439098dd --- /dev/null +++ b/desktop/tests/register-desktop-handlers.test.ts @@ -0,0 +1,132 @@ +import type { BrowserWindow, IpcMainInvokeEvent } from 'electron' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const electronMocks = vi.hoisted(() => ({ + handlers: new Map unknown>(), + handle: vi.fn(), + removeHandler: vi.fn(), + showOpenDialog: vi.fn(), + openExternal: vi.fn() +})) + +const securityMocks = vi.hoisted(() => ({ + assertTrustedIpcSender: vi.fn() +})) + +vi.mock('electron', () => ({ + dialog: { showOpenDialog: electronMocks.showOpenDialog }, + ipcMain: { + handle: electronMocks.handle, + removeHandler: electronMocks.removeHandler + }, + shell: { openExternal: electronMocks.openExternal } +})) + +vi.mock('../src/main/ipc/validate-ipc-sender', () => ({ + assertTrustedIpcSender: securityMocks.assertTrustedIpcSender +})) + +import { registerDesktopHandlers } from '../src/main/ipc/register-desktop-handlers' +import { DESKTOP_CHANNELS } from '../src/shared/desktop-api' + +describe('registerDesktopHandlers', () => { + beforeEach(() => { + electronMocks.handlers.clear() + vi.clearAllMocks() + electronMocks.handle.mockImplementation((channel, handler) => { + electronMocks.handlers.set(channel, handler) + }) + }) + + function register(): { event: IpcMainInvokeEvent; mainWindow: BrowserWindow } { + const event = {} as IpcMainInvokeEvent + const mainWindow = {} as BrowserWindow + registerDesktopHandlers({ + mainWindow, + trustedOrigin: 'http://127.0.0.1:43100', + getRuntimeState: () => ({ appPhase: 'ready', backendState: 'ready' }), + getOptionalComponents: () => ({ + gvhmr: { + requested: false, + configured: false, + guideUrl: 'https://example.com/gvhmr', + estimatedAdditionalBytes: 22, + }, + }), + setupGvhmr: async () => ({ + action: 'cancelled', + state: { + requested: false, + configured: false, + guideUrl: 'https://example.com/gvhmr', + estimatedAdditionalBytes: 22, + }, + }), + restartBackend: async () => ({ appPhase: 'ready', backendState: 'ready' }) + }) + return { event, mainWindow } + } + + it('opens a trusted native directory picker and returns the selected path', async () => { + const { event, mainWindow } = register() + electronMocks.showOpenDialog.mockResolvedValue({ + canceled: false, + filePaths: ['C:\\motions'] + }) + + const handler = electronMocks.handlers.get(DESKTOP_CHANNELS.selectDirectory) + await expect(handler?.(event)).resolves.toBe('C:\\motions') + + expect(securityMocks.assertTrustedIpcSender).toHaveBeenCalledWith( + event, + mainWindow, + 'http://127.0.0.1:43100' + ) + expect(electronMocks.showOpenDialog).toHaveBeenCalledWith(mainWindow, { + properties: ['openDirectory', 'createDirectory'] + }) + expect(securityMocks.assertTrustedIpcSender.mock.invocationCallOrder[0]).toBeLessThan( + electronMocks.showOpenDialog.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY + ) + }) + + it('returns null when the native directory picker is cancelled', async () => { + const { event } = register() + electronMocks.showOpenDialog.mockResolvedValue({ canceled: true, filePaths: [] }) + + const handler = electronMocks.handlers.get(DESKTOP_CHANNELS.selectDirectory) + + await expect(handler?.(event)).resolves.toBeNull() + }) + + it('removes the directory picker handler during cleanup', () => { + const mainWindow = {} as BrowserWindow + const unregister = registerDesktopHandlers({ + mainWindow, + trustedOrigin: 'http://127.0.0.1:43100', + getRuntimeState: () => ({ appPhase: 'ready', backendState: 'ready' }), + getOptionalComponents: () => ({ + gvhmr: { + requested: false, + configured: false, + guideUrl: 'https://example.com/gvhmr', + estimatedAdditionalBytes: 22, + }, + }), + setupGvhmr: async () => ({ + action: 'cancelled', + state: { + requested: false, + configured: false, + guideUrl: 'https://example.com/gvhmr', + estimatedAdditionalBytes: 22, + }, + }), + restartBackend: async () => ({ appPhase: 'ready', backendState: 'ready' }) + }) + + unregister() + + expect(electronMocks.removeHandler).toHaveBeenCalledWith(DESKTOP_CHANNELS.selectDirectory) + }) +}) diff --git a/desktop/tests/runtime-resolver.test.ts b/desktop/tests/runtime-resolver.test.ts new file mode 100644 index 00000000..68ea179a --- /dev/null +++ b/desktop/tests/runtime-resolver.test.ts @@ -0,0 +1,134 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { describe, expect, it } from 'vitest' + +import { buildSidecarEnvironment, resolveRuntime } from '../src/main/runtime-resolver' + +describe('resolveRuntime', () => { + it('finds a repository above the desktop working directory', () => { + const root = fileURLToPath(new URL('../..', import.meta.url)) + const runtime = resolveRuntime({ + appPath: join(root, 'desktop'), + cwd: join(root, 'desktop'), + userData: join(root, '.test-user-data') + }) + + expect(runtime.repoRoot).toBe(resolve(root)) + expect(runtime.sourceRoot).toBe(join(root, 'assets', 'motions')) + expect(runtime.cacheDirectory).toBe(join(root, '.test-user-data', 'hhtools-cache')) + expect(runtime.bundled).toBe(false) + }) + + it.each([ + ['win32', ['python', 'python.exe']], + ['linux', ['python', 'bin', 'python3']] + ] as const)('uses the bundled application and Python runtime on %s', (platform, pythonParts) => { + const resourcesPath = mkdtempSync(join(tmpdir(), 'hhtools-packaged-runtime-test-')) + const repoRoot = join(resourcesPath, 'runtime', 'app') + const pythonExecutable = join(resourcesPath, 'runtime', ...pythonParts) + mkdirSync(join(repoRoot, 'hhtools'), { recursive: true }) + mkdirSync(dirname(pythonExecutable), { recursive: true }) + writeFileSync(join(repoRoot, 'pyproject.toml'), '', 'utf8') + writeFileSync(pythonExecutable, '', 'utf8') + + const runtime = resolveRuntime({ + appPath: 'C:\\Program Files\\hhtools', + cwd: 'C:\\Program Files\\hhtools', + userData: join(resourcesPath, 'user-data'), + isPackaged: true, + resourcesPath, + env: {}, + platform + }) + + expect(runtime.repoRoot).toBe(repoRoot) + expect(runtime.pythonExecutable).toBe(pythonExecutable) + expect(runtime.bundled).toBe(true) + }) + + it('finds a checkout-local Linux virtual environment', () => { + const root = mkdtempSync(join(tmpdir(), 'hhtools-linux-runtime-test-')) + const pythonExecutable = join(root, '.venv', 'bin', 'python') + mkdirSync(join(root, 'hhtools'), { recursive: true }) + mkdirSync(dirname(pythonExecutable), { recursive: true }) + writeFileSync(join(root, 'pyproject.toml'), '', 'utf8') + writeFileSync(pythonExecutable, '', 'utf8') + + const runtime = resolveRuntime({ + appPath: join(root, 'desktop'), + cwd: root, + userData: join(root, 'data'), + env: {}, + platform: 'linux' + }) + + expect(runtime.pythonExecutable).toBe(pythonExecutable) + expect(runtime.bundled).toBe(false) + }) + + it('honors an explicit checkout and Python runtime', () => { + const root = mkdtempSync(join(tmpdir(), 'hhtools-runtime-test-')) + mkdirSync(join(root, 'hhtools'), { recursive: true }) + writeFileSync(join(root, 'pyproject.toml'), '', 'utf8') + const runtime = resolveRuntime({ + appPath: root, + cwd: root, + userData: join(root, 'data'), + env: { HHTOOLS_REPO_ROOT: root, HHTOOLS_PYTHON: 'python-test' } + }) + + expect(runtime.pythonExecutable).toBe('python-test') + }) + + it('does not copy unrelated parent secrets into the sidecar environment', () => { + const environment = buildSidecarEnvironment('C:\\repo', { + PATH: 'C:\\bin', + AWS_SECRET_ACCESS_KEY: 'do-not-copy', + CUDA_PATH: 'C:\\cuda', + HHTOOLS_MAX_RUNNING_JOBS: '2', + HHTOOLS_MAX_QUEUED_JOBS: '32', + HHTOOLS_WEB_SETTINGS_PATH: 'C:\\config\\web-settings.json', + HHTOOLS_MOTION_LIBRARY_SETTINGS_PATH: 'C:\\config\\motion-library-settings.json', + HHTOOLS_ROBOT_DIR: 'C:\\config\\robots', + LD_LIBRARY_PATH: '/opt/cuda/lib64', + XDG_RUNTIME_DIR: '/run/user/1000', + DISPLAY: ':1', + WAYLAND_DISPLAY: 'wayland-0', + XAUTHORITY: '/home/nora/.Xauthority', + DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus', + MUJOCO_GL: 'egl', + PYOPENGL_PLATFORM: 'egl', + XDG_CONFIG_HOME: 'C:\\config', + XDG_DATA_HOME: 'C:\\data', + HHTOOLS_ARBITRARY_SECRET: 'do-not-copy-either' + }) + + expect(environment.PATH).toBe('C:\\bin') + expect(environment.CUDA_PATH).toBe('C:\\cuda') + expect(environment.HHTOOLS_MAX_RUNNING_JOBS).toBe('2') + expect(environment.HHTOOLS_MAX_QUEUED_JOBS).toBe('32') + expect(environment.HHTOOLS_WEB_SETTINGS_PATH).toBe('C:\\config\\web-settings.json') + expect(environment.HHTOOLS_MOTION_LIBRARY_SETTINGS_PATH).toBe( + 'C:\\config\\motion-library-settings.json' + ) + expect(environment.HHTOOLS_ROBOT_DIR).toBe('C:\\config\\robots') + expect(environment.LD_LIBRARY_PATH).toBe('/opt/cuda/lib64') + expect(environment.XDG_RUNTIME_DIR).toBe('/run/user/1000') + expect(environment.DISPLAY).toBe(':1') + expect(environment.WAYLAND_DISPLAY).toBe('wayland-0') + expect(environment.XAUTHORITY).toBe('/home/nora/.Xauthority') + expect(environment.DBUS_SESSION_BUS_ADDRESS).toBe('unix:path=/run/user/1000/bus') + expect(environment.MUJOCO_GL).toBe('egl') + expect(environment.PYOPENGL_PLATFORM).toBe('egl') + expect(environment.XDG_CONFIG_HOME).toBe('C:\\config') + expect(environment.XDG_DATA_HOME).toBe('C:\\data') + expect(environment.HHTOOLS_ARBITRARY_SECRET).toBeUndefined() + expect(environment.AWS_SECRET_ACCESS_KEY).toBeUndefined() + expect(environment.PYTHONDONTWRITEBYTECODE).toBe('1') + expect(environment.PYTHONNOUSERSITE).toBe('1') + expect(environment.PYTHONUTF8).toBe('1') + }) +}) diff --git a/desktop/tests/runtime-staging-policy.test.ts b/desktop/tests/runtime-staging-policy.test.ts new file mode 100644 index 00000000..0a374e0e --- /dev/null +++ b/desktop/tests/runtime-staging-policy.test.ts @@ -0,0 +1,91 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { + assertPathInside, + assertRobotDestinationAvailable, + listApplicationSourceFiles, + resolveBundledRobotDirectory +} from '../scripts/runtime-staging-policy.mjs' + +describe('runtime staging policy', () => { + it('selects tracked files without copying untracked or ignored content', () => { + const root = mkdtempSync(join(tmpdir(), 'hhtools-staging-git-')) + mkdirSync(join(root, 'hhtools'), { recursive: true }) + writeFileSync(join(root, 'hhtools', 'tracked.py'), 'tracked\n', 'utf8') + writeFileSync(join(root, 'hhtools', 'untracked.secret'), 'private\n', 'utf8') + writeFileSync(join(root, '.gitignore'), '*.secret\n', 'utf8') + execFileSync('git', ['init', '--quiet'], { cwd: root }) + execFileSync( + 'git', + ['-c', 'core.autocrlf=false', 'add', '.gitignore', 'hhtools/tracked.py'], + { cwd: root } + ) + + const selected = listApplicationSourceFiles(root, ['hhtools'], {}) + + expect(selected.provenance).toBe('git-tracked-worktree') + expect(selected.files).toEqual(['hhtools/tracked.py']) + }) + + it('requires explicit trust for an extracted source archive', () => { + const root = mkdtempSync(join(tmpdir(), 'hhtools-staging-archive-')) + mkdirSync(join(root, 'hhtools'), { recursive: true }) + writeFileSync(join(root, 'hhtools', 'app.py'), '', 'utf8') + + expect(() => listApplicationSourceFiles(root, ['hhtools'], {})).toThrow( + 'HHTOOLS_TRUST_SOURCE_ARCHIVE=1' + ) + expect( + listApplicationSourceFiles(root, ['hhtools'], { HHTOOLS_TRUST_SOURCE_ARCHIVE: '1' }) + ).toEqual({ provenance: 'trusted-archive', files: ['hhtools/app.py'] }) + }) + + it('does not infer bundled robots from HOME or XDG paths', () => { + const root = mkdtempSync(join(tmpdir(), 'hhtools-staging-robots-')) + + expect( + resolveBundledRobotDirectory( + { HOME: '/home/nora', XDG_CONFIG_HOME: '/tmp/config' }, + root + ) + ).toBeNull() + expect( + resolveBundledRobotDirectory({ HHTOOLS_BUNDLED_ROBOT_DIR: 'robots' }, root) + ).toBe(join(root, 'robots')) + }) + + it('rejects a robot name that would merge into an existing destination', () => { + const root = mkdtempSync(join(tmpdir(), 'hhtools-staging-robot-name-')) + const destination = join(root, 'g1') + mkdirSync(destination) + + expect(() => assertRobotDestinationAvailable(destination, 'g1')).toThrow( + 'refusing to merge duplicate bundled robot: g1' + ) + }) + + it('rejects staged paths outside the runtime root', () => { + const root = mkdtempSync(join(tmpdir(), 'hhtools-staging-root-')) + + expect(() => + assertPathInside(root, join(root, 'python', 'lib'), 'outside runtime', { + allowRoot: false + }) + ).not.toThrow() + expect(() => + assertPathInside(root, join(root, '..', 'system-python'), 'outside runtime', { + allowRoot: false + }) + ).toThrow('outside runtime') + expect(() => + assertPathInside(root, root, 'site-packages cannot equal Python root', { + allowRoot: false + }) + ).toThrow('site-packages cannot equal Python root') + }) +}) diff --git a/desktop/tests/sidecar-supervisor.test.ts b/desktop/tests/sidecar-supervisor.test.ts new file mode 100644 index 00000000..e7e79ee7 --- /dev/null +++ b/desktop/tests/sidecar-supervisor.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it } from 'vitest' + +import type { LoggerLike } from '../src/main/desktop-logger' +import { findAvailablePort } from '../src/main/network' +import { SidecarSupervisor } from '../src/main/sidecar-supervisor' + +const silentLogger: LoggerLike = { + info: () => undefined, + warn: () => undefined, + error: () => undefined, + processOutput: () => undefined +} + +const supervisors: SidecarSupervisor[] = [] + +afterEach(async () => { + await Promise.all(supervisors.splice(0).map((supervisor) => supervisor.stop())) +}) + +describe('SidecarSupervisor', () => { + it('waits for HTTP readiness and stops the child process', async () => { + const port = await findAvailablePort() + const script = ` + const http = require('node:http'); + const server = http.createServer((request, response) => { + if (request.url === '/api/health' && request.headers['x-hhtools-session'] === 'test') { + response.writeHead(200, {'content-type': 'application/json'}); response.end('{"ok":true}'); + } else { response.writeHead(401); response.end(); } + }); + setTimeout(() => server.listen(${port}, '127.0.0.1'), 150); + process.on('SIGTERM', () => server.close(() => process.exit(0))); + ` + const supervisor = new SidecarSupervisor( + { + command: process.execPath, + args: ['-e', script], + cwd: process.cwd(), + env: process.env, + origin: `http://127.0.0.1:${port}`, + sessionSecret: 'test', + startTimeoutMs: 5_000, + stopTimeoutMs: 2_000 + }, + silentLogger + ) + supervisors.push(supervisor) + + const ready = await supervisor.start() + expect(ready.state).toBe('ready') + expect(ready.pid).toBeTypeOf('number') + + await supervisor.stop() + expect(supervisor.snapshot.state).toBe('stopped') + }) + + it('classifies a startup timeout as crashed', async () => { + const port = await findAvailablePort() + const supervisor = new SidecarSupervisor( + { + command: process.execPath, + args: ['-e', 'setInterval(() => {}, 1000)'], + cwd: process.cwd(), + env: process.env, + origin: `http://127.0.0.1:${port}`, + sessionSecret: 'test', + startTimeoutMs: 250, + stopTimeoutMs: 500 + }, + silentLogger + ) + supervisors.push(supervisor) + + await expect(supervisor.start()).rejects.toThrow(/Timed out/) + expect(supervisor.snapshot.state).toBe('crashed') + }) +}) diff --git a/desktop/tests/window-readiness.test.ts b/desktop/tests/window-readiness.test.ts new file mode 100644 index 00000000..175f1ba1 --- /dev/null +++ b/desktop/tests/window-readiness.test.ts @@ -0,0 +1,33 @@ +import { EventEmitter } from 'node:events' + +import { describe, expect, it, vi } from 'vitest' + +import { waitForWindowReadiness } from '../src/main/window-readiness' + +describe('waitForWindowReadiness', () => { + it('resolves on Electron ready-to-show and removes the load fallback', async () => { + const windowEvents = new EventEmitter() + const webContentsEvents = new EventEmitter() + const resolved = vi.fn() + void waitForWindowReadiness(windowEvents, webContentsEvents).then(resolved) + + windowEvents.emit('ready-to-show') + await Promise.resolve() + + expect(resolved).toHaveBeenCalledOnce() + expect(webContentsEvents.listenerCount('did-finish-load')).toBe(0) + }) + + it('resolves on did-finish-load when Wayland omits ready-to-show', async () => { + const windowEvents = new EventEmitter() + const webContentsEvents = new EventEmitter() + const resolved = vi.fn() + void waitForWindowReadiness(windowEvents, webContentsEvents).then(resolved) + + webContentsEvents.emit('did-finish-load') + await Promise.resolve() + + expect(resolved).toHaveBeenCalledOnce() + expect(windowEvents.listenerCount('ready-to-show')).toBe(0) + }) +}) diff --git a/desktop/tests/window-state-store.test.ts b/desktop/tests/window-state-store.test.ts new file mode 100644 index 00000000..0b8d3834 --- /dev/null +++ b/desktop/tests/window-state-store.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' + +import { normalizeWindowState } from '../src/main/window-state-store' + +const primary = { workArea: { x: 0, y: 0, width: 1920, height: 1080 } } + +describe('normalizeWindowState', () => { + it('keeps valid bounds that intersect a display', () => { + const state = normalizeWindowState( + { x: 100, y: 80, width: 1200, height: 800, maximized: true }, + [primary], + primary + ) + + expect(state).toEqual({ x: 100, y: 80, width: 1200, height: 800, maximized: true }) + }) + + it('centers bounds that belonged to a removed display', () => { + const state = normalizeWindowState( + { x: 4000, y: 100, width: 1200, height: 800, maximized: false }, + [primary], + primary + ) + + expect(state.x).toBe(240) + expect(state.y).toBe(90) + expect(state.maximized).toBe(false) + }) + + it('rejects undersized persisted windows', () => { + const state = normalizeWindowState( + { x: 0, y: 0, width: 400, height: 300, maximized: false }, + [primary], + primary + ) + + expect(state.width).toBe(1440) + expect(state.height).toBe(900) + }) +}) diff --git a/desktop/tsconfig.json b/desktop/tsconfig.json new file mode 100644 index 00000000..5a6806ce --- /dev/null +++ b/desktop/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": [ + "electron.vite.config.ts", + "e2e/**/*.ts", + "src/**/*.ts", + "tests/**/*.ts", + "vitest.config.ts" + ] +} diff --git a/desktop/vitest.config.ts b/desktop/vitest.config.ts new file mode 100644 index 00000000..adfd6351 --- /dev/null +++ b/desktop/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'] + } +}) diff --git a/docker/gvhmr/Dockerfile b/docker/gvhmr/Dockerfile new file mode 100644 index 00000000..773b73de --- /dev/null +++ b/docker/gvhmr/Dockerfile @@ -0,0 +1,42 @@ +ARG CUDA_IMAGE=nvidia/cuda:12.8.1-cudnn-devel-ubuntu22.04 +FROM ${CUDA_IMAGE} + +ARG DEBIAN_FRONTEND=noninteractive +ARG PYTORCH_VERSION=2.8.0 +ARG TORCHVISION_VERSION=0.23.0 + +ENV PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD=1 \ + TORCH_CUDA_ARCH_LIST=12.0 \ + MAX_JOBS=4 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + ffmpeg \ + git \ + libgl1 \ + libglib2.0-0 \ + ninja-build \ + python3.10 \ + python3.10-dev \ + python3-pip \ + && rm -rf /var/lib/apt/lists/* + +RUN python3.10 -m pip install --no-cache-dir --upgrade pip setuptools wheel \ + && python3.10 -m pip install --no-cache-dir \ + "torch==${PYTORCH_VERSION}" \ + "torchvision==${TORCHVISION_VERSION}" \ + --index-url https://download.pytorch.org/whl/cu128 + +COPY docker/gvhmr/requirements-runtime.txt /opt/hhtools-gvhmr/requirements-runtime.txt +RUN python3.10 -m pip install --no-cache-dir \ + -r /opt/hhtools-gvhmr/requirements-runtime.txt \ + && PYTORCH3D_NO_EXTENSION=1 python3.10 -m pip install --no-cache-dir \ + --no-build-isolation \ + "git+https://github.com/facebookresearch/pytorch3d.git@stable" + +COPY docker/gvhmr/run_video.py /opt/hhtools-gvhmr/run_video.py + +WORKDIR /workspace/gvhmr +ENTRYPOINT ["python3.10", "/opt/hhtools-gvhmr/run_video.py"] diff --git a/docker/gvhmr/requirements-runtime.txt b/docker/gvhmr/requirements-runtime.txt new file mode 100644 index 00000000..f0b773e4 --- /dev/null +++ b/docker/gvhmr/requirements-runtime.txt @@ -0,0 +1,28 @@ +# Runtime dependencies from the official GVHMR requirements. PyTorch and +# PyTorch3D are installed separately so the image can target RTX 50-series +# GPUs with CUDA 12.8 while keeping the released model weights unchanged. +timm==0.9.12 +lightning==2.3.0 +hydra-core==1.3.2 +hydra-zen +hydra-colorlog +rich +numpy==1.23.5 +matplotlib +tensorboardX +opencv-python-headless==4.10.0.84 +ffmpeg-python +scikit-image==0.21.0 +termcolor +einops +imageio==2.34.1 +av==13.0.0 +joblib +trimesh +smplx==0.1.28 +wis3d +pycolmap +ultralytics==8.2.42 +cython-bbox +lapx +yacs diff --git a/docker/gvhmr/run_video.py b/docker/gvhmr/run_video.py new file mode 100644 index 00000000..396bd4c3 --- /dev/null +++ b/docker/gvhmr/run_video.py @@ -0,0 +1,212 @@ +"""Run the official GVHMR predictor without the optional mesh render pass. + +The official demo renders two videos after writing ``hmr4d_results.pt``. The +hhtools workflow consumes that result file directly, so rendering would only +increase latency and GPU memory use. Model construction, preprocessing, and +prediction remain the official GVHMR implementation and released weights. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +import types +from collections import namedtuple +from pathlib import Path + + +def _progress(value: float, message: str) -> None: + print(f"HHTOOLS_PROGRESS {value:.3f} {message}", flush=True) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--video", required=True) + parser.add_argument("--output-root", required=True) + parser.add_argument( + "--checkpoint", + default=None, + help=( + "Optional best-effort GVHMR checkpoint. HHTools does not guarantee " + "custom checkpoint compatibility." + ), + ) + parser.add_argument("--static-cam", action="store_true") + parser.add_argument("--f-mm", type=int, default=None) + return parser.parse_args() + + +def _hydra_safe_video_alias(video: Path, output_root: Path) -> Path: + """Expose the input under an ASCII stem accepted by Hydra's override grammar.""" + + digest = hashlib.sha256(str(video).encode("utf-8")).hexdigest()[:16] + alias_root = output_root.parent / ".hhtools-gvhmr-input" + alias_root.mkdir(parents=True, exist_ok=True) + alias = alias_root / f"source_{digest}{video.suffix.lower()}" + if alias.is_symlink(): + if alias.resolve() == video.resolve(): + return alias + alias.unlink() + elif alias.exists(): + raise FileExistsError(f"refusing to replace GVHMR input alias: {alias}") + alias.symlink_to(video.resolve()) + return alias + + +def _install_inference_only_pytorch3d_stubs(torch: object) -> None: + """Keep GVHMR's predictor independent from PyTorch3D render extensions. + + The released model uses the pure-PyTorch rotation transforms. The demo + module also imports mesh rendering helpers and one optional KNN helper at + module import time, even though hhtools neither renders meshes nor invokes + that helper during prediction. A small torch.cdist fallback preserves the + KNN contract if a future preprocessing path does call it. + """ + + # GVHMR's BodyModel module contains an unused ``from turtle import + # forward`` statement. Importing turtle would pull Tk into this headless + # worker even though BodyModel defines its own forward method immediately. + turtle_module = types.ModuleType("turtle") + turtle_module.forward = lambda *_args, **_kwargs: None + sys.modules.setdefault("turtle", turtle_module) + + import pytorch3d + + knn_result = namedtuple("KNN", ("dists", "idx", "knn")) + knn_module = types.ModuleType("pytorch3d.ops.knn") + + def knn_points( + p1: object, + p2: object, + lengths1: object | None = None, + lengths2: object | None = None, + norm: int = 2, + K: int = 1, # noqa: N803 - mirror PyTorch3D's public argument + version: int = -1, + return_nn: bool = False, + return_sorted: bool = True, + ) -> object: + del lengths1, lengths2, norm, version + distances = torch.cdist(p1, p2).square() + distances, indices = torch.topk( + distances, + k=K, + dim=-1, + largest=False, + sorted=return_sorted, + ) + neighbors = None + if return_nn: + expanded = p2[:, None, :, :].expand(-1, p1.shape[1], -1, -1) + neighbors = torch.gather( + expanded, + 2, + indices[..., None].expand(-1, -1, -1, p2.shape[-1]), + ) + return knn_result(distances, indices, neighbors) + + knn_module.knn_points = knn_points + ops_module = types.ModuleType("pytorch3d.ops") + ops_module.__path__ = [] + ops_module.knn = knn_module + pytorch3d.ops = ops_module + sys.modules["pytorch3d.ops"] = ops_module + sys.modules["pytorch3d.ops.knn"] = knn_module + + renderer_module = types.ModuleType("hmr4d.utils.vis.renderer") + + def rendering_disabled(*_args: object, **_kwargs: object) -> None: + raise RuntimeError("mesh rendering is disabled in the hhtools worker") + + renderer_module.Renderer = rendering_disabled + renderer_module.get_global_cameras_static = rendering_disabled + renderer_module.get_ground_params_from_points = rendering_disabled + sys.modules["hmr4d.utils.vis.renderer"] = renderer_module + + +def main() -> int: + args = _parse_args() + video = Path(args.video) + checkpoint = Path(args.checkpoint) if args.checkpoint else None + output_root = Path(args.output_root) + if not video.is_file(): + raise FileNotFoundError(f"input video does not exist: {video}") + if checkpoint is not None and not checkpoint.is_file(): + raise FileNotFoundError(f"custom checkpoint does not exist: {checkpoint}") + output_root.mkdir(parents=True, exist_ok=True) + safe_video = _hydra_safe_video_alias(video, output_root) + + # Executing this worker by absolute path makes Python use the worker's + # directory as sys.path[0]. Register the mounted official checkout + # explicitly so GVHMR's top-level ``hmr4d`` and ``tools`` packages resolve. + gvhmr_root = Path.cwd() + if not (gvhmr_root / "hmr4d").is_dir(): + raise FileNotFoundError( + f"GVHMR checkout is not mounted at the working directory: {gvhmr_root}" + ) + sys.path.insert(0, str(gvhmr_root)) + + # The official helper parses its own argv and creates the Hydra config. + official_argv = [ + "tools/demo/demo.py", + "--video", + str(safe_video), + "--output_root", + str(output_root), + ] + if args.static_cam: + official_argv.append("--static_cam") + if args.f_mm is not None: + official_argv.extend(["--f_mm", str(args.f_mm)]) + + _progress(0.01, "initializing GVHMR") + sys.argv = official_argv + + import hydra + import torch + + _install_inference_only_pytorch3d_stubs(torch) + + from hmr4d.utils.net_utils import detach_to_cpu + from hmr4d.utils.pylogger import Log + from tools.demo.demo import load_data_dict, parse_args_to_cfg, run_preprocess + + cfg = parse_args_to_cfg() + if checkpoint is not None: + # Keep the custom checkpoint hook deliberately permissive. Callers are + # responsible for architecture compatibility with their GVHMR checkout. + cfg.ckpt_path = str(checkpoint) + paths = cfg.paths + _progress(0.08, "preprocessing video") + run_preprocess(cfg) + _progress(0.66, "loading preprocessed features") + data = load_data_dict(cfg) + + result_path = Path(paths.hmr4d_results) + if not result_path.exists(): + checkpoint_label = "custom (best effort)" if checkpoint is not None else "official" + _progress(0.72, f"running {checkpoint_label} GVHMR checkpoint") + model = hydra.utils.instantiate(cfg.model, _recursive_=False) + model.load_pretrained_model(cfg.ckpt_path) + model = model.eval().cuda() + tic = Log.sync_time() + with torch.no_grad(): + pred = model.predict(data, static_cam=cfg.static_cam) + pred = detach_to_cpu(pred) + Log.info(f"[HHTOOLS] GVHMR prediction elapsed: {Log.sync_time() - tic:.2f}s") + torch.save(pred, result_path) + + if not result_path.is_file(): + raise RuntimeError(f"GVHMR did not create {result_path}") + _progress(1.0, "GVHMR motion ready") + print( + "HHTOOLS_RESULT " + json.dumps({"result_path": str(result_path)}, ensure_ascii=False), + flush=True, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/schemas/agent/v1/agent-job-view.schema.json b/docs/schemas/agent/v1/agent-job-view.schema.json new file mode 100644 index 00000000..7993df93 --- /dev/null +++ b/docs/schemas/agent/v1/agent-job-view.schema.json @@ -0,0 +1,616 @@ +{ + "$defs": { + "ApiError": { + "additionalProperties": false, + "description": "Structured failure that an agent can inspect without parsing prose.", + "properties": { + "code": { + "description": "Stable, English, machine-readable code.", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Z][A-Z0-9_]*$", + "title": "Code", + "type": "string" + }, + "details": { + "additionalProperties": true, + "description": "Small structured context; large payloads belong in artifacts.", + "title": "Details", + "type": "object" + }, + "message": { + "description": "Human-readable, potentially localized explanation.", + "minLength": 1, + "title": "Message", + "type": "string" + }, + "next_action": { + "anyOf": [ + { + "$ref": "#/$defs/NextAction" + }, + { + "type": "null" + } + ], + "default": null + }, + "retryable": { + "default": false, + "title": "Retryable", + "type": "boolean" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + }, + "stage": { + "$ref": "#/$defs/ErrorStage" + } + }, + "required": [ + "code", + "message", + "stage" + ], + "title": "ApiError", + "type": "object" + }, + "ArtifactDescriptor": { + "additionalProperties": false, + "description": "Metadata and URI for a job output; binary data is never embedded.", + "properties": { + "artifact_id": { + "description": "Artifact id with a stable kind namespace.", + "pattern": "^artifact:[a-z][a-z0-9_-]*:[A-Za-z0-9._~-]+$", + "title": "Artifact Id", + "type": "string" + }, + "created_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Created At" + }, + "format": { + "anyOf": [ + { + "maxLength": 32, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._+-]{0,31}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Format" + }, + "job_id": { + "maxLength": 256, + "minLength": 1, + "title": "Job Id", + "type": "string" + }, + "kind": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-z][a-z0-9_-]{0,127}$", + "title": "Kind", + "type": "string" + }, + "media_type": { + "anyOf": [ + { + "maxLength": 255, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Media Type" + }, + "metadata": { + "additionalProperties": true, + "title": "Metadata", + "type": "object" + }, + "resource_uri": { + "description": "Resolvable canonical job-scoped HHTools artifact URI or portable HTTP(S) URI.", + "minLength": 1, + "pattern": "^(?:https?://(?:\\[(?:(?:(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,7}:|(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,5}(?::[0-9A-Fa-f]{1,4}){1,2}|(?:[0-9A-Fa-f]{1,4}:){1,4}(?::[0-9A-Fa-f]{1,4}){1,3}|(?:[0-9A-Fa-f]{1,4}:){1,3}(?::[0-9A-Fa-f]{1,4}){1,4}|(?:[0-9A-Fa-f]{1,4}:){1,2}(?::[0-9A-Fa-f]{1,4}){1,5}|[0-9A-Fa-f]{1,4}:(?:(?::[0-9A-Fa-f]{1,4}){1,6})|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|:)))\\]|[A-Za-z0-9._~-]+)(?::(?:0|[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:[/?#](?:(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f]{2})|[/?#])*)?|hhtools://jobs/[A-Za-z0-9._~:-]+/artifacts/[A-Za-z0-9._~:-]+)$", + "title": "Resource Uri", + "type": "string" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + }, + "sha256": { + "anyOf": [ + { + "description": "Lower-case SHA-256 content digest.", + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sha256" + }, + "size_bytes": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Size Bytes" + } + }, + "required": [ + "artifact_id", + "job_id", + "kind", + "resource_uri" + ], + "title": "ArtifactDescriptor", + "type": "object" + }, + "ErrorStage": { + "description": "Stable stage in which an API error occurred.", + "enum": [ + "request", + "asset_registration", + "asset_inspection", + "preflight", + "admission", + "execution", + "evaluation", + "artifact", + "internal" + ], + "title": "ErrorStage", + "type": "string" + }, + "JobOutcome": { + "description": "Semantic result of a completed job.", + "enum": [ + "success", + "partial", + "review_required", + "rejected" + ], + "title": "JobOutcome", + "type": "string" + }, + "JobProgress": { + "additionalProperties": false, + "description": "Small monotonic progress snapshot suitable for frequent polling.", + "properties": { + "completed_items": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Completed Items" + }, + "eta_seconds": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Eta Seconds" + }, + "fraction": { + "default": 0.0, + "maximum": 1.0, + "minimum": 0.0, + "title": "Fraction", + "type": "number" + }, + "message": { + "anyOf": [ + { + "maxLength": 2048, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "phase": { + "default": "queued", + "maxLength": 128, + "minLength": 1, + "title": "Phase", + "type": "string" + }, + "revision": { + "default": 0, + "minimum": 0, + "title": "Revision", + "type": "integer" + }, + "total_items": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Total Items" + }, + "updated_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Updated At" + } + }, + "title": "JobProgress", + "type": "object" + }, + "JobQueueView": { + "additionalProperties": false, + "description": "Queue position and admission settings captured with a job snapshot.", + "properties": { + "max_queued_jobs": { + "default": 0, + "minimum": 0, + "title": "Max Queued Jobs", + "type": "integer" + }, + "max_running_jobs": { + "default": 0, + "minimum": 0, + "title": "Max Running Jobs", + "type": "integer" + }, + "mode": { + "$ref": "#/$defs/SchedulerMode" + }, + "position": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Position" + } + }, + "required": [ + "mode" + ], + "title": "JobQueueView", + "type": "object" + }, + "JobState": { + "description": "Execution lifecycle, independent from output quality.", + "enum": [ + "queued", + "running", + "completed", + "failed", + "cancelled" + ], + "title": "JobState", + "type": "string" + }, + "NextAction": { + "additionalProperties": false, + "description": "An explicit recovery or continuation step for an agent or human.", + "properties": { + "action": { + "description": "Stable, English action identifier.", + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$", + "title": "Action", + "type": "string" + }, + "actor": { + "enum": [ + "agent", + "human", + "system" + ], + "title": "Actor", + "type": "string" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional human-readable instruction.", + "title": "Message" + }, + "parameters": { + "additionalProperties": true, + "description": "Structured parameters needed to perform the action.", + "title": "Parameters", + "type": "object" + }, + "url": { + "anyOf": [ + { + "pattern": "^(?:(?:/(?:\\?(?:(?:calibrate|panel|robot|view)=[^&#\\s]{0,256}(?:&(?:calibrate|panel|robot|view)=[^&#\\s]{0,256})*)?)?|http://(?:127\\.0\\.0\\.1|localhost|\\[::1\\]):(?:0|[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])/(?:\\?(?:(?:calibrate|panel|robot|view)=[^&#\\s]{0,256}(?:&(?:calibrate|panel|robot|view)=[^&#\\s]{0,256})*)?)?)|https://(?:\\[(?:(?:(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,7}:|(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,5}(?::[0-9A-Fa-f]{1,4}){1,2}|(?:[0-9A-Fa-f]{1,4}:){1,4}(?::[0-9A-Fa-f]{1,4}){1,3}|(?:[0-9A-Fa-f]{1,4}:){1,3}(?::[0-9A-Fa-f]{1,4}){1,4}|(?:[0-9A-Fa-f]{1,4}:){1,2}(?::[0-9A-Fa-f]{1,4}){1,5}|[0-9A-Fa-f]{1,4}:(?:(?::[0-9A-Fa-f]{1,4}){1,6})|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|:)))\\]|[A-Za-z0-9._~-]+)(?::(?:0|[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:[/?#](?:(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f]{2})|[/?#])*)?)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional allowlisted local calibration UI route or portable HTTPS documentation URL.", + "title": "Url" + } + }, + "required": [ + "actor", + "action" + ], + "title": "NextAction", + "type": "object" + }, + "SchedulerMode": { + "description": "How the running and queued admission limits are configured.", + "enum": [ + "unlimited", + "limited", + "mixed" + ], + "title": "SchedulerMode", + "type": "string" + }, + "SchemaVersion": { + "description": "Version of the transport-neutral HHTools agent schema.", + "enum": [ + "1.0" + ], + "title": "SchemaVersion", + "type": "string" + } + }, + "additionalProperties": false, + "description": "Compact default job view; large arrays live behind artifact URIs.", + "properties": { + "artifact_count": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Artifact Count" + }, + "artifacts": { + "items": { + "$ref": "#/$defs/ArtifactDescriptor" + }, + "maxItems": 32, + "title": "Artifacts", + "type": "array" + }, + "attempt": { + "default": 1, + "minimum": 1, + "title": "Attempt", + "type": "integer" + }, + "cancellable": { + "default": false, + "title": "Cancellable", + "type": "boolean" + }, + "cancellation_requested": { + "default": false, + "title": "Cancellation Requested", + "type": "boolean" + }, + "completed_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Completed At" + }, + "error": { + "anyOf": [ + { + "$ref": "#/$defs/ApiError" + }, + { + "type": "null" + } + ], + "default": null + }, + "job_id": { + "maxLength": 256, + "minLength": 1, + "title": "Job Id", + "type": "string" + }, + "next_action": { + "anyOf": [ + { + "$ref": "#/$defs/NextAction" + }, + { + "type": "null" + } + ], + "default": null + }, + "outcome": { + "anyOf": [ + { + "$ref": "#/$defs/JobOutcome" + }, + { + "type": "null" + } + ], + "default": null + }, + "parent_job_id": { + "anyOf": [ + { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Parent Job Id" + }, + "poll_after_ms": { + "anyOf": [ + { + "maximum": 300000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Poll After Ms" + }, + "progress": { + "$ref": "#/$defs/JobProgress" + }, + "queue": { + "anyOf": [ + { + "$ref": "#/$defs/JobQueueView" + }, + { + "type": "null" + } + ], + "default": null + }, + "root_job_id": { + "anyOf": [ + { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Root Job Id" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + }, + "started_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Started At" + }, + "state": { + "$ref": "#/$defs/JobState" + }, + "submitted_at": { + "format": "date-time", + "title": "Submitted At", + "type": "string" + }, + "summary": { + "additionalProperties": true, + "description": "Small input/backend/robot summary, never trajectory arrays.", + "title": "Summary", + "type": "object" + } + }, + "required": [ + "job_id", + "state", + "progress", + "submitted_at" + ], + "title": "AgentJobView", + "type": "object" +} diff --git a/docs/schemas/agent/v1/api-error.schema.json b/docs/schemas/agent/v1/api-error.schema.json new file mode 100644 index 00000000..42790b71 --- /dev/null +++ b/docs/schemas/agent/v1/api-error.schema.json @@ -0,0 +1,144 @@ +{ + "$defs": { + "ErrorStage": { + "description": "Stable stage in which an API error occurred.", + "enum": [ + "request", + "asset_registration", + "asset_inspection", + "preflight", + "admission", + "execution", + "evaluation", + "artifact", + "internal" + ], + "title": "ErrorStage", + "type": "string" + }, + "NextAction": { + "additionalProperties": false, + "description": "An explicit recovery or continuation step for an agent or human.", + "properties": { + "action": { + "description": "Stable, English action identifier.", + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$", + "title": "Action", + "type": "string" + }, + "actor": { + "enum": [ + "agent", + "human", + "system" + ], + "title": "Actor", + "type": "string" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional human-readable instruction.", + "title": "Message" + }, + "parameters": { + "additionalProperties": true, + "description": "Structured parameters needed to perform the action.", + "title": "Parameters", + "type": "object" + }, + "url": { + "anyOf": [ + { + "pattern": "^(?:(?:/(?:\\?(?:(?:calibrate|panel|robot|view)=[^&#\\s]{0,256}(?:&(?:calibrate|panel|robot|view)=[^&#\\s]{0,256})*)?)?|http://(?:127\\.0\\.0\\.1|localhost|\\[::1\\]):(?:0|[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])/(?:\\?(?:(?:calibrate|panel|robot|view)=[^&#\\s]{0,256}(?:&(?:calibrate|panel|robot|view)=[^&#\\s]{0,256})*)?)?)|https://(?:\\[(?:(?:(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,7}:|(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,5}(?::[0-9A-Fa-f]{1,4}){1,2}|(?:[0-9A-Fa-f]{1,4}:){1,4}(?::[0-9A-Fa-f]{1,4}){1,3}|(?:[0-9A-Fa-f]{1,4}:){1,3}(?::[0-9A-Fa-f]{1,4}){1,4}|(?:[0-9A-Fa-f]{1,4}:){1,2}(?::[0-9A-Fa-f]{1,4}){1,5}|[0-9A-Fa-f]{1,4}:(?:(?::[0-9A-Fa-f]{1,4}){1,6})|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|:)))\\]|[A-Za-z0-9._~-]+)(?::(?:0|[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:[/?#](?:(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f]{2})|[/?#])*)?)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional allowlisted local calibration UI route or portable HTTPS documentation URL.", + "title": "Url" + } + }, + "required": [ + "actor", + "action" + ], + "title": "NextAction", + "type": "object" + }, + "SchemaVersion": { + "description": "Version of the transport-neutral HHTools agent schema.", + "enum": [ + "1.0" + ], + "title": "SchemaVersion", + "type": "string" + } + }, + "additionalProperties": false, + "description": "Structured failure that an agent can inspect without parsing prose.", + "properties": { + "code": { + "description": "Stable, English, machine-readable code.", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Z][A-Z0-9_]*$", + "title": "Code", + "type": "string" + }, + "details": { + "additionalProperties": true, + "description": "Small structured context; large payloads belong in artifacts.", + "title": "Details", + "type": "object" + }, + "message": { + "description": "Human-readable, potentially localized explanation.", + "minLength": 1, + "title": "Message", + "type": "string" + }, + "next_action": { + "anyOf": [ + { + "$ref": "#/$defs/NextAction" + }, + { + "type": "null" + } + ], + "default": null + }, + "retryable": { + "default": false, + "title": "Retryable", + "type": "boolean" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + }, + "stage": { + "$ref": "#/$defs/ErrorStage" + } + }, + "required": [ + "code", + "message", + "stage" + ], + "title": "ApiError", + "type": "object" +} diff --git a/docs/schemas/agent/v1/artifact-export-receipt.schema.json b/docs/schemas/agent/v1/artifact-export-receipt.schema.json new file mode 100644 index 00000000..2c3966fd --- /dev/null +++ b/docs/schemas/agent/v1/artifact-export-receipt.schema.json @@ -0,0 +1,104 @@ +{ + "$defs": { + "SchemaVersion": { + "description": "Version of the transport-neutral HHTools agent schema.", + "enum": [ + "1.0" + ], + "title": "SchemaVersion", + "type": "string" + } + }, + "additionalProperties": false, + "description": "Host-independent identity for one file copied to the configured export root.", + "properties": { + "artifact_id": { + "description": "Artifact id with a stable kind namespace.", + "pattern": "^artifact:[a-z][a-z0-9_-]*:[A-Za-z0-9._~-]+$", + "title": "Artifact Id", + "type": "string" + }, + "format": { + "anyOf": [ + { + "maxLength": 32, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._+-]{0,31}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Format" + }, + "job_id": { + "maxLength": 256, + "minLength": 1, + "pattern": "^job:[A-Za-z0-9][A-Za-z0-9._~-]{0,251}$", + "title": "Job Id", + "type": "string" + }, + "kind": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-z][a-z0-9_-]{0,127}$", + "title": "Kind", + "type": "string" + }, + "media_type": { + "anyOf": [ + { + "maxLength": 255, + "pattern": "^[!#$%&'*+.^_`|~0-9A-Za-z-]+/[!#$%&'*+.^_`|~0-9A-Za-z-]+(?:[ \\t]*;[^\\r\\n\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]+)*$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Media Type" + }, + "relative_path": { + "description": "Portable path below the server-configured agent export root.", + "maxLength": 1024, + "minLength": 1, + "pattern": "^jobs/[0-9a-f]{64}/[0-9a-f]{64}\\.[a-z0-9][a-z0-9._+-]{0,31}$", + "title": "Relative Path", + "type": "string" + }, + "root_id": { + "const": "agent-exports", + "default": "agent-exports", + "title": "Root Id", + "type": "string" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + }, + "sha256": { + "description": "Lower-case SHA-256 content digest.", + "pattern": "^[0-9a-f]{64}$", + "title": "Sha256", + "type": "string" + }, + "size_bytes": { + "minimum": 0, + "title": "Size Bytes", + "type": "integer" + } + }, + "required": [ + "relative_path", + "job_id", + "artifact_id", + "kind", + "size_bytes", + "sha256" + ], + "title": "ArtifactExportReceipt", + "type": "object" +} diff --git a/docs/schemas/agent/v1/artifact-list-response.schema.json b/docs/schemas/agent/v1/artifact-list-response.schema.json new file mode 100644 index 00000000..2014ac3f --- /dev/null +++ b/docs/schemas/agent/v1/artifact-list-response.schema.json @@ -0,0 +1,175 @@ +{ + "$defs": { + "ArtifactDescriptor": { + "additionalProperties": false, + "description": "Metadata and URI for a job output; binary data is never embedded.", + "properties": { + "artifact_id": { + "description": "Artifact id with a stable kind namespace.", + "pattern": "^artifact:[a-z][a-z0-9_-]*:[A-Za-z0-9._~-]+$", + "title": "Artifact Id", + "type": "string" + }, + "created_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Created At" + }, + "format": { + "anyOf": [ + { + "maxLength": 32, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._+-]{0,31}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Format" + }, + "job_id": { + "maxLength": 256, + "minLength": 1, + "title": "Job Id", + "type": "string" + }, + "kind": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-z][a-z0-9_-]{0,127}$", + "title": "Kind", + "type": "string" + }, + "media_type": { + "anyOf": [ + { + "maxLength": 255, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Media Type" + }, + "metadata": { + "additionalProperties": true, + "title": "Metadata", + "type": "object" + }, + "resource_uri": { + "description": "Resolvable canonical job-scoped HHTools artifact URI or portable HTTP(S) URI.", + "minLength": 1, + "pattern": "^(?:https?://(?:\\[(?:(?:(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,7}:|(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,5}(?::[0-9A-Fa-f]{1,4}){1,2}|(?:[0-9A-Fa-f]{1,4}:){1,4}(?::[0-9A-Fa-f]{1,4}){1,3}|(?:[0-9A-Fa-f]{1,4}:){1,3}(?::[0-9A-Fa-f]{1,4}){1,4}|(?:[0-9A-Fa-f]{1,4}:){1,2}(?::[0-9A-Fa-f]{1,4}){1,5}|[0-9A-Fa-f]{1,4}:(?:(?::[0-9A-Fa-f]{1,4}){1,6})|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|:)))\\]|[A-Za-z0-9._~-]+)(?::(?:0|[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:[/?#](?:(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f]{2})|[/?#])*)?|hhtools://jobs/[A-Za-z0-9._~:-]+/artifacts/[A-Za-z0-9._~:-]+)$", + "title": "Resource Uri", + "type": "string" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + }, + "sha256": { + "anyOf": [ + { + "description": "Lower-case SHA-256 content digest.", + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sha256" + }, + "size_bytes": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Size Bytes" + } + }, + "required": [ + "artifact_id", + "job_id", + "kind", + "resource_uri" + ], + "title": "ArtifactDescriptor", + "type": "object" + }, + "SchemaVersion": { + "description": "Version of the transport-neutral HHTools agent schema.", + "enum": [ + "1.0" + ], + "title": "SchemaVersion", + "type": "string" + } + }, + "additionalProperties": false, + "description": "Bounded page of canonical artifacts attached to one job.", + "properties": { + "artifacts": { + "items": { + "$ref": "#/$defs/ArtifactDescriptor" + }, + "maxItems": 500, + "title": "Artifacts", + "type": "array" + }, + "job_id": { + "maxLength": 256, + "minLength": 1, + "title": "Job Id", + "type": "string" + }, + "limit": { + "default": 100, + "maximum": 500, + "minimum": 1, + "title": "Limit", + "type": "integer" + }, + "offset": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + }, + "total": { + "minimum": 0, + "title": "Total", + "type": "integer" + } + }, + "required": [ + "job_id", + "total" + ], + "title": "ArtifactListResponse", + "type": "object" +} diff --git a/docs/schemas/agent/v1/artifact.schema.json b/docs/schemas/agent/v1/artifact.schema.json new file mode 100644 index 00000000..a9e28968 --- /dev/null +++ b/docs/schemas/agent/v1/artifact.schema.json @@ -0,0 +1,127 @@ +{ + "$defs": { + "SchemaVersion": { + "description": "Version of the transport-neutral HHTools agent schema.", + "enum": [ + "1.0" + ], + "title": "SchemaVersion", + "type": "string" + } + }, + "additionalProperties": false, + "description": "Metadata and URI for a job output; binary data is never embedded.", + "properties": { + "artifact_id": { + "description": "Artifact id with a stable kind namespace.", + "pattern": "^artifact:[a-z][a-z0-9_-]*:[A-Za-z0-9._~-]+$", + "title": "Artifact Id", + "type": "string" + }, + "created_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Created At" + }, + "format": { + "anyOf": [ + { + "maxLength": 32, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._+-]{0,31}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Format" + }, + "job_id": { + "maxLength": 256, + "minLength": 1, + "title": "Job Id", + "type": "string" + }, + "kind": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-z][a-z0-9_-]{0,127}$", + "title": "Kind", + "type": "string" + }, + "media_type": { + "anyOf": [ + { + "maxLength": 255, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Media Type" + }, + "metadata": { + "additionalProperties": true, + "title": "Metadata", + "type": "object" + }, + "resource_uri": { + "description": "Resolvable canonical job-scoped HHTools artifact URI or portable HTTP(S) URI.", + "minLength": 1, + "pattern": "^(?:https?://(?:\\[(?:(?:(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,7}:|(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,5}(?::[0-9A-Fa-f]{1,4}){1,2}|(?:[0-9A-Fa-f]{1,4}:){1,4}(?::[0-9A-Fa-f]{1,4}){1,3}|(?:[0-9A-Fa-f]{1,4}:){1,3}(?::[0-9A-Fa-f]{1,4}){1,4}|(?:[0-9A-Fa-f]{1,4}:){1,2}(?::[0-9A-Fa-f]{1,4}){1,5}|[0-9A-Fa-f]{1,4}:(?:(?::[0-9A-Fa-f]{1,4}){1,6})|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|:)))\\]|[A-Za-z0-9._~-]+)(?::(?:0|[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:[/?#](?:(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f]{2})|[/?#])*)?|hhtools://jobs/[A-Za-z0-9._~:-]+/artifacts/[A-Za-z0-9._~:-]+)$", + "title": "Resource Uri", + "type": "string" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + }, + "sha256": { + "anyOf": [ + { + "description": "Lower-case SHA-256 content digest.", + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sha256" + }, + "size_bytes": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Size Bytes" + } + }, + "required": [ + "artifact_id", + "job_id", + "kind", + "resource_uri" + ], + "title": "ArtifactDescriptor", + "type": "object" +} diff --git a/docs/schemas/agent/v1/asset-bundle.schema.json b/docs/schemas/agent/v1/asset-bundle.schema.json new file mode 100644 index 00000000..025489e2 --- /dev/null +++ b/docs/schemas/agent/v1/asset-bundle.schema.json @@ -0,0 +1,280 @@ +{ + "$defs": { + "AssetCategory": { + "description": "Workflow category used for backend selection.", + "enum": [ + "plain_motion", + "object_interaction", + "terrain_scene", + "robot_model", + "calibration" + ], + "title": "AssetCategory", + "type": "string" + }, + "AssetDetected": { + "additionalProperties": false, + "description": "Small set of routing hints discovered from a registered bundle.", + "properties": { + "dataset": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Dataset" + }, + "recommended_backend": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Recommended Backend" + }, + "reference": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reference" + } + }, + "title": "AssetDetected", + "type": "object" + }, + "AssetFile": { + "additionalProperties": false, + "description": "One content-addressed file inside an :class:`AssetBundle`.", + "properties": { + "media_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "IANA media type when known.", + "title": "Media Type" + }, + "relative_path": { + "description": "Portable path relative to the bundle.", + "maxLength": 1024, + "minLength": 1, + "title": "Relative Path", + "type": "string" + }, + "required": { + "default": true, + "title": "Required", + "type": "boolean" + }, + "role": { + "$ref": "#/$defs/AssetFileRole" + }, + "sha256": { + "description": "Lower-case SHA-256 content digest.", + "pattern": "^[0-9a-f]{64}$", + "title": "Sha256", + "type": "string" + }, + "size_bytes": { + "minimum": 0, + "title": "Size Bytes", + "type": "integer" + } + }, + "required": [ + "role", + "relative_path", + "sha256", + "size_bytes" + ], + "title": "AssetFile", + "type": "object" + }, + "AssetFileRole": { + "description": "Semantic role of a file inside a bundle.", + "enum": [ + "motion", + "robot_description", + "visual_mesh", + "collision_mesh", + "object_mesh", + "terrain_mesh", + "object_trajectory", + "calibration", + "metadata", + "video", + "other" + ], + "title": "AssetFileRole", + "type": "string" + }, + "AssetKind": { + "description": "Logical type of a registered asset.", + "enum": [ + "motion_bundle", + "robot_bundle", + "calibration_bundle", + "dataset_bundle", + "video" + ], + "title": "AssetKind", + "type": "string" + }, + "AssetSource": { + "additionalProperties": false, + "description": "Location identity without exposing an arbitrary host absolute path.", + "properties": { + "logical_path": { + "anyOf": [ + { + "maxLength": 1024, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Logical Path" + }, + "registered_at": { + "format": "date-time", + "title": "Registered At", + "type": "string" + }, + "root_id": { + "maxLength": 128, + "minLength": 1, + "title": "Root Id", + "type": "string" + }, + "scheme": { + "$ref": "#/$defs/AssetSourceScheme" + } + }, + "required": [ + "scheme", + "root_id", + "registered_at" + ], + "title": "AssetSource", + "type": "object" + }, + "AssetSourceScheme": { + "description": "Controlled source schemes understood by the AssetRegistry.", + "enum": [ + "managed_file", + "upload", + "shared_storage", + "artifact" + ], + "title": "AssetSourceScheme", + "type": "string" + }, + "SchemaVersion": { + "description": "Version of the transport-neutral HHTools agent schema.", + "enum": [ + "1.0" + ], + "title": "SchemaVersion", + "type": "string" + } + }, + "additionalProperties": false, + "description": "Portable manifest for all files required by one logical input.", + "properties": { + "asset_id": { + "description": "Content-addressed asset id.", + "pattern": "^asset:sha256:[0-9a-f]{64}$", + "title": "Asset Id", + "type": "string" + }, + "category": { + "$ref": "#/$defs/AssetCategory" + }, + "detected": { + "anyOf": [ + { + "$ref": "#/$defs/AssetDetected" + }, + { + "type": "null" + } + ], + "default": null + }, + "display_name": { + "maxLength": 256, + "minLength": 1, + "title": "Display Name", + "type": "string" + }, + "files": { + "items": { + "$ref": "#/$defs/AssetFile" + }, + "minItems": 1, + "title": "Files", + "type": "array" + }, + "kind": { + "$ref": "#/$defs/AssetKind" + }, + "metadata": { + "additionalProperties": true, + "title": "Metadata", + "type": "object" + }, + "primary_file": { + "maxLength": 1024, + "minLength": 1, + "title": "Primary File", + "type": "string" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + }, + "source": { + "anyOf": [ + { + "$ref": "#/$defs/AssetSource" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "asset_id", + "kind", + "category", + "display_name", + "primary_file", + "files" + ], + "title": "AssetBundle", + "type": "object" +} diff --git a/docs/schemas/agent/v1/asset-inspection.schema.json b/docs/schemas/agent/v1/asset-inspection.schema.json new file mode 100644 index 00000000..a5e35efe --- /dev/null +++ b/docs/schemas/agent/v1/asset-inspection.schema.json @@ -0,0 +1,330 @@ +{ + "$defs": { + "ApiError": { + "additionalProperties": false, + "description": "Structured failure that an agent can inspect without parsing prose.", + "properties": { + "code": { + "description": "Stable, English, machine-readable code.", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Z][A-Z0-9_]*$", + "title": "Code", + "type": "string" + }, + "details": { + "additionalProperties": true, + "description": "Small structured context; large payloads belong in artifacts.", + "title": "Details", + "type": "object" + }, + "message": { + "description": "Human-readable, potentially localized explanation.", + "minLength": 1, + "title": "Message", + "type": "string" + }, + "next_action": { + "anyOf": [ + { + "$ref": "#/$defs/NextAction" + }, + { + "type": "null" + } + ], + "default": null + }, + "retryable": { + "default": false, + "title": "Retryable", + "type": "boolean" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + }, + "stage": { + "$ref": "#/$defs/ErrorStage" + } + }, + "required": [ + "code", + "message", + "stage" + ], + "title": "ApiError", + "type": "object" + }, + "AssetCategory": { + "description": "Workflow category used for backend selection.", + "enum": [ + "plain_motion", + "object_interaction", + "terrain_scene", + "robot_model", + "calibration" + ], + "title": "AssetCategory", + "type": "string" + }, + "AssetKind": { + "description": "Logical type of a registered asset.", + "enum": [ + "motion_bundle", + "robot_bundle", + "calibration_bundle", + "dataset_bundle", + "video" + ], + "title": "AssetKind", + "type": "string" + }, + "ErrorStage": { + "description": "Stable stage in which an API error occurred.", + "enum": [ + "request", + "asset_registration", + "asset_inspection", + "preflight", + "admission", + "execution", + "evaluation", + "artifact", + "internal" + ], + "title": "ErrorStage", + "type": "string" + }, + "InspectionStatus": { + "description": "Machine-readable outcome of inspecting an asset.", + "enum": [ + "valid", + "valid_with_warnings", + "invalid" + ], + "title": "InspectionStatus", + "type": "string" + }, + "NextAction": { + "additionalProperties": false, + "description": "An explicit recovery or continuation step for an agent or human.", + "properties": { + "action": { + "description": "Stable, English action identifier.", + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$", + "title": "Action", + "type": "string" + }, + "actor": { + "enum": [ + "agent", + "human", + "system" + ], + "title": "Actor", + "type": "string" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional human-readable instruction.", + "title": "Message" + }, + "parameters": { + "additionalProperties": true, + "description": "Structured parameters needed to perform the action.", + "title": "Parameters", + "type": "object" + }, + "url": { + "anyOf": [ + { + "pattern": "^(?:(?:/(?:\\?(?:(?:calibrate|panel|robot|view)=[^&#\\s]{0,256}(?:&(?:calibrate|panel|robot|view)=[^&#\\s]{0,256})*)?)?|http://(?:127\\.0\\.0\\.1|localhost|\\[::1\\]):(?:0|[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])/(?:\\?(?:(?:calibrate|panel|robot|view)=[^&#\\s]{0,256}(?:&(?:calibrate|panel|robot|view)=[^&#\\s]{0,256})*)?)?)|https://(?:\\[(?:(?:(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,7}:|(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,5}(?::[0-9A-Fa-f]{1,4}){1,2}|(?:[0-9A-Fa-f]{1,4}:){1,4}(?::[0-9A-Fa-f]{1,4}){1,3}|(?:[0-9A-Fa-f]{1,4}:){1,3}(?::[0-9A-Fa-f]{1,4}){1,4}|(?:[0-9A-Fa-f]{1,4}:){1,2}(?::[0-9A-Fa-f]{1,4}){1,5}|[0-9A-Fa-f]{1,4}:(?:(?::[0-9A-Fa-f]{1,4}){1,6})|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|:)))\\]|[A-Za-z0-9._~-]+)(?::(?:0|[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:[/?#](?:(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f]{2})|[/?#])*)?)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional allowlisted local calibration UI route or portable HTTPS documentation URL.", + "title": "Url" + } + }, + "required": [ + "actor", + "action" + ], + "title": "NextAction", + "type": "object" + }, + "SchemaVersion": { + "description": "Version of the transport-neutral HHTools agent schema.", + "enum": [ + "1.0" + ], + "title": "SchemaVersion", + "type": "string" + } + }, + "additionalProperties": false, + "description": "Compact, structured facts discovered without running retargeting.", + "properties": { + "asset_id": { + "description": "Content-addressed asset id.", + "pattern": "^asset:sha256:[0-9a-f]{64}$", + "title": "Asset Id", + "type": "string" + }, + "category": { + "$ref": "#/$defs/AssetCategory" + }, + "dataset": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Dataset" + }, + "duration_seconds": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Duration Seconds" + }, + "errors": { + "items": { + "$ref": "#/$defs/ApiError" + }, + "title": "Errors", + "type": "array" + }, + "frame_count": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Frame Count" + }, + "frame_rate_hz": { + "anyOf": [ + { + "exclusiveMinimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Frame Rate Hz" + }, + "has_object": { + "default": false, + "title": "Has Object", + "type": "boolean" + }, + "has_terrain": { + "default": false, + "title": "Has Terrain", + "type": "boolean" + }, + "joint_count": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Joint Count" + }, + "kind": { + "$ref": "#/$defs/AssetKind" + }, + "metadata": { + "additionalProperties": true, + "title": "Metadata", + "type": "object" + }, + "reference_model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Detected human reference, for example smpl, smplh, or smplx.", + "title": "Reference Model" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + }, + "source_format": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Detected source format.", + "title": "Source Format" + }, + "status": { + "$ref": "#/$defs/InspectionStatus" + }, + "warnings": { + "items": { + "type": "string" + }, + "title": "Warnings", + "type": "array" + } + }, + "required": [ + "asset_id", + "status", + "kind", + "category" + ], + "title": "AssetInspection", + "type": "object" +} diff --git a/docs/schemas/agent/v1/asset-registration-request.schema.json b/docs/schemas/agent/v1/asset-registration-request.schema.json new file mode 100644 index 00000000..c9c4ce81 --- /dev/null +++ b/docs/schemas/agent/v1/asset-registration-request.schema.json @@ -0,0 +1,102 @@ +{ + "$defs": { + "AssetCategory": { + "description": "Workflow category used for backend selection.", + "enum": [ + "plain_motion", + "object_interaction", + "terrain_scene", + "robot_model", + "calibration" + ], + "title": "AssetCategory", + "type": "string" + }, + "AssetKind": { + "description": "Logical type of a registered asset.", + "enum": [ + "motion_bundle", + "robot_bundle", + "calibration_bundle", + "dataset_bundle", + "video" + ], + "title": "AssetKind", + "type": "string" + }, + "SchemaVersion": { + "description": "Version of the transport-neutral HHTools agent schema.", + "enum": [ + "1.0" + ], + "title": "SchemaVersion", + "type": "string" + } + }, + "additionalProperties": false, + "description": "Register a bundle from a path below a server-configured root.", + "properties": { + "category": { + "anyOf": [ + { + "$ref": "#/$defs/AssetCategory" + }, + { + "type": "null" + } + ], + "default": null + }, + "display_name": { + "anyOf": [ + { + "maxLength": 256, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display Name" + }, + "kind": { + "anyOf": [ + { + "$ref": "#/$defs/AssetKind" + }, + { + "type": "null" + } + ], + "default": null + }, + "recursive": { + "default": true, + "title": "Recursive", + "type": "boolean" + }, + "relative_path": { + "maxLength": 1024, + "minLength": 1, + "title": "Relative Path", + "type": "string" + }, + "root_id": { + "maxLength": 128, + "minLength": 1, + "title": "Root Id", + "type": "string" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + } + }, + "required": [ + "root_id", + "relative_path" + ], + "title": "AssetRegistrationRequest", + "type": "object" +} diff --git a/docs/schemas/agent/v1/asset-search-response.schema.json b/docs/schemas/agent/v1/asset-search-response.schema.json new file mode 100644 index 00000000..e4e71483 --- /dev/null +++ b/docs/schemas/agent/v1/asset-search-response.schema.json @@ -0,0 +1,320 @@ +{ + "$defs": { + "AssetBundle": { + "additionalProperties": false, + "description": "Portable manifest for all files required by one logical input.", + "properties": { + "asset_id": { + "description": "Content-addressed asset id.", + "pattern": "^asset:sha256:[0-9a-f]{64}$", + "title": "Asset Id", + "type": "string" + }, + "category": { + "$ref": "#/$defs/AssetCategory" + }, + "detected": { + "anyOf": [ + { + "$ref": "#/$defs/AssetDetected" + }, + { + "type": "null" + } + ], + "default": null + }, + "display_name": { + "maxLength": 256, + "minLength": 1, + "title": "Display Name", + "type": "string" + }, + "files": { + "items": { + "$ref": "#/$defs/AssetFile" + }, + "minItems": 1, + "title": "Files", + "type": "array" + }, + "kind": { + "$ref": "#/$defs/AssetKind" + }, + "metadata": { + "additionalProperties": true, + "title": "Metadata", + "type": "object" + }, + "primary_file": { + "maxLength": 1024, + "minLength": 1, + "title": "Primary File", + "type": "string" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + }, + "source": { + "anyOf": [ + { + "$ref": "#/$defs/AssetSource" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "asset_id", + "kind", + "category", + "display_name", + "primary_file", + "files" + ], + "title": "AssetBundle", + "type": "object" + }, + "AssetCategory": { + "description": "Workflow category used for backend selection.", + "enum": [ + "plain_motion", + "object_interaction", + "terrain_scene", + "robot_model", + "calibration" + ], + "title": "AssetCategory", + "type": "string" + }, + "AssetDetected": { + "additionalProperties": false, + "description": "Small set of routing hints discovered from a registered bundle.", + "properties": { + "dataset": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Dataset" + }, + "recommended_backend": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Recommended Backend" + }, + "reference": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reference" + } + }, + "title": "AssetDetected", + "type": "object" + }, + "AssetFile": { + "additionalProperties": false, + "description": "One content-addressed file inside an :class:`AssetBundle`.", + "properties": { + "media_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "IANA media type when known.", + "title": "Media Type" + }, + "relative_path": { + "description": "Portable path relative to the bundle.", + "maxLength": 1024, + "minLength": 1, + "title": "Relative Path", + "type": "string" + }, + "required": { + "default": true, + "title": "Required", + "type": "boolean" + }, + "role": { + "$ref": "#/$defs/AssetFileRole" + }, + "sha256": { + "description": "Lower-case SHA-256 content digest.", + "pattern": "^[0-9a-f]{64}$", + "title": "Sha256", + "type": "string" + }, + "size_bytes": { + "minimum": 0, + "title": "Size Bytes", + "type": "integer" + } + }, + "required": [ + "role", + "relative_path", + "sha256", + "size_bytes" + ], + "title": "AssetFile", + "type": "object" + }, + "AssetFileRole": { + "description": "Semantic role of a file inside a bundle.", + "enum": [ + "motion", + "robot_description", + "visual_mesh", + "collision_mesh", + "object_mesh", + "terrain_mesh", + "object_trajectory", + "calibration", + "metadata", + "video", + "other" + ], + "title": "AssetFileRole", + "type": "string" + }, + "AssetKind": { + "description": "Logical type of a registered asset.", + "enum": [ + "motion_bundle", + "robot_bundle", + "calibration_bundle", + "dataset_bundle", + "video" + ], + "title": "AssetKind", + "type": "string" + }, + "AssetSource": { + "additionalProperties": false, + "description": "Location identity without exposing an arbitrary host absolute path.", + "properties": { + "logical_path": { + "anyOf": [ + { + "maxLength": 1024, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Logical Path" + }, + "registered_at": { + "format": "date-time", + "title": "Registered At", + "type": "string" + }, + "root_id": { + "maxLength": 128, + "minLength": 1, + "title": "Root Id", + "type": "string" + }, + "scheme": { + "$ref": "#/$defs/AssetSourceScheme" + } + }, + "required": [ + "scheme", + "root_id", + "registered_at" + ], + "title": "AssetSource", + "type": "object" + }, + "AssetSourceScheme": { + "description": "Controlled source schemes understood by the AssetRegistry.", + "enum": [ + "managed_file", + "upload", + "shared_storage", + "artifact" + ], + "title": "AssetSourceScheme", + "type": "string" + }, + "SchemaVersion": { + "description": "Version of the transport-neutral HHTools agent schema.", + "enum": [ + "1.0" + ], + "title": "SchemaVersion", + "type": "string" + } + }, + "additionalProperties": false, + "description": "Versioned, bounded search result for registered asset manifests.", + "properties": { + "assets": { + "items": { + "$ref": "#/$defs/AssetBundle" + }, + "title": "Assets", + "type": "array" + }, + "limit": { + "maximum": 500, + "minimum": 1, + "title": "Limit", + "type": "integer" + }, + "offset": { + "minimum": 0, + "title": "Offset", + "type": "integer" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + }, + "total": { + "minimum": 0, + "title": "Total", + "type": "integer" + } + }, + "required": [ + "total", + "limit", + "offset" + ], + "title": "AssetSearchResponse", + "type": "object" +} diff --git a/docs/schemas/agent/v1/capabilities.schema.json b/docs/schemas/agent/v1/capabilities.schema.json new file mode 100644 index 00000000..f21b7461 --- /dev/null +++ b/docs/schemas/agent/v1/capabilities.schema.json @@ -0,0 +1,417 @@ +{ + "$defs": { + "AssetCategory": { + "description": "Workflow category used for backend selection.", + "enum": [ + "plain_motion", + "object_interaction", + "terrain_scene", + "robot_model", + "calibration" + ], + "title": "AssetCategory", + "type": "string" + }, + "BackendCapability": { + "additionalProperties": false, + "description": "A retargeting backend and the inputs/outputs it can handle.", + "properties": { + "available": { + "title": "Available", + "type": "boolean" + }, + "backend_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-z][a-z0-9_-]*$", + "title": "Backend Id", + "type": "string" + }, + "display_name": { + "maxLength": 256, + "minLength": 1, + "title": "Display Name", + "type": "string" + }, + "features": { + "additionalProperties": { + "type": "boolean" + }, + "title": "Features", + "type": "object" + }, + "limits": { + "additionalProperties": true, + "title": "Limits", + "type": "object" + }, + "output_formats": { + "items": { + "type": "string" + }, + "title": "Output Formats", + "type": "array" + }, + "supported_categories": { + "items": { + "$ref": "#/$defs/AssetCategory" + }, + "title": "Supported Categories", + "type": "array" + }, + "unavailable_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Unavailable Reason" + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Version" + } + }, + "required": [ + "backend_id", + "display_name", + "available" + ], + "title": "BackendCapability", + "type": "object" + }, + "DeviceCapability": { + "additionalProperties": false, + "description": "One execution device visible to the HHTools service.", + "properties": { + "available": { + "title": "Available", + "type": "boolean" + }, + "compute_capability": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Compute Capability" + }, + "device_id": { + "maxLength": 128, + "minLength": 1, + "title": "Device Id", + "type": "string" + }, + "display_name": { + "maxLength": 256, + "minLength": 1, + "title": "Display Name", + "type": "string" + }, + "free_memory_bytes": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Free Memory Bytes" + }, + "kind": { + "$ref": "#/$defs/DeviceKind" + }, + "metadata": { + "additionalProperties": true, + "title": "Metadata", + "type": "object" + }, + "total_memory_bytes": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Total Memory Bytes" + } + }, + "required": [ + "device_id", + "kind", + "display_name", + "available" + ], + "title": "DeviceCapability", + "type": "object" + }, + "DeviceKind": { + "enum": [ + "cpu", + "cuda", + "mps" + ], + "title": "DeviceKind", + "type": "string" + }, + "RobotCapability": { + "additionalProperties": false, + "description": "Agent-facing robot availability and calibration summary.", + "properties": { + "available": { + "title": "Available", + "type": "boolean" + }, + "calibrated_references": { + "items": { + "type": "string" + }, + "title": "Calibrated References", + "type": "array" + }, + "display_name": { + "maxLength": 256, + "minLength": 1, + "title": "Display Name", + "type": "string" + }, + "dof_count": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Dof Count" + }, + "has_ik_mapping": { + "title": "Has Ik Mapping", + "type": "boolean" + }, + "has_urdf": { + "title": "Has Urdf", + "type": "boolean" + }, + "robot_id": { + "maxLength": 256, + "minLength": 1, + "title": "Robot Id", + "type": "string" + }, + "scaler_references": { + "items": { + "type": "string" + }, + "title": "Scaler References", + "type": "array" + }, + "supported_references": { + "items": { + "type": "string" + }, + "title": "Supported References", + "type": "array" + }, + "unavailable_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Unavailable Reason" + } + }, + "required": [ + "robot_id", + "display_name", + "available", + "has_urdf", + "has_ik_mapping" + ], + "title": "RobotCapability", + "type": "object" + }, + "SchedulerCapability": { + "additionalProperties": false, + "description": "Current admission policy and occupancy.\n\n``max_running_jobs == 0`` disables admission control entirely in the Web\nscheduler. In that state ``max_queued_jobs`` is retained as configured\nmetadata but is not enforced, so the effective mode remains ``unlimited``.", + "properties": { + "closed": { + "default": false, + "title": "Closed", + "type": "boolean" + }, + "max_queued_jobs": { + "default": 0, + "minimum": 0, + "title": "Max Queued Jobs", + "type": "integer" + }, + "max_running_jobs": { + "default": 0, + "minimum": 0, + "title": "Max Running Jobs", + "type": "integer" + }, + "mode": { + "$ref": "#/$defs/SchedulerMode" + }, + "queued": { + "default": 0, + "minimum": 0, + "title": "Queued", + "type": "integer" + }, + "reserved": { + "default": 0, + "minimum": 0, + "title": "Reserved", + "type": "integer" + }, + "running": { + "default": 0, + "minimum": 0, + "title": "Running", + "type": "integer" + } + }, + "required": [ + "mode" + ], + "title": "SchedulerCapability", + "type": "object" + }, + "SchedulerMode": { + "description": "How the running and queued admission limits are configured.", + "enum": [ + "unlimited", + "limited", + "mixed" + ], + "title": "SchedulerMode", + "type": "string" + }, + "SchemaVersion": { + "description": "Version of the transport-neutral HHTools agent schema.", + "enum": [ + "1.0" + ], + "title": "SchemaVersion", + "type": "string" + } + }, + "additionalProperties": false, + "description": "Compact discovery response used before an agent builds a plan.", + "properties": { + "agent_api_version": { + "default": "v1", + "title": "Agent Api Version", + "type": "string" + }, + "asset_root_ids": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$", + "type": "string" + }, + "title": "Asset Root Ids", + "type": "array" + }, + "backends": { + "items": { + "$ref": "#/$defs/BackendCapability" + }, + "title": "Backends", + "type": "array" + }, + "devices": { + "items": { + "$ref": "#/$defs/DeviceCapability" + }, + "title": "Devices", + "type": "array" + }, + "features": { + "additionalProperties": { + "type": "boolean" + }, + "title": "Features", + "type": "object" + }, + "robots": { + "items": { + "$ref": "#/$defs/RobotCapability" + }, + "title": "Robots", + "type": "array" + }, + "scheduler": { + "$ref": "#/$defs/SchedulerCapability" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + }, + "service_name": { + "default": "hhtools", + "title": "Service Name", + "type": "string" + }, + "service_version": { + "minLength": 1, + "title": "Service Version", + "type": "string" + }, + "supported_input_formats": { + "items": { + "type": "string" + }, + "title": "Supported Input Formats", + "type": "array" + }, + "supported_output_formats": { + "items": { + "type": "string" + }, + "title": "Supported Output Formats", + "type": "array" + } + }, + "required": [ + "service_version", + "scheduler" + ], + "title": "CapabilityResponse", + "type": "object" +} diff --git a/docs/schemas/agent/v1/evaluation-report.schema.json b/docs/schemas/agent/v1/evaluation-report.schema.json new file mode 100644 index 00000000..35a80651 --- /dev/null +++ b/docs/schemas/agent/v1/evaluation-report.schema.json @@ -0,0 +1,79 @@ +{ + "$defs": { + "JobOutcome": { + "description": "Semantic result of a completed job.", + "enum": [ + "success", + "partial", + "review_required", + "rejected" + ], + "title": "JobOutcome", + "type": "string" + }, + "SchemaVersion": { + "description": "Version of the transport-neutral HHTools agent schema.", + "enum": [ + "1.0" + ], + "title": "SchemaVersion", + "type": "string" + } + }, + "additionalProperties": false, + "description": "Compact quality verdict; large plots and previews remain separate artifacts.", + "properties": { + "checks": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "maxItems": 256, + "title": "Checks", + "type": "array" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "job_id": { + "maxLength": 256, + "minLength": 1, + "title": "Job Id", + "type": "string" + }, + "metrics": { + "additionalProperties": true, + "title": "Metrics", + "type": "object" + }, + "outcome": { + "$ref": "#/$defs/JobOutcome" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + }, + "summary": { + "anyOf": [ + { + "maxLength": 4096, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Summary" + } + }, + "required": [ + "job_id", + "outcome", + "created_at" + ], + "title": "EvaluationReport", + "type": "object" +} diff --git a/docs/schemas/agent/v1/failure-report.schema.json b/docs/schemas/agent/v1/failure-report.schema.json new file mode 100644 index 00000000..7c75421a --- /dev/null +++ b/docs/schemas/agent/v1/failure-report.schema.json @@ -0,0 +1,117 @@ +{ + "$defs": { + "ErrorStage": { + "description": "Stable stage in which an API error occurred.", + "enum": [ + "request", + "asset_registration", + "asset_inspection", + "preflight", + "admission", + "execution", + "evaluation", + "artifact", + "internal" + ], + "title": "ErrorStage", + "type": "string" + }, + "FailureItem": { + "additionalProperties": false, + "description": "One structured failed input or execution stage.", + "properties": { + "code": { + "description": "Stable, English, machine-readable code.", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Z][A-Z0-9_]*$", + "title": "Code", + "type": "string" + }, + "details": { + "additionalProperties": true, + "title": "Details", + "type": "object" + }, + "item_id": { + "anyOf": [ + { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Item Id" + }, + "message": { + "maxLength": 8192, + "minLength": 1, + "title": "Message", + "type": "string" + }, + "retryable": { + "default": false, + "title": "Retryable", + "type": "boolean" + }, + "stage": { + "$ref": "#/$defs/ErrorStage" + } + }, + "required": [ + "code", + "message", + "stage" + ], + "title": "FailureItem", + "type": "object" + }, + "SchemaVersion": { + "description": "Version of the transport-neutral HHTools agent schema.", + "enum": [ + "1.0" + ], + "title": "SchemaVersion", + "type": "string" + } + }, + "additionalProperties": false, + "description": "Structured failures for a failed or partially completed job.", + "properties": { + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "failures": { + "items": { + "$ref": "#/$defs/FailureItem" + }, + "maxItems": 10000, + "minItems": 1, + "title": "Failures", + "type": "array" + }, + "job_id": { + "maxLength": 256, + "minLength": 1, + "title": "Job Id", + "type": "string" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + } + }, + "required": [ + "job_id", + "failures", + "created_at" + ], + "title": "FailureReport", + "type": "object" +} diff --git a/docs/schemas/agent/v1/job-lookup-request.schema.json b/docs/schemas/agent/v1/job-lookup-request.schema.json new file mode 100644 index 00000000..e4a89346 --- /dev/null +++ b/docs/schemas/agent/v1/job-lookup-request.schema.json @@ -0,0 +1,53 @@ +{ + "$defs": { + "SchemaVersion": { + "description": "Version of the transport-neutral HHTools agent schema.", + "enum": [ + "1.0" + ], + "title": "SchemaVersion", + "type": "string" + } + }, + "additionalProperties": false, + "description": "Recover one caller-owned submission without enumerating other jobs.", + "properties": { + "after_revision": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "After Revision" + }, + "idempotency_key": { + "description": "Caller-generated key binding one logical job submission.", + "maxLength": 256, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._~:-]{0,255}$", + "title": "Idempotency Key", + "type": "string" + }, + "plan_id": { + "description": "Content-addressed plan id.", + "pattern": "^plan:sha256:[0-9a-f]{64}$", + "title": "Plan Id", + "type": "string" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + } + }, + "required": [ + "plan_id", + "idempotency_key" + ], + "title": "JobLookupRequest", + "type": "object" +} diff --git a/docs/schemas/agent/v1/job-manifest.schema.json b/docs/schemas/agent/v1/job-manifest.schema.json new file mode 100644 index 00000000..8531d988 --- /dev/null +++ b/docs/schemas/agent/v1/job-manifest.schema.json @@ -0,0 +1,693 @@ +{ + "$defs": { + "ApiError": { + "additionalProperties": false, + "description": "Structured failure that an agent can inspect without parsing prose.", + "properties": { + "code": { + "description": "Stable, English, machine-readable code.", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Z][A-Z0-9_]*$", + "title": "Code", + "type": "string" + }, + "details": { + "additionalProperties": true, + "description": "Small structured context; large payloads belong in artifacts.", + "title": "Details", + "type": "object" + }, + "message": { + "description": "Human-readable, potentially localized explanation.", + "minLength": 1, + "title": "Message", + "type": "string" + }, + "next_action": { + "anyOf": [ + { + "$ref": "#/$defs/NextAction" + }, + { + "type": "null" + } + ], + "default": null + }, + "retryable": { + "default": false, + "title": "Retryable", + "type": "boolean" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + }, + "stage": { + "$ref": "#/$defs/ErrorStage" + } + }, + "required": [ + "code", + "message", + "stage" + ], + "title": "ApiError", + "type": "object" + }, + "ArtifactDescriptor": { + "additionalProperties": false, + "description": "Metadata and URI for a job output; binary data is never embedded.", + "properties": { + "artifact_id": { + "description": "Artifact id with a stable kind namespace.", + "pattern": "^artifact:[a-z][a-z0-9_-]*:[A-Za-z0-9._~-]+$", + "title": "Artifact Id", + "type": "string" + }, + "created_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Created At" + }, + "format": { + "anyOf": [ + { + "maxLength": 32, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._+-]{0,31}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Format" + }, + "job_id": { + "maxLength": 256, + "minLength": 1, + "title": "Job Id", + "type": "string" + }, + "kind": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-z][a-z0-9_-]{0,127}$", + "title": "Kind", + "type": "string" + }, + "media_type": { + "anyOf": [ + { + "maxLength": 255, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Media Type" + }, + "metadata": { + "additionalProperties": true, + "title": "Metadata", + "type": "object" + }, + "resource_uri": { + "description": "Resolvable canonical job-scoped HHTools artifact URI or portable HTTP(S) URI.", + "minLength": 1, + "pattern": "^(?:https?://(?:\\[(?:(?:(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,7}:|(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,5}(?::[0-9A-Fa-f]{1,4}){1,2}|(?:[0-9A-Fa-f]{1,4}:){1,4}(?::[0-9A-Fa-f]{1,4}){1,3}|(?:[0-9A-Fa-f]{1,4}:){1,3}(?::[0-9A-Fa-f]{1,4}){1,4}|(?:[0-9A-Fa-f]{1,4}:){1,2}(?::[0-9A-Fa-f]{1,4}){1,5}|[0-9A-Fa-f]{1,4}:(?:(?::[0-9A-Fa-f]{1,4}){1,6})|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|:)))\\]|[A-Za-z0-9._~-]+)(?::(?:0|[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:[/?#](?:(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f]{2})|[/?#])*)?|hhtools://jobs/[A-Za-z0-9._~:-]+/artifacts/[A-Za-z0-9._~:-]+)$", + "title": "Resource Uri", + "type": "string" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + }, + "sha256": { + "anyOf": [ + { + "description": "Lower-case SHA-256 content digest.", + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sha256" + }, + "size_bytes": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Size Bytes" + } + }, + "required": [ + "artifact_id", + "job_id", + "kind", + "resource_uri" + ], + "title": "ArtifactDescriptor", + "type": "object" + }, + "ErrorStage": { + "description": "Stable stage in which an API error occurred.", + "enum": [ + "request", + "asset_registration", + "asset_inspection", + "preflight", + "admission", + "execution", + "evaluation", + "artifact", + "internal" + ], + "title": "ErrorStage", + "type": "string" + }, + "JobOutcome": { + "description": "Semantic result of a completed job.", + "enum": [ + "success", + "partial", + "review_required", + "rejected" + ], + "title": "JobOutcome", + "type": "string" + }, + "JobSpecCalibration": { + "additionalProperties": false, + "description": "Exact calibration selected by preflight.", + "properties": { + "calibration_id": { + "description": "Content-addressed calibration id.", + "pattern": "^cal:sha256:[0-9a-f]{64}$", + "title": "Calibration Id", + "type": "string" + }, + "sha256": { + "description": "Lower-case SHA-256 content digest.", + "pattern": "^[0-9a-f]{64}$", + "title": "Sha256", + "type": "string" + } + }, + "required": [ + "calibration_id", + "sha256" + ], + "title": "JobSpecCalibration", + "type": "object" + }, + "JobSpecInput": { + "additionalProperties": false, + "description": "Content-bound input reference used by an executable job.", + "properties": { + "asset_id": { + "description": "Content-addressed asset id.", + "pattern": "^asset:sha256:[0-9a-f]{64}$", + "title": "Asset Id", + "type": "string" + }, + "sha256": { + "description": "Lower-case SHA-256 content digest.", + "pattern": "^[0-9a-f]{64}$", + "title": "Sha256", + "type": "string" + } + }, + "required": [ + "asset_id", + "sha256" + ], + "title": "JobSpecInput", + "type": "object" + }, + "JobSpecKind": { + "enum": [ + "retarget", + "batch_retarget" + ], + "title": "JobSpecKind", + "type": "string" + }, + "JobSpecProvenance": { + "additionalProperties": false, + "description": "Code, dependency, and execution-device identity for reproduction.", + "properties": { + "cuda": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cuda" + }, + "dependencies": { + "additionalProperties": { + "type": "string" + }, + "title": "Dependencies", + "type": "object" + }, + "device": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Device" + }, + "hhtools_dirty": { + "title": "Hhtools Dirty", + "type": "boolean" + }, + "hhtools_git_commit": { + "maxLength": 128, + "minLength": 1, + "title": "Hhtools Git Commit", + "type": "string" + }, + "newton": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Newton" + }, + "platform": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Platform" + }, + "python": { + "maxLength": 128, + "minLength": 1, + "title": "Python", + "type": "string" + }, + "pytorch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pytorch" + } + }, + "required": [ + "hhtools_git_commit", + "hhtools_dirty", + "python" + ], + "title": "JobSpecProvenance", + "type": "object" + }, + "JobSpecRobot": { + "additionalProperties": false, + "description": "Robot identity and exact configuration used by the job.", + "properties": { + "asset_id": { + "description": "Content-addressed asset id.", + "pattern": "^asset:sha256:[0-9a-f]{64}$", + "title": "Asset Id", + "type": "string" + }, + "config_sha256": { + "description": "Lower-case SHA-256 content digest.", + "pattern": "^[0-9a-f]{64}$", + "title": "Config Sha256", + "type": "string" + }, + "robot_id": { + "maxLength": 256, + "minLength": 1, + "title": "Robot Id", + "type": "string" + } + }, + "required": [ + "robot_id", + "asset_id", + "config_sha256" + ], + "title": "JobSpecRobot", + "type": "object" + }, + "JobSpecV2": { + "additionalProperties": false, + "description": "Immutable, preflight-resolved execution identity for a retarget job.", + "properties": { + "backend": { + "maxLength": 128, + "minLength": 1, + "title": "Backend", + "type": "string" + }, + "calibration": { + "anyOf": [ + { + "$ref": "#/$defs/JobSpecCalibration" + }, + { + "type": "null" + } + ] + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "effective_parameters": { + "additionalProperties": true, + "title": "Effective Parameters", + "type": "object" + }, + "inputs": { + "items": { + "$ref": "#/$defs/JobSpecInput" + }, + "minItems": 1, + "title": "Inputs", + "type": "array" + }, + "kind": { + "$ref": "#/$defs/JobSpecKind" + }, + "output_policy": { + "$ref": "#/$defs/OutputPolicy" + }, + "plan_id": { + "description": "Content-addressed plan id.", + "pattern": "^plan:sha256:[0-9a-f]{64}$", + "title": "Plan Id", + "type": "string" + }, + "provenance": { + "$ref": "#/$defs/JobSpecProvenance" + }, + "robot": { + "$ref": "#/$defs/JobSpecRobot" + }, + "schema_version": { + "const": 2, + "default": 2, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "kind", + "plan_id", + "inputs", + "robot", + "calibration", + "backend", + "output_policy", + "provenance", + "created_at" + ], + "title": "JobSpecV2", + "type": "object" + }, + "JobState": { + "description": "Execution lifecycle, independent from output quality.", + "enum": [ + "queued", + "running", + "completed", + "failed", + "cancelled" + ], + "title": "JobState", + "type": "string" + }, + "NextAction": { + "additionalProperties": false, + "description": "An explicit recovery or continuation step for an agent or human.", + "properties": { + "action": { + "description": "Stable, English action identifier.", + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$", + "title": "Action", + "type": "string" + }, + "actor": { + "enum": [ + "agent", + "human", + "system" + ], + "title": "Actor", + "type": "string" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional human-readable instruction.", + "title": "Message" + }, + "parameters": { + "additionalProperties": true, + "description": "Structured parameters needed to perform the action.", + "title": "Parameters", + "type": "object" + }, + "url": { + "anyOf": [ + { + "pattern": "^(?:(?:/(?:\\?(?:(?:calibrate|panel|robot|view)=[^&#\\s]{0,256}(?:&(?:calibrate|panel|robot|view)=[^&#\\s]{0,256})*)?)?|http://(?:127\\.0\\.0\\.1|localhost|\\[::1\\]):(?:0|[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])/(?:\\?(?:(?:calibrate|panel|robot|view)=[^&#\\s]{0,256}(?:&(?:calibrate|panel|robot|view)=[^&#\\s]{0,256})*)?)?)|https://(?:\\[(?:(?:(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,7}:|(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,5}(?::[0-9A-Fa-f]{1,4}){1,2}|(?:[0-9A-Fa-f]{1,4}:){1,4}(?::[0-9A-Fa-f]{1,4}){1,3}|(?:[0-9A-Fa-f]{1,4}:){1,3}(?::[0-9A-Fa-f]{1,4}){1,4}|(?:[0-9A-Fa-f]{1,4}:){1,2}(?::[0-9A-Fa-f]{1,4}){1,5}|[0-9A-Fa-f]{1,4}:(?:(?::[0-9A-Fa-f]{1,4}){1,6})|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|:)))\\]|[A-Za-z0-9._~-]+)(?::(?:0|[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:[/?#](?:(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f]{2})|[/?#])*)?)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional allowlisted local calibration UI route or portable HTTPS documentation URL.", + "title": "Url" + } + }, + "required": [ + "actor", + "action" + ], + "title": "NextAction", + "type": "object" + }, + "OutputPolicy": { + "enum": [ + "create_new", + "fail_if_exists", + "overwrite" + ], + "title": "OutputPolicy", + "type": "string" + }, + "SchemaVersion": { + "description": "Version of the transport-neutral HHTools agent schema.", + "enum": [ + "1.0" + ], + "title": "SchemaVersion", + "type": "string" + } + }, + "additionalProperties": false, + "description": "Terminal audit record.\n\n``artifacts`` lists every artifact published before the manifest itself;\nself-inclusion would make a content hash recursively impossible.", + "properties": { + "artifacts": { + "items": { + "$ref": "#/$defs/ArtifactDescriptor" + }, + "maxItems": 10000, + "title": "Artifacts", + "type": "array" + }, + "attempt": { + "default": 1, + "minimum": 1, + "title": "Attempt", + "type": "integer" + }, + "cancellation_requested": { + "default": false, + "title": "Cancellation Requested", + "type": "boolean" + }, + "completed_at": { + "format": "date-time", + "title": "Completed At", + "type": "string" + }, + "error": { + "anyOf": [ + { + "$ref": "#/$defs/ApiError" + }, + { + "type": "null" + } + ], + "default": null + }, + "execution_provenance": { + "additionalProperties": true, + "title": "Execution Provenance", + "type": "object" + }, + "job_id": { + "maxLength": 256, + "minLength": 1, + "title": "Job Id", + "type": "string" + }, + "job_spec": { + "$ref": "#/$defs/JobSpecV2" + }, + "outcome": { + "anyOf": [ + { + "$ref": "#/$defs/JobOutcome" + }, + { + "type": "null" + } + ], + "default": null + }, + "parent_job_id": { + "anyOf": [ + { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Parent Job Id" + }, + "plan_id": { + "description": "Content-addressed plan id.", + "pattern": "^plan:sha256:[0-9a-f]{64}$", + "title": "Plan Id", + "type": "string" + }, + "root_job_id": { + "anyOf": [ + { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Root Job Id" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + }, + "started_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Started At" + }, + "state": { + "$ref": "#/$defs/JobState" + }, + "submitted_at": { + "format": "date-time", + "title": "Submitted At", + "type": "string" + }, + "summary": { + "additionalProperties": true, + "title": "Summary", + "type": "object" + } + }, + "required": [ + "job_id", + "plan_id", + "state", + "job_spec", + "submitted_at", + "completed_at" + ], + "title": "JobManifest", + "type": "object" +} diff --git a/docs/schemas/agent/v1/job-retry-request.schema.json b/docs/schemas/agent/v1/job-retry-request.schema.json new file mode 100644 index 00000000..0283d486 --- /dev/null +++ b/docs/schemas/agent/v1/job-retry-request.schema.json @@ -0,0 +1,33 @@ +{ + "$defs": { + "SchemaVersion": { + "description": "Version of the transport-neutral HHTools agent schema.", + "enum": [ + "1.0" + ], + "title": "SchemaVersion", + "type": "string" + } + }, + "additionalProperties": false, + "description": "Create a child attempt without mutating the terminal parent job.", + "properties": { + "idempotency_key": { + "description": "Caller-generated key binding one logical job submission.", + "maxLength": 256, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._~:-]{0,255}$", + "title": "Idempotency Key", + "type": "string" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + } + }, + "required": [ + "idempotency_key" + ], + "title": "JobRetryRequest", + "type": "object" +} diff --git a/docs/schemas/agent/v1/job-spec-v2.schema.json b/docs/schemas/agent/v1/job-spec-v2.schema.json new file mode 100644 index 00000000..55b98ca4 --- /dev/null +++ b/docs/schemas/agent/v1/job-spec-v2.schema.json @@ -0,0 +1,271 @@ +{ + "$defs": { + "JobSpecCalibration": { + "additionalProperties": false, + "description": "Exact calibration selected by preflight.", + "properties": { + "calibration_id": { + "description": "Content-addressed calibration id.", + "pattern": "^cal:sha256:[0-9a-f]{64}$", + "title": "Calibration Id", + "type": "string" + }, + "sha256": { + "description": "Lower-case SHA-256 content digest.", + "pattern": "^[0-9a-f]{64}$", + "title": "Sha256", + "type": "string" + } + }, + "required": [ + "calibration_id", + "sha256" + ], + "title": "JobSpecCalibration", + "type": "object" + }, + "JobSpecInput": { + "additionalProperties": false, + "description": "Content-bound input reference used by an executable job.", + "properties": { + "asset_id": { + "description": "Content-addressed asset id.", + "pattern": "^asset:sha256:[0-9a-f]{64}$", + "title": "Asset Id", + "type": "string" + }, + "sha256": { + "description": "Lower-case SHA-256 content digest.", + "pattern": "^[0-9a-f]{64}$", + "title": "Sha256", + "type": "string" + } + }, + "required": [ + "asset_id", + "sha256" + ], + "title": "JobSpecInput", + "type": "object" + }, + "JobSpecKind": { + "enum": [ + "retarget", + "batch_retarget" + ], + "title": "JobSpecKind", + "type": "string" + }, + "JobSpecProvenance": { + "additionalProperties": false, + "description": "Code, dependency, and execution-device identity for reproduction.", + "properties": { + "cuda": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cuda" + }, + "dependencies": { + "additionalProperties": { + "type": "string" + }, + "title": "Dependencies", + "type": "object" + }, + "device": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Device" + }, + "hhtools_dirty": { + "title": "Hhtools Dirty", + "type": "boolean" + }, + "hhtools_git_commit": { + "maxLength": 128, + "minLength": 1, + "title": "Hhtools Git Commit", + "type": "string" + }, + "newton": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Newton" + }, + "platform": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Platform" + }, + "python": { + "maxLength": 128, + "minLength": 1, + "title": "Python", + "type": "string" + }, + "pytorch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pytorch" + } + }, + "required": [ + "hhtools_git_commit", + "hhtools_dirty", + "python" + ], + "title": "JobSpecProvenance", + "type": "object" + }, + "JobSpecRobot": { + "additionalProperties": false, + "description": "Robot identity and exact configuration used by the job.", + "properties": { + "asset_id": { + "description": "Content-addressed asset id.", + "pattern": "^asset:sha256:[0-9a-f]{64}$", + "title": "Asset Id", + "type": "string" + }, + "config_sha256": { + "description": "Lower-case SHA-256 content digest.", + "pattern": "^[0-9a-f]{64}$", + "title": "Config Sha256", + "type": "string" + }, + "robot_id": { + "maxLength": 256, + "minLength": 1, + "title": "Robot Id", + "type": "string" + } + }, + "required": [ + "robot_id", + "asset_id", + "config_sha256" + ], + "title": "JobSpecRobot", + "type": "object" + }, + "OutputPolicy": { + "enum": [ + "create_new", + "fail_if_exists", + "overwrite" + ], + "title": "OutputPolicy", + "type": "string" + } + }, + "additionalProperties": false, + "description": "Immutable, preflight-resolved execution identity for a retarget job.", + "properties": { + "backend": { + "maxLength": 128, + "minLength": 1, + "title": "Backend", + "type": "string" + }, + "calibration": { + "anyOf": [ + { + "$ref": "#/$defs/JobSpecCalibration" + }, + { + "type": "null" + } + ] + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "effective_parameters": { + "additionalProperties": true, + "title": "Effective Parameters", + "type": "object" + }, + "inputs": { + "items": { + "$ref": "#/$defs/JobSpecInput" + }, + "minItems": 1, + "title": "Inputs", + "type": "array" + }, + "kind": { + "$ref": "#/$defs/JobSpecKind" + }, + "output_policy": { + "$ref": "#/$defs/OutputPolicy" + }, + "plan_id": { + "description": "Content-addressed plan id.", + "pattern": "^plan:sha256:[0-9a-f]{64}$", + "title": "Plan Id", + "type": "string" + }, + "provenance": { + "$ref": "#/$defs/JobSpecProvenance" + }, + "robot": { + "$ref": "#/$defs/JobSpecRobot" + }, + "schema_version": { + "const": 2, + "default": 2, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "kind", + "plan_id", + "inputs", + "robot", + "calibration", + "backend", + "output_policy", + "provenance", + "created_at" + ], + "title": "JobSpecV2", + "type": "object" +} diff --git a/docs/schemas/agent/v1/job-start-request.schema.json b/docs/schemas/agent/v1/job-start-request.schema.json new file mode 100644 index 00000000..da59a69d --- /dev/null +++ b/docs/schemas/agent/v1/job-start-request.schema.json @@ -0,0 +1,40 @@ +{ + "$defs": { + "SchemaVersion": { + "description": "Version of the transport-neutral HHTools agent schema.", + "enum": [ + "1.0" + ], + "title": "SchemaVersion", + "type": "string" + } + }, + "additionalProperties": false, + "description": "Submit one already-preflighted immutable retarget plan.", + "properties": { + "idempotency_key": { + "description": "Caller-generated key binding one logical job submission.", + "maxLength": 256, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._~:-]{0,255}$", + "title": "Idempotency Key", + "type": "string" + }, + "plan_id": { + "description": "Content-addressed plan id.", + "pattern": "^plan:sha256:[0-9a-f]{64}$", + "title": "Plan Id", + "type": "string" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + } + }, + "required": [ + "plan_id", + "idempotency_key" + ], + "title": "JobStartRequest", + "type": "object" +} diff --git a/docs/schemas/agent/v1/legacy-job-upgrade-request.schema.json b/docs/schemas/agent/v1/legacy-job-upgrade-request.schema.json new file mode 100644 index 00000000..57e4f0f4 --- /dev/null +++ b/docs/schemas/agent/v1/legacy-job-upgrade-request.schema.json @@ -0,0 +1,31 @@ +{ + "$defs": { + "SchemaVersion": { + "description": "Version of the transport-neutral HHTools agent schema.", + "enum": [ + "1.0" + ], + "title": "SchemaVersion", + "type": "string" + } + }, + "additionalProperties": false, + "description": "One bounded JSON object containing a JobSpec v1 or download wrapper.\n\nThe migration service remains responsible for the stricter v1 shape,\ndepth, node-count, byte-size, allowlisted-root, and content checks. This\nwrapper only gives REST and JSON CLI a stable, versioned transport shape.", + "properties": { + "payload": { + "additionalProperties": true, + "description": "Raw JobSpec v1 document or an existing single-job download wrapper.", + "title": "Payload", + "type": "object" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + } + }, + "required": [ + "payload" + ], + "title": "LegacyJobUpgradeRequest", + "type": "object" +} diff --git a/docs/schemas/agent/v1/legacy-job-upgrade-response.schema.json b/docs/schemas/agent/v1/legacy-job-upgrade-response.schema.json new file mode 100644 index 00000000..6baa97f0 --- /dev/null +++ b/docs/schemas/agent/v1/legacy-job-upgrade-response.schema.json @@ -0,0 +1,802 @@ +{ + "$defs": { + "ApiError": { + "additionalProperties": false, + "description": "Structured failure that an agent can inspect without parsing prose.", + "properties": { + "code": { + "description": "Stable, English, machine-readable code.", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Z][A-Z0-9_]*$", + "title": "Code", + "type": "string" + }, + "details": { + "additionalProperties": true, + "description": "Small structured context; large payloads belong in artifacts.", + "title": "Details", + "type": "object" + }, + "message": { + "description": "Human-readable, potentially localized explanation.", + "minLength": 1, + "title": "Message", + "type": "string" + }, + "next_action": { + "anyOf": [ + { + "$ref": "#/$defs/NextAction" + }, + { + "type": "null" + } + ], + "default": null + }, + "retryable": { + "default": false, + "title": "Retryable", + "type": "boolean" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + }, + "stage": { + "$ref": "#/$defs/ErrorStage" + } + }, + "required": [ + "code", + "message", + "stage" + ], + "title": "ApiError", + "type": "object" + }, + "ErrorStage": { + "description": "Stable stage in which an API error occurred.", + "enum": [ + "request", + "asset_registration", + "asset_inspection", + "preflight", + "admission", + "execution", + "evaluation", + "artifact", + "internal" + ], + "title": "ErrorStage", + "type": "string" + }, + "JobSpecCalibration": { + "additionalProperties": false, + "description": "Exact calibration selected by preflight.", + "properties": { + "calibration_id": { + "description": "Content-addressed calibration id.", + "pattern": "^cal:sha256:[0-9a-f]{64}$", + "title": "Calibration Id", + "type": "string" + }, + "sha256": { + "description": "Lower-case SHA-256 content digest.", + "pattern": "^[0-9a-f]{64}$", + "title": "Sha256", + "type": "string" + } + }, + "required": [ + "calibration_id", + "sha256" + ], + "title": "JobSpecCalibration", + "type": "object" + }, + "JobSpecInput": { + "additionalProperties": false, + "description": "Content-bound input reference used by an executable job.", + "properties": { + "asset_id": { + "description": "Content-addressed asset id.", + "pattern": "^asset:sha256:[0-9a-f]{64}$", + "title": "Asset Id", + "type": "string" + }, + "sha256": { + "description": "Lower-case SHA-256 content digest.", + "pattern": "^[0-9a-f]{64}$", + "title": "Sha256", + "type": "string" + } + }, + "required": [ + "asset_id", + "sha256" + ], + "title": "JobSpecInput", + "type": "object" + }, + "JobSpecKind": { + "enum": [ + "retarget", + "batch_retarget" + ], + "title": "JobSpecKind", + "type": "string" + }, + "JobSpecProvenance": { + "additionalProperties": false, + "description": "Code, dependency, and execution-device identity for reproduction.", + "properties": { + "cuda": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cuda" + }, + "dependencies": { + "additionalProperties": { + "type": "string" + }, + "title": "Dependencies", + "type": "object" + }, + "device": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Device" + }, + "hhtools_dirty": { + "title": "Hhtools Dirty", + "type": "boolean" + }, + "hhtools_git_commit": { + "maxLength": 128, + "minLength": 1, + "title": "Hhtools Git Commit", + "type": "string" + }, + "newton": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Newton" + }, + "platform": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Platform" + }, + "python": { + "maxLength": 128, + "minLength": 1, + "title": "Python", + "type": "string" + }, + "pytorch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pytorch" + } + }, + "required": [ + "hhtools_git_commit", + "hhtools_dirty", + "python" + ], + "title": "JobSpecProvenance", + "type": "object" + }, + "JobSpecRobot": { + "additionalProperties": false, + "description": "Robot identity and exact configuration used by the job.", + "properties": { + "asset_id": { + "description": "Content-addressed asset id.", + "pattern": "^asset:sha256:[0-9a-f]{64}$", + "title": "Asset Id", + "type": "string" + }, + "config_sha256": { + "description": "Lower-case SHA-256 content digest.", + "pattern": "^[0-9a-f]{64}$", + "title": "Config Sha256", + "type": "string" + }, + "robot_id": { + "maxLength": 256, + "minLength": 1, + "title": "Robot Id", + "type": "string" + } + }, + "required": [ + "robot_id", + "asset_id", + "config_sha256" + ], + "title": "JobSpecRobot", + "type": "object" + }, + "JobSpecV2": { + "additionalProperties": false, + "description": "Immutable, preflight-resolved execution identity for a retarget job.", + "properties": { + "backend": { + "maxLength": 128, + "minLength": 1, + "title": "Backend", + "type": "string" + }, + "calibration": { + "anyOf": [ + { + "$ref": "#/$defs/JobSpecCalibration" + }, + { + "type": "null" + } + ] + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "effective_parameters": { + "additionalProperties": true, + "title": "Effective Parameters", + "type": "object" + }, + "inputs": { + "items": { + "$ref": "#/$defs/JobSpecInput" + }, + "minItems": 1, + "title": "Inputs", + "type": "array" + }, + "kind": { + "$ref": "#/$defs/JobSpecKind" + }, + "output_policy": { + "$ref": "#/$defs/OutputPolicy" + }, + "plan_id": { + "description": "Content-addressed plan id.", + "pattern": "^plan:sha256:[0-9a-f]{64}$", + "title": "Plan Id", + "type": "string" + }, + "provenance": { + "$ref": "#/$defs/JobSpecProvenance" + }, + "robot": { + "$ref": "#/$defs/JobSpecRobot" + }, + "schema_version": { + "const": 2, + "default": 2, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "kind", + "plan_id", + "inputs", + "robot", + "calibration", + "backend", + "output_policy", + "provenance", + "created_at" + ], + "title": "JobSpecV2", + "type": "object" + }, + "LegacyMigrationReceipt": { + "additionalProperties": false, + "description": "Portable proof of how one canonical v1 document became JobSpec v2.", + "properties": { + "canonical_v1_sha256": { + "description": "Lower-case SHA-256 content digest.", + "pattern": "^[0-9a-f]{64}$", + "title": "Canonical V1 Sha256", + "type": "string" + }, + "job_spec_sha256": { + "description": "Lower-case SHA-256 content digest.", + "pattern": "^[0-9a-f]{64}$", + "title": "Job Spec Sha256", + "type": "string" + }, + "motion_asset_id": { + "description": "Content-addressed asset id.", + "pattern": "^asset:sha256:[0-9a-f]{64}$", + "title": "Motion Asset Id", + "type": "string" + }, + "output_format": { + "const": "csv", + "default": "csv", + "title": "Output Format", + "type": "string" + }, + "output_policy": { + "const": "create_new", + "default": "create_new", + "title": "Output Policy", + "type": "string" + }, + "plan_id": { + "description": "Content-addressed plan id.", + "pattern": "^plan:sha256:[0-9a-f]{64}$", + "title": "Plan Id", + "type": "string" + }, + "robot_asset_id": { + "description": "Content-addressed asset id.", + "pattern": "^asset:sha256:[0-9a-f]{64}$", + "title": "Robot Asset Id", + "type": "string" + }, + "schema_version": { + "const": "1.0", + "default": "1.0", + "title": "Schema Version", + "type": "string" + }, + "semantics": { + "const": "hhtools.legacy-job-upgrade.v1", + "default": "hhtools.legacy-job-upgrade.v1", + "title": "Semantics", + "type": "string" + }, + "source_schema_version": { + "const": 1, + "default": 1, + "title": "Source Schema Version", + "type": "integer" + }, + "warnings": { + "default": [], + "items": { + "description": "Stable, English, machine-readable code.", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Z][A-Z0-9_]*$", + "type": "string" + }, + "title": "Warnings", + "type": "array" + } + }, + "required": [ + "canonical_v1_sha256", + "motion_asset_id", + "robot_asset_id", + "plan_id", + "job_spec_sha256" + ], + "title": "LegacyMigrationReceipt", + "type": "object" + }, + "NextAction": { + "additionalProperties": false, + "description": "An explicit recovery or continuation step for an agent or human.", + "properties": { + "action": { + "description": "Stable, English action identifier.", + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$", + "title": "Action", + "type": "string" + }, + "actor": { + "enum": [ + "agent", + "human", + "system" + ], + "title": "Actor", + "type": "string" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional human-readable instruction.", + "title": "Message" + }, + "parameters": { + "additionalProperties": true, + "description": "Structured parameters needed to perform the action.", + "title": "Parameters", + "type": "object" + }, + "url": { + "anyOf": [ + { + "pattern": "^(?:(?:/(?:\\?(?:(?:calibrate|panel|robot|view)=[^&#\\s]{0,256}(?:&(?:calibrate|panel|robot|view)=[^&#\\s]{0,256})*)?)?|http://(?:127\\.0\\.0\\.1|localhost|\\[::1\\]):(?:0|[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])/(?:\\?(?:(?:calibrate|panel|robot|view)=[^&#\\s]{0,256}(?:&(?:calibrate|panel|robot|view)=[^&#\\s]{0,256})*)?)?)|https://(?:\\[(?:(?:(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,7}:|(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,5}(?::[0-9A-Fa-f]{1,4}){1,2}|(?:[0-9A-Fa-f]{1,4}:){1,4}(?::[0-9A-Fa-f]{1,4}){1,3}|(?:[0-9A-Fa-f]{1,4}:){1,3}(?::[0-9A-Fa-f]{1,4}){1,4}|(?:[0-9A-Fa-f]{1,4}:){1,2}(?::[0-9A-Fa-f]{1,4}){1,5}|[0-9A-Fa-f]{1,4}:(?:(?::[0-9A-Fa-f]{1,4}){1,6})|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|:)))\\]|[A-Za-z0-9._~-]+)(?::(?:0|[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:[/?#](?:(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f]{2})|[/?#])*)?)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional allowlisted local calibration UI route or portable HTTPS documentation URL.", + "title": "Url" + } + }, + "required": [ + "actor", + "action" + ], + "title": "NextAction", + "type": "object" + }, + "OutputPolicy": { + "enum": [ + "create_new", + "fail_if_exists", + "overwrite" + ], + "title": "OutputPolicy", + "type": "string" + }, + "PreflightCheck": { + "additionalProperties": false, + "description": "One deterministic precondition evaluated by the service.", + "properties": { + "code": { + "description": "Stable, English, machine-readable code.", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Z][A-Z0-9_]*$", + "title": "Code", + "type": "string" + }, + "details": { + "additionalProperties": true, + "title": "Details", + "type": "object" + }, + "level": { + "$ref": "#/$defs/PreflightCheckLevel" + }, + "message": { + "minLength": 1, + "title": "Message", + "type": "string" + }, + "next_action": { + "anyOf": [ + { + "$ref": "#/$defs/NextAction" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "code", + "level", + "message" + ], + "title": "PreflightCheck", + "type": "object" + }, + "PreflightCheckLevel": { + "enum": [ + "pass", + "warning", + "error" + ], + "title": "PreflightCheckLevel", + "type": "string" + }, + "PreflightResponse": { + "additionalProperties": false, + "description": "Preflight result; only ``ready`` responses expose a runnable plan.", + "properties": { + "checks": { + "items": { + "$ref": "#/$defs/PreflightCheck" + }, + "title": "Checks", + "type": "array" + }, + "error": { + "anyOf": [ + { + "$ref": "#/$defs/ApiError" + }, + { + "type": "null" + } + ], + "default": null + }, + "plan": { + "anyOf": [ + { + "$ref": "#/$defs/RetargetPlan" + }, + { + "type": "null" + } + ], + "default": null + }, + "recommended_backend": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Recommended Backend" + }, + "request_id": { + "maxLength": 256, + "minLength": 1, + "title": "Request Id", + "type": "string" + }, + "required_actions": { + "items": { + "$ref": "#/$defs/NextAction" + }, + "title": "Required Actions", + "type": "array" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + }, + "status": { + "$ref": "#/$defs/PreflightStatus" + } + }, + "required": [ + "request_id", + "status" + ], + "title": "PreflightResponse", + "type": "object" + }, + "PreflightStatus": { + "enum": [ + "ready", + "human_action_required", + "rejected" + ], + "title": "PreflightStatus", + "type": "string" + }, + "RetargetPlan": { + "additionalProperties": false, + "description": "Fully resolved, content-bound plan accepted by ``start_retarget``.", + "properties": { + "backend": { + "maxLength": 128, + "minLength": 1, + "title": "Backend", + "type": "string" + }, + "calibration_digest": { + "anyOf": [ + { + "description": "Lower-case SHA-256 content digest.", + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Calibration Digest" + }, + "calibration_id": { + "anyOf": [ + { + "description": "Content-addressed calibration id.", + "pattern": "^cal:sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Calibration Id" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "expires_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Expires At" + }, + "input_digest": { + "description": "Lower-case SHA-256 content digest.", + "pattern": "^[0-9a-f]{64}$", + "title": "Input Digest", + "type": "string" + }, + "motion_asset_id": { + "description": "Content-addressed asset id.", + "pattern": "^asset:sha256:[0-9a-f]{64}$", + "title": "Motion Asset Id", + "type": "string" + }, + "output_format": { + "maxLength": 32, + "minLength": 1, + "title": "Output Format", + "type": "string" + }, + "output_policy": { + "$ref": "#/$defs/OutputPolicy" + }, + "parameters": { + "additionalProperties": true, + "title": "Parameters", + "type": "object" + }, + "plan_id": { + "description": "Content-addressed plan id.", + "pattern": "^plan:sha256:[0-9a-f]{64}$", + "title": "Plan Id", + "type": "string" + }, + "robot_asset_id": { + "description": "Content-addressed asset id.", + "pattern": "^asset:sha256:[0-9a-f]{64}$", + "title": "Robot Asset Id", + "type": "string" + }, + "robot_digest": { + "description": "Lower-case SHA-256 content digest.", + "pattern": "^[0-9a-f]{64}$", + "title": "Robot Digest", + "type": "string" + }, + "robot_id": { + "maxLength": 256, + "minLength": 1, + "title": "Robot Id", + "type": "string" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + } + }, + "required": [ + "plan_id", + "created_at", + "motion_asset_id", + "robot_id", + "robot_asset_id", + "backend", + "output_format", + "output_policy", + "input_digest", + "robot_digest" + ], + "title": "RetargetPlan", + "type": "object" + }, + "SchemaVersion": { + "description": "Version of the transport-neutral HHTools agent schema.", + "enum": [ + "1.0" + ], + "title": "SchemaVersion", + "type": "string" + } + }, + "additionalProperties": false, + "description": "Non-executing upgrade result tied to its authoritative preflight.", + "properties": { + "job_spec": { + "anyOf": [ + { + "$ref": "#/$defs/JobSpecV2" + }, + { + "type": "null" + } + ], + "default": null + }, + "preflight": { + "$ref": "#/$defs/PreflightResponse" + }, + "receipt": { + "anyOf": [ + { + "$ref": "#/$defs/LegacyMigrationReceipt" + }, + { + "type": "null" + } + ], + "default": null + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + } + }, + "required": [ + "preflight" + ], + "title": "LegacyJobUpgradeResponse", + "type": "object" +} diff --git a/docs/schemas/agent/v1/legacy-migration-receipt.schema.json b/docs/schemas/agent/v1/legacy-migration-receipt.schema.json new file mode 100644 index 00000000..36fa3535 --- /dev/null +++ b/docs/schemas/agent/v1/legacy-migration-receipt.schema.json @@ -0,0 +1,87 @@ +{ + "additionalProperties": false, + "description": "Portable proof of how one canonical v1 document became JobSpec v2.", + "properties": { + "canonical_v1_sha256": { + "description": "Lower-case SHA-256 content digest.", + "pattern": "^[0-9a-f]{64}$", + "title": "Canonical V1 Sha256", + "type": "string" + }, + "job_spec_sha256": { + "description": "Lower-case SHA-256 content digest.", + "pattern": "^[0-9a-f]{64}$", + "title": "Job Spec Sha256", + "type": "string" + }, + "motion_asset_id": { + "description": "Content-addressed asset id.", + "pattern": "^asset:sha256:[0-9a-f]{64}$", + "title": "Motion Asset Id", + "type": "string" + }, + "output_format": { + "const": "csv", + "default": "csv", + "title": "Output Format", + "type": "string" + }, + "output_policy": { + "const": "create_new", + "default": "create_new", + "title": "Output Policy", + "type": "string" + }, + "plan_id": { + "description": "Content-addressed plan id.", + "pattern": "^plan:sha256:[0-9a-f]{64}$", + "title": "Plan Id", + "type": "string" + }, + "robot_asset_id": { + "description": "Content-addressed asset id.", + "pattern": "^asset:sha256:[0-9a-f]{64}$", + "title": "Robot Asset Id", + "type": "string" + }, + "schema_version": { + "const": "1.0", + "default": "1.0", + "title": "Schema Version", + "type": "string" + }, + "semantics": { + "const": "hhtools.legacy-job-upgrade.v1", + "default": "hhtools.legacy-job-upgrade.v1", + "title": "Semantics", + "type": "string" + }, + "source_schema_version": { + "const": 1, + "default": 1, + "title": "Source Schema Version", + "type": "integer" + }, + "warnings": { + "default": [], + "items": { + "description": "Stable, English, machine-readable code.", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Z][A-Z0-9_]*$", + "type": "string" + }, + "title": "Warnings", + "type": "array" + } + }, + "required": [ + "canonical_v1_sha256", + "motion_asset_id", + "robot_asset_id", + "plan_id", + "job_spec_sha256" + ], + "title": "LegacyMigrationReceipt", + "type": "object" +} diff --git a/docs/schemas/agent/v1/preflight-response.schema.json b/docs/schemas/agent/v1/preflight-response.schema.json new file mode 100644 index 00000000..6cf53f84 --- /dev/null +++ b/docs/schemas/agent/v1/preflight-response.schema.json @@ -0,0 +1,415 @@ +{ + "$defs": { + "ApiError": { + "additionalProperties": false, + "description": "Structured failure that an agent can inspect without parsing prose.", + "properties": { + "code": { + "description": "Stable, English, machine-readable code.", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Z][A-Z0-9_]*$", + "title": "Code", + "type": "string" + }, + "details": { + "additionalProperties": true, + "description": "Small structured context; large payloads belong in artifacts.", + "title": "Details", + "type": "object" + }, + "message": { + "description": "Human-readable, potentially localized explanation.", + "minLength": 1, + "title": "Message", + "type": "string" + }, + "next_action": { + "anyOf": [ + { + "$ref": "#/$defs/NextAction" + }, + { + "type": "null" + } + ], + "default": null + }, + "retryable": { + "default": false, + "title": "Retryable", + "type": "boolean" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + }, + "stage": { + "$ref": "#/$defs/ErrorStage" + } + }, + "required": [ + "code", + "message", + "stage" + ], + "title": "ApiError", + "type": "object" + }, + "ErrorStage": { + "description": "Stable stage in which an API error occurred.", + "enum": [ + "request", + "asset_registration", + "asset_inspection", + "preflight", + "admission", + "execution", + "evaluation", + "artifact", + "internal" + ], + "title": "ErrorStage", + "type": "string" + }, + "NextAction": { + "additionalProperties": false, + "description": "An explicit recovery or continuation step for an agent or human.", + "properties": { + "action": { + "description": "Stable, English action identifier.", + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$", + "title": "Action", + "type": "string" + }, + "actor": { + "enum": [ + "agent", + "human", + "system" + ], + "title": "Actor", + "type": "string" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional human-readable instruction.", + "title": "Message" + }, + "parameters": { + "additionalProperties": true, + "description": "Structured parameters needed to perform the action.", + "title": "Parameters", + "type": "object" + }, + "url": { + "anyOf": [ + { + "pattern": "^(?:(?:/(?:\\?(?:(?:calibrate|panel|robot|view)=[^&#\\s]{0,256}(?:&(?:calibrate|panel|robot|view)=[^&#\\s]{0,256})*)?)?|http://(?:127\\.0\\.0\\.1|localhost|\\[::1\\]):(?:0|[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])/(?:\\?(?:(?:calibrate|panel|robot|view)=[^&#\\s]{0,256}(?:&(?:calibrate|panel|robot|view)=[^&#\\s]{0,256})*)?)?)|https://(?:\\[(?:(?:(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,7}:|(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,5}(?::[0-9A-Fa-f]{1,4}){1,2}|(?:[0-9A-Fa-f]{1,4}:){1,4}(?::[0-9A-Fa-f]{1,4}){1,3}|(?:[0-9A-Fa-f]{1,4}:){1,3}(?::[0-9A-Fa-f]{1,4}){1,4}|(?:[0-9A-Fa-f]{1,4}:){1,2}(?::[0-9A-Fa-f]{1,4}){1,5}|[0-9A-Fa-f]{1,4}:(?:(?::[0-9A-Fa-f]{1,4}){1,6})|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|:)))\\]|[A-Za-z0-9._~-]+)(?::(?:0|[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:[/?#](?:(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f]{2})|[/?#])*)?)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional allowlisted local calibration UI route or portable HTTPS documentation URL.", + "title": "Url" + } + }, + "required": [ + "actor", + "action" + ], + "title": "NextAction", + "type": "object" + }, + "OutputPolicy": { + "enum": [ + "create_new", + "fail_if_exists", + "overwrite" + ], + "title": "OutputPolicy", + "type": "string" + }, + "PreflightCheck": { + "additionalProperties": false, + "description": "One deterministic precondition evaluated by the service.", + "properties": { + "code": { + "description": "Stable, English, machine-readable code.", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Z][A-Z0-9_]*$", + "title": "Code", + "type": "string" + }, + "details": { + "additionalProperties": true, + "title": "Details", + "type": "object" + }, + "level": { + "$ref": "#/$defs/PreflightCheckLevel" + }, + "message": { + "minLength": 1, + "title": "Message", + "type": "string" + }, + "next_action": { + "anyOf": [ + { + "$ref": "#/$defs/NextAction" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "code", + "level", + "message" + ], + "title": "PreflightCheck", + "type": "object" + }, + "PreflightCheckLevel": { + "enum": [ + "pass", + "warning", + "error" + ], + "title": "PreflightCheckLevel", + "type": "string" + }, + "PreflightStatus": { + "enum": [ + "ready", + "human_action_required", + "rejected" + ], + "title": "PreflightStatus", + "type": "string" + }, + "RetargetPlan": { + "additionalProperties": false, + "description": "Fully resolved, content-bound plan accepted by ``start_retarget``.", + "properties": { + "backend": { + "maxLength": 128, + "minLength": 1, + "title": "Backend", + "type": "string" + }, + "calibration_digest": { + "anyOf": [ + { + "description": "Lower-case SHA-256 content digest.", + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Calibration Digest" + }, + "calibration_id": { + "anyOf": [ + { + "description": "Content-addressed calibration id.", + "pattern": "^cal:sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Calibration Id" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "expires_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Expires At" + }, + "input_digest": { + "description": "Lower-case SHA-256 content digest.", + "pattern": "^[0-9a-f]{64}$", + "title": "Input Digest", + "type": "string" + }, + "motion_asset_id": { + "description": "Content-addressed asset id.", + "pattern": "^asset:sha256:[0-9a-f]{64}$", + "title": "Motion Asset Id", + "type": "string" + }, + "output_format": { + "maxLength": 32, + "minLength": 1, + "title": "Output Format", + "type": "string" + }, + "output_policy": { + "$ref": "#/$defs/OutputPolicy" + }, + "parameters": { + "additionalProperties": true, + "title": "Parameters", + "type": "object" + }, + "plan_id": { + "description": "Content-addressed plan id.", + "pattern": "^plan:sha256:[0-9a-f]{64}$", + "title": "Plan Id", + "type": "string" + }, + "robot_asset_id": { + "description": "Content-addressed asset id.", + "pattern": "^asset:sha256:[0-9a-f]{64}$", + "title": "Robot Asset Id", + "type": "string" + }, + "robot_digest": { + "description": "Lower-case SHA-256 content digest.", + "pattern": "^[0-9a-f]{64}$", + "title": "Robot Digest", + "type": "string" + }, + "robot_id": { + "maxLength": 256, + "minLength": 1, + "title": "Robot Id", + "type": "string" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + } + }, + "required": [ + "plan_id", + "created_at", + "motion_asset_id", + "robot_id", + "robot_asset_id", + "backend", + "output_format", + "output_policy", + "input_digest", + "robot_digest" + ], + "title": "RetargetPlan", + "type": "object" + }, + "SchemaVersion": { + "description": "Version of the transport-neutral HHTools agent schema.", + "enum": [ + "1.0" + ], + "title": "SchemaVersion", + "type": "string" + } + }, + "additionalProperties": false, + "description": "Preflight result; only ``ready`` responses expose a runnable plan.", + "properties": { + "checks": { + "items": { + "$ref": "#/$defs/PreflightCheck" + }, + "title": "Checks", + "type": "array" + }, + "error": { + "anyOf": [ + { + "$ref": "#/$defs/ApiError" + }, + { + "type": "null" + } + ], + "default": null + }, + "plan": { + "anyOf": [ + { + "$ref": "#/$defs/RetargetPlan" + }, + { + "type": "null" + } + ], + "default": null + }, + "recommended_backend": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Recommended Backend" + }, + "request_id": { + "maxLength": 256, + "minLength": 1, + "title": "Request Id", + "type": "string" + }, + "required_actions": { + "items": { + "$ref": "#/$defs/NextAction" + }, + "title": "Required Actions", + "type": "array" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + }, + "status": { + "$ref": "#/$defs/PreflightStatus" + } + }, + "required": [ + "request_id", + "status" + ], + "title": "PreflightResponse", + "type": "object" +} diff --git a/docs/schemas/agent/v1/retarget-preflight-request.schema.json b/docs/schemas/agent/v1/retarget-preflight-request.schema.json new file mode 100644 index 00000000..28895c3d --- /dev/null +++ b/docs/schemas/agent/v1/retarget-preflight-request.schema.json @@ -0,0 +1,106 @@ +{ + "$defs": { + "OutputPolicy": { + "enum": [ + "create_new", + "fail_if_exists", + "overwrite" + ], + "title": "OutputPolicy", + "type": "string" + }, + "SchemaVersion": { + "description": "Version of the transport-neutral HHTools agent schema.", + "enum": [ + "1.0" + ], + "title": "SchemaVersion", + "type": "string" + } + }, + "additionalProperties": false, + "description": "User intent that the service resolves into an immutable plan.", + "properties": { + "backend": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Backend id, or null to request a recommendation.", + "title": "Backend" + }, + "calibration_id": { + "anyOf": [ + { + "description": "Content-addressed calibration id.", + "pattern": "^cal:sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Calibration Id" + }, + "motion_asset_id": { + "description": "Content-addressed asset id.", + "pattern": "^asset:sha256:[0-9a-f]{64}$", + "title": "Motion Asset Id", + "type": "string" + }, + "output_format": { + "default": "csv", + "maxLength": 32, + "minLength": 1, + "title": "Output Format", + "type": "string" + }, + "output_policy": { + "$ref": "#/$defs/OutputPolicy", + "default": "create_new" + }, + "parameters": { + "additionalProperties": true, + "description": "Backend-independent and namespaced backend parameters.", + "title": "Parameters", + "type": "object" + }, + "robot_asset_id": { + "anyOf": [ + { + "description": "Content-addressed asset id.", + "pattern": "^asset:sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Registered RobotBundle identity; required for a runnable plan.", + "title": "Robot Asset Id" + }, + "robot_id": { + "maxLength": 256, + "minLength": 1, + "title": "Robot Id", + "type": "string" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + } + }, + "required": [ + "motion_asset_id", + "robot_id" + ], + "title": "RetargetPreflightRequest", + "type": "object" +} diff --git a/docs/schemas/agent/v1/robot-list-response.schema.json b/docs/schemas/agent/v1/robot-list-response.schema.json new file mode 100644 index 00000000..13d8846b --- /dev/null +++ b/docs/schemas/agent/v1/robot-list-response.schema.json @@ -0,0 +1,114 @@ +{ + "$defs": { + "RobotCapability": { + "additionalProperties": false, + "description": "Agent-facing robot availability and calibration summary.", + "properties": { + "available": { + "title": "Available", + "type": "boolean" + }, + "calibrated_references": { + "items": { + "type": "string" + }, + "title": "Calibrated References", + "type": "array" + }, + "display_name": { + "maxLength": 256, + "minLength": 1, + "title": "Display Name", + "type": "string" + }, + "dof_count": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Dof Count" + }, + "has_ik_mapping": { + "title": "Has Ik Mapping", + "type": "boolean" + }, + "has_urdf": { + "title": "Has Urdf", + "type": "boolean" + }, + "robot_id": { + "maxLength": 256, + "minLength": 1, + "title": "Robot Id", + "type": "string" + }, + "scaler_references": { + "items": { + "type": "string" + }, + "title": "Scaler References", + "type": "array" + }, + "supported_references": { + "items": { + "type": "string" + }, + "title": "Supported References", + "type": "array" + }, + "unavailable_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Unavailable Reason" + } + }, + "required": [ + "robot_id", + "display_name", + "available", + "has_urdf", + "has_ik_mapping" + ], + "title": "RobotCapability", + "type": "object" + }, + "SchemaVersion": { + "description": "Version of the transport-neutral HHTools agent schema.", + "enum": [ + "1.0" + ], + "title": "SchemaVersion", + "type": "string" + } + }, + "additionalProperties": false, + "description": "Stable envelope for MCP robot discovery without repeating all capabilities.", + "properties": { + "robots": { + "items": { + "$ref": "#/$defs/RobotCapability" + }, + "title": "Robots", + "type": "array" + }, + "schema_version": { + "$ref": "#/$defs/SchemaVersion", + "default": "1.0" + } + }, + "title": "RobotListResponse", + "type": "object" +} diff --git a/hhtools/cli/_stdio.py b/hhtools/cli/_stdio.py new file mode 100644 index 00000000..29af3001 --- /dev/null +++ b/hhtools/cli/_stdio.py @@ -0,0 +1,30 @@ +"""Cross-platform standard-stream configuration for the command-line app.""" + +from __future__ import annotations + +import sys +from typing import TextIO + + +def _reconfigure_utf8(stream: TextIO | None) -> None: + """Use UTF-8 when the active stream supports runtime reconfiguration.""" + if stream is None: + return + reconfigure = getattr(stream, "reconfigure", None) + if not callable(reconfigure): + # Test runners and embedded hosts commonly replace stdio with StringIO. + return + try: + reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, OSError, ValueError): + # A closed or host-owned stream cannot be reconfigured safely. + return + + +def configure_utf8_stdio() -> None: + """Make CLI output deterministic on Windows and when redirected to a pipe.""" + _reconfigure_utf8(sys.stdout) + _reconfigure_utf8(sys.stderr) + + +__all__ = ["configure_utf8_stdio"] diff --git a/hhtools/cli/agent.py b/hhtools/cli/agent.py new file mode 100644 index 00000000..119dd326 --- /dev/null +++ b/hhtools/cli/agent.py @@ -0,0 +1,1325 @@ +"""Strict, versioned JSON client for the HHTools Agent REST API. + +Every invocation writes exactly one JSON document to stdout. Human-oriented +diagnostics belong on stderr, while large artifact bytes require an explicit +``--output`` file and never appear in JSON or Base64 form. +""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import sys +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Never, TextIO, cast +from urllib.parse import quote + +import typer +from pydantic import BaseModel, ValidationError + +from hhtools.contracts import ( + AgentCliArgumentDiagnostic, + AgentCliHelp, + AgentCliHelpArgument, + AgentCliHelpSubcommand, + AgentJobView, + ApiError, + ArtifactDescriptor, + ArtifactListResponse, + AssetBundle, + AssetInspection, + AssetRegistrationRequest, + AssetSearchResponse, + CapabilityResponse, + ErrorStage, + JobLookupRequest, + JobRetryRequest, + JobStartRequest, + LegacyJobUpgradeRequest, + LegacyJobUpgradeResponse, + PreflightResponse, + PreflightStatus, + RetargetPreflightRequest, +) +from hhtools.contracts.cli import ( + AgentCliCommandName, + AgentCliDiagnosticArgument, + AgentCliReasonCode, +) + +from .agent_transport import ( + AgentTransport, + AgentTransportError, + HttpAgentTransport, + PortableJsonError, + StrictJsonError, + ensure_portable_json, + loads_strict_json, +) + +DEFAULT_BASE_URL = "http://127.0.0.1:8009/api/agent/v1" +EXIT_SUCCESS = 0 +EXIT_PARAMETER_ERROR = 2 +EXIT_PREFLIGHT_ERROR = 3 +EXIT_JOB_ERROR = 4 +EXIT_INTERNAL_ERROR = 5 +_MAX_REQUEST_BYTES = 8 * 1024 * 1024 + +TransportFactory = Callable[[str, float], AgentTransport] + + +@dataclass(frozen=True, slots=True) +class _CliArgumentSpec: + name: AgentCliDiagnosticArgument + description: str + value_name: str | None = None + required: bool = False + expected: str | None = None + value_kind: str = "string" + + +@dataclass(frozen=True, slots=True) +class _CliCommandSpec: + path: tuple[str, ...] + summary: str + positionals: tuple[_CliArgumentSpec, ...] = () + options: tuple[_CliArgumentSpec, ...] = () + + +_REQUEST_ARGUMENT = _CliArgumentSpec( + "--request", + "Read one strict UTF-8 JSON request from this file, or from stdin when the value is '-'.", + value_name="JSON_FILE_OR_DASH", + required=True, + expected="A readable UTF-8 JSON file, or '-' for stdin.", +) +_PLAN_ARGUMENT = _CliArgumentSpec( + "--plan", + "Use the immutable plan id returned by retarget preflight.", + value_name="PLAN_ID", + required=True, + expected="A plan id matching 'plan:sha256:' followed by 64 lowercase hex characters.", +) +_IDEMPOTENCY_ARGUMENT = _CliArgumentSpec( + "--idempotency-key", + "Bind this caller-generated key to one logical submission.", + value_name="KEY", + required=True, + expected=( + "A 1-256 character key beginning with a letter or digit and containing only " + "letters, digits, '.', '_', '~', ':', or '-'." + ), +) +_JOB_ID_ARGUMENT = _CliArgumentSpec( + "JOB_ID", + "Use a job id returned by job start or retry.", + required=True, + expected="A job id returned by job start or retry.", +) +_ASSET_ID_ARGUMENT = _CliArgumentSpec( + "ASSET_ID", + "Use a content-addressed asset id returned by asset register or search.", + required=True, + expected="An asset id returned by asset register or search.", +) +_ARTIFACT_ID_ARGUMENT = _CliArgumentSpec( + "ARTIFACT_ID", + "Use an artifact id returned by artifact list.", + required=True, + expected="An artifact id returned by artifact list.", +) +_AFTER_REVISION_ARGUMENT = _CliArgumentSpec( + "--after-revision", + "Mark a response unchanged when this revision is still current.", + value_name="INTEGER", + expected="A non-negative integer revision.", + value_kind="int", +) + +_COMMAND_SPECS: dict[tuple[str, ...], _CliCommandSpec] = { + (): _CliCommandSpec((), "Call the versioned Agent API with strict JSON input and output."), + ("capabilities",): _CliCommandSpec( + ("capabilities",), "Return the live Agent capability document." + ), + ("asset",): _CliCommandSpec(("asset",), "Register, inspect, get, or search assets."), + ("asset", "register"): _CliCommandSpec( + ("asset", "register"), + "Register one allowlisted content-addressed asset bundle.", + options=(_REQUEST_ARGUMENT,), + ), + ("asset", "get"): _CliCommandSpec( + ("asset", "get"), + "Return one registered asset manifest.", + positionals=(_ASSET_ID_ARGUMENT,), + ), + ("asset", "inspect"): _CliCommandSpec( + ("asset", "inspect"), + "Verify and inspect one registered asset bundle.", + positionals=(_ASSET_ID_ARGUMENT,), + options=( + _CliArgumentSpec("--no-verify-hashes", "Skip content hash verification."), + _CliArgumentSpec("--no-parse-content", "Skip bounded content parsing."), + ), + ), + ("asset", "search"): _CliCommandSpec( + ("asset", "search"), + "Search registered assets with bounded scalar filters.", + options=( + _CliArgumentSpec("--query", "Filter by text query.", value_name="TEXT"), + _CliArgumentSpec("--kind", "Filter by asset kind.", value_name="KIND"), + _CliArgumentSpec("--category", "Filter by workflow category.", value_name="CATEGORY"), + _CliArgumentSpec("--dataset", "Filter by dataset identity.", value_name="DATASET"), + _CliArgumentSpec("--reference", "Filter by reference model.", value_name="REFERENCE"), + _CliArgumentSpec( + "--limit", + "Limit the returned page.", + value_name="INTEGER", + expected="An integer page limit.", + value_kind="int", + ), + _CliArgumentSpec( + "--offset", + "Start the returned page at this offset.", + value_name="INTEGER", + expected="A non-negative integer offset.", + value_kind="int", + ), + ), + ), + ("preflight",): _CliCommandSpec(("preflight",), "Validate and freeze an execution plan."), + ("preflight", "retarget"): _CliCommandSpec( + ("preflight", "retarget"), + "Validate one retarget request and freeze an immutable plan.", + options=(_REQUEST_ARGUMENT,), + ), + ("job",): _CliCommandSpec(("job",), "Start, recover, inspect, cancel, or retry jobs."), + ("job", "start"): _CliCommandSpec( + ("job", "start"), + "Submit one immutable preflight plan.", + options=(_PLAN_ARGUMENT, _IDEMPOTENCY_ARGUMENT), + ), + ("job", "get"): _CliCommandSpec( + ("job", "get"), + "Return one compact revision-aware job snapshot.", + positionals=(_JOB_ID_ARGUMENT,), + options=(_AFTER_REVISION_ARGUMENT,), + ), + ("job", "lookup"): _CliCommandSpec( + ("job", "lookup"), + "Recover one caller-owned submission without enumerating jobs.", + options=(_PLAN_ARGUMENT, _IDEMPOTENCY_ARGUMENT, _AFTER_REVISION_ARGUMENT), + ), + ("job", "cancel"): _CliCommandSpec( + ("job", "cancel"), + "Request queued or cooperative-running cancellation.", + positionals=(_JOB_ID_ARGUMENT,), + ), + ("job", "retry"): _CliCommandSpec( + ("job", "retry"), + "Create one idempotent child attempt for a terminal job.", + positionals=(_JOB_ID_ARGUMENT,), + options=(_IDEMPOTENCY_ARGUMENT,), + ), + ("artifact",): _CliCommandSpec(("artifact",), "List, verify, or download job artifacts."), + ("artifact", "list"): _CliCommandSpec( + ("artifact", "list"), + "List a bounded page of artifacts attached to one job.", + positionals=(_JOB_ID_ARGUMENT,), + options=( + _CliArgumentSpec( + "--limit", + "Limit the returned page.", + value_name="INTEGER", + expected="An integer page limit.", + value_kind="int", + ), + _CliArgumentSpec( + "--offset", + "Start the returned page at this offset.", + value_name="INTEGER", + expected="A non-negative integer offset.", + value_kind="int", + ), + ), + ), + ("artifact", "get"): _CliCommandSpec( + ("artifact", "get"), + "Return one descriptor and optionally download its verified bytes.", + positionals=(_JOB_ID_ARGUMENT, _ARTIFACT_ID_ARGUMENT), + options=( + _CliArgumentSpec("--verify", "Verify managed bytes before returning the descriptor."), + _CliArgumentSpec( + "--output", + "Download artifact bytes to this destination.", + value_name="PATH", + expected="A destination path supplied by the caller.", + ), + _CliArgumentSpec("--force", "Replace an existing --output destination."), + ), + ), + ("legacy",): _CliCommandSpec(("legacy",), "Upgrade supported legacy Agent documents."), + ("legacy", "upgrade"): _CliCommandSpec( + ("legacy", "upgrade"), + "Upgrade one legacy JobSpec v1 document through preflight.", + options=(_REQUEST_ARGUMENT,), + ), +} + +_GLOBAL_HELP_ARGUMENTS = ( + _CliArgumentSpec("--json", "Compatibility flag; Agent CLI output is always strict JSON."), + _CliArgumentSpec( + "--base-url", + "Override the resident Agent REST base URL.", + value_name="URL", + expected="A configured Agent REST base URL.", + ), + _CliArgumentSpec( + "--timeout", + "Set the request timeout in seconds.", + value_name="SECONDS", + expected="A finite number from 0.1 through 3600.", + value_kind="float", + ), + _CliArgumentSpec("--help", "Return machine-readable JSON help without contacting the service."), + _CliArgumentSpec("-h", "Alias for --help."), +) + + +def _command_name(path: tuple[str, ...]) -> AgentCliCommandName: + return cast(AgentCliCommandName, " ".join(("hhtools", "agent", *path))) + + +def _child_specs(spec: _CliCommandSpec) -> list[_CliCommandSpec]: + child_length = len(spec.path) + 1 + return [ + candidate + for path, candidate in _COMMAND_SPECS.items() + if len(path) == child_length and path[: len(spec.path)] == spec.path + ] + + +def _usage(spec: _CliCommandSpec) -> str: + parts: list[str] = [_command_name(spec.path)] + if _child_specs(spec): + parts.append("COMMAND") + for argument in spec.positionals: + parts.append(argument.name if argument.required else f"[{argument.name}]") + for argument in spec.options: + rendered: str = argument.name + if argument.value_name is not None: + rendered = f"{rendered} {argument.value_name}" + parts.append(rendered if argument.required else f"[{rendered}]") + parts.extend(("[--json]", "[--base-url URL]", "[--timeout SECONDS]", "[--help]")) + return " ".join(parts) + + +def _help_argument(argument: _CliArgumentSpec) -> AgentCliHelpArgument: + return AgentCliHelpArgument( + name=argument.name, + value_name=argument.value_name, + required=argument.required, + description=argument.description, + ) + + +def _command_spec_from_argv(arguments: Sequence[str]) -> _CliCommandSpec: + """Resolve only static command tokens, skipping global option values.""" + + path: tuple[str, ...] = () + index = 0 + while index < len(arguments): + value = arguments[index] + if value in {"--json", "--help", "-h"}: + index += 1 + continue + if value in {"--base-url", "--timeout"}: + index += 2 + continue + if value.startswith(("--base-url=", "--timeout=")): + index += 1 + continue + children = { + candidate.path[-1]: candidate for candidate in _child_specs(_COMMAND_SPECS[path]) + } + child = children.get(value) + if child is None: + break + path = child.path + index += 1 + if not _child_specs(child): + break + return _COMMAND_SPECS[path] + + +def _help_document(arguments: Sequence[str]) -> AgentCliHelp | None: + if not any(value in {"--help", "-h"} for value in arguments): + return None + spec = _command_spec_from_argv(arguments) + return AgentCliHelp( + command=_command_name(spec.path), + summary=spec.summary, + usage=_usage(spec), + positionals=[_help_argument(argument) for argument in spec.positionals], + options=[_help_argument(argument) for argument in (*spec.options, *_GLOBAL_HELP_ARGUMENTS)], + subcommands=[ + AgentCliHelpSubcommand(name=child.path[-1], summary=child.summary) + for child in _child_specs(spec) + ], + ) + + +class _ArgumentError(RuntimeError): + def __init__( + self, + reason_code: AgentCliReasonCode = "INVALID_VALUE", + *, + argument: AgentCliDiagnosticArgument | None = None, + expected: str | None = None, + ) -> None: + self.reason_code = reason_code + self.argument = argument + self.expected = expected + super().__init__(reason_code) + + +class _JsonArgumentParser(argparse.ArgumentParser): + """Raise parse failures so stdout can remain one structured document.""" + + def error(self, message: str) -> Never: + del message + raise _ArgumentError("INVALID_VALUE") + + def exit(self, status: int = 0, message: str | None = None) -> Never: + del status, message + raise _ArgumentError("INVALID_VALUE") + + +def _expected_subcommands(spec: _CliCommandSpec) -> str: + names = ", ".join(child.path[-1] for child in _child_specs(spec)) + return f"One of: {names}." + + +def _diagnostic( + spec: _CliCommandSpec, + reason_code: AgentCliReasonCode, + *, + argument: AgentCliDiagnosticArgument | None = None, + expected: str | None = None, +) -> AgentCliArgumentDiagnostic: + return AgentCliArgumentDiagnostic( + reason_code=reason_code, + command=_command_name(spec.path), + argument=argument, + expected=expected, + usage=_usage(spec), + ) + + +def _diagnose_parse_failure( # noqa: PLR0911 - one safe branch per parser failure + arguments: Sequence[str], +) -> AgentCliArgumentDiagnostic: + """Classify argparse rejection without copying any caller-supplied token.""" + + spec = _COMMAND_SPECS[()] + index = 0 + while children := _child_specs(spec): + if index >= len(arguments): + return _diagnostic( + spec, + "MISSING_COMMAND", + argument="COMMAND", + expected=_expected_subcommands(spec), + ) + by_name = {child.path[-1]: child for child in children} + child = by_name.get(arguments[index]) + if child is None: + if arguments[index].startswith("-"): + return _diagnostic( + spec, + "UNKNOWN_ARGUMENT", + argument="", + expected="Only the documented global options are accepted here.", + ) + return _diagnostic( + spec, + "UNKNOWN_COMMAND", + argument="COMMAND", + expected=_expected_subcommands(spec), + ) + spec = child + index += 1 + + option_by_name: dict[str, _CliArgumentSpec] = { + argument.name: argument for argument in spec.options + } + seen_options: set[str] = set() + positional_count = 0 + positional_only = False + while index < len(arguments): + raw = arguments[index] + if raw == "--" and not positional_only: + positional_only = True + index += 1 + continue + if not positional_only and raw.startswith("-"): + name, separator, inline_value = raw.partition("=") + argument = option_by_name.get(name) + if argument is None: + return _diagnostic( + spec, + "UNKNOWN_ARGUMENT", + argument="", + expected="Only the documented options and positional arguments are accepted.", + ) + seen_options.add(name) + if argument.value_name is None: + if separator: + return _diagnostic( + spec, + "INVALID_VALUE", + argument=argument.name, + expected=f"{argument.name} is a flag and does not take a value.", + ) + index += 1 + continue + if separator: + if not inline_value: + return _diagnostic( + spec, + "MISSING_VALUE", + argument=argument.name, + expected=argument.expected or f"A value for {argument.name}.", + ) + value = inline_value + index += 1 + else: + if index + 1 >= len(arguments) or arguments[index + 1].startswith("-"): + return _diagnostic( + spec, + "MISSING_VALUE", + argument=argument.name, + expected=argument.expected or f"A value for {argument.name}.", + ) + value = arguments[index + 1] + index += 2 + try: + if argument.value_kind == "int": + int(value) + elif argument.value_kind == "float": + parsed = float(value) + if not math.isfinite(parsed): + raise ValueError + except ValueError: + return _diagnostic( + spec, + "INVALID_VALUE", + argument=argument.name, + expected=argument.expected or f"A valid value for {argument.name}.", + ) + continue + positional_count += 1 + index += 1 + + for argument in spec.options: + if argument.required and argument.name not in seen_options: + return _diagnostic( + spec, + "MISSING_ARGUMENT", + argument=argument.name, + expected=argument.expected or f"The required {argument.name} option.", + ) + if positional_count < len(spec.positionals): + argument = spec.positionals[positional_count] + return _diagnostic( + spec, + "MISSING_ARGUMENT", + argument=argument.name, + expected=argument.expected, + ) + if positional_count > len(spec.positionals): + return _diagnostic( + spec, + "UNEXPECTED_ARGUMENT", + argument="", + expected="No additional positional arguments.", + ) + return _diagnostic( + spec, + "INVALID_VALUE", + argument="", + expected="Arguments matching the documented command usage.", + ) + + +def _argument_api_error( + error: _ArgumentError, + arguments: Sequence[str], +) -> ApiError: + if error.reason_code == "INVALID_VALUE" and error.argument is None: + diagnostic = _diagnose_parse_failure(arguments) + else: + diagnostic = _diagnostic( + _command_spec_from_argv(arguments), + error.reason_code, + argument=error.argument, + expected=error.expected, + ) + return ApiError( + code="INVALID_PARAMETER", + message="The Agent command arguments are invalid.", + stage=ErrorStage.REQUEST, + details=diagnostic.model_dump(mode="json", exclude_none=True), + ) + + +def _parser() -> _JsonArgumentParser: + parser = _JsonArgumentParser(prog="hhtools agent", add_help=False) + commands = parser.add_subparsers(dest="group", required=True) + + capabilities = commands.add_parser("capabilities", add_help=False) + capabilities.set_defaults(operation="capabilities") + + asset = commands.add_parser("asset", add_help=False) + asset_commands = asset.add_subparsers(dest="asset_command", required=True) + register = asset_commands.add_parser("register", add_help=False) + register.add_argument("--request", required=True) + register.set_defaults(operation="asset_register") + get_asset = asset_commands.add_parser("get", add_help=False) + get_asset.add_argument("asset_id") + get_asset.set_defaults(operation="asset_get") + inspect = asset_commands.add_parser("inspect", add_help=False) + inspect.add_argument("asset_id") + inspect.add_argument("--no-verify-hashes", action="store_false", dest="verify_hashes") + inspect.add_argument("--no-parse-content", action="store_false", dest="parse_content") + inspect.set_defaults(operation="asset_inspect") + search = asset_commands.add_parser("search", add_help=False) + search.add_argument("--query") + search.add_argument("--kind") + search.add_argument("--category") + search.add_argument("--dataset") + search.add_argument("--reference") + search.add_argument("--limit", type=int, default=100) + search.add_argument("--offset", type=int, default=0) + search.set_defaults(operation="asset_search") + + preflight = commands.add_parser("preflight", add_help=False) + preflight_commands = preflight.add_subparsers(dest="preflight_command", required=True) + retarget = preflight_commands.add_parser("retarget", add_help=False) + retarget.add_argument("--request", required=True) + retarget.set_defaults(operation="preflight_retarget") + + job = commands.add_parser("job", add_help=False) + job_commands = job.add_subparsers(dest="job_command", required=True) + start = job_commands.add_parser("start", add_help=False) + start.add_argument("--plan", required=True) + start.add_argument("--idempotency-key", required=True) + start.set_defaults(operation="job_start") + get_job = job_commands.add_parser("get", add_help=False) + get_job.add_argument("job_id") + get_job.add_argument("--after-revision", type=int) + get_job.set_defaults(operation="job_get") + lookup_job = job_commands.add_parser("lookup", add_help=False) + lookup_job.add_argument("--plan", required=True) + lookup_job.add_argument("--idempotency-key", required=True) + lookup_job.add_argument("--after-revision", type=int) + lookup_job.set_defaults(operation="job_lookup") + cancel = job_commands.add_parser("cancel", add_help=False) + cancel.add_argument("job_id") + cancel.set_defaults(operation="job_cancel") + retry = job_commands.add_parser("retry", add_help=False) + retry.add_argument("job_id") + retry.add_argument("--idempotency-key", required=True) + retry.set_defaults(operation="job_retry") + + artifact = commands.add_parser("artifact", add_help=False) + artifact_commands = artifact.add_subparsers(dest="artifact_command", required=True) + list_artifacts = artifact_commands.add_parser("list", add_help=False) + list_artifacts.add_argument("job_id") + list_artifacts.add_argument("--limit", type=int, default=100) + list_artifacts.add_argument("--offset", type=int, default=0) + list_artifacts.set_defaults(operation="artifact_list") + get_artifact = artifact_commands.add_parser("get", add_help=False) + get_artifact.add_argument("job_id") + get_artifact.add_argument("artifact_id") + get_artifact.add_argument("--verify", action="store_true") + get_artifact.add_argument("--output", type=Path) + get_artifact.add_argument("--force", action="store_true") + get_artifact.set_defaults(operation="artifact_get") + + legacy = commands.add_parser("legacy", add_help=False) + legacy_commands = legacy.add_subparsers(dest="legacy_command", required=True) + upgrade = legacy_commands.add_parser("upgrade", add_help=False) + upgrade.add_argument("--request", required=True) + upgrade.set_defaults(operation="legacy_upgrade") + return parser + + +def _extract_global_option( + arguments: list[str], + name: str, + *, + default: str, +) -> tuple[list[str], str]: + """Allow connection options before or after nested command names.""" + + remaining: list[str] = [] + selected: str | None = None + expected = next( + argument.expected for argument in _GLOBAL_HELP_ARGUMENTS if argument.name == name + ) + index = 0 + while index < len(arguments): + value = arguments[index] + if value == name: + if selected is not None: + raise _ArgumentError( + "DUPLICATE_ARGUMENT", + argument=cast(AgentCliDiagnosticArgument, name), + expected=f"Provide {name} at most once.", + ) + if index + 1 >= len(arguments) or arguments[index + 1].startswith("--"): + raise _ArgumentError( + "MISSING_VALUE", + argument=cast(AgentCliDiagnosticArgument, name), + expected=expected, + ) + selected = arguments[index + 1] + index += 2 + continue + prefix = f"{name}=" + if value.startswith(prefix): + if selected is not None: + raise _ArgumentError( + "DUPLICATE_ARGUMENT", + argument=cast(AgentCliDiagnosticArgument, name), + expected=f"Provide {name} at most once.", + ) + selected = value[len(prefix) :] + if not selected: + raise _ArgumentError( + "MISSING_VALUE", + argument=cast(AgentCliDiagnosticArgument, name), + expected=expected, + ) + index += 1 + continue + remaining.append(value) + index += 1 + return remaining, selected if selected is not None else default + + +def _normalize_argv(argv: Sequence[str]) -> tuple[list[str], str, float]: + # ``--json`` is accepted in any position for parity with the documented + # examples. Agent commands are always strict JSON even when it is omitted. + arguments = [value for value in argv if value != "--json"] + arguments, base_url = _extract_global_option( + arguments, + "--base-url", + default=os.environ.get("HHTOOLS_AGENT_BASE_URL", DEFAULT_BASE_URL), + ) + arguments, raw_timeout = _extract_global_option( + arguments, + "--timeout", + default=os.environ.get("HHTOOLS_AGENT_TIMEOUT", "30"), + ) + try: + timeout = float(raw_timeout) + except ValueError as error: + raise _ArgumentError( + "INVALID_VALUE", + argument="--timeout", + expected="A finite number from 0.1 through 3600.", + ) from error + if not math.isfinite(timeout) or not 0.1 <= timeout <= 3_600: + raise _ArgumentError( + "INVALID_VALUE", + argument="--timeout", + expected="A finite number from 0.1 through 3600.", + ) + return arguments, base_url, timeout + + +def _request_document(location: str, stdin: TextIO) -> Any: + if location == "-": + raw = stdin.read(_MAX_REQUEST_BYTES + 1) + else: + try: + with Path(location).open("r", encoding="utf-8") as stream: + raw = stream.read(_MAX_REQUEST_BYTES + 1) + except UnicodeError as error: + raise _ArgumentError( + "REQUEST_ENCODING_INVALID", + argument="--request", + expected="A request encoded as UTF-8 JSON.", + ) from error + except OSError as error: + raise _ArgumentError( + "REQUEST_FILE_UNREADABLE", + argument="--request", + expected="A readable UTF-8 JSON file, or '-' for stdin.", + ) from error + try: + encoded_size = len(raw.encode("utf-8")) + except UnicodeError as error: + raise _ArgumentError( + "REQUEST_ENCODING_INVALID", + argument="--request", + expected="A request encoded as UTF-8 JSON.", + ) from error + if encoded_size > _MAX_REQUEST_BYTES: + raise _ArgumentError( + "REQUEST_TOO_LARGE", + argument="--request", + expected="A UTF-8 JSON request no larger than 8 MiB.", + ) + try: + return loads_strict_json(raw) + except StrictJsonError as error: + raise _ArgumentError( + "REQUEST_JSON_INVALID", + argument="--request", + expected="Exactly one strict UTF-8 JSON document.", + ) from error + + +def _validated_request[ContractT: BaseModel]( + model: type[ContractT], location: str, stdin: TextIO +) -> ContractT: + try: + return model.model_validate(_request_document(location, stdin)) + except ValidationError as error: + raise _ArgumentError( + "REQUEST_CONTRACT_INVALID", + argument="--request", + expected=f"A JSON document matching the {model.__name__} contract.", + ) from error + + +def _response[ContractT: BaseModel](model: type[ContractT], payload: Any) -> ContractT: + try: + return model.model_validate(payload) + except ValidationError as error: + raise AgentTransportError( + ApiError( + code="REMOTE_PROTOCOL_ERROR", + message="The Agent service response does not match the versioned contract.", + retryable=True, + stage=ErrorStage.INTERNAL, + ) + ) from error + + +def _path_segment(value: str) -> str: + return quote(value, safe="") + + +def _execute( # noqa: PLR0911 - one explicit branch per public CLI operation + namespace: argparse.Namespace, + transport: AgentTransport, + *, + stdin: TextIO, +) -> BaseModel: + operation = namespace.operation + if operation == "capabilities": + return _response(CapabilityResponse, transport.request_json("GET", "/capabilities")) + + if operation == "asset_register": + registration_request = _validated_request( + AssetRegistrationRequest, namespace.request, stdin + ) + return _response( + AssetBundle, + transport.request_json( + "POST", + "/assets", + document=registration_request.model_dump(mode="json", exclude_none=True), + ), + ) + if operation == "asset_get": + path = f"/assets/{_path_segment(namespace.asset_id)}" + return _response(AssetBundle, transport.request_json("GET", path)) + if operation == "asset_inspect": + path = f"/assets/{_path_segment(namespace.asset_id)}/inspect" + return _response( + AssetInspection, + transport.request_json( + "GET", + path, + query={ + "verify_hashes": namespace.verify_hashes, + "parse_content": namespace.parse_content, + }, + ), + ) + if operation == "asset_search": + return _response( + AssetSearchResponse, + transport.request_json( + "GET", + "/assets", + query={ + "query": namespace.query, + "kind": namespace.kind, + "category": namespace.category, + "dataset": namespace.dataset, + "reference": namespace.reference, + "limit": namespace.limit, + "offset": namespace.offset, + }, + ), + ) + + if operation == "preflight_retarget": + preflight_request = _validated_request(RetargetPreflightRequest, namespace.request, stdin) + return _response( + PreflightResponse, + transport.request_json( + "POST", + "/preflight/retarget", + document=preflight_request.model_dump(mode="json", exclude_none=True), + ), + ) + + if operation == "job_start": + try: + start_request = JobStartRequest( + plan_id=namespace.plan, + idempotency_key=namespace.idempotency_key, + ) + except ValidationError as error: + invalid_fields = {issue["loc"][0] for issue in error.errors() if issue["loc"]} + argument = _PLAN_ARGUMENT if "plan_id" in invalid_fields else _IDEMPOTENCY_ARGUMENT + raise _ArgumentError( + "INVALID_VALUE", + argument=argument.name, + expected=argument.expected, + ) from error + return _response( + AgentJobView, + transport.request_json( + "POST", + "/jobs", + document=start_request.model_dump(mode="json", exclude_none=True), + ), + ) + if operation == "job_get": + path = f"/jobs/{_path_segment(namespace.job_id)}" + return _response( + AgentJobView, + transport.request_json("GET", path, query={"after_revision": namespace.after_revision}), + ) + if operation == "job_lookup": + try: + lookup_request = JobLookupRequest( + plan_id=namespace.plan, + idempotency_key=namespace.idempotency_key, + after_revision=namespace.after_revision, + ) + except ValidationError as error: + invalid_fields = {issue["loc"][0] for issue in error.errors() if issue["loc"]} + if "plan_id" in invalid_fields: + argument = _PLAN_ARGUMENT + elif "idempotency_key" in invalid_fields: + argument = _IDEMPOTENCY_ARGUMENT + else: + argument = _AFTER_REVISION_ARGUMENT + raise _ArgumentError( + "INVALID_VALUE", + argument=argument.name, + expected=argument.expected, + ) from error + return _response( + AgentJobView, + transport.request_json( + "POST", + "/jobs/lookup", + document=lookup_request.model_dump(mode="json", exclude_none=True), + ), + ) + if operation == "job_cancel": + path = f"/jobs/{_path_segment(namespace.job_id)}/cancel" + return _response(AgentJobView, transport.request_json("POST", path, document={})) + if operation == "job_retry": + try: + retry_request = JobRetryRequest(idempotency_key=namespace.idempotency_key) + except ValidationError as error: + raise _ArgumentError( + "INVALID_VALUE", + argument=_IDEMPOTENCY_ARGUMENT.name, + expected=_IDEMPOTENCY_ARGUMENT.expected, + ) from error + path = f"/jobs/{_path_segment(namespace.job_id)}/retry" + return _response( + AgentJobView, + transport.request_json( + "POST", + path, + document=retry_request.model_dump(mode="json", exclude_none=True), + ), + ) + + if operation == "artifact_list": + path = f"/jobs/{_path_segment(namespace.job_id)}/artifacts" + return _response( + ArtifactListResponse, + transport.request_json( + "GET", path, query={"limit": namespace.limit, "offset": namespace.offset} + ), + ) + if operation == "artifact_get": + path = ( + f"/jobs/{_path_segment(namespace.job_id)}/artifacts/" + f"{_path_segment(namespace.artifact_id)}" + ) + descriptor = _response( + ArtifactDescriptor, + transport.request_json("GET", path, query={"verify": namespace.verify}), + ) + if descriptor.job_id != namespace.job_id or descriptor.artifact_id != namespace.artifact_id: + raise AgentTransportError( + ApiError( + code="REMOTE_PROTOCOL_ERROR", + message="The Agent service returned a descriptor for another job or artifact.", + retryable=True, + stage=ErrorStage.INTERNAL, + ) + ) + if namespace.output is not None: + transport.download_artifact( + f"{path}/content", + destination=namespace.output, + descriptor=descriptor, + overwrite=namespace.force, + ) + elif namespace.force: + raise _ArgumentError( + "INVALID_COMBINATION", + argument="--force", + expected="Use --force only together with --output PATH.", + ) + return descriptor + + if operation == "legacy_upgrade": + # The file is the historical v1 document (or its historical download + # wrapper), not a second transport wrapper users must manufacture. The + # CLI adds the versioned request envelope before crossing REST. + try: + upgrade_request = LegacyJobUpgradeRequest( + payload=_request_document(namespace.request, stdin) + ) + except ValidationError as error: + raise AgentTransportError( + ApiError( + code="INVALID_PARAMETER", + message="The legacy request must contain one JSON object.", + stage=ErrorStage.REQUEST, + ) + ) from error + return _response( + LegacyJobUpgradeResponse, + transport.request_json( + "POST", + "/legacy/jobspec-v1/upgrade", + document=upgrade_request.model_dump(mode="json", exclude_none=True), + ), + ) + raise RuntimeError("unknown Agent CLI operation") + + +def _error_exit_code(error: ApiError) -> int: + if error.stage in { + ErrorStage.REQUEST, + ErrorStage.ASSET_REGISTRATION, + ErrorStage.ASSET_INSPECTION, + }: + return EXIT_PARAMETER_ERROR + if error.stage is ErrorStage.PREFLIGHT: + return EXIT_PREFLIGHT_ERROR + if error.stage in { + ErrorStage.ADMISSION, + ErrorStage.EXECUTION, + ErrorStage.EVALUATION, + ErrorStage.ARTIFACT, + }: + return EXIT_JOB_ERROR + return EXIT_INTERNAL_ERROR + + +def _result_exit_code(result: BaseModel) -> int: + if isinstance(result, PreflightResponse): + return EXIT_SUCCESS if result.status is PreflightStatus.READY else EXIT_PREFLIGHT_ERROR + if isinstance(result, LegacyJobUpgradeResponse): + return ( + EXIT_SUCCESS + if result.preflight.status is PreflightStatus.READY + else EXIT_PREFLIGHT_ERROR + ) + return EXIT_SUCCESS + + +def _write_document(document: BaseModel, stdout: TextIO) -> bool: + """Write one portable document, replacing unsafe responses as a whole.""" + + safe = True + try: + payload = document.model_dump(mode="json", exclude_none=True) + ensure_portable_json(payload) + encoded = json.dumps( + payload, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ) + except (PortableJsonError, TypeError, ValueError, OverflowError): + safe = False + fallback = ApiError( + code="INTERNAL_ERROR", + message="The Agent response was not safe for portable JSON output.", + retryable=False, + stage=ErrorStage.INTERNAL, + ) + encoded = fallback.model_dump_json(exclude_none=True) + stdout.write(encoded) + stdout.write("\n") + stdout.flush() + return safe + + +def _default_transport_factory(base_url: str, timeout: float) -> AgentTransport: + return HttpAgentTransport(base_url, timeout_seconds=timeout) + + +def run( + argv: Sequence[str] | None = None, + *, + transport_factory: TransportFactory | None = None, + stdin: TextIO | None = None, + stdout: TextIO | None = None, + stderr: TextIO | None = None, +) -> int: + """Run one command without allowing parser or protocol prose on stdout.""" + + input_stream = stdin or sys.stdin + output_stream = stdout or sys.stdout + # Retained as an explicit dependency boundary for future progress logging; + # no current command emits human prose during a successful invocation. + _ = stderr or sys.stderr + raw_arguments = list(argv or ()) + arguments: list[str] | None = None + try: + help_document = _help_document(raw_arguments) + if help_document is not None: + safe = _write_document(help_document, output_stream) + return EXIT_SUCCESS if safe else EXIT_INTERNAL_ERROR + arguments, base_url, timeout = _normalize_argv(raw_arguments) + namespace = _parser().parse_args(arguments) + factory = transport_factory or _default_transport_factory + transport = factory(base_url, timeout) + result = _execute(namespace, transport, stdin=input_stream) + safe = _write_document(result, output_stream) + return _result_exit_code(result) if safe else EXIT_INTERNAL_ERROR + except _ArgumentError as error: + api_error = _argument_api_error( + error, + arguments if arguments is not None else raw_arguments, + ) + except AgentTransportError as error: + api_error = error.error + except Exception: # noqa: BLE001 - stdout must remain a JSON contract on failure + api_error = ApiError( + code="INTERNAL_ERROR", + message="The JSON CLI could not complete the request.", + retryable=True, + stage=ErrorStage.INTERNAL, + ) + safe = _write_document(api_error, output_stream) + return _error_exit_code(api_error) if safe else EXIT_INTERNAL_ERROR + + +_PASSTHROUGH_CONTEXT = { + "allow_extra_args": True, + "ignore_unknown_options": True, + "help_option_names": [], +} + +app = typer.Typer( + help="Call the versioned Agent API with strict JSON input and output.", + add_completion=False, + no_args_is_help=False, + context_settings=_PASSTHROUGH_CONTEXT, +) + + +@app.callback(invoke_without_command=True) +def launch(ctx: typer.Context) -> None: + """Return a JSON argument error when no operation was selected.""" + + if ctx.invoked_subcommand is None: + raise typer.Exit(code=run(ctx.args)) + + +def _passthrough(prefix: Sequence[str], ctx: typer.Context) -> None: + raise typer.Exit(code=run([*prefix, *ctx.args])) + + +@app.command("capabilities", context_settings=_PASSTHROUGH_CONTEXT) +def capabilities_command(ctx: typer.Context) -> None: + _passthrough(["capabilities"], ctx) + + +asset_app = typer.Typer( + add_completion=False, + no_args_is_help=False, + context_settings=_PASSTHROUGH_CONTEXT, +) +app.add_typer(asset_app, name="asset") + + +@asset_app.callback(invoke_without_command=True) +def asset_group(ctx: typer.Context) -> None: + if ctx.invoked_subcommand is None: + _passthrough(["asset"], ctx) + + +@asset_app.command("register", context_settings=_PASSTHROUGH_CONTEXT) +def asset_register_command(ctx: typer.Context) -> None: + _passthrough(["asset", "register"], ctx) + + +@asset_app.command("get", context_settings=_PASSTHROUGH_CONTEXT) +def asset_get_command(ctx: typer.Context) -> None: + _passthrough(["asset", "get"], ctx) + + +@asset_app.command("inspect", context_settings=_PASSTHROUGH_CONTEXT) +def asset_inspect_command(ctx: typer.Context) -> None: + _passthrough(["asset", "inspect"], ctx) + + +@asset_app.command("search", context_settings=_PASSTHROUGH_CONTEXT) +def asset_search_command(ctx: typer.Context) -> None: + _passthrough(["asset", "search"], ctx) + + +preflight_app = typer.Typer( + add_completion=False, + no_args_is_help=False, + context_settings=_PASSTHROUGH_CONTEXT, +) +app.add_typer(preflight_app, name="preflight") + + +@preflight_app.callback(invoke_without_command=True) +def preflight_group(ctx: typer.Context) -> None: + if ctx.invoked_subcommand is None: + _passthrough(["preflight"], ctx) + + +@preflight_app.command("retarget", context_settings=_PASSTHROUGH_CONTEXT) +def preflight_retarget_command(ctx: typer.Context) -> None: + _passthrough(["preflight", "retarget"], ctx) + + +job_app = typer.Typer( + add_completion=False, + no_args_is_help=False, + context_settings=_PASSTHROUGH_CONTEXT, +) +app.add_typer(job_app, name="job") + + +@job_app.callback(invoke_without_command=True) +def job_group(ctx: typer.Context) -> None: + if ctx.invoked_subcommand is None: + _passthrough(["job"], ctx) + + +@job_app.command("start", context_settings=_PASSTHROUGH_CONTEXT) +def job_start_command(ctx: typer.Context) -> None: + _passthrough(["job", "start"], ctx) + + +@job_app.command("get", context_settings=_PASSTHROUGH_CONTEXT) +def job_get_command(ctx: typer.Context) -> None: + _passthrough(["job", "get"], ctx) + + +@job_app.command("lookup", context_settings=_PASSTHROUGH_CONTEXT) +def job_lookup_command(ctx: typer.Context) -> None: + _passthrough(["job", "lookup"], ctx) + + +@job_app.command("cancel", context_settings=_PASSTHROUGH_CONTEXT) +def job_cancel_command(ctx: typer.Context) -> None: + _passthrough(["job", "cancel"], ctx) + + +@job_app.command("retry", context_settings=_PASSTHROUGH_CONTEXT) +def job_retry_command(ctx: typer.Context) -> None: + _passthrough(["job", "retry"], ctx) + + +artifact_app = typer.Typer( + add_completion=False, + no_args_is_help=False, + context_settings=_PASSTHROUGH_CONTEXT, +) +app.add_typer(artifact_app, name="artifact") + + +@artifact_app.callback(invoke_without_command=True) +def artifact_group(ctx: typer.Context) -> None: + if ctx.invoked_subcommand is None: + _passthrough(["artifact"], ctx) + + +@artifact_app.command("list", context_settings=_PASSTHROUGH_CONTEXT) +def artifact_list_command(ctx: typer.Context) -> None: + _passthrough(["artifact", "list"], ctx) + + +@artifact_app.command("get", context_settings=_PASSTHROUGH_CONTEXT) +def artifact_get_command(ctx: typer.Context) -> None: + _passthrough(["artifact", "get"], ctx) + + +legacy_app = typer.Typer( + add_completion=False, + no_args_is_help=False, + context_settings=_PASSTHROUGH_CONTEXT, +) +app.add_typer(legacy_app, name="legacy") + + +@legacy_app.callback(invoke_without_command=True) +def legacy_group(ctx: typer.Context) -> None: + if ctx.invoked_subcommand is None: + _passthrough(["legacy"], ctx) + + +@legacy_app.command("upgrade", context_settings=_PASSTHROUGH_CONTEXT) +def legacy_upgrade_command(ctx: typer.Context) -> None: + _passthrough(["legacy", "upgrade"], ctx) + + +def main(argv: Sequence[str] | None = None) -> int: + """Standalone entry point used by tests and embedders.""" + + return run(sys.argv[1:] if argv is None else argv) + + +__all__ = [ + "DEFAULT_BASE_URL", + "EXIT_INTERNAL_ERROR", + "EXIT_JOB_ERROR", + "EXIT_PARAMETER_ERROR", + "EXIT_PREFLIGHT_ERROR", + "EXIT_SUCCESS", + "app", + "main", + "run", +] diff --git a/hhtools/cli/agent_transport.py b/hhtools/cli/agent_transport.py new file mode 100644 index 00000000..0898b76f --- /dev/null +++ b/hhtools/cli/agent_transport.py @@ -0,0 +1,467 @@ +"""Strict HTTP transport for the versioned Agent JSON CLI. + +The CLI is intentionally a client of the long-lived Web composition root. It +does not construct an ``AssetRegistry``, ``JobManager``, scheduler, or solver of +its own; otherwise a submitted job would be owned by a short-lived CLI process. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import os +import tempfile +from collections.abc import Mapping +from pathlib import Path +from typing import Any, Protocol +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode, urlsplit, urlunsplit +from urllib.request import Request, urlopen + +from hhtools.contracts import ApiError, ArtifactDescriptor, ErrorStage, NextAction +from hhtools.contracts.portability import ( + PortableJsonError as ContractPortableJsonError, +) +from hhtools.contracts.portability import ( + looks_like_host_path as contract_looks_like_host_path, +) +from hhtools.contracts.portability import ( + validate_portable_json as validate_contract_portable_json, +) + +_DEFAULT_MAX_JSON_BYTES = 16 * 1024 * 1024 +_COPY_CHUNK_BYTES = 1024 * 1024 +_MAX_JSON_NUMBER_TOKEN = 128 + + +class AgentTransportError(RuntimeError): + """Expected transport failure expressed with the public error contract.""" + + def __init__(self, error: ApiError) -> None: + self.error = error + super().__init__(f"{error.code}: {error.message}") + + +class StrictJsonError(ValueError): + """The document uses a non-standard constant or a duplicate object key.""" + + +class PortableJsonError(ValueError): + """The public JSON document contains a host-specific path or unsafe value.""" + + +def _reject_json_constant(value: str) -> None: + raise StrictJsonError(f"non-standard JSON constant: {value}") + + +def _strict_json_int(value: str) -> int: + if len(value) > _MAX_JSON_NUMBER_TOKEN: + raise StrictJsonError("integer token is too long") + return int(value) + + +def _strict_json_float(value: str) -> float: + if len(value) > _MAX_JSON_NUMBER_TOKEN: + raise StrictJsonError("float token is too long") + result = float(value) + if not math.isfinite(result): + raise StrictJsonError("non-finite number") + return result + + +def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + document: dict[str, Any] = {} + for key, value in pairs: + if key in document: + raise StrictJsonError("duplicate JSON object key") + document[key] = value + return document + + +def loads_strict_json(payload: str | bytes) -> Any: + """Decode RFC-style JSON without Python's NaN or duplicate-key extensions.""" + + try: + text = payload.decode("utf-8") if isinstance(payload, bytes) else payload + except UnicodeDecodeError as error: + raise StrictJsonError("JSON is not valid UTF-8") from error + try: + return json.loads( + text, + parse_float=_strict_json_float, + parse_int=_strict_json_int, + parse_constant=_reject_json_constant, + object_pairs_hook=_reject_duplicate_keys, + ) + except json.JSONDecodeError as error: + raise StrictJsonError("invalid JSON syntax") from error + except RecursionError as error: + raise StrictJsonError("JSON nesting exceeds the supported depth") from error + + +def _looks_like_host_path( + value: str, + *, + allow_same_origin_ui_url: bool = False, +) -> bool: + return contract_looks_like_host_path( + value, + allow_same_origin_ui_url=allow_same_origin_ui_url, + ) + + +def ensure_portable_json( + value: Any, + *, + allow_same_origin_ui_url: bool = False, +) -> None: + """Fail closed when public JSON could reveal a host path or local URI.""" + + try: + if allow_same_origin_ui_url and isinstance(value, str): + if contract_looks_like_host_path( + value, + allow_same_origin_ui_url=True, + ): + raise ContractPortableJsonError("host path") + return + validate_contract_portable_json(value) + except ContractPortableJsonError as error: + raise PortableJsonError(str(error)) from error + + +class AgentTransport(Protocol): + """Small injectable boundary used by the command adapter and its tests.""" + + def request_json( + self, + method: str, + path: str, + *, + query: Mapping[str, str | int | float | bool | None] | None = None, + document: Mapping[str, Any] | None = None, + ) -> Any: ... + + def download_artifact( + self, + path: str, + *, + destination: Path, + descriptor: ArtifactDescriptor, + overwrite: bool, + ) -> None: ... + + +def _transport_error( + code: str, + message: str, + *, + stage: ErrorStage = ErrorStage.INTERNAL, + retryable: bool = False, + details: Mapping[str, Any] | None = None, + next_action: NextAction | None = None, +) -> AgentTransportError: + return AgentTransportError( + ApiError( + code=code, + message=message, + retryable=retryable, + stage=stage, + details=dict(details or {}), + next_action=next_action, + ) + ) + + +def _output_write_error(error: OSError) -> AgentTransportError: + return _transport_error( + "OUTPUT_WRITE_FAILED", + "The artifact output could not be written or published.", + stage=ErrorStage.ARTIFACT, + retryable=False, + ) + + +def _validate_base_url(value: str) -> str: + """Accept one HTTP(S) Agent namespace URL without credentials or query.""" + + parsed = urlsplit(value.strip()) + if ( + parsed.scheme not in {"http", "https"} + or not parsed.netloc + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + ): + raise _transport_error( + "INVALID_PARAMETER", + "The Agent base URL must be an HTTP(S) URL without credentials, query, or fragment.", + stage=ErrorStage.REQUEST, + details={"field": "base_url"}, + ) + path = parsed.path.rstrip("/") + return urlunsplit((parsed.scheme, parsed.netloc, path, "", "")) + + +def _api_error_from_body(payload: bytes) -> ApiError | None: + try: + document = loads_strict_json(payload) + return ApiError.model_validate(document) + except (ValueError, TypeError): + return None + + +class HttpAgentTransport: + """stdlib-only HTTP transport for an already-running Agent REST service.""" + + def __init__( + self, + base_url: str, + *, + timeout_seconds: float = 30.0, + max_json_bytes: int = _DEFAULT_MAX_JSON_BYTES, + ) -> None: + self._base_url = _validate_base_url(base_url) + if not 0.1 <= timeout_seconds <= 3_600: + raise _transport_error( + "INVALID_PARAMETER", + "The Agent timeout must be between 0.1 and 3600 seconds.", + stage=ErrorStage.REQUEST, + details={"field": "timeout"}, + ) + self._timeout_seconds = float(timeout_seconds) + self._max_json_bytes = int(max_json_bytes) + + def _url( + self, + path: str, + query: Mapping[str, str | int | float | bool | None] | None = None, + ) -> str: + if not path.startswith("/") or ".." in path.split("/"): + raise _transport_error( + "INTERNAL_ERROR", + "The CLI constructed an invalid Agent endpoint.", + ) + values = { + key: str(value).lower() if isinstance(value, bool) else str(value) + for key, value in (query or {}).items() + if value is not None + } + suffix = f"?{urlencode(values)}" if values else "" + return f"{self._base_url}{path}{suffix}" + + def _raise_http_error(self, error: HTTPError) -> None: + payload = error.read(self._max_json_bytes + 1) + parsed = _api_error_from_body(payload) + if parsed is not None: + raise AgentTransportError(parsed) from error + raise _transport_error( + "REMOTE_PROTOCOL_ERROR", + "The Agent service returned an error outside the versioned contract.", + retryable=error.code >= 500, + details={"http_status": error.code}, + ) from error + + def _raise_connection_error(self, error: BaseException) -> None: + raise _transport_error( + "AGENT_SERVICE_UNAVAILABLE", + "The Agent service is unavailable at the configured base URL.", + retryable=True, + next_action=NextAction( + actor="human", + action="start_agent_service", + message="Start `hhtools web` or select the correct local Agent endpoint.", + ), + ) from error + + def request_json( + self, + method: str, + path: str, + *, + query: Mapping[str, str | int | float | bool | None] | None = None, + document: Mapping[str, Any] | None = None, + ) -> Any: + payload = None + headers = {"Accept": "application/json", "User-Agent": "hhtools-agent-json/1"} + if document is not None: + payload = json.dumps( + document, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + headers["Content-Type"] = "application/json" + request = Request( + self._url(path, query), + data=payload, + headers=headers, + method=method.upper(), + ) + try: + with urlopen(request, timeout=self._timeout_seconds) as response: # noqa: S310 + body = response.read(self._max_json_bytes + 1) + except HTTPError as error: + self._raise_http_error(error) + raise AssertionError("unreachable") from error + except (OSError, TimeoutError, URLError) as error: + self._raise_connection_error(error) + raise AssertionError("unreachable") from error + if len(body) > self._max_json_bytes: + raise _transport_error( + "REMOTE_PROTOCOL_ERROR", + "The Agent JSON response exceeds the CLI safety limit.", + ) + try: + return loads_strict_json(body) + except StrictJsonError as error: + raise _transport_error( + "REMOTE_PROTOCOL_ERROR", + "The Agent service did not return one valid UTF-8 JSON document.", + ) from error + + def download_artifact( + self, + path: str, + *, + destination: Path, + descriptor: ArtifactDescriptor, + overwrite: bool, + ) -> None: + """Stream one authorized artifact to an explicit local destination. + + The descriptor is fetched and membership-checked before this method is + called. Server and client hashes are both verified; bytes never enter + the JSON response or an in-memory Base64 representation. + """ + + target = Path(destination).expanduser() + try: + parent = target.parent.resolve(strict=True) + except OSError as error: + raise _transport_error( + "INVALID_PARAMETER", + "The artifact output parent directory does not exist or is unavailable.", + stage=ErrorStage.REQUEST, + details={"field": "output"}, + ) from error + if not parent.is_dir() or target.name in {"", ".", ".."}: + raise _transport_error( + "INVALID_PARAMETER", + "The artifact output must name a file inside an existing directory.", + stage=ErrorStage.REQUEST, + details={"field": "output"}, + ) + target = parent / target.name + if target.exists() and (target.is_dir() or not overwrite): + raise _transport_error( + "OUTPUT_EXISTS", + "The artifact output already exists; pass --force to replace a file.", + stage=ErrorStage.REQUEST, + details={"field": "output"}, + ) + + request = Request( + self._url(path), + headers={ + "Accept": "application/octet-stream", + "User-Agent": "hhtools-agent-json/1", + }, + method="GET", + ) + try: + temporary_fd, temporary_name = tempfile.mkstemp( + prefix=f".{target.name}.hhtools-", + suffix=".tmp", + dir=parent, + ) + except OSError as error: + raise _output_write_error(error) from error + temporary = Path(temporary_name) + digest = hashlib.sha256() + size = 0 + try: + try: + with os.fdopen(temporary_fd, "wb") as stream: + try: + with urlopen( # noqa: S310 + request, + timeout=self._timeout_seconds, + ) as response: + while True: + try: + chunk = response.read(_COPY_CHUNK_BYTES) + except (OSError, TimeoutError, URLError) as error: + self._raise_connection_error(error) + if not chunk: + break + try: + stream.write(chunk) + except OSError as error: + raise _output_write_error(error) from error + digest.update(chunk) + size += len(chunk) + except HTTPError as error: + self._raise_http_error(error) + except (OSError, TimeoutError, URLError) as error: + self._raise_connection_error(error) + try: + stream.flush() + os.fsync(stream.fileno()) + except OSError as error: + raise _output_write_error(error) from error + except AgentTransportError: + raise + except OSError as error: + raise _output_write_error(error) from error + + if descriptor.size_bytes is not None and size != descriptor.size_bytes: + raise _transport_error( + "ARTIFACT_HASH_MISMATCH", + "The downloaded artifact size differs from its descriptor.", + stage=ErrorStage.ARTIFACT, + retryable=True, + ) + if descriptor.sha256 is not None and digest.hexdigest() != descriptor.sha256: + raise _transport_error( + "ARTIFACT_HASH_MISMATCH", + "The downloaded artifact hash differs from its descriptor.", + stage=ErrorStage.ARTIFACT, + retryable=True, + ) + + try: + if overwrite: + os.replace(temporary, target) + else: + # A hard-link publication is atomic and fails if another process + # created the destination after the initial existence check. + os.link(temporary, target) + except FileExistsError as error: + raise _transport_error( + "OUTPUT_EXISTS", + "The artifact output was created concurrently.", + stage=ErrorStage.REQUEST, + details={"field": "output"}, + ) from error + except OSError as error: + raise _output_write_error(error) from error + finally: + try: + temporary.unlink(missing_ok=True) + except OSError: + pass + + +__all__ = [ + "AgentTransport", + "AgentTransportError", + "HttpAgentTransport", + "PortableJsonError", + "StrictJsonError", + "ensure_portable_json", + "loads_strict_json", +] diff --git a/hhtools/cli/desktop_sidecar.py b/hhtools/cli/desktop_sidecar.py new file mode 100644 index 00000000..f2f2da26 --- /dev/null +++ b/hhtools/cli/desktop_sidecar.py @@ -0,0 +1,77 @@ +"""Secured FastAPI sidecar entry point for the Electron desktop shell.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path + +from hhtools.web.dependencies import MissingWebDependenciesError + + +def _non_negative_int(value: str) -> int: + parsed = int(value) + if parsed < 0: + raise argparse.ArgumentTypeError("must be a non-negative integer") + return parsed + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Run the hhtools Electron sidecar") + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--save-dir", type=Path, required=True) + parser.add_argument("--cache", type=Path, required=True) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, required=True) + parser.add_argument("--session-secret") + parser.add_argument( + "--max-running-jobs", + type=_non_negative_int, + default=os.environ.get("HHTOOLS_MAX_RUNNING_JOBS"), + help="Concurrent jobs; 0 means unlimited.", + ) + parser.add_argument( + "--max-queued-jobs", + type=_non_negative_int, + default=os.environ.get("HHTOOLS_MAX_QUEUED_JOBS"), + help="Waiting jobs under a concurrency cap; 0 means unlimited.", + ) + return parser + + +def main(argv: list[str] | None = None) -> None: + parser = _parser() + args = parser.parse_args(argv) + args.source.mkdir(parents=True, exist_ok=True) + args.save_dir.mkdir(parents=True, exist_ok=True) + args.cache.mkdir(parents=True, exist_ok=True) + # Electron normally uses the environment so the secret is not exposed in process listings. + session_secret = args.session_secret or os.environ.get("HHTOOLS_DESKTOP_SESSION_SECRET") + if not session_secret: + parser.error( + "a session secret is required via --session-secret or HHTOOLS_DESKTOP_SESSION_SECRET" + ) + + # Delay the heavier web imports until arguments and writable directories are valid. + from hhtools.web.server import run_desktop_sidecar + + try: + run_desktop_sidecar( + source_root=args.source, + save_dir=args.save_dir, + cache_dir=args.cache, + host=args.host, + port=args.port, + session_secret=session_secret, + max_running_jobs=args.max_running_jobs, + max_queued_jobs=args.max_queued_jobs, + ) + except MissingWebDependenciesError as exc: + parser.exit(status=1, message=f"{exc}\n") + + +if __name__ == "__main__": + main() + + +__all__ = ["main"] diff --git a/hhtools/cli/main.py b/hhtools/cli/main.py index 758f0a29..b76ed8a8 100644 --- a/hhtools/cli/main.py +++ b/hhtools/cli/main.py @@ -12,6 +12,11 @@ import typer from hhtools._version import __version__ +from hhtools.cli._stdio import configure_utf8_stdio + +# Configure streams before importing a selected subcommand. Rich consoles created by +# those modules then inherit UTF-8 instead of a locale-dependent Windows code page. +configure_utf8_stdio() app = typer.Typer( help="hhtools - Human-to-Humanoid Tools.", @@ -42,7 +47,7 @@ def _attach(name: str, module_path: str, help_text: str) -> None: module = importlib.import_module(module_path) - app.add_typer(getattr(module, "app"), name=name, help=help_text) + app.add_typer(module.app, name=name, help=help_text) def _subcommands_for_argv() -> list[tuple[str, str, str]]: @@ -50,6 +55,15 @@ def _subcommands_for_argv() -> list[tuple[str, str, str]]: if len(sys.argv) < 2: return _SUBCOMMANDS arg = sys.argv[1] + if arg == "agent": + # The strict Agent command is registered directly below and lazily + # imports its transport adapter. Do not import unrelated solver/UI + # command trees for a lightweight JSON request. + return [] + if arg in {"--version", "-V"}: + # Version reporting must stay instant and must not initialize optional + # viewer, solver, or GPU-related command modules. + return [] if arg.startswith("-"): return _SUBCOMMANDS for name, path, help_text in _SUBCOMMANDS: @@ -62,6 +76,25 @@ def _subcommands_for_argv() -> list[tuple[str, str, str]]: _attach(_name, _path, _help) +@app.command( + "agent", + context_settings={ + "allow_extra_args": True, + "ignore_unknown_options": True, + "help_option_names": [], + }, +) +def _agent(ctx: typer.Context) -> None: + """Call the resident Agent REST service with strict JSON input/output.""" + + # One passthrough Click command is deliberate: argparse inside the JSON + # adapter converts *all* malformed or unknown tails into ApiError stdout, + # instead of allowing Click/Rich to emit a second, non-JSON document. + from hhtools.cli.agent import run + + raise typer.Exit(code=run(ctx.args)) + + @app.callback(invoke_without_command=True) def _root( ctx: typer.Context, diff --git a/hhtools/cli/retarget.py b/hhtools/cli/retarget.py index b57511a2..51263c9e 100644 --- a/hhtools/cli/retarget.py +++ b/hhtools/cli/retarget.py @@ -218,7 +218,7 @@ def retarget( from hhtools.io.robot_csv import save_robot_csv from hhtools.retarget.calibration import ( load_calibration, - resolve_calibration_file, + resolve_preset_calibration_file, ) from hhtools.robot.retarget_profile import ( build_feet_stabilizer_config, @@ -244,19 +244,20 @@ def retarget( raise typer.BadParameter(str(err)) from err robot_model = load_robot(preset) - # Require a retarget calibration yaml next to the URDF (per reference - # format, or legacy single file when its embedded reference matches). + # Resolve either a writable per-user override or the calibration bundled + # with the robot preset. Installed applications keep bundled assets + # immutable, so calibrations saved from the GUI normally live in the + # user's hhtools robot configuration directory. if preset.urdf_path is None: raise typer.BadParameter( f"robot preset {robot!r} has no URDF on disk; calibration " "cannot be resolved." ) - preset_dir = preset.urdf_path.parent - cal_path = resolve_calibration_file(preset_dir, calibration_reference) + cal_path = resolve_preset_calibration_file(preset, calibration_reference) if cal_path is None: raise typer.BadParameter( f"no retarget calibration for robot {robot!r} with reference " - f"{calibration_reference!r} under {preset_dir}.\n" + f"{calibration_reference!r}.\n" "Expected e.g. " f"`retarget_calibration_{calibration_reference}.yaml`, or a " "legacy `retarget_calibration.yaml` whose `reference` field " @@ -429,7 +430,7 @@ def interaction_mesh_precompute_laplacian( ) from pathlib import Path as _P - from hhtools.retarget.calibration import resolve_calibration_file + from hhtools.retarget.calibration import resolve_preset_calibration_file from hhtools.retarget.interaction_mesh.pipeline import InteractionMeshPipeline from hhtools.robot.loader import load_robot from hhtools.robot.registry import get as get_preset @@ -445,7 +446,7 @@ def interaction_mesh_precompute_laplacian( motion = _load_motion_any(src) ref = _calibration_reference_for_motion(motion, calibration_reference) - cal_path = resolve_calibration_file(preset.urdf_path.parent, ref) + cal_path = resolve_preset_calibration_file(preset, ref) if cal_path is None: raise typer.BadParameter(f"no calibration for {robot!r} ref={ref!r}") if limit_frames is not None and motion.num_frames > limit_frames: @@ -503,7 +504,7 @@ def interaction_mesh_run( format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) from hhtools.io.robot_csv import save_robot_csv - from hhtools.retarget.calibration import resolve_calibration_file + from hhtools.retarget.calibration import resolve_preset_calibration_file from hhtools.retarget.interaction_mesh.pipeline import InteractionMeshPipeline from hhtools.robot.loader import load_robot from hhtools.robot.registry import get as get_preset @@ -525,7 +526,7 @@ def interaction_mesh_run( for src in files: motion = _load_motion_any(src) ref = _calibration_reference_for_motion(motion, calibration_reference) - cal_path = resolve_calibration_file(preset.urdf_path.parent, ref) + cal_path = resolve_preset_calibration_file(preset, ref) if cal_path is None: raise typer.BadParameter(f"no calibration for {robot!r} ref={ref!r}") if limit_frames is not None and motion.num_frames > limit_frames: diff --git a/hhtools/cli/ui.py b/hhtools/cli/ui.py index fdc5e44a..4ac25da8 100644 --- a/hhtools/cli/ui.py +++ b/hhtools/cli/ui.py @@ -23,6 +23,8 @@ def launch( Path("assets/motions"), "--source", "-s", + envvar="HHTOOLS_SOURCE_ROOT", + show_envvar=True, help="Raw-dataset root scanned recursively for the folder-indexed library. " "Intermediate grouping folders (mimic/ intermimic/ meshmimic/ ...) are " "transparent — only the innermost dataset directory names matter.", @@ -30,12 +32,16 @@ def launch( cache: Path | None = typer.Option( None, "--cache", + envvar="HHTOOLS_CACHE_DIR", + show_envvar=True, help="Per-session NPZ cache directory. Defaults to a fresh tempfile.mkdtemp " "under /tmp that is rmtree'd on shutdown regardless of saves.", ), save_dir: Path = typer.Option( Path("assets/save_npz"), "--save-dir", + envvar="HHTOOLS_SAVE_DIR", + show_envvar=True, help="Destination for NPZs the user explicitly persists via the 'Save' buttons.", ), keep_cache: bool = typer.Option( diff --git a/hhtools/cli/web.py b/hhtools/cli/web.py index df0e69dc..3bab88fd 100644 --- a/hhtools/cli/web.py +++ b/hhtools/cli/web.py @@ -10,6 +10,8 @@ import typer +from hhtools.web.dependencies import MissingWebDependenciesError + app = typer.Typer(help="Launch the HTML web UI (Apple-styled three.js front-end).") @@ -20,40 +22,63 @@ def launch( Path("assets/motions"), "--source", "-s", + envvar="HHTOOLS_SOURCE_ROOT", + show_envvar=True, help="Raw-dataset root scanned recursively for the motion library.", ), save_dir: Path = typer.Option( Path("assets/save_npz"), "--save-dir", + envvar="HHTOOLS_SAVE_DIR", + show_envvar=True, help="Viser-style persisted NPZ cache (web exports download via the browser).", ), cache: Path | None = typer.Option( - None, "--cache", help="Per-session NPZ cache dir (defaults to a tempdir)." + None, + "--cache", + envvar="HHTOOLS_CACHE_DIR", + show_envvar=True, + help="Per-session NPZ cache dir (defaults to a tempdir).", ), host: str = typer.Option("127.0.0.1", "--host"), port: int = typer.Option(8009, "--port"), + max_running_jobs: int | None = typer.Option( + None, + "--max-running-jobs", + min=0, + envvar="HHTOOLS_MAX_RUNNING_JOBS", + show_envvar=True, + help="Concurrent background jobs; 0 selects unlimited mode.", + ), + max_queued_jobs: int | None = typer.Option( + None, + "--max-queued-jobs", + min=0, + envvar="HHTOOLS_MAX_QUEUED_JOBS", + show_envvar=True, + help=( + "Waiting jobs when concurrency is limited; 0 means an unlimited queue." + ), + ), ) -> None: """Start the web UI on ``host:port`` and open a browser.""" if ctx.invoked_subcommand is not None: return + from hhtools.web.server import run_web + try: - from hhtools.web.server import run_web - except ImportError as exc: - typer.echo( - "The web UI requires the optional extras. Install them with:\n" - " uv sync --extra web --extra retarget\n" - "Retarget (Newton IK) also needs the NVIDIA ``newton`` package per upstream docs.\n" - " (or: pip install 'hhtools[web,retarget]')" + run_web( + source_root=source, + save_dir=save_dir, + cache_dir=cache, + host=host, + port=port, + max_running_jobs=max_running_jobs, + max_queued_jobs=max_queued_jobs, ) - raise typer.Exit(code=1) from exc - - run_web( - source_root=source, - save_dir=save_dir, - cache_dir=cache, - host=host, - port=port, - ) + except MissingWebDependenciesError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(code=1) from None __all__ = ["app"] diff --git a/hhtools/contracts/__init__.py b/hhtools/contracts/__init__.py new file mode 100644 index 00000000..5d26f9a6 --- /dev/null +++ b/hhtools/contracts/__init__.py @@ -0,0 +1,156 @@ +"""Stable, transport-neutral contracts for HHTools agent integrations.""" + +from .artifact_exports import ArtifactExportReceipt +from .artifacts import EvaluationReport, FailureItem, FailureReport, JobManifest +from .assets import ( + AssetBundle, + AssetCategory, + AssetDetected, + AssetFile, + AssetFileRole, + AssetInspection, + AssetInspectionRequest, + AssetKind, + AssetRegistrationRequest, + AssetSearchResponse, + AssetSource, + AssetSourceScheme, + DetectedAssetMetadata, + InspectionStatus, +) +from .capabilities import ( + BackendCapability, + CapabilityResponse, + DeviceCapability, + DeviceKind, + RobotCapability, + RobotListResponse, + SchedulerCapability, + SchedulerMode, +) +from .cli import ( + AgentCliArgumentDiagnostic, + AgentCliHelp, + AgentCliHelpArgument, + AgentCliHelpSubcommand, +) +from .common import ( + ApiError, + ArtifactId, + AssetId, + CalibrationId, + ContractModel, + ErrorStage, + NextAction, + PlanId, + ResourceUri, + SchemaVersion, + Sha256Hex, +) +from .job_spec import ( + JobSpecCalibration, + JobSpecInput, + JobSpecKind, + JobSpecProvenance, + JobSpecRobot, + JobSpecV2, +) +from .jobs import ( + AgentJobView, + ArtifactDescriptor, + ArtifactListResponse, + JobLookupRequest, + JobOutcome, + JobProgress, + JobQueueView, + JobRetryRequest, + JobStartRequest, + JobState, +) +from .migration import ( + LegacyJobUpgradeRequest, + LegacyJobUpgradeResponse, + LegacyMigrationReceipt, +) +from .preflight import ( + OutputPolicy, + PreflightCheck, + PreflightCheckLevel, + PreflightCheckStatus, + PreflightResponse, + PreflightStatus, + RetargetPlan, + RetargetPreflightRequest, +) + +__all__ = [ + "AgentCliArgumentDiagnostic", + "AgentCliHelp", + "AgentCliHelpArgument", + "AgentCliHelpSubcommand", + "AgentJobView", + "ApiError", + "ArtifactDescriptor", + "ArtifactExportReceipt", + "ArtifactListResponse", + "ArtifactId", + "AssetBundle", + "AssetCategory", + "AssetDetected", + "AssetFile", + "AssetFileRole", + "AssetInspection", + "AssetInspectionRequest", + "AssetId", + "AssetKind", + "AssetRegistrationRequest", + "AssetSearchResponse", + "AssetSource", + "AssetSourceScheme", + "BackendCapability", + "CapabilityResponse", + "CalibrationId", + "ContractModel", + "DetectedAssetMetadata", + "DeviceCapability", + "DeviceKind", + "ErrorStage", + "EvaluationReport", + "FailureItem", + "FailureReport", + "InspectionStatus", + "JobOutcome", + "JobProgress", + "JobQueueView", + "JobRetryRequest", + "JobSpecCalibration", + "JobSpecInput", + "JobSpecKind", + "JobSpecProvenance", + "JobSpecRobot", + "JobSpecV2", + "JobState", + "JobStartRequest", + "JobManifest", + "JobLookupRequest", + "LegacyJobUpgradeRequest", + "LegacyJobUpgradeResponse", + "LegacyMigrationReceipt", + "NextAction", + "OutputPolicy", + "PreflightCheck", + "PreflightCheckLevel", + "PreflightCheckStatus", + "PreflightResponse", + "PreflightStatus", + "PlanId", + "ResourceUri", + "RetargetPlan", + "RetargetPreflightRequest", + "RobotCapability", + "RobotListResponse", + "SchedulerCapability", + "SchedulerMode", + "SchemaVersion", + "Sha256Hex", +] diff --git a/hhtools/contracts/artifact_exports.py b/hhtools/contracts/artifact_exports.py new file mode 100644 index 00000000..44b8ca51 --- /dev/null +++ b/hhtools/contracts/artifact_exports.py @@ -0,0 +1,84 @@ +"""Portable receipt for an explicitly exported managed artifact.""" + +from __future__ import annotations + +import hashlib +from pathlib import PurePosixPath, PureWindowsPath +from typing import Annotated, Literal + +from pydantic import Field, field_validator, model_validator + +from .common import ArtifactId, ContractModel, SchemaVersion, Sha256Hex + +_FORMAT_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,31}$" +_KIND_PATTERN = r"^[a-z][a-z0-9_-]{0,127}$" +_MEDIA_TYPE_PATTERN = ( + r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+/[!#$%&'*+.^_`|~0-9A-Za-z-]+" + r"(?:[ \t]*;[^\r\n\x00-\x08\x0b\x0c\x0e-\x1f\x7f]+)*$" +) +_RELATIVE_PATH_PATTERN = r"^jobs/[0-9a-f]{64}/[0-9a-f]{64}\.[a-z0-9][a-z0-9._+-]{0,31}$" + + +class ArtifactExportReceipt(ContractModel): + """Host-independent identity for one file copied to the configured export root.""" + + schema_version: SchemaVersion = SchemaVersion.V1 + root_id: Literal["agent-exports"] = "agent-exports" + relative_path: Annotated[ + str, + Field( + min_length=1, + max_length=1024, + pattern=_RELATIVE_PATH_PATTERN, + description="Portable path below the server-configured agent export root.", + ), + ] + job_id: Annotated[ + str, + Field( + min_length=1, + max_length=256, + pattern=r"^job:[A-Za-z0-9][A-Za-z0-9._~-]{0,251}$", + ), + ] + artifact_id: ArtifactId + kind: Annotated[ + str, + Field(min_length=1, max_length=128, pattern=_KIND_PATTERN), + ] + format: Annotated[ + str | None, + Field(default=None, min_length=1, max_length=32, pattern=_FORMAT_PATTERN), + ] + media_type: Annotated[ + str | None, + Field(default=None, max_length=255, pattern=_MEDIA_TYPE_PATTERN), + ] + size_bytes: Annotated[int, Field(ge=0)] + sha256: Sha256Hex + + @field_validator("relative_path") + @classmethod + def validate_relative_path(cls, value: str) -> str: + if "\\" in value: + raise ValueError("export paths must use forward slashes") + posix = PurePosixPath(value) + windows = PureWindowsPath(value) + if posix.is_absolute() or windows.is_absolute() or windows.drive: + raise ValueError("export path must be relative") + if any(part in {"", ".", ".."} for part in posix.parts): + raise ValueError("export path must be normalized and cannot traverse parents") + return posix.as_posix() + + @model_validator(mode="after") + def validate_identity_path(self) -> ArtifactExportReceipt: + job_token = hashlib.sha256(self.job_id.encode("utf-8")).hexdigest() + artifact_token = hashlib.sha256(self.artifact_id.encode("utf-8")).hexdigest() + extension = self.format.casefold() if self.format is not None else "bin" + expected = f"jobs/{job_token}/{artifact_token}.{extension}" + if self.relative_path != expected: + raise ValueError("export path must match the job and artifact identity") + return self + + +__all__ = ["ArtifactExportReceipt"] diff --git a/hhtools/contracts/artifacts.py b/hhtools/contracts/artifacts.py new file mode 100644 index 00000000..16ab9432 --- /dev/null +++ b/hhtools/contracts/artifacts.py @@ -0,0 +1,102 @@ +"""Versioned JSON reports stored as immutable job artifacts.""" + +from __future__ import annotations + +from typing import Annotated, Any + +from pydantic import AwareDatetime, Field, model_validator + +from .common import ApiError, ContractModel, ErrorStage, MachineCode, PlanId, SchemaVersion +from .job_spec import JobSpecV2 +from .jobs import ArtifactDescriptor, JobOutcome, JobState + + +class EvaluationReport(ContractModel): + """Compact quality verdict; large plots and previews remain separate artifacts.""" + + schema_version: SchemaVersion = SchemaVersion.V1 + job_id: Annotated[str, Field(min_length=1, max_length=256)] + outcome: JobOutcome + summary: Annotated[str | None, Field(default=None, max_length=4_096)] + metrics: dict[str, Any] = Field(default_factory=dict) + checks: list[dict[str, Any]] = Field(default_factory=list, max_length=256) + created_at: AwareDatetime + + +class FailureItem(ContractModel): + """One structured failed input or execution stage.""" + + item_id: Annotated[str | None, Field(default=None, min_length=1, max_length=256)] + code: MachineCode + message: Annotated[str, Field(min_length=1, max_length=8_192)] + stage: ErrorStage + retryable: bool = False + details: dict[str, Any] = Field(default_factory=dict) + + +class FailureReport(ContractModel): + """Structured failures for a failed or partially completed job.""" + + schema_version: SchemaVersion = SchemaVersion.V1 + job_id: Annotated[str, Field(min_length=1, max_length=256)] + failures: Annotated[list[FailureItem], Field(min_length=1, max_length=10_000)] + created_at: AwareDatetime + + +class JobManifest(ContractModel): + """Terminal audit record. + + ``artifacts`` lists every artifact published before the manifest itself; + self-inclusion would make a content hash recursively impossible. + """ + + schema_version: SchemaVersion = SchemaVersion.V1 + job_id: Annotated[str, Field(min_length=1, max_length=256)] + parent_job_id: Annotated[str | None, Field(default=None, min_length=1, max_length=256)] + root_job_id: Annotated[str | None, Field(default=None, min_length=1, max_length=256)] + attempt: Annotated[int, Field(ge=1)] = 1 + plan_id: PlanId + state: JobState + outcome: JobOutcome | None = None + error: ApiError | None = None + cancellation_requested: bool = False + job_spec: JobSpecV2 + execution_provenance: dict[str, Any] = Field(default_factory=dict) + summary: dict[str, Any] = Field(default_factory=dict) + artifacts: list[ArtifactDescriptor] = Field(default_factory=list, max_length=10_000) + submitted_at: AwareDatetime + started_at: AwareDatetime | None = None + completed_at: AwareDatetime + + @model_validator(mode="after") + def validate_terminal_result(self) -> JobManifest: + if self.state not in {JobState.COMPLETED, JobState.FAILED, JobState.CANCELLED}: + raise ValueError("a manifest can only describe a terminal job") + if self.state is JobState.COMPLETED: + if self.outcome is None or self.error is not None: + raise ValueError("completed manifests require an outcome and no error") + elif self.outcome is not None: + raise ValueError("only completed manifests may include an outcome") + if self.state is JobState.FAILED: + if self.error is None: + raise ValueError("failed manifests require an error") + elif self.error is not None: + raise ValueError("only failed manifests may include an error") + if self.started_at is not None and self.started_at < self.submitted_at: + raise ValueError("started_at cannot precede submitted_at") + if self.completed_at < (self.started_at or self.submitted_at): + raise ValueError("completed_at cannot precede job execution") + if self.parent_job_id is None: + if self.root_job_id is not None or self.attempt != 1: + raise ValueError("root manifests cannot declare retry lineage") + elif self.root_job_id is None or self.attempt < 2: + raise ValueError("retry manifests require complete lineage") + return self + + +__all__ = [ + "EvaluationReport", + "FailureItem", + "FailureReport", + "JobManifest", +] diff --git a/hhtools/contracts/assets.py b/hhtools/contracts/assets.py new file mode 100644 index 00000000..28455148 --- /dev/null +++ b/hhtools/contracts/assets.py @@ -0,0 +1,228 @@ +"""Asset bundle and inspection contracts.""" + +from __future__ import annotations + +from enum import StrEnum +from pathlib import PurePosixPath, PureWindowsPath +from typing import Annotated, Any + +from pydantic import AwareDatetime, Field, field_validator, model_validator + +from .common import ApiError, AssetId, ContractModel, SchemaVersion, Sha256Hex + + +class AssetKind(StrEnum): + """Logical type of a registered asset.""" + + MOTION_BUNDLE = "motion_bundle" + ROBOT_BUNDLE = "robot_bundle" + CALIBRATION_BUNDLE = "calibration_bundle" + DATASET_BUNDLE = "dataset_bundle" + VIDEO = "video" + + +class AssetCategory(StrEnum): + """Workflow category used for backend selection.""" + + PLAIN_MOTION = "plain_motion" + OBJECT_INTERACTION = "object_interaction" + TERRAIN_SCENE = "terrain_scene" + ROBOT_MODEL = "robot_model" + CALIBRATION = "calibration" + + +class AssetFileRole(StrEnum): + """Semantic role of a file inside a bundle.""" + + MOTION = "motion" + ROBOT_DESCRIPTION = "robot_description" + VISUAL_MESH = "visual_mesh" + COLLISION_MESH = "collision_mesh" + OBJECT_MESH = "object_mesh" + TERRAIN_MESH = "terrain_mesh" + OBJECT_TRAJECTORY = "object_trajectory" + CALIBRATION = "calibration" + METADATA = "metadata" + VIDEO = "video" + OTHER = "other" + + +class InspectionStatus(StrEnum): + """Machine-readable outcome of inspecting an asset.""" + + VALID = "valid" + VALID_WITH_WARNINGS = "valid_with_warnings" + INVALID = "invalid" + + +class AssetSourceScheme(StrEnum): + """Controlled source schemes understood by the AssetRegistry.""" + + MANAGED_FILE = "managed_file" + UPLOAD = "upload" + SHARED_STORAGE = "shared_storage" + ARTIFACT = "artifact" + + +class AssetSource(ContractModel): + """Location identity without exposing an arbitrary host absolute path.""" + + scheme: AssetSourceScheme + root_id: Annotated[str, Field(min_length=1, max_length=128)] + registered_at: AwareDatetime + logical_path: Annotated[str | None, Field(default=None, min_length=1, max_length=1024)] + + @field_validator("logical_path") + @classmethod + def validate_logical_path(cls, value: str | None) -> str | None: + return None if value is None else _validate_bundle_path(value) + + +class AssetDetected(ContractModel): + """Small set of routing hints discovered from a registered bundle.""" + + dataset: str | None = None + reference: str | None = None + recommended_backend: str | None = None + + +# Backwards-compatible import name for the earliest service prototype. +DetectedAssetMetadata = AssetDetected + + +def _validate_bundle_path(value: str) -> str: + """Require a portable, bundle-relative path without traversal.""" + + if not value: + raise ValueError("bundle path must not be empty") + if "\\" in value: + raise ValueError("bundle paths must use forward slashes") + + posix_path = PurePosixPath(value) + windows_path = PureWindowsPath(value) + if posix_path.is_absolute() or windows_path.is_absolute() or windows_path.drive: + raise ValueError("bundle path must be relative") + if any(part in {"", ".", ".."} for part in posix_path.parts): + raise ValueError("bundle path must be normalized and cannot traverse parents") + return posix_path.as_posix() + + +class AssetFile(ContractModel): + """One content-addressed file inside an :class:`AssetBundle`.""" + + role: AssetFileRole + relative_path: Annotated[ + str, + Field(min_length=1, max_length=1024, description="Portable path relative to the bundle."), + ] + sha256: Sha256Hex + size_bytes: Annotated[int, Field(ge=0)] + media_type: str | None = Field(default=None, description="IANA media type when known.") + required: bool = True + + @field_validator("relative_path") + @classmethod + def validate_relative_path(cls, value: str) -> str: + return _validate_bundle_path(value) + + +class AssetBundle(ContractModel): + """Portable manifest for all files required by one logical input.""" + + schema_version: SchemaVersion = SchemaVersion.V1 + asset_id: AssetId + kind: AssetKind + category: AssetCategory + display_name: Annotated[str, Field(min_length=1, max_length=256)] + primary_file: Annotated[str, Field(min_length=1, max_length=1024)] + files: Annotated[list[AssetFile], Field(min_length=1)] + source: AssetSource | None = None + detected: AssetDetected | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + @field_validator("primary_file") + @classmethod + def validate_primary_file(cls, value: str) -> str: + return _validate_bundle_path(value) + + @model_validator(mode="after") + def validate_manifest(self) -> AssetBundle: + paths = [item.relative_path for item in self.files] + if len(paths) != len(set(paths)): + raise ValueError("asset bundle contains duplicate relative paths") + if self.primary_file not in paths: + raise ValueError("primary_file must reference a file in the bundle") + return self + + +class AssetInspection(ContractModel): + """Compact, structured facts discovered without running retargeting.""" + + schema_version: SchemaVersion = SchemaVersion.V1 + asset_id: AssetId + status: InspectionStatus + kind: AssetKind + category: AssetCategory + source_format: str | None = Field(default=None, description="Detected source format.") + dataset: str | None = None + reference_model: str | None = Field( + default=None, + description="Detected human reference, for example smpl, smplh, or smplx.", + ) + frame_count: Annotated[int | None, Field(default=None, ge=0)] + frame_rate_hz: Annotated[float | None, Field(default=None, gt=0)] + duration_seconds: Annotated[float | None, Field(default=None, ge=0)] + joint_count: Annotated[int | None, Field(default=None, ge=0)] + has_object: bool = False + has_terrain: bool = False + warnings: list[str] = Field(default_factory=list) + errors: list[ApiError] = Field(default_factory=list) + metadata: dict[str, Any] = Field(default_factory=dict) + + @model_validator(mode="after") + def validate_status(self) -> AssetInspection: + if self.status is InspectionStatus.INVALID and not self.errors: + raise ValueError("invalid inspections must include at least one error") + if self.errors and self.status is not InspectionStatus.INVALID: + raise ValueError("inspections with errors must use invalid status") + if self.status is InspectionStatus.VALID and (self.warnings or self.errors): + raise ValueError("valid inspections cannot include warnings or errors") + if self.status is InspectionStatus.VALID_WITH_WARNINGS and not self.warnings: + raise ValueError("valid_with_warnings inspections must include a warning") + return self + + +class AssetRegistrationRequest(ContractModel): + """Register a bundle from a path below a server-configured root.""" + + schema_version: SchemaVersion = SchemaVersion.V1 + root_id: Annotated[str, Field(min_length=1, max_length=128)] + relative_path: Annotated[str, Field(min_length=1, max_length=1024)] + display_name: Annotated[str | None, Field(default=None, max_length=256)] + kind: AssetKind | None = None + category: AssetCategory | None = None + recursive: bool = True + + @field_validator("relative_path") + @classmethod + def validate_relative_path(cls, value: str) -> str: + return _validate_bundle_path(value) + + +class AssetInspectionRequest(ContractModel): + """Request integrity and parse checks for an existing registered asset.""" + + schema_version: SchemaVersion = SchemaVersion.V1 + asset_id: AssetId + verify_hashes: bool = True + parse_content: bool = True + + +class AssetSearchResponse(ContractModel): + """Versioned, bounded search result for registered asset manifests.""" + + schema_version: SchemaVersion = SchemaVersion.V1 + assets: list[AssetBundle] = Field(default_factory=list) + total: Annotated[int, Field(ge=0)] + limit: Annotated[int, Field(ge=1, le=500)] + offset: Annotated[int, Field(ge=0)] diff --git a/hhtools/contracts/capabilities.py b/hhtools/contracts/capabilities.py new file mode 100644 index 00000000..0aa31707 --- /dev/null +++ b/hhtools/contracts/capabilities.py @@ -0,0 +1,131 @@ +"""Service, backend, and compute-device capability contracts.""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Annotated, Any + +from pydantic import Field, model_validator + +from .assets import AssetCategory +from .common import ContractModel, SchemaVersion + + +class DeviceKind(StrEnum): + CPU = "cpu" + CUDA = "cuda" + MPS = "mps" + + +class SchedulerMode(StrEnum): + """How the running and queued admission limits are configured.""" + + UNLIMITED = "unlimited" + LIMITED = "limited" + MIXED = "mixed" + + +class SchedulerCapability(ContractModel): + """Current admission policy and occupancy. + + ``max_running_jobs == 0`` disables admission control entirely in the Web + scheduler. In that state ``max_queued_jobs`` is retained as configured + metadata but is not enforced, so the effective mode remains ``unlimited``. + """ + + max_running_jobs: Annotated[int, Field(ge=0)] = 0 + max_queued_jobs: Annotated[int, Field(ge=0)] = 0 + running: Annotated[int, Field(ge=0)] = 0 + queued: Annotated[int, Field(ge=0)] = 0 + reserved: Annotated[int, Field(ge=0)] = 0 + mode: SchedulerMode + closed: bool = False + + @model_validator(mode="after") + def validate_mode(self) -> SchedulerCapability: + expected = SchedulerMode.MIXED + if self.max_running_jobs == 0: + expected = SchedulerMode.UNLIMITED + elif self.max_running_jobs > 0 and self.max_queued_jobs > 0: + expected = SchedulerMode.LIMITED + if self.mode is not expected: + raise ValueError(f"mode must be {expected.value} for the configured limits") + return self + + +class DeviceCapability(ContractModel): + """One execution device visible to the HHTools service.""" + + device_id: Annotated[str, Field(min_length=1, max_length=128)] + kind: DeviceKind + display_name: Annotated[str, Field(min_length=1, max_length=256)] + available: bool + total_memory_bytes: Annotated[int | None, Field(default=None, ge=0)] + free_memory_bytes: Annotated[int | None, Field(default=None, ge=0)] + compute_capability: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + +class BackendCapability(ContractModel): + """A retargeting backend and the inputs/outputs it can handle.""" + + backend_id: Annotated[ + str, + Field(min_length=1, max_length=128, pattern=r"^[a-z][a-z0-9_-]*$"), + ] + display_name: Annotated[str, Field(min_length=1, max_length=256)] + available: bool + version: str | None = None + supported_categories: list[AssetCategory] = Field(default_factory=list) + output_formats: list[str] = Field(default_factory=list) + unavailable_reason: str | None = None + features: dict[str, bool] = Field(default_factory=dict) + limits: dict[str, Any] = Field(default_factory=dict) + + +class RobotCapability(ContractModel): + """Agent-facing robot availability and calibration summary.""" + + robot_id: Annotated[str, Field(min_length=1, max_length=256)] + display_name: Annotated[str, Field(min_length=1, max_length=256)] + available: bool + has_urdf: bool + has_ik_mapping: bool + dof_count: Annotated[int | None, Field(default=None, ge=0)] + supported_references: list[str] = Field(default_factory=list) + calibrated_references: list[str] = Field(default_factory=list) + scaler_references: list[str] = Field(default_factory=list) + unavailable_reason: str | None = None + + +class RobotListResponse(ContractModel): + """Stable envelope for MCP robot discovery without repeating all capabilities.""" + + schema_version: SchemaVersion = SchemaVersion.V1 + robots: list[RobotCapability] = Field(default_factory=list) + + +class CapabilityResponse(ContractModel): + """Compact discovery response used before an agent builds a plan.""" + + schema_version: SchemaVersion = SchemaVersion.V1 + service_name: str = "hhtools" + service_version: Annotated[str, Field(min_length=1)] + agent_api_version: str = "v1" + backends: list[BackendCapability] = Field(default_factory=list) + devices: list[DeviceCapability] = Field(default_factory=list) + robots: list[RobotCapability] = Field(default_factory=list) + scheduler: SchedulerCapability + asset_root_ids: list[ + Annotated[ + str, + Field( + min_length=1, + max_length=128, + pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]*$", + ), + ] + ] = Field(default_factory=list) + supported_input_formats: list[str] = Field(default_factory=list) + supported_output_formats: list[str] = Field(default_factory=list) + features: dict[str, bool] = Field(default_factory=dict) diff --git a/hhtools/contracts/cli.py b/hhtools/contracts/cli.py new file mode 100644 index 00000000..b5d23806 --- /dev/null +++ b/hhtools/contracts/cli.py @@ -0,0 +1,135 @@ +"""Versioned, transport-safe documents emitted by the strict JSON CLI.""" + +from __future__ import annotations + +from typing import Annotated, Literal + +from pydantic import Field + +from .common import ContractModel, SchemaVersion + +AgentCliCommandName = Literal[ + "hhtools agent", + "hhtools agent capabilities", + "hhtools agent asset", + "hhtools agent asset register", + "hhtools agent asset get", + "hhtools agent asset inspect", + "hhtools agent asset search", + "hhtools agent preflight", + "hhtools agent preflight retarget", + "hhtools agent job", + "hhtools agent job start", + "hhtools agent job get", + "hhtools agent job lookup", + "hhtools agent job cancel", + "hhtools agent job retry", + "hhtools agent artifact", + "hhtools agent artifact list", + "hhtools agent artifact get", + "hhtools agent legacy", + "hhtools agent legacy upgrade", +] +AgentCliDiagnosticArgument = Literal[ + "COMMAND", + "ASSET_ID", + "JOB_ID", + "ARTIFACT_ID", + "", + "--base-url", + "--timeout", + "--request", + "--plan", + "--idempotency-key", + "--query", + "--kind", + "--category", + "--dataset", + "--reference", + "--no-verify-hashes", + "--no-parse-content", + "--after-revision", + "--limit", + "--offset", + "--verify", + "--output", + "--force", + "--json", + "--help", + "-h", +] +AgentCliReasonCode = Literal[ + "MISSING_COMMAND", + "UNKNOWN_COMMAND", + "MISSING_ARGUMENT", + "MISSING_VALUE", + "UNKNOWN_ARGUMENT", + "UNEXPECTED_ARGUMENT", + "DUPLICATE_ARGUMENT", + "INVALID_VALUE", + "INVALID_COMBINATION", + "REQUEST_FILE_UNREADABLE", + "REQUEST_ENCODING_INVALID", + "REQUEST_TOO_LARGE", + "REQUEST_JSON_INVALID", + "REQUEST_CONTRACT_INVALID", +] + + +class AgentCliArgumentDiagnostic(ContractModel): + """Sanitized CLI failure detail assembled only from static command metadata.""" + + reason_code: AgentCliReasonCode + command: AgentCliCommandName + argument: AgentCliDiagnosticArgument | None = None + expected: Annotated[str | None, Field(default=None, max_length=512)] + usage: Annotated[str, Field(min_length=1, max_length=1_024)] + + +class AgentCliHelpArgument(ContractModel): + """One documented positional or option in a JSON help response.""" + + name: Annotated[ + str, + Field(min_length=1, max_length=64, pattern=r"^(?:--?[a-z][a-z0-9-]*|[A-Z][A-Z0-9_]*)$"), + ] + value_name: Annotated[ + str | None, + Field(default=None, min_length=1, max_length=64, pattern=r"^[A-Z][A-Z0-9_]*$"), + ] + required: bool = False + description: Annotated[str, Field(min_length=1, max_length=512)] + + +class AgentCliHelpSubcommand(ContractModel): + """One immediate child command in a JSON help response.""" + + name: Annotated[ + str, + Field(min_length=1, max_length=64, pattern=r"^[a-z][a-z0-9-]*$"), + ] + summary: Annotated[str, Field(min_length=1, max_length=512)] + + +class AgentCliHelp(ContractModel): + """Machine-readable help that preserves the CLI's one-JSON-document invariant.""" + + schema_version: SchemaVersion = SchemaVersion.V1 + kind: Literal["agent_cli_help"] = "agent_cli_help" + command: AgentCliCommandName + summary: Annotated[str, Field(min_length=1, max_length=512)] + usage: Annotated[str, Field(min_length=1, max_length=1_024)] + positionals: list[AgentCliHelpArgument] = Field(default_factory=list, max_length=8) + options: list[AgentCliHelpArgument] = Field(default_factory=list, max_length=32) + subcommands: list[AgentCliHelpSubcommand] = Field(default_factory=list, max_length=16) + + +__all__ = [ + "AgentCliArgumentDiagnostic", + "AgentCliCommandName", + "AgentCliDiagnosticArgument", + "AgentCliHelp", + "AgentCliHelpArgument", + "AgentCliHelpSubcommand", + "AgentCliReasonCode", +] diff --git a/hhtools/contracts/common.py b/hhtools/contracts/common.py new file mode 100644 index 00000000..98577a02 --- /dev/null +++ b/hhtools/contracts/common.py @@ -0,0 +1,180 @@ +"""Shared primitives for HHTools' public agent contracts. + +The models in :mod:`hhtools.contracts` are transport-neutral. REST, JSON CLI, +OpenAPI, and MCP adapters should serialize these models instead of defining +their own wire formats. +""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Annotated, Any, Literal + +from pydantic import AfterValidator, BaseModel, ConfigDict, Field, field_validator + +from .portability import ( + PORTABLE_URI_HOST_PATTERN, + PORTABLE_URI_PORT_PATTERN, + PORTABLE_URI_TAIL_PATTERN, + is_portable_next_action_url, + is_portable_resource_uri, +) + + +class ContractModel(BaseModel): + """Base class that rejects misspelled or unsupported wire fields.""" + + model_config = ConfigDict( + extra="forbid", + str_strip_whitespace=True, + validate_assignment=True, + ) + + +class SchemaVersion(StrEnum): + """Version of the transport-neutral HHTools agent schema.""" + + V1 = "1.0" + + +MachineCode = Annotated[ + str, + Field( + min_length=1, + max_length=128, + pattern=r"^[A-Z][A-Z0-9_]*$", + description="Stable, English, machine-readable code.", + ), +] + +Sha256Hex = Annotated[ + str, + Field(pattern=r"^[0-9a-f]{64}$", description="Lower-case SHA-256 content digest."), +] +AssetId = Annotated[ + str, + Field(pattern=r"^asset:sha256:[0-9a-f]{64}$", description="Content-addressed asset id."), +] +PlanId = Annotated[ + str, + Field(pattern=r"^plan:sha256:[0-9a-f]{64}$", description="Content-addressed plan id."), +] +CalibrationId = Annotated[ + str, + Field(pattern=r"^cal:sha256:[0-9a-f]{64}$", description="Content-addressed calibration id."), +] +ArtifactId = Annotated[ + str, + Field( + pattern=r"^artifact:[a-z][a-z0-9_-]*:[A-Za-z0-9._~-]+$", + description="Artifact id with a stable kind namespace.", + ), +] + +_HTTP_URI = ( + rf"https?://{PORTABLE_URI_HOST_PATTERN}" + rf"(?::{PORTABLE_URI_PORT_PATTERN})?{PORTABLE_URI_TAIL_PATTERN}" +) +_HTTPS_URI = ( + rf"https://{PORTABLE_URI_HOST_PATTERN}" + rf"(?::{PORTABLE_URI_PORT_PATTERN})?{PORTABLE_URI_TAIL_PATTERN}" +) +_HHTOOLS_ARTIFACT_URI = ( + r"hhtools://jobs/[A-Za-z0-9._~:-]+/artifacts/[A-Za-z0-9._~:-]+" +) +_UI_QUERY_PAIR = r"(?:calibrate|panel|robot|view)=[^&#\s]{0,256}" +_UI_QUERY = rf"\?(?:{_UI_QUERY_PAIR}(?:&{_UI_QUERY_PAIR})*)?" +_LOCAL_UI_URL = ( + rf"(?:/(?:{_UI_QUERY})?|http://(?:127\.0\.0\.1|localhost|\[::1\]):" + rf"{PORTABLE_URI_PORT_PATTERN}/(?:{_UI_QUERY})?)" +) +_RESOURCE_URI_PATTERN = rf"^(?:{_HTTP_URI}|{_HHTOOLS_ARTIFACT_URI})$" +_NEXT_ACTION_URL_PATTERN = rf"^(?:{_LOCAL_UI_URL}|{_HTTPS_URI})$" + + +def _validate_resource_uri(value: str) -> str: + if not is_portable_resource_uri(value): + raise ValueError("resource URI must be canonical and host independent") + return value + + +ResourceUri = Annotated[ + str, + Field( + min_length=1, + pattern=_RESOURCE_URI_PATTERN, + description=("Canonical job-scoped HHTools artifact URI or portable HTTP(S) URI."), + ), + AfterValidator(_validate_resource_uri), +] + + +class NextAction(ContractModel): + """An explicit recovery or continuation step for an agent or human.""" + + actor: Literal["agent", "human", "system"] + action: Annotated[ + str, + Field( + min_length=1, + max_length=128, + pattern=r"^[a-z][a-z0-9_]*$", + description="Stable, English action identifier.", + ), + ] + message: str | None = Field( + default=None, + description="Optional human-readable instruction.", + ) + url: str | None = Field( + default=None, + pattern=_NEXT_ACTION_URL_PATTERN, + description=( + "Optional allowlisted local calibration UI route or portable HTTPS documentation URL." + ), + ) + parameters: dict[str, Any] = Field( + default_factory=dict, + description="Structured parameters needed to perform the action.", + ) + + @field_validator("url") + @classmethod + def validate_url(cls, value: str | None) -> str | None: + if value is not None and not is_portable_next_action_url(value): + raise ValueError( + "url must be an allowlisted local UI route or portable HTTPS documentation" + ) + return value + + +class ErrorStage(StrEnum): + """Stable stage in which an API error occurred.""" + + REQUEST = "request" + ASSET_REGISTRATION = "asset_registration" + ASSET_INSPECTION = "asset_inspection" + PREFLIGHT = "preflight" + ADMISSION = "admission" + EXECUTION = "execution" + EVALUATION = "evaluation" + ARTIFACT = "artifact" + INTERNAL = "internal" + + +class ApiError(ContractModel): + """Structured failure that an agent can inspect without parsing prose.""" + + schema_version: SchemaVersion = SchemaVersion.V1 + code: MachineCode + message: Annotated[ + str, + Field(min_length=1, description="Human-readable, potentially localized explanation."), + ] + retryable: bool = False + stage: ErrorStage + details: dict[str, Any] = Field( + default_factory=dict, + description="Small structured context; large payloads belong in artifacts.", + ) + next_action: NextAction | None = None diff --git a/hhtools/contracts/job_spec.py b/hhtools/contracts/job_spec.py new file mode 100644 index 00000000..eee74e41 --- /dev/null +++ b/hhtools/contracts/job_spec.py @@ -0,0 +1,87 @@ +"""Auditable JobSpec v2 contract. + +JobSpec v1 remains implemented in :mod:`hhtools.web.job_specs`; this module +does not reinterpret or rewrite it. A v1 replay must register its assets and +run preflight before a truthful v2 spec can be created. +""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Annotated, Any, Literal + +from pydantic import AwareDatetime, ConfigDict, Field, model_validator + +from .common import AssetId, CalibrationId, ContractModel, PlanId, Sha256Hex +from .preflight import OutputPolicy + + +class JobSpecKind(StrEnum): + RETARGET = "retarget" + BATCH_RETARGET = "batch_retarget" + + +class JobSpecInput(ContractModel): + """Content-bound input reference used by an executable job.""" + + asset_id: AssetId + sha256: Sha256Hex + + +class JobSpecRobot(ContractModel): + """Robot identity and exact configuration used by the job.""" + + robot_id: Annotated[str, Field(min_length=1, max_length=256)] + asset_id: AssetId + config_sha256: Sha256Hex + + +class JobSpecCalibration(ContractModel): + """Exact calibration selected by preflight.""" + + calibration_id: CalibrationId + sha256: Sha256Hex + + +class JobSpecProvenance(ContractModel): + """Code, dependency, and execution-device identity for reproduction.""" + + hhtools_git_commit: Annotated[str, Field(min_length=1, max_length=128)] + hhtools_dirty: bool + python: Annotated[str, Field(min_length=1, max_length=128)] + pytorch: str | None = None + cuda: str | None = None + newton: str | None = None + device: str | None = None + platform: str | None = None + dependencies: dict[str, str] = Field(default_factory=dict) + + +class JobSpecV2(ContractModel): + """Immutable, preflight-resolved execution identity for a retarget job.""" + + model_config = ConfigDict( + extra="forbid", + str_strip_whitespace=True, + validate_assignment=True, + frozen=True, + ) + + schema_version: Literal[2] = 2 + kind: JobSpecKind + plan_id: PlanId + inputs: Annotated[list[JobSpecInput], Field(min_length=1)] + robot: JobSpecRobot + calibration: JobSpecCalibration | None + backend: Annotated[str, Field(min_length=1, max_length=128)] + effective_parameters: dict[str, Any] = Field(default_factory=dict) + output_policy: OutputPolicy + provenance: JobSpecProvenance + created_at: AwareDatetime + + @model_validator(mode="after") + def validate_unique_inputs(self) -> JobSpecV2: + asset_ids = [item.asset_id for item in self.inputs] + if len(asset_ids) != len(set(asset_ids)): + raise ValueError("JobSpec v2 inputs must not contain duplicate asset ids") + return self diff --git a/hhtools/contracts/jobs.py b/hhtools/contracts/jobs.py new file mode 100644 index 00000000..fd89e856 --- /dev/null +++ b/hhtools/contracts/jobs.py @@ -0,0 +1,246 @@ +"""Compact job and artifact contracts for polling agents.""" + +from __future__ import annotations + +import re +from enum import StrEnum +from typing import Annotated, Any + +from pydantic import AliasChoices, AwareDatetime, Field, field_validator, model_validator + +from .capabilities import SchedulerMode +from .common import ( + ApiError, + ArtifactId, + ContractModel, + NextAction, + PlanId, + ResourceUri, + SchemaVersion, + Sha256Hex, +) + +IdempotencyKey = Annotated[ + str, + Field( + min_length=1, + max_length=256, + pattern=r"^[A-Za-z0-9][A-Za-z0-9._~:-]{0,255}$", + description="Caller-generated key binding one logical job submission.", + ), +] +_MEDIA_TYPE = re.compile( + r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+/[!#$%&'*+.^_`|~0-9A-Za-z-]+" + r"(?:[ \t]*;[^\r\n\x00-\x08\x0b\x0c\x0e-\x1f\x7f]+)*$" +) + + +class JobStartRequest(ContractModel): + """Submit one already-preflighted immutable retarget plan.""" + + schema_version: SchemaVersion = SchemaVersion.V1 + plan_id: PlanId + idempotency_key: IdempotencyKey + + +class JobRetryRequest(ContractModel): + """Create a child attempt without mutating the terminal parent job.""" + + schema_version: SchemaVersion = SchemaVersion.V1 + idempotency_key: IdempotencyKey + + +class JobLookupRequest(ContractModel): + """Recover one caller-owned submission without enumerating other jobs.""" + + schema_version: SchemaVersion = SchemaVersion.V1 + plan_id: PlanId + idempotency_key: IdempotencyKey + after_revision: Annotated[int | None, Field(default=None, ge=0)] + + +class JobState(StrEnum): + """Execution lifecycle, independent from output quality.""" + + QUEUED = "queued" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +class JobOutcome(StrEnum): + """Semantic result of a completed job.""" + + SUCCESS = "success" + PARTIAL = "partial" + REVIEW_REQUIRED = "review_required" + REJECTED = "rejected" + + +class JobProgress(ContractModel): + """Small monotonic progress snapshot suitable for frequent polling.""" + + phase: Annotated[str, Field(min_length=1, max_length=128)] = "queued" + fraction: Annotated[float, Field(ge=0.0, le=1.0)] = 0.0 + revision: Annotated[int, Field(ge=0)] = 0 + completed_items: Annotated[int | None, Field(default=None, ge=0)] + total_items: Annotated[int | None, Field(default=None, ge=0)] + message: Annotated[str | None, Field(default=None, max_length=2_048)] + updated_at: AwareDatetime | None = None + eta_seconds: Annotated[float | None, Field(default=None, ge=0)] + + @model_validator(mode="after") + def validate_item_counts(self) -> JobProgress: + if self.completed_items is not None and self.total_items is None: + raise ValueError("total_items is required when completed_items is provided") + if ( + self.completed_items is not None + and self.total_items is not None + and self.completed_items > self.total_items + ): + raise ValueError("completed_items cannot exceed total_items") + return self + + +class ArtifactDescriptor(ContractModel): + """Metadata and URI for a job output; binary data is never embedded.""" + + schema_version: SchemaVersion = SchemaVersion.V1 + artifact_id: ArtifactId + job_id: Annotated[str, Field(min_length=1, max_length=256)] + kind: Annotated[ + str, + Field(min_length=1, max_length=128, pattern=r"^[a-z][a-z0-9_-]{0,127}$"), + ] + format: Annotated[ + str | None, + Field( + default=None, + min_length=1, + max_length=32, + pattern=r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,31}$", + ), + ] + resource_uri: Annotated[ + ResourceUri, + Field( + min_length=1, + validation_alias=AliasChoices("resource_uri", "uri"), + description=( + "Resolvable canonical job-scoped HHTools artifact URI or portable HTTP(S) URI." + ), + ), + ] + media_type: Annotated[str | None, Field(default=None, max_length=255)] + size_bytes: Annotated[int | None, Field(default=None, ge=0)] + sha256: Sha256Hex | None = None + created_at: AwareDatetime | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + @field_validator("media_type") + @classmethod + def validate_media_type(cls, value: str | None) -> str | None: + if value is not None and _MEDIA_TYPE.fullmatch(value) is None: + raise ValueError("media_type must be a safe MIME type without control characters") + return value + + @property + def uri(self) -> str: + """Read-only compatibility accessor; JSON always uses ``resource_uri``.""" + + return self.resource_uri + + +class ArtifactListResponse(ContractModel): + """Bounded page of canonical artifacts attached to one job.""" + + schema_version: SchemaVersion = SchemaVersion.V1 + job_id: Annotated[str, Field(min_length=1, max_length=256)] + artifacts: list[ArtifactDescriptor] = Field(default_factory=list, max_length=500) + total: Annotated[int, Field(ge=0)] + limit: Annotated[int, Field(ge=1, le=500)] = 100 + offset: Annotated[int, Field(ge=0)] = 0 + + @model_validator(mode="after") + def validate_page(self) -> ArtifactListResponse: + if any(item.job_id != self.job_id for item in self.artifacts): + raise ValueError("every artifact must belong to the requested job") + if len(self.artifacts) > self.limit: + raise ValueError("the returned artifact page cannot exceed limit") + if self.artifacts and self.offset + len(self.artifacts) > self.total: + raise ValueError("the returned artifact page cannot extend beyond total") + return self + + +class JobQueueView(ContractModel): + """Queue position and admission settings captured with a job snapshot.""" + + position: Annotated[int | None, Field(default=None, ge=1)] + max_running_jobs: Annotated[int, Field(ge=0)] = 0 + max_queued_jobs: Annotated[int, Field(ge=0)] = 0 + mode: SchedulerMode + + +class AgentJobView(ContractModel): + """Compact default job view; large arrays live behind artifact URIs.""" + + schema_version: SchemaVersion = SchemaVersion.V1 + job_id: Annotated[str, Field(min_length=1, max_length=256)] + parent_job_id: Annotated[str | None, Field(default=None, min_length=1, max_length=256)] + root_job_id: Annotated[str | None, Field(default=None, min_length=1, max_length=256)] + attempt: Annotated[int, Field(ge=1)] = 1 + state: JobState + outcome: JobOutcome | None = None + progress: JobProgress + summary: dict[str, Any] = Field( + default_factory=dict, + description="Small input/backend/robot summary, never trajectory arrays.", + ) + queue: JobQueueView | None = None + artifacts: list[ArtifactDescriptor] = Field(default_factory=list, max_length=32) + artifact_count: Annotated[int | None, Field(default=None, ge=0)] + error: ApiError | None = None + next_action: NextAction | None = None + cancellation_requested: bool = False + cancellable: bool = False + submitted_at: AwareDatetime + started_at: AwareDatetime | None = None + completed_at: AwareDatetime | None = None + poll_after_ms: Annotated[int | None, Field(default=None, ge=0, le=300_000)] + + @model_validator(mode="after") + def validate_lifecycle(self) -> AgentJobView: + terminal = {JobState.COMPLETED, JobState.FAILED, JobState.CANCELLED} + if self.outcome is not None and self.state is not JobState.COMPLETED: + raise ValueError("outcome is only valid for completed jobs") + if self.state is JobState.COMPLETED and self.outcome is None: + raise ValueError("completed jobs must include an outcome") + if self.state is JobState.FAILED and self.error is None: + raise ValueError("failed jobs must include an error") + if self.state is not JobState.FAILED and self.error is not None: + raise ValueError("only failed jobs may include an error") + if self.state is JobState.QUEUED and self.started_at is not None: + raise ValueError("queued jobs cannot include started_at") + if self.state is JobState.RUNNING and self.started_at is None: + raise ValueError("running jobs must include started_at") + if self.state in terminal and self.completed_at is None: + raise ValueError("terminal jobs must include completed_at") + if self.state not in terminal and self.completed_at is not None: + raise ValueError("non-terminal jobs cannot include completed_at") + if self.state in terminal and self.cancellable: + raise ValueError("terminal jobs cannot be cancellable") + if self.artifact_count is not None and self.artifact_count < len(self.artifacts): + raise ValueError("artifact_count cannot be smaller than returned artifacts") + if self.parent_job_id is None: + if self.root_job_id is not None or self.attempt != 1: + raise ValueError("root jobs cannot declare retry lineage") + elif self.parent_job_id == self.job_id or self.root_job_id is None or self.attempt < 2: + raise ValueError("retry jobs require valid parent/root lineage and attempt") + if self.started_at is not None and self.started_at < self.submitted_at: + raise ValueError("started_at cannot precede submitted_at") + if self.completed_at is not None: + baseline = self.started_at or self.submitted_at + if self.completed_at < baseline: + raise ValueError("completed_at cannot precede the job start") + return self diff --git a/hhtools/contracts/migration.py b/hhtools/contracts/migration.py new file mode 100644 index 00000000..d3b51bda --- /dev/null +++ b/hhtools/contracts/migration.py @@ -0,0 +1,84 @@ +"""Public contracts for safe, non-executing legacy JobSpec upgrades.""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import ConfigDict, Field, field_validator, model_validator + +from .common import ( + AssetId, + ContractModel, + MachineCode, + PlanId, + SchemaVersion, + Sha256Hex, +) +from .job_spec import JobSpecV2 +from .preflight import PreflightResponse, PreflightStatus + + +class LegacyJobUpgradeRequest(ContractModel): + """One bounded JSON object containing a JobSpec v1 or download wrapper. + + The migration service remains responsible for the stricter v1 shape, + depth, node-count, byte-size, allowlisted-root, and content checks. This + wrapper only gives REST and JSON CLI a stable, versioned transport shape. + """ + + schema_version: SchemaVersion = SchemaVersion.V1 + payload: dict[str, Any] = Field( + description="Raw JobSpec v1 document or an existing single-job download wrapper." + ) + + +class LegacyMigrationReceipt(ContractModel): + """Portable proof of how one canonical v1 document became JobSpec v2.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + schema_version: Literal["1.0"] = "1.0" + semantics: Literal["hhtools.legacy-job-upgrade.v1"] = "hhtools.legacy-job-upgrade.v1" + source_schema_version: Literal[1] = 1 + canonical_v1_sha256: Sha256Hex + motion_asset_id: AssetId + robot_asset_id: AssetId + plan_id: PlanId + job_spec_sha256: Sha256Hex + output_format: Literal["csv"] = "csv" + output_policy: Literal["create_new"] = "create_new" + warnings: tuple[MachineCode, ...] = () + + @field_validator("warnings") + @classmethod + def validate_warnings(cls, value: tuple[str, ...]) -> tuple[str, ...]: + if tuple(sorted(set(value))) != value: + raise ValueError("migration warning codes must be unique and sorted") + return value + + +class LegacyJobUpgradeResponse(ContractModel): + """Non-executing upgrade result tied to its authoritative preflight.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + schema_version: SchemaVersion = SchemaVersion.V1 + preflight: PreflightResponse + job_spec: JobSpecV2 | None = None + receipt: LegacyMigrationReceipt | None = None + + @model_validator(mode="after") + def validate_preflight_result(self) -> LegacyJobUpgradeResponse: + ready = self.preflight.status is PreflightStatus.READY + complete = self.job_spec is not None and self.receipt is not None + empty = self.job_spec is None and self.receipt is None + if (ready and not complete) or (not ready and not empty): + raise ValueError("upgrade result must match its preflight status") + return self + + +__all__ = [ + "LegacyJobUpgradeRequest", + "LegacyJobUpgradeResponse", + "LegacyMigrationReceipt", +] diff --git a/hhtools/contracts/portability.py b/hhtools/contracts/portability.py new file mode 100644 index 00000000..71b54ffc --- /dev/null +++ b/hhtools/contracts/portability.py @@ -0,0 +1,526 @@ +"""Transport-neutral guards for bounded, host-independent public JSON.""" + +from __future__ import annotations + +import json +import math +import re +from dataclasses import dataclass +from pathlib import PurePosixPath, PureWindowsPath +from typing import Any +from urllib.parse import parse_qsl, unquote, urlsplit + +# Public contracts contain metadata, not bulk payloads. Binary output belongs in +# an artifact; the aggregate budget also prevents splitting one payload across fields. +MAX_PORTABLE_STRING_BYTES = 1024 * 1024 +MAX_PORTABLE_DOCUMENT_STRING_BYTES = 2 * 1024 * 1024 +MAX_PORTABLE_DOCUMENT_BYTES = 2 * 1024 * 1024 +MAX_INLINE_BASE64_DECODED_BYTES = 64 * 1024 +MAX_PORTABLE_CONTAINER_ITEMS = 10_000 +MAX_PORTABLE_NODES = 262_144 +MAX_PORTABLE_DEPTH = 64 + +PORTABLE_URI_PORT_PATTERN = ( + r"(?:0|[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|" + r"65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])" +) +_IPV6_ADDRESS_PATTERN = ( + r"(?:" + r"(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|" + r"(?:[0-9A-Fa-f]{1,4}:){1,7}:|" + r"(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}|" + r"(?:[0-9A-Fa-f]{1,4}:){1,5}(?::[0-9A-Fa-f]{1,4}){1,2}|" + r"(?:[0-9A-Fa-f]{1,4}:){1,4}(?::[0-9A-Fa-f]{1,4}){1,3}|" + r"(?:[0-9A-Fa-f]{1,4}:){1,3}(?::[0-9A-Fa-f]{1,4}){1,4}|" + r"(?:[0-9A-Fa-f]{1,4}:){1,2}(?::[0-9A-Fa-f]{1,4}){1,5}|" + r"[0-9A-Fa-f]{1,4}:(?:(?::[0-9A-Fa-f]{1,4}){1,6})|" + r":(?:(?::[0-9A-Fa-f]{1,4}){1,7}|:)" + r")" +) +PORTABLE_URI_HOST_PATTERN = ( + rf"(?:\[(?:{_IPV6_ADDRESS_PATTERN})\]|[A-Za-z0-9._~-]+)" +) +_URI_PCHAR_PATTERN = ( + r"(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f]{2})" +) +PORTABLE_URI_TAIL_PATTERN = ( + rf"(?:[/?#](?:{_URI_PCHAR_PATTERN}|[/?#])*)?" +) + +_MAX_PERCENT_DECODE_ROUNDS = 8 +_MAX_NESTED_URI_DEPTH = 4 +_CONTROLLED_URI_TOKEN = re.compile( + r"(?:hhtools|https?)://[^\s\"'{}()<>]+", + re.IGNORECASE, +) +_EMBEDDED_URI_SCHEME = re.compile(r"(? tuple[str, ...]: + """Return bounded percent-decoding layers, failing closed on deeper nesting.""" + + layers = [value] + for _ in range(_MAX_PERCENT_DECODE_ROUNDS): + if _PERCENT_ESCAPE.search(layers[-1]) is None: + break + try: + decoded = unquote(layers[-1], errors="strict") + except UnicodeError: + # ``looks_like_host_path`` is a predicate used at protocol + # boundaries. Malformed percent-encoded bytes must be rejected, + # not escape as an implementation-specific decoder exception. + layers.append("/invalid-percent-encoding") + break + if decoded == layers[-1]: + break + layers.append(decoded) + if _PERCENT_ESCAPE.search(layers[-1]) is not None: + layers.append("/encoded-path-depth-exceeded") + return tuple(layers) + + +def _raw_host_path(value: str) -> bool: + if _EMBEDDED_FILE_URI.search(value) or _EMBEDDED_DATA_URI.search(value): + return True + posix = PurePosixPath(value) + windows = PureWindowsPath(value) + if posix.is_absolute() or windows.is_absolute() or windows.drive or windows.root: + return True + return bool( + _EMBEDDED_WINDOWS_PATH.search(value) + or _WINDOWS_DRIVE_ANYWHERE.search(value) + or _EMBEDDED_SENSITIVE_POSIX_PATH.search(value) + or _EMBEDDED_POSIX_PATH.search(value) + or _EMBEDDED_PROTOCOL_RELATIVE.search(value) + ) + + +def _decoded_value_has_host_path(value: str, *, nested_depth: int = 0) -> bool: + for layer in _decoded_layers(value): + try: + parsed = urlsplit(layer) + except (UnicodeError, ValueError): + return True + scheme = parsed.scheme.casefold() + if scheme in {"http", "https"}: + if nested_depth >= _MAX_NESTED_URI_DEPTH or _http_uri_has_host_path( + layer, + nested_depth=nested_depth + 1, + ): + return True + continue + if scheme == "hhtools": + if not _canonical_hhtools_uri(layer): + return True + continue + if scheme or _raw_host_path(layer): + return True + return False + + +def _canonical_hhtools_uri(value: str) -> bool: + try: + parsed = urlsplit(value) + invalid = ( + parsed.scheme.casefold() != "hhtools" + or parsed.username is not None + or parsed.password is not None + or parsed.port is not None + or parsed.query + or parsed.fragment + or "%" in value + ) + except (UnicodeError, ValueError): + return False + if invalid: + return False + authority = parsed.netloc + parts = parsed.path.removeprefix("/").split("/") if parsed.path else [] + if any(_SAFE_URI_SEGMENT.fullmatch(part) is None for part in parts): + return False + valid = False + if authority == "capabilities": + valid = not parts + elif authority == "schemas": + valid = len(parts) == 3 and parts[:2] == ["agent", "v1"] + elif authority in {"robots", "plans"}: + valid = len(parts) == 1 + elif authority == "assets": + valid = len(parts) == 2 and parts[1] == "manifest" + elif authority == "jobs": + report = len(parts) == 2 and parts[1] in { + "status", + "manifest", + "evaluation", + "failures", + } + valid = report or (len(parts) == 3 and parts[1] == "artifacts") + return valid + + +def _http_uri_has_host_path( # noqa: PLR0911 - fail closed at each URI boundary + value: str, + *, + nested_depth: int = 0, +) -> bool: + try: + parsed = urlsplit(value) + _port = parsed.port + except (UnicodeError, ValueError): + return True + if not parsed.hostname or parsed.username is not None or parsed.password is not None: + return True + if ( + _PORTABLE_IPV6_ADDRESS.fullmatch(parsed.hostname) is None + if ":" in parsed.hostname + else _PORTABLE_HOSTNAME.fullmatch(parsed.hostname) is None + ): + return True + if ( + _PORTABLE_HTTP_PATH.fullmatch(parsed.path) is None + or _PORTABLE_HTTP_QUERY_FRAGMENT.fullmatch(parsed.query) is None + or _PORTABLE_HTTP_QUERY_FRAGMENT.fullmatch(parsed.fragment) is None + ): + return True + + # A URL path is portable, but Windows/file syntax in it is not. Query values + # and fragments are data, so absolute path syntax there is always a leak. + for layer in _decoded_layers(parsed.path): + if _EMBEDDED_FILE_URI.search(layer) or _WINDOWS_DRIVE_ANYWHERE.search(layer): + return True + try: + query_items = parse_qsl(parsed.query, keep_blank_values=True, max_num_fields=32) + except (UnicodeError, ValueError): + return True + for key, item in query_items: + if _decoded_value_has_host_path(key, nested_depth=nested_depth): + return True + if not _decoded_value_has_host_path(item, nested_depth=nested_depth): + continue + if key in _SAFE_WEB_ROUTE_QUERY_KEYS and _safe_root_relative_web_path(item): + continue + return True + return _decoded_value_has_host_path(parsed.fragment, nested_depth=nested_depth) + + +def _safe_root_relative_web_path(value: str) -> bool: + """Allow a bounded web route without treating it as a filesystem path.""" + + for layer in _decoded_layers(value): + if layer in {"/encoded-path-depth-exceeded", "/invalid-percent-encoding"}: + return False + if ( + not layer.startswith("/") + or layer.startswith("//") + or len(layer) > 2048 + or _EMBEDDED_FILE_URI.search(layer) + or _WINDOWS_DRIVE_ANYWHERE.search(layer) + or _EMBEDDED_SENSITIVE_POSIX_PATH.search(layer) + ): + return False + return True + + +def _safe_next_action_url(value: str) -> bool: + try: + parsed = urlsplit(value) + port = parsed.port + except (UnicodeError, ValueError): + return False + if parsed.scheme: + if ( + parsed.scheme != "http" + or parsed.hostname not in {"127.0.0.1", "localhost", "::1"} + or parsed.username is not None + or parsed.password is not None + or port is None + ): + return False + elif parsed.netloc or not value.startswith("/"): + return False + if parsed.path != "/" or parsed.fragment: + return False + try: + fields = parse_qsl(parsed.query, keep_blank_values=True, max_num_fields=16) + except (UnicodeError, ValueError): + return False + return all( + key in _SAFE_UI_QUERY_KEYS + and _UI_QUERY_KEY.fullmatch(key) is not None + and len(item) <= 256 + and not _decoded_value_has_host_path(item) + for key, item in fields + ) + + +def _looks_like_host_path_once(value: str) -> bool: + # Inspect path syntax before masking a controlled URI. Otherwise a + # malicious URI authority such as ``https://C:\\Users\\...@host`` can make + # the URI regex consume the drive prefix and leave only a harmless-looking + # suffix behind. + if ( + _EMBEDDED_FILE_URI.search(value) + or _EMBEDDED_DATA_URI.search(value) + or _URI_WITH_USERINFO.search(value) + ): + return True + for match in _EMBEDDED_URI_SCHEME.finditer(value): + if match.group(1).casefold() not in {"hhtools", "http", "https"}: + return True + + unsafe_uri = False + + def portable_uri(uri: str) -> bool: + return ( + _canonical_hhtools_uri(uri) + if uri.casefold().startswith("hhtools://") + else not _http_uri_has_host_path(uri) + ) + + def mask_uri(match: re.Match[str]) -> str: + nonlocal unsafe_uri + uri = match.group(0) + safe = portable_uri(uri) + # Square brackets commonly wrap a URI in prose and tests. Keep a + # single closing wrapper outside the mask only when removing it turns + # the complete URI into a valid portable token; malformed IPv6 remains + # fail-closed because its truncated candidate is invalid too. + if not safe and uri.endswith("]") and portable_uri(uri[:-1]): + return "]" + if not safe: + unsafe_uri = True + return uri + return "" + + masked = _CONTROLLED_URI_TOKEN.sub(mask_uri, value) + return unsafe_uri or _raw_host_path(masked) + + +def looks_like_host_path( + value: str, + *, + allow_same_origin_ui_url: bool = False, +) -> bool: + """Detect raw or encoded POSIX, Windows, UNC, and file paths.""" + + try: + value.encode("utf-8") + except UnicodeError: + return True + if allow_same_origin_ui_url and _safe_next_action_url(value): + return False + return any(_looks_like_host_path_once(layer) for layer in _decoded_layers(value)) + + +def _safe_documentation_url(value: str) -> bool: + try: + parsed = urlsplit(value) + _port = parsed.port + except (UnicodeError, ValueError): + return False + return bool( + parsed.scheme == "https" + and parsed.hostname + and parsed.username is None + and parsed.password is None + and not _http_uri_has_host_path(value) + ) + + +def is_portable_next_action_url(value: str) -> bool: + """Whether a NextAction URL is a bounded local UI route or HTTPS documentation.""" + + return _safe_next_action_url(value) or _safe_documentation_url(value) + + +def is_portable_resource_uri(value: str) -> bool: + """Whether a public resource URI is canonical and host independent.""" + + try: + parsed = urlsplit(value) + except (UnicodeError, ValueError): + return False + if parsed.scheme.casefold() == "hhtools": + return _canonical_hhtools_uri(value) + if parsed.scheme.casefold() in {"http", "https"}: + return not _http_uri_has_host_path(value) + return False + + +def _looks_like_large_base64(value: str) -> bool: + explicit = _EXPLICIT_BASE64_PREFIX.match(value) + payload = value[explicit.end() :] if explicit is not None else value + compact = ( + "".join(payload.split()) + if explicit is not None + else payload.replace("\r", "").replace("\n", "") + ) + if _BASE64.fullmatch(compact) is None: + return False + core = compact.rstrip("=") + padding = len(compact) - len(core) + # RFC 4648 URL-safe values are commonly emitted without trailing padding. + # A one-character remainder can never be valid Base64; padded values must + # retain the normal four-character block shape. + if len(core) % 4 == 1 or (padding and len(compact) % 4): + return False + decoded_size = (len(core) * 6) // 8 + return decoded_size > MAX_INLINE_BASE64_DECODED_BYTES + + +@dataclass +class _StringBudget: + used_bytes: int = 0 + nodes: int = 0 + + def enter(self, *, depth: int) -> None: + if depth > MAX_PORTABLE_DEPTH: + raise PortableJsonError("document nesting too deep") + self.nodes += 1 + if self.nodes > MAX_PORTABLE_NODES: + raise PortableJsonError("document node budget exceeded") + + @staticmethod + def check_container(size: int) -> None: + if size > MAX_PORTABLE_CONTAINER_ITEMS: + raise PortableJsonError("container item budget exceeded") + + def consume(self, value: str) -> None: + if len(value) > MAX_PORTABLE_STRING_BYTES: + raise PortableJsonError("string too large") + try: + size = len(value.encode("utf-8")) + except UnicodeError as error: + raise PortableJsonError("invalid UTF-8 string") from error + if size > MAX_PORTABLE_STRING_BYTES: + raise PortableJsonError("string too large") + if _looks_like_large_base64(value): + raise PortableJsonError("inline base64 payload too large") + self.used_bytes += size + if self.used_bytes > MAX_PORTABLE_DOCUMENT_STRING_BYTES: + raise PortableJsonError("document string budget exceeded") + + +def _validate_portable_json( + value: Any, + budget: _StringBudget, + *, + depth: int, +) -> None: + budget.enter(depth=depth) + if value is None or isinstance(value, bool | int): + return + if isinstance(value, float): + if not math.isfinite(value): + raise PortableJsonError("non-finite number") + return + if isinstance(value, str): + budget.consume(value) + if looks_like_host_path(value): + raise PortableJsonError("host path") + return + if isinstance(value, list): + budget.check_container(len(value)) + for item in value: + _validate_portable_json(item, budget, depth=depth + 1) + return + if isinstance(value, dict): + budget.check_container(len(value)) + actor = value.get("actor") + next_action = ( + isinstance(actor, str) + and actor in {"agent", "human", "system"} + and isinstance(value.get("action"), str) + ) + for key, item in value.items(): + if not isinstance(key, str): + raise PortableJsonError("invalid object key") + budget.consume(key) + if looks_like_host_path(key) or _SENSITIVE_PUBLIC_KEY.search(key): + raise PortableJsonError("invalid object key") + if next_action and key == "url": + if item is None: + continue + if not isinstance(item, str) or not is_portable_next_action_url(item): + raise PortableJsonError("host path") + budget.consume(item) + continue + _validate_portable_json(item, budget, depth=depth + 1) + return + raise PortableJsonError("non-JSON value") + + +def validate_portable_json(value: Any) -> None: + """Require finite, bounded public JSON without host-local payloads.""" + + _validate_portable_json(value, _StringBudget(), depth=0) + try: + encoded = json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ).encode("utf-8") + except (OverflowError, RecursionError, TypeError, UnicodeError, ValueError) as error: + raise PortableJsonError("non-JSON value") from error + if len(encoded) > MAX_PORTABLE_DOCUMENT_BYTES: + raise PortableJsonError("document byte budget exceeded") + + +__all__ = [ + "MAX_INLINE_BASE64_DECODED_BYTES", + "MAX_PORTABLE_CONTAINER_ITEMS", + "MAX_PORTABLE_DEPTH", + "MAX_PORTABLE_DOCUMENT_BYTES", + "MAX_PORTABLE_DOCUMENT_STRING_BYTES", + "MAX_PORTABLE_NODES", + "MAX_PORTABLE_STRING_BYTES", + "PORTABLE_URI_HOST_PATTERN", + "PORTABLE_URI_PORT_PATTERN", + "PORTABLE_URI_TAIL_PATTERN", + "PortableJsonError", + "is_portable_next_action_url", + "is_portable_resource_uri", + "looks_like_host_path", + "validate_portable_json", +] diff --git a/hhtools/contracts/preflight.py b/hhtools/contracts/preflight.py new file mode 100644 index 00000000..b308d1b3 --- /dev/null +++ b/hhtools/contracts/preflight.py @@ -0,0 +1,140 @@ +"""Retarget preflight request, check, and immutable plan contracts.""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Annotated, Any + +from pydantic import AwareDatetime, ConfigDict, Field, model_validator + +from .common import ( + ApiError, + AssetId, + CalibrationId, + ContractModel, + MachineCode, + NextAction, + PlanId, + SchemaVersion, + Sha256Hex, +) + + +class OutputPolicy(StrEnum): + CREATE_NEW = "create_new" + FAIL_IF_EXISTS = "fail_if_exists" + OVERWRITE = "overwrite" + + +class PreflightCheckLevel(StrEnum): + PASS = "pass" + WARNING = "warning" + ERROR = "error" + + +# Compatibility import name used by early service prototypes. The serialized +# contract is the documented ``level`` field and values above. +PreflightCheckStatus = PreflightCheckLevel + + +class PreflightStatus(StrEnum): + READY = "ready" + HUMAN_ACTION_REQUIRED = "human_action_required" + REJECTED = "rejected" + + +class RetargetPreflightRequest(ContractModel): + """User intent that the service resolves into an immutable plan.""" + + schema_version: SchemaVersion = SchemaVersion.V1 + motion_asset_id: AssetId + robot_id: Annotated[str, Field(min_length=1, max_length=256)] + robot_asset_id: AssetId | None = Field( + default=None, + description="Registered RobotBundle identity; required for a runnable plan.", + ) + backend: str | None = Field( + default=None, + description="Backend id, or null to request a recommendation.", + ) + calibration_id: CalibrationId | None = None + output_format: Annotated[str, Field(min_length=1, max_length=32)] = "csv" + output_policy: OutputPolicy = OutputPolicy.CREATE_NEW + parameters: dict[str, Any] = Field( + default_factory=dict, + description="Backend-independent and namespaced backend parameters.", + ) + + +class PreflightCheck(ContractModel): + """One deterministic precondition evaluated by the service.""" + + code: MachineCode + level: PreflightCheckLevel + message: Annotated[str, Field(min_length=1)] + details: dict[str, Any] = Field(default_factory=dict) + next_action: NextAction | None = None + + +class RetargetPlan(ContractModel): + """Fully resolved, content-bound plan accepted by ``start_retarget``.""" + + model_config = ConfigDict( + extra="forbid", + str_strip_whitespace=True, + validate_assignment=True, + frozen=True, + ) + + schema_version: SchemaVersion = SchemaVersion.V1 + plan_id: PlanId + created_at: AwareDatetime + expires_at: AwareDatetime | None = None + motion_asset_id: AssetId + robot_id: Annotated[str, Field(min_length=1, max_length=256)] + robot_asset_id: AssetId + backend: Annotated[str, Field(min_length=1, max_length=128)] + calibration_id: CalibrationId | None = None + output_format: Annotated[str, Field(min_length=1, max_length=32)] + output_policy: OutputPolicy + parameters: dict[str, Any] = Field(default_factory=dict) + input_digest: Sha256Hex + robot_digest: Sha256Hex + calibration_digest: Sha256Hex | None = None + + @model_validator(mode="after") + def validate_expiry(self) -> RetargetPlan: + if self.expires_at is not None and self.expires_at <= self.created_at: + raise ValueError("expires_at must be later than created_at") + return self + + +class PreflightResponse(ContractModel): + """Preflight result; only ``ready`` responses expose a runnable plan.""" + + schema_version: SchemaVersion = SchemaVersion.V1 + request_id: Annotated[str, Field(min_length=1, max_length=256)] + status: PreflightStatus + plan: RetargetPlan | None = None + checks: list[PreflightCheck] = Field(default_factory=list) + recommended_backend: str | None = None + required_actions: list[NextAction] = Field(default_factory=list) + error: ApiError | None = None + + @model_validator(mode="after") + def validate_response_state(self) -> PreflightResponse: + if self.status is PreflightStatus.READY: + if self.plan is None: + raise ValueError("ready preflight responses must include a plan") + if self.error is not None or self.required_actions: + raise ValueError( + "ready preflight responses cannot include errors or required actions" + ) + else: + if self.plan is not None: + raise ValueError("non-ready preflight responses cannot include a plan") + if self.status is PreflightStatus.HUMAN_ACTION_REQUIRED and not self.required_actions: + raise ValueError("human_action_required responses must include a required action") + if self.status is PreflightStatus.REJECTED and self.error is None: + raise ValueError("rejected preflight responses must include an error") + return self diff --git a/hhtools/contracts/schema_registry.py b/hhtools/contracts/schema_registry.py new file mode 100644 index 00000000..1d837c69 --- /dev/null +++ b/hhtools/contracts/schema_registry.py @@ -0,0 +1,68 @@ +"""Canonical registry for the public Agent JSON Schema surface. + +Schema export and MCP resource discovery both use this registry so a new +contract cannot silently appear in one transport but not the other. +""" + +from __future__ import annotations + +from types import MappingProxyType + +from pydantic import BaseModel + +from .artifact_exports import ArtifactExportReceipt +from .artifacts import EvaluationReport, FailureReport, JobManifest +from .assets import ( + AssetBundle, + AssetInspection, + AssetRegistrationRequest, + AssetSearchResponse, +) +from .capabilities import CapabilityResponse, RobotListResponse +from .common import ApiError +from .job_spec import JobSpecV2 +from .jobs import ( + AgentJobView, + ArtifactDescriptor, + ArtifactListResponse, + JobLookupRequest, + JobRetryRequest, + JobStartRequest, +) +from .migration import ( + LegacyJobUpgradeRequest, + LegacyJobUpgradeResponse, + LegacyMigrationReceipt, +) +from .preflight import PreflightResponse, RetargetPreflightRequest + +PUBLIC_AGENT_SCHEMAS: MappingProxyType[str, type[BaseModel]] = MappingProxyType( + { + "agent-job-view": AgentJobView, + "api-error": ApiError, + "artifact": ArtifactDescriptor, + "artifact-export-receipt": ArtifactExportReceipt, + "artifact-list-response": ArtifactListResponse, + "asset-bundle": AssetBundle, + "asset-inspection": AssetInspection, + "asset-registration-request": AssetRegistrationRequest, + "asset-search-response": AssetSearchResponse, + "capabilities": CapabilityResponse, + "evaluation-report": EvaluationReport, + "failure-report": FailureReport, + "job-manifest": JobManifest, + "job-lookup-request": JobLookupRequest, + "job-retry-request": JobRetryRequest, + "job-start-request": JobStartRequest, + "job-spec-v2": JobSpecV2, + "legacy-job-upgrade-request": LegacyJobUpgradeRequest, + "legacy-job-upgrade-response": LegacyJobUpgradeResponse, + "legacy-migration-receipt": LegacyMigrationReceipt, + "preflight-response": PreflightResponse, + "retarget-preflight-request": RetargetPreflightRequest, + "robot-list-response": RobotListResponse, + } +) + + +__all__ = ["PUBLIC_AGENT_SCHEMAS"] diff --git a/hhtools/integrations/__init__.py b/hhtools/integrations/__init__.py new file mode 100644 index 00000000..21db0b11 --- /dev/null +++ b/hhtools/integrations/__init__.py @@ -0,0 +1 @@ +"""Optional integrations with external tools and isolated runtimes.""" diff --git a/hhtools/integrations/gvhmr.py b/hhtools/integrations/gvhmr.py new file mode 100644 index 00000000..914ccf65 --- /dev/null +++ b/hhtools/integrations/gvhmr.py @@ -0,0 +1,411 @@ +"""Isolated GVHMR video-to-motion runtime. + +GVHMR has a large, Linux/CUDA-specific dependency graph that should not be +imported into the hhtools web process. This module validates the local official +checkout and invokes a pinned Docker image with argument lists (never a shell). +""" + +from __future__ import annotations + +import json +import os +import queue +import re +import shutil +import subprocess +import threading +import time +import uuid +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any + +GVHMR_ROOT_ENV = "HHTOOLS_GVHMR_ROOT" +GVHMR_IMAGE_ENV = "HHTOOLS_GVHMR_IMAGE" +GVHMR_BODY_MODELS_ENV = "HHTOOLS_GVHMR_BODY_MODELS" +GVHMR_TIMEOUT_ENV = "HHTOOLS_GVHMR_TIMEOUT_SECONDS" + +DEFAULT_IMAGE = "hhtools-gvhmr:cu128" +DEFAULT_TIMEOUT_SECONDS = 2 * 60 * 60 + +_PUBLIC_CHECKPOINTS = { + "GVHMR": Path("gvhmr/gvhmr_siga24_release.ckpt"), + "HMR2": Path("hmr2/epoch=10-step=25000.ckpt"), + "ViTPose": Path("vitpose/vitpose-h-multi-coco.pth"), + "YOLOv8": Path("yolo/yolov8x.pt"), +} +_VIDEO_SUFFIXES = frozenset({".mp4", ".mov", ".mkv", ".avi", ".webm", ".m4v"}) +_PROGRESS_RE = re.compile(r"^HHTOOLS_PROGRESS\s+([0-9.]+)\s+(.*)$") +_RESULT_PREFIX = "HHTOOLS_RESULT " + + +def _posix_container_identity() -> tuple[int, int] | None: + """Return the host identity used for writable bind mounts on Linux.""" + + if os.name != "posix" or not hasattr(os, "getuid") or not hasattr(os, "getgid"): + return None + return os.getuid(), os.getgid() + + +def _docker_isolation_args(*, home: str) -> list[str]: + """Build the security and host-user options shared by GVHMR containers.""" + + arguments = [ + "--gpus", + "all", + "--network", + "none", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + ] + identity = _posix_container_identity() + if identity is not None: + uid, gid = identity + arguments.extend(["--user", f"{uid}:{gid}", "--env", f"HOME={home}"]) + return arguments + + +@dataclass(frozen=True) +class GvhmrConfig: + root: Path + body_models_root: Path + image: str = DEFAULT_IMAGE + docker: str = "docker" + timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS + cuda_visible_devices: str | None = None + + @classmethod + def from_environment(cls) -> GvhmrConfig: + root = _default_root() + body_models = Path( + os.environ.get( + GVHMR_BODY_MODELS_ENV, + root / "inputs" / "checkpoints" / "body_models", + ) + ).expanduser() + raw_timeout = os.environ.get(GVHMR_TIMEOUT_ENV, str(DEFAULT_TIMEOUT_SECONDS)) + try: + timeout = max(60, int(raw_timeout)) + except ValueError: + timeout = DEFAULT_TIMEOUT_SECONDS + return cls( + root=root, + body_models_root=body_models, + image=os.environ.get(GVHMR_IMAGE_ENV, DEFAULT_IMAGE), + docker=shutil.which("docker") or "docker", + timeout_seconds=timeout, + cuda_visible_devices=(os.environ.get("CUDA_VISIBLE_DEVICES") or None), + ) + + +def _default_root() -> Path: + override = os.environ.get(GVHMR_ROOT_ENV) + if override: + return Path(override).expanduser() + windows_default = Path("C:/GVHMR") + if windows_default.is_dir(): + return windows_default + return Path.home() / "GVHMR" + + +def _run_probe(args: list[str], *, timeout: float = 10.0) -> tuple[bool, str]: + try: + completed = subprocess.run( + args, + capture_output=True, + check=False, + encoding="utf-8", + errors="replace", + timeout=timeout, + ) + except (OSError, subprocess.TimeoutExpired) as err: + return False, str(err) + output = (completed.stdout or completed.stderr or "").strip() + return completed.returncode == 0, output + + +def gvhmr_status(config: GvhmrConfig | None = None) -> dict[str, Any]: + """Return actionable readiness checks without importing GVHMR or CUDA.""" + + cfg = config or GvhmrConfig.from_environment() + checkpoint_root = cfg.root / "inputs" / "checkpoints" + checks: dict[str, bool] = { + "official_repo": (cfg.root / "tools" / "demo" / "demo.py").is_file(), + "docker_cli": shutil.which(cfg.docker) is not None or Path(cfg.docker).is_file(), + } + missing: list[str] = [] + if not checks["official_repo"]: + missing.append(f"GVHMR official checkout: {cfg.root}") + + for label, relative in _PUBLIC_CHECKPOINTS.items(): + checkpoint_path = checkpoint_root / relative + available = checkpoint_path.is_file() + checks[f"checkpoint_{label.lower()}"] = available + if not available: + missing.append(f"{label} checkpoint: {checkpoint_path}") + + smplx = cfg.body_models_root / "smplx" / "SMPLX_NEUTRAL.npz" + checks["smplx_neutral"] = smplx.is_file() + if not checks["smplx_neutral"]: + missing.append( + "licensed SMPL-X neutral model: " + f"{smplx} (download after accepting the official SMPL-X license)" + ) + + docker_ready = False + image_ready = False + if checks["docker_cli"]: + docker_ready, _ = _run_probe( + [cfg.docker, "version", "--format", "{{.Server.Version}}"], + ) + if docker_ready: + image_ready, _ = _run_probe( + [cfg.docker, "image", "inspect", cfg.image, "--format", "{{.Id}}"], + ) + checks["docker_engine"] = docker_ready + checks["runtime_image"] = image_ready + if not docker_ready: + missing.append("running Docker Desktop Linux engine") + elif not image_ready: + missing.append(f"GVHMR runtime image: {cfg.image}") + + return { + "ready": all(checks.values()), + "checks": checks, + "missing": missing, + "root": str(cfg.root), + "body_models_root": str(cfg.body_models_root), + "image": cfg.image, + "cuda_visible_devices": cfg.cuda_visible_devices, + "uses_official_weights": True, + "supports_custom_weights": True, + "custom_weights_support": "best_effort", + "training_enabled": False, + } + + +def ensure_gvhmr_ready(config: GvhmrConfig | None = None) -> GvhmrConfig: + cfg = config or GvhmrConfig.from_environment() + status = gvhmr_status(cfg) + if not status["ready"]: + details = "\n- ".join(status["missing"]) + raise RuntimeError(f"GVHMR is not ready:\n- {details}") + return cfg + + +def _container_path(host_path: Path, mount_root: Path, container_root: str) -> str: + relative = host_path.resolve().relative_to(mount_root.resolve()) + suffix = "/".join(relative.parts) + return f"{container_root}/{suffix}" if suffix else container_root + + +def _host_result_path(job_root: Path, container_path: str) -> Path: + """Resolve a worker result without allowing traversal or output symlinks.""" + + container_result = PurePosixPath(container_path) + try: + relative = container_result.relative_to(PurePosixPath("/work/output")) + except ValueError as err: + raise RuntimeError("GVHMR published a result outside /work/output") from err + if relative.name != "hmr4d_results.pt" or ".." in relative.parts: + raise RuntimeError("GVHMR published an invalid result path") + work_root = job_root.resolve() + raw_output_root = work_root / "output" + if raw_output_root.is_symlink(): + raise RuntimeError("GVHMR job output directory must not be a symlink") + output_root = raw_output_root.resolve() + try: + output_root.relative_to(work_root) + except ValueError as err: + raise RuntimeError("GVHMR output directory resolves outside the job") from err + result = (output_root / Path(*relative.parts)).resolve() + try: + result.relative_to(output_root) + except ValueError as err: + raise RuntimeError("GVHMR result resolves outside the job output directory") from err + return result + + +def build_gvhmr_command( + config: GvhmrConfig, + *, + video_path: Path, + job_root: Path, + checkpoint_path: Path | None = None, + static_cam: bool = True, + f_mm: int | None = None, +) -> list[str]: + """Build the Docker argv for one isolated inference job.""" + + root = config.root.resolve() + body_models = config.body_models_root.resolve() + video = video_path.resolve() + work = job_root.resolve() + video.relative_to(work) + if video.suffix.lower() not in _VIDEO_SUFFIXES: + raise ValueError(f"unsupported video extension: {video.suffix or ''}") + checkpoint: Path | None = None + if checkpoint_path is not None: + checkpoint = checkpoint_path.resolve() + checkpoint.relative_to(work) + if not checkpoint.is_file(): + raise FileNotFoundError(f"custom checkpoint does not exist: {checkpoint}") + output = work / "output" + output.mkdir(parents=True, exist_ok=True) + isolation_args = _docker_isolation_args(home="/work/.container-home") + if "--user" in isolation_args: + (work / ".container-home").mkdir(exist_ok=True) + command = [ + config.docker, + "run", + "--rm", + *isolation_args, + "--name", + f"hhtools-gvhmr-{uuid.uuid4().hex[:10]}", + "--mount", + f"type=bind,source={root},target=/workspace/gvhmr,readonly", + "--mount", + f"type=bind,source={work},target=/work", + ] + if config.cuda_visible_devices: + command.extend(["--env", f"CUDA_VISIBLE_DEVICES={config.cuda_visible_devices}"]) + default_body_models = (root / "inputs" / "checkpoints" / "body_models").resolve() + if body_models != default_body_models: + command.extend( + [ + "--mount", + ( + "type=bind," + f"source={body_models}," + "target=/workspace/gvhmr/inputs/checkpoints/body_models,readonly" + ), + ] + ) + command.extend( + [ + config.image, + "--video", + _container_path(video, work, "/work"), + "--output-root", + "/work/output", + ] + ) + if checkpoint is not None: + command.extend(["--checkpoint", _container_path(checkpoint, work, "/work")]) + if static_cam: + command.append("--static-cam") + if f_mm is not None: + if f_mm <= 0: + raise ValueError("f_mm must be positive") + command.extend(["--f-mm", str(f_mm)]) + return command + + +def run_gvhmr( + video_path: Path, + job_root: Path, + *, + checkpoint_path: Path | None = None, + static_cam: bool = True, + f_mm: int | None = None, + config: GvhmrConfig | None = None, + progress: Callable[[float, str], None] | None = None, +) -> Path: + """Run GVHMR and return the generated ``hmr4d_results.pt`` path.""" + + cfg = ensure_gvhmr_ready(config) + command = build_gvhmr_command( + cfg, + video_path=video_path, + job_root=job_root, + checkpoint_path=checkpoint_path, + static_cam=static_cam, + f_mm=f_mm, + ) + process = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + ) + result_container_path: str | None = None + recent: list[str] = [] + output_lines: queue.Queue[str | None] = queue.Queue() + + def read_output() -> None: + assert process.stdout is not None + try: + for raw_line in process.stdout: + output_lines.put(raw_line) + finally: + output_lines.put(None) + + reader = threading.Thread(target=read_output, name="gvhmr-output", daemon=True) + reader.start() + deadline = time.monotonic() + cfg.timeout_seconds + timed_out = False + try: + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + timed_out = True + break + try: + raw = output_lines.get(timeout=min(0.5, remaining)) + except queue.Empty: + if process.poll() is not None and not reader.is_alive(): + break + continue + if raw is None: + break + line = raw.strip() + if line: + recent.append(line) + recent = recent[-30:] + match = _PROGRESS_RE.match(line) + if match and progress is not None: + progress(float(match.group(1)), match.group(2)) + elif line.startswith(_RESULT_PREFIX): + payload = json.loads(line[len(_RESULT_PREFIX) :]) + result_container_path = str(payload["result_path"]) + if timed_out: + raise TimeoutError(f"GVHMR exceeded the {cfg.timeout_seconds}-second inference timeout") + return_code = process.wait(timeout=max(1.0, deadline - time.monotonic())) + except BaseException: + process.kill() + process.wait(timeout=30) + try: + container_name = command[command.index("--name") + 1] + _run_probe([cfg.docker, "rm", "--force", container_name], timeout=30) + except (ValueError, IndexError): + pass + raise + if return_code != 0: + diagnostic = "\n".join(recent[-12:]) + raise RuntimeError(f"GVHMR container exited with code {return_code}.\n{diagnostic}") + if not result_container_path: + raise RuntimeError("GVHMR completed without publishing a result path") + result = _host_result_path(job_root, result_container_path) + if not result.is_file(): + raise RuntimeError(f"GVHMR result was not found on the host: {result}") + if progress is not None: + progress(1.0, "GVHMR motion ready") + return result + + +__all__ = [ + "DEFAULT_IMAGE", + "GVHMR_BODY_MODELS_ENV", + "GVHMR_IMAGE_ENV", + "GVHMR_ROOT_ENV", + "GvhmrConfig", + "build_gvhmr_command", + "ensure_gvhmr_ready", + "gvhmr_status", + "run_gvhmr", +] diff --git a/hhtools/io/datasets/hmr4d.py b/hhtools/io/datasets/hmr4d.py index 4ccc5902..a7184b03 100644 --- a/hhtools/io/datasets/hmr4d.py +++ b/hhtools/io/datasets/hmr4d.py @@ -1,14 +1,9 @@ -"""Adapter for GVHMR / KungFuAthlete-style ``hmr4d_results.pt`` files. +"""Adapter for GVHMR / KungFuAthlete ``hmr4d_results.pt`` files. -Both the GVHMR release and the KungFuAthlete sample produced by the HMR4D pipeline share the -same on-disk layout -- a PyTorch checkpoint whose top-level dict contains -``smpl_params_global`` with ``body_pose`` (T, 63), ``betas`` (T, 10), ``global_orient`` (T, 3) -and ``transl`` (T, 3) tensors, plus some auxiliary network outputs. - -We treat them as SMPL (not SMPL-H) because the pose dimensionality matches (21 body joints) -and no hand parameters are emitted by HMR4D. However HMR4D stores a 21-joint body_pose which -matches SMPL-H convention, so we actually use SMPL-H if its weights are available, otherwise -we zero-pad to SMPL's 23-joint body pose. +The official result stores 21 body-joint rotations plus SMPL-X shape, root orientation, and +translation parameters under ``smpl_params_global``. GVHMR itself predicts with the neutral +SMPL-X model, so this adapter reuses that licensed model when present. Existing SMPL-H and +SMPL installations remain supported as compatibility fallbacks for older imported results. """ from __future__ import annotations @@ -35,21 +30,19 @@ def _to_numpy(x: Any) -> np.ndarray: def _load_hmr4d(path: Path) -> SmplMotionParams: - import torch # noqa: F401 -- ensure torch is imported before unpickling + import torch from hhtools.bodymodels.compat import patch_chumpy_compat + from hhtools.bodymodels.paths import find_body_model patch_chumpy_compat() - try: - import chumpy # noqa: F401 - except ImportError as exc: - raise ImportError( - f"{path}: GVHMR/SMPL-H 需要 chumpy 才能读取 SMPL-H 权重。" - "请执行 `uv pip install chumpy`(或 `uv sync --extra smpl`)," - "或在 configs/body_models/smplh 下提供 SMPL-H neutral .npz 权重。" - ) from exc - - data = __import__("torch").load(str(path), map_location="cpu", weights_only=False) + # Imported ``.pt`` files are an untrusted boundary. The expected GVHMR + # document is a plain dict of tensors, so PyTorch's restricted loader + # preserves the supported format without allowing arbitrary pickle globals + # to execute in the desktop process. + data = torch.load(str(path), map_location="cpu", weights_only=True) + if not isinstance(data, dict): + raise ValueError(f"{path} is not a tensor dictionary") block = data.get("smpl_params_global") if block is None: raise ValueError( @@ -70,15 +63,14 @@ def _load_hmr4d(path: Path) -> SmplMotionParams: else: betas_flat = betas.reshape(-1) - # HMR4D body_pose has 21 body joints which matches SMPL-H layout; SMPL expects 23 joints - # (69 dims) so we pad with zeros when using SMPL. We ship SMPL-H parameters by default - # because SMPL-H weights are available in most user installations, but fall back to SMPL - # when SMPL-H is absent (engine constructor will raise a clear error that the caller can - # catch and retry with SMPL). + # GVHMR itself requires SMPL-X neutral weights, so prefer that same licensed + # file when available. Existing installations with only SMPL-H keep their + # previous behavior; SMPL remains the final zero-padded fallback below. + surface_model = "smplx" if find_body_model("smplx", "neutral") else "smplh" return SmplMotionParams( - surface_model="smplh", + surface_model=surface_model, root_orient=global_orient, - body_pose=body_pose_21, # (T, 63) for SMPL-H + body_pose=body_pose_21, # (T, 63), shared SMPL-X/SMPL-H body-joint convention betas=betas_flat, trans=transl, gender="neutral", @@ -91,7 +83,7 @@ def _load_hmr4d(path: Path) -> SmplMotionParams: class _Hmr4dBase(DatasetAdapter): - requires = "smplh" + requires = "smplx" file_patterns = ("*.pt", "*.pth") def list_sequences(self) -> Iterator[str]: @@ -117,7 +109,36 @@ def load_motion(self, sequence_id: str, **kwargs: Any) -> Motion: try: engine = engine_for_params(params) except FileNotFoundError: - # SMPL-H weights missing; fall back to SMPL (pad to 69 body-pose dims). + if params.surface_model == "smplx": + # A separately configured SMPL-H installation can still load + # the 21-joint body pose when SMPL-X lookup unexpectedly fails. + params = SmplMotionParams( + surface_model="smplh", + root_orient=params.root_orient, + body_pose=params.body_pose, + betas=params.betas, + trans=params.trans, + gender=params.gender, + framerate=params.framerate, + up_axis=params.up_axis, + meta=params.meta, + ) + try: + engine = engine_for_params(params) + except FileNotFoundError: + engine = None + else: + engine = None + if engine is not None: + return engine.to_motion( + params, + name=Path(sequence_id).stem, + source_format=f"hmr4d/{params.surface_model}", + return_mesh=with_mesh, + progress_callback=progress_callback, + ) + # SMPL-X / SMPL-H weights missing; fall back to SMPL by padding + # the 21-joint body pose to SMPL's 23-joint convention. padded = np.zeros((params.num_frames, 69), dtype=np.float32) padded[:, :63] = params.body_pose params = SmplMotionParams( diff --git a/hhtools/io/mimic_detect.py b/hhtools/io/mimic_detect.py index 48a113ba..1d0e2f10 100644 --- a/hhtools/io/mimic_detect.py +++ b/hhtools/io/mimic_detect.py @@ -143,14 +143,14 @@ def sniff_npy_dataset(path: Path) -> str: def sniff_pt_dataset(path: Path) -> str: - """Classify HMR4D-style ``.pt`` / ``.pth`` checkpoints.""" + """Classify HMR4D-style ``.pt`` / ``.pth`` results without unsafe unpickling.""" hint = path_dataset_hint(path) if hint in {"gvhmr", "kungfu_athlete"}: return hint try: import torch - data = torch.load(str(path), map_location="cpu", weights_only=False) + data = torch.load(str(path), map_location="cpu", weights_only=True) if isinstance(data, dict) and "smpl_params_global" in data: return hint or "gvhmr" except Exception: diff --git a/hhtools/mcp/__init__.py b/hhtools/mcp/__init__.py new file mode 100644 index 00000000..1515d39a --- /dev/null +++ b/hhtools/mcp/__init__.py @@ -0,0 +1,5 @@ +"""Local MCP adapter for HHTools' transport-neutral Agent services.""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/hhtools/mcp/runtime.py b/hhtools/mcp/runtime.py new file mode 100644 index 00000000..272a4c47 --- /dev/null +++ b/hhtools/mcp/runtime.py @@ -0,0 +1,106 @@ +"""Lifecycle bridge from the local stdio host to HHTools application services. + +The existing Web composition root owns the production loader/solver bindings. +Creating it without an HTTP listener gives MCP the exact same service and +executor instances while keeping every tool call transport-neutral. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from hhtools.services import ( + AgentAssetService, + ArtifactExportService, + CapabilitiesService, + JobManager, + PlanStore, + PreflightService, +) + + +@dataclass(frozen=True) +class LocalRuntimeConfig: + """Host-only configuration used to assemble one stdio-owned runtime.""" + + source_root: Path = Path("assets/motions") + save_dir: Path = Path("assets/save_npz") + cache_dir: Path | None = None + max_running_jobs: int | None = None + max_queued_jobs: int | None = None + job_settings_path: Path | None = None + web_ui_url: str = "http://127.0.0.1:8009" + + +@dataclass(frozen=True) +class AgentRuntime: + """Only the transport-neutral services exposed to MCP handlers.""" + + capabilities: CapabilitiesService + assets: AgentAssetService + preflight: PreflightService + plans: PlanStore + jobs: JobManager + exports: ArtifactExportService + + @classmethod + def from_application(cls, app: Any) -> AgentRuntime: + """Project the service surface from a fully assembled local app.""" + + return cls( + capabilities=app.state.agent_capabilities_service, + assets=app.state.agent_asset_service, + preflight=app.state.agent_preflight_service, + plans=app.state.agent_plan_store, + jobs=app.state.agent_job_manager, + exports=app.state.agent_artifact_export_service, + ) + + +@asynccontextmanager +async def local_agent_runtime( + config: LocalRuntimeConfig, +) -> AsyncIterator[AgentRuntime]: + """Create one service owner and drain its scheduler when stdio closes.""" + + # Warp prints its device banner to stdout on first initialization. stdout + # is the MCP JSON-RPC wire, so configure the library before importing the + # Web composition root (and therefore before any lazy Newton import can + # initialize Warp). ``quiet`` is deliberately MCP-only: normal CLI/WebUI + # processes keep Warp's useful startup diagnostics. + from hhtools.retarget.newton_basic._warp_config import configure as configure_warp_cache + + configure_warp_cache(quiet=True) + + # Keep FastAPI and heavy Web/solver imports outside normal ``hhtools`` + # imports. The MCP extra is useful only together with the local H2R stack. + from hhtools.web.server import ( + create_app, + effective_job_admission_settings, + ) + + settings, settings_path = effective_job_admission_settings( + max_running_jobs=config.max_running_jobs, + max_queued_jobs=config.max_queued_jobs, + job_settings_path=config.job_settings_path, + ) + app = create_app( + source_root=config.source_root, + save_dir=config.save_dir, + cache_dir=config.cache_dir, + max_running_jobs=settings.max_running_jobs, + max_queued_jobs=settings.max_queued_jobs, + job_settings_path=settings_path, + agent_mcp_available=True, + agent_rest_available=False, + agent_json_cli_available=False, + ) + async with app.router.lifespan_context(app): + yield AgentRuntime.from_application(app) + + +__all__ = ["AgentRuntime", "LocalRuntimeConfig", "local_agent_runtime"] diff --git a/hhtools/mcp/server.py b/hhtools/mcp/server.py new file mode 100644 index 00000000..524dcacc --- /dev/null +++ b/hhtools/mcp/server.py @@ -0,0 +1,803 @@ +"""HHTools MCP Python SDK v2 server over local stdio. + +Tools in this module are deliberately thin calls into application services. +They neither invoke the CLI/REST adapter nor duplicate loader, IK, calibration, +export, scheduler, or artifact-membership logic. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import logging +import sys +from collections.abc import AsyncIterator, Callable, Sequence +from contextlib import AbstractAsyncContextManager, asynccontextmanager +from pathlib import Path +from typing import Any, cast +from urllib.parse import urlsplit + +from mcp.server import MCPServer +from mcp.server.mcpserver import Context +from mcp.server.mcpserver.exceptions import ResourceError +from mcp.types import CallToolResult, TextContent, ToolAnnotations +from pydantic import BaseModel, ConfigDict + +from hhtools._version import __version__ +from hhtools.contracts import ( + AgentJobView, + ApiError, + ArtifactDescriptor, + ArtifactExportReceipt, + ArtifactListResponse, + AssetBundle, + AssetCategory, + AssetInspection, + AssetInspectionRequest, + AssetKind, + AssetRegistrationRequest, + AssetSearchResponse, + CapabilityResponse, + ErrorStage, + EvaluationReport, + FailureReport, + JobLookupRequest, + JobManifest, + JobRetryRequest, + JobStartRequest, + PreflightResponse, + RetargetPreflightRequest, + RobotListResponse, +) +from hhtools.contracts.portability import validate_portable_json +from hhtools.contracts.schema_registry import PUBLIC_AGENT_SCHEMAS +from hhtools.services.jobs import JobManagerError +from hhtools.services.runtime_lease import RuntimeLeaseError + +from .runtime import AgentRuntime, LocalRuntimeConfig, local_agent_runtime + +_log = logging.getLogger(__name__) +_REPORT_LIMIT_BYTES = 2 * 1024 * 1024 +RuntimeFactory = Callable[[], AbstractAsyncContextManager[AgentRuntime]] + +_READ_ONLY = ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, +) +_SAFE_WRITE = ToolAnnotations( + read_only_hint=False, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, +) +_CANCEL = ToolAnnotations( + read_only_hint=False, + destructive_hint=True, + idempotent_hint=True, + open_world_hint=False, +) + + +def _harden_tool_argument_models(server: MCPServer[Any]) -> None: + """Reject unknown MCP arguments without echoing their values. + + The MCP SDK currently derives a dynamic Pydantic model for every function + signature with Pydantic's default ``extra='ignore'`` behavior. That makes + a misspelled or wrongly wrapped request silently fall back to defaults. + Harden the registered models at this composition boundary so the live + validator and the advertised JSON Schema enforce the same closed shape. + + ``hide_input_in_errors`` is equally important: validation failures are + returned to the MCP client, so rejected paths, tokens, or other caller data + must not be copied into the error prose. + """ + + # MCPServer does not yet expose a public hook for configuring its generated + # argument models. Keep the SDK-specific access isolated here and cover it + # with contract tests so an SDK upgrade fails loudly rather than weakening + # validation unnoticed. + manager = cast(Any, server)._tool_manager + for tool in manager.list_tools(): + argument_model = tool.fn_metadata.arg_model + argument_model.model_config = ConfigDict( + **argument_model.model_config, + extra="forbid", + hide_input_in_errors=True, + ) + argument_model.model_rebuild(force=True) + tool.parameters = argument_model.model_json_schema(by_alias=True) + + +def _model_document(model: BaseModel) -> dict[str, Any]: + document = model.model_dump(mode="json", exclude_none=True) + validate_portable_json(document) + return document + + +def _internal_error() -> ApiError: + return ApiError( + code="INTERNAL_ERROR", + message="The HHTools MCP service could not complete the request.", + retryable=True, + stage=ErrorStage.INTERNAL, + ) + + +def _public_error(exception: Exception) -> ApiError: + error = getattr(exception, "api_error", None) + return error if isinstance(error, ApiError) else _internal_error() + + +def _safe_error_document(exception: Exception) -> tuple[ApiError, dict[str, Any]]: + """Return one portable public error without echoing rejected service data.""" + + error = _public_error(exception) + try: + return error, _model_document(error) + except Exception: # noqa: BLE001 - the protocol boundary must never leak it + # Do not log the rejected error or exception: either may contain the + # host path (or other unsafe value) that this boundary is removing. + _log.error("discarded a non-portable HHTools MCP service error") + error = _internal_error() + return error, _model_document(error) + + +def _error_result(document: dict[str, Any]) -> CallToolResult: + return CallToolResult( + content=[ + TextContent( + type="text", + text=json.dumps(document, ensure_ascii=False, separators=(",", ":")), + ) + ], + structuredContent=document, + isError=True, + ) + + +def _tool_call[T](call: Callable[[], T]) -> T: + """Keep success schemas while returning expected failures as MCP tool errors.""" + + try: + result = call() + if isinstance(result, BaseModel): + # Return a detached model rebuilt from the exact portable snapshot + # that was checked. Returning the service-owned instance would let + # a concurrently mutated nested dict/list diverge before the SDK's + # later serialization step. + return cast(T, type(result).model_validate(_model_document(result))) + return result + except Exception as exception: # noqa: BLE001 - protocol boundary + error, document = _safe_error_document(exception) + if error.code == "INTERNAL_ERROR": + # Exception text can itself contain a host path. Keep diagnostics + # useful without copying untrusted service data to stderr. + _log.error( + "unexpected HHTools MCP tool failure (%s)", + type(exception).__name__, + ) + # MCPServer recognises a direct CallToolResult before validating the + # declared success model, retaining both outputSchema and ApiError. + return cast(T, _error_result(document)) + + +def _resource_call[T](call: Callable[[], T]) -> T: + try: + return call() + except Exception as exception: # noqa: BLE001 - protocol boundary + error, document = _safe_error_document(exception) + if error.code == "INTERNAL_ERROR": + _log.error( + "unexpected HHTools MCP resource failure (%s)", + type(exception).__name__, + ) + raise ResourceError( + json.dumps( + document, + ensure_ascii=False, + separators=(",", ":"), + ) + ) from None + + +def _runtime(context: Context[AgentRuntime, Any]) -> AgentRuntime: + return context.request_context.lifespan_context + + +def _job_error(code: str, message: str, *, job_id: str) -> JobManagerError: + return JobManagerError( + ApiError( + code=code, + message=message, + stage=ErrorStage.ARTIFACT, + details={"job_id": job_id}, + ) + ) + + +def _find_report( + runtime: AgentRuntime, + job_id: str, + kind: str, +) -> ArtifactDescriptor: + view = runtime.jobs.get_job(job_id) + total = view.artifact_count or 0 + offset = 0 + matches: list[ArtifactDescriptor] = [] + while offset < total: + page = runtime.jobs.list_artifacts(job_id, offset=offset, limit=500) + if not page: + break + matches.extend(item for item in page if item.kind == kind) + offset += len(page) + if len(matches) != 1: + raise _job_error( + "ARTIFACT_NOT_FOUND", + f"The job does not expose one canonical {kind} artifact.", + job_id=job_id, + ) + return matches[0] + + +def _read_report[T]( + runtime: AgentRuntime, + job_id: str, + kind: str, + model: type[T], +) -> T: + descriptor = _find_report(runtime, job_id, kind) + stored = runtime.jobs.get_artifact(job_id, descriptor.artifact_id, verify=False) + try: + with stored.path.open("rb") as stream: + payload = stream.read(_REPORT_LIMIT_BYTES + 1) + except OSError as exception: + raise _job_error( + "ARTIFACT_HASH_MISMATCH", + "The managed report no longer matches its descriptor.", + job_id=job_id, + ) from exception + # Hash exactly the immutable byte string that will be parsed and returned. + # A second read of the file would permit a concurrent writer to make the + # digest describe different bytes (a classic check/use race). + if ( + len(payload) > _REPORT_LIMIT_BYTES + or len(payload) != descriptor.size_bytes + or hashlib.sha256(payload).hexdigest() != descriptor.sha256 + ): + raise _job_error( + "ARTIFACT_HASH_MISMATCH", + "The managed report no longer matches its descriptor.", + job_id=job_id, + ) + try: + validator = cast(Any, model) + return cast(T, validator.model_validate_json(payload)) + except Exception as exception: # noqa: BLE001 - invalid managed artifact + raise _job_error( + "ARTIFACT_HASH_MISMATCH", + "The managed report is not a valid versioned contract.", + job_id=job_id, + ) from exception + + +def _server_instructions(web_ui_url: str) -> str: + return ( + "For every new H2R run: get capabilities, register/search and inspect assets, " + "preflight a smoke plan, start only a ready plan, poll by revision, then read " + "evaluation and manifest for human review. Persist each plan_id plus idempotency " + "key before start; use lookup_job to recover an ambiguous submission without job " + "enumeration. On human_action_required, stop and " + "present next_action; never guess calibration. run_mode is frozen at preflight, " + "and full requires a new full preflight plus explicit user approval. Completed " + "does not mean quality-approved. Never use host paths, Base64 binary artifacts, " + "or real-robot deployment. For user-requested files, export only by job_id and " + "artifact_id and return the portable agent-exports receipt. Cancellation is " + "cooperative while native code runs. " + "Only one local runtime may own a save directory. If calibration is required, " + "ask the human to disconnect this stdio server before starting the WebUI with " + f"the same save directory at {web_ui_url}; after WebUI exit, reconnect and run " + "preflight again. Never read or request the WebUI session token." + ) + + +def _validate_web_ui_url(value: str) -> str: + parsed = urlsplit(value) + if ( + parsed.scheme != "http" + or parsed.hostname not in {"127.0.0.1", "localhost", "::1"} + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + ): + raise ValueError("web_ui_url must be an unauthenticated loopback HTTP URL") + return value.rstrip("/") + + +def create_mcp_server( + config: LocalRuntimeConfig | None = None, + *, + runtime_factory: RuntimeFactory | None = None, +) -> MCPServer[AgentRuntime]: + """Build the stdio server; tests may inject the same service protocols.""" + + config = config or LocalRuntimeConfig() + web_ui_url = _validate_web_ui_url(config.web_ui_url) + factory = runtime_factory or (lambda: local_agent_runtime(config)) + + runtime_slot: AgentRuntime | None = None + + def active_runtime() -> AgentRuntime: + if runtime_slot is None: + raise RuntimeError("the MCP runtime is not active") + return runtime_slot + + @asynccontextmanager + async def lifespan(_server: MCPServer[AgentRuntime]) -> AsyncIterator[AgentRuntime]: + nonlocal runtime_slot + async with factory() as runtime: + if runtime_slot is not None: + raise RuntimeError("the MCP runtime is already active") + runtime_slot = runtime + try: + yield runtime + finally: + runtime_slot = None + + server: MCPServer[AgentRuntime] = MCPServer( + "hhtools", + title="HHTools Agent", + description="Safe local human-to-humanoid retargeting services.", + instructions=_server_instructions(web_ui_url), + version=__version__, + lifespan=lifespan, + # stdio reserves stdout for protocol frames. Keep routine SDK + # diagnostics on stderr quiet while retaining warnings and failures. + log_level="WARNING", + ) + + @server.tool(annotations=_READ_ONLY) + def get_capabilities(context: Context[AgentRuntime, Any]) -> CapabilityResponse: + """Return backends, devices, robots, allowlisted roots, and live admission state.""" + + return _tool_call(_runtime(context).capabilities.get_capabilities) + + @server.tool(annotations=_SAFE_WRITE) + def register_asset_bundle( + request: AssetRegistrationRequest, + context: Context[AgentRuntime, Any], + ) -> AssetBundle: + """Register a content-addressed bundle using root_id plus a relative path.""" + + return _tool_call(lambda: _runtime(context).assets.register(request)) + + @server.tool(annotations=_READ_ONLY) + def search_assets( + context: Context[AgentRuntime, Any], + query: str | None = None, + kind: AssetKind | None = None, + category: AssetCategory | None = None, + dataset: str | None = None, + reference: str | None = None, + limit: int = 100, + offset: int = 0, + ) -> AssetSearchResponse: + """Search immutable asset manifests with bounded portable filters.""" + + return _tool_call( + lambda: _runtime(context).assets.search( + query=query, + kind=kind, + category=category, + dataset=dataset, + reference=reference, + limit=limit, + offset=offset, + ) + ) + + @server.tool(annotations=_READ_ONLY) + def inspect_asset_bundle( + request: AssetInspectionRequest, + context: Context[AgentRuntime, Any], + ) -> AssetInspection: + """Verify manifest hashes and parse bundle content without starting a job.""" + + return _tool_call(lambda: _runtime(context).assets.inspect(request)) + + @server.tool(annotations=_READ_ONLY) + def list_robots(context: Context[AgentRuntime, Any]) -> RobotListResponse: + """List robot availability, references, IK-map facts, and calibration readiness.""" + + return _tool_call( + lambda: RobotListResponse( + robots=_runtime(context).capabilities.get_capabilities().robots + ) + ) + + @server.tool(annotations=_SAFE_WRITE) + def preflight_retarget( + request: RetargetPreflightRequest, + context: Context[AgentRuntime, Any], + ) -> PreflightResponse: + """Validate an H2R intent and freeze an immutable smoke or full plan.""" + + return _tool_call(lambda: _runtime(context).preflight.preflight_retarget(request)) + + @server.tool(annotations=_SAFE_WRITE) + def start_retarget( + request: JobStartRequest, + context: Context[AgentRuntime, Any], + ) -> AgentJobView: + """Submit one preflighted plan; run_mode cannot be changed at this step.""" + + return _tool_call( + lambda: _runtime(context).jobs.start_retarget( + request.plan_id, + idempotency_key=request.idempotency_key, + ) + ) + + @server.tool(annotations=_READ_ONLY) + def get_job( + job_id: str, + context: Context[AgentRuntime, Any], + after_revision: int | None = None, + ) -> AgentJobView: + """Read a compact job snapshot, optionally marking an unchanged revision.""" + + return _tool_call( + lambda: _runtime(context).jobs.get_job( + job_id, + after_revision=after_revision, + ) + ) + + @server.tool(annotations=_READ_ONLY) + def lookup_job( + request: JobLookupRequest, + context: Context[AgentRuntime, Any], + ) -> AgentJobView: + """Recover one caller-owned submission by its immutable plan and key.""" + + return _tool_call( + lambda: _runtime(context).jobs.lookup_job( + request.plan_id, + idempotency_key=request.idempotency_key, + after_revision=request.after_revision, + ) + ) + + @server.tool(annotations=_CANCEL) + def cancel_job( + job_id: str, + context: Context[AgentRuntime, Any], + ) -> AgentJobView: + """Request exact queued cancellation or cooperative running cancellation.""" + + return _tool_call(lambda: _runtime(context).jobs.cancel_job(job_id)) + + @server.tool(annotations=_SAFE_WRITE) + def retry_job( + job_id: str, + request: JobRetryRequest, + context: Context[AgentRuntime, Any], + ) -> AgentJobView: + """Create an idempotent whole-plan child attempt for a terminal H2R job.""" + + return _tool_call( + lambda: _runtime(context).jobs.retry_job( + job_id, + idempotency_key=request.idempotency_key, + ) + ) + + @server.tool(annotations=_READ_ONLY) + def list_job_artifacts( + job_id: str, + context: Context[AgentRuntime, Any], + limit: int = 100, + offset: int = 0, + ) -> ArtifactListResponse: + """List a bounded page of canonical descriptors attached to one job.""" + + def list_page() -> ArtifactListResponse: + runtime = _runtime(context) + artifacts = runtime.jobs.list_artifacts(job_id, limit=limit, offset=offset) + view = runtime.jobs.get_job(job_id) + if view.artifact_count is None: + raise _job_error( + "INTERNAL_ERROR", + "The canonical artifact count is unavailable.", + job_id=job_id, + ) + return ArtifactListResponse( + job_id=job_id, + artifacts=artifacts, + total=view.artifact_count, + limit=limit, + offset=offset, + ) + + return _tool_call(list_page) + + @server.tool(annotations=_SAFE_WRITE) + def export_artifact( + job_id: str, + artifact_id: str, + context: Context[AgentRuntime, Any], + ) -> ArtifactExportReceipt: + """Materialize verified bytes below the fixed agent-exports root. + + The tool never returns bytes or a host path. Its receipt identifies a + deterministic path below ``/agent-exports`` so the caller can + hand the result to the human without inspecting HHTools' private store. + """ + + return _tool_call(lambda: _runtime(context).exports.export(job_id, artifact_id)) + + @server.resource( + "hhtools://capabilities", + name="hhtools-capabilities", + description="Current HHTools capability snapshot.", + mime_type="application/json", + ) + def capabilities_resource() -> dict[str, Any]: + return _resource_call( + lambda: _model_document(active_runtime().capabilities.get_capabilities()) + ) + + @server.resource( + "hhtools://schemas/agent/v1/{schema_name}", + name="hhtools-agent-schema", + description="One public Agent v1 JSON Schema by canonical slug.", + mime_type="application/schema+json", + ) + def schema_resource(schema_name: str) -> dict[str, Any]: + def schema() -> dict[str, Any]: + model = PUBLIC_AGENT_SCHEMAS.get(schema_name) + if model is None: + raise JobManagerError( + ApiError( + code="SCHEMA_NOT_FOUND", + message="No public Agent schema has the requested name.", + stage=ErrorStage.REQUEST, + details={"schema_name": schema_name}, + ) + ) + return model.model_json_schema() + + return _resource_call(schema) + + @server.resource( + "hhtools://robots/{robot_id}", + name="hhtools-robot", + description="One robot capability and calibration-readiness record.", + mime_type="application/json", + ) + async def robot_resource( + robot_id: str, + context: Context, + ) -> dict[str, Any]: + def robot() -> dict[str, Any]: + robots = _runtime(context).capabilities.get_capabilities().robots + match = next((item for item in robots if item.robot_id == robot_id), None) + if match is None: + raise JobManagerError( + ApiError( + code="ROBOT_NOT_FOUND", + message="No robot has the requested id.", + stage=ErrorStage.REQUEST, + details={"robot_id": robot_id}, + ) + ) + return _model_document(match) + + return _resource_call(robot) + + @server.resource( + "hhtools://assets/{asset_id}/manifest", + name="hhtools-asset-manifest", + description="Portable immutable manifest for one registered asset.", + mime_type="application/json", + ) + async def asset_resource( + asset_id: str, + context: Context, + ) -> dict[str, Any]: + return _resource_call(lambda: _model_document(_runtime(context).assets.get(asset_id))) + + @server.resource( + "hhtools://plans/{plan_id}", + name="hhtools-retarget-plan", + description="One immutable preflighted retarget plan.", + mime_type="application/json", + ) + async def plan_resource( + plan_id: str, + context: Context, + ) -> dict[str, Any]: + return _resource_call(lambda: _model_document(_runtime(context).plans.get(plan_id))) + + @server.resource( + "hhtools://jobs/{job_id}/status", + name="hhtools-job-status", + description="Compact current state for one H2R job.", + mime_type="application/json", + ) + async def job_resource( + job_id: str, + context: Context, + ) -> dict[str, Any]: + return _resource_call(lambda: _model_document(_runtime(context).jobs.get_job(job_id))) + + @server.resource( + "hhtools://jobs/{job_id}/manifest", + name="hhtools-job-manifest", + description="Verified terminal audit manifest for one H2R job.", + mime_type="application/json", + ) + async def manifest_resource( + job_id: str, + context: Context, + ) -> dict[str, Any]: + return _resource_call( + lambda: _model_document( + _read_report(_runtime(context), job_id, "manifest", JobManifest) + ) + ) + + @server.resource( + "hhtools://jobs/{job_id}/evaluation", + name="hhtools-job-evaluation", + description="Verified quality report; completion alone is not approval.", + mime_type="application/json", + ) + async def evaluation_resource( + job_id: str, + context: Context, + ) -> dict[str, Any]: + return _resource_call( + lambda: _model_document( + _read_report( + _runtime(context), + job_id, + "evaluation_report", + EvaluationReport, + ) + ) + ) + + @server.resource( + "hhtools://jobs/{job_id}/failures", + name="hhtools-job-failures", + description="Verified structured failure report for a failed or partial job.", + mime_type="application/json", + ) + async def failures_resource( + job_id: str, + context: Context, + ) -> dict[str, Any]: + return _resource_call( + lambda: _model_document( + _read_report( + _runtime(context), + job_id, + "failure_report", + FailureReport, + ) + ) + ) + + @server.resource( + "hhtools://jobs/{job_id}/artifacts/{artifact_id}", + name="hhtools-artifact-descriptor", + description="Job-scoped descriptor only; binary bytes are never embedded.", + mime_type="application/json", + ) + async def artifact_resource( + job_id: str, + artifact_id: str, + context: Context, + ) -> dict[str, Any]: + return _resource_call( + lambda: _model_document( + _runtime(context).jobs.get_artifact(job_id, artifact_id, verify=True).descriptor + ) + ) + + _harden_tool_argument_models(server) + return server + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="hhtools-mcp", + description="Run the local HHTools MCP server over stdio.", + ) + parser.add_argument("--source", type=Path, default=Path("assets/motions")) + parser.add_argument("--save-dir", type=Path, default=Path("assets/save_npz")) + parser.add_argument("--cache", type=Path, default=None) + parser.add_argument("--job-settings", type=Path, default=None) + parser.add_argument("--max-running-jobs", type=int, default=None) + parser.add_argument("--max-queued-jobs", type=int, default=None) + parser.add_argument("--web-ui-url", default="http://127.0.0.1:8009") + return parser + + +_RUNTIME_LEASE_EXIT_CODE = 3 + + +def _runtime_lease_failure(exception: BaseException) -> RuntimeLeaseError | None: + """Unwrap an expected lease failure from the MCP SDK's task group.""" + + if isinstance(exception, RuntimeLeaseError): + return exception + if not isinstance(exception, BaseExceptionGroup): + return None + + matched, remainder = exception.split(RuntimeLeaseError) + # Do not hide an unrelated sibling failure. The MCP/AnyIO startup path + # currently wraps the single lifespan exception in an ExceptionGroup. + if matched is None or remainder is not None: + return None + pending: list[BaseException] = [matched] + while pending: + item = pending.pop() + if isinstance(item, RuntimeLeaseError): + return item + if isinstance(item, BaseExceptionGroup): + pending.extend(item.exceptions) + return None + + +def _runtime_lease_message(error: RuntimeLeaseError) -> str: + """Return one actionable stderr line without echoing a host path.""" + + if error.code == "RUNTIME_ALREADY_ACTIVE": + return ( + "ERROR RUNTIME_ALREADY_ACTIVE: Another HHTools runtime owns this Agent " + "data directory. Close the existing WebUI or other HHTools runtime, then " + "reconnect hhtools-mcp with the same --save-dir." + ) + return ( + "ERROR RUNTIME_LEASE_UNAVAILABLE: HHTools could not establish exclusive runtime " + "ownership. Check the configured --save-dir permissions and retry." + ) + + +def main(argv: Sequence[str] | None = None) -> None: + """Console entry point. stdout remains exclusively owned by MCP framing.""" + + arguments = _parser().parse_args(argv) + for name in ("max_running_jobs", "max_queued_jobs"): + value = getattr(arguments, name) + if value is not None and value < 0: + _parser().error(f"--{name.replace('_', '-')} must be non-negative") + config = LocalRuntimeConfig( + source_root=arguments.source, + save_dir=arguments.save_dir, + cache_dir=arguments.cache, + max_running_jobs=arguments.max_running_jobs, + max_queued_jobs=arguments.max_queued_jobs, + job_settings_path=arguments.job_settings, + web_ui_url=arguments.web_ui_url, + ) + try: + create_mcp_server(config).run("stdio") + except BaseException as exception: + lease_error = _runtime_lease_failure(exception) + if lease_error is None: + raise + print(_runtime_lease_message(lease_error), file=sys.stderr) + raise SystemExit(_RUNTIME_LEASE_EXIT_CODE) from None + + +if __name__ == "__main__": + main() + + +__all__ = ["RuntimeFactory", "create_mcp_server", "main"] diff --git a/hhtools/retarget/calibration/__init__.py b/hhtools/retarget/calibration/__init__.py index 81373180..0fddbfd0 100644 --- a/hhtools/retarget/calibration/__init__.py +++ b/hhtools/retarget/calibration/__init__.py @@ -7,15 +7,15 @@ * the same robot posed via manually dialled actuated-joint angles with the floating base at identity. -When the user confirms the two poses match visually, a yaml file is -written as ``retarget_calibration_.yaml`` next to the URDF -(one calibration per robot **and** per reference format). At -retarget time, per-canonical-joint scales + orientation offsets are -re-derived in closed form from the stored joint-angle configuration so -that the source motion's frame 0 lines up exactly with the robot's -calibrated pose. Subsequent frames flow through the scaler as -"relative rotation from motion-frame-0 composed with the calibrated -orientation offset". +When the user confirms the two poses match visually, a yaml file is written +as ``retarget_calibration_.yaml`` (one calibration per robot **and** +per reference format). Writable source trees retain a sibling file next to the +URDF; packaged read-only presets use a per-user override and fall back to their +bundled calibration. At retarget time, per-canonical-joint scales + orientation +offsets are re-derived in closed form from the stored joint-angle configuration +so that the source motion's frame 0 lines up exactly with the robot's calibrated +pose. Subsequent frames flow through the scaler as "relative rotation from +motion-frame-0 composed with the calibrated orientation offset". Compared to the ad-hoc first-frame heuristic this replaces, calibration is explicit, inspectable (``git diff`` the YAML), and — once done per @@ -35,14 +35,16 @@ normalize_calibration_reference, repair_apose_calibration_for_straight_t_reference, resolve_calibration_file, + resolve_preset_calibration_file, save_calibration, + save_calibration_for_preset, ) from hhtools.retarget.calibration.reference import ( HumanReferencePose, ReferenceName, build_motion_reference, - load_reference_pose, list_reference_names, + load_reference_pose, reference_pose_from_motion_frame0_quantized, ) @@ -63,5 +65,7 @@ "normalize_calibration_reference", "repair_apose_calibration_for_straight_t_reference", "resolve_calibration_file", + "resolve_preset_calibration_file", "save_calibration", + "save_calibration_for_preset", ] diff --git a/hhtools/retarget/calibration/calibration.py b/hhtools/retarget/calibration/calibration.py index 3f5ac75e..84a57a8c 100644 --- a/hhtools/retarget/calibration/calibration.py +++ b/hhtools/retarget/calibration/calibration.py @@ -6,12 +6,12 @@ 1. **Capture** — the viewer's calibration mode lets the user dial actuated joint angles so the robot, at floating-base identity, visually matches a chosen reference human T-pose. The resulting - configuration is packaged into a :class:`RobotRetargetCalibration` - and written next to the URDF as - ``retarget_calibration_.yaml`` (one file per robot **and** - per reference format: ``smpl``, ``lafan_bvh``, …) via - :func:`save_calibration`. Legacy ``retarget_calibration.yaml`` is - still loaded when its embedded ``reference`` matches. + configuration is packaged into a :class:`RobotRetargetCalibration` and + persisted via :func:`save_calibration_for_preset`. Writable source-tree + presets keep the historical sibling file; packaged read-only presets use a + per-user override below ``~/.config/hhtools/robots//``. Legacy + ``retarget_calibration.yaml`` is still loaded when its embedded + ``reference`` matches. 2. **Use** — at retarget time, :func:`build_scaler_config_from_calibration` reads that yaml, runs the URDF's forward kinematics at the stored @@ -47,9 +47,11 @@ from __future__ import annotations +import errno import logging +import os from dataclasses import dataclass, field -from pathlib import Path +from pathlib import Path, PurePosixPath, PureWindowsPath from typing import TYPE_CHECKING import numpy as np @@ -69,6 +71,7 @@ from hhtools.core.motion import Motion from hhtools.retarget.newton_basic.config import ScalerConfig from hhtools.retarget.newton_basic.rest_pose import SourceRestPose + from hhtools.robot.base import RobotPreset from hhtools.robot.loader import URDFRobotModel CALIBRATION_FILENAME = "retarget_calibration.yaml" @@ -197,6 +200,295 @@ def calibration_path_for( return base / CALIBRATION_FILENAME +def _require_calibration_reference(reference: str) -> str: + """Return a canonical, filename-safe calibration reference. + + Public Web endpoints ultimately pass user-selected reference names into + calibration storage. Normalising and validating before constructing a + filename both preserves the historical aliases and prevents an unknown + value from becoming a path component. + """ + + normalized = normalize_calibration_reference(str(reference)) + if normalized not in _VALID_CALIBRATION_REFERENCES: + raise ValueError( + f"unknown calibration reference {reference!r} " + f"(normalised {normalized!r}); expected one of " + f"{sorted(_VALID_CALIBRATION_REFERENCES)}" + ) + return normalized + + +def _safe_preset_component(name: str) -> str: + """Validate a preset id before using it below the user robot root.""" + + value = str(name).strip() + normalized = value.replace("\\", "/") + posix = PurePosixPath(normalized) + windows = PureWindowsPath(value) + if ( + not value + or "\x00" in value + or posix.is_absolute() + or windows.is_absolute() + or bool(windows.drive) + or len(posix.parts) != 1 + or posix.name in {"", ".", ".."} + ): + raise ValueError(f"unsafe robot preset name for calibration storage: {name!r}") + return posix.name + + +def _user_calibration_directory( + preset: RobotPreset, + user_root: str | Path | None, +) -> Path: + """Return a contained per-preset directory in the user robot library. + + ``resolve(strict=False)`` deliberately resolves any already-existing + symlink components. A per-robot symlink that escapes the configured user + root is rejected before either a read or a write can follow it. + """ + + if user_root is None: + from hhtools.utils.paths import user_robot_dir + + root = user_robot_dir() + else: + root = Path(user_root).expanduser() + resolved_root = root.resolve(strict=False) + candidate = (resolved_root / _safe_preset_component(preset.name)).resolve( + strict=False + ) + try: + candidate.relative_to(resolved_root) + except ValueError as err: + raise ValueError( + f"user calibration directory escapes the configured robot root: {candidate}" + ) from err + return candidate + + +def _reference_specific_filenames(reference: str) -> tuple[str, ...]: + """Canonical filename followed by an optional historical alias filename.""" + + canonical = _require_calibration_reference(reference) + names = [f"retarget_calibration_{canonical}.yaml"] + raw = str(reference) + if raw != canonical and normalize_calibration_reference(raw) == canonical: + names.append(f"retarget_calibration_{raw}.yaml") + return tuple(names) + + +def _contained_calibration_path(directory: Path, candidate: Path) -> Path: + """Resolve one candidate without allowing a file symlink to escape.""" + + root = directory.resolve(strict=False) + resolved = candidate.resolve(strict=False) + try: + resolved.relative_to(root) + except ValueError as err: + raise ValueError( + f"calibration path escapes its preset directory: {candidate}" + ) from err + return resolved + + +def _validate_preset_calibration_candidate( + path: Path, + preset: RobotPreset, + reference: str, + *, + legacy: bool, +) -> bool: + """Validate one candidate and report whether it matches ``reference``. + + A reference-specific filename is an explicit claim and therefore a + malformed document or mismatched identity is an error. The legacy + single-file form may validly belong to another reference; that one case is + a normal non-match so resolution can continue to the bundled fallback. + """ + + wanted = _require_calibration_reference(reference) + try: + calibration = load_calibration(path) + except Exception as err: + raise ValueError(f"invalid retarget calibration at {path}: {err}") from err + + if calibration.robot != preset.name: + raise ValueError( + f"{path}: calibration robot {calibration.robot!r} does not match " + f"preset {preset.name!r}" + ) + actual = normalize_calibration_reference(str(calibration.reference)) + if actual != wanted: + if legacy: + return False + raise ValueError( + f"{path}: calibration reference {actual!r} does not match {wanted!r}" + ) + return True + + +def _resolve_calibration_in_directory( + directory: Path, + preset: RobotPreset, + reference: str, +) -> Path | None: + """Resolve and strictly validate one directory layer.""" + + for filename in _reference_specific_filenames(reference): + preferred = directory / filename + if preferred.is_file() or preferred.is_symlink(): + preferred = _contained_calibration_path(directory, preferred) + if _validate_preset_calibration_candidate( + preferred, preset, reference, legacy=False + ): + return preferred + + legacy = directory / CALIBRATION_FILENAME + if legacy.is_file() or legacy.is_symlink(): + legacy = _contained_calibration_path(directory, legacy) + if _validate_preset_calibration_candidate( + legacy, preset, reference, legacy=True + ): + return legacy + return None + + +def resolve_preset_calibration_file( + preset: RobotPreset, + reference: str, + user_root: str | Path | None = None, +) -> Path | None: + """Resolve a preset calibration with a per-user override layer. + + Resolution order is deliberately deterministic: + + 1. ``//`` (canonical file, historical alias, + then matching legacy single-file calibration), + 2. the directory containing the preset URDF, and + 3. ``preset.root_dir`` when it differs from the URDF directory. + + The user layer is therefore writable even when a packaged robot lives in + a root-owned ``/opt`` tree. Existing source checkouts retain their bundled + calibration fallback. A present but malformed or identity-mismatched + override raises :class:`ValueError`; silently falling back would make a + successful-looking save use a different file on the next run. + """ + + _require_calibration_reference(reference) + user_directory = _user_calibration_directory(preset, user_root) + + search: list[Path] = [user_directory] + urdf_path = getattr(preset, "urdf_path", None) + if urdf_path is not None: + search.append(Path(urdf_path).parent) + search.append(Path(preset.root_dir)) + + seen: set[Path] = set() + for directory in search: + resolved_directory = directory.resolve(strict=False) + if resolved_directory in seen: + continue + seen.add(resolved_directory) + candidate = _resolve_calibration_in_directory( + resolved_directory, preset, reference + ) + if candidate is not None: + return candidate + return None + + +def _user_override_exists(directory: Path, reference: str) -> bool: + """Whether this preset has adopted user-layer calibration storage.""" + + filenames = (*_reference_specific_filenames(reference), CALIBRATION_FILENAME) + if any( + (directory / name).exists() or (directory / name).is_symlink() + for name in filenames + ): + return True + try: + return any(directory.glob("retarget_calibration_*.yaml")) + except OSError: + return False + + +def _path_appears_writable(path: Path) -> bool: + """Cheap preflight for packaged read-only directories. + + This is only an optimisation and user-facing hint boundary; the actual + write remains authoritative and permission/read-only errors are handled + below to avoid a time-of-check/time-of-use assumption. + """ + + probe = path if path.exists() else path.parent + return probe.exists() and os.access(probe, os.W_OK) + + +def _is_read_only_write_error(error: OSError) -> bool: + return isinstance(error, PermissionError) or error.errno in { + errno.EACCES, + errno.EPERM, + errno.EROFS, + } + + +def save_calibration_for_preset( + calibration: RobotRetargetCalibration, + preset: RobotPreset, + *, + derived: _DerivedParams | None = None, + user_robot_root: str | Path | None = None, +) -> Path: + """Persist calibration beside a writable source preset or in user overlay. + + Source-tree development keeps the historical, version-controllable + sibling YAML. Installed packages normally fail the writability preflight + (or the authoritative open with ``EACCES``/``EROFS``), at which point the + exact same document is written below the per-user robot directory. Once a + preset has any user calibration, later saves stay in that layer so a + writable checkout cannot unexpectedly bypass an existing override. + """ + + reference = _require_calibration_reference(str(calibration.reference)) + if calibration.robot != preset.name: + raise ValueError( + f"calibration robot {calibration.robot!r} does not match preset " + f"{preset.name!r}" + ) + + user_directory = _user_calibration_directory(preset, user_robot_root) + user_target = _contained_calibration_path( + user_directory, + user_directory / f"retarget_calibration_{reference}.yaml", + ) + + urdf_path = getattr(preset, "urdf_path", None) + bundled_directory = ( + Path(urdf_path).parent if urdf_path is not None else Path(preset.root_dir) + ).resolve(strict=False) + bundled_target = _contained_calibration_path( + bundled_directory, + bundled_directory / f"retarget_calibration_{reference}.yaml", + ) + + if ( + bundled_target.resolve(strict=False) == user_target.resolve(strict=False) + or _user_override_exists(user_directory, reference) + or not _path_appears_writable(bundled_target) + ): + return save_calibration(calibration, user_target, derived=derived) + + try: + return save_calibration(calibration, bundled_target, derived=derived) + except OSError as err: + if not _is_read_only_write_error(err): + raise + return save_calibration(calibration, user_target, derived=derived) + + def resolve_calibration_file( robot_preset_dir: str | Path, reference: str, @@ -368,7 +660,7 @@ def load_calibration(path: str | Path) -> RobotRetargetCalibration: def repair_apose_calibration_for_straight_t_reference( calibration: RobotRetargetCalibration, - robot_preset_dir: str | Path, + robot_preset_dir: str | Path | RobotPreset, ) -> RobotRetargetCalibration: """Borrow T-pose arm angles from a sibling calibration when needed. @@ -388,11 +680,20 @@ def repair_apose_calibration_for_straight_t_reference( if abs(roll) > 0.5: return calibration - preset_dir = Path(robot_preset_dir) + preset = ( + robot_preset_dir + if hasattr(robot_preset_dir, "name") and hasattr(robot_preset_dir, "root_dir") + else None + ) + preset_dir = Path(preset.root_dir if preset is not None else robot_preset_dir) for donor_ref in _STRAIGHT_T_ARM_REFERENCES: if donor_ref == ref: continue - donor_path = resolve_calibration_file(preset_dir, donor_ref) + donor_path = ( + resolve_preset_calibration_file(preset, donor_ref) + if preset is not None + else resolve_calibration_file(preset_dir, donor_ref) + ) if donor_path is None: continue try: @@ -860,10 +1161,10 @@ def build_scaler_config_from_calibration( from dataclasses import replace as _dc_replace - preset_dir = getattr(getattr(model, "preset", None), "root_dir", None) - if preset_dir is not None: + preset = getattr(model, "preset", None) + if preset is not None: calibration = repair_apose_calibration_for_straight_t_reference( - calibration, preset_dir, + calibration, preset, ) from hhtools.retarget.newton_basic.human_aliases import ( diff --git a/hhtools/retarget/interaction_mesh/arm_reach.py b/hhtools/retarget/interaction_mesh/arm_reach.py index 9e3c38ff..8fe874cc 100644 --- a/hhtools/retarget/interaction_mesh/arm_reach.py +++ b/hhtools/retarget/interaction_mesh/arm_reach.py @@ -14,7 +14,6 @@ from __future__ import annotations import logging -from pathlib import Path from typing import TYPE_CHECKING, Iterable import numpy as np @@ -58,15 +57,17 @@ def _resolve_robot_arm_joint_q( if joint_q: return dict(joint_q) try: - from hhtools.retarget.calibration import load_calibration, resolve_calibration_file + from hhtools.retarget.calibration import ( + load_calibration, + resolve_preset_calibration_file, + ) urdf = getattr(robot.preset, "urdf_path", None) if urdf is None: return None - parent = Path(urdf).parent # OmniContact / Mixamo use lafan_bvh; fall through common refs. for ref in ("lafan_bvh", "smpl", "soma_bvh", "omnicontact_bvh"): - path = resolve_calibration_file(parent, ref) + path = resolve_preset_calibration_file(robot.preset, ref) if path is None: continue cal = load_calibration(path) diff --git a/hhtools/retarget/newton_basic/_warp_config.py b/hhtools/retarget/newton_basic/_warp_config.py index 40d0a90f..eb9f59b0 100644 --- a/hhtools/retarget/newton_basic/_warp_config.py +++ b/hhtools/retarget/newton_basic/_warp_config.py @@ -55,7 +55,11 @@ def is_cache_persistent() -> bool: return _persistent -def configure(explicit: str | os.PathLike[str] | None = None) -> Path: +def configure( + explicit: str | os.PathLike[str] | None = None, + *, + quiet: bool | None = None, +) -> Path: """Point Warp at a writable kernel cache directory. Must be called *before* the first ``import warp``; we rely on the @@ -68,12 +72,17 @@ def configure(explicit: str | os.PathLike[str] | None = None) -> Path: created on demand. Otherwise we honour ``WARP_CACHE_DIR`` / default, and fall back to the workspace-local cache when the default isn't writable. + quiet: Optional process-local Warp diagnostic setting. ``True`` is + used by stdio protocol hosts so Warp cannot write its first-init + banner into the wire. The default ``None`` preserves Warp's + existing behavior for the CLI and WebUI. Returns: The resolved cache directory. """ + def _commit(path: Path, *, persistent: bool) -> Path: - global _configured, _resolved, _persistent + global _configured, _resolved, _persistent # noqa: PLW0603 - module config snapshot path.mkdir(parents=True, exist_ok=True) os.environ["WARP_CACHE_DIR"] = str(path) # Also update config for already-loaded ``warp`` modules; this is @@ -81,6 +90,13 @@ def _commit(path: Path, *, persistent: bool) -> Path: # env var is the authoritative setting. try: import warp as wp # local import — may not be imported yet + + # Importing Warp itself is silent; its first ``wp.init()`` emits + # the device banner. Set this before any Newton import can cause + # that initialization. Do not redirect process-wide stdout: + # concurrent stdio traffic could otherwise be captured with it. + if quiet is not None: + wp.config.quiet = quiet wp.config.kernel_cache_dir = str(path) except ImportError: pass @@ -94,7 +110,7 @@ def _commit(path: Path, *, persistent: bool) -> Path: "Warp kernel cache fell back to a non-persistent temp dir: %s. " "GPU kernels will be RECOMPILED every run. Make a stable cache " "writable, e.g.:\n" - " sudo chown -R \"$USER\" ~/.cache/warp .hhtools/warp_cache 2>/dev/null; " + ' sudo chown -R "$USER" ~/.cache/warp .hhtools/warp_cache 2>/dev/null; ' "rm -rf ~/.cache/warp/* .hhtools/warp_cache/*\n" "or set WARP_CACHE_DIR to a writable, persistent path.", path, @@ -155,9 +171,7 @@ def _dir_writable(path: Path) -> bool: except OSError: return False try: - with tempfile.NamedTemporaryFile( - dir=str(path), prefix=".hhtools_writetest_", delete=True - ): + with tempfile.NamedTemporaryFile(dir=str(path), prefix=".hhtools_writetest_", delete=True): pass except OSError: return False diff --git a/hhtools/retarget/robot_to_robot.py b/hhtools/retarget/robot_to_robot.py index d107340d..efdb10af 100644 --- a/hhtools/retarget/robot_to_robot.py +++ b/hhtools/retarget/robot_to_robot.py @@ -29,9 +29,16 @@ from __future__ import annotations import csv +import errno +import hashlib +import math +import os import pickle +import re +import tempfile +from collections.abc import Mapping from dataclasses import dataclass -from pathlib import Path +from pathlib import Path, PurePosixPath, PureWindowsPath import numpy as np from numpy.typing import NDArray @@ -62,6 +69,8 @@ "load_r2r_calibration", "load_source_trajectory", "r2r_calibration_path", + "r2r_user_calibration_path", + "resolve_r2r_calibration_file", "retarget_robot_to_robot", "save_r2r_calibration", "align_retargeted_ankles_to_scaled_source", @@ -770,9 +779,410 @@ def trajectory_to_retargeted_motion( # --------------------------------------------------------------------------- +_R2R_CALIBRATION_PREFIX = "r2r_calibration_" +_R2R_CALIBRATION_SUFFIX = ".yaml" +_R2R_CALIBRATION_KIND = "robot_to_robot" +_MAX_R2R_CALIBRATION_BYTES = 1024 * 1024 +_PORTABLE_CALIBRATION_COMPONENT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,126}$") +_WINDOWS_RESERVED_COMPONENTS = frozenset( + { + "CON", + "PRN", + "AUX", + "NUL", + *(f"COM{i}" for i in range(1, 10)), + *(f"LPT{i}" for i in range(1, 10)), + } +) + + +def _is_windows_reserved_component(value: str) -> bool: + return value.rstrip(" .").split(".", 1)[0].upper() in _WINDOWS_RESERVED_COMPONENTS + + +def _validated_robot_identity(value: str, *, field: str) -> str: + """Validate a logical robot id without turning it into a filesystem path.""" + + if not isinstance(value, str) or not value or len(value) > 512: + raise ValueError(f"{field} must be a non-empty string of at most 512 characters") + if any(ord(char) < 32 or ord(char) == 127 for char in value): + raise ValueError(f"{field} contains a control character") + return value + + +def _portable_calibration_component(value: str) -> str: + """Return a readable, collision-resistant filename component. + + Registry ids normally fit the portable subset, so names such as + ``g1_29dof`` and ``rp1`` keep their historical filenames. An id containing + a separator, drive marker, Unicode, or a Windows device name is represented + by a digest instead of lossy character replacement. + """ + + identity = _validated_robot_identity(value, field="robot identity") + if ( + _PORTABLE_CALIBRATION_COMPONENT.fullmatch(identity) + and identity not in {".", ".."} + and not identity.endswith(".") + and not _is_windows_reserved_component(identity) + ): + return identity + digest = hashlib.sha256(identity.encode("utf-8")).hexdigest() + return f"id-{digest}" + + +def _safe_target_component(name: str) -> str: + """Use the same per-preset directory convention as H2R calibration.""" + + value = _validated_robot_identity(name, field="target_robot").strip() + normalized = value.replace("\\", "/") + posix = PurePosixPath(normalized) + windows = PureWindowsPath(value) + if ( + not value + or posix.is_absolute() + or windows.is_absolute() + or bool(windows.drive) + or len(posix.parts) != 1 + or posix.name in {"", ".", ".."} + or _is_windows_reserved_component(value) + or value.endswith((" ", ".")) + or any(char in '<>:"|?*' for char in value) + ): + raise ValueError(f"unsafe target_robot for calibration storage: {name!r}") + return posix.name + + +def _calibration_filename(source_name: str) -> str: + return ( + f"{_R2R_CALIBRATION_PREFIX}" + f"{_portable_calibration_component(source_name)}" + f"{_R2R_CALIBRATION_SUFFIX}" + ) + + +def _user_robot_root(user_root: str | Path | None) -> Path: + if user_root is not None: + return Path(user_root).expanduser() + from hhtools.utils.paths import user_robot_dir + + return user_robot_dir() + + +def _path_below(root: Path, relative: str) -> Path: + """Join one generated child and prove that it remains below ``root``.""" + + resolved_root = root.expanduser().resolve(strict=False) + candidate = (resolved_root / relative).resolve(strict=False) + try: + candidate.relative_to(resolved_root) + except ValueError as err: # defensive: components above are already encoded + raise ValueError("calibration path escapes its storage root") from err + return candidate + + def r2r_calibration_path(target_dir: str | Path, source_name: str) -> Path: - safe = source_name.replace("/", "_").replace(":", "_") - return Path(target_dir) / f"r2r_calibration_{safe}.yaml" + """Return the legacy/bundled calibration path beside a target URDF. + + Standard registry ids retain the historical filename. Unsafe ids use a + digest; :func:`resolve_r2r_calibration_file` still discovers old sanitized + sibling files by inspecting and validating their payload. + """ + + # The generated filename is one portable component, so joining it cannot + # escape ``target_dir``. Keep the caller's relative/absolute path form for + # backwards compatibility with the original public helper. + return Path(target_dir).expanduser() / _calibration_filename(source_name) + + +def r2r_user_calibration_path( + target_robot: str, + source_name: str, + *, + user_root: str | Path | None = None, +) -> Path: + """Return the writable per-user override path for one robot pair.""" + + target_component = _safe_target_component(target_robot) + root = _user_robot_root(user_root) + target_root = _path_below(root, target_component) + return _path_below(target_root, _calibration_filename(source_name)) + + +def _validated_joint_q(value: object, *, path: Path | None = None) -> dict[str, float]: + where = f"{path}: " if path is not None else "" + if not isinstance(value, Mapping) or not value: + raise ValueError(f"{where}calibrated_joint_q must be a non-empty mapping") + out: dict[str, float] = {} + for raw_name, raw_value in value.items(): + if not isinstance(raw_name, str) or not raw_name: + raise ValueError(f"{where}calibrated_joint_q contains an invalid joint name") + if any(ord(char) < 32 or ord(char) == 127 for char in raw_name): + raise ValueError( + f"{where}calibrated_joint_q joint {raw_name!r} contains a control character" + ) + if isinstance(raw_value, bool) or not isinstance(raw_value, int | float): + raise ValueError(f"{where}joint {raw_name!r} must contain a numeric angle") + angle = float(raw_value) + if not math.isfinite(angle): + raise ValueError(f"{where}joint {raw_name!r} contains a non-finite angle") + out[raw_name] = angle + return out + + +def _validated_r2r_payload( + value: object, + *, + source_robot: str, + target_robot: str | None, + path: Path, +) -> tuple[str, dict[str, float]]: + if not isinstance(value, Mapping): + raise ValueError(f"{path}: calibration yaml root must be a mapping") + if value.get("kind") != _R2R_CALIBRATION_KIND: + raise ValueError(f"{path}: calibration kind must be {_R2R_CALIBRATION_KIND!r}") + stored_target = value.get("target_robot") + stored_source = value.get("source_robot") + if not isinstance(stored_target, str): + raise ValueError(f"{path}: target_robot must be a string") + if not isinstance(stored_source, str): + raise ValueError(f"{path}: source_robot must be a string") + _validated_robot_identity(stored_target, field="target_robot") + _validated_robot_identity(stored_source, field="source_robot") + if stored_source != source_robot: + raise ValueError( + f"{path}: calibration source {stored_source!r} does not match " + f"requested source {source_robot!r}" + ) + if target_robot is not None and stored_target != target_robot: + raise ValueError( + f"{path}: calibration target {stored_target!r} does not match " + f"requested target {target_robot!r}" + ) + return stored_target, _validated_joint_q(value.get("calibrated_joint_q"), path=path) + + +def _read_r2r_payload( + path: Path, + *, + source_robot: str, + target_robot: str | None, +) -> tuple[str, dict[str, float]]: + import yaml + + try: + stat = path.lstat() + except FileNotFoundError: + raise + if path.is_symlink() or not path.is_file(): + raise ValueError(f"{path}: calibration must be a regular non-symlink file") + if stat.st_size > _MAX_R2R_CALIBRATION_BYTES: + raise ValueError(f"{path}: calibration exceeds {_MAX_R2R_CALIBRATION_BYTES} bytes") + try: + with path.open("r", encoding="utf-8") as fp: + data = yaml.safe_load(fp) + except (OSError, UnicodeError, yaml.YAMLError) as err: + raise ValueError(f"{path}: calibration could not be parsed: {err}") from err + return _validated_r2r_payload( + data, + source_robot=source_robot, + target_robot=target_robot, + path=path, + ) + + +def _legacy_r2r_candidates(directory: Path, *, canonical: Path) -> list[Path]: + """Return contained old lossy filenames for read-only compatibility.""" + + if not directory.is_dir(): + return [] + out: list[Path] = [] + resolved_directory = directory.resolve(strict=True) + for candidate in sorted(directory.glob("r2r_calibration_*.yaml")): + try: + resolved = candidate.resolve(strict=True) + resolved.relative_to(resolved_directory) + except (OSError, ValueError): + continue + if resolved == canonical.resolve(strict=False) or candidate.is_symlink(): + continue + out.append(resolved) + return out + + +def _legacy_r2r_path(directory: Path, source_name: str) -> Path | None: + """Recreate the historical lossy filename when it is still path-safe.""" + + component = source_name.replace("/", "_").replace(":", "_") + if _portable_calibration_component(component) != component: + return None + return directory.resolve(strict=False) / ( + f"{_R2R_CALIBRATION_PREFIX}{component}{_R2R_CALIBRATION_SUFFIX}" + ) + + +def _resolve_r2r_calibration( + target_dir: str | Path, + source_name: str, + *, + target_robot: str | None, + user_root: str | Path | None, +) -> tuple[Path, dict[str, float]] | None: + source = _validated_robot_identity(source_name, field="source_robot") + expected_target = ( + _validated_robot_identity(target_robot, field="target_robot") + if target_robot is not None + else None + ) + target_path = Path(target_dir).expanduser().resolve(strict=False) + inferred_target = expected_target or target_path.name + user_path = r2r_user_calibration_path( + inferred_target, + source, + user_root=user_root, + ) + bundled_path = r2r_calibration_path(target_path, source) + + # A canonical user override is authoritative. If it exists but is invalid, + # surface that error rather than silently falling back to a bundled default. + if user_path.exists() or user_path.is_symlink(): + _stored_target, joint_q = _read_r2r_payload( + user_path, + source_robot=source, + target_robot=expected_target, + ) + return user_path, joint_q + + user_legacy_path = _legacy_r2r_path(user_path.parent, source) + if ( + user_legacy_path is not None + and user_legacy_path != user_path + and (user_legacy_path.exists() or user_legacy_path.is_symlink()) + ): + _stored_target, joint_q = _read_r2r_payload( + user_legacy_path, + source_robot=source, + target_robot=expected_target, + ) + return user_legacy_path, joint_q + + user_legacy: list[tuple[Path, dict[str, float]]] = [] + for candidate in _legacy_r2r_candidates(user_path.parent, canonical=user_path): + try: + _stored_target, joint_q = _read_r2r_payload( + candidate, + source_robot=source, + target_robot=expected_target, + ) + except ValueError: + continue + user_legacy.append((candidate, joint_q)) + if len(user_legacy) > 1: + raise ValueError( + f"multiple user R2R calibrations match target={inferred_target!r}, source={source!r}" + ) + if user_legacy: + return user_legacy[0] + + if bundled_path.exists() or bundled_path.is_symlink(): + _stored_target, joint_q = _read_r2r_payload( + bundled_path, + source_robot=source, + target_robot=expected_target, + ) + return bundled_path, joint_q + + bundled_legacy_path = _legacy_r2r_path(target_path, source) + if ( + bundled_legacy_path is not None + and bundled_legacy_path != bundled_path + and (bundled_legacy_path.exists() or bundled_legacy_path.is_symlink()) + ): + _stored_target, joint_q = _read_r2r_payload( + bundled_legacy_path, + source_robot=source, + target_robot=expected_target, + ) + return bundled_legacy_path, joint_q + + bundled_legacy: list[tuple[Path, dict[str, float]]] = [] + for candidate in _legacy_r2r_candidates(target_path, canonical=bundled_path): + try: + _stored_target, joint_q = _read_r2r_payload( + candidate, + source_robot=source, + target_robot=expected_target, + ) + except ValueError: + continue + bundled_legacy.append((candidate, joint_q)) + if len(bundled_legacy) > 1: + raise ValueError( + f"multiple bundled R2R calibrations match target={inferred_target!r}, source={source!r}" + ) + return bundled_legacy[0] if bundled_legacy else None + + +def resolve_r2r_calibration_file( + target_dir: str | Path, + source_name: str, + *, + target_robot: str | None = None, + user_root: str | Path | None = None, +) -> Path | None: + """Resolve a validated R2R calibration, preferring a user override.""" + + resolved = _resolve_r2r_calibration( + target_dir, + source_name, + target_robot=target_robot, + user_root=user_root, + ) + return resolved[0] if resolved is not None else None + + +def _atomic_write_r2r_payload(path: Path, payload: Mapping[str, object]) -> None: + import yaml + + path.parent.mkdir(parents=True, exist_ok=True) + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + prefix=f".{path.name}.", + suffix=".tmp", + dir=path.parent, + delete=False, + ) as fp: + temporary = Path(fp.name) + yaml.safe_dump(payload, fp, sort_keys=True, default_flow_style=False) + fp.flush() + os.fsync(fp.fileno()) + os.replace(temporary, path) + temporary = None + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +def _is_readonly_write_error(error: OSError) -> bool: + return isinstance(error, PermissionError) or error.errno in { + errno.EACCES, + errno.EPERM, + errno.EROFS, + } + + +def _user_r2r_override_exists(user_path: Path) -> bool: + """Whether this target preset has adopted user-layer R2R storage.""" + + if user_path.exists() or user_path.is_symlink(): + return True + try: + return any(user_path.parent.glob("r2r_calibration_*.yaml")) + except OSError: + return False def save_r2r_calibration( @@ -781,38 +1191,58 @@ def save_r2r_calibration( target_robot: str, source_robot: str, calibrated_joint_q: dict[str, float], + user_root: str | Path | None = None, ) -> Path: - import yaml - - path = r2r_calibration_path(target_dir, source_robot) - path.parent.mkdir(parents=True, exist_ok=True) + target = _validated_robot_identity(target_robot, field="target_robot") + source = _validated_robot_identity(source_robot, field="source_robot") + joint_q = _validated_joint_q(calibrated_joint_q) payload = { - "kind": "robot_to_robot", - "target_robot": target_robot, - "source_robot": source_robot, - "calibrated_joint_q": { - k: float(v) for k, v in sorted(calibrated_joint_q.items()) - }, + "kind": _R2R_CALIBRATION_KIND, + "target_robot": target, + "source_robot": source, + "calibrated_joint_q": {k: joint_q[k] for k in sorted(joint_q)}, } - with path.open("w", encoding="utf-8") as fp: - yaml.safe_dump(payload, fp, sort_keys=True, default_flow_style=False) - return path + sibling = r2r_calibration_path(target_dir, source) + user_path = r2r_user_calibration_path(target, source, user_root=user_root) + + # Once a user override exists it remains authoritative, even in a source + # checkout whose sibling directory becomes writable again. + same_storage_path = sibling.resolve(strict=False) == user_path.resolve(strict=False) + if _user_r2r_override_exists(user_path) or same_storage_path: + _atomic_write_r2r_payload(user_path, payload) + return user_path + + try: + _atomic_write_r2r_payload(sibling, payload) + return sibling + except OSError as err: + if not _is_readonly_write_error(err): + raise + _atomic_write_r2r_payload(user_path, payload) + return user_path def load_r2r_calibration( - target_dir: str | Path, source_name: str + target_dir: str | Path, + source_name: str, + *, + target_robot: str | None = None, + user_root: str | Path | None = None, ) -> dict[str, float] | None: - import yaml + """Load one validated R2R calibration with user-over-bundled precedence. - path = r2r_calibration_path(target_dir, source_name) - if not path.is_file(): - return None - with path.open("r", encoding="utf-8") as fp: - data = yaml.safe_load(fp) or {} - jq = data.get("calibrated_joint_q") or {} - if not isinstance(jq, dict): - return None - return {str(k): float(v) for k, v in jq.items()} + ``target_robot`` remains optional for source compatibility. New callers + should pass it so a copied calibration cannot be applied to another target + preset that happens to share the same directory. + """ + + resolved = _resolve_r2r_calibration( + target_dir, + source_name, + target_robot=target_robot, + user_root=user_root, + ) + return dict(resolved[1]) if resolved is not None else None # --------------------------------------------------------------------------- diff --git a/hhtools/robot/foot_geometry.py b/hhtools/robot/foot_geometry.py index 3edfa730..3df912d1 100644 --- a/hhtools/robot/foot_geometry.py +++ b/hhtools/robot/foot_geometry.py @@ -26,6 +26,15 @@ _GEOM_VERTEX_CACHE: dict[tuple[str, str], np.ndarray] = {} +def _model_content_identity(model: URDFRobotModel) -> str: + """Separate Agent caches by immutable RobotBundle when identity is known.""" + + asset_id = model.preset.meta.get("_agent_asset_id") + if isinstance(asset_id, str) and asset_id: + return asset_id + return f"preset:{model.preset.name}" + + def _lateral_axis_idx(preset) -> int: up = str(getattr(preset, "up_axis", "Z") or "Z").upper() fwd = str(getattr(preset, "forward_axis", "X") or "X").upper() @@ -88,7 +97,7 @@ def _root_lateral_direction(preset, root_xyzw: np.ndarray | None) -> np.ndarray: def _foot_mesh_node_parts(model: "URDFRobotModel", link: str) -> tuple[tuple[str, str], ...]: """Cached ``(scene_node, geom_name)`` pairs belonging to ``link``.""" - key = (str(model.preset.name), link) + key = (_model_content_identity(model), link) cached = _FOOT_MESH_NODE_CACHE.get(key) if cached is not None: return cached @@ -110,7 +119,7 @@ def _foot_mesh_node_parts(model: "URDFRobotModel", link: str) -> tuple[tuple[str def _cached_geom_vertices(model: "URDFRobotModel", geom_name: str) -> np.ndarray | None: import trimesh - key = (str(model.preset.name), geom_name) + key = (_model_content_identity(model), geom_name) if key in _GEOM_VERTEX_CACHE: return _GEOM_VERTEX_CACHE[key] geom = model.urdf.scene.geometry.get(geom_name) diff --git a/hhtools/robot/joint_scales.py b/hhtools/robot/joint_scales.py index 45459beb..f7b134bd 100644 --- a/hhtools/robot/joint_scales.py +++ b/hhtools/robot/joint_scales.py @@ -35,12 +35,14 @@ _scale_context_cache: dict[tuple[object, ...], tuple[dict[str, float], dict[str, float]]] = {} -def _newest_calibration_mtime(robot_dir: Path) -> float: - from hhtools.retarget.calibration.calibration import resolve_calibration_file +def _newest_calibration_mtime(preset) -> float: + from hhtools.retarget.calibration.calibration import ( + resolve_preset_calibration_file, + ) newest = 0.0 for ref in _CALIBRATION_REF_ORDER: - cal_path = resolve_calibration_file(robot_dir, ref) + cal_path = resolve_preset_calibration_file(preset, ref) if cal_path is not None and cal_path.is_file(): try: newest = max(newest, cal_path.stat().st_mtime) @@ -65,8 +67,10 @@ def _scale_context_cache_key(preset) -> tuple[object, ...]: urdf_mtime = preset.urdf_path.stat().st_mtime except OSError: pass - cal_mtime = _newest_calibration_mtime(Path(preset.root_dir)) + cal_mtime = _newest_calibration_mtime(preset) + agent_asset_id = preset.meta.get("_agent_asset_id") return ( + agent_asset_id if isinstance(agent_asset_id, str) else None, preset.name, y_mtime, urdf_mtime, @@ -228,7 +232,7 @@ def all_calibration_scales_for_preset( from hhtools.retarget.calibration.calibration import ( derive_calibration_params, load_calibration, - resolve_calibration_file, + resolve_preset_calibration_file, ) model = robot_model @@ -242,7 +246,7 @@ def all_calibration_scales_for_preset( tables: list[dict[str, float]] = [] for ref in _CALIBRATION_REF_ORDER: - cal_path = resolve_calibration_file(preset.root_dir, ref) + cal_path = resolve_preset_calibration_file(preset, ref) if cal_path is None or not cal_path.is_file(): continue try: @@ -361,7 +365,7 @@ def joint_scale_baselines_for_preset( from hhtools.retarget.calibration.calibration import ( derive_calibration_params, load_calibration, - resolve_calibration_file, + resolve_preset_calibration_file, ) model = robot_model @@ -371,7 +375,7 @@ def joint_scale_baselines_for_preset( model = load_robot(preset, compile_mjcf=False) for ref in _CALIBRATION_REF_ORDER: - cal_path = resolve_calibration_file(preset.root_dir, ref) + cal_path = resolve_preset_calibration_file(preset, ref) if cal_path is None or not cal_path.is_file(): continue try: diff --git a/hhtools/robot/registry.py b/hhtools/robot/registry.py index 71bd82aa..d260391c 100644 --- a/hhtools/robot/registry.py +++ b/hhtools/robot/registry.py @@ -72,6 +72,24 @@ def list_presets() -> list[RobotPreset]: return sorted(_CACHE.values(), key=lambda p: p.name) +def list_presets_readonly() -> list[RobotPreset]: + """Discover existing YAML presets without scaffolding or mutating cache. + + Capability discovery and preflight use this path because a read-only query + must not turn an orphan URDF into a newly written ``robot.yaml``. Normal + UI/CLI discovery keeps the historical zero-config scaffolding behaviour in + :func:`list_presets` and :func:`refresh`. + """ + + if _CACHE is not None: + return sorted(_CACHE.values(), key=lambda preset: preset.name) + discovered: dict[str, RobotPreset] = {} + for root in _discovery_roots(): + for preset in _scan_root(root, scaffold_missing=False): + discovered[preset.name] = preset + return sorted(discovered.values(), key=lambda preset: preset.name) + + def get(name: str) -> RobotPreset: """Look up a preset by name. Raises :class:`KeyError` if unknown.""" _ensure_loaded() @@ -120,6 +138,23 @@ def preset_from_dir(drop: Path) -> RobotPreset: return preset +def preset_from_yaml(yaml_path: Path) -> RobotPreset: + """Load one exact existing preset YAML without caching or scaffolding. + + Agent preflight uses this after binding the YAML hash to a RobotBundle so + a previously populated process cache cannot supply stale ``dof_order`` or + ``ik_map`` values. The function is read-only and never synthesizes files. + """ + + path = Path(yaml_path).resolve(strict=True) + if not path.is_file() or not ( + path.name == "robot.yaml" + or (path.name.startswith("robot.") and path.name.endswith(".yaml")) + ): + raise FileNotFoundError(f"no robot preset YAML at {path}") + return _load_preset(path, path.parent) + + def clear_cache() -> None: """Wipe the cache so the next access re-scans. Used in tests.""" global _CACHE @@ -175,7 +210,7 @@ def _discovery_roots() -> list[Path]: return roots -def _scan_root(root: Path) -> list[RobotPreset]: +def _scan_root(root: Path, *, scaffold_missing: bool = True) -> list[RobotPreset]: """Scan one discovery root for ``/robot*.yaml`` files. Per directory we first collect all ``robot.yaml`` + ``robot..yaml`` @@ -195,7 +230,8 @@ def _scan_root(root: Path) -> list[RobotPreset]: # ``_template`` and any other private scaffolding stays invisible. continue - _autoscaffold_missing_yaml(child) + if scaffold_missing: + _autoscaffold_missing_yaml(child) yaml_paths = _collect_yaml_paths(child) if not yaml_paths: diff --git a/hhtools/robot/retarget_profile.py b/hhtools/robot/retarget_profile.py index 92d7cb97..ffed127a 100644 --- a/hhtools/robot/retarget_profile.py +++ b/hhtools/robot/retarget_profile.py @@ -514,13 +514,13 @@ def _shoulder_roll_scale_ratios( from hhtools.retarget.calibration.calibration import ( load_calibration, - resolve_calibration_file, + resolve_preset_calibration_file, ) from hhtools.robot.joint_scales import _CALIBRATION_REF_ORDER calibration = None for ref in _CALIBRATION_REF_ORDER: - cal_path = resolve_calibration_file(preset.root_dir, ref) + cal_path = resolve_preset_calibration_file(preset, ref) if cal_path is None or not cal_path.is_file(): continue try: @@ -640,6 +640,11 @@ def _scaler_search_roots(preset: "RobotPreset") -> list[Path]: """Preset dir first, then same-named workspace bundle (user upload shadowing).""" roots: list[Path] = [preset.root_dir.resolve()] + # Agent jobs materialize an exact RobotBundle into an isolated workspace. + # Falling back to a same-named source-tree preset would silently consume + # configuration that is absent from the content-addressed asset. + if preset.meta.get("_agent_asset_id"): + return roots ws = _workspace_robot_dir(preset.name) if ws is not None: resolved = ws.resolve() @@ -659,6 +664,9 @@ def _scaler_rel_candidates( if user_rel: rels.append(str(user_rel)) + if preset.meta.get("_agent_asset_id"): + return rels + ws = _workspace_robot_dir(preset.name) if ws is not None and ws.resolve() != preset.root_dir.resolve(): yaml_path = ws / "robot.yaml" diff --git a/hhtools/services/__init__.py b/hhtools/services/__init__.py new file mode 100644 index 00000000..d3f435c4 --- /dev/null +++ b/hhtools/services/__init__.py @@ -0,0 +1,74 @@ +"""Transport-neutral application services for HHTools clients. + +The Web UI, JSON CLI, REST API, and MCP adapter must call this layer rather +than importing one another. Solver and calibration algorithms stay in their +existing modules; services only discover capabilities and orchestrate them. +""" + +from .artifact_exports import ( + AGENT_EXPORT_ROOT_ID, + ArtifactExportError, + ArtifactExportService, +) +from .artifacts import ArtifactStore, ArtifactStoreError, StoredArtifact +from .asset_service import AgentAssetService +from .assets import AssetRegistry, AssetServiceError +from .capabilities import CapabilitiesService +from .job_store import JobStore, JobStoreError, StoredJob, compute_request_fingerprint +from .jobs import ( + JobCancelledError, + JobExecutionContext, + JobExecutionError, + JobExecutionResult, + JobExecutor, + JobManager, + JobManagerError, +) +from .legacy_job_upgrade import ( + DynamicRootLocator, + LegacyJobUpgradeError, + LegacyJobUpgradeResult, + LegacyJobUpgradeService, + LegacyMigrationReceipt, +) +from .plans import PlanStore, PlanStoreError, compute_plan_id +from .preflight import PreflightService +from .retarget import RetargetService, RetargetServiceError +from .runtime_lease import AgentRuntimeLease, RuntimeLeaseError + +__all__ = [ + "AGENT_EXPORT_ROOT_ID", + "AgentAssetService", + "AgentRuntimeLease", + "AssetRegistry", + "AssetServiceError", + "ArtifactStore", + "ArtifactStoreError", + "ArtifactExportError", + "ArtifactExportService", + "CapabilitiesService", + "JobCancelledError", + "JobExecutionContext", + "JobExecutionError", + "JobExecutionResult", + "JobExecutor", + "JobManager", + "JobManagerError", + "JobStore", + "JobStoreError", + "DynamicRootLocator", + "LegacyJobUpgradeError", + "LegacyJobUpgradeResult", + "LegacyJobUpgradeService", + "LegacyMigrationReceipt", + "PlanStore", + "PlanStoreError", + "PreflightService", + "RetargetService", + "RetargetServiceError", + "RuntimeLeaseError", + "StoredArtifact", + "StoredJob", + "compute_plan_id", + "compute_request_fingerprint", +] diff --git a/hhtools/services/admission.py b/hhtools/services/admission.py new file mode 100644 index 00000000..a42722c6 --- /dev/null +++ b/hhtools/services/admission.py @@ -0,0 +1,57 @@ +"""Transport-neutral protocols for shared background-job admission. + +The Web scheduler implements these small interfaces, while JobManager depends +only on the application-service boundary. This avoids making REST, CLI, or +MCP adapters part of the execution semantics. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Protocol + + +class AdmissionQueueFullError(RuntimeError): + """Raised before durable work when the configured waiting queue is full.""" + + +class AdmissionClosedError(RuntimeError): + """Raised when the execution scheduler no longer accepts work.""" + + +class ScheduledHandle(Protocol): + """Opaque identity for one admitted callable.""" + + def cancel(self) -> bool: ... + + def queue_position(self) -> int | None: ... + + +class AdmissionReservation(Protocol): + """One capacity token reserved before a durable job is created.""" + + def submit( + self, + run: Callable[[], object], + *, + on_cancel: Callable[[str], None] | None = None, + ) -> ScheduledHandle: ... + + def cancel(self) -> None: ... + + +class AdmissionScheduler(Protocol): + """Shared scheduler surface consumed by the application service.""" + + def reserve(self) -> AdmissionReservation: ... + + def snapshot(self) -> object: ... + + +__all__ = [ + "AdmissionClosedError", + "AdmissionQueueFullError", + "AdmissionReservation", + "AdmissionScheduler", + "ScheduledHandle", +] diff --git a/hhtools/services/artifact_exports.py b/hhtools/services/artifact_exports.py new file mode 100644 index 00000000..845b060b --- /dev/null +++ b/hhtools/services/artifact_exports.py @@ -0,0 +1,281 @@ +"""Safe delivery of job-scoped artifacts to one server-configured export root.""" + +from __future__ import annotations + +import hashlib +import os +import stat +import threading +import time +import uuid +from collections.abc import Mapping +from pathlib import Path, PurePosixPath + +from pydantic import ValidationError + +from hhtools.contracts import ApiError, ErrorStage +from hhtools.contracts.artifact_exports import ArtifactExportReceipt + +from .artifacts import StoredArtifact +from .jobs import JobManager, JobManagerError + +AGENT_EXPORT_ROOT_ID = "agent-exports" +_COPY_CHUNK_BYTES = 1024 * 1024 +_PUBLISH_RETRIES = 20 +_PUBLISH_LOCK = threading.Lock() + + +class ArtifactExportError(RuntimeError): + """Expected export failure with a transport-neutral public error.""" + + def __init__(self, error: ApiError) -> None: + self.error = error + super().__init__(f"{error.code}: {error.message}") + + @property + def api_error(self) -> ApiError: + return self.error + + @property + def code(self) -> str: + return self.error.code + + +def _error( + code: str, + message: str, + *, + retryable: bool = False, + stage: ErrorStage = ErrorStage.ARTIFACT, + details: Mapping[str, str] | None = None, +) -> ArtifactExportError: + return ArtifactExportError( + ApiError( + code=code, + message=message, + retryable=retryable, + stage=stage, + details=dict(details or {}), + ) + ) + + +def _copy_job_error(error: ApiError) -> ArtifactExportError: + return ArtifactExportError(ApiError.model_validate_json(error.model_dump_json())) + + +def _hash_file(path: Path) -> tuple[str, int]: + digest = hashlib.sha256() + size = 0 + with path.open("rb") as stream: + while chunk := stream.read(_COPY_CHUNK_BYTES): + digest.update(chunk) + size += len(chunk) + return digest.hexdigest(), size + + +class ArtifactExportService: + """Copy verified managed artifacts without accepting caller-selected paths.""" + + def __init__(self, jobs: JobManager, export_root: Path) -> None: + self._jobs = jobs + try: + root = Path(export_root).expanduser() + root.mkdir(parents=True, exist_ok=True) + self._root = root.resolve(strict=True) + if not self._root.is_dir(): + raise OSError("export root is not a directory") + except OSError as exc: + raise _error( + "ARTIFACT_EXPORT_FAILED", + "The configured artifact export root is unavailable.", + retryable=True, + stage=ErrorStage.INTERNAL, + details={"root_id": AGENT_EXPORT_ROOT_ID}, + ) from exc + + @staticmethod + def _relative_path(stored: StoredArtifact) -> PurePosixPath: + descriptor = stored.descriptor + job_token = hashlib.sha256(descriptor.job_id.encode("utf-8")).hexdigest() + artifact_token = hashlib.sha256(descriptor.artifact_id.encode("utf-8")).hexdigest() + extension = descriptor.format.casefold() if descriptor.format is not None else "bin" + return PurePosixPath("jobs", job_token, f"{artifact_token}.{extension}") + + @staticmethod + def _details(stored: StoredArtifact) -> dict[str, str]: + return { + "artifact_id": stored.descriptor.artifact_id, + "job_id": stored.descriptor.job_id, + "root_id": AGENT_EXPORT_ROOT_ID, + } + + @staticmethod + def _receipt( + stored: StoredArtifact, + relative_path: PurePosixPath, + ) -> ArtifactExportReceipt: + descriptor = stored.descriptor + if descriptor.size_bytes is None or descriptor.sha256 is None: + raise _error( + "INTERNAL_ERROR", + "The managed artifact lacks required integrity metadata.", + retryable=True, + details=ArtifactExportService._details(stored), + ) + try: + return ArtifactExportReceipt( + relative_path=relative_path.as_posix(), + job_id=descriptor.job_id, + artifact_id=descriptor.artifact_id, + kind=descriptor.kind, + format=descriptor.format, + media_type=descriptor.media_type, + size_bytes=descriptor.size_bytes, + sha256=descriptor.sha256, + ) + except ValidationError as exc: + raise _error( + "INTERNAL_ERROR", + "The managed artifact cannot be represented by an export receipt.", + details=ArtifactExportService._details(stored), + ) from exc + + def _destination(self, relative_path: PurePosixPath, stored: StoredArtifact) -> Path: + destination = self._root.joinpath(*relative_path.parts) + try: + destination.parent.mkdir(parents=True, exist_ok=True) + resolved_parent = destination.parent.resolve(strict=True) + resolved_parent.relative_to(self._root) + except (OSError, ValueError) as exc: + raise _error( + "ARTIFACT_EXPORT_FAILED", + "The artifact export destination is unavailable.", + retryable=True, + details=self._details(stored), + ) from exc + return resolved_parent / destination.name + + def _destination_matches(self, destination: Path, stored: StoredArtifact) -> bool: + descriptor = stored.descriptor + try: + metadata = destination.lstat() + except FileNotFoundError: + return False + except OSError as exc: + raise _error( + "ARTIFACT_EXPORT_FAILED", + "The existing artifact export could not be inspected.", + retryable=True, + details=self._details(stored), + ) from exc + if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode): + return False + if metadata.st_size != descriptor.size_bytes: + return False + try: + digest, size = _hash_file(destination) + except OSError as exc: + raise _error( + "ARTIFACT_EXPORT_FAILED", + "The existing artifact export could not be verified.", + retryable=True, + details=self._details(stored), + ) from exc + return size == descriptor.size_bytes and digest == descriptor.sha256 + + def _copy_to_temporary(self, stored: StoredArtifact, destination: Path) -> Path: + descriptor = stored.descriptor + temporary = destination.parent / f".{destination.name}.{uuid.uuid4().hex}.tmp" + digest = hashlib.sha256() + size = 0 + try: + with stored.path.open("rb") as source, temporary.open("xb") as target: + while chunk := source.read(_COPY_CHUNK_BYTES): + digest.update(chunk) + target.write(chunk) + size += len(chunk) + target.flush() + os.fsync(target.fileno()) + except OSError as exc: + temporary.unlink(missing_ok=True) + raise _error( + "ARTIFACT_EXPORT_FAILED", + "The managed artifact could not be copied to the export root.", + retryable=True, + details=self._details(stored), + ) from exc + if size != descriptor.size_bytes or digest.hexdigest() != descriptor.sha256: + temporary.unlink(missing_ok=True) + raise _error( + "ARTIFACT_HASH_MISMATCH", + "The managed artifact changed while it was being exported.", + retryable=True, + details=self._details(stored), + ) + return temporary + + def _publish(self, temporary: Path, destination: Path, stored: StoredArtifact) -> None: + with _PUBLISH_LOCK: + if self._destination_matches(destination, stored): + temporary.unlink(missing_ok=True) + return + for attempt in range(_PUBLISH_RETRIES): + try: + os.replace(temporary, destination) + return + except PermissionError as exc: + if self._destination_matches(destination, stored): + temporary.unlink(missing_ok=True) + return + if attempt + 1 >= _PUBLISH_RETRIES: + raise _error( + "ARTIFACT_EXPORT_FAILED", + "The artifact export could not be published atomically.", + retryable=True, + details=self._details(stored), + ) from exc + time.sleep(0.005) + except OSError as exc: + raise _error( + "ARTIFACT_EXPORT_FAILED", + "The artifact export could not be published atomically.", + retryable=True, + details=self._details(stored), + ) from exc + + def export(self, job_id: str, artifact_id: str) -> ArtifactExportReceipt: + """Export one canonically job-bound artifact and return portable metadata.""" + + try: + stored = self._jobs.get_artifact(job_id, artifact_id, verify=True) + except JobManagerError as exc: + raise _copy_job_error(exc.api_error) from exc + except Exception as exc: # noqa: BLE001 - application-service boundary + raise _error( + "INTERNAL_ERROR", + "The managed artifact could not be resolved for export.", + retryable=True, + stage=ErrorStage.INTERNAL, + details={"root_id": AGENT_EXPORT_ROOT_ID}, + ) from exc + + relative_path = self._relative_path(stored) + receipt = self._receipt(stored, relative_path) + destination = self._destination(relative_path, stored) + with _PUBLISH_LOCK: + if self._destination_matches(destination, stored): + return receipt + temporary = self._copy_to_temporary(stored, destination) + try: + self._publish(temporary, destination, stored) + finally: + temporary.unlink(missing_ok=True) + return receipt + + +__all__ = [ + "AGENT_EXPORT_ROOT_ID", + "ArtifactExportError", + "ArtifactExportService", +] diff --git a/hhtools/services/artifacts.py b/hhtools/services/artifacts.py new file mode 100644 index 00000000..4d7ce0d7 --- /dev/null +++ b/hhtools/services/artifacts.py @@ -0,0 +1,636 @@ +"""Immutable managed artifacts for Agent-facing jobs. + +The store owns the bytes below its data directory and exposes only controlled +``hhtools://`` resource URIs. Host paths are never persisted in public +descriptors or metadata, which keeps manifests portable across Windows, Linux, +and macOS deployments. +""" + +from __future__ import annotations + +import hashlib +import io +import json +import math +import os +import re +import sqlite3 +import threading +import time +import uuid +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path, PurePosixPath, PureWindowsPath +from typing import Any, BinaryIO + +from pydantic import ValidationError + +from hhtools.contracts import ApiError, ArtifactDescriptor, ErrorStage + +_CHUNK_SIZE = 1024 * 1024 +_KIND = re.compile(r"^[a-z][a-z0-9_-]{0,127}$") +_FORMAT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,31}$") +_MAX_METADATA_BYTES = 64 * 1024 +_OBJECT_WRITE_LOCK = threading.Lock() +_WINDOWS_PUBLISH_RETRIES = 20 + + +class ArtifactStoreError(RuntimeError): + """Expected artifact failure with a transport-neutral error body.""" + + def __init__(self, error: ApiError) -> None: + self.error = error + super().__init__(f"{error.code}: {error.message}") + + @property + def api_error(self) -> ApiError: + return self.error + + @property + def code(self) -> str: + return self.error.code + + +@dataclass(frozen=True, slots=True) +class StoredArtifact: + """One validated descriptor plus its private managed object path.""" + + descriptor: ArtifactDescriptor + path: Path + + +class _InvalidArtifactError(ValueError): + """Private validation failure that must not expose caller values.""" + + +def _error( + code: str, + message: str, + *, + retryable: bool = False, + stage: ErrorStage = ErrorStage.ARTIFACT, + details: Mapping[str, Any] | None = None, +) -> ArtifactStoreError: + return ArtifactStoreError( + ApiError( + code=code, + message=message, + retryable=retryable, + stage=stage, + details=dict(details or {}), + ) + ) + + +def _looks_like_host_path(value: str) -> bool: + if re.match(r"^(?:hhtools|https?)://[^\s]+$", value): + return False + posix = PurePosixPath(value) + windows = PureWindowsPath(value) + return posix.is_absolute() or windows.is_absolute() or bool(windows.drive) or bool(windows.root) + + +def _validate_portable_json(value: Any) -> None: + if value is None or isinstance(value, bool | int): + return + if isinstance(value, float): + if not math.isfinite(value): + raise _InvalidArtifactError("non-finite number") + return + if isinstance(value, str): + if _looks_like_host_path(value): + raise _InvalidArtifactError("host path") + return + if isinstance(value, list): + for item in value: + _validate_portable_json(item) + return + if isinstance(value, dict): + for key, item in value.items(): + if not isinstance(key, str) or _looks_like_host_path(key): + raise _InvalidArtifactError("invalid object key") + _validate_portable_json(item) + return + raise _InvalidArtifactError("non-JSON value") + + +def _canonical_json(value: Any) -> str: + _validate_portable_json(value) + try: + encoded = json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ) + except (TypeError, ValueError, OverflowError, RecursionError) as exc: + raise _InvalidArtifactError("invalid JSON") from exc + return encoded + + +def _validate_job_id(job_id: str) -> None: + # Job ids are minted by JobStore. Keeping the URI segment strict prevents + # a future HTTP/resource adapter from having to reinterpret slashes or URLs. + if not re.fullmatch(r"job:[A-Za-z0-9._~-]{1,240}", job_id): + raise _error("INVALID_PARAMETER", "The artifact job id is invalid.") + + +def _validate_kind(kind: str) -> None: + if _KIND.fullmatch(kind) is None: + raise _error("INVALID_PARAMETER", "The artifact kind is invalid.") + + +def _validate_format(format_name: str | None) -> None: + if format_name is not None and _FORMAT.fullmatch(format_name) is None: + raise _error("INVALID_PARAMETER", "The artifact format is invalid.") + + +def _hash_stream(stream: BinaryIO, target: BinaryIO) -> tuple[str, int]: + digest = hashlib.sha256() + size = 0 + while chunk := stream.read(_CHUNK_SIZE): + digest.update(chunk) + target.write(chunk) + size += len(chunk) + return digest.hexdigest(), size + + +class ArtifactStore: + """SQLite-indexed, immutable artifact bytes below one managed root.""" + + def __init__(self, data_dir: Path) -> None: + self._data_dir = Path(data_dir) + self._object_root = self._data_dir / "artifact-objects" + self._database_path = self._data_dir / "artifacts.sqlite3" + try: + self._object_root.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise _error( + "INTERNAL_ERROR", + "The artifact store directory could not be initialized.", + retryable=True, + stage=ErrorStage.INTERNAL, + ) from exc + self._initialize_database() + + @property + def database_path(self) -> Path: + """Internal database location for deployment diagnostics only.""" + + return self._database_path + + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect(self._database_path, timeout=30.0) + connection.row_factory = sqlite3.Row + return connection + + def _initialize_database(self) -> None: + try: + with self._connect() as connection: + connection.execute("PRAGMA journal_mode=WAL") + connection.execute( + """ + CREATE TABLE IF NOT EXISTS artifacts ( + artifact_id TEXT PRIMARY KEY, + job_id TEXT NOT NULL, + kind TEXT NOT NULL, + format TEXT, + media_type TEXT, + size_bytes INTEGER NOT NULL, + sha256 TEXT NOT NULL, + created_at TEXT NOT NULL, + metadata_json TEXT NOT NULL, + object_path TEXT NOT NULL + ) + """ + ) + connection.execute( + """ + CREATE INDEX IF NOT EXISTS artifacts_job_created + ON artifacts(job_id, created_at, artifact_id) + """ + ) + except sqlite3.Error as exc: + raise _error( + "INTERNAL_ERROR", + "The artifact store database could not be initialized.", + retryable=True, + stage=ErrorStage.INTERNAL, + ) from exc + + def put_json( + self, + *, + job_id: str, + kind: str, + document: Any, + metadata: Mapping[str, Any] | None = None, + ) -> ArtifactDescriptor: + """Persist one canonical portable JSON artifact.""" + + try: + payload = _canonical_json(document).encode("utf-8") + except _InvalidArtifactError as exc: + raise _error( + "INVALID_PARAMETER", + "Artifact JSON must contain finite portable values without host paths.", + ) from exc + return self.put_bytes( + job_id=job_id, + kind=kind, + payload=payload, + format="json", + media_type="application/json", + metadata=metadata, + ) + + def put_bytes( + self, + *, + job_id: str, + kind: str, + payload: bytes, + format: str | None = None, + media_type: str | None = None, + metadata: Mapping[str, Any] | None = None, + ) -> ArtifactDescriptor: + """Persist immutable in-memory bytes and return their compact descriptor.""" + + if not isinstance(payload, bytes): + raise _error("INVALID_PARAMETER", "Artifact payload must be bytes.") + return self._put_stream( + job_id=job_id, + kind=kind, + stream_factory=lambda: io.BytesIO(payload), + format=format, + media_type=media_type, + metadata=metadata, + ) + + def put_file( + self, + *, + job_id: str, + kind: str, + source: Path, + format: str | None = None, + media_type: str | None = None, + metadata: Mapping[str, Any] | None = None, + ) -> ArtifactDescriptor: + """Copy one stable internal output file into managed artifact storage.""" + + path = Path(source) + try: + before = path.stat() + if not path.is_file(): + raise OSError("not a regular file") + except OSError as exc: + raise _error( + "OUTPUT_WRITE_FAILED", + "The output artifact is missing or unreadable.", + retryable=True, + ) from exc + + def source_is_stable(copied_size: int) -> bool: + try: + after = path.stat() + except OSError: + return False + snapshot_before = ( + before.st_dev, + before.st_ino, + before.st_size, + before.st_mtime_ns, + ) + snapshot_after = ( + after.st_dev, + after.st_ino, + after.st_size, + after.st_mtime_ns, + ) + return snapshot_before == snapshot_after and copied_size == after.st_size + + return self._put_stream( + job_id=job_id, + kind=kind, + stream_factory=lambda: path.open("rb"), + format=format, + media_type=media_type, + metadata=metadata, + stability_check=source_is_stable, + ) + + def _put_stream( + self, + *, + job_id: str, + kind: str, + stream_factory: Callable[[], BinaryIO], + format: str | None, + media_type: str | None, + metadata: Mapping[str, Any] | None, + stability_check: Callable[[int], bool] | None = None, + ) -> ArtifactDescriptor: + _validate_job_id(job_id) + _validate_kind(kind) + _validate_format(format) + try: + metadata_json = _canonical_json(dict(metadata or {})) + except _InvalidArtifactError as exc: + raise _error( + "INVALID_PARAMETER", + "Artifact metadata must be finite portable JSON without host paths.", + ) from exc + if len(metadata_json.encode("utf-8")) > _MAX_METADATA_BYTES: + raise _error("INVALID_PARAMETER", "Artifact metadata is too large.") + + temporary = self._object_root / f".{uuid.uuid4().hex}.tmp" + try: + with stream_factory() as source_stream, temporary.open("xb") as target: + sha256, size = _hash_stream(source_stream, target) + target.flush() + os.fsync(target.fileno()) + relative_object = f"artifact-objects/{sha256[:2]}/{sha256}" + object_path = self._data_dir / PurePosixPath(relative_object) + object_path.parent.mkdir(parents=True, exist_ok=True) + self._publish_object( + temporary, + object_path, + sha256=sha256, + size=size, + ) + if stability_check is not None and not stability_check(size): + raise _error( + "OUTPUT_WRITE_FAILED", + "The output artifact changed while it was being stored.", + retryable=True, + ) + except ArtifactStoreError: + temporary.unlink(missing_ok=True) + raise + except (OSError, TypeError, ValueError) as exc: + temporary.unlink(missing_ok=True) + raise _error( + "OUTPUT_WRITE_FAILED", + "The artifact could not be written to managed storage.", + retryable=True, + ) from exc + + identity = _canonical_json( + { + "format": format, + "job_id": job_id, + "kind": kind, + "media_type": media_type, + "metadata": json.loads(metadata_json), + "sha256": sha256, + } + ) + identity_digest = hashlib.sha256(identity.encode("utf-8")).hexdigest() + artifact_id = f"artifact:{kind}:{identity_digest}" + created_at = datetime.now(UTC).isoformat() + row = ( + artifact_id, + job_id, + kind, + format, + media_type, + size, + sha256, + created_at, + metadata_json, + relative_object, + ) + try: + with self._connect() as connection: + connection.execute("BEGIN IMMEDIATE") + connection.execute( + """ + INSERT OR IGNORE INTO artifacts ( + artifact_id, job_id, kind, format, media_type, + size_bytes, sha256, created_at, metadata_json, object_path + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + row, + ) + stored = connection.execute( + "SELECT * FROM artifacts WHERE artifact_id = ?", + (artifact_id,), + ).fetchone() + except sqlite3.Error as exc: + raise _error( + "INTERNAL_ERROR", + "The artifact descriptor could not be persisted.", + retryable=True, + stage=ErrorStage.INTERNAL, + ) from exc + if stored is None: + raise _error( + "INTERNAL_ERROR", + "The artifact descriptor is unavailable after persistence.", + stage=ErrorStage.INTERNAL, + ) + decoded = self._decode_row(stored) + expected = row[:7] + (metadata_json, relative_object) + actual = ( + stored["artifact_id"], + stored["job_id"], + stored["kind"], + stored["format"], + stored["media_type"], + stored["size_bytes"], + stored["sha256"], + stored["metadata_json"], + stored["object_path"], + ) + if actual != expected: + raise _error( + "INTERNAL_ERROR", + "The artifact id is already bound to different content.", + stage=ErrorStage.INTERNAL, + ) + return decoded.descriptor + + @staticmethod + def _hash_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + while chunk := stream.read(_CHUNK_SIZE): + digest.update(chunk) + return digest.hexdigest() + + def _publish_object( + self, + temporary: Path, + object_path: Path, + *, + sha256: str, + size: int, + ) -> None: + """Publish one content object without racing another Windows writer. + + Windows does not allow ``os.replace`` while another thread/process has + the destination open for verification. Serialize local publishers and + retry the cross-process winner path; content addressing makes accepting + an already verified destination equivalent to our own rename. + """ + + with _OBJECT_WRITE_LOCK: + for attempt in range(_WINDOWS_PUBLISH_RETRIES): + if object_path.exists(): + try: + valid = ( + object_path.stat().st_size == size + and self._hash_file(object_path) == sha256 + ) + except PermissionError: + valid = False + else: + if not valid: + raise _error( + "INTERNAL_ERROR", + "Managed artifact content is corrupted.", + stage=ErrorStage.INTERNAL, + ) + temporary.unlink(missing_ok=True) + return + try: + os.replace(temporary, object_path) + return + except PermissionError: + if attempt + 1 >= _WINDOWS_PUBLISH_RETRIES: + raise + time.sleep(0.005) + + def _decode_row(self, row: sqlite3.Row) -> StoredArtifact: + try: + metadata = json.loads(row["metadata_json"]) + if _canonical_json(metadata) != row["metadata_json"]: + raise _InvalidArtifactError("non-canonical metadata") + relative = PurePosixPath(row["object_path"]) + if ( + relative.is_absolute() + or any(part in {"", ".", ".."} for part in relative.parts) + or relative.parts[:1] != ("artifact-objects",) + ): + raise _InvalidArtifactError("invalid object path") + object_path = (self._data_dir / relative).resolve(strict=False) + object_path.relative_to(self._data_dir.resolve()) + descriptor = ArtifactDescriptor( + artifact_id=row["artifact_id"], + job_id=row["job_id"], + kind=row["kind"], + format=row["format"], + resource_uri=(f"hhtools://jobs/{row['job_id']}/artifacts/{row['artifact_id']}"), + media_type=row["media_type"], + size_bytes=row["size_bytes"], + sha256=row["sha256"], + created_at=row["created_at"], + metadata=metadata, + ) + except ( + _InvalidArtifactError, + KeyError, + OSError, + TypeError, + ValueError, + ValidationError, + ) as exc: + raise _error( + "INTERNAL_ERROR", + "A persisted artifact descriptor is invalid.", + stage=ErrorStage.INTERNAL, + ) from exc + expected_path = ( + self._data_dir / "artifact-objects" / descriptor.sha256[:2] / descriptor.sha256 + ) + if object_path != expected_path.resolve(strict=False): + raise _error( + "INTERNAL_ERROR", + "A persisted artifact object path is inconsistent.", + stage=ErrorStage.INTERNAL, + ) + return StoredArtifact(descriptor=descriptor, path=object_path) + + def get(self, artifact_id: str, *, verify: bool = False) -> StoredArtifact: + """Return one managed artifact and optionally verify its current bytes.""" + + try: + with self._connect() as connection: + row = connection.execute( + "SELECT * FROM artifacts WHERE artifact_id = ?", + (artifact_id,), + ).fetchone() + except sqlite3.Error as exc: + raise _error( + "INTERNAL_ERROR", + "The artifact store could not be read.", + retryable=True, + stage=ErrorStage.INTERNAL, + ) from exc + if row is None: + raise _error("ARTIFACT_NOT_FOUND", "No artifact has the requested id.") + stored = self._decode_row(row) + if verify: + try: + valid = ( + stored.path.is_file() + and stored.path.stat().st_size == stored.descriptor.size_bytes + and self._hash_file(stored.path) == stored.descriptor.sha256 + ) + except OSError: + valid = False + if not valid: + raise _error( + "ARTIFACT_HASH_MISMATCH", + "The managed artifact no longer matches its descriptor.", + retryable=True, + ) + return stored + + def list_candidates_for_job(self, job_id: str) -> list[ArtifactDescriptor]: + """List all managed candidates for a job in stable creation order. + + Candidates are not an authorization or lifecycle-membership boundary. + A write can survive a failed JobStore CAS or a process interruption, so + callers serving Agent APIs must list JobStore's canonical descriptors + instead of exposing this raw catalog. + """ + + _validate_job_id(job_id) + try: + with self._connect() as connection: + rows = connection.execute( + """ + SELECT * FROM artifacts + WHERE job_id = ? + ORDER BY created_at, artifact_id + """, + (job_id,), + ).fetchall() + except sqlite3.Error as exc: + raise _error( + "INTERNAL_ERROR", + "The artifact store could not be read.", + retryable=True, + stage=ErrorStage.INTERNAL, + ) from exc + return [self._decode_row(row).descriptor for row in rows] + + def list_for_job(self, job_id: str) -> list[ArtifactDescriptor]: + """Compatibility alias for the raw candidate catalog. + + This method may include unbound artifacts. Use JobManager's canonical + artifact APIs for user-visible listing and access control. + """ + + return self.list_candidates_for_job(job_id) + + +__all__ = [ + "ArtifactStore", + "ArtifactStoreError", + "StoredArtifact", +] diff --git a/hhtools/services/asset_inspection.py b/hhtools/services/asset_inspection.py new file mode 100644 index 00000000..66cbac9b --- /dev/null +++ b/hhtools/services/asset_inspection.py @@ -0,0 +1,1070 @@ +"""Read-only inspection for registered human-motion asset bundles. + +The registry owns path authorization and durable identity. This module owns the +next, deliberately separate, concern: checking that one already-resolved bundle +still has the files and content needed by retargeting. It never creates WebUI +session tokens, imports a retarget backend, or writes conversion caches. + +All paths accepted here are internal server paths. Returned contracts contain +only manifest-relative paths and compact statistics, so an Agent cannot learn a +host absolute path through either a successful result or an expected error. +""" + +from __future__ import annotations + +import csv +import hashlib +import json +import math +import pickle +import pickletools +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import numpy as np + +from hhtools.contracts import ( + ApiError, + AssetBundle, + AssetCategory, + AssetFileRole, + AssetInspection, + AssetKind, + ErrorStage, + InspectionStatus, +) +from hhtools.io.mimic_detect import infer_mimic_dataset, path_dataset_hint + +from .routing import ( + backend_for_category, + category_for_dataset, + reference_for_dataset, +) + +_SUPPORTED_PRIMARY_EXTENSIONS = frozenset( + { + ".bvh", + ".csv", + ".glb", + ".gltf", + ".npy", + ".npz", + ".pickle", + ".pkl", + ".pt", + ".pth", + } +) + +_UNIFIED_NPZ_REQUIRED_KEYS = frozenset( + { + "schema_version", + "name", + "framerate", + "up_axis", + "bone_names", + "parent_indices", + "positions", + "quaternions", + } +) + + +@dataclass(frozen=True, slots=True) +class MotionAssetDiscovery: + """Cheap routing facts for one unambiguous motion candidate. + + ``primary_path`` and ``sidecars`` are internal values for the registry. A + caller must turn them into portable paths before creating an Agent-facing + contract. + """ + + primary_path: Path + dataset: str + category: AssetCategory + reference: str + recommended_backend: str + sidecars: dict[AssetFileRole, tuple[Path, ...]] + + +class MotionAssetDiscoveryError(ValueError): + """Expected discovery failure with a stable machine-readable code.""" + + def __init__( + self, + code: str, + message: str, + *, + candidates: tuple[str, ...] = (), + ) -> None: + super().__init__(message) + self.code = code + self.candidates = candidates + + +@dataclass(slots=True) +class _ContentFacts: + frame_count: int | None = None + frame_rate_hz: float | None = None + joint_count: int | None = None + has_object: bool = False + has_terrain: bool = False + warning: str | None = None + metadata: dict[str, Any] | None = None + semantically_parsed: bool = True + + +class _ContentValidationError(ValueError): + """An expected, path-free explanation of malformed motion content.""" + + +def _fallback_dataset(path: Path) -> str: + hint = path_dataset_hint(path) + if hint: + return hint + return { + ".bvh": "lafan", + ".glb": "glb", + ".gltf": "glb", + ".npy": "meshmimic_holosoma", + ".npz": "amass", + ".pkl": "omomo", + ".pt": "gvhmr", + ".pth": "gvhmr", + }.get(path.suffix.lower(), "amass") + + +def _safe_npz_dataset(path: Path, hint: str | None) -> str: + """Classify NPZ keys without enabling NumPy's pickle loader.""" + + dataset = hint or "amass" + try: + with np.load(path, allow_pickle=False) as archive: + keys = set(archive.files) + if {"schema_version", "bone_names", "positions"}.issubset(keys): + if hint: + dataset = hint + elif (path.parent / f"{path.stem}_terrain.obj").is_file(): + dataset = "parc_ms" + elif "meta_json" in keys: + try: + raw_meta = np.asarray(archive["meta_json"]) + if raw_meta.dtype.kind in {"S", "U"}: + metadata = json.loads(str(raw_meta.item())) + declared = str(metadata.get("dataset", "")) + if declared: + dataset = declared + else: + dataset = "unified_npz" + except (json.JSONDecodeError, TypeError, ValueError): + dataset = "unified_npz" + else: + dataset = "unified_npz" + elif "poses" in keys or {"pose_body", "trans"}.issubset(keys): + if hint in {"amass", "gvhmr", "motion_x", "phuma"}: + dataset = hint + else: + dataset = "amass" + except (EOFError, OSError, pickle.UnpicklingError, TypeError, ValueError): + pass + return dataset + + +def _safe_npy_dataset(path: Path, hint: str | None) -> str: + try: + array = np.load(path, mmap_mode="r", allow_pickle=False) + if array.ndim == 2 and array.shape[1] == 322: + return "motion_x" + if array.ndim == 2 and array.shape[1] == 69: + return "phuma" + except (EOFError, OSError, TypeError, ValueError): + pass + if hint: + return hint + if path.stem == path.parent.name: + return "meshmimic_holosoma" + return "meshmimic_holosoma" + + +def _safe_pickle_dataset(path: Path, hint: str | None) -> str: + # A named dataset ancestor is stronger evidence than a sidecar that may + # have just gone missing. This lets inspect() still say "OMOMO mesh is + # missing" rather than accidentally reclassifying the clip as PARC. + if hint in {"omomo", "parc_ms"}: + return hint + if any(path.parent.glob("*_cleaned_simplified.obj")): + return "omomo" + if (path.parent / f"{path.stem}_terrain.obj").is_file(): + return "parc_ms" + return hint or "omomo" + + +def _infer_dataset(path: Path) -> str: + suffix = path.suffix.lower() + hint = path_dataset_hint(path) + if suffix in {".glb", ".gltf"}: + dataset = "glb" + elif suffix == ".npz": + dataset = _safe_npz_dataset(path, hint) + elif suffix == ".npy": + dataset = _safe_npy_dataset(path, hint) + elif suffix in {".pickle", ".pkl"}: + dataset = _safe_pickle_dataset(path, hint) + elif suffix in {".pt", ".pth"}: + dataset = hint if hint in {"gvhmr", "kungfu_athlete"} else "gvhmr" + else: + try: + dataset = str(infer_mimic_dataset(path)) + except ( + EOFError, + ImportError, + OSError, + pickle.UnpicklingError, + RuntimeError, + TypeError, + ValueError, + ): + # Discovery must remain available for a damaged file so inspect() + # can report MOTION_PARSE_FAILED rather than failing before a + # contract is produced. Directory hints are deterministic and do + # not parse executable dataset objects. + dataset = _fallback_dataset(path) + return dataset + + +def _logical_primary_candidates(directory: Path) -> list[Path]: + root = directory.resolve() + candidates: list[Path] = [] + for path in sorted(directory.rglob("*")): + if path.suffix.lower() not in _SUPPORTED_PRIMARY_EXTENSIONS or not path.is_file(): + continue + try: + resolved = path.resolve() + resolved.relative_to(root) + except (OSError, ValueError): + continue + + # A same-stem pickle next to a conventional motion file is a terrain + # sidecar in the current HHTools layouts, not a second logical clip. + if resolved.suffix.lower() == ".pkl" and any( + resolved.with_suffix(extension).is_file() + for extension in (".npz", ".npy", ".bvh", ".glb", ".gltf") + ): + continue + candidates.append(resolved) + return candidates + + +def discover_motion_sidecars( + primary_path: str | Path, + *, + dataset: str | None = None, +) -> dict[AssetFileRole, tuple[Path, ...]]: + """Return deterministic sibling files that belong to ``primary_path``. + + The helper does no hashing and does not invent missing paths. It is safe for + an AssetRegistry to call before it constructs its own discovery record. + """ + + primary = Path(primary_path).resolve() + dataset_name = dataset or _infer_dataset(primary) + directory = primary.parent + found: dict[AssetFileRole, list[Path]] = {} + + def add(role: AssetFileRole, path: Path) -> None: + if not path.is_file() or path.resolve() == primary: + return + values = found.setdefault(role, []) + resolved = path.resolve() + if resolved not in values: + values.append(resolved) + + if dataset_name == "omomo": + for path in sorted(directory.glob("*_cleaned_simplified.obj")): + add(AssetFileRole.OBJECT_MESH, path) + elif dataset_name == "omnicontact": + for pattern in ("prop_*.csv", "object_pose_*.csv", "object_poses_*.csv"): + for path in sorted(directory.glob(pattern)): + if path.name.lower() != "motion_actor.csv": + add(AssetFileRole.OBJECT_TRAJECTORY, path) + add(AssetFileRole.METADATA, directory / "capture_meta.json") + elif dataset_name == "parc_ms": + add(AssetFileRole.TERRAIN_MESH, directory / f"{primary.stem}_terrain.obj") + if primary.suffix.lower() != ".pkl": + add(AssetFileRole.OTHER, primary.with_suffix(".pkl")) + elif dataset_name == "meshmimic_holosoma": + add(AssetFileRole.TERRAIN_MESH, directory / "terrain.obj") + add(AssetFileRole.TERRAIN_MESH, directory / f"{primary.stem}_terrain.obj") + add(AssetFileRole.OTHER, directory / f"{directory.name}.pkl") + current = directory + for _ in range(4): + manifest = current / "source.yaml" + if manifest.is_file(): + add(AssetFileRole.METADATA, manifest) + break + if current.parent == current: + break + current = current.parent + + return { + role: tuple(sorted(paths)) + for role, paths in sorted(found.items(), key=lambda item: item[0].value) + } + + +def discover_primary(candidate: str | Path) -> MotionAssetDiscovery: + """Resolve a file or a directory containing exactly one logical clip. + + A directory with multiple clips is intentionally rejected. Choosing the + first filesystem entry would make asset identity depend on sort order and + could silently retarget the wrong performance. + """ + + path = Path(candidate) + if not path.exists(): + raise MotionAssetDiscoveryError("ASSET_NOT_FOUND", "The motion candidate does not exist.") + if path.is_dir(): + candidates = _logical_primary_candidates(path) + if not candidates: + raise MotionAssetDiscoveryError( + "ASSET_NOT_FOUND", + "The directory contains no supported motion clip.", + ) + if len(candidates) > 1: + relative = tuple(item.relative_to(path.resolve()).as_posix() for item in candidates) + raise MotionAssetDiscoveryError( + "BUNDLE_AMBIGUOUS", + "The directory contains multiple logical motion clips; register one clip.", + candidates=relative, + ) + primary = candidates[0] + elif path.is_file(): + primary = path.resolve() + else: + raise MotionAssetDiscoveryError( + "ASSET_NOT_FOUND", + "The motion candidate is not a regular file or directory.", + ) + + suffix = primary.suffix.lower() + if suffix not in _SUPPORTED_PRIMARY_EXTENSIONS: + raise MotionAssetDiscoveryError( + "UNSUPPORTED_FORMAT", + f"The motion format {suffix or '(none)'} is not supported.", + ) + dataset = _infer_dataset(primary) + category = category_for_dataset(dataset) + return MotionAssetDiscovery( + primary_path=primary, + dataset=dataset, + category=category, + reference=reference_for_dataset(dataset, suffix), + recommended_backend=backend_for_category(category), + sidecars=discover_motion_sidecars(primary, dataset=dataset), + ) + + +def _api_error( + code: str, + message: str, + *, + details: dict[str, Any] | None = None, +) -> ApiError: + return ApiError( + code=code, + message=message, + retryable=False, + stage=ErrorStage.ASSET_INSPECTION, + details=details or {}, + ) + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _finite_or_raise(*arrays: np.ndarray) -> None: + for array in arrays: + if np.issubdtype(array.dtype, np.number) and not bool(np.isfinite(array).all()): + raise _ContentValidationError("motion arrays contain NaN or infinite values") + + +def _positive_fps(value: Any) -> float: + fps = float(np.asarray(value).reshape(())) + if not math.isfinite(fps) or fps <= 0: + raise _ContentValidationError("frame rate must be finite and greater than zero") + return fps + + +def _required_keys(keys: set[str], required: frozenset[str], label: str) -> None: + missing = sorted(required - keys) + if missing: + raise _ContentValidationError(f"{label} is missing required fields: {', '.join(missing)}") + + +def _scalar_value(value: Any, label: str) -> Any: + array = np.asarray(value) + if array.shape != (): + raise _ContentValidationError(f"{label} must be a scalar value") + return array.item() + + +def _numeric_array(value: Any, label: str) -> np.ndarray: + array = np.asarray(value) + if not np.issubdtype(array.dtype, np.number): + raise _ContentValidationError(f"{label} must be a numeric array") + _finite_or_raise(array) + return array + + +def _inspect_unified_npz(archive: Any, keys: set[str]) -> _ContentFacts: + """Mirror the structure consumed by :func:`hhtools.io.npz.load_npz`.""" + + _required_keys(keys, _UNIFIED_NPZ_REQUIRED_KEYS, "Unified NPZ") + schema = str(_scalar_value(archive["schema_version"], "schema_version")) + if schema != "1": + raise _ContentValidationError("Unified NPZ schema_version must be '1'") + _scalar_value(archive["name"], "name") + fps = _positive_fps(archive["framerate"]) + up_axis = str(_scalar_value(archive["up_axis"], "up_axis")) + if up_axis not in {"X", "Y", "Z"}: + raise _ContentValidationError("up_axis must be one of X, Y, or Z") + + positions = _numeric_array(archive["positions"], "positions") + quaternions = _numeric_array(archive["quaternions"], "quaternions") + bone_names = np.asarray(archive["bone_names"]) + parents = np.asarray(archive["parent_indices"]) + if positions.ndim != 3 or positions.shape[-1] != 3: + raise _ContentValidationError("positions must have shape (frames, joints, 3)") + if quaternions.ndim != 3 or quaternions.shape[-1] != 4: + raise _ContentValidationError("quaternions must have shape (frames, joints, 4)") + if positions.shape[:2] != quaternions.shape[:2]: + raise _ContentValidationError("positions and quaternions have incompatible shapes") + if positions.shape[0] == 0 or positions.shape[1] == 0: + raise _ContentValidationError("motion must contain at least one frame and joint") + joint_count = int(positions.shape[1]) + if bone_names.ndim != 1 or bone_names.size != joint_count: + raise _ContentValidationError("bone_names must be a one-dimensional joint list") + if parents.shape != (joint_count,): + raise _ContentValidationError("parent_indices must have shape (joints,)") + try: + parent_indices = parents.astype(np.int64, copy=False) + except (TypeError, ValueError) as exc: + raise _ContentValidationError("parent_indices must contain integers") from exc + if bool((parent_indices >= joint_count).any()): + raise _ContentValidationError("parent_indices references an unknown joint") + + # load_npz accesses meta_json when it is present. Touching it here with + # allow_pickle=False rejects object arrays without executing them. + if "meta_json" in keys: + _scalar_value(archive["meta_json"], "meta_json") + if "source_format" in keys: + _scalar_value(archive["source_format"], "source_format") + + object_fields = { + "objects_names", + "objects_positions", + "objects_quaternions", + "objects_extents", + } + has_object = False + if object_fields.issubset(keys): + object_names = np.asarray(archive["objects_names"]) + object_positions = _numeric_array(archive["objects_positions"], "objects_positions") + object_quaternions = _numeric_array(archive["objects_quaternions"], "objects_quaternions") + object_extents = _numeric_array(archive["objects_extents"], "objects_extents") + if object_names.ndim != 1: + raise _ContentValidationError("objects_names must be one-dimensional") + object_count = int(object_names.size) + if object_positions.ndim != 3 or object_positions.shape[1:] != (object_count, 3): + raise _ContentValidationError("objects_positions must have shape (frames, objects, 3)") + if object_quaternions.ndim != 3 or object_quaternions.shape != object_positions.shape[ + :2 + ] + (4,): + raise _ContentValidationError( + "objects_quaternions must have shape (frames, objects, 4)" + ) + if object_extents.shape != (object_count, 3): + raise _ContentValidationError("objects_extents must have shape (objects, 3)") + if "objects_mesh_paths" in keys: + mesh_paths = np.asarray(archive["objects_mesh_paths"]) + if mesh_paths.ndim != 1: + raise _ContentValidationError("objects_mesh_paths must be one-dimensional") + if "objects_scales" in keys: + scales = _numeric_array(archive["objects_scales"], "objects_scales") + if scales.ndim != 1: + raise _ContentValidationError("objects_scales must be one-dimensional") + has_object = object_count > 0 + return _ContentFacts( + frame_count=int(positions.shape[0]), + frame_rate_hz=fps, + joint_count=joint_count, + has_object=has_object, + has_terrain=bool("terrain_heightfield" in keys), + ) + + +def _inspect_amass_npz(archive: Any, keys: set[str]) -> _ContentFacts: + """Validate the arrays required by ``AmassAdapter.load_params`` safely.""" + + _required_keys(keys, frozenset({"trans", "betas"}), "AMASS NPZ") + trans = _numeric_array(archive["trans"], "trans") + betas = _numeric_array(archive["betas"], "betas") + if trans.ndim != 2 or trans.shape[1] != 3 or trans.shape[0] == 0: + raise _ContentValidationError("AMASS trans must have shape (frames, 3)") + if betas.size == 0: + raise _ContentValidationError("AMASS betas must not be empty") + frame_count = int(trans.shape[0]) + + is_stageii = "pose_body" in keys or "surface_model_type" in keys + if is_stageii: + _required_keys( + keys, + frozenset({"pose_body", "root_orient"}), + "AMASS stage-II NPZ", + ) + root_orient = _numeric_array(archive["root_orient"], "root_orient") + pose_body = _numeric_array(archive["pose_body"], "pose_body") + if root_orient.shape != (frame_count, 3): + raise _ContentValidationError("AMASS stage-II root_orient must have shape (frames, 3)") + if pose_body.ndim != 2 or pose_body.shape[0] != frame_count: + raise _ContentValidationError("AMASS stage-II pose_body must be a per-frame matrix") + for key in ("pose_hand", "pose_eye"): + if key not in keys: + continue + optional_pose = _numeric_array(archive[key], key) + if optional_pose.ndim != 2 or optional_pose.shape[0] != frame_count: + raise _ContentValidationError(f"AMASS {key} must be a per-frame matrix") + parameter_schema = "stageii" + else: + _required_keys(keys, frozenset({"poses"}), "AMASS legacy NPZ") + poses = _numeric_array(archive["poses"], "poses") + if poses.ndim != 2 or poses.shape[0] != frame_count or poses.shape[1] < 66: + raise _ContentValidationError( + "AMASS legacy poses must have shape (frames, at least 66)" + ) + parameter_schema = "legacy" + + fps = 30.0 + for key in ("mocap_frame_rate", "mocap_framerate"): + if key in keys: + fps = _positive_fps(archive[key]) + break + return _ContentFacts( + frame_count=frame_count, + frame_rate_hz=fps, + metadata={ + "joint_positions_available": False, + "parameter_schema": parameter_schema, + }, + ) + + +def _inspect_npz(path: Path, dataset: str) -> _ContentFacts: + with np.load(path, allow_pickle=False) as archive: + keys = set(archive.files) + unified_markers = {"positions", "quaternions", "bone_names", "parent_indices"} + if dataset in {"unified_npz", "parc_ms"} or bool(keys & unified_markers): + return _inspect_unified_npz(archive, keys) + return _inspect_amass_npz(archive, keys) + + +def _holosoma_manifest_facts(path: Path) -> tuple[float, int, int]: + """Safely read the same manifest fields required by the Holosoma adapter.""" + + current = path.parent + manifest: Path | None = None + for _ in range(4): + candidate = current / "source.yaml" + if candidate.is_file(): + manifest = candidate + break + if current.parent == current: + break + current = current.parent + if manifest is None: + raise _ContentValidationError("Holosoma source.yaml is required") + try: + import yaml # type: ignore[import-untyped] + + data = yaml.safe_load(manifest.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise _ContentValidationError("Holosoma source.yaml must contain a mapping") + framerate = data.get("framerate", {}) + if not isinstance(framerate, dict): + raise _ContentValidationError("Holosoma framerate must contain a mapping") + raw = float(framerate.get("raw_hz", 120.0)) + downsample = int(framerate.get("recommended_downsample", 1)) + downsample = max(1, downsample) + if not math.isfinite(raw) or raw <= 0: + raise _ContentValidationError("Holosoma raw frame rate must be positive") + + skeleton = data.get("skeleton") + if not isinstance(skeleton, dict): + raise _ContentValidationError("Holosoma source.yaml requires a skeleton mapping") + names = skeleton.get("joint_names", []) + parents = skeleton.get("parent_indices", []) + if not isinstance(names, list) or not isinstance(parents, list) or not names: + raise _ContentValidationError( + "Holosoma skeleton requires non-empty joint_names and parent_indices lists" + ) + if len(names) != len(parents): + raise _ContentValidationError( + "Holosoma joint_names and parent_indices must have equal length" + ) + try: + parent_indices = np.asarray(parents, dtype=np.int64) + except (TypeError, ValueError) as exc: + raise _ContentValidationError("Holosoma parent_indices must contain integers") from exc + if parent_indices.shape != (len(names),): + raise _ContentValidationError("Holosoma parent_indices must be one-dimensional") + if bool((parent_indices >= len(names)).any()): + raise _ContentValidationError("Holosoma parent_indices references an unknown joint") + for section in ("coordinate_system", "clip_layout"): + if section in data and not isinstance(data[section], dict): + raise _ContentValidationError(f"Holosoma {section} must contain a mapping") + return raw, downsample, len(names) + except _ContentValidationError: + raise + except (OSError, TypeError, ValueError, yaml.YAMLError) as exc: + raise _ContentValidationError("Holosoma source.yaml is malformed") from exc + + +def _inspect_npy(path: Path, dataset: str) -> _ContentFacts: + array = _numeric_array( + np.load(path, mmap_mode="r", allow_pickle=False), + "NPY motion", + ) + if array.ndim < 2 or array.shape[0] == 0: + raise _ContentValidationError("NPY motion must be a non-empty array with at least 2 axes") + _finite_or_raise(array) + frames = int(array.shape[0]) + joints: int | None = None + fps = 30.0 if dataset in {"motion_x", "phuma"} else None + metadata: dict[str, Any] = {} + if array.ndim == 3 and array.shape[-1] == 3: + joints = int(array.shape[1]) + elif array.ndim == 2 and dataset == "motion_x" and array.shape[1] != 322: + raise _ContentValidationError("Motion-X rows must contain 322 values") + elif array.ndim == 2 and dataset == "phuma" and array.shape[1] != 69: + raise _ContentValidationError("PHUMA rows must contain 69 values") + + if dataset == "meshmimic_holosoma": + raw_fps, downsample, expected_joints = _holosoma_manifest_facts(path) + if array.ndim != 3 or array.shape[2] != 3: + raise _ContentValidationError( + "Holosoma joint positions must have shape (frames, joints, 3)" + ) + if array.shape[1] != expected_joints: + raise _ContentValidationError("Holosoma joint count does not match source.yaml") + joints = expected_joints + metadata.update({"raw_frame_count": frames, "downsample": downsample}) + frames = max(1, math.ceil(frames / downsample)) + fps = raw_fps / downsample + warning = None if fps is not None else "Frame rate could not be determined without a manifest." + return _ContentFacts( + frame_count=frames, + frame_rate_hz=fps, + joint_count=joints, + warning=warning, + metadata=metadata, + ) + + +def _inspect_pickle(path: Path) -> _ContentFacts: + # Pickle is code-capable. Structural validation with pickletools confirms + # the stream is complete without executing constructors from an untrusted + # dataset. Full decoding remains the execution service's responsibility. + saw_stop = False + with path.open("rb") as handle: + for opcode, _argument, _position in pickletools.genops(handle): + if opcode.name == "STOP": + saw_stop = True + if not saw_stop: + raise _ContentValidationError("pickle stream has no STOP opcode") + return _ContentFacts( + warning=("Pickle structure is valid, but semantic content requires isolated validation."), + metadata={ + "pickle_executed": False, + "content_validation_code": "CONTENT_REQUIRES_ISOLATED_VALIDATION", + }, + semantically_parsed=False, + ) + + +def _inspect_bvh(path: Path) -> _ContentFacts: + from hhtools.io.bvh import load_bvh + + motion = load_bvh(path) + _finite_or_raise(motion.positions, motion.quaternions) + return _ContentFacts( + frame_count=motion.num_frames, + frame_rate_hz=float(motion.framerate), + joint_count=motion.num_bones, + ) + + +def _inspect_glb(path: Path) -> _ContentFacts: + from hhtools.io.base import load_motion + + motion = load_motion(path) + _finite_or_raise(motion.positions, motion.quaternions) + return _ContentFacts( + frame_count=motion.num_frames, + frame_rate_hz=float(motion.framerate), + joint_count=motion.num_bones, + ) + + +def _inspect_csv(path: Path) -> _ContentFacts: + with path.open("r", encoding="utf-8", errors="strict", newline="") as handle: + rows = [row for row in csv.reader(handle) if any(cell.strip() for cell in row)] + if not rows: + raise _ContentValidationError("CSV contains no rows") + return _ContentFacts( + frame_count=max(0, len(rows) - 1), + warning="CSV columns require a workflow-specific schema before joints can be identified.", + metadata={ + "content_validation_code": "CONTENT_REQUIRES_WORKFLOW_SCHEMA", + }, + semantically_parsed=False, + ) + + +def _inspect_content(path: Path, dataset: str) -> _ContentFacts: + suffix = path.suffix.lower() + if suffix == ".npy": + return _inspect_npy(path, dataset) + if suffix == ".npz": + return _inspect_npz(path, dataset) + if suffix in {".pt", ".pth"}: + return _ContentFacts( + warning=("Torch checkpoint content requires isolated validation before execution."), + metadata={ + "checkpoint_executed": False, + "content_validation_code": "CONTENT_REQUIRES_ISOLATED_VALIDATION", + }, + semantically_parsed=False, + ) + inspectors = { + ".bvh": _inspect_bvh, + ".csv": _inspect_csv, + ".glb": _inspect_glb, + ".gltf": _inspect_glb, + ".pickle": _inspect_pickle, + ".pkl": _inspect_pickle, + } + inspector = inspectors.get(suffix) + if inspector is None: + raise _ContentValidationError(f"unsupported primary extension {suffix or '(none)'}") + return inspector(path) + + +class MotionAssetInspector: + """Validate one content-addressed motion bundle without running a solver.""" + + def inspect( + self, + bundle: AssetBundle, + bundle_root: str | Path, + *, + verify_hashes: bool = True, + parse_content: bool = True, + ) -> AssetInspection: + """Inspect a resolved bundle root and return only Agent-safe facts.""" + + errors: list[ApiError] = [] + warnings: list[str] = [] + root = Path(bundle_root) + try: + resolved_root = root.resolve(strict=True) + except OSError: + resolved_root = root.resolve(strict=False) + errors.append( + _api_error("ASSET_NOT_FOUND", "The registered bundle root is unavailable.") + ) + if not resolved_root.is_dir(): + errors.append( + _api_error( + "ASSET_NOT_FOUND", + "The registered bundle root is not a directory.", + ) + ) + + if bundle.kind is not AssetKind.MOTION_BUNDLE: + errors.append( + _api_error( + "UNSUPPORTED_ASSET_KIND", + "Motion inspection requires a motion_bundle asset.", + details={"kind": bundle.kind.value}, + ) + ) + + resolved_files: dict[str, Path] = {} + for manifest_file in bundle.files: + relative = manifest_file.relative_path + candidate = resolved_root.joinpath(*relative.split("/")) + try: + resolved = candidate.resolve(strict=False) + resolved.relative_to(resolved_root) + except (OSError, ValueError): + errors.append( + _api_error( + "ASSET_OUTSIDE_ALLOWED_ROOT", + "A bundle file resolves outside its registered root.", + details={"relative_path": relative}, + ) + ) + continue + if not resolved.is_file(): + if not manifest_file.required: + warnings.append( + f"Optional bundle file is unavailable: {manifest_file.relative_path}." + ) + continue + code = "ASSET_NOT_FOUND" if relative == bundle.primary_file else "BUNDLE_INCOMPLETE" + errors.append( + _api_error( + code, + "The primary motion is missing." + if code == "ASSET_NOT_FOUND" + else "A required bundle sidecar is missing.", + details={ + "relative_path": relative, + "role": manifest_file.role.value, + "required": manifest_file.required, + }, + ) + ) + continue + resolved_files[relative] = resolved + if verify_hashes: + try: + actual_hash = _sha256(resolved) + except OSError: + errors.append( + _api_error( + "ASSET_NOT_FOUND", + "A registered bundle file cannot be read.", + details={"relative_path": relative}, + ) + ) + continue + if actual_hash != manifest_file.sha256: + errors.append( + _api_error( + "ASSET_HASH_MISMATCH", + "A bundle file no longer matches its registered content hash.", + details={ + "relative_path": relative, + "expected_sha256": manifest_file.sha256, + "actual_sha256": actual_hash, + }, + ) + ) + + primary_path = resolved_files.get(bundle.primary_file) + suffix = Path(bundle.primary_file).suffix.lower() + source_format = suffix.removeprefix(".") or None + declared_dataset = bundle.detected.dataset if bundle.detected is not None else None + detected_dataset = ( + _infer_dataset(primary_path) if primary_path is not None else declared_dataset + ) + dataset = detected_dataset or declared_dataset + dataset = dataset or _fallback_dataset(Path(bundle.primary_file)) + category = category_for_dataset(dataset) + reference = reference_for_dataset(dataset, suffix) + recommended_backend = backend_for_category(category) + + routing_mismatches: dict[str, dict[str, str]] = {} + if declared_dataset and detected_dataset and declared_dataset != detected_dataset: + routing_mismatches["dataset"] = { + "declared": declared_dataset, + "detected": detected_dataset, + } + if bundle.detected is not None: + if bundle.detected.reference and bundle.detected.reference != reference: + routing_mismatches["reference"] = { + "declared": bundle.detected.reference, + "detected": reference, + } + if ( + bundle.detected.recommended_backend + and bundle.detected.recommended_backend != recommended_backend + ): + routing_mismatches["recommended_backend"] = { + "declared": bundle.detected.recommended_backend, + "detected": recommended_backend, + } + if routing_mismatches: + errors.append( + _api_error( + "BUNDLE_METADATA_MISMATCH", + "Registered routing metadata does not match the motion content.", + details={"mismatches": routing_mismatches}, + ) + ) + if bundle.category is not category: + errors.append( + _api_error( + "BUNDLE_METADATA_MISMATCH", + "The registered category does not match the detected motion dataset.", + details={ + "declared_category": bundle.category.value, + "detected_category": category.value, + }, + ) + ) + + role_paths: dict[AssetFileRole, list[Path]] = {} + for manifest_file in bundle.files: + resolved_manifest_file = resolved_files.get(manifest_file.relative_path) + if resolved_manifest_file is not None: + role_paths.setdefault(manifest_file.role, []).append(resolved_manifest_file) + + has_object_sidecar = bool( + role_paths.get(AssetFileRole.OBJECT_MESH) + or role_paths.get(AssetFileRole.OBJECT_TRAJECTORY) + ) + has_terrain_sidecar = bool(role_paths.get(AssetFileRole.TERRAIN_MESH)) + if dataset == "omomo" and not role_paths.get(AssetFileRole.OBJECT_MESH): + errors.append( + _api_error( + "BUNDLE_INCOMPLETE", + "OMOMO interaction bundles require a registered object mesh.", + details={"missing_roles": [AssetFileRole.OBJECT_MESH.value]}, + ) + ) + if dataset == "omnicontact" and not role_paths.get(AssetFileRole.OBJECT_TRAJECTORY): + errors.append( + _api_error( + "BUNDLE_INCOMPLETE", + "OmniContact bundles require a registered object trajectory CSV.", + details={"missing_roles": [AssetFileRole.OBJECT_TRAJECTORY.value]}, + ) + ) + if dataset == "meshmimic_holosoma": + missing_roles: list[str] = [] + if not role_paths.get(AssetFileRole.METADATA): + missing_roles.append(AssetFileRole.METADATA.value) + if not has_terrain_sidecar: + missing_roles.append(AssetFileRole.TERRAIN_MESH.value) + if missing_roles: + errors.append( + _api_error( + "BUNDLE_INCOMPLETE", + "Holosoma terrain bundles require source metadata and terrain data.", + details={"missing_roles": missing_roles}, + ) + ) + if dataset == "parc_ms" and not has_terrain_sidecar: + errors.append( + _api_error( + "BUNDLE_INCOMPLETE", + "PARC-MS terrain bundles require a registered terrain mesh.", + details={"missing_roles": [AssetFileRole.TERRAIN_MESH.value]}, + ) + ) + + facts = _ContentFacts() + primary_hash_failed = any( + error.code in {"ASSET_HASH_MISMATCH", "ASSET_NOT_FOUND"} + and error.details.get("relative_path") == bundle.primary_file + for error in errors + ) + content_parsed = False + if parse_content and primary_path is not None and not primary_hash_failed: + try: + facts = _inspect_content(primary_path, dataset) + content_parsed = facts.semantically_parsed + except _ContentValidationError as exc: + code = ( + "MOTION_NONFINITE_VALUES" + if "NaN or infinite" in str(exc) + else "MOTION_PARSE_FAILED" + ) + errors.append( + _api_error( + code, + "The motion content contains non-finite numeric values." + if code == "MOTION_NONFINITE_VALUES" + else "The primary motion content is malformed.", + details={"reason": str(exc)}, + ) + ) + except ( + EOFError, + ImportError, + OSError, + pickle.UnpicklingError, + RuntimeError, + TypeError, + UnicodeError, + ValueError, + ) as exc: + errors.append( + _api_error( + "MOTION_PARSE_FAILED", + "The primary motion content could not be parsed.", + details={"exception_type": type(exc).__name__}, + ) + ) + + if facts.warning: + warnings.append(facts.warning) + has_object = category is AssetCategory.OBJECT_INTERACTION and ( + has_object_sidecar or facts.has_object or dataset == "omomo" + ) + has_terrain = category is AssetCategory.TERRAIN_SCENE and ( + has_terrain_sidecar or facts.has_terrain + ) + metadata: dict[str, Any] = { + "recommended_backend": recommended_backend, + "content_parsed": content_parsed, + } + if facts.metadata: + metadata.update(facts.metadata) + if bundle.category is not category: + metadata["declared_category"] = bundle.category.value + + duration = None + if facts.frame_count is not None and facts.frame_rate_hz is not None: + duration = max(0.0, (facts.frame_count - 1) / facts.frame_rate_hz) + if errors: + status = InspectionStatus.INVALID + elif warnings: + status = InspectionStatus.VALID_WITH_WARNINGS + else: + status = InspectionStatus.VALID + return AssetInspection( + asset_id=bundle.asset_id, + status=status, + kind=bundle.kind, + category=category, + source_format=source_format, + dataset=dataset, + reference_model=reference, + frame_count=facts.frame_count, + frame_rate_hz=facts.frame_rate_hz, + duration_seconds=duration, + joint_count=facts.joint_count, + has_object=has_object, + has_terrain=has_terrain, + warnings=warnings, + errors=errors, + metadata=metadata, + ) + + +__all__ = [ + "MotionAssetDiscovery", + "MotionAssetDiscoveryError", + "MotionAssetInspector", + "discover_motion_sidecars", + "discover_primary", +] diff --git a/hhtools/services/asset_service.py b/hhtools/services/asset_service.py new file mode 100644 index 00000000..33a1ab76 --- /dev/null +++ b/hhtools/services/asset_service.py @@ -0,0 +1,326 @@ +"""Application service for registering and inspecting Agent motion assets. + +``AssetRegistry`` owns filesystem authorization, immutable manifests, and +persistence. ``MotionAssetInspector`` owns format discovery and read-only +content checks. This module composes those two boundaries so transport adapters +do not need to exchange host paths or reimplement bundle assembly. +""" + +from __future__ import annotations + +from pathlib import Path, PurePosixPath, PureWindowsPath + +from hhtools.contracts import ( + ApiError, + AssetBundle, + AssetCategory, + AssetDetected, + AssetFileRole, + AssetInspection, + AssetInspectionRequest, + AssetKind, + AssetRegistrationRequest, + AssetSearchResponse, + ErrorStage, +) + +from .asset_inspection import ( + MotionAssetDiscoveryError, + MotionAssetInspector, + discover_primary, +) +from .assets import ( + AssetRegistry, + AssetServiceError, + DiscoveredAsset, + DiscoveredAssetFile, +) +from .robot_asset_inspection import ( + RobotAssetDiscoveryError, + RobotAssetInspector, + discover_robot_bundle, +) + +_MOTION_EXTENSIONS = frozenset( + {".bvh", ".csv", ".glb", ".gltf", ".npy", ".npz", ".pickle", ".pkl", ".pt", ".pth"} +) + + +def _safe_discovery_candidates(values: tuple[str, ...]) -> list[str]: + """Keep only normalized relative candidate names in public error details.""" + + safe: list[str] = [] + for value in values: + if not value or "\\" in value: + continue + posix = PurePosixPath(value) + windows = PureWindowsPath(value) + if ( + posix.is_absolute() + or windows.is_absolute() + or bool(windows.drive) + or any(part in {"", ".", ".."} for part in posix.parts) + ): + continue + safe.append(posix.as_posix()) + return safe + + +def _discovery_error(error: MotionAssetDiscoveryError) -> AssetServiceError: + """Translate an inspector discovery failure to the shared service error.""" + + details: dict[str, object] = {} + candidates = _safe_discovery_candidates(error.candidates) + if candidates: + details["candidates"] = candidates + return AssetServiceError( + ApiError( + code=error.code, + message=str(error), + retryable=False, + stage=ErrorStage.ASSET_REGISTRATION, + details=details, + ) + ) + + +def _robot_discovery_error(error: RobotAssetDiscoveryError) -> AssetServiceError: + """Move safe robot-discovery diagnostics to the registration stage.""" + + return AssetServiceError( + error.api_error.model_copy(update={"stage": ErrorStage.ASSET_REGISTRATION}) + ) + + +def _registration_kind( + request: AssetRegistrationRequest, + candidate: Path, +) -> AssetKind: + """Resolve an explicit or unambiguous motion/robot registration kind.""" + + if request.kind is not None: + if request.kind not in {AssetKind.MOTION_BUNDLE, AssetKind.ROBOT_BUNDLE}: + raise AssetServiceError( + ApiError( + code="UNSUPPORTED_ASSET_KIND", + message="Asset registration currently supports motion and robot bundles.", + stage=ErrorStage.ASSET_REGISTRATION, + details={"kind": request.kind.value}, + ) + ) + return request.kind + if request.category is AssetCategory.ROBOT_MODEL or candidate.suffix.casefold() == ".urdf": + return AssetKind.ROBOT_BUNDLE + if candidate.is_file(): + return AssetKind.MOTION_BUNDLE + + has_urdf = any(path.is_file() for path in candidate.rglob("*.urdf")) + has_motion = any( + path.is_file() and path.suffix.casefold() in _MOTION_EXTENSIONS + for path in candidate.rglob("*") + ) + if has_urdf and has_motion: + raise AssetServiceError( + ApiError( + code="BUNDLE_AMBIGUOUS", + message="The directory contains both robot and motion assets; specify kind.", + stage=ErrorStage.ASSET_REGISTRATION, + ) + ) + return AssetKind.ROBOT_BUNDLE if has_urdf else AssetKind.MOTION_BUNDLE + + +class AgentAssetService: + """Transport-neutral facade for the Agent asset lifecycle.""" + + def __init__( + self, + registry: AssetRegistry, + inspector: MotionAssetInspector | None = None, + robot_inspector: RobotAssetInspector | None = None, + ) -> None: + self._registry = registry + self._inspector = inspector or MotionAssetInspector() + self._robot_inspector = robot_inspector or RobotAssetInspector() + + @property + def allowed_root_ids(self) -> tuple[str, ...]: + """Return configured root identifiers without revealing their paths.""" + + return self._registry.allowed_root_ids + + def registration_hint( + self, + trusted_path: Path, + *, + kind: AssetKind | None = None, + category: AssetCategory | None = None, + recursive: bool = True, + ) -> AssetRegistrationRequest: + """Return a portable request for a path chosen by trusted service code. + + Transport adapters must never expose ``trusted_path`` as an Agent + parameter. This method exists for composition code such as preflight, + where an installed preset already owns the local path and an Agent + needs an executable ``register_asset_bundle`` continuation. + """ + + return self._registry.registration_hint( + trusted_path, + kind=kind, + category=category, + recursive=recursive, + ) + + def register(self, request: AssetRegistrationRequest) -> AssetBundle: + """Discover and persist one motion bundle below an allowlisted root.""" + + candidate = self._registry.resolve_registration_path(request) + kind = _registration_kind(request, candidate) + if kind is AssetKind.ROBOT_BUNDLE: + try: + robot = discover_robot_bundle(candidate) + except RobotAssetDiscoveryError as error: + raise _robot_discovery_error(error) from error + discovery = DiscoveredAsset( + primary_file=robot.primary_urdf, + files=tuple( + DiscoveredAssetFile( + path=item.path, + role=item.role, + required=item.required, + ) + for item in robot.files + ), + kind=AssetKind.ROBOT_BUNDLE, + category=AssetCategory.ROBOT_MODEL, + metadata=robot.metadata, + ) + else: + try: + discovered = discover_primary(candidate) + except MotionAssetDiscoveryError as error: + raise _discovery_error(error) from error + + files = [ + DiscoveredAssetFile( + path=discovered.primary_path, + role=AssetFileRole.MOTION, + required=True, + ) + ] + for role, paths in sorted( + discovered.sidecars.items(), + key=lambda item: item[0].value, + ): + files.extend( + DiscoveredAssetFile(path=path, role=role, required=True) + for path in paths + if path != discovered.primary_path + ) + + discovery = DiscoveredAsset( + primary_file=discovered.primary_path, + files=tuple(files), + kind=AssetKind.MOTION_BUNDLE, + category=discovered.category, + detected=AssetDetected( + dataset=discovered.dataset, + reference=discovered.reference, + recommended_backend=discovered.recommended_backend, + ), + ) + return self._registry.register(request, discovery=discovery) + + def get(self, asset_id: str) -> AssetBundle: + """Return one registered, portable asset manifest.""" + + return self._registry.get(asset_id) + + def resolve_primary(self, asset_id: str, *, verify_hash: bool = True) -> Path: + """Resolve the trusted primary file for an in-process executor. + + This is deliberately a Python-only application-service boundary. REST, + CLI, and MCP adapters must return the portable :class:`AssetBundle` + instead of serializing this host path. + """ + + bundle = self._registry.get(asset_id) + return self.resolve_file( + bundle.asset_id, + bundle.primary_file, + verify_hash=verify_hash, + ) + + def resolve_file( + self, + asset_id: str, + relative_path: str, + *, + verify_hash: bool = True, + ) -> Path: + """Resolve one manifest-declared file for trusted in-process consumers. + + The registry enforces membership, containment, and optional content-hash + verification. Protocol adapters must keep returning portable manifest + paths instead of exposing this application-internal absolute path. + """ + + return self._registry.resolve_file( + asset_id, + relative_path, + verify_hash=verify_hash, + ) + + def search( + self, + *, + query: str | None = None, + kind: AssetKind | str | None = None, + category: AssetCategory | str | None = None, + dataset: str | None = None, + reference: str | None = None, + limit: int = 100, + offset: int = 0, + ) -> AssetSearchResponse: + """Search registered manifests using the registry's bounded filters.""" + + return self._registry.search( + query=query, + kind=kind, + category=category, + dataset=dataset, + reference=reference, + limit=limit, + offset=offset, + ) + + def inspect(self, request: AssetInspectionRequest) -> AssetInspection: + """Inspect a registered bundle after re-resolving its trusted base path.""" + + bundle = self._registry.get(request.asset_id) + primary = self._registry.resolve_file( + bundle.asset_id, + bundle.primary_file, + verify_hash=False, + ) + + # The registry returns the resolved primary file, while the inspector + # consumes the bundle base. Walk one parent per portable manifest path + # component so nested primaries remain relative to the registered root. + bundle_root = primary + for _ in PurePosixPath(bundle.primary_file).parts: + bundle_root = bundle_root.parent + + inspector = ( + self._robot_inspector if bundle.kind is AssetKind.ROBOT_BUNDLE else self._inspector + ) + return inspector.inspect( + bundle, + bundle_root, + verify_hashes=request.verify_hashes, + parse_content=request.parse_content, + ) + + +__all__ = ["AgentAssetService"] diff --git a/hhtools/services/assets.py b/hhtools/services/assets.py new file mode 100644 index 00000000..71dde7f3 --- /dev/null +++ b/hhtools/services/assets.py @@ -0,0 +1,1002 @@ +"""Persistent, content-addressed asset registration for Agent clients. + +The registry is deliberately a control-plane service. Public contracts only +contain a configured ``root_id`` and portable paths; absolute host paths are +resolved again at the service boundary and are never written to SQLite or +returned in an :class:`~hhtools.contracts.AssetBundle`. +""" + +from __future__ import annotations + +import hashlib +import json +import mimetypes +import os +import sqlite3 +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, field +from datetime import UTC, datetime +from pathlib import Path, PurePosixPath, PureWindowsPath +from typing import Any + +from hhtools.contracts import ( + ApiError, + AssetBundle, + AssetCategory, + AssetDetected, + AssetFile, + AssetFileRole, + AssetKind, + AssetRegistrationRequest, + AssetSearchResponse, + AssetSource, + AssetSourceScheme, + ErrorStage, +) + +_HASH_CHUNK_SIZE = 1024 * 1024 +_DEFAULT_SEARCH_LIMIT = 100 +_MAX_SEARCH_LIMIT = 500 + +RootProvider = Path | Callable[[], Path] +AssetDiscoverer = Callable[[Path], "DiscoveredAsset"] + + +class AssetServiceError(RuntimeError): + """Expected asset-service failure with a transport-neutral error body.""" + + def __init__(self, error: ApiError) -> None: + self.error = error + super().__init__(f"{error.code}: {error.message}") + + @property + def api_error(self) -> ApiError: + """Alias used by protocol adapters that expose an API error payload.""" + + return self.error + + @property + def code(self) -> str: + """Stable machine code without requiring callers to inspect text.""" + + return self.error.code + + +@dataclass(frozen=True, slots=True) +class DiscoveredAssetFile: + """One trusted inspector result that still needs root-bound validation.""" + + path: Path + role: AssetFileRole = AssetFileRole.OTHER + required: bool = True + media_type: str | None = None + + +@dataclass(frozen=True, slots=True) +class DiscoveredAsset: + """Inspector output consumed by :meth:`AssetRegistry.register`. + + Paths are an internal hand-off only. The registry resolves every path, + proves it remains below the configured root, and converts it to a portable + relative path before constructing or persisting a public contract. + """ + + primary_file: Path + files: Sequence[DiscoveredAssetFile] + kind: AssetKind = AssetKind.MOTION_BUNDLE + category: AssetCategory = AssetCategory.PLAIN_MOTION + display_name: str | None = None + detected: AssetDetected | None = None + metadata: Mapping[str, Any] = field(default_factory=dict) + + +def _asset_error( + code: str, + message: str, + *, + retryable: bool = False, + details: Mapping[str, Any] | None = None, +) -> AssetServiceError: + return AssetServiceError( + ApiError( + code=code, + message=message, + retryable=retryable, + stage=ErrorStage.ASSET_REGISTRATION, + details=dict(details or {}), + ) + ) + + +def _portable_relative_path(value: str) -> PurePosixPath: + """Defensively validate a root-relative path, even for constructed models.""" + + if not value or "\\" in value: + raise _asset_error( + "ASSET_OUTSIDE_ALLOWED_ROOT", + "Asset paths must be normalized relative paths below an allowed root.", + ) + posix = PurePosixPath(value) + windows = PureWindowsPath(value) + if ( + posix.is_absolute() + or windows.is_absolute() + or bool(windows.drive) + or any(part in {"", ".", ".."} for part in posix.parts) + ): + raise _asset_error( + "ASSET_OUTSIDE_ALLOWED_ROOT", + "Asset paths must be normalized relative paths below an allowed root.", + ) + return posix + + +def _looks_like_absolute_path(value: str) -> bool: + posix = PurePosixPath(value) + windows = PureWindowsPath(value) + return posix.is_absolute() or windows.is_absolute() or bool(windows.drive) + + +def _portable_json(value: Any, *, field_name: str) -> Any: + """Copy JSON metadata while rejecting host paths and non-JSON objects.""" + + def validate(item: Any) -> None: + if item is None or isinstance(item, bool | int | float): + return + if isinstance(item, str): + if _looks_like_absolute_path(item): + raise _asset_error( + "ASSET_OUTSIDE_ALLOWED_ROOT", + f"{field_name} cannot contain an absolute host path.", + ) + return + if isinstance(item, Mapping): + for key, child in item.items(): + if not isinstance(key, str): + raise _asset_error( + "INVALID_PARAMETER", + f"{field_name} object keys must be strings.", + ) + if _looks_like_absolute_path(key): + raise _asset_error( + "ASSET_OUTSIDE_ALLOWED_ROOT", + f"{field_name} cannot contain an absolute host path.", + ) + validate(child) + return + if isinstance(item, list | tuple): + for child in item: + validate(child) + return + raise _asset_error( + "INVALID_PARAMETER", + f"{field_name} must contain JSON-compatible values.", + ) + + validate(value) + try: + encoded = json.dumps(value, ensure_ascii=False, allow_nan=False) + except (TypeError, ValueError) as exc: + raise _asset_error( + "INVALID_PARAMETER", + f"{field_name} must contain finite JSON-compatible values.", + ) from exc + return json.loads(encoded) + + +def _sha256_file(path: Path) -> tuple[str, int]: + """Hash one stable file snapshot and reject concurrent modification.""" + + try: + before = path.stat() + digest = hashlib.sha256() + size = 0 + with path.open("rb") as stream: + while chunk := stream.read(_HASH_CHUNK_SIZE): + digest.update(chunk) + size += len(chunk) + after = path.stat() + except (OSError, PermissionError) as exc: + raise _asset_error( + "ASSET_NOT_FOUND", + "An asset file is missing or unreadable.", + retryable=True, + ) from exc + + snapshot_before = (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns) + snapshot_after = (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns) + if snapshot_before != snapshot_after or size != after.st_size: + raise _asset_error( + "ASSET_HASH_MISMATCH", + "An asset file changed while it was being registered.", + retryable=True, + ) + return digest.hexdigest(), size + + +def _infer_role(path: Path) -> AssetFileRole: + suffix = path.suffix.lower() + if suffix in {".urdf", ".mjcf", ".xml"}: + return AssetFileRole.ROBOT_DESCRIPTION + if suffix in {".mp4", ".mov", ".avi", ".webm", ".mkv"}: + return AssetFileRole.VIDEO + if suffix in { + ".bvh", + ".csv", + ".glb", + ".gltf", + ".npy", + ".npz", + ".pickle", + ".pkl", + ".pt", + ".pth", + }: + return AssetFileRole.MOTION + return AssetFileRole.OTHER + + +def _raise_walk_error(error: OSError) -> None: + raise error + + +def _default_discovery(candidate: Path, *, recursive: bool) -> DiscoveredAsset: + if candidate.is_file(): + paths = [candidate] + elif candidate.is_dir(): + try: + if recursive: + paths = [] + for current, directory_names, file_names in os.walk( + candidate, + topdown=True, + onerror=_raise_walk_error, + followlinks=False, + ): + directory = Path(current) + paths.extend( + directory / name + for name in directory_names + if (directory / name).is_symlink() + ) + paths.extend(directory / name for name in file_names) + else: + paths = list(candidate.iterdir()) + paths.sort(key=lambda item: item.as_posix().casefold()) + except (OSError, RuntimeError) as exc: + raise _asset_error( + "ASSET_NOT_FOUND", + "The asset directory could not be read.", + retryable=True, + ) from exc + else: + raise _asset_error("ASSET_NOT_FOUND", "The requested asset does not exist.") + + # Directories and symlinks are retained temporarily so register() can + # validate their resolved locations before filtering to regular files. + discovered = [ + DiscoveredAssetFile(path=path, role=_infer_role(path)) + for path in paths + if path.is_file() or path.is_symlink() + ] + if not discovered: + raise _asset_error( + "BUNDLE_INCOMPLETE", + "The requested asset bundle contains no regular files.", + ) + + regular = [item for item in discovered if item.path.is_file()] + primary = ( + next( + (item for item in regular if item.role is AssetFileRole.MOTION), + regular[0], + ) + if regular + else discovered[0] + ) + role = primary.role + kind = AssetKind.MOTION_BUNDLE + category = AssetCategory.PLAIN_MOTION + if role is AssetFileRole.ROBOT_DESCRIPTION: + kind = AssetKind.ROBOT_BUNDLE + category = AssetCategory.ROBOT_MODEL + elif role is AssetFileRole.VIDEO: + kind = AssetKind.VIDEO + return DiscoveredAsset( + primary_file=primary.path, + files=discovered, + kind=kind, + category=category, + ) + + +def _identity_digest( + *, + kind: AssetKind, + category: AssetCategory, + primary_file: str, + files: Sequence[AssetFile], + detected: AssetDetected | None, +) -> str: + """Hash immutable content and routing semantics; labels/source are excluded.""" + + payload = { + "schema_version": "1.0", + "kind": kind.value, + "category": category.value, + "primary_file": primary_file, + "detected": detected.model_dump(mode="json") if detected is not None else None, + "files": [ + { + "relative_path": item.relative_path, + "role": item.role.value, + "required": item.required, + "size_bytes": item.size_bytes, + "sha256": item.sha256, + } + for item in sorted(files, key=lambda value: (value.relative_path, value.role.value)) + ], + } + canonical = json.dumps( + payload, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(canonical).hexdigest() + + +class AssetRegistry: + """SQLite-backed registry for immutable asset manifests. + + ``roots`` is deployment configuration, not request data. A callable root + provider is resolved on every operation so a WebUI setting can change + without persisting an absolute machine path in the registry database. + """ + + def __init__( + self, + data_dir: Path, + roots: Mapping[str, RootProvider], + *, + discoverer: AssetDiscoverer | None = None, + ) -> None: + self._data_dir = Path(data_dir) + self._database_path = self._data_dir / "assets.sqlite3" + self._artifact_root = self._data_dir / "artifacts" + self._roots = dict(roots) + if any( + not root_id + or root_id in {".", ".."} + or "/" in root_id + or "\\" in root_id + or _looks_like_absolute_path(root_id) + for root_id in self._roots + ): + raise _asset_error( + "INVALID_PARAMETER", + "Asset root identifiers must be portable names, not paths.", + ) + self._discoverer = discoverer + self._data_dir.mkdir(parents=True, exist_ok=True) + self._artifact_root.mkdir(parents=True, exist_ok=True) + self._initialize_database() + + @property + def artifact_root(self) -> Path: + """Internal local boundary reserved for the later ArtifactStore.""" + + return self._artifact_root + + @property + def allowed_root_ids(self) -> tuple[str, ...]: + """Return safe configuration identifiers, never their host locations.""" + + return tuple(sorted(self._roots)) + + def registration_hint( + self, + trusted_path: Path, + *, + kind: AssetKind | None = None, + category: AssetCategory | None = None, + recursive: bool = True, + ) -> AssetRegistrationRequest: + """Convert one trusted local path to a portable registration request. + + This is an in-process service boundary, not a public path resolver. It + accepts a path already selected by trusted application code, proves the + path is below a configured root, and returns only ``root_id`` plus a + normalized relative path. When roots overlap, the deepest usable root + wins. Equally specific root identifiers are rejected instead of being + selected by an arbitrary ordering. + """ + + try: + resolved = Path(trusted_path).resolve(strict=True) + if not resolved.is_file() and not resolved.is_dir(): + raise OSError("trusted asset source is not a regular file or directory") + except (OSError, RuntimeError, TypeError, ValueError) as exc: + raise _asset_error( + "ASSET_NOT_FOUND", + "The trusted asset source is unavailable.", + ) from exc + + candidates: list[tuple[int, str, Path]] = [] + for root_id in sorted(self._roots): + # Fail closed when any configured provider cannot be resolved. A + # silent fallback to a broader root could change the portable + # identity selected for the same installed preset. + root = self._root(root_id) + try: + relative = resolved.relative_to(root) + except ValueError: + continue + candidates.append((len(root.parts), root_id, relative)) + + if not candidates: + raise _asset_error( + "ASSET_OUTSIDE_ALLOWED_ROOT", + "The trusted asset source is not addressable below an allowed root.", + ) + + specificity = max(depth for depth, _root_id, _relative in candidates) + selected = [candidate for candidate in candidates if candidate[0] == specificity] + if len(selected) != 1: + raise _asset_error( + "ASSET_ROOT_AMBIGUOUS", + "Multiple equally specific allowed roots identify the trusted asset source.", + details={"root_ids": sorted(root_id for _depth, root_id, _relative in selected)}, + ) + + _depth, root_id, relative = selected[0] + if not relative.parts: + raise _asset_error( + "ASSET_ROOT_UNREPRESENTABLE", + "The most specific allowed root cannot name itself as a portable relative path.", + details={"root_id": root_id}, + ) + relative_path = relative.as_posix() + try: + return AssetRegistrationRequest( + root_id=root_id, + relative_path=relative_path, + display_name=None, + kind=kind, + category=category, + recursive=recursive, + ) + except (TypeError, ValueError) as exc: + raise _asset_error( + "INVALID_PARAMETER", + "The trusted asset source cannot be represented by the registration contract.", + ) from exc + + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect(self._database_path, timeout=30.0) + connection.row_factory = sqlite3.Row + return connection + + def _initialize_database(self) -> None: + try: + with self._connect() as connection: + connection.execute("PRAGMA journal_mode=WAL") + connection.execute( + """ + CREATE TABLE IF NOT EXISTS assets ( + asset_id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + category TEXT NOT NULL, + display_name TEXT NOT NULL, + root_id TEXT NOT NULL, + logical_path TEXT NOT NULL, + dataset TEXT, + reference_model TEXT, + recommended_backend TEXT, + registered_at TEXT NOT NULL, + manifest_json TEXT NOT NULL + ) + """ + ) + connection.execute("CREATE INDEX IF NOT EXISTS assets_kind_idx ON assets(kind)") + connection.execute( + "CREATE INDEX IF NOT EXISTS assets_category_idx ON assets(category)" + ) + connection.execute( + "CREATE INDEX IF NOT EXISTS assets_dataset_idx ON assets(dataset)" + ) + except sqlite3.Error as exc: + raise _asset_error( + "INTERNAL_ERROR", + "The asset registry database could not be initialized.", + retryable=True, + ) from exc + + def _root(self, root_id: str) -> Path: + provider = self._roots.get(root_id) + if provider is None: + details = ( + {"root_id": root_id} + if root_id + and root_id not in {".", ".."} + and "/" not in root_id + and "\\" not in root_id + and not _looks_like_absolute_path(root_id) + else {} + ) + raise _asset_error( + "ASSET_OUTSIDE_ALLOWED_ROOT", + "The requested asset root is not allowed.", + details=details, + ) + try: + configured = provider() if callable(provider) else provider + root = Path(configured).resolve(strict=True) + except (OSError, RuntimeError, TypeError, ValueError) as exc: + raise _asset_error( + "ASSET_NOT_FOUND", + "The configured asset root is unavailable.", + retryable=True, + details={"root_id": root_id}, + ) from exc + if not root.is_dir(): + raise _asset_error( + "ASSET_NOT_FOUND", + "The configured asset root is not a directory.", + details={"root_id": root_id}, + ) + return root + + @staticmethod + def _contain(root: Path, path: Path, *, root_id: str) -> Path: + try: + resolved = path.resolve(strict=True) + resolved.relative_to(root) + except (OSError, RuntimeError, ValueError) as exc: + raise _asset_error( + "ASSET_OUTSIDE_ALLOWED_ROOT", + "An asset path resolves outside its allowed root.", + details={"root_id": root_id}, + ) from exc + return resolved + + def _registration_path(self, root: Path, request: AssetRegistrationRequest) -> Path: + logical = _portable_relative_path(request.relative_path) + candidate = root.joinpath(*logical.parts) + try: + return self._contain(root, candidate, root_id=request.root_id) + except AssetServiceError as exc: + if not candidate.exists() and not candidate.is_symlink(): + raise _asset_error( + "ASSET_NOT_FOUND", + "The requested asset does not exist below the configured root.", + details={ + "root_id": request.root_id, + "logical_path": request.relative_path, + }, + ) from exc + raise + + def resolve_registration_path(self, request: AssetRegistrationRequest) -> Path: + """Resolve an input for a trusted inspector without exposing it publicly.""" + + root = self._root(request.root_id) + return self._registration_path(root, request) + + def _resolve_discovered_path(self, root: Path, root_id: str, value: Path) -> Path: + candidate = value if value.is_absolute() else root / value + return self._contain(root, candidate, root_id=root_id) + + @staticmethod + def _relative_to_bundle(path: Path, bundle_base: Path) -> str: + """Return a bundle-relative path after enforcing the bundle boundary.""" + + try: + return path.relative_to(bundle_base).as_posix() + except ValueError as exc: + raise _asset_error( + "BUNDLE_INCOMPLETE", + "A discovered file is outside the requested asset bundle.", + ) from exc + + def register( + self, + request: AssetRegistrationRequest, + *, + discovery: DiscoveredAsset | None = None, + ) -> AssetBundle: + """Register a safe bundle and return its stable content identity.""" + + root = self._root(request.root_id) + candidate = self._registration_path(root, request) + bundle_base = candidate if candidate.is_dir() else candidate.parent + if discovery is None: + if self._discoverer is not None: + try: + discovery = self._discoverer(candidate) + except AssetServiceError: + raise + except Exception as exc: + raise _asset_error( + "BUNDLE_INCOMPLETE", + "Asset bundle discovery failed.", + ) from exc + else: + discovery = _default_discovery(candidate, recursive=request.recursive) + + metadata = _portable_json(dict(discovery.metadata), field_name="Asset metadata") + detected = discovery.detected + if detected is not None: + detected = AssetDetected.model_validate( + _portable_json( + detected.model_dump(mode="json"), + field_name="Detected asset metadata", + ) + ) + display_name = request.display_name or discovery.display_name or candidate.stem + if _looks_like_absolute_path(display_name): + raise _asset_error( + "ASSET_OUTSIDE_ALLOWED_ROOT", + "Asset display names cannot contain an absolute host path.", + ) + + resolved_files: dict[str, tuple[Path, DiscoveredAssetFile]] = {} + for item in discovery.files: + resolved = self._resolve_discovered_path(root, request.root_id, Path(item.path)) + if not resolved.is_file(): + # A directory symlink is still containment-checked above, but + # directories are not part of the immutable file manifest. + continue + relative = self._relative_to_bundle(resolved, bundle_base) + if relative in resolved_files: + previous = resolved_files[relative][1] + if previous.role is not item.role or previous.required != item.required: + raise _asset_error( + "BUNDLE_INCOMPLETE", + "Asset discovery assigned conflicting roles to one file.", + details={"relative_path": relative}, + ) + continue + resolved_files[relative] = (resolved, item) + + if not resolved_files: + raise _asset_error( + "BUNDLE_INCOMPLETE", + "The requested asset bundle contains no regular files.", + ) + + primary_path = self._resolve_discovered_path( + root, + request.root_id, + Path(discovery.primary_file), + ) + if not primary_path.is_file(): + raise _asset_error( + "BUNDLE_INCOMPLETE", + "The asset primary file is not a regular file.", + ) + primary_relative = self._relative_to_bundle(primary_path, bundle_base) + if primary_relative not in resolved_files: + raise _asset_error( + "BUNDLE_INCOMPLETE", + "The asset primary file is missing from the discovered manifest.", + details={"relative_path": primary_relative}, + ) + + files: list[AssetFile] = [] + for relative, (path, item) in sorted(resolved_files.items()): + digest, size = _sha256_file(path) + media_type = item.media_type or mimetypes.guess_type(path.name)[0] + files.append( + AssetFile( + role=item.role, + relative_path=relative, + sha256=digest, + size_bytes=size, + media_type=media_type, + required=item.required, + ) + ) + + kind = request.kind or discovery.kind + category = request.category or discovery.category + digest = _identity_digest( + kind=kind, + category=category, + primary_file=primary_relative, + files=files, + detected=detected, + ) + registered_at = datetime.now(UTC) + bundle = AssetBundle( + asset_id=f"asset:sha256:{digest}", + kind=kind, + category=category, + display_name=display_name, + primary_file=primary_relative, + files=files, + source=AssetSource( + scheme=AssetSourceScheme.MANAGED_FILE, + root_id=request.root_id, + registered_at=registered_at, + logical_path=request.relative_path, + ), + detected=detected, + metadata=metadata, + ) + return self._persist(bundle) + + def _persist(self, bundle: AssetBundle) -> AssetBundle: + source = bundle.source + if source is None: + raise _asset_error("INTERNAL_ERROR", "A registered asset must have a source.") + detected = bundle.detected + try: + with self._connect() as connection: + connection.execute( + """ + INSERT OR IGNORE INTO assets ( + asset_id, kind, category, display_name, root_id, + logical_path, dataset, reference_model, + recommended_backend, registered_at, manifest_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + bundle.asset_id, + bundle.kind.value, + bundle.category.value, + bundle.display_name, + source.root_id, + source.logical_path or bundle.primary_file, + detected.dataset if detected else None, + detected.reference if detected else None, + detected.recommended_backend if detected else None, + source.registered_at.isoformat(), + bundle.model_dump_json(), + ), + ) + row = connection.execute( + "SELECT manifest_json FROM assets WHERE asset_id = ?", + (bundle.asset_id,), + ).fetchone() + except sqlite3.Error as exc: + raise _asset_error( + "INTERNAL_ERROR", + "The asset manifest could not be persisted.", + retryable=True, + ) from exc + if row is None: + raise _asset_error( + "INTERNAL_ERROR", + "The asset manifest was not available after registration.", + retryable=True, + ) + return self._decode_bundle(row["manifest_json"]) + + @staticmethod + def _decode_bundle(payload: str) -> AssetBundle: + try: + bundle = AssetBundle.model_validate_json(payload) + if _looks_like_absolute_path(bundle.display_name): + raise ValueError("absolute display name") + _portable_json(bundle.metadata, field_name="Asset metadata") + if bundle.detected is not None: + _portable_json( + bundle.detected.model_dump(mode="json"), + field_name="Detected asset metadata", + ) + source = bundle.source + if source is not None and ( + not source.root_id + or source.root_id in {".", ".."} + or "/" in source.root_id + or "\\" in source.root_id + or _looks_like_absolute_path(source.root_id) + ): + raise ValueError("path-like root id") + return bundle + except (AssetServiceError, TypeError, ValueError) as exc: + raise _asset_error( + "INTERNAL_ERROR", + "A persisted asset manifest is invalid.", + ) from exc + + def get(self, asset_id: str) -> AssetBundle: + """Return one portable manifest by stable id.""" + + try: + with self._connect() as connection: + row = connection.execute( + "SELECT manifest_json FROM assets WHERE asset_id = ?", + (asset_id,), + ).fetchone() + except sqlite3.Error as exc: + raise _asset_error( + "INTERNAL_ERROR", + "The asset registry could not be read.", + retryable=True, + ) from exc + if row is None: + raise _asset_error( + "ASSET_NOT_FOUND", + "No registered asset has the requested id.", + details={"asset_id": asset_id}, + ) + return self._decode_bundle(row["manifest_json"]) + + def search( + self, + *, + query: str | None = None, + kind: AssetKind | str | None = None, + category: AssetCategory | str | None = None, + dataset: str | None = None, + reference: str | None = None, + limit: int = _DEFAULT_SEARCH_LIMIT, + offset: int = 0, + ) -> AssetSearchResponse: + """Search compact manifest metadata with deterministic pagination.""" + + if not 1 <= limit <= _MAX_SEARCH_LIMIT or offset < 0: + raise _asset_error( + "INVALID_PARAMETER", + "Asset search requires limit 1..500 and a non-negative offset.", + ) + try: + kind_value = AssetKind(kind).value if kind is not None else None + category_value = AssetCategory(category).value if category is not None else None + except ValueError as exc: + raise _asset_error( + "INVALID_PARAMETER", + "Asset search contains an unsupported kind or category.", + ) from exc + + clauses: list[str] = [] + parameters: list[Any] = [] + if query: + escaped = query.casefold().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + clauses.append( + "(LOWER(display_name) LIKE ? ESCAPE '\\' OR LOWER(logical_path) LIKE ? ESCAPE '\\')" + ) + parameters.extend((f"%{escaped}%", f"%{escaped}%")) + for column, value in ( + ("kind", kind_value), + ("category", category_value), + ("dataset", dataset), + ("reference_model", reference), + ): + if value is not None: + clauses.append(f"LOWER({column}) = ?") + parameters.append(str(value).casefold()) + where = f" WHERE {' AND '.join(clauses)}" if clauses else "" + + try: + with self._connect() as connection: + total_row = connection.execute( + f"SELECT COUNT(*) AS count FROM assets{where}", # noqa: S608 + parameters, + ).fetchone() + rows = connection.execute( + f""" + SELECT manifest_json FROM assets{where} + ORDER BY registered_at DESC, asset_id ASC + LIMIT ? OFFSET ? + """, # noqa: S608 + [*parameters, limit, offset], + ).fetchall() + except sqlite3.Error as exc: + raise _asset_error( + "INTERNAL_ERROR", + "The asset registry search failed.", + retryable=True, + ) from exc + total = int(total_row["count"]) if total_row is not None else 0 + return AssetSearchResponse( + assets=[self._decode_bundle(row["manifest_json"]) for row in rows], + total=total, + limit=limit, + offset=offset, + ) + + def resolve_file( + self, + asset_id: str, + relative_path: str | None = None, + *, + verify_hash: bool = True, + ) -> Path: + """Resolve a declared file for trusted services and optionally verify it. + + This method is not an Agent response. It repeats root and symlink + containment checks on every call, so a changed linked directory cannot + silently expand the registry's filesystem authority. + """ + + bundle = self.get(asset_id) + source = bundle.source + if source is None: + raise _asset_error("INTERNAL_ERROR", "The asset has no registered source.") + selected = relative_path or bundle.primary_file + declared = {item.relative_path: item for item in bundle.files} + asset_file = declared.get(selected) + if asset_file is None: + raise _asset_error( + "ASSET_NOT_FOUND", + "The requested file is not declared by this asset bundle.", + details={"asset_id": asset_id, "relative_path": selected}, + ) + logical = _portable_relative_path(selected) + root = self._root(source.root_id) + if source.logical_path is None: + raise _asset_error("INTERNAL_ERROR", "The asset source has no logical path.") + source_path = _portable_relative_path(source.logical_path) + unresolved_candidate = root.joinpath(*source_path.parts) + try: + candidate = self._contain( + root, + unresolved_candidate, + root_id=source.root_id, + ) + except AssetServiceError as exc: + if not unresolved_candidate.exists() and not unresolved_candidate.is_symlink(): + raise _asset_error( + "ASSET_NOT_FOUND", + "The registered asset source is no longer available.", + details={"asset_id": asset_id}, + ) from exc + raise + bundle_base = candidate if candidate.is_dir() else candidate.parent + unresolved_file = bundle_base.joinpath(*logical.parts) + try: + path = self._contain( + root, + unresolved_file, + root_id=source.root_id, + ) + except AssetServiceError as exc: + if not unresolved_file.exists() and not unresolved_file.is_symlink(): + raise _asset_error( + "ASSET_NOT_FOUND", + "A declared asset file is no longer available.", + details={"asset_id": asset_id, "relative_path": selected}, + ) from exc + raise + try: + path.relative_to(bundle_base) + except ValueError as exc: + raise _asset_error( + "ASSET_OUTSIDE_ALLOWED_ROOT", + "A declared asset file resolves outside its bundle boundary.", + details={"asset_id": asset_id, "relative_path": selected}, + ) from exc + if not path.is_file(): + raise _asset_error( + "ASSET_NOT_FOUND", + "A declared asset file is no longer available.", + details={"asset_id": asset_id, "relative_path": selected}, + ) + if verify_hash: + digest, size = _sha256_file(path) + if digest != asset_file.sha256 or size != asset_file.size_bytes: + raise _asset_error( + "ASSET_HASH_MISMATCH", + "A registered asset file no longer matches its manifest.", + details={ + "asset_id": asset_id, + "relative_path": selected, + "expected_sha256": asset_file.sha256, + "actual_sha256": digest, + }, + ) + return path + + +__all__ = [ + "AssetDiscoverer", + "AssetRegistry", + "AssetServiceError", + "DiscoveredAsset", + "DiscoveredAssetFile", + "RootProvider", +] diff --git a/hhtools/services/capabilities.py b/hhtools/services/capabilities.py new file mode 100644 index 00000000..a4a4a9d6 --- /dev/null +++ b/hhtools/services/capabilities.py @@ -0,0 +1,437 @@ +"""Read-only capability discovery for humans, automation, and AI agents. + +Capability discovery must stay cheap and side-effect free: in particular it +does not import either retarget pipeline or initialise Warp. Optional package +availability is therefore probed with :mod:`importlib`, while device discovery +uses Torch only when it is already installable in the current environment. +""" + +from __future__ import annotations + +import importlib.util +import platform +from collections.abc import Callable, Iterable +from importlib import metadata +from typing import TYPE_CHECKING, Any + +from hhtools._version import __version__ +from hhtools.contracts import ( + AssetCategory, + BackendCapability, + CapabilityResponse, + DeviceCapability, + RobotCapability, + SchedulerCapability, + SchedulerMode, +) + +if TYPE_CHECKING: + from hhtools.robot.base import RobotPreset + + +_INPUT_FORMATS = ( + "bvh", + "csv", + "glb", + "gltf", + "npy", + "npz", + "pickle", + "pkl", + "pt", + "pth", +) +_OUTPUT_FORMATS = ("csv", "pkl") +_CALIBRATION_REFERENCES = ( + "smpl", + "smplx", + "gvhmr", + "soma_bvh", + "lafan_bvh", + "mocap_bvh", + "xsens_mocap", + "glb", +) + +# Distribution names are not always the same as import names. The first +# installed distribution in each tuple supplies the optional version string. +_DISTRIBUTIONS: dict[str, tuple[str, ...]] = { + "mujoco": ("mujoco",), + "newton": ("newton", "newton-python"), + "osqp": ("osqp",), + "torch": ("torch",), + "warp": ("warp-lang", "warp"), +} + + +def _module_available(module_name: str) -> bool: + """Return whether an optional module is importable without importing it.""" + + try: + return importlib.util.find_spec(module_name) is not None + except (ImportError, ModuleNotFoundError, ValueError): + return False + + +def _module_version(module_name: str) -> str | None: + for distribution in _DISTRIBUTIONS.get(module_name, (module_name,)): + try: + return metadata.version(distribution) + except metadata.PackageNotFoundError: + continue + return None + + +def _cpu_name() -> str: + return platform.processor().strip() or platform.machine().strip() or "CPU" + + +def _detect_devices() -> list[DeviceCapability]: + """Return compact CPU/CUDA/MPS facts without importing a retarget backend.""" + + devices = [ + DeviceCapability( + device_id="cpu", + kind="cpu", + display_name=_cpu_name(), + available=True, + metadata={"platform": platform.system().lower()}, + ) + ] + try: + import torch + except (ImportError, OSError, RuntimeError): + return devices + + torch_version = str(getattr(torch, "__version__", "unknown")) + torch_runtime = getattr(getattr(torch, "version", None), "cuda", None) + cuda = getattr(torch, "cuda", None) + try: + cuda_available = bool(cuda is not None and cuda.is_available()) + except (AttributeError, RuntimeError): + cuda_available = False + + if cuda_available and cuda is not None: + try: + count = int(cuda.device_count()) + except (AttributeError, RuntimeError, TypeError, ValueError): + count = 0 + for index in range(count): + try: + props = cuda.get_device_properties(index) + name = str(getattr(props, "name", f"CUDA device {index}")) + total_memory = int(getattr(props, "total_memory", 0)) or None + major = getattr(props, "major", None) + minor = getattr(props, "minor", None) + compute_capability = ( + f"{int(major)}.{int(minor)}" + if major is not None and minor is not None + else None + ) + except (AttributeError, RuntimeError, TypeError, ValueError): + name = f"CUDA device {index}" + total_memory = None + compute_capability = None + + free_memory: int | None = None + try: + free_memory = int(cuda.mem_get_info(index)[0]) + except (AttributeError, RuntimeError, TypeError, ValueError): + pass + + devices.append( + DeviceCapability( + device_id=f"cuda:{index}", + kind="cuda", + display_name=name, + available=True, + total_memory_bytes=total_memory, + free_memory_bytes=free_memory, + compute_capability=compute_capability, + metadata={ + "torch": torch_version, + "cuda_runtime": str(torch_runtime) if torch_runtime else None, + }, + ) + ) + + mps = getattr(getattr(torch, "backends", None), "mps", None) + try: + mps_available = bool(mps is not None and mps.is_available()) + except (AttributeError, RuntimeError): + mps_available = False + if mps_available: + devices.append( + DeviceCapability( + device_id="mps", + kind="mps", + display_name="Apple Metal Performance Shaders", + available=True, + metadata={"torch": torch_version}, + ) + ) + return devices + + +def _scheduler_capability(snapshot: object | None) -> SchedulerCapability: + """Normalize a Web scheduler snapshot without depending on its class.""" + + def value(name: str, default: int | bool = 0) -> Any: + if snapshot is None: + return default + if isinstance(snapshot, dict): + return snapshot.get(name, default) + return getattr(snapshot, name, default) + + max_running = int(value("max_running_jobs")) + max_queued = int(value("max_queued_jobs")) + # JobScheduler bypasses both running and queue admission checks whenever + # max_running_jobs is zero. Preserve max_queued as configured metadata, + # but describe the effective policy rather than implying it is enforced. + if max_running == 0: + mode = SchedulerMode.UNLIMITED + elif max_running > 0 and max_queued > 0: + mode = SchedulerMode.LIMITED + else: + mode = SchedulerMode.MIXED + return SchedulerCapability( + max_running_jobs=max_running, + max_queued_jobs=max_queued, + running=int(value("running_jobs")), + queued=int(value("queued_jobs")), + reserved=int(value("reserved_jobs")), + mode=mode, + closed=bool(value("closed", False)), + ) + + +def _reference_readiness(preset: RobotPreset) -> tuple[list[str], list[str]]: + """Return independently validated calibration and scaler references. + + A bundled Newton scaler is not equivalent to a human-reviewed robot pose + calibration: notably, Interaction-Mesh still requires the latter. Keep + both facts separate so clients can make backend-specific decisions. + """ + + from hhtools.retarget.calibration import ( + load_calibration, + normalize_calibration_reference, + resolve_preset_calibration_file, + ) + from hhtools.retarget.newton_basic.config import load_scaler_config + from hhtools.robot.retarget_profile import bundled_scaler_path + + calibrated: list[str] = [] + scalers: list[str] = [] + known_joints = set(preset.dof_order) + preset_root = preset.root_dir.resolve() + for reference in _CALIBRATION_REFERENCES: + try: + calibration_path = resolve_preset_calibration_file(preset, reference) + if calibration_path is not None: + calibration = load_calibration(calibration_path) + calibration_joints = set(calibration.calibrated_joint_q) + robot_matches = calibration.robot == preset.name + reference_matches = ( + normalize_calibration_reference(str(calibration.reference)) == reference + ) + joints_match = not calibration_joints.difference(known_joints) + if robot_matches and reference_matches and joints_match: + calibrated.append(reference) + except Exception: # noqa: BLE001 - invalid optional metadata is "not ready" + pass + + try: + scaler_path = bundled_scaler_path(preset, reference) + if scaler_path is not None: + contained_scaler = scaler_path.resolve(strict=True) + contained_scaler.relative_to(preset_root) + load_scaler_config(contained_scaler) + scalers.append(reference) + except Exception: # noqa: BLE001 - invalid optional metadata is "not ready" + pass + return calibrated, scalers + + +def _robot_capabilities(presets: Iterable[RobotPreset]) -> list[RobotCapability]: + robots: list[RobotCapability] = [] + for preset in sorted(presets, key=lambda item: item.name): + has_urdf = bool(preset.has_urdf) + has_ik_mapping = bool(preset.ik_map) + has_dof_order = bool(preset.dof_order) + unavailable: list[str] = [] + if not has_urdf: + unavailable.append("URDF is missing") + if not has_ik_mapping: + unavailable.append("IK mapping is missing") + if not has_dof_order: + unavailable.append("DOF order is missing") + calibrated_references, scaler_references = _reference_readiness(preset) + robots.append( + RobotCapability( + robot_id=preset.name, + display_name=preset.display_name or preset.name, + available=not unavailable, + has_urdf=has_urdf, + has_ik_mapping=has_ik_mapping, + dof_count=len(preset.dof_order) if preset.dof_order else None, + supported_references=list(_CALIBRATION_REFERENCES), + calibrated_references=calibrated_references, + scaler_references=scaler_references, + unavailable_reason="; ".join(unavailable) if unavailable else None, + ) + ) + return robots + + +def _backend_capabilities(devices: list[DeviceCapability]) -> list[BackendCapability]: + cuda_available = any(device.kind.value == "cuda" and device.available for device in devices) + + definitions = ( + ( + "newton", + "Newton IK", + # The solver core is Newton + Warp. The current end-to-end robot + # adapter imports yourdfpy and MuJoCo before constructing it, so + # capability discovery must include those real execution-path deps. + ("newton", "warp", "mujoco", "yourdfpy"), + [AssetCategory.PLAIN_MOTION], + { + "batch": True, + "scene_geometry": False, + "cuda_graph": cuda_available, + "cpu_fallback": True, + }, + { + "requires_cuda": False, + "recommended_linux_cuda": True, + # These are admission-safety ceilings, not performance + # recommendations. Expert callers may still choose any + # value below them, while accidental pathological values are + # rejected before a solver or resampler is constructed. + "max_ik_iterations": 200, + "max_retarget_fps": 1_000.0, + "max_retarget_frames": 100_000, + "max_human_height": 10.0, + }, + ), + ( + "interaction_mesh", + "Interaction-Mesh MPC", + ("mujoco", "osqp", "scipy", "yourdfpy"), + [AssetCategory.OBJECT_INTERACTION, AssetCategory.TERRAIN_SCENE], + { + "batch": False, + "scene_geometry": True, + "mpc": True, + "cpu_fallback": True, + }, + { + "requires_cuda": False, + "max_retarget_fps": 1_000.0, + "max_retarget_frames": 100_000, + "max_human_height": 10.0, + }, + ), + ) + capabilities: list[BackendCapability] = [] + for backend_id, display_name, dependencies, categories, features, limits in definitions: + missing = [name for name in dependencies if not _module_available(name)] + reasons: list[str] = [] + if missing: + reasons.append(f"missing dependencies: {', '.join(missing)}") + capabilities.append( + BackendCapability( + backend_id=backend_id, + display_name=display_name, + available=not reasons, + version=_module_version("newton" if backend_id == "newton" else "mujoco"), + supported_categories=categories, + output_formats=list(_OUTPUT_FORMATS), + unavailable_reason="; ".join(reasons) if reasons else None, + features=features, + limits=limits, + ) + ) + return capabilities + + +class CapabilitiesService: + """Build one truthful snapshot from optional runtime providers.""" + + def __init__( + self, + *, + scheduler_snapshot: Callable[[], object] | None = None, + robot_provider: Callable[[], Iterable[RobotPreset]] | None = None, + device_probe: Callable[[], list[DeviceCapability]] = _detect_devices, + asset_root_provider: Callable[[], Iterable[str]] | None = None, + preflight_available: bool = False, + artifact_store_available: bool = False, + job_manager_available: bool = False, + job_execution_available: bool = False, + mcp_available: bool = False, + agent_rest_available: bool = True, + json_cli_available: bool = True, + ) -> None: + if robot_provider is None: + from hhtools.robot.registry import list_presets_readonly + + robot_provider = list_presets_readonly + self._scheduler_snapshot = scheduler_snapshot + self._robot_provider = robot_provider + self._device_probe = device_probe + self._asset_root_provider = asset_root_provider + self._preflight_available = bool(preflight_available) + self._artifact_store_available = bool(artifact_store_available) + self._job_manager_available = bool(job_manager_available) + # Execution, cancellation, and retry all require a trusted executor. + # A durable JobStore on its own can still serve compact historical + # queries, but it must not make a client believe new solver work can run. + self._job_execution_available = bool(job_manager_available and job_execution_available) + self._mcp_available = bool(mcp_available) + self._agent_rest_available = bool(agent_rest_available) + self._json_cli_available = bool(json_cli_available) + + def get_capabilities(self) -> CapabilityResponse: + """Return a compact snapshot; no solver, queue slot, or asset is created.""" + + snapshot = self._scheduler_snapshot() if self._scheduler_snapshot is not None else None + devices = self._device_probe() + asset_root_ids = ( + sorted(set(self._asset_root_provider())) + if self._asset_root_provider is not None + else [] + ) + return CapabilityResponse( + service_version=__version__, + backends=_backend_capabilities(devices), + devices=devices, + robots=_robot_capabilities(self._robot_provider()), + scheduler=_scheduler_capability(snapshot), + asset_root_ids=asset_root_ids, + supported_input_formats=list(_INPUT_FORMATS), + supported_output_formats=list(_OUTPUT_FORMATS), + features={ + "agent_rest": self._agent_rest_available, + "asset_inspection": self._asset_root_provider is not None, + "asset_registry": self._asset_root_provider is not None, + "artifact_store": self._artifact_store_available, + "idempotent_jobs": self._job_manager_available, + "job_cancellation": self._job_execution_available, + "job_execution": self._job_execution_available, + "job_retry": self._job_execution_available, + "job_spec_v2": True, + # Phase 4 ships the strict JSON client in the same package. It + # delegates to this long-lived REST composition root so job + # ownership never moves into a short-lived CLI process. + "json_cli": self._json_cli_available, + "mcp": self._mcp_available, + "persistent_jobs": self._job_manager_available, + "preflight": self._preflight_available, + "revision_polling": self._job_manager_available, + }, + ) + + +__all__ = ["CapabilitiesService"] diff --git a/hhtools/services/job_store.py b/hhtools/services/job_store.py new file mode 100644 index 00000000..6b569fcd --- /dev/null +++ b/hhtools/services/job_store.py @@ -0,0 +1,1673 @@ +"""Durable job identity and lifecycle storage for agent-facing execution. + +``JobStore`` owns durable facts only: the immutable :class:`JobSpecV2`, the +current lifecycle revision, outcome, progress, artifact descriptors, retry +lineage, and a cooperative cancellation request. It deliberately does not +reserve scheduler capacity, select a device, start a worker, invoke a solver, +or write artifact payload bytes. + +The idempotency fingerprint is the SHA-256 digest of the complete canonical +JobSpec v2 document. Consequently, a key can be retried safely for exactly +the same execution identity, while reusing it for a changed plan, parameter, +asset, provenance snapshot, or output policy is a conflict. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import re +import sqlite3 +import uuid +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path, PurePosixPath, PureWindowsPath +from typing import Any, NoReturn + +from pydantic import ValidationError + +from hhtools.contracts import ( + AgentJobView, + ApiError, + ArtifactDescriptor, + ErrorStage, + JobOutcome, + JobProgress, + JobSpecV2, + JobState, + NextAction, +) + +_IDEMPOTENCY_KEY_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._~:-]{0,255}$") +_JOB_ID_PATTERN = re.compile(r"^job:[A-Za-z0-9][A-Za-z0-9._~-]{0,251}$") +_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") +_TERMINAL_STATES = frozenset({JobState.COMPLETED, JobState.FAILED, JobState.CANCELLED}) +_ALLOWED_TRANSITIONS: dict[JobState, frozenset[JobState]] = { + JobState.QUEUED: frozenset({JobState.RUNNING, JobState.FAILED, JobState.CANCELLED}), + JobState.RUNNING: frozenset({JobState.COMPLETED, JobState.FAILED, JobState.CANCELLED}), + JobState.COMPLETED: frozenset(), + JobState.FAILED: frozenset(), + JobState.CANCELLED: frozenset(), +} + +_SELECT_JOB = """ + SELECT + job_id, + idempotency_key, + request_fingerprint, + spec_sha256, + spec_json, + state, + outcome, + progress_json, + summary_json, + error_json, + next_action_json, + revision, + cancel_requested, + parent_job_id, + root_job_id, + attempt, + artifacts_json, + submitted_at, + started_at, + completed_at, + cancel_requested_at, + poll_after_ms + FROM jobs +""" + + +class JobStoreError(RuntimeError): + """Expected job-store failure with a transport-neutral error body.""" + + def __init__(self, error: ApiError) -> None: + self.error = error + super().__init__(f"{error.code}: {error.message}") + + @property + def api_error(self) -> ApiError: + """Alias used by REST, CLI, and MCP adapters.""" + + return self.error + + @property + def code(self) -> str: + """Return the stable machine code without parsing prose.""" + + return self.error.code + + +@dataclass(frozen=True, slots=True) +class StoredJob: + """Validated snapshot returned by the durable job store. + + A fresh instance is decoded for every operation. This is significant + because JobSpec v2 is frozen at the model boundary but intentionally + contains JSON dictionaries; mutating a returned dictionary cannot mutate + the persisted execution identity. + """ + + spec: JobSpecV2 + view: AgentJobView + idempotency_key: str + request_fingerprint: str + artifacts: tuple[ArtifactDescriptor, ...] + cancel_requested: bool + cancel_requested_at: datetime | None + created: bool = False + + @property + def job_id(self) -> str: + """Return the public job id.""" + + return self.view.job_id + + @property + def revision(self) -> int: + """Return the store-owned lifecycle revision.""" + + return self.view.progress.revision + + +class _InvalidStoredJobError(ValueError): + """Private signal for malformed or internally inconsistent rows.""" + + +def _error( + code: str, + message: str, + *, + stage: ErrorStage = ErrorStage.EXECUTION, + retryable: bool = False, + details: Mapping[str, Any] | None = None, +) -> JobStoreError: + return JobStoreError( + ApiError( + code=code, + message=message, + retryable=retryable, + stage=stage, + details=dict(details or {}), + ) + ) + + +def _reject_json_constant(value: str) -> NoReturn: + raise _InvalidStoredJobError(f"non-finite JSON number: {value}") + + +def _object_from_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise _InvalidStoredJobError(f"duplicate JSON object key: {key}") + result[key] = value + return result + + +def _strict_json_loads(payload: str) -> Any: + try: + return json.loads( + payload, + object_pairs_hook=_object_from_pairs, + parse_constant=_reject_json_constant, + ) + except (json.JSONDecodeError, TypeError, ValueError, RecursionError) as exc: + raise _InvalidStoredJobError("invalid JSON document") from exc + + +def _looks_like_absolute_path(value: str) -> bool: + posix = PurePosixPath(value) + windows = PureWindowsPath(value) + return posix.is_absolute() or windows.is_absolute() or bool(windows.drive) or bool(windows.root) + + +def _validate_portable_json(value: Any, *, location: str = "$") -> None: + """Reject non-JSON values, non-finite numbers, and host absolute paths.""" + + if value is None or isinstance(value, bool | int): + return + if isinstance(value, float): + if not math.isfinite(value): + raise _InvalidStoredJobError(f"non-finite number at {location}") + return + if isinstance(value, str): + if _looks_like_absolute_path(value): + raise _InvalidStoredJobError(f"absolute host path at {location}") + return + if isinstance(value, list): + for index, item in enumerate(value): + _validate_portable_json(item, location=f"{location}[{index}]") + return + if isinstance(value, dict): + for key, item in value.items(): + if not isinstance(key, str): + raise _InvalidStoredJobError(f"non-string object key at {location}") + if _looks_like_absolute_path(key): + raise _InvalidStoredJobError(f"absolute host path key at {location}") + _validate_portable_json(item, location=f"{location}.{key}") + return + raise _InvalidStoredJobError(f"non-JSON value at {location}") + + +def _canonical_json(document: Any) -> str: + _validate_portable_json(document) + try: + return json.dumps( + document, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ) + except (TypeError, ValueError, OverflowError, RecursionError) as exc: + raise _InvalidStoredJobError("document cannot be encoded as canonical JSON") from exc + + +def _encode_spec(spec: JobSpecV2) -> tuple[str, JobSpecV2]: + if not isinstance(spec, JobSpecV2): + raise _error( + "INVALID_PARAMETER", + "A valid JobSpec v2 is required to create a job.", + stage=ErrorStage.REQUEST, + ) + try: + encoded = _canonical_json(spec.model_dump(mode="json")) + restored = JobSpecV2.model_validate_json(encoded) + if _canonical_json(restored.model_dump(mode="json")) != encoded: + raise _InvalidStoredJobError("JobSpec v2 did not survive a canonical round trip") + except (_InvalidStoredJobError, TypeError, ValueError, ValidationError) as exc: + raise _error( + "INVALID_PARAMETER", + "JobSpec v2 must be lossless portable JSON without host paths.", + stage=ErrorStage.REQUEST, + ) from exc + return encoded, restored + + +def compute_request_fingerprint(spec: JobSpecV2) -> str: + """Return the canonical SHA-256 request identity for ``spec``.""" + + encoded, _ = _encode_spec(spec) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def _summary_for_spec(spec: JobSpecV2) -> dict[str, Any]: + summary: dict[str, Any] = { + "input_count": len(spec.inputs), + "robot_id": spec.robot.robot_id, + "backend": spec.backend, + } + run_mode = spec.effective_parameters.get("run_mode") + if isinstance(run_mode, str) and run_mode: + summary["run_mode"] = run_mode + return summary + + +def _normalize_idempotency_key(value: str) -> str: + if not isinstance(value, str) or _IDEMPOTENCY_KEY_PATTERN.fullmatch(value) is None: + raise _error( + "INVALID_PARAMETER", + "The idempotency key must be 1-256 portable token characters.", + stage=ErrorStage.REQUEST, + ) + return value + + +def _normalize_fingerprint(value: str) -> str: + if not isinstance(value, str) or _SHA256_PATTERN.fullmatch(value) is None: + raise _error( + "INVALID_PARAMETER", + "The request fingerprint must be a lower-case SHA-256 digest.", + stage=ErrorStage.REQUEST, + ) + return value + + +def _normalize_job_id(value: str) -> str: + if not isinstance(value, str) or _JOB_ID_PATTERN.fullmatch(value) is None: + raise _error( + "INVALID_PARAMETER", + "The job id is not a valid HHTools job identifier.", + stage=ErrorStage.REQUEST, + ) + return value + + +def _normalize_revision(value: int) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise _error( + "INVALID_PARAMETER", + "The expected job revision must be a non-negative integer.", + stage=ErrorStage.REQUEST, + ) + return value + + +def _normalize_poll_after_ms(value: int | None) -> int | None: + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, int) or value < 0 or value > 300_000: + raise _error( + "INVALID_PARAMETER", + "The polling interval must be an integer from 0 to 300000 milliseconds.", + stage=ErrorStage.REQUEST, + ) + return value + + +def _normalize_next_action(value: NextAction | None) -> NextAction | None: + if value is not None and not isinstance(value, NextAction): + raise _error( + "INVALID_PARAMETER", + "The next action must use the NextAction contract.", + stage=ErrorStage.REQUEST, + ) + return value + + +def _parse_datetime(value: Any, *, required: bool) -> datetime | None: + if value is None and not required: + return None + if not isinstance(value, str): + raise _InvalidStoredJobError("persisted timestamp has an invalid type") + try: + parsed = datetime.fromisoformat(value) + except ValueError as exc: + raise _InvalidStoredJobError("persisted timestamp is invalid") from exc + if parsed.tzinfo is None or parsed.utcoffset() is None: + raise _InvalidStoredJobError("persisted timestamp is not timezone aware") + return parsed + + +def _decode_optional_model( + payload: Any, + model_type: type[ApiError] | type[NextAction], +) -> ApiError | NextAction | None: + if payload is None: + return None + if not isinstance(payload, str): + raise _InvalidStoredJobError("persisted optional model has an invalid type") + document = _strict_json_loads(payload) + if not isinstance(document, dict) or _canonical_json(document) != payload: + raise _InvalidStoredJobError("persisted optional model is not canonical JSON") + return model_type.model_validate(document) + + +def _validate_artifact(artifact: ArtifactDescriptor, *, job_id: str) -> str: + if not isinstance(artifact, ArtifactDescriptor): + raise _InvalidStoredJobError("artifact does not use ArtifactDescriptor") + if artifact.job_id != job_id: + raise _InvalidStoredJobError("artifact job id does not match its owner") + if artifact.sha256 is None or artifact.size_bytes is None or artifact.created_at is None: + raise _InvalidStoredJobError("artifact requires sha256, size_bytes, and created_at") + encoded = _canonical_json(artifact.model_dump(mode="json")) + restored = ArtifactDescriptor.model_validate_json(encoded) + if _canonical_json(restored.model_dump(mode="json")) != encoded: + raise _InvalidStoredJobError("artifact did not survive a canonical round trip") + return encoded + + +def _normalize_artifacts( + job_id: str, + artifacts: Sequence[ArtifactDescriptor], +) -> tuple[ArtifactDescriptor, ...]: + if isinstance(artifacts, str | bytes) or not isinstance(artifacts, Sequence): + raise _error( + "INVALID_PARAMETER", + "Artifacts must be a non-empty sequence of ArtifactDescriptor values.", + stage=ErrorStage.REQUEST, + ) + if not artifacts: + raise _error( + "INVALID_PARAMETER", + "At least one artifact descriptor is required.", + stage=ErrorStage.REQUEST, + ) + + normalized: list[ArtifactDescriptor] = [] + by_id: dict[str, str] = {} + try: + for artifact in artifacts: + encoded = _validate_artifact(artifact, job_id=job_id) + existing = by_id.get(artifact.artifact_id) + if existing is not None: + if existing != encoded: + raise _InvalidStoredJobError( + "one request contains divergent descriptors for an artifact id" + ) + continue + by_id[artifact.artifact_id] = encoded + normalized.append(ArtifactDescriptor.model_validate_json(encoded)) + except (_InvalidStoredJobError, TypeError, ValueError, ValidationError) as exc: + raise _error( + "INVALID_PARAMETER", + "Artifact descriptors must be complete, portable, and owned by the job.", + stage=ErrorStage.REQUEST, + details={"job_id": job_id}, + ) from exc + return tuple(normalized) + + +def _decode_artifacts(payload: Any, *, job_id: str) -> tuple[ArtifactDescriptor, ...]: + if not isinstance(payload, str): + raise _InvalidStoredJobError("persisted artifacts have an invalid type") + document = _strict_json_loads(payload) + if not isinstance(document, list) or _canonical_json(document) != payload: + raise _InvalidStoredJobError("persisted artifacts are not canonical JSON") + + result: list[ArtifactDescriptor] = [] + identities: dict[str, str] = {} + for item in document: + artifact = ArtifactDescriptor.model_validate(item) + encoded = _validate_artifact(artifact, job_id=job_id) + existing = identities.get(artifact.artifact_id) + if existing is not None: + raise _InvalidStoredJobError("persisted artifact ids are not unique") + identities[artifact.artifact_id] = encoded + result.append(artifact) + return tuple(result) + + +def _encode_artifacts(artifacts: Sequence[ArtifactDescriptor]) -> str: + return _canonical_json([artifact.model_dump(mode="json") for artifact in artifacts]) + + +def _merge_artifacts( + current: StoredJob, + additions: Sequence[ArtifactDescriptor], +) -> tuple[tuple[ArtifactDescriptor, ...], bool]: + merged = list(current.artifacts) + by_id = { + artifact.artifact_id: _validate_artifact(artifact, job_id=current.job_id) + for artifact in current.artifacts + } + changed = False + for artifact in additions: + encoded = _validate_artifact(artifact, job_id=current.job_id) + existing = by_id.get(artifact.artifact_id) + if existing is not None: + if existing != encoded: + raise _error( + "JOB_CONFLICT", + "The artifact id is already bound to another descriptor.", + details={ + "job_id": current.job_id, + "artifact_id": artifact.artifact_id, + }, + ) + continue + by_id[artifact.artifact_id] = encoded + merged.append(artifact) + changed = True + return tuple(merged), changed + + +class JobStore: + """SQLite WAL store for immutable JobSpec v2 and atomic lifecycle facts.""" + + def __init__( + self, + data_dir: Path, + *, + clock: Callable[[], datetime] | None = None, + job_id_provider: Callable[[], str] | None = None, + ) -> None: + self._data_dir = Path(data_dir) + self._database_path = self._data_dir / "jobs.sqlite3" + self._clock = clock or (lambda: datetime.now(UTC)) + self._job_id_provider = job_id_provider or (lambda: f"job:{uuid.uuid4().hex}") + try: + self._data_dir.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise _error( + "INTERNAL_ERROR", + "The job store directory could not be initialized.", + stage=ErrorStage.INTERNAL, + retryable=True, + ) from exc + self._initialize_database() + + @property + def database_path(self) -> Path: + """Return the SQLite path for deployment diagnostics.""" + + return self._database_path + + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect(self._database_path, timeout=30.0) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA foreign_keys=ON") + connection.execute("PRAGMA busy_timeout=30000") + return connection + + def _initialize_database(self) -> None: + try: + with self._connect() as connection: + connection.execute("PRAGMA journal_mode=WAL") + connection.execute("PRAGMA synchronous=NORMAL") + connection.execute( + """ + CREATE TABLE IF NOT EXISTS jobs ( + job_id TEXT PRIMARY KEY, + idempotency_key TEXT NOT NULL UNIQUE, + request_fingerprint TEXT NOT NULL, + spec_sha256 TEXT NOT NULL, + spec_json TEXT NOT NULL, + state TEXT NOT NULL, + outcome TEXT, + progress_json TEXT NOT NULL, + summary_json TEXT NOT NULL, + error_json TEXT, + next_action_json TEXT, + revision INTEGER NOT NULL CHECK (revision >= 0), + cancel_requested INTEGER NOT NULL DEFAULT 0 + CHECK (cancel_requested IN (0, 1)), + parent_job_id TEXT, + root_job_id TEXT, + attempt INTEGER NOT NULL DEFAULT 1 CHECK (attempt >= 1), + artifacts_json TEXT NOT NULL DEFAULT '[]', + submitted_at TEXT NOT NULL, + started_at TEXT, + completed_at TEXT, + cancel_requested_at TEXT, + poll_after_ms INTEGER + CHECK (poll_after_ms IS NULL OR + (poll_after_ms >= 0 AND poll_after_ms <= 300000)), + FOREIGN KEY (parent_job_id) REFERENCES jobs (job_id), + FOREIGN KEY (root_job_id) REFERENCES jobs (job_id) + ) + """ + ) + columns = { + row[1] for row in connection.execute("PRAGMA table_info(jobs)").fetchall() + } + migrations = { + "parent_job_id": "ALTER TABLE jobs ADD COLUMN parent_job_id TEXT", + "root_job_id": "ALTER TABLE jobs ADD COLUMN root_job_id TEXT", + "attempt": ("ALTER TABLE jobs ADD COLUMN attempt INTEGER NOT NULL DEFAULT 1"), + "artifacts_json": ( + "ALTER TABLE jobs ADD COLUMN artifacts_json TEXT NOT NULL DEFAULT '[]'" + ), + } + for column, statement in migrations.items(): + if column not in columns: + connection.execute(statement) + connection.execute( + "CREATE INDEX IF NOT EXISTS jobs_state_revision ON jobs (state, revision)" + ) + connection.execute("CREATE INDEX IF NOT EXISTS jobs_parent ON jobs (parent_job_id)") + except sqlite3.Error as exc: + raise _error( + "INTERNAL_ERROR", + "The job store database could not be initialized.", + stage=ErrorStage.INTERNAL, + retryable=True, + ) from exc + + def _now(self) -> datetime: + try: + value = self._clock() + except Exception as exc: + raise _error( + "INTERNAL_ERROR", + "The job store clock failed.", + stage=ErrorStage.INTERNAL, + ) from exc + if not isinstance(value, datetime) or value.tzinfo is None or value.utcoffset() is None: + raise _error( + "INTERNAL_ERROR", + "The job store clock must return a timezone-aware datetime.", + stage=ErrorStage.INTERNAL, + ) + return value + + def _new_job_id(self) -> str: + try: + value = self._job_id_provider() + except Exception as exc: + raise _error( + "INTERNAL_ERROR", + "The job id provider failed.", + stage=ErrorStage.INTERNAL, + retryable=True, + ) from exc + if not isinstance(value, str) or _JOB_ID_PATTERN.fullmatch(value) is None: + raise _error( + "INTERNAL_ERROR", + "The job id provider returned an invalid identifier.", + stage=ErrorStage.INTERNAL, + ) + return value + + @staticmethod + def _decode_row(row: sqlite3.Row, *, created: bool = False) -> StoredJob: + try: + job_id = row["job_id"] + idempotency_key = row["idempotency_key"] + request_fingerprint = row["request_fingerprint"] + spec_sha256 = row["spec_sha256"] + spec_json = row["spec_json"] + state_value = row["state"] + outcome_value = row["outcome"] + revision = row["revision"] + cancel_value = row["cancel_requested"] + parent_job_id = row["parent_job_id"] + root_job_id = row["root_job_id"] + attempt = row["attempt"] + poll_after_ms = row["poll_after_ms"] + + if not isinstance(job_id, str) or _JOB_ID_PATTERN.fullmatch(job_id) is None: + raise _InvalidStoredJobError("persisted job id is invalid") + if ( + not isinstance(idempotency_key, str) + or _IDEMPOTENCY_KEY_PATTERN.fullmatch(idempotency_key) is None + ): + raise _InvalidStoredJobError("persisted idempotency key is invalid") + if ( + not isinstance(request_fingerprint, str) + or _SHA256_PATTERN.fullmatch(request_fingerprint) is None + or not isinstance(spec_sha256, str) + or _SHA256_PATTERN.fullmatch(spec_sha256) is None + ): + raise _InvalidStoredJobError("persisted request digest is invalid") + if not isinstance(spec_json, str): + raise _InvalidStoredJobError("persisted JobSpec has an invalid type") + + spec_document = _strict_json_loads(spec_json) + if not isinstance(spec_document, dict) or _canonical_json(spec_document) != spec_json: + raise _InvalidStoredJobError("persisted JobSpec is not canonical JSON") + actual_spec_sha256 = hashlib.sha256(spec_json.encode("utf-8")).hexdigest() + if spec_sha256 != actual_spec_sha256 or request_fingerprint != spec_sha256: + raise _InvalidStoredJobError("persisted JobSpec identity is inconsistent") + spec = JobSpecV2.model_validate(spec_document) + + if isinstance(attempt, bool) or not isinstance(attempt, int) or attempt < 1: + raise _InvalidStoredJobError("persisted retry attempt is invalid") + if parent_job_id is None: + if root_job_id is not None or attempt != 1: + raise _InvalidStoredJobError("persisted root lineage is invalid") + elif not isinstance(parent_job_id, str) or not isinstance(root_job_id, str): + raise _InvalidStoredJobError("persisted retry lineage is invalid") + elif ( + _JOB_ID_PATTERN.fullmatch(parent_job_id) is None + or _JOB_ID_PATTERN.fullmatch(root_job_id) is None + or job_id in {parent_job_id, root_job_id} + or attempt < 2 + ): + raise _InvalidStoredJobError("persisted retry lineage is invalid") + + artifacts = _decode_artifacts(row["artifacts_json"], job_id=job_id) + + if not isinstance(state_value, str): + raise _InvalidStoredJobError("persisted state has an invalid type") + state = JobState(state_value) + outcome = None if outcome_value is None else JobOutcome(outcome_value) + if isinstance(revision, bool) or not isinstance(revision, int) or revision < 0: + raise _InvalidStoredJobError("persisted revision is invalid") + + progress_json = row["progress_json"] + if not isinstance(progress_json, str): + raise _InvalidStoredJobError("persisted progress has an invalid type") + progress_document = _strict_json_loads(progress_json) + if ( + not isinstance(progress_document, dict) + or _canonical_json(progress_document) != progress_json + ): + raise _InvalidStoredJobError("persisted progress is not canonical JSON") + progress = JobProgress.model_validate(progress_document) + if progress.revision != revision: + raise _InvalidStoredJobError("progress and row revisions diverge") + + summary_json = row["summary_json"] + if not isinstance(summary_json, str): + raise _InvalidStoredJobError("persisted summary has an invalid type") + summary = _strict_json_loads(summary_json) + if not isinstance(summary, dict) or _canonical_json(summary) != summary_json: + raise _InvalidStoredJobError("persisted summary is not canonical JSON") + if summary != _summary_for_spec(spec): + raise _InvalidStoredJobError("persisted summary diverges from JobSpec") + + error = _decode_optional_model(row["error_json"], ApiError) + next_action = _decode_optional_model(row["next_action_json"], NextAction) + if error is not None and not isinstance(error, ApiError): + raise _InvalidStoredJobError("persisted error has the wrong model type") + if next_action is not None and not isinstance(next_action, NextAction): + raise _InvalidStoredJobError("persisted next action has the wrong model type") + + submitted_at = _parse_datetime(row["submitted_at"], required=True) + started_at = _parse_datetime(row["started_at"], required=False) + completed_at = _parse_datetime(row["completed_at"], required=False) + cancel_requested_at = _parse_datetime(row["cancel_requested_at"], required=False) + if submitted_at is None: # required=True; narrows the type for Pydantic + raise _InvalidStoredJobError("persisted submission timestamp is missing") + if progress.updated_at is None or progress.updated_at < submitted_at: + raise _InvalidStoredJobError( + "persisted progress timestamp is missing or predates submission" + ) + if cancel_value not in {0, 1}: + raise _InvalidStoredJobError("persisted cancellation flag is invalid") + cancel_requested = bool(cancel_value) + if cancel_requested != (cancel_requested_at is not None): + raise _InvalidStoredJobError("cancellation flag and timestamp diverge") + if cancel_requested_at is not None and cancel_requested_at < submitted_at: + raise _InvalidStoredJobError("cancellation predates submission") + + if state in _TERMINAL_STATES: + if completed_at is None: + raise _InvalidStoredJobError("terminal jobs require a completion timestamp") + if progress.updated_at > completed_at: + raise _InvalidStoredJobError("persisted progress timestamp follows completion") + if cancel_requested_at is not None and cancel_requested_at > completed_at: + raise _InvalidStoredJobError( + "persisted cancellation timestamp follows completion" + ) + elif completed_at is not None: + raise _InvalidStoredJobError("non-terminal job has a completion timestamp") + if state is JobState.RUNNING and started_at is None: + raise _InvalidStoredJobError("running jobs require a start timestamp") + if state is JobState.QUEUED and started_at is not None: + raise _InvalidStoredJobError("queued jobs cannot have a start timestamp") + if state is JobState.FAILED: + if error is None: + raise _InvalidStoredJobError("failed job has no error") + elif error is not None: + raise _InvalidStoredJobError("only failed jobs may persist an error") + + view = AgentJobView( + job_id=job_id, + parent_job_id=parent_job_id, + root_job_id=root_job_id, + attempt=attempt, + state=state, + outcome=outcome, + progress=progress, + summary=summary, + artifacts=list(artifacts[:32]), + artifact_count=len(artifacts), + error=error, + next_action=next_action, + cancellation_requested=cancel_requested, + cancellable=state not in _TERMINAL_STATES, + submitted_at=submitted_at, + started_at=started_at, + completed_at=completed_at, + poll_after_ms=poll_after_ms, + ) + except ( + _InvalidStoredJobError, + KeyError, + TypeError, + ValueError, + ValidationError, + ) as exc: + raise _error( + "INTERNAL_ERROR", + "A persisted agent job is invalid.", + stage=ErrorStage.INTERNAL, + ) from exc + return StoredJob( + spec=spec, + view=view, + idempotency_key=idempotency_key, + request_fingerprint=request_fingerprint, + artifacts=artifacts, + cancel_requested=cancel_requested, + cancel_requested_at=cancel_requested_at, + created=created, + ) + + def create( + self, + spec: JobSpecV2, + *, + idempotency_key: str, + request_fingerprint: str | None = None, + parent_job_id: str | None = None, + ) -> StoredJob: + """Atomically create a queued job or replay an identical submission. + + Reusing ``idempotency_key`` with another canonical JobSpec v2 raises + ``JOB_CONFLICT``. A supplied fingerprint is checked against the full + canonical spec instead of being trusted as an arbitrary caller label. + """ + + key = _normalize_idempotency_key(idempotency_key) + normalized_parent_id = ( + _normalize_job_id(parent_job_id) if parent_job_id is not None else None + ) + spec_json, detached_spec = _encode_spec(spec) + computed_fingerprint = hashlib.sha256(spec_json.encode("utf-8")).hexdigest() + if request_fingerprint is not None: + supplied_fingerprint = _normalize_fingerprint(request_fingerprint) + if supplied_fingerprint != computed_fingerprint: + raise _error( + "INVALID_PARAMETER", + "The request fingerprint does not match JobSpec v2.", + stage=ErrorStage.REQUEST, + details={"expected_request_fingerprint": computed_fingerprint}, + ) + + submitted_at = self._now() + progress = JobProgress( + phase="queued", + fraction=0.0, + revision=0, + updated_at=submitted_at, + ) + progress_json = _canonical_json(progress.model_dump(mode="json")) + summary_json = _canonical_json(_summary_for_spec(detached_spec)) + + try: + with self._connect() as connection: + connection.execute("BEGIN IMMEDIATE") + existing = connection.execute( + f"{_SELECT_JOB} WHERE idempotency_key = ?", + (key,), + ).fetchone() + if existing is not None: + stored = self._decode_row(existing) + if ( + stored.request_fingerprint != computed_fingerprint + or _canonical_json(stored.spec.model_dump(mode="json")) != spec_json + or stored.view.parent_job_id != normalized_parent_id + ): + raise _error( + "JOB_CONFLICT", + "The idempotency key is already bound to another job request.", + stage=ErrorStage.ADMISSION, + details={"job_id": stored.job_id}, + ) + return stored + + root_job_id: str | None = None + attempt = 1 + if normalized_parent_id is not None: + parent_row = connection.execute( + f"{_SELECT_JOB} WHERE job_id = ?", + (normalized_parent_id,), + ).fetchone() + if parent_row is None: + raise _error( + "JOB_NOT_FOUND", + "The requested parent job does not exist.", + stage=ErrorStage.ADMISSION, + details={"parent_job_id": normalized_parent_id}, + ) + parent = self._decode_row(parent_row) + if parent.view.state not in _TERMINAL_STATES: + raise _error( + "JOB_CONFLICT", + "A retry can only be created from a terminal parent job.", + stage=ErrorStage.ADMISSION, + details={ + "parent_job_id": parent.job_id, + "parent_state": parent.view.state.value, + }, + ) + if ( + parent.spec.plan_id != detached_spec.plan_id + or parent.spec.kind != detached_spec.kind + ): + raise _error( + "JOB_CONFLICT", + "A retry must retain its parent's plan id and job kind.", + stage=ErrorStage.ADMISSION, + details={"parent_job_id": parent.job_id}, + ) + if ( + parent.view.completed_at is not None + and submitted_at < parent.view.completed_at + ): + raise _error( + "INTERNAL_ERROR", + "The job store clock moved backwards before retry creation.", + stage=ErrorStage.INTERNAL, + ) + root_job_id = parent.view.root_job_id or parent.job_id + attempt = parent.view.attempt + 1 + + inserted_job_id: str | None = None + for _ in range(8): + candidate = self._new_job_id() + try: + connection.execute( + """ + INSERT INTO jobs ( + job_id, + idempotency_key, + request_fingerprint, + spec_sha256, + spec_json, + state, + outcome, + progress_json, + summary_json, + error_json, + next_action_json, + revision, + cancel_requested, + parent_job_id, + root_job_id, + attempt, + artifacts_json, + submitted_at, + started_at, + completed_at, + cancel_requested_at, + poll_after_ms + ) VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, NULL, NULL, + 0, 0, ?, ?, ?, '[]', ?, NULL, NULL, NULL, NULL) + """, + ( + candidate, + key, + computed_fingerprint, + computed_fingerprint, + spec_json, + JobState.QUEUED.value, + progress_json, + summary_json, + normalized_parent_id, + root_job_id, + attempt, + submitted_at.isoformat(), + ), + ) + except sqlite3.IntegrityError: + collision = connection.execute( + "SELECT 1 FROM jobs WHERE job_id = ?", + (candidate,), + ).fetchone() + if collision is not None: + continue + raise + inserted_job_id = candidate + break + if inserted_job_id is None: + raise _error( + "INTERNAL_ERROR", + "A unique job id could not be allocated.", + stage=ErrorStage.INTERNAL, + retryable=True, + ) + + row = connection.execute( + f"{_SELECT_JOB} WHERE job_id = ?", + (inserted_job_id,), + ).fetchone() + except JobStoreError: + raise + except sqlite3.Error as exc: + raise _error( + "INTERNAL_ERROR", + "The agent job could not be persisted.", + stage=ErrorStage.INTERNAL, + retryable=True, + ) from exc + if row is None: + raise _error( + "INTERNAL_ERROR", + "The agent job was unavailable after persistence.", + stage=ErrorStage.INTERNAL, + retryable=True, + ) + return self._decode_row(row, created=True) + + def get(self, job_id: str) -> StoredJob: + """Load one fresh, fully validated job snapshot by id.""" + + normalized = _normalize_job_id(job_id) + try: + with self._connect() as connection: + row = connection.execute( + f"{_SELECT_JOB} WHERE job_id = ?", + (normalized,), + ).fetchone() + except sqlite3.Error as exc: + raise _error( + "INTERNAL_ERROR", + "The job store could not be read.", + stage=ErrorStage.INTERNAL, + retryable=True, + ) from exc + if row is None: + raise _error( + "JOB_NOT_FOUND", + "No agent job has the requested id.", + details={"job_id": normalized}, + ) + return self._decode_row(row) + + def get_spec(self, job_id: str) -> JobSpecV2: + """Load a detached copy of the immutable JobSpec v2.""" + + return self.get(job_id).spec + + def get_by_idempotency_key(self, idempotency_key: str) -> StoredJob: + """Recover a previously submitted job from its idempotency key.""" + + key = _normalize_idempotency_key(idempotency_key) + try: + with self._connect() as connection: + row = connection.execute( + f"{_SELECT_JOB} WHERE idempotency_key = ?", + (key,), + ).fetchone() + except sqlite3.Error as exc: + raise _error( + "INTERNAL_ERROR", + "The job store could not be read.", + stage=ErrorStage.INTERNAL, + retryable=True, + ) from exc + if row is None: + raise _error( + "JOB_NOT_FOUND", + "No agent job has the requested idempotency key.", + ) + return self._decode_row(row) + + def list_active(self) -> list[StoredJob]: + """Return queued and running jobs in deterministic submission order.""" + + try: + with self._connect() as connection: + rows = connection.execute( + f"{_SELECT_JOB} WHERE state IN (?, ?) ORDER BY submitted_at ASC, job_id ASC", + (JobState.QUEUED.value, JobState.RUNNING.value), + ).fetchall() + except sqlite3.Error as exc: + raise _error( + "INTERNAL_ERROR", + "Active jobs could not be listed.", + stage=ErrorStage.INTERNAL, + retryable=True, + ) from exc + return [self._decode_row(row) for row in rows] + + @staticmethod + def _state(value: JobState | str) -> JobState: + try: + return value if isinstance(value, JobState) else JobState(value) + except (TypeError, ValueError) as exc: + raise _error( + "INVALID_PARAMETER", + "The target job state is not supported.", + stage=ErrorStage.REQUEST, + ) from exc + + @staticmethod + def _outcome(value: JobOutcome | str | None) -> JobOutcome | None: + if value is None or isinstance(value, JobOutcome): + return value + try: + return JobOutcome(value) + except (TypeError, ValueError) as exc: + raise _error( + "INVALID_PARAMETER", + "The job outcome is not supported.", + stage=ErrorStage.REQUEST, + ) from exc + + @staticmethod + def _validate_terminal_fields( + state: JobState, + outcome: JobOutcome | None, + error: ApiError | None, + ) -> None: + if state is JobState.COMPLETED: + if outcome is None or error is not None: + raise _error( + "INVALID_PARAMETER", + "A completed job requires an outcome and cannot include an error.", + stage=ErrorStage.REQUEST, + ) + return + if outcome is not None: + raise _error( + "INVALID_PARAMETER", + "Only completed jobs may include an outcome.", + stage=ErrorStage.REQUEST, + ) + if state is JobState.FAILED: + if not isinstance(error, ApiError): + raise _error( + "INVALID_PARAMETER", + "A failed job requires a structured ApiError.", + stage=ErrorStage.REQUEST, + ) + elif error is not None: + raise _error( + "INVALID_PARAMETER", + "Only failed jobs may include an error.", + stage=ErrorStage.REQUEST, + ) + + @staticmethod + def _next_progress( + current: JobProgress, + supplied: JobProgress | None, + *, + state: JobState, + revision: int, + updated_at: datetime, + ) -> JobProgress: + if supplied is not None and not isinstance(supplied, JobProgress): + raise _error( + "INVALID_PARAMETER", + "Job progress must use the JobProgress contract.", + stage=ErrorStage.REQUEST, + ) + + if supplied is None: + document = current.model_dump() + document["phase"] = state.value + if state is JobState.COMPLETED: + document["fraction"] = 1.0 + if state in _TERMINAL_STATES: + document["eta_seconds"] = None + else: + document = supplied.model_dump() + + if state is JobState.COMPLETED and document["fraction"] != 1.0: + raise _error( + "INVALID_PARAMETER", + "Completed job progress must have fraction 1.0.", + stage=ErrorStage.REQUEST, + ) + if document["fraction"] < current.fraction: + raise _error( + "INVALID_JOB_TRANSITION", + "Job progress cannot move backwards.", + details={ + "current_fraction": current.fraction, + "requested_fraction": document["fraction"], + }, + ) + + old_completed = current.completed_items + new_completed = document["completed_items"] + if old_completed is not None and new_completed is None: + document["completed_items"] = old_completed + new_completed = old_completed + if ( + old_completed is not None + and new_completed is not None + and new_completed < old_completed + ): + raise _error( + "INVALID_JOB_TRANSITION", + "Completed item count cannot move backwards.", + ) + if current.total_items is not None: + if document["total_items"] is None: + document["total_items"] = current.total_items + elif document["total_items"] != current.total_items: + raise _error( + "INVALID_JOB_TRANSITION", + "A job's total item count cannot change once known.", + ) + + document["revision"] = revision + document["updated_at"] = updated_at + if state in _TERMINAL_STATES: + document["eta_seconds"] = None + try: + return JobProgress.model_validate(document) + except ValidationError as exc: + raise _error( + "INVALID_PARAMETER", + "The requested job progress is invalid.", + stage=ErrorStage.REQUEST, + ) from exc + + @staticmethod + def _encode_optional_model(value: ApiError | NextAction | None) -> str | None: + if value is None: + return None + try: + return _canonical_json(value.model_dump(mode="json")) + except (_InvalidStoredJobError, TypeError, ValueError) as exc: + raise _error( + "INVALID_PARAMETER", + "The structured job metadata must be finite JSON.", + stage=ErrorStage.REQUEST, + ) from exc + + @staticmethod + def _assert_revision(current: StoredJob, expected_revision: int) -> None: + expected = _normalize_revision(expected_revision) + if current.revision != expected: + raise _error( + "JOB_CONFLICT", + "The job changed after the caller's last revision.", + details={ + "job_id": current.job_id, + "expected_revision": expected, + "current_revision": current.revision, + }, + ) + + @staticmethod + def _updated_timestamps( + current: StoredJob, + target_state: JobState, + now: datetime, + ) -> tuple[datetime | None, datetime | None]: + started_at = current.view.started_at + if target_state is JobState.RUNNING and started_at is None: + started_at = now + completed_at = now if target_state in _TERMINAL_STATES else None + return started_at, completed_at + + @staticmethod + def _assert_clock_order(current: StoredJob, now: datetime) -> None: + baseline = current.view.started_at or current.view.submitted_at + if current.view.progress.updated_at is not None: + baseline = max(baseline, current.view.progress.updated_at) + if current.cancel_requested_at is not None: + baseline = max(baseline, current.cancel_requested_at) + if now < baseline: + raise _error( + "INTERNAL_ERROR", + "The job store clock moved backwards.", + stage=ErrorStage.INTERNAL, + ) + + @staticmethod + def _replace_row( + connection: sqlite3.Connection, + current: StoredJob, + *, + state: JobState, + outcome: JobOutcome | None, + progress: JobProgress, + error: ApiError | None, + next_action: NextAction | None, + started_at: datetime | None, + completed_at: datetime | None, + poll_after_ms: int | None, + artifacts: Sequence[ArtifactDescriptor] | None = None, + cancel_requested: bool | None = None, + cancel_requested_at: datetime | None = None, + ) -> None: + requested = current.cancel_requested if cancel_requested is None else cancel_requested + requested_at = ( + current.cancel_requested_at if cancel_requested is None else cancel_requested_at + ) + stored_artifacts = current.artifacts if artifacts is None else artifacts + cursor = connection.execute( + """ + UPDATE jobs + SET state = ?, + outcome = ?, + progress_json = ?, + error_json = ?, + next_action_json = ?, + revision = ?, + cancel_requested = ?, + artifacts_json = ?, + started_at = ?, + completed_at = ?, + cancel_requested_at = ?, + poll_after_ms = ? + WHERE job_id = ? AND revision = ? + """, + ( + state.value, + outcome.value if outcome is not None else None, + _canonical_json(progress.model_dump(mode="json")), + JobStore._encode_optional_model(error), + JobStore._encode_optional_model(next_action), + progress.revision, + int(requested), + _encode_artifacts(stored_artifacts), + started_at.isoformat() if started_at is not None else None, + completed_at.isoformat() if completed_at is not None else None, + requested_at.isoformat() if requested_at is not None else None, + poll_after_ms, + current.job_id, + current.revision, + ), + ) + if cursor.rowcount != 1: + raise _error( + "JOB_CONFLICT", + "The job changed during the atomic update.", + details={"job_id": current.job_id}, + ) + + def transition( + self, + job_id: str, + *, + expected_revision: int, + state: JobState | str, + progress: JobProgress | None = None, + outcome: JobOutcome | str | None = None, + error: ApiError | None = None, + next_action: NextAction | None = None, + poll_after_ms: int | None = None, + artifacts: Sequence[ArtifactDescriptor] | None = None, + ) -> StoredJob: + """Atomically apply one legal lifecycle transition. + + The caller supplies the revision it observed. The store owns the next + revision and progress timestamp, preventing workers from overwriting a + newer cancellation or terminal result. + """ + + normalized_id = _normalize_job_id(job_id) + target_state = self._state(state) + normalized_outcome = self._outcome(outcome) + normalized_next_action = _normalize_next_action(next_action) + normalized_poll_after_ms = _normalize_poll_after_ms(poll_after_ms) + normalized_artifacts: tuple[ArtifactDescriptor, ...] = () + if artifacts is not None: + if isinstance(artifacts, str | bytes) or not isinstance(artifacts, Sequence): + normalized_artifacts = _normalize_artifacts(normalized_id, artifacts) + elif artifacts: + normalized_artifacts = _normalize_artifacts(normalized_id, artifacts) + self._validate_terminal_fields(target_state, normalized_outcome, error) + + try: + with self._connect() as connection: + connection.execute("BEGIN IMMEDIATE") + row = connection.execute( + f"{_SELECT_JOB} WHERE job_id = ?", + (normalized_id,), + ).fetchone() + if row is None: + raise _error( + "JOB_NOT_FOUND", + "No agent job has the requested id.", + details={"job_id": normalized_id}, + ) + current = self._decode_row(row) + self._assert_revision(current, expected_revision) + if target_state not in _ALLOWED_TRANSITIONS[current.view.state]: + raise _error( + "INVALID_JOB_TRANSITION", + "The requested job state transition is not legal.", + details={ + "job_id": current.job_id, + "current_state": current.view.state.value, + "requested_state": target_state.value, + }, + ) + if ( + current.cancel_requested + and current.view.state is JobState.QUEUED + and target_state is JobState.RUNNING + ): + raise _error( + "JOB_CONFLICT", + "A queued job with a cancellation request cannot be started.", + details={"job_id": current.job_id}, + ) + + merged_artifacts = current.artifacts + if normalized_artifacts: + merged_artifacts, _ = _merge_artifacts(current, normalized_artifacts) + + now = self._now() + self._assert_clock_order(current, now) + next_revision = current.revision + 1 + next_progress = self._next_progress( + current.view.progress, + progress, + state=target_state, + revision=next_revision, + updated_at=now, + ) + started_at, completed_at = self._updated_timestamps(current, target_state, now) + self._replace_row( + connection, + current, + state=target_state, + outcome=normalized_outcome, + progress=next_progress, + error=error, + next_action=normalized_next_action, + started_at=started_at, + completed_at=completed_at, + poll_after_ms=normalized_poll_after_ms, + artifacts=merged_artifacts, + ) + updated_row = connection.execute( + f"{_SELECT_JOB} WHERE job_id = ?", + (normalized_id,), + ).fetchone() + except JobStoreError: + raise + except sqlite3.Error as exc: + raise _error( + "INTERNAL_ERROR", + "The job state could not be updated.", + stage=ErrorStage.INTERNAL, + retryable=True, + ) from exc + if updated_row is None: + raise _error( + "INTERNAL_ERROR", + "The job disappeared after its state update.", + stage=ErrorStage.INTERNAL, + ) + return self._decode_row(updated_row) + + def update_progress( + self, + job_id: str, + *, + expected_revision: int, + progress: JobProgress, + next_action: NextAction | None = None, + poll_after_ms: int | None = None, + ) -> StoredJob: + """Atomically update progress for a queued or running job.""" + + normalized_id = _normalize_job_id(job_id) + normalized_next_action = _normalize_next_action(next_action) + normalized_poll_after_ms = _normalize_poll_after_ms(poll_after_ms) + try: + with self._connect() as connection: + connection.execute("BEGIN IMMEDIATE") + row = connection.execute( + f"{_SELECT_JOB} WHERE job_id = ?", + (normalized_id,), + ).fetchone() + if row is None: + raise _error( + "JOB_NOT_FOUND", + "No agent job has the requested id.", + details={"job_id": normalized_id}, + ) + current = self._decode_row(row) + self._assert_revision(current, expected_revision) + if current.view.state in _TERMINAL_STATES: + raise _error( + "INVALID_JOB_TRANSITION", + "Terminal job progress is immutable.", + details={"job_id": current.job_id}, + ) + + now = self._now() + self._assert_clock_order(current, now) + next_progress = self._next_progress( + current.view.progress, + progress, + state=current.view.state, + revision=current.revision + 1, + updated_at=now, + ) + self._replace_row( + connection, + current, + state=current.view.state, + outcome=None, + progress=next_progress, + error=None, + next_action=normalized_next_action, + started_at=current.view.started_at, + completed_at=None, + poll_after_ms=normalized_poll_after_ms, + ) + updated_row = connection.execute( + f"{_SELECT_JOB} WHERE job_id = ?", + (normalized_id,), + ).fetchone() + except JobStoreError: + raise + except sqlite3.Error as exc: + raise _error( + "INTERNAL_ERROR", + "Job progress could not be updated.", + stage=ErrorStage.INTERNAL, + retryable=True, + ) from exc + if updated_row is None: + raise _error( + "INTERNAL_ERROR", + "The job disappeared after its progress update.", + stage=ErrorStage.INTERNAL, + ) + return self._decode_row(updated_row) + + def attach_artifacts( + self, + job_id: str, + *, + expected_revision: int, + artifacts: Sequence[ArtifactDescriptor], + ) -> StoredJob: + """Atomically attach complete descriptors to an active job. + + Artifact ids are immutable within a job. Reattaching byte-for-byte + equivalent descriptors is an idempotent no-op, including a transport + retry that still carries the revision from before the first attach. + """ + + normalized_id = _normalize_job_id(job_id) + _normalize_revision(expected_revision) + normalized_artifacts = _normalize_artifacts(normalized_id, artifacts) + try: + with self._connect() as connection: + connection.execute("BEGIN IMMEDIATE") + row = connection.execute( + f"{_SELECT_JOB} WHERE job_id = ?", + (normalized_id,), + ).fetchone() + if row is None: + raise _error( + "JOB_NOT_FOUND", + "No agent job has the requested id.", + details={"job_id": normalized_id}, + ) + current = self._decode_row(row) + if current.view.state in _TERMINAL_STATES: + raise _error( + "INVALID_JOB_TRANSITION", + "Artifacts can only be attached while a job is active.", + details={"job_id": current.job_id}, + ) + + merged_artifacts, changed = _merge_artifacts(current, normalized_artifacts) + if not changed: + return current + self._assert_revision(current, expected_revision) + + now = self._now() + self._assert_clock_order(current, now) + next_progress = current.view.progress.model_copy( + update={"revision": current.revision + 1, "updated_at": now} + ) + self._replace_row( + connection, + current, + state=current.view.state, + outcome=None, + progress=next_progress, + error=None, + next_action=current.view.next_action, + started_at=current.view.started_at, + completed_at=None, + poll_after_ms=current.view.poll_after_ms, + artifacts=merged_artifacts, + ) + updated_row = connection.execute( + f"{_SELECT_JOB} WHERE job_id = ?", + (normalized_id,), + ).fetchone() + except JobStoreError: + raise + except sqlite3.Error as exc: + raise _error( + "INTERNAL_ERROR", + "Artifact descriptors could not be attached to the job.", + stage=ErrorStage.INTERNAL, + retryable=True, + ) from exc + if updated_row is None: + raise _error( + "INTERNAL_ERROR", + "The job disappeared after its artifact update.", + stage=ErrorStage.INTERNAL, + ) + return self._decode_row(updated_row) + + def request_cancel( + self, + job_id: str, + *, + expected_revision: int | None = None, + ) -> StoredJob: + """Persist a cooperative cancellation request without faking success. + + Queued/running state is retained until a scheduler or worker observes + the flag and performs a legal transition to ``cancelled``. Repeating + an already-recorded request is idempotent, including a retry carrying + the pre-request revision. + """ + + normalized_id = _normalize_job_id(job_id) + if expected_revision is not None: + _normalize_revision(expected_revision) + try: + with self._connect() as connection: + connection.execute("BEGIN IMMEDIATE") + row = connection.execute( + f"{_SELECT_JOB} WHERE job_id = ?", + (normalized_id,), + ).fetchone() + if row is None: + raise _error( + "JOB_NOT_FOUND", + "No agent job has the requested id.", + details={"job_id": normalized_id}, + ) + current = self._decode_row(row) + if current.cancel_requested or current.view.state is JobState.CANCELLED: + return current + if current.view.state in {JobState.COMPLETED, JobState.FAILED}: + raise _error( + "JOB_CANCEL_UNSUPPORTED", + "A completed or failed job can no longer be cancelled.", + details={ + "job_id": current.job_id, + "state": current.view.state.value, + }, + ) + if expected_revision is not None: + self._assert_revision(current, expected_revision) + + now = self._now() + self._assert_clock_order(current, now) + next_progress = current.view.progress.model_copy( + update={"revision": current.revision + 1, "updated_at": now} + ) + self._replace_row( + connection, + current, + state=current.view.state, + outcome=None, + progress=next_progress, + error=None, + next_action=current.view.next_action, + started_at=current.view.started_at, + completed_at=None, + poll_after_ms=current.view.poll_after_ms, + cancel_requested=True, + cancel_requested_at=now, + ) + updated_row = connection.execute( + f"{_SELECT_JOB} WHERE job_id = ?", + (normalized_id,), + ).fetchone() + except JobStoreError: + raise + except sqlite3.Error as exc: + raise _error( + "INTERNAL_ERROR", + "The cancellation request could not be persisted.", + stage=ErrorStage.INTERNAL, + retryable=True, + ) from exc + if updated_row is None: + raise _error( + "INTERNAL_ERROR", + "The job disappeared after its cancellation request.", + stage=ErrorStage.INTERNAL, + ) + return self._decode_row(updated_row) + + +__all__ = [ + "JobStore", + "JobStoreError", + "StoredJob", + "compute_request_fingerprint", +] diff --git a/hhtools/services/jobs.py b/hhtools/services/jobs.py new file mode 100644 index 00000000..9cc0a55d --- /dev/null +++ b/hhtools/services/jobs.py @@ -0,0 +1,1210 @@ +"""Persistent, idempotent Agent job lifecycle orchestration. + +``JobManager`` is the control-plane bridge between immutable retarget plans, +the shared admission scheduler, an injected executor, and managed artifacts. +It never implements IK, calibration, or robot mathematics. A solver adapter +receives the exact JobSpec v2 plus progress/cancellation callbacks and returns +one explicit semantic outcome. +""" + +from __future__ import annotations + +import json +import logging +import threading +from collections.abc import Iterator, Mapping, Sequence +from contextlib import contextmanager +from dataclasses import dataclass, field +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Protocol + +from pydantic import ValidationError + +from hhtools.contracts import ( + AgentJobView, + ApiError, + ArtifactDescriptor, + ErrorStage, + EvaluationReport, + FailureItem, + FailureReport, + JobManifest, + JobOutcome, + JobProgress, + JobQueueView, + JobSpecV2, + JobState, + NextAction, + SchedulerMode, +) + +from .admission import ( + AdmissionClosedError, + AdmissionQueueFullError, + AdmissionScheduler, + ScheduledHandle, +) +from .artifacts import ArtifactStore, ArtifactStoreError, StoredArtifact +from .job_store import JobStore, JobStoreError, StoredJob +from .retarget import RetargetService, RetargetServiceError + +_log = logging.getLogger(__name__) +_ACTIVE_STATES = frozenset({JobState.QUEUED, JobState.RUNNING}) +_TERMINAL_STATES = frozenset({JobState.COMPLETED, JobState.FAILED, JobState.CANCELLED}) +_MAX_COMPACT_SUMMARY_BYTES = 32 * 1024 +_DEFAULT_POLL_AFTER_MS = 1_500 +_MUTATION_LOCK_STRIPES = 64 +_MAX_ARTIFACT_PAGE_SIZE = 500 + + +class JobManagerError(RuntimeError): + """Expected job-service failure with a transport-neutral error body.""" + + def __init__(self, error: ApiError) -> None: + self.error = error + super().__init__(f"{error.code}: {error.message}") + + @property + def api_error(self) -> ApiError: + return self.error + + @property + def code(self) -> str: + return self.error.code + + +class JobCancelledError(RuntimeError): + """Cooperative executor signal acknowledging a cancellation request.""" + + +class JobExecutionError(RuntimeError): + """Structured expected failure raised by an injected executor.""" + + def __init__(self, error: ApiError) -> None: + self.error = error + super().__init__(f"{error.code}: {error.message}") + + +@dataclass(frozen=True, slots=True) +class JobExecutionResult: + """Small terminal result returned by a solver adapter. + + Large outputs must be published through ``JobExecutionContext`` and are + represented here only by managed ``ArtifactDescriptor`` instances. + """ + + outcome: JobOutcome + summary: Mapping[str, str | int | float | bool | None] = field(default_factory=dict) + evaluation_summary: str | None = None + evaluation_metrics: Mapping[str, Any] = field(default_factory=dict) + evaluation_checks: Sequence[Mapping[str, Any]] = field(default_factory=tuple) + failures: Sequence[FailureItem | Mapping[str, Any]] = field(default_factory=tuple) + execution_provenance: Mapping[str, Any] = field(default_factory=dict) + next_action: NextAction | None = None + + +class JobExecutor(Protocol): + """Injected solver adapter; implementations remain outside JobManager.""" + + def __call__( + self, + spec: JobSpecV2, + context: JobExecutionContext, + ) -> JobExecutionResult: ... + + +def _error( + code: str, + message: str, + *, + stage: ErrorStage = ErrorStage.EXECUTION, + retryable: bool = False, + details: Mapping[str, Any] | None = None, + next_action: NextAction | None = None, +) -> JobManagerError: + return JobManagerError( + ApiError( + code=code, + message=message, + retryable=retryable, + stage=stage, + details=dict(details or {}), + next_action=next_action, + ) + ) + + +def _wrap_service_error(error: ApiError) -> JobManagerError: + # JSON round-tripping detaches nested ``details``/``parameters`` mappings + # before the error crosses another adapter boundary. + return JobManagerError(ApiError.model_validate_json(error.model_dump_json())) + + +def _compact_summary( + base: Mapping[str, Any], + additions: Mapping[str, str | int | float | bool | None], +) -> dict[str, Any]: + summary = dict(base) + for key, value in additions.items(): + if not isinstance(key, str) or not key or len(key) > 128: + raise _error( + "INVALID_PARAMETER", + "Execution summary keys must be short strings.", + stage=ErrorStage.EXECUTION, + ) + if isinstance(value, str) and len(value) > 4_096: + raise _error( + "INVALID_PARAMETER", + "Execution summary strings are too large.", + stage=ErrorStage.EXECUTION, + ) + summary[key] = value + try: + encoded = json.dumps( + summary, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ) + except (TypeError, ValueError, OverflowError) as exc: + raise _error( + "INVALID_PARAMETER", + "Execution summary values must be finite compact JSON scalars.", + stage=ErrorStage.EXECUTION, + ) from exc + if len(encoded.encode("utf-8")) > _MAX_COMPACT_SUMMARY_BYTES: + raise _error( + "INVALID_PARAMETER", + "The compact execution summary is too large.", + stage=ErrorStage.EXECUTION, + ) + return summary + + +def _failure_item(value: FailureItem | Mapping[str, Any]) -> FailureItem: + try: + if isinstance(value, FailureItem): + return FailureItem.model_validate_json(value.model_dump_json()) + return FailureItem.model_validate(dict(value)) + except (TypeError, ValueError, ValidationError) as exc: + raise _error( + "INVALID_PARAMETER", + "The executor returned an invalid structured failure item.", + stage=ErrorStage.EXECUTION, + ) from exc + + +class JobExecutionContext: + """Safe callbacks and managed artifact publication for one executor call.""" + + def __init__( + self, + *, + job_id: str, + spec: JobSpecV2, + artifact_store: ArtifactStore, + cancellation_event: threading.Event, + progress_callback: Any, + ) -> None: + self.job_id = job_id + self.spec = JobSpecV2.model_validate_json(spec.model_dump_json()) + self._artifact_store = artifact_store + self._cancellation_event = cancellation_event + self._progress_callback = progress_callback + self._artifacts: list[ArtifactDescriptor] = [] + self._artifact_lock = threading.Lock() + + @property + def cancellation_requested(self) -> bool: + """Return the local cooperative cancellation signal.""" + + return self._cancellation_event.is_set() + + def raise_if_cancelled(self) -> None: + """Acknowledge cancellation at an executor-defined safe boundary.""" + + if self.cancellation_requested: + raise JobCancelledError("the job was cancelled at a safe execution boundary") + + def report_progress( + self, + *, + phase: str, + fraction: float, + completed_items: int | None = None, + total_items: int | None = None, + message: str | None = None, + eta_seconds: float | None = None, + poll_after_ms: int | None = None, + ) -> AgentJobView: + """Publish one monotonic compact progress snapshot.""" + + progress = JobProgress( + phase=phase, + fraction=fraction, + completed_items=completed_items, + total_items=total_items, + message=message, + eta_seconds=eta_seconds, + ) + return self._progress_callback(progress, poll_after_ms) + + def publish_bytes( + self, + *, + kind: str, + payload: bytes, + format: str | None = None, + media_type: str | None = None, + metadata: Mapping[str, Any] | None = None, + ) -> ArtifactDescriptor: + descriptor = self._artifact_store.put_bytes( + job_id=self.job_id, + kind=kind, + payload=payload, + format=format, + media_type=media_type, + metadata=metadata, + ) + return self._remember(descriptor) + + def publish_json( + self, + *, + kind: str, + document: Any, + metadata: Mapping[str, Any] | None = None, + ) -> ArtifactDescriptor: + descriptor = self._artifact_store.put_json( + job_id=self.job_id, + kind=kind, + document=document, + metadata=metadata, + ) + return self._remember(descriptor) + + def publish_file( + self, + *, + kind: str, + source: Path, + format: str | None = None, + media_type: str | None = None, + metadata: Mapping[str, Any] | None = None, + ) -> ArtifactDescriptor: + descriptor = self._artifact_store.put_file( + job_id=self.job_id, + kind=kind, + source=source, + format=format, + media_type=media_type, + metadata=metadata, + ) + return self._remember(descriptor) + + def _remember(self, descriptor: ArtifactDescriptor) -> ArtifactDescriptor: + with self._artifact_lock: + if all(item.artifact_id != descriptor.artifact_id for item in self._artifacts): + self._artifacts.append(descriptor) + return descriptor + + def published_artifacts(self) -> tuple[ArtifactDescriptor, ...]: + with self._artifact_lock: + return tuple(self._artifacts) + + +class JobManager: + """Durable job lifecycle facade over one shared admission scheduler.""" + + def __init__( + self, + job_store: JobStore, + artifact_store: ArtifactStore, + retarget_service: RetargetService, + scheduler: AdmissionScheduler, + *, + executor: JobExecutor | None = None, + recover_interrupted: bool = True, + ) -> None: + self._job_store = job_store + self._artifact_store = artifact_store + self._retarget_service = retarget_service + self._scheduler = scheduler + self._executor = executor + self._submission_lock = threading.RLock() + self._runtime_lock = threading.Lock() + # Lifecycle writes for one job must not race between the worker, + # progress callbacks, API cancellation, and scheduler cancellation. + # Fixed stripes avoid an unbounded per-job lock registry; JobStore CAS + # remains authoritative for other processes and manager instances. + self._mutation_locks = tuple(threading.RLock() for _ in range(_MUTATION_LOCK_STRIPES)) + self._handles: dict[str, ScheduledHandle] = {} + self._cancellation_events: dict[str, threading.Event] = {} + if recover_interrupted: + self._recover_interrupted() + + @property + def execution_available(self) -> bool: + return self._executor is not None + + @contextmanager + def _mutating(self, job_id: str) -> Iterator[None]: + """Serialize lifecycle mutations for one job in this process.""" + + lock = self._mutation_locks[hash(job_id) % len(self._mutation_locks)] + with lock: + yield + + def _existing_submission( + self, + *, + idempotency_key: str, + plan_id: str, + parent_job_id: str | None, + ) -> StoredJob | None: + try: + stored = self._job_store.get_by_idempotency_key(idempotency_key) + except JobStoreError as exc: + if exc.code == "JOB_NOT_FOUND": + return None + raise _wrap_service_error(exc.api_error) from exc + if stored.spec.plan_id != plan_id or stored.view.parent_job_id != parent_job_id: + raise _error( + "JOB_CONFLICT", + "The idempotency key is already bound to another job request.", + stage=ErrorStage.ADMISSION, + details={"job_id": stored.job_id}, + ) + return stored + + def start_retarget( + self, + plan_id: str, + *, + idempotency_key: str, + parent_job_id: str | None = None, + ) -> AgentJobView: + """Create at most one admitted job for one immutable plan request.""" + + with self._submission_lock: + existing = self._existing_submission( + idempotency_key=idempotency_key, + plan_id=plan_id, + parent_job_id=parent_job_id, + ) + if existing is not None: + return self._project_view(existing) + if self._executor is None: + raise _error( + "BACKEND_UNAVAILABLE", + "No Agent retarget executor is configured in this process.", + stage=ErrorStage.ADMISSION, + ) + try: + spec = self._retarget_service.get_job_spec(plan_id) + except RetargetServiceError as exc: + raise _wrap_service_error(exc.api_error) from exc + try: + reservation = self._scheduler.reserve() + except AdmissionQueueFullError as exc: + raise _error( + "QUEUE_FULL", + "The configured job waiting queue is full.", + stage=ErrorStage.ADMISSION, + retryable=True, + next_action=NextAction( + actor="agent", + action="retry_later", + parameters={"poll_after_ms": 2_000}, + ), + ) from exc + except AdmissionClosedError as exc: + raise _error( + "SCHEDULER_UNAVAILABLE", + "The job scheduler is shutting down.", + stage=ErrorStage.ADMISSION, + retryable=True, + ) from exc + + submitted = False + created: StoredJob | None = None + try: + try: + created = self._job_store.create( + spec, + idempotency_key=idempotency_key, + parent_job_id=parent_job_id, + ) + except JobStoreError as exc: + raise _wrap_service_error(exc.api_error) from exc + if not created.created: + reservation.cancel() + submitted = True + if ( + created.spec.plan_id != plan_id + or created.view.parent_job_id != parent_job_id + ): + raise _error( + "JOB_CONFLICT", + "The idempotency key is already bound to another job request.", + stage=ErrorStage.ADMISSION, + details={"job_id": created.job_id}, + ) + return self._project_view(created) + + spec_artifact = self._artifact_store.put_json( + job_id=created.job_id, + kind="job_spec", + document=spec.model_dump(mode="json"), + metadata={"schema_version": "2"}, + ) + created = self._job_store.attach_artifacts( + created.job_id, + expected_revision=created.revision, + artifacts=[spec_artifact], + ) + cancellation_event = threading.Event() + with self._runtime_lock: + self._cancellation_events[created.job_id] = cancellation_event + + handle = reservation.submit( + lambda: self._run_job(created.job_id), + on_cancel=lambda reason: self._on_scheduler_cancel( + created.job_id, + reason, + ), + ) + with self._runtime_lock: + latest = self._job_store.get(created.job_id) + if latest.view.state in _ACTIVE_STATES: + self._handles[created.job_id] = handle + submitted = True + return self._project_view(self._job_store.get(created.job_id)) + except (ArtifactStoreError, JobStoreError) as exc: + error = exc.api_error + if created is not None and created.created: + self._fail_before_execution(created.job_id, error) + raise _wrap_service_error(error) from exc + except AdmissionClosedError as exc: + if created is not None and created.created: + self._fail_before_execution( + created.job_id, + ApiError( + code="SCHEDULER_UNAVAILABLE", + message="The scheduler closed before the job could start.", + retryable=True, + stage=ErrorStage.ADMISSION, + ), + ) + raise _error( + "SCHEDULER_UNAVAILABLE", + "The scheduler closed before the job could start.", + stage=ErrorStage.ADMISSION, + retryable=True, + ) from exc + finally: + if not submitted: + reservation.cancel() + + def _project_polled_job( + self, + stored: StoredJob, + *, + after_revision: int | None, + ) -> AgentJobView: + """Apply the shared revision contract to an already-authorized job.""" + + if after_revision is not None and ( + isinstance(after_revision, bool) + or not isinstance(after_revision, int) + or after_revision < 0 + or after_revision > stored.revision + ): + raise _error( + "INVALID_PARAMETER", + "after_revision must be between zero and the current revision.", + stage=ErrorStage.REQUEST, + details={"current_revision": stored.revision}, + ) + return self._project_view( + stored, + unchanged=after_revision is not None and after_revision == stored.revision, + ) + + def get_job( + self, + job_id: str, + *, + after_revision: int | None = None, + ) -> AgentJobView: + """Return one compact view; large output bytes remain in artifacts.""" + + try: + stored = self._job_store.get(job_id) + except JobStoreError as exc: + raise _wrap_service_error(exc.api_error) from exc + return self._project_polled_job(stored, after_revision=after_revision) + + def lookup_job( + self, + plan_id: str, + *, + idempotency_key: str, + after_revision: int | None = None, + ) -> AgentJobView: + """Recover one known submission without exposing a global job listing.""" + + try: + stored = self._job_store.get_by_idempotency_key(idempotency_key) + except JobStoreError as exc: + raise _wrap_service_error(exc.api_error) from exc + if stored.spec.plan_id != plan_id: + raise _error( + "JOB_CONFLICT", + "The idempotency key is bound to another immutable plan.", + stage=ErrorStage.REQUEST, + ) + return self._project_polled_job(stored, after_revision=after_revision) + + def list_artifacts( + self, + job_id: str, + *, + offset: int = 0, + limit: int = 100, + ) -> list[ArtifactDescriptor]: + """List only descriptors canonically attached to one job. + + ``ArtifactStore`` may retain immutable candidates that were written + before a failed lifecycle CAS or a process interruption. JobStore's + ``artifacts_json`` is the authorization and membership boundary. + """ + + if ( + isinstance(offset, bool) + or not isinstance(offset, int) + or offset < 0 + or isinstance(limit, bool) + or not isinstance(limit, int) + or limit < 1 + or limit > _MAX_ARTIFACT_PAGE_SIZE + ): + raise _error( + "INVALID_PARAMETER", + "Artifact pagination requires offset >= 0 and limit between 1 and 500.", + stage=ErrorStage.REQUEST, + ) + try: + stored = self._job_store.get(job_id) + except JobStoreError as exc: + raise _wrap_service_error(exc.api_error) from exc + return list(stored.artifacts[offset : offset + limit]) + + def get_artifact( + self, + job_id: str, + artifact_id: str, + *, + verify: bool = False, + ) -> StoredArtifact: + """Resolve one managed artifact after canonical membership checks.""" + + try: + stored_job = self._job_store.get(job_id) + except JobStoreError as exc: + raise _wrap_service_error(exc.api_error) from exc + descriptor = next( + (item for item in stored_job.artifacts if item.artifact_id == artifact_id), + None, + ) + if descriptor is None: + # Do not query ArtifactStore first: doing so would reveal whether a + # guessed id names an unbound candidate or another job's artifact. + raise _error( + "ARTIFACT_NOT_FOUND", + "The job has no artifact with the requested id.", + stage=ErrorStage.ARTIFACT, + ) + try: + stored_artifact = self._artifact_store.get(artifact_id, verify=verify) + except ArtifactStoreError as exc: + raise _wrap_service_error(exc.api_error) from exc + if stored_artifact.descriptor != descriptor: + raise _error( + "INTERNAL_ERROR", + "The managed artifact descriptor differs from its canonical job binding.", + stage=ErrorStage.ARTIFACT, + retryable=True, + ) + return stored_artifact + + def cancel_job(self, job_id: str) -> AgentJobView: + """Request truthful queued or cooperative running cancellation.""" + + with self._submission_lock, self._mutating(job_id): + try: + requested = self._job_store.request_cancel(job_id) + except JobStoreError as exc: + raise _wrap_service_error(exc.api_error) from exc + with self._runtime_lock: + event = self._cancellation_events.get(job_id) + handle = self._handles.get(job_id) + if event is not None: + event.set() + if requested.view.state is JobState.QUEUED and (handle is None or handle.cancel()): + self._finalize_cancelled(job_id, context=None) + return self._project_view(self._job_store.get(job_id)) + + def retry_job(self, job_id: str, *, idempotency_key: str) -> AgentJobView: + """Create one explicit child attempt without mutating its parent.""" + + try: + parent = self._job_store.get(job_id) + except JobStoreError as exc: + raise _wrap_service_error(exc.api_error) from exc + if parent.view.state not in _TERMINAL_STATES: + raise _error( + "INVALID_JOB_TRANSITION", + "Only a terminal job can be retried.", + details={"job_id": parent.job_id, "state": parent.view.state.value}, + ) + return self.start_retarget( + parent.spec.plan_id, + idempotency_key=idempotency_key, + parent_job_id=parent.job_id, + ) + + def _run_job(self, job_id: str) -> None: + context: JobExecutionContext | None = None + try: + with self._mutating(job_id): + current = self._job_store.get(job_id) + if current.cancel_requested: + self._finalize_cancelled(job_id, context=None) + return + try: + running = self._job_store.transition( + job_id, + expected_revision=current.revision, + state=JobState.RUNNING, + progress=JobProgress(phase="starting", fraction=0.0), + poll_after_ms=1_000, + ) + except JobStoreError as exc: + latest = self._job_store.get(job_id) + if latest.cancel_requested: + self._finalize_cancelled(job_id, context=None) + return + raise exc + with self._runtime_lock: + cancellation_event = self._cancellation_events.setdefault( + job_id, + threading.Event(), + ) + context = JobExecutionContext( + job_id=job_id, + spec=running.spec, + artifact_store=self._artifact_store, + cancellation_event=cancellation_event, + progress_callback=lambda progress, poll_after_ms: self._report_progress( + job_id, + progress, + poll_after_ms, + ), + ) + assert self._executor is not None + result = self._executor(running.spec, context) + if not isinstance(result, JobExecutionResult): + raise JobExecutionError( + ApiError( + code="INTERNAL_ERROR", + message="The configured executor returned an invalid result.", + stage=ErrorStage.INTERNAL, + ) + ) + self._finalize_completed(job_id, result=result, context=context) + except JobCancelledError: + self._finalize_cancelled(job_id, context=context) + except JobExecutionError as exc: + self._finalize_failed(job_id, error=exc.error, context=context) + except JobManagerError as exc: + self._finalize_failed(job_id, error=exc.api_error, context=context) + except (ArtifactStoreError, JobStoreError) as exc: + self._finalize_failed(job_id, error=exc.api_error, context=context) + except Exception as exc: # noqa: BLE001 - executor internals stay private + _log.exception("unhandled Agent executor failure for %s", job_id) + self._finalize_failed( + job_id, + error=ApiError( + code="SOLVER_FAILED", + message="The retarget executor stopped before producing a result.", + stage=ErrorStage.EXECUTION, + details={"exception_type": type(exc).__name__}, + ), + context=context, + ) + finally: + self._forget_runtime(job_id) + + def _report_progress( + self, + job_id: str, + progress: JobProgress, + poll_after_ms: int | None, + ) -> AgentJobView: + with self._mutating(job_id): + for _ in range(3): + current = self._job_store.get(job_id) + if current.cancel_requested: + with self._runtime_lock: + event = self._cancellation_events.get(job_id) + if event is not None: + event.set() + if current.view.state is not JobState.RUNNING: + return self._project_view(current) + try: + updated = self._job_store.update_progress( + job_id, + expected_revision=current.revision, + progress=progress, + poll_after_ms=poll_after_ms, + ) + except JobStoreError as exc: + if exc.code == "JOB_CONFLICT": + continue + raise + return self._project_view(updated) + raise _error( + "JOB_CONFLICT", + "Progress could not be published after concurrent job updates.", + details={"job_id": job_id}, + ) + + def _finalize_completed( + self, + job_id: str, + *, + result: JobExecutionResult, + context: JobExecutionContext, + ) -> None: + with self._mutating(job_id): + self._finalize_completed_locked( + job_id, + result=result, + context=context, + ) + + def _finalize_completed_locked( + self, + job_id: str, + *, + result: JobExecutionResult, + context: JobExecutionContext, + ) -> None: + try: + outcome = ( + result.outcome + if isinstance(result.outcome, JobOutcome) + else JobOutcome(result.outcome) + ) + except (TypeError, ValueError): + self._finalize_failed( + job_id, + error=ApiError( + code="INTERNAL_ERROR", + message="The executor returned an unsupported outcome.", + stage=ErrorStage.INTERNAL, + ), + context=context, + ) + return + created_at = datetime.now(UTC) + evaluation = EvaluationReport( + job_id=job_id, + outcome=outcome, + summary=result.evaluation_summary, + metrics=dict(result.evaluation_metrics), + checks=[dict(item) for item in result.evaluation_checks], + created_at=created_at, + ) + new_artifacts = list(context.published_artifacts()) + new_artifacts.append( + self._artifact_store.put_json( + job_id=job_id, + kind="evaluation_report", + document=evaluation.model_dump(mode="json"), + ) + ) + failures = [_failure_item(item) for item in result.failures] + if failures: + failure_report = FailureReport( + job_id=job_id, + failures=failures, + created_at=created_at, + ) + new_artifacts.append( + self._artifact_store.put_json( + job_id=job_id, + kind="failure_report", + document=failure_report.model_dump(mode="json"), + ) + ) + for _ in range(3): + current = self._job_store.get(job_id) + if current.view.state not in _ACTIVE_STATES: + return + summary = _compact_summary(current.view.summary, result.summary) + manifest = self._manifest( + current, + state=JobState.COMPLETED, + outcome=outcome, + error=None, + summary=summary, + execution_provenance=dict(result.execution_provenance), + artifacts=[*current.artifacts, *new_artifacts], + completed_at=created_at, + ) + manifest_artifact = self._artifact_store.put_json( + job_id=job_id, + kind="manifest", + document=manifest.model_dump(mode="json"), + ) + total = current.view.progress.total_items + try: + self._job_store.transition( + job_id, + expected_revision=current.revision, + state=JobState.COMPLETED, + outcome=outcome, + progress=JobProgress( + phase="completed", + fraction=1.0, + completed_items=total, + total_items=total, + message="Execution completed.", + ), + next_action=result.next_action, + artifacts=[*new_artifacts, manifest_artifact], + ) + except JobStoreError as exc: + if exc.code == "JOB_CONFLICT": + continue + raise + return + raise _error( + "JOB_CONFLICT", + "The completed job changed while its manifest was being published.", + details={"job_id": job_id}, + ) + + def _finalize_failed( + self, + job_id: str, + *, + error: ApiError, + context: JobExecutionContext | None, + ) -> None: + with self._mutating(job_id): + self._finalize_failed_locked( + job_id, + error=error, + context=context, + ) + + def _finalize_failed_locked( + self, + job_id: str, + *, + error: ApiError, + context: JobExecutionContext | None, + ) -> None: + try: + current = self._job_store.get(job_id) + if current.view.state not in _ACTIVE_STATES: + return + created_at = datetime.now(UTC) + failure = FailureReport( + job_id=job_id, + failures=[ + FailureItem( + code=error.code, + message=error.message, + stage=error.stage, + retryable=error.retryable, + details=error.details, + ) + ], + created_at=created_at, + ) + new_artifacts = list(context.published_artifacts()) if context else [] + new_artifacts.append( + self._artifact_store.put_json( + job_id=job_id, + kind="failure_report", + document=failure.model_dump(mode="json"), + ) + ) + for _ in range(3): + current = self._job_store.get(job_id) + if current.view.state not in _ACTIVE_STATES: + return + manifest = self._manifest( + current, + state=JobState.FAILED, + outcome=None, + error=error, + summary=current.view.summary, + execution_provenance={}, + artifacts=[*current.artifacts, *new_artifacts], + completed_at=created_at, + ) + manifest_artifact = self._artifact_store.put_json( + job_id=job_id, + kind="manifest", + document=manifest.model_dump(mode="json"), + ) + try: + self._job_store.transition( + job_id, + expected_revision=current.revision, + state=JobState.FAILED, + error=error, + next_action=error.next_action, + artifacts=[*new_artifacts, manifest_artifact], + ) + except JobStoreError as exc: + if exc.code == "JOB_CONFLICT": + continue + raise + return + raise _error( + "JOB_CONFLICT", + "The failed job changed while its manifest was being published.", + details={"job_id": job_id}, + ) + except (ArtifactStoreError, JobStoreError, JobManagerError): + _log.exception("failed to publish the complete failure record for %s", job_id) + self._fallback_terminal(job_id, state=JobState.FAILED, error=error) + + def _finalize_cancelled( + self, + job_id: str, + *, + context: JobExecutionContext | None, + ) -> None: + with self._mutating(job_id): + self._finalize_cancelled_locked(job_id, context=context) + + def _finalize_cancelled_locked( + self, + job_id: str, + *, + context: JobExecutionContext | None, + ) -> None: + try: + current = self._job_store.get(job_id) + if current.view.state not in _ACTIVE_STATES: + return + created_at = datetime.now(UTC) + new_artifacts = list(context.published_artifacts()) if context else [] + for _ in range(3): + current = self._job_store.get(job_id) + if current.view.state not in _ACTIVE_STATES: + return + manifest = self._manifest( + current, + state=JobState.CANCELLED, + outcome=None, + error=None, + summary=current.view.summary, + execution_provenance={}, + artifacts=[*current.artifacts, *new_artifacts], + completed_at=created_at, + ) + manifest_artifact = self._artifact_store.put_json( + job_id=job_id, + kind="manifest", + document=manifest.model_dump(mode="json"), + ) + try: + self._job_store.transition( + job_id, + expected_revision=current.revision, + state=JobState.CANCELLED, + artifacts=[*new_artifacts, manifest_artifact], + ) + except JobStoreError as exc: + if exc.code == "JOB_CONFLICT": + continue + raise + return + raise _error( + "JOB_CONFLICT", + "The cancelled job changed while its manifest was being published.", + details={"job_id": job_id}, + ) + except (ArtifactStoreError, JobStoreError, JobManagerError): + _log.exception("failed to publish the complete cancellation record for %s", job_id) + self._fallback_terminal(job_id, state=JobState.CANCELLED, error=None) + finally: + self._forget_runtime(job_id) + + @staticmethod + def _manifest( + current: StoredJob, + *, + state: JobState, + outcome: JobOutcome | None, + error: ApiError | None, + summary: Mapping[str, Any], + execution_provenance: Mapping[str, Any], + artifacts: Sequence[ArtifactDescriptor], + completed_at: datetime, + ) -> JobManifest: + baseline = current.view.started_at or current.view.submitted_at + completed_at = max(completed_at, baseline) + return JobManifest( + job_id=current.job_id, + parent_job_id=current.view.parent_job_id, + root_job_id=current.view.root_job_id, + attempt=current.view.attempt, + plan_id=current.spec.plan_id, + state=state, + outcome=outcome, + error=error, + cancellation_requested=current.cancel_requested, + job_spec=current.spec, + execution_provenance=dict(execution_provenance), + summary=dict(summary), + artifacts=list(artifacts), + submitted_at=current.view.submitted_at, + started_at=current.view.started_at, + completed_at=completed_at, + ) + + def _on_scheduler_cancel(self, job_id: str, _reason: str) -> None: + with self._mutating(job_id): + try: + current = self._job_store.get(job_id) + if current.view.state in _ACTIVE_STATES and not current.cancel_requested: + self._job_store.request_cancel( + job_id, + expected_revision=current.revision, + ) + with self._runtime_lock: + event = self._cancellation_events.get(job_id) + if event is not None: + event.set() + self._finalize_cancelled(job_id, context=None) + except JobStoreError: + _log.exception("failed to persist scheduler cancellation for %s", job_id) + + def _fail_before_execution(self, job_id: str, error: ApiError) -> None: + self._finalize_failed(job_id, error=error, context=None) + self._forget_runtime(job_id) + + def _fallback_terminal( + self, + job_id: str, + *, + state: JobState, + error: ApiError | None, + ) -> None: + with self._mutating(job_id): + try: + current = self._job_store.get(job_id) + if current.view.state not in _ACTIVE_STATES: + return + self._job_store.transition( + job_id, + expected_revision=current.revision, + state=state, + error=error, + ) + except JobStoreError: + _log.exception("failed to persist fallback terminal state for %s", job_id) + + def _forget_runtime(self, job_id: str) -> None: + with self._runtime_lock: + self._handles.pop(job_id, None) + self._cancellation_events.pop(job_id, None) + + def _recover_interrupted(self) -> None: + try: + active = self._job_store.list_active() + except JobStoreError as exc: + raise _wrap_service_error(exc.api_error) from exc + for stored in active: + self._finalize_failed( + stored.job_id, + error=ApiError( + code="JOB_INTERRUPTED", + message=( + "The previous process ended before this job reached a terminal state." + ), + retryable=True, + stage=ErrorStage.EXECUTION, + next_action=NextAction( + actor="agent", + action="retry_job", + parameters={"job_id": stored.job_id}, + ), + ), + context=None, + ) + + def _project_view( + self, + stored: StoredJob, + *, + unchanged: bool = False, + ) -> AgentJobView: + queue: JobQueueView | None = None + if stored.view.state in _ACTIVE_STATES: + snapshot = self._scheduler.snapshot() + + def value(name: str, default: int = 0) -> Any: + if isinstance(snapshot, dict): + return snapshot.get(name, default) + return getattr(snapshot, name, default) + + max_running = int(value("max_running_jobs")) + max_queued = int(value("max_queued_jobs")) + if max_running == 0: + mode = SchedulerMode.UNLIMITED + elif max_queued > 0: + mode = SchedulerMode.LIMITED + else: + mode = SchedulerMode.MIXED + with self._runtime_lock: + handle = self._handles.get(stored.job_id) + queue = JobQueueView( + position=handle.queue_position() if handle is not None else None, + max_running_jobs=max_running, + max_queued_jobs=max_queued, + mode=mode, + ) + document = stored.view.model_dump(mode="json") + document["queue"] = queue.model_dump(mode="json") if queue is not None else None + document["poll_after_ms"] = ( + None + if stored.view.state in _TERMINAL_STATES + else max( + stored.view.poll_after_ms or 0, + _DEFAULT_POLL_AFTER_MS if unchanged else 500, + ) + ) + return AgentJobView.model_validate(document) + + +__all__ = [ + "JobCancelledError", + "JobExecutionContext", + "JobExecutionError", + "JobExecutionResult", + "JobExecutor", + "JobManager", + "JobManagerError", +] diff --git a/hhtools/services/legacy_job_upgrade.py b/hhtools/services/legacy_job_upgrade.py new file mode 100644 index 00000000..301a86e2 --- /dev/null +++ b/hhtools/services/legacy_job_upgrade.py @@ -0,0 +1,1199 @@ +"""Safely upgrade one path-based H2R JobSpec v1 into JobSpec v2. + +The v1 format predates content-addressed assets and immutable preflight plans. +This service therefore treats its absolute ``source_path`` as a one-time local +lookup hint only. A trusted, deployment-owned :class:`DynamicRootLocator` +must map that hint and the selected robot preset back into purpose-specific +allowlisted roots. The resulting portable paths are registered and inspected, +then normal preflight and :class:`~hhtools.services.retarget.RetargetService` +produce the plan and JobSpec v2. + +No solver is imported, no scheduler slot is reserved, and no output path is +accepted from v1. The migration receipt contains content identities and +digests only; host paths are never returned or persisted by this module. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import re +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from threading import Lock +from typing import Any, Literal, Protocol + +from pydantic import ValidationError + +from hhtools.contracts import ( + ApiError, + AssetBundle, + AssetInspection, + AssetInspectionRequest, + AssetKind, + AssetRegistrationRequest, + ErrorStage, + InspectionStatus, + JobSpecV2, + LegacyJobUpgradeResponse, + LegacyMigrationReceipt, + OutputPolicy, + PreflightResponse, + PreflightStatus, + RetargetPreflightRequest, +) +from hhtools.retarget.calibration.calibration import normalize_calibration_reference +from hhtools.web.job_specs import JobSpecError, build_job_spec, normalize_job_spec + +from .assets import AssetServiceError +from .retarget import RetargetServiceError + +RootUsage = Literal["motion", "robot"] +RootProvider = Path | Callable[[], Path] + +_PORTABLE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +_SAFE_FIELD_NAME = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]{0,127}$") +_RAW_SPEC_FIELDS = frozenset({"schema_version", "kind", "request"}) +_ENVELOPE_FIELDS = frozenset( + { + "schema_version", + "job_id", + "kind", + "status", + "created_at", + "finished_at", + "scope", + "request", + "cli", + "spec", + "replay", + "parent_job_id", + } +) +_REQUEST_FIELDS = frozenset( + { + "source_path", + "source_entry", + "robot", + "reference", + "backend", + "ik_iterations", + "human_height", + "limit_frames", + "retarget_fps", + "foot_clamp_anti_penetration", + } +) +_SOURCE_ENTRY_FIELDS = frozenset( + { + "dataset", + "folder_label", + "sequence_id", + "source_path", + "stem", + "label", + "name", + "display_name", + "origin", + "reference", + "upload_profile", + "export_subdir", + "suggested_backend", + "motion_category", + "asset_kind", + "has_scene", + } +) +_SOURCE_ENTRY_STRING_FIELDS = _SOURCE_ENTRY_FIELDS.difference({"has_scene"}) +_BACKENDS = frozenset({"newton", "interaction_mesh"}) +_REFERENCES = frozenset( + { + "smplx", + "smpl", + "gvhmr", + "soma_bvh", + "lafan_bvh", + "mocap_bvh", + "xsens_mocap", + "glb", + } +) +_MOTION_CATEGORY_CLAIMS = { + "motion": "plain_motion", + "object": "object_interaction", + "terrain": "terrain_scene", +} +_UPLOAD_PROFILE_CLAIMS = { + "mimic": "plain_motion", + "intermimic": "object_interaction", + "meshmimic": "terrain_scene", +} +_MAX_DOCUMENT_BYTES = 64 * 1024 +_MAX_STRING_LENGTH = 16 * 1024 +_MAX_DEPTH = 16 +_MAX_NODES = 4_096 +_HASH_CHUNK_SIZE = 1024 * 1024 +_INTERACTION_IK_WARNING = "LEGACY_INTERACTION_IK_ITERATIONS_IGNORED" + + +class LegacyJobUpgradeError(RuntimeError): + """Expected migration failure carrying the shared public error body.""" + + def __init__(self, error: ApiError) -> None: + self.error = error + super().__init__(f"{error.code}: {error.message}") + + @property + def api_error(self) -> ApiError: + return self.error + + @property + def code(self) -> str: + return self.error.code + + +def _upgrade_error( + code: str, + message: str, + *, + stage: ErrorStage = ErrorStage.REQUEST, + retryable: bool = False, + details: Mapping[str, Any] | None = None, +) -> LegacyJobUpgradeError: + return LegacyJobUpgradeError( + ApiError( + code=code, + message=message, + stage=stage, + retryable=retryable, + details=dict(details or {}), + ) + ) + + +def _invalid( + message: str, + *, + field: str | None = None, + fields: Iterable[str] | None = None, +) -> LegacyJobUpgradeError: + details: dict[str, Any] = {} + if field is not None: + details["field"] = field + if fields is not None: + supplied = list(fields) + safe_fields = sorted( + value + for value in supplied + if isinstance(value, str) and _SAFE_FIELD_NAME.fullmatch(value) is not None + ) + details["field_count"] = len(supplied) + if safe_fields: + details["fields"] = safe_fields + return _upgrade_error("INVALID_JOB_SPEC", message, details=details) + + +@dataclass(frozen=True, slots=True) +class _PathSnapshot: + device: int + inode: int + mode: int + size: int + modified_ns: int + + +class _TrackedRootProvider: + """Record resolved-root generations across locator and registry calls.""" + + def __init__(self, provider: RootProvider) -> None: + self._provider = provider + self._lock = Lock() + self._last: Path | None = None + self._generation = 0 + + def resolve(self) -> tuple[Path, int]: + with self._lock: + supplied = self._provider() if callable(self._provider) else self._provider + resolved = Path(supplied).resolve(strict=True) + if self._last is not None and resolved != self._last: + self._generation += 1 + self._last = resolved + return resolved, self._generation + + def __call__(self) -> Path: + """AssetRegistry-compatible provider that shares the generation log.""" + + return self.resolve()[0] + + +def _snapshot(path: Path, *, root_id: str) -> _PathSnapshot: + try: + value = path.stat() + except OSError as exc: + raise _upgrade_error( + "ASSET_NOT_FOUND", + "An allowlisted asset changed or became unreadable during migration.", + stage=ErrorStage.ASSET_REGISTRATION, + retryable=True, + details={"root_id": root_id}, + ) from exc + return _PathSnapshot( + device=value.st_dev, + inode=value.st_ino, + mode=value.st_mode, + size=value.st_size, + modified_ns=value.st_mtime_ns, + ) + + +@dataclass(frozen=True, slots=True) +class _RootMatch: + """Internal root match; host paths must never cross a transport boundary.""" + + usage: RootUsage + root_id: str + relative_path: str + resolved_root: Path + resolved_candidate: Path + root_snapshot: _PathSnapshot + root_generation: int + candidate_snapshot: _PathSnapshot + candidate_is_directory: bool + + +class DynamicRootLocator: + """Reverse-map host paths into trusted, purpose-specific dynamic roots. + + Root mappings are supplied by the composition root, not by the legacy + document. Providers are called again before preflight so a settings or + mount change cannot silently redirect an already-authorized migration. + """ + + def __init__( + self, + *, + motion_roots: Mapping[str, RootProvider], + robot_roots: Mapping[str, RootProvider], + ) -> None: + self._roots: dict[RootUsage, dict[str, _TrackedRootProvider]] = { + "motion": self._validated_roots(motion_roots), + "robot": self._validated_roots(robot_roots), + } + duplicated = set(self._roots["motion"]).intersection(self._roots["robot"]) + if duplicated: + raise ValueError("motion and robot root ids must be purpose-specific") + + @staticmethod + def _validated_roots( + values: Mapping[str, RootProvider], + ) -> dict[str, _TrackedRootProvider]: + roots = dict(values) + if not roots: + raise ValueError("at least one allowlisted root is required per purpose") + for root_id in roots: + if len(root_id) > 128 or _PORTABLE_ID.fullmatch(root_id) is None: + raise ValueError("root ids must be portable identifiers") + return {root_id: _TrackedRootProvider(provider) for root_id, provider in roots.items()} + + def registry_root_providers(self) -> dict[str, Callable[[], Path]]: + """Return the tracked providers for the in-process AssetRegistry. + + The composition root should configure ``AssetRegistry`` with this + mapping. Sharing these wrappers lets the locator detect an A→B→A + root-settings race even when both trees contain identical bytes. + """ + + return { + root_id: provider + for providers in self._roots.values() + for root_id, provider in providers.items() + } + + def allowed_root_ids(self, usage: RootUsage) -> frozenset[str]: + """Return safe identifiers for validating a deduplicated asset source.""" + + return frozenset(self._roots[usage]) + + def locate_motion_file(self, source_path: str) -> _RootMatch: + return self._locate(Path(source_path), usage="motion", expect_directory=False) + + def locate_robot_directory(self, root_dir: Path) -> _RootMatch: + return self._locate(Path(root_dir), usage="robot", expect_directory=True) + + def _resolve_root(self, usage: RootUsage, root_id: str) -> tuple[Path, int]: + provider = self._roots[usage][root_id] + try: + root, generation = provider.resolve() + except (OSError, RuntimeError, TypeError, ValueError) as exc: + raise _upgrade_error( + "ALLOWED_ROOT_UNAVAILABLE", + "A configured asset root is unavailable.", + stage=ErrorStage.ASSET_REGISTRATION, + retryable=True, + details={"root_id": root_id, "usage": usage}, + ) from exc + if not root.is_dir(): + raise _upgrade_error( + "ALLOWED_ROOT_UNAVAILABLE", + "A configured asset root is not a directory.", + stage=ErrorStage.ASSET_REGISTRATION, + details={"root_id": root_id, "usage": usage}, + ) + return root, generation + + def _locate( + self, + candidate: Path, + *, + usage: RootUsage, + expect_directory: bool, + ) -> _RootMatch: + if not candidate.is_absolute(): + raise _upgrade_error( + "ASSET_OUTSIDE_ALLOWED_ROOT", + "Legacy asset paths must be absolute paths below an allowed root.", + stage=ErrorStage.ASSET_REGISTRATION, + details={"usage": usage}, + ) + try: + resolved_candidate = candidate.resolve(strict=True) + except (OSError, RuntimeError, ValueError) as exc: + raise _upgrade_error( + "ASSET_NOT_FOUND", + "The legacy asset path is missing or unreadable.", + stage=ErrorStage.ASSET_REGISTRATION, + retryable=True, + details={"usage": usage}, + ) from exc + if expect_directory != resolved_candidate.is_dir(): + expected = "directory" if expect_directory else "file" + raise _upgrade_error( + "ASSET_KIND_MISMATCH", + f"The legacy {usage} asset must resolve to a regular {expected}.", + stage=ErrorStage.ASSET_REGISTRATION, + details={"usage": usage}, + ) + if not expect_directory and not resolved_candidate.is_file(): + raise _upgrade_error( + "ASSET_KIND_MISMATCH", + "The legacy motion asset must resolve to a regular file.", + stage=ErrorStage.ASSET_REGISTRATION, + details={"usage": usage}, + ) + + matches: list[tuple[int, str, Path, Path, int]] = [] + for root_id in sorted(self._roots[usage]): + root, generation = self._resolve_root(usage, root_id) + try: + relative = resolved_candidate.relative_to(root) + except ValueError: + continue + if not relative.parts: + continue + matches.append((len(root.parts), root_id, root, relative, generation)) + if not matches: + raise _upgrade_error( + "ASSET_OUTSIDE_ALLOWED_ROOT", + "The legacy asset is outside every allowed root for its purpose.", + stage=ErrorStage.ASSET_REGISTRATION, + details={"usage": usage}, + ) + + matches.sort(key=lambda item: (-item[0], item[1])) + specificity = matches[0][0] + most_specific = [item for item in matches if item[0] == specificity] + if len(most_specific) != 1: + raise _upgrade_error( + "AMBIGUOUS_ALLOWED_ROOT", + "The legacy asset matches multiple equally specific allowed roots.", + stage=ErrorStage.ASSET_REGISTRATION, + details={ + "usage": usage, + "root_ids": sorted(item[1] for item in most_specific), + }, + ) + _, root_id, root, relative, generation = most_specific[0] + relative_path = relative.as_posix() + try: + AssetRegistrationRequest( + root_id=root_id, + relative_path=relative_path, + display_name=None, + ) + except ValidationError as exc: + raise _upgrade_error( + "ASSET_OUTSIDE_ALLOWED_ROOT", + "The allowlisted asset path is not portable.", + stage=ErrorStage.ASSET_REGISTRATION, + details={"root_id": root_id, "usage": usage}, + ) from exc + return _RootMatch( + usage=usage, + root_id=root_id, + relative_path=relative_path, + resolved_root=root, + resolved_candidate=resolved_candidate, + root_snapshot=_snapshot(root, root_id=root_id), + root_generation=generation, + candidate_snapshot=_snapshot(resolved_candidate, root_id=root_id), + candidate_is_directory=expect_directory, + ) + + def revalidate(self, match: _RootMatch) -> None: + """Reject a root remap, path replacement, or candidate type change.""" + + root, generation = self._resolve_root(match.usage, match.root_id) + try: + candidate = root.joinpath(*PurePosixPath(match.relative_path).parts).resolve( + strict=True + ) + candidate.relative_to(root) + except (OSError, RuntimeError, ValueError) as exc: + raise _upgrade_error( + "ASSET_CHANGED_DURING_UPGRADE", + "An allowlisted asset changed while it was being migrated.", + stage=ErrorStage.ASSET_REGISTRATION, + retryable=True, + details={"root_id": match.root_id, "usage": match.usage}, + ) from exc + unchanged = ( + root == match.resolved_root + and generation == match.root_generation + and candidate == match.resolved_candidate + and _snapshot(root, root_id=match.root_id) == match.root_snapshot + and _snapshot(candidate, root_id=match.root_id) == match.candidate_snapshot + and candidate.is_dir() == match.candidate_is_directory + and (candidate.is_dir() or candidate.is_file()) + ) + if not unchanged: + raise _upgrade_error( + "ASSET_CHANGED_DURING_UPGRADE", + "An allowlisted asset changed while it was being migrated.", + stage=ErrorStage.ASSET_REGISTRATION, + retryable=True, + details={"root_id": match.root_id, "usage": match.usage}, + ) + + +class _AssetService(Protocol): + def register(self, request: AssetRegistrationRequest) -> AssetBundle: ... + + def inspect(self, request: AssetInspectionRequest) -> AssetInspection: ... + + +class _PreflightService(Protocol): + def preflight_retarget(self, request: RetargetPreflightRequest) -> PreflightResponse: ... + + +class _RetargetService(Protocol): + def get_job_spec(self, plan_id: str) -> JobSpecV2: ... + + +class _RobotPreset(Protocol): + name: str + root_dir: Path + + +LegacyJobUpgradeResult = LegacyJobUpgradeResponse + + +def _validate_json_shape(value: Any) -> bytes: + nodes = 0 + + def visit(item: Any, depth: int) -> None: + nonlocal nodes + nodes += 1 + if nodes > _MAX_NODES: + raise _invalid("The legacy JobSpec contains too many values.") + if depth > _MAX_DEPTH: + raise _invalid("The legacy JobSpec is nested too deeply.") + if item is None or isinstance(item, bool | int): + return + if isinstance(item, float): + if not math.isfinite(item): + raise _invalid("The legacy JobSpec contains a non-finite number.") + return + if isinstance(item, str): + if len(item) > _MAX_STRING_LENGTH: + raise _invalid("The legacy JobSpec contains an oversized string.") + return + if isinstance(item, dict): + for key, child in item.items(): + if not isinstance(key, str): + raise _invalid("The legacy JobSpec object keys must be strings.") + if len(key) > 256: + raise _invalid("The legacy JobSpec contains an oversized field name.") + visit(child, depth + 1) + return + if isinstance(item, list): + for child in item: + visit(child, depth + 1) + return + raise _invalid("The legacy JobSpec must contain strict JSON values only.") + + visit(value, 0) + try: + encoded = json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + except (TypeError, ValueError) as exc: + raise _invalid("The legacy JobSpec is not canonical JSON.") from exc + if len(encoded) > _MAX_DOCUMENT_BYTES: + raise _invalid("The legacy JobSpec exceeds the migration size limit.") + return encoded + + +def _strict_canonical_v1(payload: Any) -> tuple[dict[str, Any], bytes]: + _validate_json_shape(payload) + if not isinstance(payload, dict): + raise _invalid("The legacy JobSpec envelope must be an object.") + + if "spec" in payload: + unknown_envelope = set(payload).difference(_ENVELOPE_FIELDS) + if unknown_envelope: + raise _invalid( + "The downloaded JobSpec envelope contains unsupported fields.", + fields=unknown_envelope, + ) + job_id = payload.get("job_id") + if job_id is not None and (not isinstance(job_id, str) or not job_id or len(job_id) > 256): + raise _invalid("job_id must be a non-empty bounded string.", field="job_id") + candidate = payload.get("spec") + else: + candidate = payload + if not isinstance(candidate, dict): + raise _invalid("spec must be a JSON object.", field="spec") + + unknown_spec = set(candidate).difference(_RAW_SPEC_FIELDS) + if unknown_spec: + raise _invalid("The v1 spec contains unsupported fields.", fields=unknown_spec) + if set(candidate) != _RAW_SPEC_FIELDS: + raise _invalid( + "The v1 spec must contain schema_version, kind, and request.", + fields=_RAW_SPEC_FIELDS.difference(candidate), + ) + version = candidate.get("schema_version") + if type(version) is not int or version != 1: + raise _invalid("schema_version must be the integer 1.", field="schema_version") + if candidate.get("kind") != "retarget": + raise _invalid("Only single H2R retarget JobSpec v1 can be upgraded.", field="kind") + raw_request = candidate.get("request") + if not isinstance(raw_request, dict): + raise _invalid("request must be a JSON object.", field="request") + unknown_request = set(raw_request).difference(_REQUEST_FIELDS) + if unknown_request: + raise _invalid( + "The retarget request contains unsupported fields.", + fields=unknown_request, + ) + + try: + normalized = normalize_job_spec(candidate) + except JobSpecError as exc: + raise _invalid("The legacy JobSpec is not a supported canonical v1 document.") from exc + if "spec" in payload: + outer_version = payload.get("schema_version") + if outer_version is not None and (type(outer_version) is not int or outer_version != 1): + raise _invalid( + "The downloaded envelope schema_version must be the integer 1.", + field="schema_version", + ) + has_outer_kind = "kind" in payload + has_outer_request = "request" in payload + if has_outer_kind != has_outer_request: + raise _invalid( + "The downloaded envelope must provide kind and request together.", + ) + if has_outer_kind: + outer_kind = payload["kind"] + outer_request = payload["request"] + if outer_kind != "retarget" or not isinstance(outer_request, dict): + raise _invalid( + "The downloaded envelope kind and request are invalid.", + field="request", + ) + if build_job_spec(outer_kind, outer_request) != normalized: + raise _invalid( + "The downloaded envelope conflicts with its nested JobSpec.", + field="spec", + ) + for field in ("status", "scope"): + value = payload.get(field) + if value is not None and (not isinstance(value, str) or not value): + raise _invalid( + f"The downloaded envelope {field} must be a non-empty string.", + field=field, + ) + for field in ("created_at", "finished_at"): + value = payload.get(field) + if value is not None and not _is_finite_number(value): + raise _invalid( + f"The downloaded envelope {field} must be a finite number or null.", + field=field, + ) + for field in ("cli", "replay"): + value = payload.get(field) + if value is not None and not isinstance(value, dict): + raise _invalid( + f"The downloaded envelope {field} must be an object.", + field=field, + ) + parent_job_id = payload.get("parent_job_id") + if parent_job_id is not None and ( + not isinstance(parent_job_id, str) or not parent_job_id or len(parent_job_id) > 256 + ): + raise _invalid( + "The downloaded envelope parent_job_id must be a bounded string or null.", + field="parent_job_id", + ) + canonical = _validate_json_shape(normalized) + return normalized, canonical + + +def _nonempty_string(request: Mapping[str, Any], field: str) -> str: + value = request.get(field) + if not isinstance(value, str) or not value.strip() or value != value.strip(): + raise _invalid(f"{field} must be a non-empty canonical string.", field=field) + return value + + +def _optional_string(request: Mapping[str, Any], field: str, default: str) -> str: + if field not in request: + return default + return _nonempty_string(request, field) + + +def _strict_positive_number(value: Any, *, field: str, minimum: float) -> float: + if isinstance(value, bool) or not isinstance(value, int | float): + raise _invalid(f"{field} must be a finite number.", field=field) + try: + normalized = float(value) + except OverflowError as exc: + raise _invalid(f"{field} must be a finite number.", field=field) from exc + if not math.isfinite(normalized) or normalized <= minimum: + raise _invalid(f"{field} must be a finite positive number.", field=field) + return normalized + + +def _is_finite_number(value: Any) -> bool: + if isinstance(value, bool) or not isinstance(value, int | float): + return False + try: + return math.isfinite(float(value)) + except OverflowError: + return False + + +def _strict_positive_int(value: Any, *, field: str) -> int: + if type(value) is not int or value < 1: + raise _invalid(f"{field} must be a positive integer.", field=field) + return value + + +def _parameters(request: Mapping[str, Any], backend: str) -> dict[str, Any]: + parameters: dict[str, Any] = {} + limit = request.get("limit_frames") + if limit is not None and (type(limit) is not int or limit < 0): + raise _invalid("limit_frames must be a non-negative integer or null.", field="limit_frames") + if limit is None or limit == 0: + parameters["run_mode"] = "full" + else: + parameters["run_mode"] = "smoke" + parameters["limit_frames"] = _strict_positive_int(limit, field="limit_frames") + + ik_iterations = request.get("ik_iterations", 24) + normalized_iterations = _strict_positive_int(ik_iterations, field="ik_iterations") + # The legacy Web worker always captured an IK value, including for its + # interaction path. The current preflight contract correctly rejects this + # Newton-only parameter, so validate but omit it for interaction jobs. + if backend == "newton": + parameters["ik_iterations"] = normalized_iterations + + human_height = request.get("human_height") + if human_height is not None: + parameters["human_height"] = _strict_positive_number( + human_height, + field="human_height", + minimum=0.1, + ) + retarget_fps = request.get("retarget_fps") + if retarget_fps is not None: + parameters["retarget_fps"] = _strict_positive_number( + retarget_fps, + field="retarget_fps", + minimum=0.0, + ) + foot_clamp = request.get("foot_clamp_anti_penetration", False) + if not isinstance(foot_clamp, bool): + raise _invalid( + "foot_clamp_anti_penetration must be a boolean.", + field="foot_clamp_anti_penetration", + ) + parameters["foot_clamp_anti_penetration"] = foot_clamp + return parameters + + +def _validate_source_entry( + request: Mapping[str, Any], + *, + source_match: _RootMatch, + reference: str, +) -> None: + raw = request.get("source_entry") + if raw is None: + return + if not isinstance(raw, dict): + raise _invalid("source_entry must be a JSON object.", field="source_entry") + unknown = set(raw).difference(_SOURCE_ENTRY_FIELDS) + if unknown: + raise _invalid("source_entry contains unsupported fields.", fields=unknown) + for field in sorted(_SOURCE_ENTRY_STRING_FIELDS.intersection(raw)): + value = raw[field] + if value is not None and not isinstance(value, str): + raise _invalid( + "source_entry text fields must be strings or null.", + field=f"source_entry.{field}", + ) + if "has_scene" in raw and not isinstance(raw["has_scene"], bool): + raise _invalid("source_entry.has_scene must be a boolean.", field="source_entry.has_scene") + if raw.get("asset_kind") not in {None, "human_motion"}: + raise _invalid( + "Robot trajectories cannot be upgraded as H2R motion inputs.", + field="source_entry.asset_kind", + ) + if raw.get("motion_category") not in {None, *_MOTION_CATEGORY_CLAIMS}: + raise _invalid( + "source_entry.motion_category is unsupported.", + field="source_entry.motion_category", + ) + if raw.get("suggested_backend") not in {None, *_BACKENDS}: + raise _invalid( + "source_entry.suggested_backend is unsupported.", + field="source_entry.suggested_backend", + ) + if raw.get("upload_profile") not in {None, "", "auto", *_UPLOAD_PROFILE_CLAIMS}: + raise _invalid( + "source_entry.upload_profile is unsupported.", + field="source_entry.upload_profile", + ) + + nested_source = raw.get("source_path") + if nested_source is not None: + try: + nested_path = Path(nested_source) + if not nested_path.is_absolute(): + raise ValueError("nested source path is not absolute") + nested_resolved = nested_path.resolve(strict=True) + except (OSError, RuntimeError, TypeError, ValueError) as exc: + raise _invalid( + "source_entry.source_path is not the selected source asset.", + field="source_entry.source_path", + ) from exc + if nested_resolved != source_match.resolved_candidate: + raise _invalid( + "source_entry.source_path is not the selected source asset.", + field="source_entry.source_path", + ) + entry_reference = raw.get("reference") + if ( + entry_reference is not None + and normalize_calibration_reference(entry_reference) != reference + ): + raise _invalid( + "source_entry.reference conflicts with the retarget request.", + field="source_entry.reference", + ) + + +def _normalized_label(value: str) -> str: + return re.sub(r"[^a-z0-9]+", "_", value.strip().casefold()).strip("_") + + +def _validate_source_inspection_claims( + request: Mapping[str, Any], + *, + inspection: AssetInspection, + backend: str, +) -> None: + raw = request.get("source_entry") + if not isinstance(raw, dict): + return + # ``origin`` and ``export_subdir`` are historical display/storage labels, + # not execution-routing claims. They remain in the canonical-v1 digest but + # never influence registration, preflight, or the resulting JobSpec. + + def conflict(field: str) -> None: + raise _upgrade_error( + "LEGACY_METADATA_MISMATCH", + "Legacy source metadata conflicts with safe asset inspection.", + stage=ErrorStage.PREFLIGHT, + details={"field": f"source_entry.{field}", "asset_id": inspection.asset_id}, + ) + + dataset = raw.get("dataset") + if isinstance(dataset, str) and dataset.strip() and dataset.casefold() != "unknown": + inspected_dataset = inspection.dataset + if not isinstance(inspected_dataset, str) or _normalized_label( + dataset + ) != _normalized_label(inspected_dataset): + conflict("dataset") + + category = raw.get("motion_category") + if isinstance(category, str): + expected = _MOTION_CATEGORY_CLAIMS[category] + if inspection.category.value != expected: + conflict("motion_category") + + upload_profile = raw.get("upload_profile") + if ( + isinstance(upload_profile, str) + and upload_profile in _UPLOAD_PROFILE_CLAIMS + and inspection.category.value != _UPLOAD_PROFILE_CLAIMS[upload_profile] + ): + conflict("upload_profile") + + has_scene = raw.get("has_scene") + if isinstance(has_scene, bool): + inspected_scene = bool(inspection.has_object or inspection.has_terrain) + if has_scene != inspected_scene: + conflict("has_scene") + + suggested_backend = raw.get("suggested_backend") + if isinstance(suggested_backend, str) and suggested_backend != backend: + conflict("suggested_backend") + + +def _verified_registration( + asset_service: _AssetService, + match: _RootMatch, + *, + kind: AssetKind, + allowed_source_root_ids: frozenset[str], +) -> tuple[AssetBundle, AssetInspection]: + try: + bundle = asset_service.register( + AssetRegistrationRequest( + root_id=match.root_id, + relative_path=match.relative_path, + display_name=None, + kind=kind, + recursive=kind is AssetKind.ROBOT_BUNDLE, + ) + ) + inspection = asset_service.inspect( + AssetInspectionRequest( + asset_id=bundle.asset_id, + verify_hashes=True, + parse_content=True, + ) + ) + except AssetServiceError as exc: + raise LegacyJobUpgradeError(exc.api_error) from exc + + source = bundle.source + valid_source = source is not None and source.root_id in allowed_source_root_ids + if ( + bundle.kind is not kind + or inspection.asset_id != bundle.asset_id + or inspection.kind is not kind + or not valid_source + ): + raise _upgrade_error( + "ASSET_REGISTRATION_MISMATCH", + "The registered asset does not match the authorized root lookup.", + stage=ErrorStage.ASSET_REGISTRATION, + details={"root_id": match.root_id, "usage": match.usage}, + ) + if inspection.status is InspectionStatus.INVALID: + if inspection.errors: + raise LegacyJobUpgradeError(inspection.errors[0]) + raise _upgrade_error( + "ASSET_INSPECTION_FAILED", + "The registered asset did not pass safe content inspection.", + stage=ErrorStage.ASSET_INSPECTION, + details={"asset_id": bundle.asset_id}, + ) + return bundle, inspection + + +def _hash_stable_file(path: Path, *, root_id: str) -> tuple[str, int]: + """Hash a located file without ever returning its host path.""" + + before = _snapshot(path, root_id=root_id) + digest = hashlib.sha256() + size = 0 + try: + with path.open("rb") as stream: + while chunk := stream.read(_HASH_CHUNK_SIZE): + digest.update(chunk) + size += len(chunk) + except OSError as exc: + raise _upgrade_error( + "ASSET_CHANGED_DURING_UPGRADE", + "An allowlisted asset changed while it was being migrated.", + stage=ErrorStage.ASSET_REGISTRATION, + retryable=True, + details={"root_id": root_id}, + ) from exc + after = _snapshot(path, root_id=root_id) + if before != after or size != after.size: + raise _upgrade_error( + "ASSET_CHANGED_DURING_UPGRADE", + "An allowlisted asset changed while it was being migrated.", + stage=ErrorStage.ASSET_REGISTRATION, + retryable=True, + details={"root_id": root_id}, + ) + return digest.hexdigest(), size + + +def _verify_registered_content(bundle: AssetBundle, match: _RootMatch) -> None: + """Bind registration output back to the exact path authorized by lookup. + + ``AssetRegistry`` may itself use a dynamic root provider. Comparing every + registered manifest file with the originally located tree closes the race + where that provider changes between reverse lookup and registration, even + if it later changes back before :meth:`DynamicRootLocator.revalidate`. + """ + + base = ( + match.resolved_candidate + if match.candidate_is_directory + else match.resolved_candidate.parent + ) + try: + primary = base.joinpath(*PurePosixPath(bundle.primary_file).parts).resolve(strict=True) + primary.relative_to(base) + except (OSError, RuntimeError, ValueError) as exc: + raise _upgrade_error( + "ASSET_REGISTRATION_MISMATCH", + "The registered asset does not match the authorized filesystem snapshot.", + stage=ErrorStage.ASSET_REGISTRATION, + details={"root_id": match.root_id, "usage": match.usage}, + ) from exc + if not match.candidate_is_directory and primary != match.resolved_candidate: + raise _upgrade_error( + "ASSET_REGISTRATION_MISMATCH", + "The registered asset does not match the authorized filesystem snapshot.", + stage=ErrorStage.ASSET_REGISTRATION, + details={"root_id": match.root_id, "usage": match.usage}, + ) + + for item in bundle.files: + try: + candidate = base.joinpath(*PurePosixPath(item.relative_path).parts).resolve(strict=True) + candidate.relative_to(base) + except (OSError, RuntimeError, ValueError) as exc: + raise _upgrade_error( + "ASSET_REGISTRATION_MISMATCH", + "The registered asset does not match the authorized filesystem snapshot.", + stage=ErrorStage.ASSET_REGISTRATION, + details={"root_id": match.root_id, "usage": match.usage}, + ) from exc + digest, size = _hash_stable_file(candidate, root_id=match.root_id) + if digest != item.sha256 or size != item.size_bytes: + raise _upgrade_error( + "ASSET_REGISTRATION_MISMATCH", + "The registered asset does not match the authorized filesystem snapshot.", + stage=ErrorStage.ASSET_REGISTRATION, + details={"root_id": match.root_id, "usage": match.usage}, + ) + + +def _sha256_json(value: Any) -> str: + encoded = json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +class LegacyJobUpgradeService: + """Orchestrate a strict, read-only single-H2R v1 to v2 migration.""" + + def __init__( + self, + asset_service: _AssetService, + preflight_service: _PreflightService, + retarget_service: _RetargetService, + root_locator: DynamicRootLocator, + *, + robot_provider: Callable[[], Iterable[_RobotPreset]] | None = None, + ) -> None: + if robot_provider is None: + from hhtools.robot.registry import list_presets_readonly + + robot_provider = list_presets_readonly + self._asset_service = asset_service + self._preflight_service = preflight_service + self._retarget_service = retarget_service + self._root_locator = root_locator + self._robot_provider = robot_provider + + def _robot_preset(self, robot_id: str) -> _RobotPreset: + try: + matches = [preset for preset in self._robot_provider() if preset.name == robot_id] + except (OSError, RuntimeError, TypeError, ValueError) as exc: + raise _upgrade_error( + "ROBOT_REGISTRY_UNAVAILABLE", + "The trusted robot registry is unavailable.", + stage=ErrorStage.ASSET_REGISTRATION, + retryable=True, + ) from exc + if not matches: + raise _upgrade_error( + "ROBOT_NOT_FOUND", + "The legacy target robot is not available in the trusted registry.", + stage=ErrorStage.ASSET_REGISTRATION, + details={"robot_id": robot_id}, + ) + if len(matches) != 1: + raise _upgrade_error( + "ROBOT_AMBIGUOUS", + "The trusted robot registry contains duplicate robot identifiers.", + stage=ErrorStage.ASSET_REGISTRATION, + details={"robot_id": robot_id}, + ) + return matches[0] + + def upgrade(self, payload: Any) -> LegacyJobUpgradeResult: + """Create a content-bound JobSpec v2 without starting any work.""" + + spec, canonical_v1 = _strict_canonical_v1(payload) + request = spec["request"] + source_path = _nonempty_string(request, "source_path") + robot_id = _nonempty_string(request, "robot") + if len(robot_id) > 256 or _PORTABLE_ID.fullmatch(robot_id) is None: + raise _invalid("robot must be a portable identifier.", field="robot") + + reference = normalize_calibration_reference(_optional_string(request, "reference", "smpl")) + if reference not in _REFERENCES: + raise _invalid( + "reference is not a supported calibration reference.", + field="reference", + ) + backend = _optional_string(request, "backend", "newton") + if backend not in _BACKENDS: + raise _invalid("backend is not a supported H2R backend.", field="backend") + parameters = _parameters(request, backend) + + motion_match = self._root_locator.locate_motion_file(source_path) + _validate_source_entry( + request, + source_match=motion_match, + reference=reference, + ) + preset = self._robot_preset(robot_id) + robot_match = self._root_locator.locate_robot_directory(preset.root_dir) + + motion_bundle, motion_inspection = _verified_registration( + self._asset_service, + motion_match, + kind=AssetKind.MOTION_BUNDLE, + allowed_source_root_ids=self._root_locator.allowed_root_ids("motion"), + ) + robot_bundle, _robot_inspection = _verified_registration( + self._asset_service, + robot_match, + kind=AssetKind.ROBOT_BUNDLE, + allowed_source_root_ids=self._root_locator.allowed_root_ids("robot"), + ) + # Re-read the tracked dynamic providers before trusting registration + # output. Shared registry providers make even an A→B→A remap advance + # the generation, while the snapshots catch in-place replacements. + self._root_locator.revalidate(motion_match) + self._root_locator.revalidate(robot_match) + _verify_registered_content(motion_bundle, motion_match) + _verify_registered_content(robot_bundle, robot_match) + detected_reference = motion_inspection.reference_model + if ( + not isinstance(detected_reference, str) + or normalize_calibration_reference(detected_reference) != reference + ): + raise _upgrade_error( + "REFERENCE_MISMATCH", + "The legacy reference does not match the inspected motion asset.", + stage=ErrorStage.PREFLIGHT, + details={"asset_id": motion_bundle.asset_id}, + ) + _validate_source_inspection_claims( + request, + inspection=motion_inspection, + backend=backend, + ) + + # Preflight and RetargetService perform their own content-hash + # verification as separate, non-executing boundaries. + try: + preflight = self._preflight_service.preflight_retarget( + RetargetPreflightRequest( + motion_asset_id=motion_bundle.asset_id, + robot_id=robot_id, + robot_asset_id=robot_bundle.asset_id, + backend=backend, + calibration_id=None, + output_format="csv", + output_policy=OutputPolicy.CREATE_NEW, + parameters=parameters, + ) + ) + except ValidationError as exc: + raise _invalid("The legacy parameters cannot form a safe preflight request.") from exc + + if preflight.status is not PreflightStatus.READY or preflight.plan is None: + return LegacyJobUpgradeResult( + preflight=preflight, + job_spec=None, + receipt=None, + ) + + self._root_locator.revalidate(motion_match) + self._root_locator.revalidate(robot_match) + try: + job_spec = self._retarget_service.get_job_spec(preflight.plan.plan_id) + except RetargetServiceError as exc: + raise LegacyJobUpgradeError(exc.api_error) from exc + if job_spec.plan_id != preflight.plan.plan_id: + raise _upgrade_error( + "JOB_SPEC_PLAN_MISMATCH", + "RetargetService returned a JobSpec for a different immutable plan.", + stage=ErrorStage.INTERNAL, + ) + + receipt = LegacyMigrationReceipt( + canonical_v1_sha256=hashlib.sha256(canonical_v1).hexdigest(), + motion_asset_id=motion_bundle.asset_id, + robot_asset_id=robot_bundle.asset_id, + plan_id=job_spec.plan_id, + job_spec_sha256=_sha256_json(job_spec.model_dump(mode="json")), + warnings=( + (_INTERACTION_IK_WARNING,) + if backend == "interaction_mesh" and "ik_iterations" in request + else () + ), + ) + return LegacyJobUpgradeResult( + preflight=preflight, + job_spec=job_spec, + receipt=receipt, + ) + + +__all__ = [ + "DynamicRootLocator", + "LegacyJobUpgradeError", + "LegacyJobUpgradeResult", + "LegacyJobUpgradeService", + "LegacyMigrationReceipt", + "RootProvider", + "RootUsage", +] diff --git a/hhtools/services/plans.py b/hhtools/services/plans.py new file mode 100644 index 00000000..0e640656 --- /dev/null +++ b/hhtools/services/plans.py @@ -0,0 +1,542 @@ +"""Immutable, content-addressed storage for resolved retarget plans. + +``PlanStore`` is deliberately transport-neutral. It persists only the public +``RetargetPlan`` document and the canonical JSON payload from which its +content id was derived. In particular, local filesystem paths must never +cross this boundary into the database. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import sqlite3 +from collections.abc import Mapping +from pathlib import Path, PurePosixPath, PureWindowsPath +from typing import Any, NoReturn + +from pydantic import ValidationError + +from hhtools.contracts import ApiError, ErrorStage, RetargetPlan + +_RETARGET_PLAN_SEMANTICS = "hhtools.retarget.plan.v1" + + +class PlanStoreError(RuntimeError): + """Expected plan-store failure with a transport-neutral error body.""" + + def __init__(self, error: ApiError) -> None: + self.error = error + super().__init__(f"{error.code}: {error.message}") + + @property + def api_error(self) -> ApiError: + """Alias used by adapters that expose structured API errors.""" + + return self.error + + @property + def code(self) -> str: + """Return the stable machine code without requiring message parsing.""" + + return self.error.code + + +class _InvalidDocumentError(ValueError): + """Private validation signal that never crosses the service boundary.""" + + +def _raise_duplicate_key(key: str) -> NoReturn: + raise _InvalidDocumentError(f"duplicate JSON object key: {key}") + + +def _object_from_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + _raise_duplicate_key(key) + result[key] = value + return result + + +def _reject_json_constant(value: str) -> NoReturn: + raise _InvalidDocumentError(f"non-finite JSON number: {value}") + + +def _strict_json_loads(payload: str) -> Any: + try: + return json.loads( + payload, + object_pairs_hook=_object_from_pairs, + parse_constant=_reject_json_constant, + ) + except (json.JSONDecodeError, TypeError, ValueError, RecursionError) as exc: + raise _InvalidDocumentError("invalid JSON document") from exc + + +def _looks_like_absolute_path(value: str) -> bool: + """Recognize POSIX, drive-qualified, rooted, and UNC host paths.""" + + posix = PurePosixPath(value) + windows = PureWindowsPath(value) + return posix.is_absolute() or windows.is_absolute() or bool(windows.drive) or bool(windows.root) + + +def _validate_portable_json(value: Any, *, location: str = "$") -> None: + """Reject values that are not finite portable JSON. + + The location is intentionally used only for internal diagnostics. Public + errors never echo values or absolute paths supplied by a caller. + """ + + if value is None or isinstance(value, bool | int): + return + if isinstance(value, float): + if not math.isfinite(value): + raise _InvalidDocumentError(f"non-finite number at {location}") + return + if isinstance(value, str): + if _looks_like_absolute_path(value): + raise _InvalidDocumentError(f"absolute host path at {location}") + return + if isinstance(value, list): + for index, item in enumerate(value): + _validate_portable_json(item, location=f"{location}[{index}]") + return + if isinstance(value, dict): + for key, item in value.items(): + if not isinstance(key, str): + raise _InvalidDocumentError(f"non-string object key at {location}") + if _looks_like_absolute_path(key): + raise _InvalidDocumentError(f"absolute host path key at {location}") + _validate_portable_json(item, location=f"{location}.{key}") + return + raise _InvalidDocumentError(f"non-JSON value at {location}") + + +def _canonical_json(value: Any) -> str: + _validate_portable_json(value) + try: + return json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ) + except (TypeError, ValueError, OverflowError, RecursionError) as exc: + raise _InvalidDocumentError("document cannot be encoded as canonical JSON") from exc + + +def _error( + code: str, + message: str, + *, + stage: ErrorStage = ErrorStage.PREFLIGHT, + retryable: bool = False, + details: Mapping[str, Any] | None = None, +) -> PlanStoreError: + return PlanStoreError( + ApiError( + code=code, + message=message, + retryable=retryable, + stage=stage, + details=dict(details or {}), + ) + ) + + +def _canonical_payload_document(canonical_payload: Mapping[str, Any]) -> tuple[str, dict[str, Any]]: + if not isinstance(canonical_payload, dict): + raise _error( + "INVALID_PARAMETER", + "A plan hash payload must be a JSON object with portable values.", + ) + try: + encoded = _canonical_json(canonical_payload) + normalized = _strict_json_loads(encoded) + except _InvalidDocumentError as exc: + raise _error( + "INVALID_PARAMETER", + "A plan hash payload must be finite portable JSON without host paths.", + ) from exc + if not isinstance(normalized, dict): + raise _error( + "INVALID_PARAMETER", + "A plan hash payload must be a JSON object with portable values.", + ) + return encoded, normalized + + +def compute_plan_id(canonical_payload: Mapping[str, Any]) -> str: + """Compute the deterministic ``plan:sha256`` id for a portable payload.""" + + encoded, _ = _canonical_payload_document(canonical_payload) + digest = hashlib.sha256(encoded.encode("utf-8")).hexdigest() + return f"plan:sha256:{digest}" + + +def _encode_plan(plan: RetargetPlan) -> str: + """Validate and snapshot a plan without retaining nested caller objects.""" + + try: + # RetargetPlan is frozen, but ``parameters`` is intentionally an open + # JSON object. Validate it before Pydantic has an opportunity to turn + # a Path or tuple into a superficially JSON-compatible representation. + _validate_portable_json(plan.parameters, location="$.parameters") + document = plan.model_dump(mode="json") + encoded = _canonical_json(document) + restored = RetargetPlan.model_validate_json(encoded) + except (_InvalidDocumentError, TypeError, ValueError, ValidationError) as exc: + raise _error( + "INVALID_PARAMETER", + "The retarget plan must be valid portable JSON without host paths.", + ) from exc + if restored != plan: + raise _error( + "INVALID_PARAMETER", + "The retarget plan did not survive a lossless JSON round trip.", + ) + return encoded + + +def _nested_object(document: Mapping[str, Any], field: str) -> Mapping[str, Any]: + value = document.get(field) + if not isinstance(value, dict): + raise _InvalidDocumentError(f"{field} must be a JSON object") + return value + + +def _is_sha256(value: Any) -> bool: + return ( + isinstance(value, str) + and len(value) == 64 + and all(character in "0123456789abcdef" for character in value) + ) + + +def _is_portable_relative_path(value: Any) -> bool: + if not isinstance(value, str) or not value or "\\" in value: + return False + posix = PurePosixPath(value) + windows = PureWindowsPath(value) + return ( + not posix.is_absolute() + and not windows.is_absolute() + and not windows.drive + and all(part not in {"", ".", ".."} for part in posix.parts) + ) + + +def _validate_retarget_plan_projection( + plan: RetargetPlan, + canonical_payload: Mapping[str, Any], +) -> None: + """Bind the public plan document to the canonical retarget semantics. + + The content id alone proves that ``canonical_payload`` has not changed; it + does not prove that the separately persisted public ``RetargetPlan`` was + projected from that payload. Validate the fields exposed to callers so a + cache hit cannot return a different robot, backend, calibration, or set of + effective parameters under an otherwise valid payload hash. + + Unknown semantics remain opaque for backwards compatibility. The v1 + retarget semantics are owned by HHTools, however, so malformed or divergent + documents must never be accepted as immutable plans. + """ + + if canonical_payload.get("semantics") != _RETARGET_PLAN_SEMANTICS: + return + + motion = _nested_object(canonical_payload, "motion") + robot = _nested_object(canonical_payload, "robot") + profile = _nested_object(canonical_payload, "retarget_profile") + output = _nested_object(canonical_payload, "output") + parameters = canonical_payload.get("parameters") + if not isinstance(parameters, dict): + raise _InvalidDocumentError("parameters must be a JSON object") + + profile_source = profile.get("source") + # Older v1 plans predate writable per-user calibration overlays. Treat a + # missing discriminator as the original robot-bundle storage so persisted + # plans retain their meaning after an upgrade. + profile_storage = profile.get("storage", "robot_bundle") + profile_digest = profile.get("digest") + calibration_id = profile.get("calibration_id") + profile_relative_path = profile.get("relative_path") + if not _is_sha256(profile_digest): + raise _InvalidDocumentError("retarget profile digest must be SHA-256") + if not _is_portable_relative_path(profile_relative_path): + raise _InvalidDocumentError("retarget profile path must be portable and relative") + if profile_storage not in {"robot_bundle", "user_calibration"}: + raise _InvalidDocumentError("unsupported retarget profile storage") + if profile_storage == "user_calibration": + robot_id = robot.get("robot_id") + reference = motion.get("reference") + if not isinstance(robot_id, str) or not isinstance(reference, str): + raise _InvalidDocumentError("user calibration identity is incomplete") + expected_paths = { + f"{robot_id}/retarget_calibration_{reference}.yaml", + f"{robot_id}/retarget_calibration.yaml", + } + if profile_relative_path not in expected_paths: + raise _InvalidDocumentError( + "user calibration path must match the plan robot and reference" + ) + if profile_source == "calibration": + if not isinstance(calibration_id, str): + raise _InvalidDocumentError("manual calibration must have an id") + if calibration_id != f"cal:sha256:{profile_digest}": + raise _InvalidDocumentError("manual calibration id must match the profile digest") + projected_calibration_digest: Any = profile_digest + elif profile_source == "bundled_scaler": + if profile_storage != "robot_bundle": + raise _InvalidDocumentError("bundled scaler must use robot-bundle storage") + if calibration_id is not None: + raise _InvalidDocumentError("bundled scaler cannot have a calibration id") + projected_calibration_digest = None + else: + raise _InvalidDocumentError("unsupported retarget profile source") + + projected = { + "motion_asset_id": motion.get("asset_id"), + "robot_id": robot.get("robot_id"), + "robot_asset_id": robot.get("asset_id"), + "backend": canonical_payload.get("backend"), + "calibration_id": calibration_id, + "output_format": output.get("format"), + "output_policy": output.get("policy"), + "parameters": parameters, + "input_digest": motion.get("digest"), + "robot_digest": robot.get("digest"), + "calibration_digest": projected_calibration_digest, + } + public_plan = plan.model_dump(mode="json") + divergent = sorted( + field for field, expected in projected.items() if public_plan.get(field) != expected + ) + if divergent: + raise _InvalidDocumentError( + "retarget plan fields diverge from canonical payload: " + ", ".join(divergent) + ) + + +class PlanStore: + """SQLite-backed immutable store for content-bound retarget plans.""" + + def __init__(self, data_dir: Path) -> None: + self._data_dir = Path(data_dir) + self._database_path = self._data_dir / "plans.sqlite3" + try: + self._data_dir.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise _error( + "INTERNAL_ERROR", + "The plan store directory could not be initialized.", + stage=ErrorStage.INTERNAL, + retryable=True, + ) from exc + self._initialize_database() + + @property + def database_path(self) -> Path: + """Return the internal database location for deployment diagnostics.""" + + return self._database_path + + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect(self._database_path, timeout=30.0) + connection.row_factory = sqlite3.Row + return connection + + def _initialize_database(self) -> None: + try: + with self._connect() as connection: + connection.execute("PRAGMA journal_mode=WAL") + connection.execute( + """ + CREATE TABLE IF NOT EXISTS plans ( + plan_id TEXT PRIMARY KEY, + plan_json TEXT NOT NULL, + canonical_payload_json TEXT NOT NULL + ) + """ + ) + except sqlite3.Error as exc: + raise _error( + "INTERNAL_ERROR", + "The plan store database could not be initialized.", + stage=ErrorStage.INTERNAL, + retryable=True, + ) from exc + + @staticmethod + def _decode_row(row: sqlite3.Row) -> tuple[RetargetPlan, dict[str, Any], str, str]: + try: + plan_id = row["plan_id"] + plan_json = row["plan_json"] + payload_json = row["canonical_payload_json"] + if ( + not isinstance(plan_id, str) + or not isinstance(plan_json, str) + or not isinstance(payload_json, str) + ): + raise _InvalidDocumentError("persisted columns have invalid types") + + plan_document = _strict_json_loads(plan_json) + payload_document = _strict_json_loads(payload_json) + if not isinstance(plan_document, dict) or not isinstance(payload_document, dict): + raise _InvalidDocumentError("persisted documents must be JSON objects") + _validate_portable_json(plan_document) + _validate_portable_json(payload_document) + + canonical_plan_json = _canonical_json(plan_document) + canonical_payload_json = _canonical_json(payload_document) + if canonical_plan_json != plan_json or canonical_payload_json != payload_json: + raise _InvalidDocumentError("persisted documents are not canonical JSON") + + plan = RetargetPlan.model_validate(plan_document) + expected_id = ( + f"plan:sha256:{hashlib.sha256(canonical_payload_json.encode('utf-8')).hexdigest()}" + ) + if plan.plan_id != plan_id or plan_id != expected_id: + raise _InvalidDocumentError("persisted plan identity is inconsistent") + _validate_retarget_plan_projection(plan, payload_document) + except ( + _InvalidDocumentError, + KeyError, + TypeError, + ValueError, + ValidationError, + ) as exc: + raise _error( + "INTERNAL_ERROR", + "A persisted retarget plan is invalid.", + stage=ErrorStage.INTERNAL, + ) from exc + return plan, payload_document, canonical_plan_json, canonical_payload_json + + def put_if_absent( + self, + plan: RetargetPlan, + canonical_payload: Mapping[str, Any], + ) -> RetargetPlan: + """Insert one plan exactly once, or return the identical stored plan. + + An existing id is never overwritten. Reusing it with any different + canonical payload or plan document is reported as ``PLAN_CONFLICT``. + """ + + payload_json, payload_document = _canonical_payload_document(canonical_payload) + expected_id = f"plan:sha256:{hashlib.sha256(payload_json.encode('utf-8')).hexdigest()}" + plan_json = _encode_plan(plan) + if plan.plan_id != expected_id: + raise _error( + "PLAN_CONFLICT", + "The plan id does not match its canonical hash payload.", + details={"expected_plan_id": expected_id}, + ) + try: + _validate_retarget_plan_projection(plan, payload_document) + except _InvalidDocumentError as exc: + raise _error( + "PLAN_CONFLICT", + "The retarget plan does not match its canonical hash payload.", + details={"plan_id": plan.plan_id}, + ) from exc + + try: + with self._connect() as connection: + connection.execute("BEGIN IMMEDIATE") + connection.execute( + """ + INSERT OR IGNORE INTO plans ( + plan_id, plan_json, canonical_payload_json + ) VALUES (?, ?, ?) + """, + (plan.plan_id, plan_json, payload_json), + ) + row = connection.execute( + """ + SELECT plan_id, plan_json, canonical_payload_json + FROM plans + WHERE plan_id = ? + """, + (plan.plan_id,), + ).fetchone() + except sqlite3.Error as exc: + raise _error( + "INTERNAL_ERROR", + "The retarget plan could not be persisted.", + stage=ErrorStage.INTERNAL, + retryable=True, + ) from exc + if row is None: + raise _error( + "INTERNAL_ERROR", + "The retarget plan was unavailable after persistence.", + stage=ErrorStage.INTERNAL, + retryable=True, + ) + + stored, _, stored_plan_json, stored_payload_json = self._decode_row(row) + if stored_payload_json != payload_json or stored_plan_json != plan_json: + raise _error( + "PLAN_CONFLICT", + "The plan id is already bound to a different immutable plan.", + details={"plan_id": plan.plan_id}, + ) + return stored + + def get(self, plan_id: str) -> RetargetPlan: + """Load a fresh validated plan object by content id.""" + + row = self._get_row(plan_id) + plan, _, _, _ = self._decode_row(row) + return plan + + def get_payload(self, plan_id: str) -> dict[str, Any]: + """Load a fresh JSON copy of the canonical plan hash payload.""" + + row = self._get_row(plan_id) + _, payload, _, payload_json = self._decode_row(row) + # Parsing again intentionally prevents a caller from retaining a + # mutable object shared with another result in this operation. + copied = _strict_json_loads(payload_json) + if not isinstance(copied, dict): # guarded by _decode_row + raise _error( + "INTERNAL_ERROR", + "A persisted retarget plan payload is invalid.", + stage=ErrorStage.INTERNAL, + ) + return copied + + def _get_row(self, plan_id: str) -> sqlite3.Row: + try: + with self._connect() as connection: + row = connection.execute( + """ + SELECT plan_id, plan_json, canonical_payload_json + FROM plans + WHERE plan_id = ? + """, + (plan_id,), + ).fetchone() + except sqlite3.Error as exc: + raise _error( + "INTERNAL_ERROR", + "The plan store could not be read.", + stage=ErrorStage.INTERNAL, + retryable=True, + ) from exc + if row is None: + raise _error( + "PLAN_NOT_FOUND", + "No immutable retarget plan has the requested id.", + ) + return row + + +__all__ = ["PlanStore", "PlanStoreError", "compute_plan_id"] diff --git a/hhtools/services/preflight.py b/hhtools/services/preflight.py new file mode 100644 index 00000000..238a5feb --- /dev/null +++ b/hhtools/services/preflight.py @@ -0,0 +1,1535 @@ +"""Read-only retarget preflight and immutable plan construction. + +The preflight boundary deliberately stops before solver construction and job +admission. It validates content-addressed assets, a read-only robot preset, +backend compatibility, calibration/scaler readiness, effective parameters, +and the scheduler snapshot. A successful call persists a portable plan; it +never imports a retarget pipeline, compiles MuJoCo, initializes Warp/Newton, +or reserves a queue slot. +""" + +from __future__ import annotations + +import hashlib +import math +import re +import uuid +from collections.abc import Callable, Iterable, Mapping +from dataclasses import asdict, is_dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, NoReturn +from urllib.parse import urlencode +from xml.etree import ElementTree + +from yaml import YAMLError # type: ignore[import-untyped] + +from hhtools.contracts import ( + ApiError, + AssetBundle, + AssetCategory, + AssetInspection, + AssetInspectionRequest, + AssetKind, + AssetRegistrationRequest, + AssetSourceScheme, + BackendCapability, + CapabilityResponse, + ErrorStage, + InspectionStatus, + NextAction, + OutputPolicy, + PreflightCheck, + PreflightCheckLevel, + PreflightResponse, + PreflightStatus, + RetargetPlan, + RetargetPreflightRequest, + SchedulerCapability, +) +from hhtools.robot.base import RobotPreset +from hhtools.services.asset_service import AgentAssetService +from hhtools.services.assets import AssetServiceError +from hhtools.services.plans import PlanStore, PlanStoreError, compute_plan_id +from hhtools.utils.paths import user_robot_dir + +_PLAN_SEMANTICS = "hhtools.retarget.plan.v1" +_SUPPORTED_OUTPUT_FORMATS = frozenset({"csv", "pkl"}) +_PARAMETERS = frozenset( + { + "run_mode", + "limit_frames", + "ik_iterations", + "human_height", + "retarget_fps", + "foot_clamp_anti_penetration", + } +) +_ACTUATED_JOINT_TYPES = frozenset({"revolute", "continuous", "prismatic"}) +_DEFAULT_MAX_IK_ITERATIONS = 200 +_DEFAULT_MAX_RETARGET_FPS = 1_000.0 +_DEFAULT_MAX_RETARGET_FRAMES = 100_000 +_DEFAULT_MAX_HUMAN_HEIGHT = 10.0 +_PORTABLE_ROBOT_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") + + +class _PreflightFailureError(RuntimeError): + """Private short-circuit carrying a safe public error and check.""" + + def __init__(self, error: ApiError, check: PreflightCheck) -> None: + self.error = error + self.check = check + super().__init__(f"{error.code}: {error.message}") + + +def _error( + code: str, + message: str, + *, + details: Mapping[str, Any] | None = None, + next_action: NextAction | None = None, + retryable: bool = False, + stage: ErrorStage = ErrorStage.PREFLIGHT, +) -> ApiError: + return ApiError( + code=code, + message=message, + retryable=retryable, + stage=stage, + details=dict(details or {}), + next_action=next_action, + ) + + +def _fail( + code: str, + message: str, + *, + details: Mapping[str, Any] | None = None, + next_action: NextAction | None = None, + retryable: bool = False, +) -> NoReturn: + error = _error( + code, + message, + details=details, + next_action=next_action, + retryable=retryable, + ) + raise _PreflightFailureError( + error, + PreflightCheck( + code=code, + level=PreflightCheckLevel.ERROR, + message=message, + details=dict(details or {}), + next_action=next_action, + ), + ) + + +def _check( + code: str, + level: PreflightCheckLevel, + message: str, + *, + details: Mapping[str, Any] | None = None, +) -> PreflightCheck: + return PreflightCheck( + code=code, + level=level, + message=message, + details=dict(details or {}), + ) + + +def _asset_digest(asset_id: str) -> str: + return asset_id.rsplit(":", 1)[-1] + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +class _FileIdentityMismatchError(ValueError): + """A file no longer matches the content identity selected for preflight.""" + + +class _FileChangedError(ValueError): + """A file changed while a parser was reading it.""" + + +def _parse_stable_file( + path: Path, + parser: Callable[[Path], Any], + *, + expected_digests: set[str] | None = None, +) -> tuple[Any, str]: + """Parse one file only when the bytes stay stable around the read. + + The parsers used by the existing robot/calibration modules accept paths. + Hashing immediately before and after parsing prevents a plan from binding + the digest of one revision to fields parsed from another revision. + """ + + before = _sha256_file(path) + if expected_digests is not None and before not in expected_digests: + raise _FileIdentityMismatchError("file does not match its selected identity") + parsed = parser(path) + after = _sha256_file(path) + if after != before: + raise _FileChangedError("file changed while it was being parsed") + return parsed, before + + +def _safe_asset_error(error: AssetServiceError) -> ApiError: + """Keep stable asset codes while moving the failure to preflight.""" + + return error.api_error.model_copy(update={"stage": ErrorStage.PREFLIGHT}) + + +def _raise_asset_error(error: AssetServiceError) -> NoReturn: + public = _safe_asset_error(error) + raise _PreflightFailureError( + public, + PreflightCheck( + code=public.code, + level=PreflightCheckLevel.ERROR, + message=public.message, + details=public.details, + next_action=public.next_action, + ), + ) from error + + +def _raise_inspection_error( + inspection: AssetInspection, + *, + fallback_code: str, + fallback_message: str, +) -> NoReturn: + """Preserve the most actionable inspector code at the preflight stage.""" + + priorities = { + "ASSET_HASH_MISMATCH": 0, + "ASSET_OUTSIDE_ALLOWED_ROOT": 1, + "ASSET_NOT_FOUND": 2, + "BUNDLE_INCOMPLETE": 3, + "BUNDLE_METADATA_MISMATCH": 4, + "UNSUPPORTED_FORMAT": 5, + } + if inspection.errors: + selected = min( + inspection.errors, + key=lambda item: priorities.get(item.code, 100), + ).model_copy(update={"stage": ErrorStage.PREFLIGHT}) + else: + selected = _error(fallback_code, fallback_message) + raise _PreflightFailureError( + selected, + PreflightCheck( + code=selected.code, + level=PreflightCheckLevel.ERROR, + message=selected.message, + details=selected.details, + next_action=selected.next_action, + ), + ) + + +def _backend_for_category(category: AssetCategory) -> str: + if category is AssetCategory.PLAIN_MOTION: + return "newton" + if category in {AssetCategory.OBJECT_INTERACTION, AssetCategory.TERRAIN_SCENE}: + return "interaction_mesh" + _fail( + "BACKEND_INCOMPATIBLE", + "The registered input category is not supported by retarget preflight.", + details={"category": category.value}, + ) + + +def _backend_capability( + capabilities: CapabilityResponse, + backend_id: str, +) -> BackendCapability: + backend = next( + (item for item in capabilities.backends if item.backend_id == backend_id), + None, + ) + if backend is None: + _fail( + "BACKEND_UNAVAILABLE", + "The requested retarget backend is not advertised by this service.", + details={"backend": backend_id}, + ) + if not backend.available: + _fail( + "BACKEND_UNAVAILABLE", + "The requested retarget backend is unavailable in this environment.", + details={"backend": backend_id}, + ) + return backend + + +def _strict_int( + value: Any, + *, + name: str, + minimum: int = 1, + maximum: int | None = None, +) -> int: + if ( + isinstance(value, bool) + or not isinstance(value, int) + or value < minimum + or (maximum is not None and value > maximum) + ): + range_text = ( + f"between {minimum} and {maximum}" + if maximum is not None + else f"greater than or equal to {minimum}" + ) + _fail( + "INVALID_PARAMETER", + f"{name} must be an integer {range_text}.", + details={"parameter": name, "maximum": maximum}, + ) + return value + + +def _strict_float( + value: Any, + *, + name: str, + minimum_exclusive: float, + maximum: float | None = None, +) -> float: + if isinstance(value, bool) or not isinstance(value, int | float): + _fail( + "INVALID_PARAMETER", + f"{name} must be a finite number in the supported range.", + details={"parameter": name, "maximum": maximum}, + ) + normalized = float(value) + if ( + not math.isfinite(normalized) + or normalized <= minimum_exclusive + or (maximum is not None and normalized > maximum) + ): + _fail( + "INVALID_PARAMETER", + f"{name} must be a finite number in the supported range.", + details={"parameter": name, "maximum": maximum}, + ) + return normalized + + +def _positive_capability_limit( + backend: BackendCapability, + name: str, + fallback: int | float, + *, + integer: bool = False, +) -> int | float: + """Read a trustworthy positive numeric limit from a backend snapshot.""" + + value = backend.limits.get(name, fallback) + if isinstance(value, bool) or not isinstance(value, int | float): + return fallback + if integer and not isinstance(value, int): + return fallback + normalized = float(value) + if not math.isfinite(normalized) or normalized <= 0: + return fallback + return value + + +def _normalize_parameters( + request: RetargetPreflightRequest, + inspection: AssetInspection, + *, + backend: BackendCapability, + reference: str, + profile_source: str, + default_human_height: float, +) -> dict[str, Any]: + raw = dict(request.parameters) + unknown = sorted(set(raw).difference(_PARAMETERS)) + if unknown: + _fail( + "INVALID_PARAMETER", + "The request contains unsupported retarget parameters.", + details={"parameters": unknown}, + ) + + run_mode = raw.get("run_mode", "smoke") + if not isinstance(run_mode, str) or run_mode not in {"smoke", "full"}: + _fail( + "INVALID_PARAMETER", + "run_mode must be either smoke or full.", + details={"parameter": "run_mode"}, + ) + + supplied_limit = raw.get("limit_frames") + if run_mode == "full": + if supplied_limit is not None: + _fail( + "INVALID_PARAMETER", + "limit_frames cannot be set when run_mode is full.", + details={"parameter": "limit_frames"}, + ) + limit_frames: int | None = None + else: + requested_limit = ( + 30 if supplied_limit is None else _strict_int(supplied_limit, name="limit_frames") + ) + limit_frames = ( + min(requested_limit, inspection.frame_count) + if inspection.frame_count is not None and inspection.frame_count > 0 + else requested_limit + ) + + if backend.backend_id == "newton": + maximum_iterations = int( + _positive_capability_limit( + backend, + "max_ik_iterations", + _DEFAULT_MAX_IK_ITERATIONS, + integer=True, + ) + ) + ik_iterations = _strict_int( + raw.get("ik_iterations", 24), + name="ik_iterations", + maximum=maximum_iterations, + ) + else: + if "ik_iterations" in raw: + _fail( + "INVALID_PARAMETER", + "ik_iterations is only supported by the Newton backend.", + details={"parameter": "ik_iterations", "backend": backend.backend_id}, + ) + ik_iterations = None + + maximum_human_height = float( + _positive_capability_limit( + backend, + "max_human_height", + _DEFAULT_MAX_HUMAN_HEIGHT, + ) + ) + human_height = _strict_float( + raw.get("human_height", default_human_height), + name="human_height", + minimum_exclusive=0.1, + maximum=maximum_human_height, + ) + source_fps = inspection.frame_rate_hz + requested_fps = raw.get("retarget_fps") + if requested_fps is not None: + requested_fps = _strict_float( + requested_fps, + name="retarget_fps", + minimum_exclusive=0.0, + ) + no_resample = requested_fps is None or ( + source_fps is not None and abs(requested_fps - float(source_fps)) < 1e-6 + ) + if requested_fps is not None and not no_resample: + maximum_retarget_fps = float( + _positive_capability_limit( + backend, + "max_retarget_fps", + _DEFAULT_MAX_RETARGET_FPS, + ) + ) + requested_fps = _strict_float( + requested_fps, + name="retarget_fps", + minimum_exclusive=0.0, + maximum=maximum_retarget_fps, + ) + if ( + source_fps is not None + and source_fps > 0 + and inspection.frame_count is not None + and inspection.frame_count > 1 + ): + predicted_frames = ( + math.floor((inspection.frame_count - 1) / float(source_fps) * requested_fps) + 1 + ) + maximum_frames = int( + _positive_capability_limit( + backend, + "max_retarget_frames", + _DEFAULT_MAX_RETARGET_FRAMES, + integer=True, + ) + ) + if predicted_frames > maximum_frames: + _fail( + "INVALID_PARAMETER", + "retarget_fps would create more frames than this backend allows.", + details={ + "parameter": "retarget_fps", + "maximum_frames": maximum_frames, + }, + ) + # The runtime returns the source FPS when no resampling was requested and + # also when the requested rate is effectively equal to it. Canonicalize + # both forms so semantically identical requests share one plan id. + retarget_fps: float | None + if source_fps is not None and source_fps > 0: + retarget_fps = float(source_fps) + if requested_fps is not None and not no_resample: + retarget_fps = requested_fps + else: + retarget_fps = requested_fps + foot_clamp = raw.get("foot_clamp_anti_penetration", False) + if not isinstance(foot_clamp, bool): + _fail( + "INVALID_PARAMETER", + "foot_clamp_anti_penetration must be a boolean.", + details={"parameter": "foot_clamp_anti_penetration"}, + ) + + normalized: dict[str, Any] = { + "run_mode": run_mode, + "limit_frames": limit_frames, + "human_height": human_height, + "retarget_fps": retarget_fps, + "foot_clamp_anti_penetration": foot_clamp, + "reference": reference, + "retarget_profile": profile_source, + } + if ik_iterations is not None: + normalized["ik_iterations"] = ik_iterations + return normalized + + +def _output_format( + request: RetargetPreflightRequest, + backend: BackendCapability, + capabilities: CapabilityResponse, +) -> str: + output_format = request.output_format.casefold() + advertised = set(backend.output_formats).intersection(capabilities.supported_output_formats) + if output_format not in _SUPPORTED_OUTPUT_FORMATS or output_format not in advertised: + _fail( + "INVALID_PARAMETER", + "The requested output format is not supported by the selected backend.", + details={"output_format": output_format, "backend": backend.backend_id}, + ) + return output_format + + +def _inspect_motion( + asset_service: AgentAssetService, + asset_id: str, +) -> tuple[AssetBundle, AssetInspection]: + try: + bundle = asset_service.get(asset_id) + if bundle.kind is not AssetKind.MOTION_BUNDLE: + _fail( + "ASSET_KIND_MISMATCH", + "motion_asset_id must refer to a registered motion bundle.", + details={"asset_id": asset_id, "kind": bundle.kind.value}, + ) + inspection = asset_service.inspect( + AssetInspectionRequest( + asset_id=asset_id, + verify_hashes=True, + parse_content=True, + ) + ) + except AssetServiceError as error: + _raise_asset_error(error) + + if inspection.status is InspectionStatus.INVALID: + _raise_inspection_error( + inspection, + fallback_code="MOTION_PARSE_FAILED", + fallback_message="The registered motion bundle did not pass content inspection.", + ) + if not bool(inspection.metadata.get("content_parsed", False)): + validation_code = str( + inspection.metadata.get( + "content_validation_code", + "CONTENT_REQUIRES_ISOLATED_VALIDATION", + ) + ) + _fail( + validation_code, + "The motion format requires an isolated content validator before execution.", + details={"source_format": inspection.source_format or "unknown"}, + ) + if inspection.frame_count is None or inspection.frame_count <= 0: + _fail( + "MOTION_PARSE_FAILED", + "The motion inspection did not report a positive frame count.", + ) + if not inspection.reference_model: + _fail( + "REFERENCE_UNDETERMINED", + "The motion reference model could not be determined safely.", + ) + return bundle, inspection + + +def _manifest_hashes(bundle: AssetBundle, *, role: str | None = None) -> set[str]: + return {item.sha256 for item in bundle.files if role is None or item.role.value == role} + + +def _contained_file(path: Path, root: Path) -> Path: + try: + resolved = path.resolve(strict=True) + resolved.relative_to(root.resolve(strict=True)) + except (OSError, RuntimeError, ValueError) as error: + raise ValueError("file is outside its robot preset boundary") from error + if not resolved.is_file(): + raise ValueError("expected a regular file") + return resolved + + +def _robot_joint_facts( + urdf_path: Path, +) -> tuple[set[str], dict[str, tuple[float | None, float | None]], set[str]]: + try: + root = ElementTree.parse(urdf_path).getroot() + except (ElementTree.ParseError, OSError) as error: + raise ValueError("robot URDF is not parseable") from error + links = {str(link.get("name")) for link in root.findall("link") if link.get("name")} + actuated: set[str] = set() + limits: dict[str, tuple[float | None, float | None]] = {} + for joint in root.findall("joint"): + name = (joint.get("name") or "").strip() + joint_type = (joint.get("type") or "").strip().casefold() + if not name or joint_type not in _ACTUATED_JOINT_TYPES: + continue + actuated.add(name) + lower: float | None = None + upper: float | None = None + limit = joint.find("limit") + if joint_type != "continuous" and limit is not None: + try: + lower_value = limit.get("lower") + upper_value = limit.get("upper") + lower = float(lower_value) if lower_value else None + upper = float(upper_value) if upper_value else None + except (TypeError, ValueError) as error: + raise ValueError("robot joint limits are malformed") from error + limits[name] = (lower, upper) + return actuated, limits, links + + +def _installed_robot_preset( + presets: Iterable[RobotPreset], + robot_id: str, +) -> RobotPreset: + preset = next((item for item in presets if item.name == robot_id), None) + if preset is None: + _fail( + "ROBOT_NOT_FOUND", + "The selected robot preset is not installed on this service.", + details={"robot_id": robot_id}, + ) + return preset + + +def _register_asset_action( + request: AssetRegistrationRequest, + *, + message: str, +) -> NextAction: + """Map a portable registration request directly to the public MCP tool.""" + + return NextAction( + actor="agent", + action="register_asset_bundle", + message=message, + parameters={"request": request.model_dump(mode="json")}, + ) + + +def _bundle_registration_request(bundle: AssetBundle) -> AssetRegistrationRequest: + """Reconstruct a strict registration request from one portable source.""" + + source = bundle.source + if ( + source is None + or source.scheme is not AssetSourceScheme.MANAGED_FILE + or source.logical_path is None + ): + _fail( + "ROBOT_BUNDLE_INVALID", + "The robot bundle has no reusable allowed-root registration source.", + ) + try: + return AssetRegistrationRequest( + root_id=source.root_id, + relative_path=source.logical_path, + display_name=None, + kind=bundle.kind, + category=bundle.category, + recursive=True, + ) + except (TypeError, ValueError) as error: + raise _PreflightFailureError( + _error( + "ROBOT_BUNDLE_INVALID", + "The robot bundle registration source is not portable.", + ), + _check( + "ROBOT_BUNDLE_INVALID", + PreflightCheckLevel.ERROR, + "The robot bundle registration source is not portable.", + ), + ) from error + + +def _robot_bundle_and_preset( + asset_service: AgentAssetService, + request: RetargetPreflightRequest, + presets: Iterable[RobotPreset], +) -> tuple[AssetBundle, RobotPreset, dict[str, tuple[float | None, float | None]]]: + if request.robot_asset_id is None: + advertised_preset = _installed_robot_preset(presets, request.robot_id) + try: + registration = asset_service.registration_hint( + advertised_preset.root_dir, + kind=AssetKind.ROBOT_BUNDLE, + category=AssetCategory.ROBOT_MODEL, + ) + except AssetServiceError as error: + _raise_asset_error(error) + action = _register_asset_action( + registration, + message="Register the installed robot directory, including YAML, URDF, and meshes.", + ) + _fail( + "ROBOT_ASSET_REQUIRED", + "A content-addressed robot bundle is required for a runnable plan.", + details={"robot_id": request.robot_id}, + next_action=action, + ) + try: + bundle = asset_service.get(request.robot_asset_id) + if bundle.kind is not AssetKind.ROBOT_BUNDLE: + _fail( + "ASSET_KIND_MISMATCH", + "robot_asset_id must refer to a registered robot bundle.", + details={ + "asset_id": request.robot_asset_id, + "kind": bundle.kind.value, + }, + ) + inspection = asset_service.inspect( + AssetInspectionRequest( + asset_id=request.robot_asset_id, + verify_hashes=True, + parse_content=True, + ) + ) + except AssetServiceError as error: + _raise_asset_error(error) + if inspection.status is InspectionStatus.INVALID: + _raise_inspection_error( + inspection, + fallback_code="ROBOT_BUNDLE_INVALID", + fallback_message=("The registered robot bundle did not pass structural inspection."), + ) + + advertised_preset = _installed_robot_preset(presets, request.robot_id) + yaml_value = advertised_preset.meta.get("yaml_path") + if not isinstance(yaml_value, str) or not yaml_value: + _fail( + "ROBOT_BUNDLE_INVALID", + "The selected robot preset has no bound robot YAML.", + details={"robot_id": request.robot_id}, + ) + try: + yaml_path = _contained_file(Path(yaml_value), advertised_preset.root_dir) + except (OSError, ValueError) as error: + raise _PreflightFailureError( + _error( + "ROBOT_BUNDLE_INVALID", + "The selected robot YAML is unavailable or outside the preset boundary.", + details={"robot_id": request.robot_id}, + ), + _check( + "ROBOT_BUNDLE_INVALID", + PreflightCheckLevel.ERROR, + "The selected robot YAML is unavailable or outside the preset boundary.", + details={"robot_id": request.robot_id}, + ), + ) from error + # Reload the exact manifest-bound YAML instead of trusting a mutable or + # previously populated process cache. Every value validated below is now + # derived from the same bytes that participate in the robot bundle hash. + try: + from hhtools.robot.registry import preset_from_yaml + + preset, _yaml_digest = _parse_stable_file( + yaml_path, + preset_from_yaml, + expected_digests=_manifest_hashes(bundle, role="metadata"), + ) + except _FileIdentityMismatchError: + _fail( + "ROBOT_BUNDLE_MISMATCH", + "The robot asset does not contain the YAML used by the selected preset.", + details={"robot_id": request.robot_id}, + ) + except _FileChangedError: + _fail( + "ASSET_HASH_MISMATCH", + "The selected robot YAML changed during preflight; register it again.", + details={"robot_id": request.robot_id}, + retryable=True, + ) + except (OSError, TypeError, ValueError): + _fail( + "ROBOT_BUNDLE_INVALID", + "The manifest-bound robot YAML could not be loaded read-only.", + details={"robot_id": request.robot_id}, + ) + if preset.name != request.robot_id: + _fail( + "ROBOT_BUNDLE_MISMATCH", + "The manifest-bound robot YAML resolves to a different preset id.", + details={"robot_id": request.robot_id}, + ) + if not preset.has_urdf or preset.urdf_path is None: + _fail( + "ROBOT_BUNDLE_INVALID", + "The selected robot preset has no readable URDF.", + details={"robot_id": request.robot_id}, + ) + + primary = next(item for item in bundle.files if item.relative_path == bundle.primary_file) + try: + preset_urdf = _contained_file(preset.urdf_path, preset.root_dir) + except (OSError, ValueError) as error: + raise _PreflightFailureError( + _error( + "ROBOT_BUNDLE_INVALID", + "The robot preset URDF is outside its trusted preset directory.", + details={"robot_id": request.robot_id}, + ), + _check( + "ROBOT_BUNDLE_INVALID", + PreflightCheckLevel.ERROR, + "The robot preset URDF is outside its trusted preset directory.", + details={"robot_id": request.robot_id}, + ), + ) from error + try: + robot_facts, _preset_urdf_digest = _parse_stable_file( + preset_urdf, + _robot_joint_facts, + expected_digests={primary.sha256}, + ) + except _FileIdentityMismatchError: + _fail( + "ROBOT_BUNDLE_MISMATCH", + "The robot asset does not match the URDF used by the selected preset.", + details={"robot_id": request.robot_id}, + ) + except _FileChangedError: + _fail( + "ASSET_HASH_MISMATCH", + "The selected robot URDF changed during preflight; register it again.", + details={"robot_id": request.robot_id}, + retryable=True, + ) + except ValueError: + _fail( + "ROBOT_BUNDLE_INVALID", + "The robot URDF topology or joint limits could not be validated.", + details={"robot_id": request.robot_id}, + ) + actuated, limits, links = robot_facts + if not preset.dof_order or len(set(preset.dof_order)) != len(preset.dof_order): + _fail( + "ROBOT_CONFIGURATION_INVALID", + "The robot preset must declare a non-empty, unique DOF order.", + details={"robot_id": request.robot_id}, + ) + invalid_dofs = sorted(set(preset.dof_order).difference(actuated)) + if invalid_dofs: + _fail( + "ROBOT_CONFIGURATION_INVALID", + "The robot DOF order contains joints that are not actuated by the URDF.", + details={"joint_names": invalid_dofs}, + ) + if not preset.ik_map: + _fail( + "ROBOT_CONFIGURATION_INVALID", + "The robot preset must declare a non-empty IK mapping.", + details={"robot_id": request.robot_id}, + ) + missing_links = sorted( + { + str(link) + for link in preset.ik_map.values() + if not isinstance(link, str) or not link or link not in links + } + ) + if missing_links: + _fail( + "ROBOT_CONFIGURATION_INVALID", + "The robot IK mapping refers to links absent from the URDF.", + details={"link_names": missing_links}, + ) + try: + from hhtools.robot.kinematics import validate_ik_map + + issues = validate_ik_map(preset_urdf, dict(preset.ik_map)) + except (ElementTree.ParseError, OSError, RuntimeError, ValueError): + _fail( + "ROBOT_CONFIGURATION_INVALID", + "The robot IK mapping could not be checked against URDF topology.", + ) + if issues: + _fail( + "ROBOT_CONFIGURATION_INVALID", + "The robot IK mapping is inconsistent with the URDF topology.", + details={ + "slots": sorted({issue.slot for issue in issues}), + "issue_count": len(issues), + }, + ) + return bundle, preset, limits + + +def _validate_finite_document(value: Any) -> bool: + if value is None or isinstance(value, bool | int | str): + return True + if isinstance(value, float): + return math.isfinite(value) + if isinstance(value, Mapping): + return all( + isinstance(key, str) and _validate_finite_document(item) for key, item in value.items() + ) + if isinstance(value, list | tuple): + return all(_validate_finite_document(item) for item in value) + return False + + +def _validate_scaler_semantics(scaler: Any, preset: RobotPreset) -> None: + """Mirror cheap Scaler runtime preconditions without building a scaler.""" + + joint_scales = getattr(scaler, "joint_scales", None) + if not isinstance(joint_scales, dict) or not joint_scales: + raise ValueError("scaler joint_scales is empty") + if any( + not isinstance(name, str) + or not name + or isinstance(value, bool) + or not isinstance(value, int | float) + or not math.isfinite(float(value)) + or float(value) <= 0.0 + for name, value in joint_scales.items() + ): + raise ValueError("scaler joint scales must be finite and positive") + root_joint = getattr(scaler, "root_joint", None) + if not isinstance(root_joint, str) or root_joint not in joint_scales: + raise ValueError("scaler root joint is not mapped") + if getattr(scaler, "up_axis", None) not in {"X", "Y", "Z"}: + raise ValueError("scaler up axis is unsupported") + if getattr(scaler, "scale_mode", None) not in {"uniform", "height"}: + raise ValueError("scaler scale mode is unsupported") + if getattr(scaler, "scale_anchor", None) not in {"origin", "root"}: + raise ValueError("scaler scale anchor is unsupported") + missing_ik_slots = set(preset.ik_map).difference(joint_scales) + if missing_ik_slots: + raise ValueError("scaler does not cover the robot IK mapping") + offsets = getattr(scaler, "joint_offsets", {}) + if not isinstance(offsets, dict) or set(offsets).difference(joint_scales): + raise ValueError("scaler offsets refer to unmapped joints") + for _translation, quaternion in offsets.values(): + if sum(float(component) ** 2 for component in quaternion) <= 1e-12: + raise ValueError("scaler offset quaternion has zero norm") + source_body_quat = getattr(scaler, "source_body_quat", ()) + if ( + len(source_body_quat) != 4 + or sum(float(component) ** 2 for component in source_body_quat) <= 1e-12 + ): + raise ValueError("scaler source-body quaternion has zero norm") + trajectory_scale = getattr(scaler, "root_trajectory_scale", None) + if trajectory_scale is not None and float(trajectory_scale) <= 0.0: + raise ValueError("scaler root trajectory scale is not positive") + + +def _calibration_action(robot_id: str, reference: str) -> NextAction: + query = urlencode({"panel": "h2r", "robot": robot_id, "calibrate": reference}) + return NextAction( + actor="human", + action="open_calibration_ui", + message="Open the HHTools calibration UI and save this robot/reference alignment.", + url=f"/?{query}", + parameters={"robot_id": robot_id, "reference": reference}, + ) + + +def _manual_calibration( + preset: RobotPreset, + reference: str, + limits: Mapping[str, tuple[float | None, float | None]], + robot_bundle: AssetBundle, +) -> tuple[Path, str, str, str] | None: + from hhtools.retarget.calibration import ( + load_calibration, + normalize_calibration_reference, + resolve_preset_calibration_file, + ) + + try: + managed_user_root = user_robot_dir().resolve(strict=True) + path = resolve_preset_calibration_file( + preset, + reference, + user_root=managed_user_root, + ) + except (OSError, RuntimeError, TypeError, ValueError, YAMLError): + _fail( + "CALIBRATION_MISMATCH", + "The matching robot calibration exists but is malformed.", + details={"robot_id": preset.name, "reference": reference}, + ) + if path is None: + return None + try: + try: + contained = _contained_file(path, managed_user_root) + storage = "user_calibration" + except ValueError: + contained = _contained_file(path, preset.root_dir) + storage = "robot_bundle" + calibration, digest = _parse_stable_file(contained, load_calibration) + except _FileChangedError: + _fail( + "CALIBRATION_MISMATCH", + "The matching robot calibration changed during preflight; retry it.", + details={"robot_id": preset.name, "reference": reference}, + retryable=True, + ) + except (FileNotFoundError, OSError, TypeError, ValueError, YAMLError): + _fail( + "CALIBRATION_MISMATCH", + "The matching robot calibration exists but is malformed.", + details={"robot_id": preset.name, "reference": reference}, + ) + try: + calibration_reference = normalize_calibration_reference(calibration.reference) + except (TypeError, ValueError): + _fail( + "CALIBRATION_MISMATCH", + "The matching robot calibration declares an unsupported reference.", + details={"robot_id": preset.name, "reference": reference}, + ) + if calibration.robot != preset.name: + _fail( + "CALIBRATION_MISMATCH", + "The calibration must name this exact robot preset.", + details={"robot_id": preset.name, "reference": reference}, + ) + if calibration_reference != reference: + _fail( + "CALIBRATION_MISMATCH", + "The calibration belongs to a different motion reference.", + details={"robot_id": preset.name, "reference": reference}, + ) + unknown = sorted(set(calibration.calibrated_joint_q).difference(preset.dof_order)) + if unknown: + _fail( + "CALIBRATION_MISMATCH", + "The calibration contains joints outside the robot DOF order.", + details={"joint_names": unknown}, + ) + for name, value in calibration.calibrated_joint_q.items(): + if not math.isfinite(value): + _fail( + "CALIBRATION_MISMATCH", + "The calibration contains a non-finite joint value.", + details={"joint_name": name}, + ) + lower, upper = limits.get(name, (None, None)) + if (lower is not None and value < lower) or (upper is not None and value > upper): + _fail( + "CALIBRATION_MISMATCH", + "The calibration contains a joint value outside its URDF limit.", + details={"joint_name": name}, + ) + if storage == "robot_bundle" and digest not in _manifest_hashes( + robot_bundle, + role="metadata", + ): + action = _register_asset_action( + _bundle_registration_request(robot_bundle), + message="Register the robot bundle again so the calibration is content-bound.", + ) + _fail( + "ROBOT_BUNDLE_MISMATCH", + "The matching calibration is not bound into the registered robot bundle.", + details={"robot_id": preset.name, "reference": reference}, + next_action=action, + ) + return contained, digest, f"cal:sha256:{digest}", storage + + +def _bundled_scaler( + preset: RobotPreset, + reference: str, + robot_bundle: AssetBundle, +) -> tuple[Path, str, float] | None: + from hhtools.retarget.newton_basic.config import load_scaler_config + from hhtools.robot.retarget_profile import bundled_scaler_path + + try: + candidate = bundled_scaler_path(preset, reference) + except (OSError, TypeError, ValueError): + candidate = None + if candidate is None: + return None + try: + contained = _contained_file(candidate, preset.root_dir) + scaler, digest = _parse_stable_file( + contained, + load_scaler_config, + expected_digests=_manifest_hashes(robot_bundle, role="metadata"), + ) + document = ( + asdict(scaler) + if is_dataclass(scaler) and not isinstance(scaler, type) + else vars(scaler) + ) + if not _validate_finite_document(document): + raise ValueError("scaler contains non-finite values") + if float(scaler.human_height_assumption) <= 0.1 or float(scaler.model_height) <= 0.1: + raise ValueError("scaler heights are invalid") + _validate_scaler_semantics(scaler, preset) + except (AttributeError, KeyError, OSError, TypeError, ValueError): + _fail( + "CALIBRATION_MISMATCH", + "The bundled robot scaler is missing, unbound, or malformed.", + details={"robot_id": preset.name, "reference": reference}, + ) + return contained, digest, float(scaler.human_height_assumption) + + +def _retarget_profile( + request: RetargetPreflightRequest, + *, + backend: str, + preset: RobotPreset, + reference: str, + limits: Mapping[str, tuple[float | None, float | None]], + robot_bundle: AssetBundle, +) -> tuple[str, str, str | None, float, str, str]: + manual = _manual_calibration(preset, reference, limits, robot_bundle) + scaler = None + if backend == "newton" and manual is None: + scaler = _bundled_scaler(preset, reference, robot_bundle) + if manual is None and scaler is None: + if request.calibration_id is not None: + _fail( + "CALIBRATION_MISMATCH", + "No installed robot calibration matches the requested id.", + details={"robot_id": preset.name, "reference": reference}, + ) + action = _calibration_action(preset.name, reference) + raise _PreflightFailureError( + _error( + "CALIBRATION_REQUIRED", + "A matching human-reviewed robot calibration is required.", + details={"robot_id": preset.name, "reference": reference}, + next_action=action, + ), + PreflightCheck( + code="CALIBRATION_REQUIRED", + level=PreflightCheckLevel.ERROR, + message="A matching human-reviewed robot calibration is required.", + details={"robot_id": preset.name, "reference": reference}, + next_action=action, + ), + ) + if manual is not None: + profile_path, digest, calibration_id, storage = manual + source = "calibration" + from hhtools.robot.retarget_profile import default_human_height + + human_height_default = default_human_height(preset, reference) + else: + assert scaler is not None + profile_path, digest, human_height_default = scaler + calibration_id = None + source = "bundled_scaler" + storage = "robot_bundle" + if request.calibration_id is not None and request.calibration_id != calibration_id: + _fail( + "CALIBRATION_MISMATCH", + "The requested calibration id does not match the selected robot profile.", + details={ + "robot_id": preset.name, + "reference": reference, + "expected_calibration_id": calibration_id, + }, + ) + try: + profile_root = ( + user_robot_dir().resolve(strict=True) + if storage == "user_calibration" + else preset.root_dir.resolve(strict=True) + ) + relative_path = profile_path.relative_to(profile_root).as_posix() + except (OSError, ValueError): + _fail( + "ROBOT_BUNDLE_MISMATCH", + "The selected retarget profile is outside its managed storage boundary.", + details={ + "robot_id": preset.name, + "reference": reference, + "storage": storage, + }, + ) + return source, digest, calibration_id, human_height_default, relative_path, storage + + +def _scheduler_check(scheduler: SchedulerCapability) -> PreflightCheck: + details = { + "max_running_jobs": scheduler.max_running_jobs, + "max_queued_jobs": scheduler.max_queued_jobs, + "running": scheduler.running, + "queued": scheduler.queued, + "reserved": scheduler.reserved, + "mode": scheduler.mode.value, + } + if scheduler.closed: + _fail( + "SCHEDULER_CLOSED", + "The job scheduler is shutting down and cannot accept new work.", + details=details, + retryable=True, + ) + if scheduler.max_running_jobs == 0: + return _check( + "JOB_ADMISSION", + PreflightCheckLevel.WARNING, + "Job concurrency is configured as unlimited.", + details=details, + ) + if scheduler.max_queued_jobs > 0: + capacity = scheduler.max_running_jobs + scheduler.max_queued_jobs + occupied = scheduler.running + scheduler.queued + scheduler.reserved + if occupied >= capacity: + return _check( + "JOB_ADMISSION", + PreflightCheckLevel.WARNING, + "The bounded queue is currently full; start may need to be retried.", + details=details, + ) + return _check( + "JOB_ADMISSION", + PreflightCheckLevel.PASS, + "The scheduler policy can admit work when the plan is started.", + details=details, + ) + + +class PreflightService: + """Resolve Agent retarget intent into a content-bound immutable plan.""" + + def __init__( + self, + asset_service: AgentAssetService, + plan_store: PlanStore, + *, + capabilities_provider: Callable[[], CapabilityResponse], + robot_provider: Callable[[], Iterable[RobotPreset]] | None = None, + clock: Callable[[], datetime] = lambda: datetime.now(UTC), + request_id_provider: Callable[[], str] = lambda: f"req_{uuid.uuid4().hex}", + ) -> None: + if robot_provider is None: + from hhtools.robot.registry import list_presets_readonly + + robot_provider = list_presets_readonly + self._asset_service = asset_service + self._plan_store = plan_store + self._capabilities_provider = capabilities_provider + self._robot_provider = robot_provider + self._clock = clock + self._request_id_provider = request_id_provider + + def preflight_retarget(self, request: RetargetPreflightRequest) -> PreflightResponse: + """Validate one request without starting a solver or reserving admission.""" + + request_id = self._request_id_provider() + checks: list[PreflightCheck] = [] + recommended_backend: str | None = None + try: + if request.output_policy is not OutputPolicy.CREATE_NEW: + _fail( + "UNSUPPORTED_OUTPUT_POLICY", + "Managed Agent artifacts currently support only create_new output policy.", + details={"output_policy": request.output_policy.value}, + ) + if _PORTABLE_ROBOT_ID.fullmatch(request.robot_id) is None: + _fail( + "INVALID_PARAMETER", + "robot_id must be a portable identifier.", + details={"parameter": "robot_id"}, + ) + motion_bundle, motion_inspection = _inspect_motion( + self._asset_service, + request.motion_asset_id, + ) + checks.append( + _check( + "INPUT_PARSEABLE", + ( + PreflightCheckLevel.WARNING + if motion_inspection.warnings + else PreflightCheckLevel.PASS + ), + "The motion bundle passed safe content inspection.", + details={ + "frame_count": motion_inspection.frame_count, + "source_format": motion_inspection.source_format, + "warning_count": len(motion_inspection.warnings), + }, + ) + ) + recommended_backend = _backend_for_category(motion_inspection.category) + requested_backend = request.backend or recommended_backend + if requested_backend != recommended_backend: + _fail( + "BACKEND_INCOMPATIBLE", + "The requested backend does not support this motion category.", + details={ + "backend": requested_backend, + "category": motion_inspection.category.value, + "recommended_backend": recommended_backend, + }, + ) + + capabilities = self._capabilities_provider() + backend = _backend_capability(capabilities, requested_backend) + if motion_inspection.category not in backend.supported_categories: + _fail( + "BACKEND_INCOMPATIBLE", + "The backend capability does not advertise this input category.", + details={ + "backend": backend.backend_id, + "category": motion_inspection.category.value, + }, + ) + checks.append( + _check( + "BACKEND_READY", + PreflightCheckLevel.PASS, + "The selected backend is installed and compatible with the input.", + details={"backend": backend.backend_id}, + ) + ) + + robot_bundle, preset, joint_limits = _robot_bundle_and_preset( + self._asset_service, + request, + self._robot_provider(), + ) + checks.append( + _check( + "ROBOT_BUNDLE_READY", + PreflightCheckLevel.PASS, + "The robot bundle, preset, DOF order, and IK map agree.", + details={ + "robot_id": preset.name, + "dof_count": len(preset.dof_order), + }, + ) + ) + + from hhtools.retarget.calibration import normalize_calibration_reference + + reference = normalize_calibration_reference(str(motion_inspection.reference_model)) + ( + profile_source, + profile_digest, + calibration_id, + human_height_default, + profile_relative_path, + profile_storage, + ) = _retarget_profile( + request, + backend=backend.backend_id, + preset=preset, + reference=reference, + limits=joint_limits, + robot_bundle=robot_bundle, + ) + checks.append( + _check( + "CALIBRATION_MATCH", + PreflightCheckLevel.PASS, + "The selected robot profile matches the motion reference.", + details={ + "robot_id": preset.name, + "reference": reference, + "profile_source": profile_source, + "profile_storage": profile_storage, + }, + ) + ) + + output_format = _output_format(request, backend, capabilities) + if ( + output_format == "pkl" + and motion_inspection.category is AssetCategory.OBJECT_INTERACTION + ): + _fail( + "UNSUPPORTED_PORTABLE_EXPORT", + "Object-interaction PKL export can expose host mesh paths.", + details={ + "category": motion_inspection.category.value, + "output_format": output_format, + }, + ) + parameters = _normalize_parameters( + request, + motion_inspection, + backend=backend, + reference=reference, + profile_source=profile_source, + default_human_height=human_height_default, + ) + checks.append( + _check( + "PARAMETERS_VALID", + PreflightCheckLevel.PASS, + "Retarget parameters and output policy were normalized successfully.", + details={ + "run_mode": parameters["run_mode"], + "output_format": output_format, + }, + ) + ) + checks.append(_scheduler_check(capabilities.scheduler)) + + canonical_payload = { + "semantics": _PLAN_SEMANTICS, + "motion": { + "asset_id": motion_bundle.asset_id, + "digest": _asset_digest(motion_bundle.asset_id), + "category": motion_inspection.category.value, + "dataset": motion_inspection.dataset, + "reference": reference, + }, + "robot": { + "asset_id": robot_bundle.asset_id, + "digest": _asset_digest(robot_bundle.asset_id), + "robot_id": preset.name, + }, + "backend": backend.backend_id, + "retarget_profile": { + "source": profile_source, + "storage": profile_storage, + "calibration_id": calibration_id, + "digest": profile_digest, + "relative_path": profile_relative_path, + }, + "output": { + "format": output_format, + "policy": request.output_policy.value, + }, + "parameters": parameters, + } + plan_id = compute_plan_id(canonical_payload) + try: + plan = self._plan_store.get(plan_id) + except PlanStoreError as error: + if error.code != "PLAN_NOT_FOUND": + raise + candidate = RetargetPlan( + plan_id=plan_id, + created_at=self._clock(), + motion_asset_id=motion_bundle.asset_id, + robot_id=preset.name, + robot_asset_id=robot_bundle.asset_id, + backend=backend.backend_id, + calibration_id=calibration_id, + output_format=output_format, + output_policy=request.output_policy, + parameters=parameters, + input_digest=_asset_digest(motion_bundle.asset_id), + robot_digest=_asset_digest(robot_bundle.asset_id), + calibration_digest=( + profile_digest if profile_source == "calibration" else None + ), + ) + try: + plan = self._plan_store.put_if_absent( + candidate, + canonical_payload, + ) + except PlanStoreError as conflict: + # A concurrent identical preflight may have won between + # get() and put_if_absent(). Return that immutable plan; + # any other store error remains a real failure. + if conflict.code != "PLAN_CONFLICT": + raise + plan = self._plan_store.get(plan_id) + if self._plan_store.get_payload(plan_id) != canonical_payload: + raise + return PreflightResponse( + request_id=request_id, + status=PreflightStatus.READY, + plan=plan, + checks=checks, + recommended_backend=recommended_backend, + ) + except _PreflightFailureError as failure: + checks.append(failure.check) + if failure.error.code == "CALIBRATION_REQUIRED": + assert failure.error.next_action is not None + return PreflightResponse( + request_id=request_id, + status=PreflightStatus.HUMAN_ACTION_REQUIRED, + checks=checks, + recommended_backend=recommended_backend, + required_actions=[failure.error.next_action], + ) + return PreflightResponse( + request_id=request_id, + status=PreflightStatus.REJECTED, + checks=checks, + recommended_backend=recommended_backend, + error=failure.error, + ) + except PlanStoreError as failure: + public = failure.api_error + checks.append( + PreflightCheck( + code=public.code, + level=PreflightCheckLevel.ERROR, + message=public.message, + details=public.details, + next_action=public.next_action, + ) + ) + return PreflightResponse( + request_id=request_id, + status=PreflightStatus.REJECTED, + checks=checks, + recommended_backend=recommended_backend, + error=public, + ) + + +__all__ = ["PreflightService"] diff --git a/hhtools/services/retarget.py b/hhtools/services/retarget.py new file mode 100644 index 00000000..7aa993fb --- /dev/null +++ b/hhtools/services/retarget.py @@ -0,0 +1,503 @@ +"""Read-only projection of immutable retarget plans into JobSpec v2. + +This facade is the final non-executing boundary before a future JobManager. It +does not import a solver, select or probe a device, reserve scheduler admission, +or create output files. A specification is rebuilt from an immutable plan on +every call after re-verifying the registered asset bytes. + +Runtime provenance is captured once when the service is constructed. The +default provider reads the Git/build and installed-package identity only; it +never queries GPU memory, scheduler occupancy, or another transient device +state. The returned spec uses the plan creation time, so the same plan and +service provenance produce the same JobSpec without a second persistence +layer. +""" + +from __future__ import annotations + +import hashlib +import json +import platform +import re +import subprocess +from collections.abc import Callable, Mapping +from importlib import metadata +from pathlib import Path +from typing import Any, Protocol + +from pydantic import ValidationError + +from hhtools._version import __version__ +from hhtools.contracts import ( + ApiError, + AssetBundle, + AssetInspection, + AssetInspectionRequest, + AssetKind, + ErrorStage, + InspectionStatus, + JobSpecCalibration, + JobSpecInput, + JobSpecKind, + JobSpecProvenance, + JobSpecRobot, + JobSpecV2, + NextAction, + RetargetPlan, +) +from hhtools.utils.paths import user_robot_dir + +from .assets import AssetServiceError +from .plans import PlanStore, PlanStoreError + +_PLAN_SEMANTICS = "hhtools.retarget.plan.v1" +_ASSET_ID_PREFIX = "asset:sha256:" +_GIT_COMMIT = re.compile(r"^[0-9a-f]{40,64}$") +_REPOSITORY_ROOT = Path(__file__).resolve().parents[2] + + +class RetargetServiceError(RuntimeError): + """Expected facade failure with the shared transport-neutral error body.""" + + def __init__(self, error: ApiError) -> None: + self.error = error + super().__init__(f"{error.code}: {error.message}") + + @property + def api_error(self) -> ApiError: + return self.error + + @property + def code(self) -> str: + return self.error.code + + +class _AssetProvider(Protocol): + def get(self, asset_id: str) -> AssetBundle: ... + + def inspect(self, request: AssetInspectionRequest) -> AssetInspection: ... + + +ProvenanceProvider = Callable[[], JobSpecProvenance | Mapping[str, Any]] + + +def _service_error( + code: str, + message: str, + *, + stage: ErrorStage = ErrorStage.PREFLIGHT, + retryable: bool = False, + details: Mapping[str, Any] | None = None, + next_action: NextAction | None = None, +) -> RetargetServiceError: + return RetargetServiceError( + ApiError( + code=code, + message=message, + stage=stage, + retryable=retryable, + details=dict(details or {}), + next_action=next_action, + ) + ) + + +def _distribution_version(*names: str) -> str | None: + for name in names: + try: + return metadata.version(name) + except metadata.PackageNotFoundError: + continue + return None + + +def _git_output(*arguments: str) -> str | None: + """Read one bounded Git fact without invoking a shell.""" + + try: + completed = subprocess.run( # noqa: S603 - fixed executable and arguments + ["git", "-C", str(_REPOSITORY_ROOT), *arguments], # noqa: S607 + check=False, + capture_output=True, + encoding="utf-8", + errors="replace", + timeout=2.0, + ) + except (OSError, subprocess.SubprocessError): + return None + if completed.returncode != 0: + return None + return completed.stdout.strip() + + +def _default_provenance() -> JobSpecProvenance: + """Capture static code/runtime identity without touching an accelerator.""" + + commit = _git_output("rev-parse", "HEAD") + if commit is None or _GIT_COMMIT.fullmatch(commit) is None: + commit = "unknown" + dirty = True + else: + status = _git_output("status", "--porcelain", "--untracked-files=normal") + # Failure to prove a clean tree must never be reported as clean. + dirty = status is None or bool(status) + + pytorch = _distribution_version("torch") + newton = _distribution_version("newton", "newton-python") + dependencies = { + name: version + for name, version in ( + ("hhtools", __version__), + ("mujoco", _distribution_version("mujoco")), + ("numpy", _distribution_version("numpy")), + ("pydantic", _distribution_version("pydantic")), + ("warp", _distribution_version("warp-lang", "warp")), + ) + if version is not None + } + return JobSpecProvenance( + hhtools_git_commit=commit, + hhtools_dirty=dirty, + python=platform.python_version(), + pytorch=pytorch, + # Importing Torch or a CUDA runtime merely to fill this field could + # initialise device state. The execution manifest records the actual + # CUDA runtime and selected device later. + cuda=None, + newton=newton, + device=None, + platform=f"{platform.system()}-{platform.machine()}", + dependencies=dict(sorted(dependencies.items())), + ) + + +def _snapshot_provenance(provider: ProvenanceProvider) -> str: + """Validate, detach, and serialize one service-lifetime provenance fact.""" + + try: + supplied = provider() + snapshot = JobSpecProvenance.model_validate(supplied) + # This facade has not selected an execution device. Never turn a + # provider's current GPU choice into the immutable execution identity. + snapshot = snapshot.model_copy( + update={ + "device": None, + "dependencies": dict(sorted(snapshot.dependencies.items())), + } + ) + encoded = snapshot.model_dump_json() + JobSpecProvenance.model_validate_json(encoded) + except (TypeError, ValueError, ValidationError) as exc: + raise _service_error( + "INTERNAL_ERROR", + "The JobSpec provenance provider returned an invalid snapshot.", + stage=ErrorStage.INTERNAL, + ) from exc + return encoded + + +def _payload_object(payload: Mapping[str, Any], field: str, plan_id: str) -> Mapping[str, Any]: + value = payload.get(field) + if not isinstance(value, dict): + raise _service_error( + "PLAN_STALE", + "The immutable plan payload is incomplete.", + details={"plan_id": plan_id, "field": field}, + next_action=_preflight_action(plan_id), + ) + return value + + +def _preflight_action(plan_id: str) -> NextAction: + return NextAction( + actor="agent", + action="run_preflight", + message="Run retarget preflight again to create a current immutable plan.", + parameters={"plan_id": plan_id}, + ) + + +def _asset_digest(asset_id: str) -> str: + if not asset_id.startswith(_ASSET_ID_PREFIX): + return "" + return asset_id.removeprefix(_ASSET_ID_PREFIX) + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _verify_user_calibration_profile( + *, + plan_id: str, + relative_path: Any, + expected_digest: Any, +) -> None: + """Verify one managed user calibration without trusting the robot manifest. + + User calibration overlays intentionally live outside immutable packaged + robot bundles. The canonical plan therefore binds their portable path and + exact content digest directly. Resolve the path under the current user's + managed robot root and re-hash it immediately before materializing a + JobSpec, so switching users, changing ``HHTOOLS_ROBOT_DIR``, path escape, + deletion, and post-preflight edits all make the plan stale. + """ + + if not isinstance(relative_path, str) or not isinstance(expected_digest, str): + raise _service_error( + "PLAN_STALE", + "The managed calibration identity recorded by the plan is incomplete.", + details={"plan_id": plan_id}, + next_action=_preflight_action(plan_id), + ) + try: + root = user_robot_dir().resolve(strict=True) + candidate = (root / Path(relative_path)).resolve(strict=True) + candidate.relative_to(root) + if not candidate.is_file(): + raise ValueError("managed calibration is not a regular file") + before = candidate.stat() + actual_digest = _sha256_file(candidate) + after = candidate.stat() + stable_identity = ( + before.st_dev, + before.st_ino, + before.st_size, + before.st_mtime_ns, + ) == ( + after.st_dev, + after.st_ino, + after.st_size, + after.st_mtime_ns, + ) + except (OSError, RuntimeError, ValueError): + actual_digest = None + stable_identity = False + if not stable_identity or actual_digest != expected_digest: + raise _service_error( + "PLAN_STALE", + "The managed user calibration no longer matches the immutable plan.", + details={"plan_id": plan_id}, + next_action=_preflight_action(plan_id), + ) + + +class RetargetService: + """Resolve a verified immutable plan into a stable, non-executing JobSpec.""" + + def __init__( + self, + plan_store: PlanStore, + asset_service: _AssetProvider, + *, + provenance_provider: ProvenanceProvider = _default_provenance, + ) -> None: + self._plan_store = plan_store + self._asset_service = asset_service + # JSON storage prevents callers from mutating the nested dependency map + # retained by this otherwise frozen Pydantic model. + self._provenance_json = _snapshot_provenance(provenance_provider) + + def _plan_record(self, plan_id: str) -> tuple[RetargetPlan, dict[str, Any]]: + try: + plan = self._plan_store.get(plan_id) + payload = self._plan_store.get_payload(plan_id) + except PlanStoreError as exc: + raise RetargetServiceError(exc.api_error) from exc + if payload.get("semantics") != _PLAN_SEMANTICS: + raise _service_error( + "UNSUPPORTED_PLAN_SEMANTICS", + "The requested plan is not a supported retarget plan.", + details={"plan_id": plan_id}, + ) + return plan, payload + + def _verified_asset( + self, + *, + plan_id: str, + asset_id: str, + expected_digest: Any, + expected_kind: AssetKind, + ) -> tuple[AssetBundle, AssetInspection]: + reason: str | None = None + try: + bundle = self._asset_service.get(asset_id) + inspection = self._asset_service.inspect( + AssetInspectionRequest( + asset_id=asset_id, + verify_hashes=True, + parse_content=False, + ) + ) + except AssetServiceError as exc: + reason = exc.code + else: + if ( + bundle.asset_id != asset_id + or inspection.asset_id != asset_id + or expected_digest != _asset_digest(asset_id) + or bundle.kind is not expected_kind + or inspection.kind is not expected_kind + ): + reason = "BUNDLE_METADATA_MISMATCH" + elif inspection.status is InspectionStatus.INVALID: + reason = ",".join(sorted({error.code for error in inspection.errors})) + + if reason is not None: + raise _service_error( + "PLAN_STALE", + "A content-bound asset no longer matches the immutable plan.", + details={ + "plan_id": plan_id, + "asset_id": asset_id, + "reason_code": reason, + }, + next_action=_preflight_action(plan_id), + ) + return bundle, inspection + + def get_job_spec(self, plan_id: str) -> JobSpecV2: + """Return a stable JobSpec v2 after read-only plan and hash checks.""" + + plan, payload = self._plan_record(plan_id) + motion_payload = _payload_object(payload, "motion", plan_id) + robot_payload = _payload_object(payload, "robot", plan_id) + profile_payload = _payload_object(payload, "retarget_profile", plan_id) + + _motion_bundle, motion_inspection = self._verified_asset( + plan_id=plan_id, + asset_id=plan.motion_asset_id, + expected_digest=motion_payload.get("digest"), + expected_kind=AssetKind.MOTION_BUNDLE, + ) + robot_bundle, robot_inspection = self._verified_asset( + plan_id=plan_id, + asset_id=plan.robot_asset_id, + expected_digest=robot_payload.get("digest"), + expected_kind=AssetKind.ROBOT_BUNDLE, + ) + + routing = { + "category": motion_inspection.category.value, + "dataset": motion_inspection.dataset, + "reference": motion_inspection.reference_model, + } + expected_routing = { + "category": motion_payload.get("category"), + "dataset": motion_payload.get("dataset"), + "reference": motion_payload.get("reference"), + } + recommended_backend = motion_inspection.metadata.get("recommended_backend") + if ( + routing != expected_routing + or robot_inspection.category.value != "robot_model" + or (isinstance(recommended_backend, str) and recommended_backend != plan.backend) + ): + raise _service_error( + "PLAN_STALE", + "Asset routing metadata no longer matches the immutable plan.", + details={"plan_id": plan_id}, + next_action=_preflight_action(plan_id), + ) + + profile_source = profile_payload.get("source") + profile_storage = profile_payload.get("storage", "robot_bundle") + profile_digest = profile_payload.get("digest") + profile_relative_path = profile_payload.get("relative_path") + if profile_storage == "robot_bundle": + profile_file = next( + ( + item + for item in robot_bundle.files + if item.relative_path == profile_relative_path + ), + None, + ) + if ( + profile_file is None + or profile_file.role.value != "metadata" + or profile_file.sha256 != profile_digest + ): + raise _service_error( + "PLAN_STALE", + "The retarget profile is not bound into the current robot bundle.", + details={"plan_id": plan_id}, + next_action=_preflight_action(plan_id), + ) + elif profile_storage == "user_calibration" and profile_source == "calibration": + _verify_user_calibration_profile( + plan_id=plan_id, + relative_path=profile_relative_path, + expected_digest=profile_digest, + ) + else: + raise _service_error( + "PLAN_STALE", + "The retarget profile storage recorded by the plan is unsupported.", + details={"plan_id": plan_id}, + next_action=_preflight_action(plan_id), + ) + calibration = None + if profile_source == "calibration": + assert plan.calibration_id is not None + assert plan.calibration_digest is not None + calibration = JobSpecCalibration( + calibration_id=plan.calibration_id, + sha256=plan.calibration_digest, + ) + elif profile_source != "bundled_scaler": + raise _service_error( + "PLAN_STALE", + "The retarget profile recorded by the plan is unsupported.", + details={"plan_id": plan_id}, + next_action=_preflight_action(plan_id), + ) + + # JSON round-trip gives each caller an independent nested parameter map. + effective_parameters = json.loads( + json.dumps( + plan.parameters, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ) + ) + effective_parameters["output_format"] = plan.output_format + provenance = JobSpecProvenance.model_validate_json(self._provenance_json) + spec = JobSpecV2( + kind=JobSpecKind.RETARGET, + plan_id=plan.plan_id, + inputs=[ + JobSpecInput( + asset_id=plan.motion_asset_id, + sha256=plan.input_digest, + ) + ], + robot=JobSpecRobot( + robot_id=plan.robot_id, + asset_id=plan.robot_asset_id, + config_sha256=plan.robot_digest, + ), + calibration=calibration, + backend=plan.backend, + effective_parameters=effective_parameters, + output_policy=plan.output_policy, + provenance=provenance, + created_at=plan.created_at, + ) + # Return a fully detached model because JobSpec's open JSON maps are + # intentionally mutable even though top-level assignment is frozen. + return JobSpecV2.model_validate_json(spec.model_dump_json()) + + +__all__ = [ + "ProvenanceProvider", + "RetargetService", + "RetargetServiceError", +] diff --git a/hhtools/services/robot_asset_inspection.py b/hhtools/services/robot_asset_inspection.py new file mode 100644 index 00000000..111a97d4 --- /dev/null +++ b/hhtools/services/robot_asset_inspection.py @@ -0,0 +1,1329 @@ +"""Read-only discovery and inspection for robot URDF bundles. + +This module intentionally stops at the asset boundary. It parses XML with the +standard library, validates referenced files, and reports compact structural +facts. It never constructs a robot model, invokes a solver, or imports MuJoCo, +Newton, Warp, or ``yourdfpy``. + +Public contracts contain only bundle-relative paths. Absolute paths returned +by discovery are an internal hand-off to :class:`AssetRegistry`; expected +errors never include those host paths. +""" + +from __future__ import annotations + +import hashlib +import math +import os +from collections import Counter, defaultdict +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from pathlib import Path, PurePosixPath, PureWindowsPath +from typing import Any, NoReturn +from urllib.parse import unquote, urlsplit +from xml.etree import ElementTree + +import yaml # type: ignore[import-untyped] + +from hhtools.contracts import ( + ApiError, + AssetBundle, + AssetCategory, + AssetFileRole, + AssetInspection, + AssetKind, + ErrorStage, + InspectionStatus, +) + +_MAX_URDF_BYTES = 16 * 1024 * 1024 +_MAX_ROBOT_METADATA_BYTES = 2 * 1024 * 1024 +_SUPPORTED_JOINT_TYPES = frozenset( + {"fixed", "revolute", "continuous", "prismatic", "floating", "planar"} +) +_BOUNDED_JOINT_TYPES = frozenset({"revolute", "prismatic"}) +_ACTUATED_JOINT_TYPES = frozenset({"revolute", "continuous", "prismatic"}) +_MESH_ROLES = frozenset({AssetFileRole.VISUAL_MESH, AssetFileRole.COLLISION_MESH}) + + +@dataclass(frozen=True, slots=True) +class RobotAssetFile: + """One file discovered as part of a logical robot bundle.""" + + path: Path + role: AssetFileRole + required: bool = True + + +@dataclass(frozen=True, slots=True) +class RobotAssetDiscovery: + """Internal paths and portable metadata for one unambiguous URDF bundle.""" + + primary_urdf: Path + files: tuple[RobotAssetFile, ...] + metadata: Mapping[str, Any] + + @property + def primary_file(self) -> Path: + """Compatibility spelling used by registry discovery adapters.""" + + return self.primary_urdf + + +class RobotAssetDiscoveryError(ValueError): + """Expected discovery failure carrying an Agent-safe structured error.""" + + def __init__(self, error: ApiError, *, candidates: tuple[str, ...] = ()) -> None: + self.api_error = error + self.candidates = candidates + super().__init__(f"{error.code}: {error.message}") + + @property + def code(self) -> str: + """Stable machine code for protocol adapters and focused tests.""" + + return self.api_error.code + + +@dataclass(frozen=True, slots=True) +class _MeshDeclaration: + role: AssetFileRole + reference: str + index: int + + +@dataclass(slots=True) +class _UrdfFacts: + robot_name: str | None + link_count: int + urdf_joint_count: int + joint_count: int + fixed_joint_count: int + joint_type_counts: dict[str, int] + visual_mesh_declaration_count: int + collision_mesh_declaration_count: int + mesh_declarations: tuple[_MeshDeclaration, ...] + errors: list[ApiError] + warnings: list[str] + + +def _api_error( + code: str, + message: str, + *, + details: Mapping[str, Any] | None = None, +) -> ApiError: + return ApiError( + code=code, + message=message, + retryable=False, + stage=ErrorStage.ASSET_INSPECTION, + details=dict(details or {}), + ) + + +def _raise_discovery( + code: str, + message: str, + *, + details: Mapping[str, Any] | None = None, + candidates: tuple[str, ...] = (), +) -> NoReturn: + raise RobotAssetDiscoveryError( + _api_error(code, message, details=details), + candidates=candidates, + ) + + +def _local_name(tag: str) -> str: + return tag.rsplit("}", 1)[-1] + + +def _safe_relative(path: Path, root: Path) -> str: + """Return a portable path after containment was already established.""" + + return path.relative_to(root).as_posix() + + +def _contains(root: Path, path: Path, *, strict: bool) -> Path: + try: + resolved = path.resolve(strict=strict) + resolved.relative_to(root) + except (OSError, RuntimeError, ValueError) as exc: + raise RobotAssetDiscoveryError( + _api_error( + "ASSET_OUTSIDE_ALLOWED_ROOT", + "A robot bundle reference resolves outside the registered bundle.", + ) + ) from exc + return resolved + + +def _read_urdf(path: Path) -> ElementTree.Element: + """Parse a bounded XML document without resolving external resources.""" + + try: + size = path.stat().st_size + if size > _MAX_URDF_BYTES: + _raise_discovery( + "ROBOT_URDF_PARSE_FAILED", + "The robot description exceeds the supported inspection size.", + details={"max_bytes": _MAX_URDF_BYTES}, + ) + payload = path.read_bytes() + except RobotAssetDiscoveryError: + raise + except OSError as exc: + raise RobotAssetDiscoveryError( + _api_error("ASSET_NOT_FOUND", "The robot description is unavailable.") + ) from exc + + upper_payload = payload.upper() + if b" list[ElementTree.Element]: + return [child for child in element if _local_name(child.tag) == name] + + +def _descendants(element: ElementTree.Element, name: str) -> Iterable[ElementTree.Element]: + return (child for child in element.iter() if _local_name(child.tag) == name) + + +def _mesh_declarations(root: ElementTree.Element) -> tuple[_MeshDeclaration, ...]: + declarations: list[_MeshDeclaration] = [] + index = 0 + for link in _children(root, "link"): + for container_name, role in ( + ("visual", AssetFileRole.VISUAL_MESH), + ("collision", AssetFileRole.COLLISION_MESH), + ): + for container in _children(link, container_name): + for mesh in _descendants(container, "mesh"): + declarations.append( + _MeshDeclaration( + role=role, + reference=(mesh.get("filename") or "").strip(), + index=index, + ) + ) + index += 1 + return tuple(declarations) + + +def _float_attribute( + element: ElementTree.Element, + name: str, + *, + joint_name: str, + required: bool, + errors: list[ApiError], +) -> float | None: + raw = element.get(name) + if raw is None: + if required: + errors.append( + _api_error( + "ROBOT_URDF_INVALID", + "A bounded joint is missing a required numeric limit.", + details={"joint": joint_name, "attribute": name}, + ) + ) + return None + try: + value = float(raw) + except ValueError: + value = math.nan + if not math.isfinite(value): + errors.append( + _api_error( + "ROBOT_URDF_INVALID", + "A joint limit is not a finite number.", + details={"joint": joint_name, "attribute": name}, + ) + ) + return None + return value + + +def _duplicates(values: Iterable[str]) -> list[str]: + counts = Counter(values) + return sorted(name for name, count in counts.items() if name and count > 1)[:20] + + +def _validate_topology( + links: set[str], + edges: list[tuple[str, str]], + *, + errors: list[ApiError], +) -> None: + valid_edges = [(parent, child) for parent, child in edges if parent in links and child in links] + children = [child for _, child in valid_edges] + duplicate_children = _duplicates(children) + if duplicate_children: + errors.append( + _api_error( + "ROBOT_URDF_INVALID", + "A robot link has more than one parent joint.", + details={"links": duplicate_children}, + ) + ) + + roots = sorted(links - set(children)) + if links and len(roots) != 1: + errors.append( + _api_error( + "ROBOT_URDF_INVALID", + "A robot description must define exactly one root link.", + details={"root_count": len(roots)}, + ) + ) + + adjacency: dict[str, list[str]] = defaultdict(list) + indegree = {name: 0 for name in links} + for parent, child in valid_edges: + adjacency[parent].append(child) + indegree[child] += 1 + queue = [name for name, degree in indegree.items() if degree == 0] + visited = 0 + while queue: + current = queue.pop() + visited += 1 + for child in adjacency[current]: + indegree[child] -= 1 + if indegree[child] == 0: + queue.append(child) + if links and visited != len(links): + errors.append( + _api_error( + "ROBOT_URDF_INVALID", + "The robot link graph contains a cycle.", + ) + ) + + +def _analyse_urdf(root: ElementTree.Element) -> _UrdfFacts: + errors: list[ApiError] = [] + warnings: list[str] = [] + if _local_name(root.tag) != "robot": + errors.append( + _api_error( + "ROBOT_URDF_INVALID", + "The XML root element must be robot.", + ) + ) + + robot_name = (root.get("name") or "").strip() or None + if robot_name is None: + warnings.append("The robot description does not declare a robot name.") + + link_elements = _children(root, "link") + link_names = [(element.get("name") or "").strip() for element in link_elements] + missing_link_names = sum(not name for name in link_names) + duplicate_links = _duplicates(link_names) + if not link_elements: + errors.append(_api_error("ROBOT_URDF_INVALID", "The robot defines no links.")) + if missing_link_names: + errors.append( + _api_error( + "ROBOT_URDF_INVALID", + "Every robot link must have a name.", + details={"missing_name_count": missing_link_names}, + ) + ) + if duplicate_links: + errors.append( + _api_error( + "ROBOT_URDF_INVALID", + "Robot link names must be unique.", + details={"duplicate_names": duplicate_links}, + ) + ) + links = {name for name in link_names if name} + + joint_elements = _children(root, "joint") + joint_names = [(element.get("name") or "").strip() for element in joint_elements] + missing_joint_names = sum(not name for name in joint_names) + duplicate_joints = _duplicates(joint_names) + if missing_joint_names: + errors.append( + _api_error( + "ROBOT_URDF_INVALID", + "Every robot joint must have a name.", + details={"missing_name_count": missing_joint_names}, + ) + ) + if duplicate_joints: + errors.append( + _api_error( + "ROBOT_URDF_INVALID", + "Robot joint names must be unique.", + details={"duplicate_names": duplicate_joints}, + ) + ) + + joint_types: Counter[str] = Counter() + edges: list[tuple[str, str]] = [] + effort_velocity_missing = 0 + for index, joint in enumerate(joint_elements): + name = (joint.get("name") or "").strip() or f"joint_{index}" + joint_type = (joint.get("type") or "").strip().lower() + joint_types[joint_type or "(missing)"] += 1 + if joint_type not in _SUPPORTED_JOINT_TYPES: + errors.append( + _api_error( + "ROBOT_URDF_INVALID", + "A robot joint has an unsupported or missing type.", + details={"joint": name, "joint_type": joint_type or None}, + ) + ) + + parent_elements = _children(joint, "parent") + child_elements = _children(joint, "child") + parent = (parent_elements[0].get("link") or "").strip() if len(parent_elements) == 1 else "" + child = (child_elements[0].get("link") or "").strip() if len(child_elements) == 1 else "" + if not parent or not child: + errors.append( + _api_error( + "ROBOT_URDF_INVALID", + "Every joint must declare exactly one parent and one child link.", + details={"joint": name}, + ) + ) + else: + edges.append((parent, child)) + missing_references = sorted({value for value in (parent, child) if value not in links}) + if missing_references: + errors.append( + _api_error( + "ROBOT_URDF_INVALID", + "A joint refers to a link that is not declared.", + details={"joint": name, "links": missing_references}, + ) + ) + if parent == child: + errors.append( + _api_error( + "ROBOT_URDF_INVALID", + "A joint cannot connect a link to itself.", + details={"joint": name}, + ) + ) + + limit_elements = _children(joint, "limit") + if joint_type in _BOUNDED_JOINT_TYPES: + if len(limit_elements) != 1: + errors.append( + _api_error( + "ROBOT_URDF_INVALID", + "A bounded joint must declare exactly one limit element.", + details={"joint": name}, + ) + ) + else: + limit = limit_elements[0] + lower = _float_attribute( + limit, + "lower", + joint_name=name, + required=True, + errors=errors, + ) + upper = _float_attribute( + limit, + "upper", + joint_name=name, + required=True, + errors=errors, + ) + if lower is not None and upper is not None and lower > upper: + errors.append( + _api_error( + "ROBOT_URDF_INVALID", + "A joint lower limit exceeds its upper limit.", + details={"joint": name}, + ) + ) + if limit.get("effort") is None or limit.get("velocity") is None: + effort_velocity_missing += 1 + elif joint_type == "continuous" and ( + not limit_elements + or any( + limit.get("effort") is None or limit.get("velocity") is None + for limit in limit_elements + ) + ): + effort_velocity_missing += 1 + + if effort_velocity_missing: + warnings.append( + f"{effort_velocity_missing} actuated joint(s) omit effort or velocity limits." + ) + _validate_topology(links, edges, errors=errors) + + declarations = _mesh_declarations(root) + empty_mesh_references = sum(not declaration.reference for declaration in declarations) + if empty_mesh_references: + errors.append( + _api_error( + "ROBOT_URDF_INVALID", + "Every mesh declaration must include a filename.", + details={"missing_filename_count": empty_mesh_references}, + ) + ) + return _UrdfFacts( + robot_name=robot_name, + link_count=len(link_elements), + urdf_joint_count=len(joint_elements), + joint_count=sum( + joint_type in _ACTUATED_JOINT_TYPES for joint_type in joint_types.elements() + ), + fixed_joint_count=joint_types.get("fixed", 0), + joint_type_counts=dict(sorted(joint_types.items())), + visual_mesh_declaration_count=sum( + declaration.role is AssetFileRole.VISUAL_MESH for declaration in declarations + ), + collision_mesh_declaration_count=sum( + declaration.role is AssetFileRole.COLLISION_MESH for declaration in declarations + ), + mesh_declarations=declarations, + errors=errors, + warnings=warnings, + ) + + +def _reference_candidate(reference: str, *, urdf_path: Path, bundle_root: Path) -> Path: + """Convert a URDF mesh reference to an internal candidate path.""" + + raw = reference.strip() + if not raw or "\x00" in raw: + _raise_discovery( + "BUNDLE_INCOMPLETE", + "A robot mesh reference is empty or invalid.", + ) + + parsed = urlsplit(raw) + scheme = parsed.scheme.casefold() + if scheme == "package": + if parsed.query or parsed.fragment or not parsed.netloc: + _raise_discovery( + "BUNDLE_INCOMPLETE", + "A package mesh reference is malformed.", + ) + package = unquote(parsed.netloc) + package_path = unquote(parsed.path).replace("\\", "/").lstrip("/") + parts = PurePosixPath(package_path).parts + if package in {".", ".."} or "/" in package or not parts: + _raise_discovery( + "BUNDLE_INCOMPLETE", + "A package mesh reference is malformed.", + ) + return bundle_root.joinpath(*parts) + + # Absolute references are not portable manifest identities. Even when an + # absolute path currently lands inside the registered bundle, copying the + # URDF into an Agent job snapshot would leave the string pointing back at + # the mutable source directory. Loader mesh repair could then read from or + # write beside that source file instead of the isolated snapshot. + decoded = unquote(raw) + windows_path = PureWindowsPath(decoded) + posix_path = PurePosixPath(decoded.replace("\\", "/")) + if ( + scheme == "file" + or windows_path.is_absolute() + or bool(windows_path.drive) + or posix_path.is_absolute() + ): + _raise_discovery( + "ASSET_OUTSIDE_ALLOWED_ROOT", + "Robot mesh references must be bundle-relative or use package URIs.", + ) + + # A Windows drive such as C:\\robot\\mesh.stl is parsed as a one-letter + # URI scheme. It was rejected above rather than treated as an unknown URI. + if scheme and not windows_path.drive: + _raise_discovery( + "BUNDLE_INCOMPLETE", + "The robot description uses an unsupported mesh URI scheme.", + ) + normalized = decoded.replace("\\", "/") + return urdf_path.parent.joinpath(*PurePosixPath(normalized).parts) + + +def _compiler_path_errors( + root: ElementTree.Element, + *, + urdf_path: Path, + bundle_root: Path, +) -> list[ApiError]: + """Reject MuJoCo compiler directories that can escape an Agent snapshot.""" + + errors: list[ApiError] = [] + for mujoco in _children(root, "mujoco"): + for compiler in _children(mujoco, "compiler"): + for attribute in ("meshdir", "texturedir", "assetdir"): + raw = compiler.get(attribute) + if raw is None or not raw.strip(): + continue + value = raw.strip() + parsed = urlsplit(value) + decoded = unquote(value) + windows_path = PureWindowsPath(decoded) + posix_path = PurePosixPath(decoded.replace("\\", "/")) + if ( + parsed.scheme + or parsed.netloc + or parsed.query + or parsed.fragment + or windows_path.is_absolute() + or bool(windows_path.drive) + or posix_path.is_absolute() + ): + errors.append( + _api_error( + "ASSET_OUTSIDE_ALLOWED_ROOT", + "MuJoCo compiler directories must be bundle-relative.", + details={"attribute": attribute}, + ) + ) + continue + candidate = urdf_path.parent.joinpath(*posix_path.parts) + try: + candidate.resolve(strict=False).relative_to(bundle_root) + except (OSError, RuntimeError, ValueError): + errors.append( + _api_error( + "ASSET_OUTSIDE_ALLOWED_ROOT", + "A MuJoCo compiler directory resolves outside the robot bundle.", + details={"attribute": attribute}, + ) + ) + return errors + + +def _resolve_mesh( + declaration: _MeshDeclaration, + *, + urdf_path: Path, + bundle_root: Path, +) -> Path: + candidate = _reference_candidate( + declaration.reference, + urdf_path=urdf_path, + bundle_root=bundle_root, + ) + try: + resolved = candidate.resolve(strict=False) + resolved.relative_to(bundle_root) + except (OSError, RuntimeError, ValueError) as exc: + raise RobotAssetDiscoveryError( + _api_error( + "ASSET_OUTSIDE_ALLOWED_ROOT", + "A robot mesh reference resolves outside the registered bundle.", + details={"role": declaration.role.value, "declaration_index": declaration.index}, + ) + ) from exc + if not resolved.is_file(): + relative = _safe_relative(resolved, bundle_root) + raise RobotAssetDiscoveryError( + _api_error( + "BUNDLE_INCOMPLETE", + "A referenced robot mesh is missing or is not a regular file.", + details={"relative_path": relative, "role": declaration.role.value}, + ) + ) + try: + strict_resolved = candidate.resolve(strict=True) + strict_resolved.relative_to(bundle_root) + except (OSError, RuntimeError, ValueError) as exc: + raise RobotAssetDiscoveryError( + _api_error( + "ASSET_OUTSIDE_ALLOWED_ROOT", + "A robot mesh reference resolves outside the registered bundle.", + details={"role": declaration.role.value, "declaration_index": declaration.index}, + ) + ) from exc + return strict_resolved + + +def _candidate_urdfs(candidate: Path, bundle_root: Path) -> list[Path]: + candidates: list[Path] = [] + try: + for current, directory_names, file_names in os.walk( + bundle_root, + topdown=True, + followlinks=False, + ): + directory_names.sort(key=str.casefold) + file_names.sort(key=str.casefold) + directory = Path(current) + for name in file_names: + if Path(name).suffix.casefold() != ".urdf": + continue + path = directory / name + resolved = _contains(bundle_root, path, strict=True) + if not resolved.is_file(): + continue + candidates.append(resolved) + except RobotAssetDiscoveryError: + raise + except OSError as exc: + raise RobotAssetDiscoveryError( + _api_error("ASSET_NOT_FOUND", "The robot bundle cannot be read.") + ) from exc + return sorted(set(candidates), key=lambda path: _safe_relative(path, bundle_root).casefold()) + + +def _bundle_context(candidate: str | Path) -> tuple[Path, Path]: + path = Path(candidate) + try: + if path.is_dir(): + bundle_root = path.resolve(strict=True) + candidates = _candidate_urdfs(path, bundle_root) + if not candidates: + _raise_discovery( + "BUNDLE_INCOMPLETE", + "The robot bundle contains no URDF description.", + ) + if len(candidates) > 1: + relative = tuple(_safe_relative(item, bundle_root) for item in candidates) + _raise_discovery( + "BUNDLE_AMBIGUOUS", + "The directory contains multiple robot descriptions; register one bundle.", + details={"candidates": list(relative)}, + candidates=relative, + ) + return bundle_root, candidates[0] + if path.is_file() or path.is_symlink(): + if path.suffix.casefold() != ".urdf": + _raise_discovery( + "UNSUPPORTED_FORMAT", + "Robot bundle discovery requires a URDF file or directory.", + ) + bundle_root = path.parent.resolve(strict=True) + primary = _contains(bundle_root, path, strict=True) + if not primary.is_file(): + _raise_discovery( + "ASSET_NOT_FOUND", + "The robot description is not a regular file.", + ) + return bundle_root, primary + except RobotAssetDiscoveryError: + raise + except OSError as exc: + raise RobotAssetDiscoveryError( + _api_error("ASSET_NOT_FOUND", "The robot bundle is unavailable.") + ) from exc + _raise_discovery("ASSET_NOT_FOUND", "The robot bundle is unavailable.") + + +def _metadata(facts: _UrdfFacts, *, unique_meshes: int, shared_meshes: int) -> dict[str, Any]: + return { + "source_format": "urdf", + "robot_name": facts.robot_name, + "link_count": facts.link_count, + "urdf_joint_count": facts.urdf_joint_count, + "joint_count": facts.joint_count, + "fixed_joint_count": facts.fixed_joint_count, + "joint_type_counts": facts.joint_type_counts, + "visual_mesh_declaration_count": facts.visual_mesh_declaration_count, + "collision_mesh_declaration_count": facts.collision_mesh_declaration_count, + "unique_mesh_count": unique_meshes, + "shared_visual_collision_mesh_count": shared_meshes, + } + + +def _robot_metadata_files(bundle_root: Path, primary: Path) -> tuple[Path, ...]: + """Return robot YAML, calibrations, and declared scaler configs.""" + + candidates = { + bundle_root / "robot.yaml", + bundle_root / f"robot.{primary.stem}.yaml", + primary.parent / "robot.yaml", + primary.parent / f"robot.{primary.stem}.yaml", + } + # Human-reviewed calibrations are executable robot configuration, not + # incidental workspace state. Binding them into the robot manifest means + # editing or adding one requires a new robot asset id before preflight can + # produce another runnable plan. + for directory in {bundle_root, primary.parent}: + candidates.update(directory.glob("retarget_calibration*.yaml")) + files: set[Path] = set() + for candidate in sorted(candidates, key=lambda item: item.as_posix().casefold()): + if not candidate.exists() and not candidate.is_symlink(): + continue + metadata_path = _contains(bundle_root, candidate, strict=True) + if not metadata_path.is_file(): + _raise_discovery( + "BUNDLE_INCOMPLETE", + "Robot metadata exists but is not a regular file.", + ) + try: + if metadata_path.stat().st_size > _MAX_ROBOT_METADATA_BYTES: + _raise_discovery( + "ROBOT_METADATA_INVALID", + "Robot metadata exceeds the supported inspection size.", + details={"max_bytes": _MAX_ROBOT_METADATA_BYTES}, + ) + payload = yaml.safe_load(metadata_path.read_text(encoding="utf-8")) or {} + except RobotAssetDiscoveryError: + raise + except (OSError, UnicodeError, yaml.YAMLError) as exc: + raise RobotAssetDiscoveryError( + _api_error( + "ROBOT_METADATA_INVALID", + "Robot metadata could not be parsed safely.", + details={"exception_type": type(exc).__name__}, + ) + ) from exc + if not isinstance(payload, dict): + _raise_discovery( + "ROBOT_METADATA_INVALID", + "Robot metadata must contain a YAML mapping.", + ) + files.add(metadata_path) + raw_urdf = payload.get("urdf") + if raw_urdf is not None: + if not isinstance(raw_urdf, str) or not raw_urdf.strip() or "\x00" in raw_urdf: + _raise_discovery( + "ROBOT_METADATA_INVALID", + "The robot metadata urdf field must be a non-empty path string.", + ) + urdf_value = raw_urdf.strip().replace("\\", "/") + windows_urdf = PureWindowsPath(urdf_value) + posix_urdf = PurePosixPath(urdf_value) + if windows_urdf.is_absolute() or windows_urdf.drive or posix_urdf.is_absolute(): + _raise_discovery( + "ROBOT_METADATA_INVALID", + "The robot metadata urdf path must be bundle-relative.", + ) + configured_urdf = metadata_path.parent.joinpath(*posix_urdf.parts) + try: + resolved_urdf = configured_urdf.resolve(strict=True) + resolved_urdf.relative_to(bundle_root) + except (OSError, RuntimeError, ValueError) as exc: + raise RobotAssetDiscoveryError( + _api_error( + "BUNDLE_INCOMPLETE", + "The URDF declared by robot metadata is missing or outside the bundle.", + ) + ) from exc + if resolved_urdf != primary: + _raise_discovery( + "BUNDLE_METADATA_MISMATCH", + "Robot metadata refers to a different URDF than the bundle primary.", + ) + raw_search_paths = payload.get("mesh_search_paths") + if raw_search_paths is not None: + if not isinstance(raw_search_paths, list): + _raise_discovery( + "ROBOT_METADATA_INVALID", + "Robot mesh_search_paths must be a list of bundle-relative directories.", + ) + for raw_search_path in raw_search_paths: + if ( + not isinstance(raw_search_path, str) + or not raw_search_path.strip() + or "\x00" in raw_search_path + ): + _raise_discovery( + "ROBOT_METADATA_INVALID", + "Each robot mesh search path must be a non-empty path string.", + ) + search_value = raw_search_path.strip().replace("\\", "/") + windows_search = PureWindowsPath(search_value) + posix_search = PurePosixPath(search_value) + if ( + windows_search.is_absolute() + or windows_search.drive + or posix_search.is_absolute() + ): + _raise_discovery( + "ROBOT_METADATA_INVALID", + "Robot mesh search paths must be bundle-relative.", + ) + search_candidate = metadata_path.parent.joinpath(*posix_search.parts) + try: + search_path = search_candidate.resolve(strict=True) + search_path.relative_to(bundle_root) + except (OSError, RuntimeError, ValueError) as exc: + raise RobotAssetDiscoveryError( + _api_error( + "ASSET_OUTSIDE_ALLOWED_ROOT", + "A robot mesh search path is missing or outside the bundle.", + ) + ) from exc + if not search_path.is_dir(): + _raise_discovery( + "BUNDLE_INCOMPLETE", + "A robot mesh search path is not a directory.", + ) + retarget = payload.get("retarget") + references = retarget.get("references") if isinstance(retarget, dict) else None + if not isinstance(references, dict): + continue + for reference_config in references.values(): + if not isinstance(reference_config, dict): + continue + raw_scaler = reference_config.get("scaler_config") + if raw_scaler is None: + continue + if not isinstance(raw_scaler, str) or not raw_scaler.strip() or "\x00" in raw_scaler: + _raise_discovery( + "ROBOT_METADATA_INVALID", + "A declared scaler_config must be a non-empty path string.", + ) + scaler_value = raw_scaler.strip() + windows_path = PureWindowsPath(scaler_value) + posix_path = PurePosixPath(scaler_value.replace("\\", "/")) + if windows_path.is_absolute() or windows_path.drive or posix_path.is_absolute(): + _raise_discovery( + "ROBOT_METADATA_INVALID", + "A declared scaler_config path must be bundle-relative.", + ) + scaler_candidate = metadata_path.parent.joinpath(*posix_path.parts) + try: + scaler_path = scaler_candidate.resolve(strict=False) + scaler_path.relative_to(bundle_root) + except (OSError, RuntimeError, ValueError) as exc: + raise RobotAssetDiscoveryError( + _api_error( + "ASSET_OUTSIDE_ALLOWED_ROOT", + "A scaler config resolves outside the registered robot bundle.", + ) + ) from exc + if not scaler_path.is_file(): + _raise_discovery( + "BUNDLE_INCOMPLETE", + "A declared robot scaler config is missing.", + details={"relative_path": _safe_relative(scaler_path, bundle_root)}, + ) + files.add(_contains(bundle_root, scaler_candidate, strict=True)) + return tuple(sorted(files, key=lambda path: _safe_relative(path, bundle_root).casefold())) + + +def discover_robot_bundle(candidate: str | Path) -> RobotAssetDiscovery: + """Discover one URDF plus every in-bundle file required by that URDF.""" + + bundle_root, primary = _bundle_context(candidate) + xml_root = _read_urdf(primary) + facts = _analyse_urdf(xml_root) + if facts.errors: + first = facts.errors[0] + raise RobotAssetDiscoveryError(first) + compiler_errors = _compiler_path_errors( + xml_root, + urdf_path=primary, + bundle_root=bundle_root, + ) + if compiler_errors: + raise RobotAssetDiscoveryError(compiler_errors[0]) + + mesh_roles: dict[Path, set[AssetFileRole]] = defaultdict(set) + for declaration in facts.mesh_declarations: + if not declaration.reference: + continue + resolved = _resolve_mesh( + declaration, + urdf_path=primary, + bundle_root=bundle_root, + ) + mesh_roles[resolved].add(declaration.role) + + files: list[RobotAssetFile] = [RobotAssetFile(primary, AssetFileRole.ROBOT_DESCRIPTION)] + metadata_files = _robot_metadata_files(bundle_root, primary) + files.extend(RobotAssetFile(path, AssetFileRole.METADATA) for path in metadata_files) + + shared_meshes = 0 + for path, roles in sorted( + mesh_roles.items(), + key=lambda item: _safe_relative(item[0], bundle_root).casefold(), + ): + if len(roles) > 1: + shared_meshes += 1 + # AssetFile has one semantic role. A shared collision/visual mesh is + # classified as collision because that is the stricter operational use; + # metadata retains the fact that the file is shared. + role = ( + AssetFileRole.COLLISION_MESH + if AssetFileRole.COLLISION_MESH in roles + else AssetFileRole.VISUAL_MESH + ) + files.append(RobotAssetFile(path, role)) + + deduplicated: dict[Path, RobotAssetFile] = {} + for item in files: + previous = deduplicated.get(item.path) + if previous is None: + deduplicated[item.path] = item + elif previous.role is not item.role: + precedence = { + AssetFileRole.ROBOT_DESCRIPTION: 4, + AssetFileRole.METADATA: 3, + AssetFileRole.COLLISION_MESH: 2, + AssetFileRole.VISUAL_MESH: 1, + } + if precedence.get(item.role, 0) > precedence.get(previous.role, 0): + deduplicated[item.path] = item + ordered = tuple( + sorted( + deduplicated.values(), + key=lambda item: ( + 0 if item.path == primary else 1, + _safe_relative(item.path, bundle_root).casefold(), + ), + ) + ) + return RobotAssetDiscovery( + primary_urdf=primary, + files=ordered, + metadata={ + **_metadata( + facts, + unique_meshes=len(mesh_roles), + shared_meshes=shared_meshes, + ), + "metadata_file_count": len(metadata_files), + }, + ) + + +def _sha256(path: Path) -> tuple[str, int, bool]: + before = path.stat() + digest = hashlib.sha256() + size = 0 + with path.open("rb") as stream: + while chunk := stream.read(1024 * 1024): + digest.update(chunk) + size += len(chunk) + after = path.stat() + before_snapshot = (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns) + after_snapshot = (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns) + stable = before_snapshot == after_snapshot and size == after.st_size + return digest.hexdigest(), size, stable + + +def _inspection_path( + bundle_root: Path, + relative_path: str, +) -> tuple[Path | None, ApiError | None]: + candidate = bundle_root.joinpath(*relative_path.split("/")) + try: + resolved = candidate.resolve(strict=False) + resolved.relative_to(bundle_root) + except (OSError, RuntimeError, ValueError): + return None, _api_error( + "ASSET_OUTSIDE_ALLOWED_ROOT", + "A robot bundle file resolves outside its registered root.", + details={"relative_path": relative_path}, + ) + if not resolved.is_file(): + return None, _api_error( + "BUNDLE_INCOMPLETE", + "A required robot bundle file is missing.", + details={"relative_path": relative_path}, + ) + try: + strict_resolved = candidate.resolve(strict=True) + strict_resolved.relative_to(bundle_root) + except (OSError, RuntimeError, ValueError): + return None, _api_error( + "ASSET_OUTSIDE_ALLOWED_ROOT", + "A robot bundle file resolves outside its registered root.", + details={"relative_path": relative_path}, + ) + return strict_resolved, None + + +class RobotAssetInspector: + """Validate one registered robot bundle without loading a robot runtime.""" + + def inspect( + self, + bundle: AssetBundle, + bundle_root: str | Path, + *, + verify_hashes: bool = True, + parse_content: bool = True, + ) -> AssetInspection: + """Return compact URDF facts and structured, Agent-safe failures.""" + + errors: list[ApiError] = [] + warnings: list[str] = [] + root_path = Path(bundle_root) + try: + root = root_path.resolve(strict=True) + if not root.is_dir(): + raise OSError + except (OSError, RuntimeError): + root = root_path.resolve(strict=False) + errors.append( + _api_error( + "ASSET_NOT_FOUND", + "The registered robot bundle root is unavailable.", + ) + ) + + if bundle.kind is not AssetKind.ROBOT_BUNDLE: + errors.append( + _api_error( + "UNSUPPORTED_ASSET_KIND", + "Robot inspection requires a robot_bundle asset.", + details={"kind": bundle.kind.value}, + ) + ) + if bundle.category is not AssetCategory.ROBOT_MODEL: + errors.append( + _api_error( + "BUNDLE_METADATA_MISMATCH", + "A robot bundle must use the robot_model category.", + details={"category": bundle.category.value}, + ) + ) + + resolved_files: dict[str, Path] = {} + manifest_by_path = {item.relative_path: item for item in bundle.files} + if root.is_dir(): + for item in bundle.files: + path, path_error = _inspection_path(root, item.relative_path) + if path_error is not None: + if not item.required and path_error.code == "BUNDLE_INCOMPLETE": + warnings.append( + f"Optional robot bundle file is missing: {item.relative_path}." + ) + else: + errors.append(path_error) + continue + assert path is not None + resolved_files[item.relative_path] = path + if verify_hashes: + try: + digest, size, stable = _sha256(path) + except OSError: + errors.append( + _api_error( + "ASSET_NOT_FOUND", + "A robot bundle file cannot be read.", + details={"relative_path": item.relative_path}, + ) + ) + continue + if not stable or digest != item.sha256 or size != item.size_bytes: + errors.append( + _api_error( + "ASSET_HASH_MISMATCH", + "A robot bundle file no longer matches its manifest.", + details={ + "relative_path": item.relative_path, + "expected_sha256": item.sha256, + "actual_sha256": digest, + }, + ) + ) + + primary_manifest = manifest_by_path.get(bundle.primary_file) + if Path(bundle.primary_file).suffix.casefold() != ".urdf": + errors.append( + _api_error( + "UNSUPPORTED_FORMAT", + "Robot inspection requires a URDF primary file.", + ) + ) + if primary_manifest is None: + errors.append( + _api_error( + "BUNDLE_INCOMPLETE", + "The primary robot description is absent from the manifest.", + ) + ) + elif primary_manifest.role is not AssetFileRole.ROBOT_DESCRIPTION: + errors.append( + _api_error( + "BUNDLE_METADATA_MISMATCH", + "The primary URDF must have the robot_description role.", + details={"relative_path": bundle.primary_file}, + ) + ) + + primary_path = resolved_files.get(bundle.primary_file) + primary_integrity_failed = any( + error.code in {"ASSET_HASH_MISMATCH", "ASSET_NOT_FOUND", "BUNDLE_INCOMPLETE"} + and error.details.get("relative_path") == bundle.primary_file + for error in errors + ) + facts: _UrdfFacts | None = None + unique_meshes: set[str] = set() + shared_meshes: set[str] = set() + content_parsed = False + if parse_content and primary_path is not None and not primary_integrity_failed: + try: + xml_root = _read_urdf(primary_path) + facts = _analyse_urdf(xml_root) + errors.extend(facts.errors) + warnings.extend(facts.warnings) + errors.extend( + _compiler_path_errors( + xml_root, + urdf_path=primary_path, + bundle_root=root, + ) + ) + content_parsed = True + + roles_by_path: dict[str, set[AssetFileRole]] = defaultdict(set) + for declaration in facts.mesh_declarations: + if not declaration.reference: + continue + try: + mesh_path = _resolve_mesh( + declaration, + urdf_path=primary_path, + bundle_root=root, + ) + except RobotAssetDiscoveryError as exc: + errors.append(exc.api_error) + continue + relative = _safe_relative(mesh_path, root) + unique_meshes.add(relative) + roles_by_path[relative].add(declaration.role) + manifest_file = manifest_by_path.get(relative) + if manifest_file is None: + errors.append( + _api_error( + "BUNDLE_INCOMPLETE", + "A referenced robot mesh is not declared in the manifest.", + details={ + "relative_path": relative, + "role": declaration.role.value, + }, + ) + ) + elif manifest_file.role not in _MESH_ROLES: + errors.append( + _api_error( + "BUNDLE_METADATA_MISMATCH", + "A referenced robot mesh has a non-mesh manifest role.", + details={ + "relative_path": relative, + "role": manifest_file.role.value, + }, + ) + ) + shared_meshes = { + relative for relative, roles in roles_by_path.items() if len(roles) > 1 + } + for relative, roles in roles_by_path.items(): + manifest_file = manifest_by_path.get(relative) + if manifest_file is None or manifest_file.role not in _MESH_ROLES: + continue + expected_role = ( + AssetFileRole.COLLISION_MESH + if AssetFileRole.COLLISION_MESH in roles + else AssetFileRole.VISUAL_MESH + ) + if manifest_file.role is not expected_role: + errors.append( + _api_error( + "BUNDLE_METADATA_MISMATCH", + "A robot mesh manifest role does not match its URDF use.", + details={ + "relative_path": relative, + "declared_role": manifest_file.role.value, + "expected_role": expected_role.value, + }, + ) + ) + + declared_meshes = { + relative + for relative, item in manifest_by_path.items() + if item.role in _MESH_ROLES + } + unused = sorted(declared_meshes - unique_meshes) + if unused: + warnings.append( + f"{len(unused)} declared mesh file(s) are not referenced by the URDF." + ) + + expected_metadata = _robot_metadata_files(root, primary_path) + for metadata_path in expected_metadata: + relative = _safe_relative(metadata_path, root) + manifest_file = manifest_by_path.get(relative) + if manifest_file is None: + errors.append( + _api_error( + "BUNDLE_INCOMPLETE", + "Robot metadata or scaler config is absent from the manifest.", + details={"relative_path": relative}, + ) + ) + elif manifest_file.role is not AssetFileRole.METADATA: + errors.append( + _api_error( + "BUNDLE_METADATA_MISMATCH", + "Robot metadata must use the metadata manifest role.", + details={ + "relative_path": relative, + "role": manifest_file.role.value, + }, + ) + ) + except RobotAssetDiscoveryError as exc: + errors.append(exc.api_error) + + metadata: dict[str, Any] = { + "content_parsed": content_parsed, + "manifest_file_count": len(bundle.files), + "mesh_manifest_count": sum(item.role in _MESH_ROLES for item in bundle.files), + } + joint_count = None + if facts is not None: + metadata.update( + _metadata( + facts, + unique_meshes=len(unique_meshes), + shared_meshes=len(shared_meshes), + ) + ) + joint_count = facts.joint_count + + if errors: + status = InspectionStatus.INVALID + elif warnings: + status = InspectionStatus.VALID_WITH_WARNINGS + else: + status = InspectionStatus.VALID + return AssetInspection( + asset_id=bundle.asset_id, + status=status, + kind=bundle.kind, + category=AssetCategory.ROBOT_MODEL, + source_format="urdf", + joint_count=joint_count, + warnings=warnings, + errors=errors, + metadata=metadata, + ) + + +__all__ = [ + "RobotAssetDiscovery", + "RobotAssetDiscoveryError", + "RobotAssetFile", + "RobotAssetInspector", + "discover_robot_bundle", +] diff --git a/hhtools/services/routing.py b/hhtools/services/routing.py new file mode 100644 index 00000000..7e433dad --- /dev/null +++ b/hhtools/services/routing.py @@ -0,0 +1,82 @@ +"""Pure routing rules shared by asset inspection and retarget preflight. + +This module intentionally has no Web, solver, Torch, MuJoCo, Newton, or Warp +imports. Dataset interpretation must have one source of truth so inspection, +preflight, CLI, REST, and MCP cannot silently select different references or +drop scene inputs by choosing an incompatible backend. +""" + +from __future__ import annotations + +from hhtools.contracts import AssetCategory + +DATASET_REFERENCE: dict[str, str] = { + "amass": "smpl", + "motion_x": "smplx", + "phuma": "smpl", + "lafan": "lafan_bvh", + "mocap": "mocap_bvh", + "soma": "soma_bvh", + "xsens_mocap": "xsens_mocap", + "gvhmr": "gvhmr", + "kungfu_athlete": "gvhmr", + "omomo": "smplx", + "omnicontact": "lafan_bvh", + "meshmimic_holosoma": "smplx", + "glb": "glb", + "unified_npz": "smpl", + "parc_ms": "smpl", +} + +FORMAT_REFERENCE: dict[str, str] = { + ".bvh": "lafan_bvh", + ".csv": "smpl", + ".glb": "glb", + ".gltf": "glb", + ".npy": "smplx", + ".npz": "smpl", + ".pickle": "smplx", + ".pkl": "smplx", + ".pt": "gvhmr", + ".pth": "gvhmr", +} + +OBJECT_DATASETS = frozenset({"omomo", "omnicontact"}) +TERRAIN_DATASETS = frozenset({"meshmimic_holosoma", "parc_ms"}) + + +def category_for_dataset(dataset: str) -> AssetCategory: + """Return the workflow category for a normalized dataset identifier.""" + + if dataset in OBJECT_DATASETS: + return AssetCategory.OBJECT_INTERACTION + if dataset in TERRAIN_DATASETS: + return AssetCategory.TERRAIN_SCENE + return AssetCategory.PLAIN_MOTION + + +def reference_for_dataset(dataset: str, suffix: str) -> str: + """Return the canonical human reference used for calibration selection.""" + + return DATASET_REFERENCE.get(dataset, FORMAT_REFERENCE.get(suffix.lower(), "smpl")) + + +def backend_for_category(category: AssetCategory) -> str: + """Return the only currently declared compatible retarget backend.""" + + if category is AssetCategory.PLAIN_MOTION: + return "newton" + if category in {AssetCategory.OBJECT_INTERACTION, AssetCategory.TERRAIN_SCENE}: + return "interaction_mesh" + raise ValueError(f"asset category {category.value!r} is not retargetable motion") + + +__all__ = [ + "DATASET_REFERENCE", + "FORMAT_REFERENCE", + "OBJECT_DATASETS", + "TERRAIN_DATASETS", + "backend_for_category", + "category_for_dataset", + "reference_for_dataset", +] diff --git a/hhtools/services/runtime_lease.py b/hhtools/services/runtime_lease.py new file mode 100644 index 00000000..8d9e6420 --- /dev/null +++ b/hhtools/services/runtime_lease.py @@ -0,0 +1,200 @@ +"""Cross-process ownership lease for one Agent data directory. + +The job scheduler and interrupted-job recovery are process-local. Two live +runtimes must therefore never construct independent managers over the same +data directory. This module provides the small ownership primitive used by +composition roots before they create any stores or recover active jobs. + +The lock file is deliberately retained after release. Removing an advisory +lock file creates an inode/handle race in which two processes can each lock a +different file with the same name. Only the operating-system lock is the +lease; closing the descriptor, including at process termination, releases it. +""" + +from __future__ import annotations + +import errno +import importlib +import os +import threading +from pathlib import Path +from types import TracebackType +from typing import Any, BinaryIO, Self + +from hhtools.contracts import ApiError, ErrorStage + +_LOCK_FILENAME = ".agent-runtime.lock" +_CONTENTION_ERRNOS = frozenset({errno.EACCES, errno.EAGAIN, errno.EDEADLK}) +_WINDOWS_CONTENTION_ERRORS = frozenset({32, 33}) + + +class RuntimeLeaseError(RuntimeError): + """Expected, transport-neutral failure to acquire runtime ownership.""" + + def __init__(self, error: ApiError) -> None: + self.error = error + super().__init__(f"{error.code}: {error.message}") + + @property + def api_error(self) -> ApiError: + """Return the versioned public error without exposing the lock path.""" + + return self.error + + @property + def code(self) -> str: + """Return the stable machine code without requiring message parsing.""" + + return self.error.code + + +def _error(code: str, message: str, *, retryable: bool) -> RuntimeLeaseError: + return RuntimeLeaseError( + ApiError( + code=code, + message=message, + retryable=retryable, + stage=ErrorStage.ADMISSION, + ) + ) + + +def _already_active_error() -> RuntimeLeaseError: + return _error( + "RUNTIME_ALREADY_ACTIVE", + "Another HHTools runtime already owns the Agent data directory.", + retryable=True, + ) + + +def _unavailable_error() -> RuntimeLeaseError: + return _error( + "RUNTIME_LEASE_UNAVAILABLE", + "HHTools could not establish exclusive runtime ownership.", + retryable=False, + ) + + +def _is_contention(error: OSError) -> bool: + return error.errno in _CONTENTION_ERRNOS or getattr(error, "winerror", None) in ( + _WINDOWS_CONTENTION_ERRORS + ) + + +def _fcntl_module() -> Any: + """Load the POSIX-only module without applying Windows stub types.""" + + return importlib.import_module("fcntl") + + +def _lock(stream: BinaryIO) -> None: + stream.seek(0) + if os.name == "nt": + import msvcrt + + msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1) + return + + fcntl = _fcntl_module() + fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + + +def _unlock(stream: BinaryIO) -> None: + stream.seek(0) + if os.name == "nt": + import msvcrt + + msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1) + return + + fcntl = _fcntl_module() + fcntl.flock(stream.fileno(), fcntl.LOCK_UN) + + +def _open_lock_file(data_dir: Path) -> BinaryIO: + data_dir.mkdir(parents=True, exist_ok=True) + flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOINHERIT", 0) + # Refuse a pre-existing symlink where the platform can enforce this in the + # same system call. The file never contains host paths or process data. + flags |= getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(data_dir / _LOCK_FILENAME, flags, 0o600) + try: + return os.fdopen(descriptor, "r+b", buffering=0) + except Exception: + os.close(descriptor) + raise + + +class AgentRuntimeLease: + """One held advisory lease over an Agent data directory. + + Acquire this before constructing ``JobManager`` so a competing process + cannot run interrupted-job recovery or create a second GPU scheduler. + Instances are context managers and ``release`` is idempotent. + """ + + def __init__(self, stream: BinaryIO) -> None: + self._stream: BinaryIO | None = stream + self._state_lock = threading.Lock() + + @classmethod + def acquire(cls, data_dir: str | os.PathLike[str]) -> Self: + """Acquire exclusive ownership or raise a sanitized service error.""" + + try: + stream = _open_lock_file(Path(data_dir)) + except OSError as error: + raise _unavailable_error() from error + + try: + _lock(stream) + except OSError as error: + try: + stream.close() + except OSError: + pass + if _is_contention(error): + raise _already_active_error() from error + raise _unavailable_error() from error + return cls(stream) + + @property + def held(self) -> bool: + """Whether this object still holds its operating-system descriptor.""" + + with self._state_lock: + return self._stream is not None + + def release(self) -> None: + """Release ownership; repeated calls are safe.""" + + with self._state_lock: + stream = self._stream + self._stream = None + if stream is None: + return + try: + _unlock(stream) + except OSError: + # Closing the descriptor is the authoritative OS release path. + # An unlock failure must not leave a live handle behind. + pass + finally: + try: + stream.close() + except OSError: + pass + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exception_type: type[BaseException] | None, + exception: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self.release() + + +__all__ = ["AgentRuntimeLease", "RuntimeLeaseError"] diff --git a/hhtools/utils/paths.py b/hhtools/utils/paths.py index 5514e1d1..ddb86022 100644 --- a/hhtools/utils/paths.py +++ b/hhtools/utils/paths.py @@ -5,10 +5,14 @@ import os from pathlib import Path -from platformdirs import user_cache_dir +from platformdirs import user_cache_dir, user_config_dir, user_data_dir HHTOOLS_CACHE_ENV = "HHTOOLS_CACHE_DIR" HHTOOLS_ROBOT_DIR_ENV = "HHTOOLS_ROBOT_DIR" +HHTOOLS_JOB_HISTORY_DIR_ENV = "HHTOOLS_JOB_HISTORY_DIR" +HHTOOLS_WEB_SETTINGS_PATH_ENV = "HHTOOLS_WEB_SETTINGS_PATH" +HHTOOLS_MOTION_LIBRARY_ROOT_ENV = "HHTOOLS_MOTION_LIBRARY_ROOT" +HHTOOLS_MOTION_LIBRARY_SETTINGS_PATH_ENV = "HHTOOLS_MOTION_LIBRARY_SETTINGS_PATH" def hhtools_cache_dir() -> Path: @@ -43,9 +47,90 @@ def user_robot_dir() -> Path: return p +def user_job_history_dir() -> Path: + """Return the persistent per-user Web job-history directory. + + Job records are application data rather than an ephemeral compute cache. Tests and + portable installations can redirect the directory with ``HHTOOLS_JOB_HISTORY_DIR``; + ``XDG_STATE_HOME`` and ``XDG_CONFIG_HOME`` are also honoured before the platform + default so isolated environments never write into the real user profile. + """ + override = os.environ.get(HHTOOLS_JOB_HISTORY_DIR_ENV) + if override: + p = Path(override).expanduser() + else: + xdg = os.environ.get("XDG_STATE_HOME") or os.environ.get("XDG_CONFIG_HOME") + p = ( + Path(xdg).expanduser() / "hhtools" / "jobs" + if xdg + else Path(user_data_dir("hhtools", "hhtools")) / "jobs" + ) + p.mkdir(parents=True, exist_ok=True) + return p + + +def user_web_settings_path() -> Path: + """Return the cross-platform file used for persistent Web service settings.""" + + override = os.environ.get(HHTOOLS_WEB_SETTINGS_PATH_ENV) + if override: + return Path(override).expanduser() + return Path(user_config_dir("hhtools", "hhtools")) / "web-settings.json" + + +def user_motion_library_root() -> Path: + """Return the configured or platform-standard Motion Library directory. + + ``HHTOOLS_MOTION_LIBRARY_ROOT`` is an explicit process-level override. An + XDG configuration root keeps existing Linux deployments and isolated test + environments on their historical ``$XDG_CONFIG_HOME/hhtools/motions`` + path. On hosts without XDG, an already populated legacy + ``~/.config/hhtools/motions`` directory wins so an upgrade never makes an + existing library appear to vanish. New installations use the platform + data directory (LocalAppData on Windows, Application Support on macOS, and + the XDG data directory on Linux). + + The directory is not created here. Callers choosing a managed storage + root must validate/adopt it before performing writes. + """ + + override = os.environ.get(HHTOOLS_MOTION_LIBRARY_ROOT_ENV) + if override: + return Path(override).expanduser() + + xdg_config = os.environ.get("XDG_CONFIG_HOME") + if xdg_config: + return Path(xdg_config).expanduser() / "hhtools" / "motions" + + legacy = Path.home() / ".config" / "hhtools" / "motions" + if legacy.exists(): + return legacy + return Path(user_data_dir("hhtools", "hhtools")) / "motions" + + +def user_motion_library_settings_path() -> Path: + """Return the JSON file used for the persistent Motion Library setting.""" + + override = os.environ.get(HHTOOLS_MOTION_LIBRARY_SETTINGS_PATH_ENV) + if override: + return Path(override).expanduser() + xdg_config = os.environ.get("XDG_CONFIG_HOME") + if xdg_config: + return Path(xdg_config).expanduser() / "hhtools" / "motion-library-settings.json" + return Path(user_config_dir("hhtools", "hhtools")) / "motion-library-settings.json" + + __all__ = [ "HHTOOLS_CACHE_ENV", + "HHTOOLS_JOB_HISTORY_DIR_ENV", + "HHTOOLS_MOTION_LIBRARY_ROOT_ENV", + "HHTOOLS_MOTION_LIBRARY_SETTINGS_PATH_ENV", "HHTOOLS_ROBOT_DIR_ENV", + "HHTOOLS_WEB_SETTINGS_PATH_ENV", "hhtools_cache_dir", + "user_job_history_dir", + "user_motion_library_root", + "user_motion_library_settings_path", "user_robot_dir", + "user_web_settings_path", ] diff --git a/hhtools/viewer/app.py b/hhtools/viewer/app.py index 184a5ef9..c48b4129 100644 --- a/hhtools/viewer/app.py +++ b/hhtools/viewer/app.py @@ -76,6 +76,7 @@ list_folders, scan_library, ) +from hhtools.viewer.markdown_compat import add_safe_markdown, set_safe_markdown from hhtools.viewer.panels import PlaybackPanel from hhtools.viewer.renderers import ( CapsuleMeshRenderer, @@ -420,7 +421,8 @@ def _on_sigint(signum, frame): # type: ignore[no-untyped-def] # Keep the top of the sidebar airy: a single low-contrast badge in place of the # old h2 title. The full brand string still lives in the browser titlebar via # ``label=TITLEBAR`` on the Viser server above. - server.gui.add_markdown( + add_safe_markdown( + server.gui, f"
" f"HHTOOLS" @@ -462,7 +464,7 @@ def _on_sigint(signum, frame): # type: ignore[no-untyped-def] # NPZ conversion, Save whole folder, etc.). We live-update both widgets AND # call server.flush() from the progress helper so the browser paints during # long synchronous Python calls. - progress_label_md = server.gui.add_markdown("") + progress_label_md = add_safe_markdown(server.gui, "") progress_bar = server.gui.add_progress_bar( value=0.0, visible=False, animated=False, ) @@ -487,7 +489,9 @@ def _on_sigint(signum, frame): # type: ignore[no-untyped-def] # ``_load_by_label`` is wired up later in this function because it # depends on ``_on_clip_pick`` being defined, but the read-only # getters work as soon as the picker exists. - status_md = server.gui.add_markdown(_make_status(entries, entries, cache)) + status_md = add_safe_markdown( + server.gui, _make_status(entries, entries, cache) + ) save_clip_btn = server.gui.add_button( "Save this clip", icon="device-floppy", hint="Copy the current clip's NPZ into assets/save_npz.", @@ -499,7 +503,7 @@ def _on_sigint(signum, frame): # type: ignore[no-untyped-def] # Persistent save log (multi-line markdown). Auto-updated by save callbacks # with "saved N clip(s) to " so users get permanent feedback even after # the transient toast notification disappears. - save_log_md = server.gui.add_markdown(_SAVE_LOG_EMPTY) + save_log_md = add_safe_markdown(server.gui, _SAVE_LOG_EMPTY) if not entries: save_clip_btn.disabled = True @@ -507,14 +511,31 @@ def _on_sigint(signum, frame): # type: ignore[no-untyped-def] # Clip info + Upload nested inside "Library" so the group is self-contained. with server.gui.add_folder("Clip info", expand_by_default=True): - motion_label = server.gui.add_text("Name", initial_value="(none)") - motion_label.disabled = True - info_label = server.gui.add_text("Frames · FPS · Bones", initial_value="—") - info_label.disabled = True - axis_label = server.gui.add_text("Up axis (src → view)", initial_value="—") - axis_label.disabled = True - saved_label = server.gui.add_text("Persisted", initial_value="no") - saved_label.disabled = True + # Viser 1.0.x renders disabled text inputs at extremely low + # contrast. Clip metadata is read-only, so expose it as safe + # markdown instead of pretending that it is an editable form. + clip_info_state = { + "name": "(none)", + "summary": "—", + "axis": "—", + "persisted": "no", + } + + def _render_clip_info() -> str: + return ( + f"Name: {_html_escape(clip_info_state['name'])}
" + f"Frames · FPS · Bones: " + f"{_html_escape(clip_info_state['summary'])}
" + f"Up axis (src → view): " + f"{_html_escape(clip_info_state['axis'])}
" + f"Persisted: {_html_escape(clip_info_state['persisted'])}" + ) + + clip_info_md = add_safe_markdown(server.gui, _render_clip_info()) + + def _set_clip_info(**changes: str) -> None: + clip_info_state.update(changes) + set_safe_markdown(clip_info_md, _render_clip_info()) with server.gui.add_folder("Upload", expand_by_default=False): file_picker = server.gui.add_upload_button( @@ -530,7 +551,7 @@ def _on_upload(event): # type: ignore[no-untyped-def] tmp.write_bytes(upload.content) lib_state["current"] = None _load_path(tmp) - saved_label.value = "upload (not persisted)" + _set_clip_info(persisted="upload (not persisted)") # ---- Display options (parent group) -------------------------------------- # Appearance-style toggles and transform toggles share no state but users tend @@ -964,11 +985,14 @@ def _load_path( _rebuild_renderers(reset_playback=True) m_final = state.get("final") if isinstance(m_final, Motion): - motion_label.value = m_final.name - info_label.value = ( - f"{m_final.num_frames} · {m_final.framerate:.1f} · {m_final.num_bones}" + _set_clip_info( + name=m_final.name, + summary=( + f"{m_final.num_frames} · {m_final.framerate:.1f} · " + f"{m_final.num_bones}" + ), + axis=f"{original_axis} → Z", ) - axis_label.value = f"{original_axis} → Z" _fit_camera_to_motion(server, m_final) _fire_motion_loaded_callbacks() @@ -1016,18 +1040,21 @@ def _load_entry(entry: LibraryEntry) -> None: _rebuild_renderers(reset_playback=True) m_final = state.get("final") if isinstance(m_final, Motion): - motion_label.value = m_final.name - info_label.value = ( - f"{m_final.num_frames} · {m_final.framerate:.1f} · " - f"{m_final.num_bones}" + _set_clip_info( + name=m_final.name, + summary=( + f"{m_final.num_frames} · {m_final.framerate:.1f} · " + f"{m_final.num_bones}" + ), + axis=f"{_cached.up_axis} → Z", ) - axis_label.value = f"{_cached.up_axis} → Z" _fit_camera_to_motion(server, m_final) - saved_label.value = ( - "yes" if cache.is_saved(entry) else "no (ephemeral)" + _set_clip_info( + persisted="yes" if cache.is_saved(entry) else "no (ephemeral)" ) - status_md.content = _make_status( - entries, lib_state["filtered"], cache, + set_safe_markdown( + status_md, + _make_status(entries, lib_state["filtered"], cache), ) _fire_motion_loaded_callbacks() return @@ -1055,8 +1082,13 @@ def _load_entry(entry: LibraryEntry) -> None: library_entry=entry, ) _motion_mem_cache[_cache_key] = state["raw"] - saved_label.value = "yes" if cache.is_saved(entry) else "no (ephemeral)" - status_md.content = _make_status(entries, lib_state["filtered"], cache) + _set_clip_info( + persisted="yes" if cache.is_saved(entry) else "no (ephemeral)" + ) + set_safe_markdown( + status_md, + _make_status(entries, lib_state["filtered"], cache), + ) finally: progress.done() return @@ -1110,15 +1142,22 @@ def _load_entry(entry: LibraryEntry) -> None: _rebuild_renderers(reset_playback=True) m_final = state.get("final") if isinstance(m_final, Motion): - motion_label.value = m_final.name - info_label.value = ( - f"{m_final.num_frames} · {m_final.framerate:.1f} · " - f"{m_final.num_bones}" + _set_clip_info( + name=m_final.name, + summary=( + f"{m_final.num_frames} · {m_final.framerate:.1f} · " + f"{m_final.num_bones}" + ), + axis=f"{motion.up_axis} → Z", ) - axis_label.value = f"{motion.up_axis} → Z" _fit_camera_to_motion(server, m_final) - saved_label.value = "yes" if cache.is_saved(entry) else "no (ephemeral)" - status_md.content = _make_status(entries, lib_state["filtered"], cache) + _set_clip_info( + persisted="yes" if cache.is_saved(entry) else "no (ephemeral)" + ) + set_safe_markdown( + status_md, + _make_status(entries, lib_state["filtered"], cache), + ) _fire_motion_loaded_callbacks() finally: progress.done() @@ -1138,9 +1177,12 @@ def _load_entry(entry: LibraryEntry) -> None: lib_state["current"] = entry _load_path(entry.source_path, library_entry=entry) _motion_mem_cache[_cache_key] = state["raw"] - saved_label.value = "yes" if cache.is_saved(entry) else "no (ephemeral)" - status_md.content = _make_status( - entries, lib_state["filtered"], cache, + _set_clip_info( + persisted="yes" if cache.is_saved(entry) else "no (ephemeral)" + ) + set_safe_markdown( + status_md, + _make_status(entries, lib_state["filtered"], cache), ) finally: progress.done() @@ -1175,8 +1217,13 @@ def _load_entry(entry: LibraryEntry) -> None: lib_state["current"] = entry _load_path(npz_path, library_entry=entry) _motion_mem_cache[_cache_key] = state["raw"] - saved_label.value = "yes" if cache.is_saved(entry) else "no (ephemeral)" - status_md.content = _make_status(entries, lib_state["filtered"], cache) + _set_clip_info( + persisted="yes" if cache.is_saved(entry) else "no (ephemeral)" + ) + set_safe_markdown( + status_md, + _make_status(entries, lib_state["filtered"], cache), + ) progress.done() # -------- Library callbacks ---------------------------------------------- @@ -1216,7 +1263,7 @@ def _refresh_clip_dropdown() -> None: # guaranteed when the value is set programmatically (e.g. Robot-tab # folder/search sync while the Motion-tab guard is active). _on_clip_pick(None) - status_md.content = _make_status(entries, filtered, cache) + set_safe_markdown(status_md, _make_status(entries, filtered, cache)) _notify_library_refreshed( tuple(clip_picker.options), str(clip_picker.value), ) @@ -1305,8 +1352,10 @@ def _worker() -> None: # wrong. The Clip Info panel still shows the truncated one-liner for # persistence, but the toast carries the actual multi-line message # (install instructions for missing FBX backends, etc.). - motion_label.value = f"(load failed) {type(exc).__name__}" - info_label.value = str(exc).splitlines()[0][:100] + _set_clip_info( + name=f"(load failed) {type(exc).__name__}", + summary=str(exc).splitlines()[0][:100], + ) _notify_all( server, f"Could not load {entry.stem}", @@ -1354,15 +1403,21 @@ def _worker() -> None: except Exception as exc: # pragma: no cover progress.done() msg = f"{type(exc).__name__}: {exc}" - saved_label.value = f"save failed: {type(exc).__name__}" + _set_clip_info(persisted=f"save failed: {type(exc).__name__}") _notify_all(server, "Save failed", msg, color="red") return rel = _relative_to_repo(dst) - saved_label.value = f"yes → {rel}" - status_md.content = _make_status(entries, lib_state["filtered"], cache) - save_log_md.content = _format_save_log( - heading="Saved 1 clip", - lines=[f"`{rel}`"], + _set_clip_info(persisted=f"yes → {rel}") + set_safe_markdown( + status_md, + _make_status(entries, lib_state["filtered"], cache), + ) + set_safe_markdown( + save_log_md, + _format_save_log( + heading="Saved 1 clip", + lines=[f"`{rel}`"], + ), ) _notify_all( server, @@ -1419,20 +1474,26 @@ def _worker() -> None: except Exception as exc: # pragma: no cover progress.done() msg = f"{type(exc).__name__}: {exc}" - saved_label.value = f"save folder failed: {type(exc).__name__}" + _set_clip_info(persisted=f"save folder failed: {type(exc).__name__}") _notify_all(server, "Save folder failed", msg, color="red") return count = len(saved) rel_root = _relative_to_repo(dst_root) - saved_label.value = f"saved {count} clips → {rel_root}" - status_md.content = _make_status(entries, lib_state["filtered"], cache) + _set_clip_info(persisted=f"saved {count} clips → {rel_root}") + set_safe_markdown( + status_md, + _make_status(entries, lib_state["filtered"], cache), + ) preview = [f"`{_relative_to_repo(p)}`" for p in saved[:5]] if count > 5: preview.append(f"… and {count - 5} more") - save_log_md.content = _format_save_log( - heading=f"Saved {count} clip(s) from " - f"{_html_escape(label)}", - lines=preview, + set_safe_markdown( + save_log_md, + _format_save_log( + heading=f"Saved {count} clip(s) from " + f"{_html_escape(label)}", + lines=preview, + ), ) _notify_all( server, @@ -1588,8 +1649,10 @@ def _initial_load() -> None: import traceback traceback.print_exc() - motion_label.value = f"(load failed) {type(exc).__name__}" - info_label.value = str(exc)[:80] + _set_clip_info( + name=f"(load failed) {type(exc).__name__}", + summary=str(exc)[:80], + ) progress.done() threading.Thread(target=_initial_load, daemon=True, name="hhtools-initial-load").start() @@ -1809,16 +1872,18 @@ def start( self._bar.animated = bar_animated self._bar.value = initial_value self._bar.visible = True - self._label.content = _progress_md( - title, initial_value, bar_animated, elapsed=0.0, + set_safe_markdown( + self._label, + _progress_md(title, initial_value, bar_animated, elapsed=0.0), ) for mbar, mmd in self._mirrors: try: mbar.animated = bar_animated mbar.value = initial_value mbar.visible = True - mmd.content = _progress_md( - title, initial_value, bar_animated, elapsed=0.0, + set_safe_markdown( + mmd, + _progress_md(title, initial_value, bar_animated, elapsed=0.0), ) except Exception: pass @@ -1840,11 +1905,14 @@ def set_message(self, message: str, *, value: float | None = None) -> None: clamped = float(max(0.0, min(100.0, value))) self._bar.value = clamped elapsed = time.monotonic() - self._started_at - self._label.content = _progress_md( - message, - float(self._bar.value) if not self._bar.animated else 0.0, - bool(self._bar.animated), - elapsed=elapsed, + set_safe_markdown( + self._label, + _progress_md( + message, + float(self._bar.value) if not self._bar.animated else 0.0, + bool(self._bar.animated), + elapsed=elapsed, + ), ) pv = float(self._bar.value) if not self._bar.animated else 0.0 anim = bool(self._bar.animated) @@ -1852,7 +1920,9 @@ def set_message(self, message: str, *, value: float | None = None) -> None: try: if value is not None and not mbar.animated: mbar.value = clamped - mmd.content = _progress_md(message, pv, anim, elapsed=elapsed) + set_safe_markdown( + mmd, _progress_md(message, pv, anim, elapsed=elapsed) + ) except Exception: pass self._server.flush() @@ -1883,15 +1953,26 @@ def pin_milestone(self, message: str | None, *, floor: float) -> None: value = self._compute_value_locked() self._bar.value = value elapsed = time.monotonic() - self._started_at - self._label.content = _progress_md( - self._title, value, bool(self._bar.animated), elapsed=elapsed, + set_safe_markdown( + self._label, + _progress_md( + self._title, + value, + bool(self._bar.animated), + elapsed=elapsed, + ), ) for mbar, mmd in self._mirrors: try: mbar.value = value - mmd.content = _progress_md( - self._title, value, bool(self._bar.animated), - elapsed=elapsed, + set_safe_markdown( + mmd, + _progress_md( + self._title, + value, + bool(self._bar.animated), + elapsed=elapsed, + ), ) except Exception: pass @@ -1913,16 +1994,20 @@ def done(self, *, success: bool = False, last_message: str | None = None) -> Non self._bar.visible = True self._bar.value = 100.0 elapsed = time.monotonic() - self._started_at - self._label.content = _progress_md( - last_message, 100.0, False, elapsed=elapsed, + set_safe_markdown( + self._label, + _progress_md(last_message, 100.0, False, elapsed=elapsed), ) for mbar, mmd in self._mirrors: try: mbar.animated = False mbar.visible = True mbar.value = 100.0 - mmd.content = _progress_md( - last_message, 100.0, False, elapsed=elapsed, + set_safe_markdown( + mmd, + _progress_md( + last_message, 100.0, False, elapsed=elapsed + ), ) except Exception: pass @@ -1931,13 +2016,13 @@ def done(self, *, success: bool = False, last_message: str | None = None) -> Non self._bar.visible = False self._bar.animated = False self._bar.value = 0.0 - self._label.content = "" + set_safe_markdown(self._label, "") for mbar, mmd in self._mirrors: try: mbar.visible = False mbar.animated = False mbar.value = 0.0 - mmd.content = "" + set_safe_markdown(mmd, "") except Exception: pass self._server.flush() @@ -1989,21 +2074,27 @@ def _tick() -> None: pv = ( float(self._bar.value) if not self._bar.animated else 0.0 ) - self._label.content = _progress_md( - self._title, - pv, - bool(self._bar.animated), - elapsed=elapsed, + set_safe_markdown( + self._label, + _progress_md( + self._title, + pv, + bool(self._bar.animated), + elapsed=elapsed, + ), ) for mbar, mmd in self._mirrors: try: if self._expected_seconds is not None: mbar.value = self._compute_value_locked() - mmd.content = _progress_md( - self._title, - float(mbar.value) if not mbar.animated else 0.0, - bool(mbar.animated), - elapsed=elapsed, + set_safe_markdown( + mmd, + _progress_md( + self._title, + float(mbar.value) if not mbar.animated else 0.0, + bool(mbar.animated), + elapsed=elapsed, + ), ) except Exception: pass @@ -2113,7 +2204,8 @@ def _await_robot_prewarm(*, timeout: float = 120.0) -> None: } if not presets: - server.gui.add_markdown( + add_safe_markdown( + server.gui, f"
" f"No robot presets found under configs/robots/. " f"Copy configs/robots/_template/ and edit." @@ -2141,7 +2233,7 @@ def _label(p: RobotPreset) -> str: ), ) - stats_md = server.gui.add_markdown("") + stats_md = add_safe_markdown(server.gui, "") # Library — identical controls + sync with Motion → Library (Search / Folder / Clip). initial_clip_labels: tuple[str, ...] = ("(no matches)",) @@ -2170,7 +2262,7 @@ def _label(p: RobotPreset) -> str: pass with server.gui.add_folder("Library", expand_by_default=True): - robot_lib_prog_md = server.gui.add_markdown("") + robot_lib_prog_md = add_safe_markdown(server.gui, "") robot_lib_prog_bar = server.gui.add_progress_bar( value=0.0, visible=False, animated=False, ) @@ -2452,8 +2544,9 @@ def _on_show_robot(_): # type: ignore[no-untyped-def] retarget_bar = server.gui.add_progress_bar( 0.0, animated=False, visible=False, ) - retarget_progress_md = server.gui.add_markdown("") - retarget_status = server.gui.add_markdown( + retarget_progress_md = add_safe_markdown(server.gui, "") + retarget_status = add_safe_markdown( + server.gui, "Select a motion in the Motion tab, " "then click Retarget above." ) @@ -2560,13 +2653,14 @@ def _check_rig_type_switch(motion_obj) -> bool: f" Reference pose auto-switched to " f"{_html_escape(suggested_ref)}." ) - retarget_status.content = ( + set_safe_markdown( + retarget_status, f"⚠ Rig type changed: " f"{esc_prev}{esc_curr}. " f"Mapping coverage: {coverage}/17 canonical joints." f"{ref_note} " f"Please verify the calibration is correct " - f"for this data source." + f"for this data source.", ) _notify_all( server, "Data source type changed", @@ -2578,13 +2672,14 @@ def _check_rig_type_switch(motion_obj) -> bool: ) elif coverage < 10: esc_curr = _html_escape(current_rig) - retarget_status.content = ( + set_safe_markdown( + retarget_status, f"⚠ Low mapping coverage: " f"rig={esc_curr}, " f"only {coverage}/17 canonical joints resolved. " f"Scale/retarget results may be incorrect. " f"Try selecting a different reference that matches " - f"your data source's joint naming convention." + f"your data source's joint naming convention.", ) _notify_all( server, f"Low mapping coverage ({coverage}/17)", @@ -2592,11 +2687,12 @@ def _check_rig_type_switch(motion_obj) -> bool: color="orange", ) elif ref_switched and suggested_ref: - retarget_status.content = ( + set_safe_markdown( + retarget_status, f" " f"Detected rig: {_html_escape(current_rig)}. " f"Reference pose auto-switched to " - f"{_html_escape(suggested_ref)}." + f"{_html_escape(suggested_ref)}.", ) _last_rig_type["value"] = current_rig @@ -2619,7 +2715,7 @@ def _check_rig_type_switch(motion_obj) -> bool: # session lives in the same right-hand panel as the rest of the # Robot tab — no occlusion, no dimming. with server.gui.add_folder("Retarget calibration"): - calib_status_md = server.gui.add_markdown("") + calib_status_md = add_safe_markdown(server.gui, "") calib_reference_picker = server.gui.add_dropdown( "Reference pose", options=( @@ -2683,7 +2779,7 @@ def _sync_calib_reference_after_motion_load() -> None: _sync_calib_reference_after_motion_load, ) - progress_md = server.gui.add_markdown("") + progress_md = add_safe_markdown(server.gui, "") def _render_stats(preset: RobotPreset) -> str: esc_name = _html_escape(preset.display_name) @@ -2939,11 +3035,11 @@ def _render_robot(model: URDFRobotModel) -> int: @picker.on_update def _on_pick(_): # type: ignore[no-untyped-def] preset = label_to_preset[picker.value] - stats_md.content = _render_stats(preset) + set_safe_markdown(stats_md, _render_stats(preset)) load_btn.disabled = not preset.has_urdf # Prime the stats + button state. - stats_md.content = _render_stats(label_to_preset[picker.value]) + set_safe_markdown(stats_md, _render_stats(label_to_preset[picker.value])) load_btn.disabled = not label_to_preset[picker.value].has_urdf # Persistent references for modal widgets so they aren't garbage- @@ -2960,7 +3056,8 @@ def _show_recalibrate_modal(preset: RobotPreset) -> None: modal = server.gui.add_modal("Calibration exists") _load_modal_refs["modal"] = modal with modal: - server.gui.add_markdown( + add_safe_markdown( + server.gui, f"{_html_escape(preset.display_name)} already has a " "saved calibration.
" "Do you want to open the calibration editor?" @@ -2984,9 +3081,10 @@ def _on_load(_): # type: ignore[no-untyped-def] preset = label_to_preset[picker.value] def _worker() -> None: - progress_md.content = ( + set_safe_markdown( + progress_md, f" " - f"Loading {_html_escape(preset.display_name)}…" + f"Loading {_html_escape(preset.display_name)}…", ) try: server.flush() @@ -2995,9 +3093,10 @@ def _worker() -> None: try: model = load_robot(preset, build_collision_scene=True) except Exception as err: - progress_md.content = ( + set_safe_markdown( + progress_md, f"Load failed: " - f"{_html_escape(f'{type(err).__name__}: {err}')}" + f"{_html_escape(f'{type(err).__name__}: {err}')}", ) _notify_all( server, "Robot load failed", @@ -3010,18 +3109,20 @@ def _worker() -> None: try: n = _render_robot(model) except Exception as err: - progress_md.content = ( + set_safe_markdown( + progress_md, f"Render failed: " - f"{_html_escape(f'{type(err).__name__}: {err}')}" + f"{_html_escape(f'{type(err).__name__}: {err}')}", ) return animator = state["animator"] ground_lift = getattr(animator, "ground_offset_z", 0.0) - progress_md.content = ( + set_safe_markdown( + progress_md, f" " f"Loaded {_html_escape(preset.display_name)} · " f"{len(model.actuated_joints)} DOF · {n} meshes · " - f"lifted {ground_lift:.3f} m to ground" + f"lifted {ground_lift:.3f} m to ground", ) _notify_all( server, "Robot loaded", @@ -3061,10 +3162,10 @@ def _prewarm_ik() -> None: # After successful load, check if a calibration already # exists and prompt the user about recalibrating. - from hhtools.retarget.calibration import resolve_calibration_file + from hhtools.retarget.calibration import resolve_preset_calibration_file cal_path = ( - resolve_calibration_file( - preset.urdf_path.parent, + resolve_preset_calibration_file( + preset, str(calib_reference_picker.value), ) if preset.urdf_path is not None else None @@ -3080,8 +3181,8 @@ def _prewarm_ik() -> None: def _on_clear(_): # type: ignore[no-untyped-def] _exit_calibration_mode() _clear_scene() - progress_md.content = ( - "Scene cleared." + set_safe_markdown( + progress_md, "Scene cleared." ) _refresh_calib_status() @@ -3143,7 +3244,7 @@ def _render_calib_status() -> str: def _refresh_calib_status() -> None: try: - calib_status_md.content = _render_calib_status() + set_safe_markdown(calib_status_md, _render_calib_status()) except Exception: pass @@ -3215,7 +3316,7 @@ def _load_existing_calib_into_state(model: URDFRobotModel) -> None: calib_state["current_q"] = q def _resolve_robot_calibration(model: URDFRobotModel): - """Look up the retarget calibration yaml sitting next to the URDF. + """Look up a user calibration override or the bundled robot yaml. Returns a :class:`~hhtools.retarget.calibration.RobotRetargetCalibration` or ``None`` if no yaml exists yet — callers gate retarget on a @@ -3224,14 +3325,14 @@ def _resolve_robot_calibration(model: URDFRobotModel): """ from hhtools.retarget.calibration import ( load_calibration, - resolve_calibration_file, + resolve_preset_calibration_file, ) preset = model.preset if preset.urdf_path is None: return None - cal_path = resolve_calibration_file( - preset.urdf_path.parent, + cal_path = resolve_preset_calibration_file( + preset, str(calib_reference_picker.value), ) if cal_path is None: @@ -3945,17 +4046,19 @@ def _worker() -> None: _publish_scaled_preview(preview) if sc is not None: _publish_robot_objects(sc, human_h) - retarget_status.content = ( + set_safe_markdown( + retarget_status, f" " f"Scaled preview · {preview.num_frames} frames · " f"{len(preview.joint_names)} ik_map joints · " f"rig: {rig_label} · " - f"height assumption: {human_h:.3f}m" + f"height assumption: {human_h:.3f}m", ) except Exception as err: # noqa: BLE001 - retarget_status.content = ( + set_safe_markdown( + retarget_status, f"Scaled preview failed: " - f"{_html_escape(f'{type(err).__name__}: {err}')}" + f"{_html_escape(f'{type(err).__name__}: {err}')}", ) threading.Thread( @@ -4095,12 +4198,13 @@ def _worker() -> None: f"{len(written)} pkl → {rel_root}", color="green", ) - retarget_status.content = ( + set_safe_markdown( + retarget_status, f" " f"Robot clip saved → " f"{_html_escape(str(rel_root))}/ " f"({len(written)} pkl: " - f"{', '.join(written.keys())})" + f"{', '.join(written.keys())})", ) except Exception as exc: # noqa: BLE001 import traceback @@ -4209,7 +4313,7 @@ def _on_retarget(_): # type: ignore[no-untyped-def] def _worker() -> None: from hhtools.io.robot_csv import save_robot_csv - from hhtools.retarget.calibration import resolve_calibration_file + from hhtools.retarget.calibration import resolve_preset_calibration_file from hhtools.retarget.interaction_mesh import ( InteractionMeshPipeline, InteractionMeshPipelineConfig, @@ -4285,8 +4389,10 @@ def _retarget_bump_pct(p: float) -> float: ) if not clips: - retarget_status.content = ( - f"Nothing was retargeted." + set_safe_markdown( + retarget_status, + f"" + "Nothing was retargeted.", ) return @@ -4332,8 +4438,8 @@ def _retarget_bump_pct(p: float) -> float: ) cal_path_str: str | None = None if preset.urdf_path is not None: - cr = resolve_calibration_file( - preset.urdf_path.parent, + cr = resolve_preset_calibration_file( + preset, ref_name, ) if cr is not None and cr.is_file(): @@ -4627,8 +4733,10 @@ def _single_frame_cb( ) if not exported: - retarget_status.content = ( - f"Nothing was retargeted." + set_safe_markdown( + retarget_status, + f"" + "Nothing was retargeted.", ) return @@ -4643,14 +4751,15 @@ def _single_frame_cb( if use_newton_batch else "per-clip solvers · " ) - retarget_status.content = ( + set_safe_markdown( + retarget_status, f" " f"Retargeted {len(exported)} clip(s) · " f"{batch_note}" f"rig: {rig_label} · " f"last result playing now · " f"{_html_escape(head.parent)}/ " - f"(see {_html_escape(head.name)}, …)" + f"(see {_html_escape(head.name)}, …)", ) _notify_all( server, f"Retargeted × {len(exported)}", @@ -4659,12 +4768,13 @@ def _single_frame_cb( else: path = exported[0] assert last_result is not None - retarget_status.content = ( + set_safe_markdown( + retarget_status, f" " f"Retargeted {last_result.num_frames} frames · " f"{len(last_result.dof_names)} DOF · " f"rig: {rig_label} → " - f"{_html_escape(path)}" + f"{_html_escape(path)}", ) _notify_all( server, "Retargeted", @@ -4672,9 +4782,10 @@ def _single_frame_cb( color="green", ) except Exception as err: # noqa: BLE001 - retarget_status.content = ( + set_safe_markdown( + retarget_status, f"Retarget failed: " - f"{_html_escape(f'{type(err).__name__}: {err}')}" + f"{_html_escape(f'{type(err).__name__}: {err}')}", ) _notify_all( server, "Retarget failed", @@ -4711,7 +4822,9 @@ def _do_save_calibration() -> None: """Gather the session's slider state, derive + persist, teardown UI. Side-effects: - * Writes ``retarget_calibration_.yaml`` next to the URDF. + * Writes ``retarget_calibration_.yaml`` next to a + writable source preset, or to the per-user overlay for an + installed read-only preset. * Mirrors the closed-form scale/offset cache into the yaml for diffability (see :func:`save_calibration`). * Removes the session folder and clears the reference skeleton. @@ -4743,9 +4856,8 @@ def _do_save_calibration() -> None: } from hhtools.retarget.calibration import ( RobotRetargetCalibration, - calibration_path_for, derive_calibration_params, - save_calibration, + save_calibration_for_preset, ) cal = RobotRetargetCalibration( @@ -4775,12 +4887,12 @@ def _do_save_calibration() -> None: color="orange", ) - cal_path = calibration_path_for( - preset.urdf_path.parent, - reference=str(calib_reference_picker.value), - ) try: - save_calibration(cal, cal_path, derived=derived) + cal_path = save_calibration_for_preset( + cal, + preset, + derived=derived, + ) except Exception as err: # noqa: BLE001 _notify_all( server, "Save failed", @@ -4986,7 +5098,8 @@ def _build_mapping_dropdowns(model: URDFRobotModel) -> dict[str, object]: c2n, _ = _current_ref_mappings() if not options: - server.gui.add_markdown( + add_safe_markdown( + server.gui, f"No reference joints available " "— mapping editor disabled." ) @@ -4995,7 +5108,8 @@ def _build_mapping_dropdowns(model: URDFRobotModel) -> dict[str, object]: with server.gui.add_folder( "Joint mapping", expand_by_default=False, ): - server.gui.add_markdown( + add_safe_markdown( + server.gui, "For each robot link, pick which human joint from the " f"{_html_escape(calib_reference_picker.value)} " "reference should drive it. Edits here are saved into " @@ -5072,7 +5186,8 @@ def _build_calib_session(model: URDFRobotModel) -> None: handles: dict[str, object] = {} with session: - server.gui.add_markdown( + add_safe_markdown( + server.gui, "Drag each slider so the robot's pose overlays the blue " "reference human. When you click **Save**, the closed-" "form per-limb scale + offset will be derived and cached " @@ -5443,9 +5558,10 @@ def _on_schema(_): # type: ignore[no-untyped-def] f"{out} ({len(cols)} columns)", color="blue", ) - progress_md.content = ( + set_safe_markdown( + progress_md, f" " - f"Wrote {_html_escape(out)} · {len(cols)} columns" + f"Wrote {_html_escape(out)} · {len(cols)} columns", ) diff --git a/hhtools/viewer/markdown_compat.py b/hhtools/viewer/markdown_compat.py new file mode 100644 index 00000000..8df3f5f9 --- /dev/null +++ b/hhtools/viewer/markdown_compat.py @@ -0,0 +1,58 @@ +"""Compatibility helpers for Viser's legacy MDX markdown renderer. + +Viser 1.0.x forwards HTML ``style`` attributes from markdown as string React +props. React requires an object for ``style``, so a single inline style can +replace the whole component with ``Markdown Failed to Render``. Keep this +workaround at the GUI boundary so both initial content and later handle updates +are treated consistently. + +This module is intentionally not a general-purpose HTML sanitizer. It only +removes the unsupported attribute from hhtools-controlled status markup. The +original strings retain their styles so the workaround can be removed after a +released Viser version adopts the fixed markdown pipeline. +""" + +from __future__ import annotations + +import re +from typing import Any + +_INLINE_STYLE_ATTRIBUTE = re.compile( + r"""\s+style\s*=\s*(?:"[^"<>]*"|'[^'<>]*'|(?!\{)[^\s<>"']+)""", + re.IGNORECASE, +) +_MARKUP_TAG = re.compile(r"<[^<>]+>") +_NON_SELF_CLOSING_BREAK = re.compile(r"", re.IGNORECASE) + + +def sanitize_markdown_for_viser(content: str) -> str: + """Remove inline ``style`` attributes unsupported by Viser 1.0.x MDX. + + Angle brackets are excluded from every attribute-value branch so malformed + quotes cannot consume later tags or paragraphs. The expression also + accepts mixed-case and unquoted string attributes for defensive + compatibility with future hhtools-controlled markup. Values beginning + with ``{`` are intentionally preserved because they are MDX expressions, + not the string prop that triggers React error #62. + """ + + without_string_styles = _MARKUP_TAG.sub( + lambda match: _INLINE_STYLE_ATTRIBUTE.sub("", match.group(0)), + content, + ) + # MDX parses HTML-like tags as JSX, where void elements must be explicitly + # self-closing. Normalizing the legacy spelling prevents an independent + # parse failure in status and modal copy. + return _NON_SELF_CLOSING_BREAK.sub("
", without_string_styles) + + +def add_safe_markdown(gui: Any, content: str, **kwargs: Any) -> Any: + """Create a Viser markdown handle after applying the compatibility pass.""" + + return gui.add_markdown(sanitize_markdown_for_viser(content), **kwargs) + + +def set_safe_markdown(handle: Any, content: str) -> None: + """Update a Viser markdown handle through the same compatibility pass.""" + + handle.content = sanitize_markdown_for_viser(content) diff --git a/hhtools/web/agent_api.py b/hhtools/web/agent_api.py new file mode 100644 index 00000000..35e30cd1 --- /dev/null +++ b/hhtools/web/agent_api.py @@ -0,0 +1,770 @@ +"""Versioned REST adapter for HHTools' transport-neutral agent services. + +This module intentionally contains no capability probing or solver logic. It +only retrieves the service instance assembled by :func:`hhtools.web.server.create_app` +and serializes its contracts through FastAPI. +""" + +from __future__ import annotations + +import json +import logging +import math +from collections.abc import Awaitable, Callable +from typing import Annotated, Any, Protocol, cast + +from fastapi import APIRouter, Body, Query, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse, Response +from fastapi.routing import APIRoute + +from hhtools.contracts import ( + AgentJobView, + ApiError, + ArtifactDescriptor, + ArtifactId, + ArtifactListResponse, + AssetBundle, + AssetCategory, + AssetId, + AssetInspection, + AssetInspectionRequest, + AssetKind, + AssetRegistrationRequest, + AssetSearchResponse, + CapabilityResponse, + ErrorStage, + JobLookupRequest, + JobRetryRequest, + JobStartRequest, + LegacyJobUpgradeRequest, + LegacyJobUpgradeResponse, + PreflightResponse, + RetargetPreflightRequest, +) +from hhtools.contracts.portability import ( + PortableJsonError as ContractPortableJsonError, +) +from hhtools.contracts.portability import ( + looks_like_host_path as contract_looks_like_host_path, +) +from hhtools.contracts.portability import ( + validate_portable_json as validate_contract_portable_json, +) +from hhtools.services.artifacts import StoredArtifact +from hhtools.services.assets import AssetServiceError +from hhtools.services.jobs import JobManagerError +from hhtools.services.legacy_job_upgrade import LegacyJobUpgradeError +from hhtools.web.agent_artifact_response import verified_artifact_response + +_log = logging.getLogger(__name__) + +_ERROR_STATUS_BY_CODE = { + "ALLOWED_ROOT_UNAVAILABLE": 503, + "AMBIGUOUS_ALLOWED_ROOT": 409, + "ARTIFACT_HASH_MISMATCH": 409, + "ARTIFACT_NOT_FOUND": 404, + "ASSET_CHANGED_DURING_UPGRADE": 409, + "ASSET_HASH_MISMATCH": 409, + "ASSET_NOT_FOUND": 404, + "ASSET_OUTSIDE_ALLOWED_ROOT": 403, + "ASSET_REGISTRATION_MISMATCH": 409, + "BACKEND_UNAVAILABLE": 503, + "BUNDLE_AMBIGUOUS": 409, + "INTERNAL_ERROR": 500, + "INVALID_JOB_TRANSITION": 409, + "INVALID_JOB_SPEC": 400, + "INVALID_JSON": 400, + "INVALID_PARAMETER": 400, + "JOB_CANCEL_UNSUPPORTED": 409, + "JOB_CONFLICT": 409, + "JOB_NOT_FOUND": 404, + "LEGACY_METADATA_MISMATCH": 409, + "PLAN_CONFLICT": 409, + "PLAN_NOT_FOUND": 404, + "PLAN_STALE": 409, + "QUEUE_FULL": 429, + "ROBOT_AMBIGUOUS": 409, + "ROBOT_NOT_FOUND": 404, + "ROBOT_REGISTRY_UNAVAILABLE": 503, + "RANGE_NOT_SUPPORTED": 416, + "REFERENCE_MISMATCH": 409, + "SCHEDULER_UNAVAILABLE": 503, +} + + +class _InvalidAgentJsonError(ValueError): + """The JSON representation is ambiguous or unsafe for the public wire.""" + + +def _strict_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise _InvalidAgentJsonError("duplicate object key") + result[key] = value + return result + + +def _reject_nonfinite_constant(_value: str) -> None: + raise _InvalidAgentJsonError("non-finite number") + + +def _strict_json_int(value: str) -> int: + if len(value) > 128: + raise _InvalidAgentJsonError("integer token is too long") + return int(value) + + +def _strict_json_float(value: str) -> float: + if len(value) > 128: + raise _InvalidAgentJsonError("float token is too long") + result = float(value) + if not math.isfinite(result): + raise _InvalidAgentJsonError("non-finite number") + return result + + +def _strict_json_loads(payload: bytes) -> Any: + try: + return json.loads( + payload.decode("utf-8"), + object_pairs_hook=_strict_object, + parse_float=_strict_json_float, + parse_int=_strict_json_int, + parse_constant=_reject_nonfinite_constant, + ) + except ( + UnicodeDecodeError, + json.JSONDecodeError, + _InvalidAgentJsonError, + RecursionError, + ) as exc: + raise _InvalidAgentJsonError("invalid strict JSON") from exc + + +def _looks_like_host_path( + value: str, + *, + allow_same_origin_ui_url: bool = False, +) -> bool: + return contract_looks_like_host_path( + value, + allow_same_origin_ui_url=allow_same_origin_ui_url, + ) + + +def _validate_portable_json( + value: Any, + *, + allow_same_origin_ui_url: bool = False, +) -> None: + try: + if allow_same_origin_ui_url and isinstance(value, str): + if contract_looks_like_host_path( + value, + allow_same_origin_ui_url=True, + ): + raise ContractPortableJsonError("host path") + return + validate_contract_portable_json(value) + except ContractPortableJsonError as error: + raise _InvalidAgentJsonError(str(error)) from error + + +def _error_status(error: ApiError) -> int: + status = _ERROR_STATUS_BY_CODE.get(error.code) + if status is not None: + return status + return 500 if error.stage is ErrorStage.INTERNAL else 422 + + +def _error_response(error: ApiError, *, status_code: int | None = None) -> JSONResponse: + headers = { + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + } + if error.code == "QUEUE_FULL" and error.next_action is not None: + raw_poll_ms = error.next_action.parameters.get("poll_after_ms") + if ( + isinstance(raw_poll_ms, int | float) + and not isinstance(raw_poll_ms, bool) + and math.isfinite(raw_poll_ms) + and 0 <= raw_poll_ms <= 300_000 + ): + headers["Retry-After"] = str(max(1, math.ceil(raw_poll_ms / 1000))) + return JSONResponse( + status_code=status_code or _error_status(error), + content=error.model_dump(mode="json", exclude_none=True), + headers=headers, + ) + + +def _portable_response(response: Response) -> Response: + content_type = response.headers.get("content-type", "").partition(";")[0].strip() + if content_type != "application/json" and not content_type.endswith("+json"): + return response + body = getattr(response, "body", None) + if not isinstance(body, bytes | bytearray | memoryview): + raise _InvalidAgentJsonError("JSON response is not buffered") + value = _strict_json_loads(bytes(body)) + _validate_portable_json(value) + response.headers["Cache-Control"] = "no-store" + response.headers["X-Content-Type-Options"] = "nosniff" + return response + + +def _portable_or_internal_error(response: Response) -> Response: + try: + return _portable_response(response) + except _InvalidAgentJsonError: + _log.error("blocked a non-portable Agent JSON response") + return _error_response( + ApiError( + code="INTERNAL_ERROR", + message="The Agent service produced an unsafe response.", + retryable=False, + stage=ErrorStage.INTERNAL, + ) + ) + + +class _AgentRoute(APIRoute): + """Keep expected Agent failures on the same versioned error contract.""" + + def get_route_handler(self) -> Callable[[Request], Awaitable[Response]]: + route_handler = super().get_route_handler() + + async def agent_route_handler(request: Request) -> Response: + try: + body = await request.body() + if body: + try: + _strict_json_loads(body) + except _InvalidAgentJsonError: + return _portable_or_internal_error( + _error_response( + ApiError( + code="INVALID_JSON", + message=( + "The Agent request body must be strict UTF-8 JSON " + "without duplicate keys or non-finite numbers." + ), + stage=ErrorStage.REQUEST, + ), + status_code=400, + ) + ) + response = await route_handler(request) + except RequestValidationError as exc: + issues = [ + { + "location": ".".join(str(part) for part in issue.get("loc", ())), + "type": str(issue.get("type", "validation_error")), + } + for issue in exc.errors() + ] + response = _error_response( + ApiError( + code="INVALID_PARAMETER", + message="The Agent request does not match the versioned contract.", + stage=ErrorStage.REQUEST, + details={"issues": issues}, + ), + status_code=422, + ) + except AssetServiceError as exc: + response = _error_response(exc.api_error) + except (JobManagerError, LegacyJobUpgradeError) as exc: + response = _error_response(exc.api_error) + except Exception: # noqa: BLE001 - REST must not expose internals + _log.exception("unexpected Agent REST failure") + response = _error_response( + ApiError( + code="INTERNAL_ERROR", + message="The Agent service could not complete the request.", + retryable=True, + stage=ErrorStage.INTERNAL, + ) + ) + return _portable_or_internal_error(response) + + return agent_route_handler + + +router = APIRouter( + prefix="/api/agent/v1", + tags=["agent"], + route_class=_AgentRoute, +) + + +class _CapabilityProvider(Protocol): + def get_capabilities(self) -> CapabilityResponse: ... + + +class _AssetProvider(Protocol): + @property + def allowed_root_ids(self) -> tuple[str, ...]: ... + + def register(self, request: AssetRegistrationRequest) -> AssetBundle: ... + + def get(self, asset_id: str) -> AssetBundle: ... + + def search(self, **filters: Any) -> AssetSearchResponse: ... + + def inspect(self, request: AssetInspectionRequest) -> AssetInspection: ... + + +class _PreflightProvider(Protocol): + def preflight_retarget( + self, + request: RetargetPreflightRequest, + ) -> PreflightResponse: ... + + +class _JobProvider(Protocol): + def start_retarget( + self, + plan_id: str, + *, + idempotency_key: str, + parent_job_id: str | None = None, + ) -> AgentJobView: ... + + def get_job( + self, + job_id: str, + *, + after_revision: int | None = None, + ) -> AgentJobView: ... + + def lookup_job( + self, + plan_id: str, + *, + idempotency_key: str, + after_revision: int | None = None, + ) -> AgentJobView: ... + + def cancel_job(self, job_id: str) -> AgentJobView: ... + + def retry_job(self, job_id: str, *, idempotency_key: str) -> AgentJobView: ... + + def list_artifacts( + self, + job_id: str, + *, + offset: int = 0, + limit: int = 100, + ) -> list[ArtifactDescriptor]: ... + + def get_artifact( + self, + job_id: str, + artifact_id: str, + *, + verify: bool = False, + ) -> StoredArtifact: ... + + +class _LegacyUpgradeProvider(Protocol): + def upgrade(self, payload: Any) -> LegacyJobUpgradeResponse: ... + + +def _capabilities_service(request: Request) -> _CapabilityProvider: + service = getattr(request.app.state, "agent_capabilities_service", None) + if service is None or not callable(getattr(service, "get_capabilities", None)): + raise RuntimeError("agent capabilities service is not configured") + return cast("_CapabilityProvider", service) + + +def _asset_service(request: Request) -> _AssetProvider: + service = getattr(request.app.state, "agent_asset_service", None) + required = ("register", "get", "search", "inspect") + if service is None or any(not callable(getattr(service, name, None)) for name in required): + raise RuntimeError("agent asset service is not configured") + return cast("_AssetProvider", service) + + +def _preflight_service(request: Request) -> _PreflightProvider: + service = getattr(request.app.state, "agent_preflight_service", None) + if service is None or not callable(getattr(service, "preflight_retarget", None)): + raise RuntimeError("agent preflight service is not configured") + return cast("_PreflightProvider", service) + + +def _job_manager(request: Request) -> _JobProvider: + service = getattr(request.app.state, "agent_job_manager", None) + required = ( + "start_retarget", + "get_job", + "lookup_job", + "cancel_job", + "retry_job", + "list_artifacts", + "get_artifact", + ) + if service is None or any(not callable(getattr(service, name, None)) for name in required): + raise RuntimeError("agent job manager is not configured") + return cast("_JobProvider", service) + + +def _legacy_upgrade_service(request: Request) -> _LegacyUpgradeProvider: + service = getattr(request.app.state, "agent_legacy_job_upgrade_service", None) + if service is None or not callable(getattr(service, "upgrade", None)): + raise RuntimeError("agent legacy job upgrade service is not configured") + return cast("_LegacyUpgradeProvider", service) + + +@router.get( + "/capabilities", + response_model=CapabilityResponse, + response_model_exclude_none=True, +) +def get_capabilities(request: Request) -> CapabilityResponse: + """Describe backends, devices, robots, formats, and live admission state.""" + + return _capabilities_service(request).get_capabilities() + + +@router.post( + "/assets", + response_model=AssetBundle, + response_model_exclude_none=True, + status_code=201, +) +def register_asset( + request: Request, + registration: AssetRegistrationRequest, +) -> AssetBundle: + """Register one content-addressed bundle below a configured server root.""" + + return _asset_service(request).register(registration) + + +@router.get( + "/assets", + response_model=AssetSearchResponse, + response_model_exclude_none=True, +) +def search_assets( + request: Request, + query: str | None = Query(default=None, max_length=256), + kind: AssetKind | None = None, + category: AssetCategory | None = None, + dataset: str | None = Query(default=None, max_length=128), + reference: str | None = Query(default=None, max_length=128), + limit: int = Query(default=100, ge=1, le=500), + offset: int = Query(default=0, ge=0), +) -> AssetSearchResponse: + """Search immutable asset manifests using compact, bounded filters.""" + + return _asset_service(request).search( + query=query, + kind=kind, + category=category, + dataset=dataset, + reference=reference, + limit=limit, + offset=offset, + ) + + +@router.get( + "/assets/{asset_id}", + response_model=AssetBundle, + response_model_exclude_none=True, +) +def get_asset(request: Request, asset_id: AssetId) -> AssetBundle: + """Return one portable content manifest without exposing a host path.""" + + return _asset_service(request).get(asset_id) + + +@router.get( + "/assets/{asset_id}/inspect", + response_model=AssetInspection, + response_model_exclude_none=True, +) +def inspect_asset( + request: Request, + asset_id: AssetId, + verify_hashes: bool = True, + parse_content: bool = True, +) -> AssetInspection: + """Validate bundle integrity and content without starting a solver job.""" + + return _asset_service(request).inspect( + AssetInspectionRequest( + asset_id=asset_id, + verify_hashes=verify_hashes, + parse_content=parse_content, + ) + ) + + +@router.post( + "/preflight/retarget", + response_model=PreflightResponse, + response_model_exclude_none=True, +) +def preflight_retarget( + request: Request, + preflight: RetargetPreflightRequest, +) -> PreflightResponse: + """Resolve retarget intent without loading a solver or reserving a job.""" + + return _preflight_service(request).preflight_retarget(preflight) + + +@router.post( + "/jobs", + response_model=AgentJobView, + response_model_exclude_none=True, + status_code=202, +) +def start_retarget_job( + request: Request, + submission: Annotated[ + JobStartRequest, + Body( + openapi_examples={ + "smoke": { + "summary": "Submit a preflighted smoke plan", + "value": { + "schema_version": "1.0", + "plan_id": f"plan:sha256:{'1' * 64}", + "idempotency_key": "agent-smoke-001", + }, + } + } + ), + ], +) -> AgentJobView: + """Submit one immutable preflight plan with caller-owned idempotency.""" + + return _job_manager(request).start_retarget( + submission.plan_id, + idempotency_key=submission.idempotency_key, + ) + + +@router.post( + "/jobs/lookup", + response_model=AgentJobView, + response_model_exclude_none=True, +) +def lookup_retarget_job( + request: Request, + lookup: Annotated[ + JobLookupRequest, + Body( + openapi_examples={ + "recover": { + "summary": "Recover one caller-owned submission", + "value": { + "schema_version": "1.0", + "plan_id": f"plan:sha256:{'1' * 64}", + "idempotency_key": "agent-smoke-001", + "after_revision": 4, + }, + } + } + ), + ], +) -> AgentJobView: + """Recover a known submission without exposing a global job listing.""" + + return _job_manager(request).lookup_job( + lookup.plan_id, + idempotency_key=lookup.idempotency_key, + after_revision=lookup.after_revision, + ) + + +@router.get( + "/jobs/{job_id}", + response_model=AgentJobView, + response_model_exclude_none=True, +) +def get_retarget_job( + request: Request, + job_id: str, + after_revision: int | None = Query(default=None, ge=0), +) -> AgentJobView: + """Return a compact job snapshot suitable for revision-aware polling.""" + + return _job_manager(request).get_job( + job_id, + after_revision=after_revision, + ) + + +@router.post( + "/jobs/{job_id}/cancel", + response_model=AgentJobView, + response_model_exclude_none=True, +) +def cancel_retarget_job(request: Request, job_id: str) -> AgentJobView: + """Persist a queued or cooperative-running cancellation request.""" + + return _job_manager(request).cancel_job(job_id) + + +@router.post( + "/jobs/{job_id}/retry", + response_model=AgentJobView, + response_model_exclude_none=True, + status_code=202, +) +def retry_retarget_job( + request: Request, + job_id: str, + retry: Annotated[ + JobRetryRequest, + Body( + openapi_examples={ + "retry": { + "summary": "Create one child attempt", + "value": { + "schema_version": "1.0", + "idempotency_key": "agent-retry-001", + }, + } + } + ), + ], +) -> AgentJobView: + """Create an idempotent child attempt for one terminal parent job.""" + + return _job_manager(request).retry_job( + job_id, + idempotency_key=retry.idempotency_key, + ) + + +@router.get( + "/jobs/{job_id}/artifacts", + response_model=ArtifactListResponse, + response_model_exclude_none=True, +) +def list_job_artifacts( + request: Request, + job_id: str, + limit: int = Query(default=100, ge=1, le=500), + offset: int = Query(default=0, ge=0), +) -> ArtifactListResponse: + """List a bounded page of canonical artifacts attached to one job.""" + + manager = _job_manager(request) + # List first, then read the compact view. Canonical membership only grows, + # so a concurrently completed job cannot make ``total`` smaller than this + # returned page. + artifacts = manager.list_artifacts(job_id, offset=offset, limit=limit) + view = manager.get_job(job_id) + if view.artifact_count is None: + raise JobManagerError( + ApiError( + code="INTERNAL_ERROR", + message="The canonical artifact count is unavailable.", + retryable=True, + stage=ErrorStage.INTERNAL, + ) + ) + return ArtifactListResponse( + job_id=job_id, + artifacts=artifacts, + total=view.artifact_count, + limit=limit, + offset=offset, + ) + + +@router.get( + "/jobs/{job_id}/artifacts/{artifact_id}", + response_model=ArtifactDescriptor, + response_model_exclude_none=True, +) +def get_job_artifact_descriptor( + request: Request, + job_id: str, + artifact_id: ArtifactId, + verify: bool = False, +) -> ArtifactDescriptor: + """Return metadata only after canonical job-membership authorization.""" + + return ( + _job_manager(request) + .get_artifact( + job_id, + artifact_id, + verify=verify, + ) + .descriptor + ) + + +@router.get( + "/jobs/{job_id}/artifacts/{artifact_id}/content", + response_class=Response, +) +def download_job_artifact( + request: Request, + job_id: str, + artifact_id: ArtifactId, +) -> Response: + """Download managed bytes for a canonically attached artifact.""" + + stored = _job_manager(request).get_artifact( + job_id, + artifact_id, + verify=False, + ) + if request.headers.get("range") is not None: + raise JobManagerError( + ApiError( + code="RANGE_NOT_SUPPORTED", + message="Range requests are not supported for Agent artifacts.", + retryable=False, + stage=ErrorStage.ARTIFACT, + ) + ) + return verified_artifact_response(stored) + + +@router.post( + "/legacy/jobspec-v1/upgrade", + response_model=LegacyJobUpgradeResponse, + response_model_exclude_none=True, +) +def upgrade_legacy_jobspec( + request: Request, + upgrade: Annotated[ + LegacyJobUpgradeRequest, + Body( + openapi_examples={ + "single_h2r": { + "summary": "Upgrade one allowlisted H2R JobSpec v1", + "value": { + "schema_version": "1.0", + "payload": { + "schema_version": 1, + "kind": "retarget", + "request": { + "source_path": "/srv/hhtools/motions/walk.bvh", + "robot": "g1_29dof", + }, + }, + }, + } + } + ), + ], +) -> LegacyJobUpgradeResponse: + """Safely re-register and preflight one legacy path-based JobSpec v1.""" + + return _legacy_upgrade_service(request).upgrade(upgrade.payload) + + +__all__ = ["router"] diff --git a/hhtools/web/agent_artifact_response.py b/hhtools/web/agent_artifact_response.py new file mode 100644 index 00000000..abdf9f65 --- /dev/null +++ b/hhtools/web/agent_artifact_response.py @@ -0,0 +1,149 @@ +"""Race-resistant HTTP streaming for canonically authorized Agent artifacts.""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import os +import re +import stat +from collections.abc import Iterator + +from starlette.responses import StreamingResponse + +from hhtools.contracts import ApiError, ArtifactDescriptor, ErrorStage +from hhtools.services.artifacts import StoredArtifact +from hhtools.services.jobs import JobManagerError + +_READ_CHUNK_BYTES = 1024 * 1024 +_SAFE_JOB_ID = re.compile(r"^job:[A-Za-z0-9][A-Za-z0-9._~-]{0,251}$") + + +def _artifact_failure(message: str, *, retryable: bool = True) -> JobManagerError: + return JobManagerError( + ApiError( + code="ARTIFACT_HASH_MISMATCH", + message=message, + retryable=retryable, + stage=ErrorStage.ARTIFACT, + ) + ) + + +def _internal_failure(message: str) -> JobManagerError: + return JobManagerError( + ApiError( + code="INTERNAL_ERROR", + message=message, + retryable=False, + stage=ErrorStage.INTERNAL, + ) + ) + + +def _validated_descriptor(stored: StoredArtifact) -> ArtifactDescriptor: + """Revalidate even a test double constructed without Pydantic validation.""" + + try: + descriptor = ArtifactDescriptor.model_validate(stored.descriptor.model_dump(mode="python")) + if _SAFE_JOB_ID.fullmatch(descriptor.job_id) is None: + raise ValueError("unsafe job id") + return descriptor + except (AttributeError, TypeError, ValueError) as exc: + raise _internal_failure( + "The managed artifact descriptor is not safe for transport." + ) from exc + + +def verified_artifact_response(stored: StoredArtifact) -> StreamingResponse: + """Hash and stream bytes through one open file handle. + + ``FileResponse`` opens a path later, after route authorization and optional + verification. A path swap in that interval could make the response serve + different bytes. This helper opens once, verifies SHA-256 and length on + that exact descriptor, rewinds it, and gives the same handle to + ``StreamingResponse``. It intentionally does not implement Range or the + ASGI ``pathsend`` extension. + """ + + descriptor = _validated_descriptor(stored) + if descriptor.sha256 is None or descriptor.size_bytes is None: + raise _internal_failure("The managed artifact is missing required integrity metadata.") + + try: + handle = stored.path.open("rb") + except OSError as exc: + raise _artifact_failure("The managed artifact is unavailable for download.") from exc + + try: + before = os.fstat(handle.fileno()) + if not stat.S_ISREG(before.st_mode): + raise _artifact_failure( + "The managed artifact is not a regular file.", + retryable=False, + ) + + digest = hashlib.sha256() + observed_size = 0 + while chunk := handle.read(_READ_CHUNK_BYTES): + observed_size += len(chunk) + digest.update(chunk) + after = os.fstat(handle.fileno()) + stable_identity = ( + before.st_dev, + before.st_ino, + before.st_size, + before.st_mtime_ns, + ) == ( + after.st_dev, + after.st_ino, + after.st_size, + after.st_mtime_ns, + ) + observed_sha256 = digest.hexdigest() + if ( + not stable_identity + or observed_size != descriptor.size_bytes + or after.st_size != descriptor.size_bytes + or not hmac.compare_digest(observed_sha256, descriptor.sha256) + ): + raise _artifact_failure( + "The managed artifact no longer matches its canonical descriptor." + ) + handle.seek(0) + except Exception: + handle.close() + raise + + def stream_same_handle() -> Iterator[bytes]: + try: + while chunk := handle.read(_READ_CHUNK_BYTES): + yield chunk + finally: + handle.close() + + digest_bytes = bytes.fromhex(descriptor.sha256) + filename = descriptor.kind + if descriptor.format is not None: + filename = f"{filename}.{descriptor.format}" + headers = { + "Cache-Control": "no-store", + "Content-Disposition": f'attachment; filename="{filename}"', + "Content-Digest": f"sha-256=:{base64.b64encode(digest_bytes).decode('ascii')}:", + "Content-Length": str(descriptor.size_bytes), + "ETag": f'"sha256:{descriptor.sha256}"', + "X-Content-SHA256": descriptor.sha256, + "X-Content-Type-Options": "nosniff", + "X-HHTools-Artifact-Id": descriptor.artifact_id, + "X-HHTools-Job-Id": descriptor.job_id, + } + return StreamingResponse( + stream_same_handle(), + status_code=200, + media_type=descriptor.media_type or "application/octet-stream", + headers=headers, + ) + + +__all__ = ["verified_artifact_response"] diff --git a/hhtools/web/agent_boundary.py b/hhtools/web/agent_boundary.py new file mode 100644 index 00000000..eab58bea --- /dev/null +++ b/hhtools/web/agent_boundary.py @@ -0,0 +1,345 @@ +"""Local-only transport boundary for the versioned Agent HTTP API. + +The Agent API deliberately has no remote authentication in v1. This pure +ASGI middleware therefore treats a literal loopback connection *and* a +loopback ``Host`` header as part of the protocol boundary. It also buffers a +small, bounded request body before FastAPI or Pydantic can parse it, so a +missing ``Content-Length`` or chunked transfer cannot bypass the limit. +""" + +from __future__ import annotations + +import ipaddress +import re +from collections.abc import Awaitable, Callable, Mapping +from typing import Any +from urllib.parse import urlsplit + +from starlette.responses import JSONResponse +from starlette.types import Message, Receive, Scope, Send + +from hhtools.contracts import ApiError, ErrorStage + +AGENT_API_PREFIX = "/api/agent/v1" +LEGACY_UPGRADE_PATH = f"{AGENT_API_PREFIX}/legacy/jobspec-v1/upgrade" +AGENT_MAX_BODY_BYTES = 1024 * 1024 +LEGACY_UPGRADE_MAX_BODY_BYTES = 64 * 1024 + +_CONTENT_LENGTH_RE = re.compile(rb"[0-9]+\Z") + +type ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]] + + +def is_agent_path(path: str) -> bool: + """Return whether ``path`` is inside the exact v1 Agent namespace.""" + + return path == AGENT_API_PREFIX or path.startswith(f"{AGENT_API_PREFIX}/") + + +def agent_error_response( + *, + status_code: int, + code: str, + message: str, + stage: ErrorStage = ErrorStage.REQUEST, + retryable: bool = False, + details: Mapping[str, Any] | None = None, + headers: Mapping[str, str] | None = None, +) -> JSONResponse: + """Build the one versioned error envelope used by early Agent failures.""" + + response_headers = { + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + } + if headers is not None: + response_headers.update(headers) + error = ApiError( + code=code, + message=message, + retryable=retryable, + stage=stage, + details=dict(details or {}), + ) + return JSONResponse( + status_code=status_code, + content=error.model_dump(mode="json", exclude_none=True), + headers=response_headers, + ) + + +def _is_loopback_literal(value: str | None) -> bool: + if value is None: + return False + try: + address = ipaddress.ip_address(value) + except (TypeError, ValueError): + return False + if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped is not None: + return address.ipv4_mapped.is_loopback + return address.is_loopback + + +def _host_name(value: str) -> str | None: # noqa: PLR0911 + """Parse a strict HTTP Host value without DNS resolution.""" + + if not value or any(character.isspace() for character in value): + return None + if value.startswith("["): + closing = value.find("]") + if closing <= 1: + return None + name = value[1:closing] + remainder = value[closing + 1 :] + if remainder and (not remainder.startswith(":") or not _valid_port(remainder[1:])): + return None + return name + + # An unbracketed IPv6 Host is ambiguous with a port and is not valid per + # the HTTP URI grammar. Require the normal ``[::1]:port`` spelling. + if value.count(":") > 1: + return None + if ":" in value: + name, port = value.rsplit(":", 1) + if not name or not _valid_port(port): + return None + return name + return value + + +def _valid_port(value: str) -> bool: + return ( + value.isascii() and value.isdigit() and 1 <= len(value) <= 5 and 1 <= int(value) <= 65_535 + ) + + +def _loopback_host(headers: list[tuple[bytes, bytes]]) -> bool: + values = [value for name, value in headers if name.lower() == b"host"] + if len(values) != 1: + return False + try: + raw = values[0].decode("ascii") + except UnicodeDecodeError: + return False + name = _host_name(raw) + if name is None: + return False + return name.casefold() == "localhost" or _is_loopback_literal(name) + + +def _content_length(headers: list[tuple[bytes, bytes]]) -> tuple[int | None, bool]: + values = [value for name, value in headers if name.lower() == b"content-length"] + if not values: + return None, True + if len(values) != 1 or _CONTENT_LENGTH_RE.fullmatch(values[0]) is None: + return None, False + try: + return int(values[0]), True + except ValueError: # pragma: no cover - guarded by the decimal regex + return None, False + + +def _identity_content_encoding(headers: list[tuple[bytes, bytes]]) -> bool: + values = [value for name, value in headers if name.lower() == b"content-encoding"] + if not values: + return True + if len(values) != 1: + return False + try: + return values[0].decode("ascii").casefold() == "identity" + except UnicodeDecodeError: + return False + + +def _loopback_origin(headers: list[tuple[bytes, bytes]]) -> bool: + """Allow absent CLI Origin or one syntactically valid loopback Web origin.""" + + values = [value for name, value in headers if name.lower() == b"origin"] + if not values: + return True + if len(values) != 1: + return False + try: + raw = values[0].decode("ascii") + parsed = urlsplit(raw) + # An Origin is only scheme + authority. Userinfo, paths, query and + # fragments are rejected instead of being normalized permissively. + if ( + parsed.scheme not in {"http", "https"} + or not parsed.netloc + or parsed.username is not None + or parsed.password is not None + or parsed.path + or parsed.query + or parsed.fragment + ): + return False + if parsed.port is not None and not 1 <= parsed.port <= 65_535: + return False + except (UnicodeDecodeError, ValueError): + return False + hostname = parsed.hostname + return hostname is not None and ( + hostname.casefold() == "localhost" or _is_loopback_literal(hostname) + ) + + +async def _bounded_body( + receive: Receive, + *, + maximum: int, +) -> tuple[bytes | None, bool]: + """Read one HTTP body, returning ``(body, disconnected)``. + + ``None`` means the limit was crossed. The check happens while chunks are + received, so it also covers HTTP/1.1 chunked bodies and HTTP/2 requests + where no trustworthy Content-Length exists. + """ + + body = bytearray() + while True: + message = await receive() + message_type = message.get("type") + if message_type == "http.disconnect": + return bytes(body), True + if message_type != "http.request": + continue + chunk = message.get("body", b"") + if chunk: + body.extend(chunk) + if len(body) > maximum: + return None, False + if not message.get("more_body", False): + return bytes(body), False + + +class AgentBoundaryMiddleware: + """Enforce the local-only, bounded-body v1 Agent transport boundary.""" + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__( # noqa: PLR0911 + self, + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + if scope.get("type") != "http" or not is_agent_path(str(scope.get("path", ""))): + await self.app(scope, receive, send) + return + + client = scope.get("client") + client_host = client[0] if isinstance(client, tuple) and client else None + if not _is_loopback_literal(client_host): + await agent_error_response( + status_code=403, + code="LOOPBACK_REQUIRED", + message="The Agent API only accepts loopback clients.", + )(scope, receive, send) + return + + headers = list(scope.get("headers", [])) + if not _loopback_host(headers): + await agent_error_response( + status_code=400, + code="INVALID_HOST", + message="The Agent API requires a loopback Host header.", + )(scope, receive, send) + return + + # CLI clients normally omit Origin. When a browser supplies one, a + # second loopback check prevents a public site from issuing a simple + # cross-origin mutation against this unauthenticated localhost API. + if not _loopback_origin(headers): + await agent_error_response( + status_code=403, + code="ORIGIN_FORBIDDEN", + message="Browser requests to the Agent API require a loopback Origin.", + )(scope, receive, send) + return + + declared_length, valid_length = _content_length(headers) + if not valid_length: + await agent_error_response( + status_code=400, + code="INVALID_CONTENT_LENGTH", + message="Content-Length must be one non-negative decimal value.", + )(scope, receive, send) + return + + if not _identity_content_encoding(headers): + await agent_error_response( + status_code=415, + code="UNSUPPORTED_CONTENT_ENCODING", + message="Compressed Agent request bodies are not supported.", + )(scope, receive, send) + return + + maximum = ( + LEGACY_UPGRADE_MAX_BODY_BYTES + if scope.get("path") == LEGACY_UPGRADE_PATH + else AGENT_MAX_BODY_BYTES + ) + if declared_length is not None and declared_length > maximum: + await self._too_large(scope, receive, send, maximum=maximum) + return + + body, disconnected = await _bounded_body(receive, maximum=maximum) + if body is None: + await self._too_large(scope, receive, send, maximum=maximum) + return + if disconnected: + await agent_error_response( + status_code=400, + code="INCOMPLETE_REQUEST_BODY", + message="The Agent request body ended before it was complete.", + )(scope, receive, send) + return + if declared_length is not None and declared_length != len(body): + await agent_error_response( + status_code=400, + code="INVALID_CONTENT_LENGTH", + message="Content-Length does not match the received request body.", + )(scope, receive, send) + return + + replayed = False + + async def replay_receive() -> Message: + nonlocal replayed + if not replayed: + replayed = True + return {"type": "http.request", "body": body, "more_body": False} + # The original channel is now waiting for a real disconnect. A + # StreamingResponse listener may await it, and its task group will + # cancel that wait as soon as streaming completes. + return await receive() + + await self.app(scope, replay_receive, send) + + @staticmethod + async def _too_large( + scope: Scope, + receive: Receive, + send: Send, + *, + maximum: int, + ) -> None: + await agent_error_response( + status_code=413, + code="REQUEST_TOO_LARGE", + message="The Agent request body exceeds the route limit.", + details={"max_bytes": maximum}, + )(scope, receive, send) + + +__all__ = [ + "AGENT_API_PREFIX", + "AGENT_MAX_BODY_BYTES", + "AgentBoundaryMiddleware", + "LEGACY_UPGRADE_MAX_BODY_BYTES", + "LEGACY_UPGRADE_PATH", + "agent_error_response", + "is_agent_path", +] diff --git a/hhtools/web/calibration_session.py b/hhtools/web/calibration_session.py index d1c77b46..786110e1 100644 --- a/hhtools/web/calibration_session.py +++ b/hhtools/web/calibration_session.py @@ -7,6 +7,7 @@ import numpy as np from hhtools.core.grounding import foot_floor_z_in_positions +from hhtools.core.math import quaternion as Q from hhtools.core.motion import Motion from hhtools.robot.loader import URDFRobotModel @@ -149,15 +150,27 @@ def serialize_reference_skeleton( parents.append(-1 if p is None else name_to_i.get(p, -1)) pos = np.asarray(ref.positions, dtype=np.float32).reshape(-1, 3).copy() + quat = np.asarray(ref.quaternions, dtype=np.float32).reshape(-1, 4).copy() if pos.shape[0] != len(names): raise ValueError( f"reference positions ({pos.shape[0]} joints) != joint_names ({len(names)})" ) + if quat.shape[0] != len(names): + raise ValueError( + f"reference quaternions ({quat.shape[0]} joints) != joint_names ({len(names)})" + ) if abs(heading_rad) > 1e-8: c, s = float(np.cos(heading_rad)), float(np.sin(heading_rad)) rot = np.array([[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]], dtype=np.float32) pos = (pos @ rot.T).astype(np.float32, copy=False) + # Heading is a world-frame edit. Rotate both the displayed joint + # positions and orientations so arcball axes/handles remain registered + # with the skeleton instead of retaining their pre-heading directions. + heading_q = Q.from_axis_angle( + np.array([[0.0, 0.0, heading_rad]], dtype=np.float32), + )[0] + quat = Q.normalize(Q.multiply(np.broadcast_to(heading_q, quat.shape), quat)) z_floor = float(foot_floor_z_in_positions(pos, tuple(names))) if abs(z_floor) > 1e-6: @@ -165,8 +178,10 @@ def serialize_reference_skeleton( return { "bone_names": names, + "canonical_names": [ref.source_to_canonical.get(name, name) for name in names], "parent_indices": parents, "positions": [pos.tolist()], + "quaternions": [quat.astype(np.float32).tolist()], "color": 0x5eb3ff, } @@ -203,7 +218,10 @@ def build_calibration_session( motion: Motion | None, ) -> dict[str, Any]: """Payload for entering calibration mode in the browser.""" - from hhtools.retarget.calibration import load_calibration, resolve_calibration_file + from hhtools.retarget.calibration import ( + load_calibration, + resolve_preset_calibration_file, + ) joint_order = [j.name for j in model.actuated_joints if j.joint_type != "fixed"] if not joint_order: @@ -213,7 +231,7 @@ def build_calibration_session( urdf_parent = getattr(model.preset, "urdf_path", None) cal_path = None if urdf_parent is not None: - cal_path = resolve_calibration_file(urdf_parent.parent, reference) + cal_path = resolve_preset_calibration_file(model.preset, reference) if cal_path is not None: cal = load_calibration(cal_path) for name, value in cal.calibrated_joint_q.items(): diff --git a/hhtools/web/dependencies.py b/hhtools/web/dependencies.py new file mode 100644 index 00000000..159c58ba --- /dev/null +++ b/hhtools/web/dependencies.py @@ -0,0 +1,59 @@ +"""Startup checks for the optional WebUI runtime dependencies.""" + +from __future__ import annotations + +from importlib.util import find_spec + +_WEB_RUNTIME_IMPORTS: tuple[tuple[str, str], ...] = ( + ("fastapi", "fastapi"), + ("uvicorn", "uvicorn"), + ("starlette", "starlette"), + ("python-multipart", "multipart"), +) + + +class MissingWebDependenciesError(RuntimeError): + """Raised when the browser UI or desktop sidecar cannot start.""" + + def __init__(self, missing: tuple[str, ...]) -> None: + self.missing = missing + names = ", ".join(missing) + super().__init__( + "Cannot start the hhtools WebUI because required Python packages are " + f"missing: {names}.\n" + "Install the WebUI dependencies from the repository root with:\n" + " uv sync --locked --extra web\n" + "For WebUI retargeting support, use:\n" + " uv sync --locked --extra web --extra retarget\n" + "Then start it again with:\n" + " uv run hhtools web" + ) + + +def missing_web_runtime_dependencies() -> tuple[str, ...]: + """Return install-distribution names missing from the active interpreter.""" + + missing: list[str] = [] + for distribution, import_name in _WEB_RUNTIME_IMPORTS: + try: + available = find_spec(import_name) is not None + except (ImportError, ModuleNotFoundError, ValueError): + available = False + if not available: + missing.append(distribution) + return tuple(missing) + + +def require_web_runtime_dependencies() -> None: + """Fail before server construction with an actionable installation message.""" + + missing = missing_web_runtime_dependencies() + if missing: + raise MissingWebDependenciesError(missing) + + +__all__ = [ + "MissingWebDependenciesError", + "missing_web_runtime_dependencies", + "require_web_runtime_dependencies", +] diff --git a/hhtools/web/export_bundle.py b/hhtools/web/export_bundle.py index 704389db..39271b9d 100644 --- a/hhtools/web/export_bundle.py +++ b/hhtools/web/export_bundle.py @@ -73,6 +73,9 @@ def resolve_clip_export_dir( "quat_y", "quat_z", "quat_w", + "ext_x", + "ext_y", + "ext_z", ) @@ -295,12 +298,11 @@ def _save_object_track_csv( path.parent.mkdir(parents=True, exist_ok=True) positions = np.asarray(blob["positions"], dtype=np.float64) quats_wxyz = np.asarray(blob["quaternions"], dtype=np.float64) + extents = np.asarray(blob["extents"], dtype=np.float64).reshape(3) sample_rate = float(blob["sample_rate"]) num_frames = int(positions.shape[0]) times = np.arange(num_frames, dtype=np.float64) / max(sample_rate, 1.0) - # ``ext_*`` cuboid dimensions are intentionally not written: consumers read - # the object geometry from the sidecar ``.obj`` mesh instead. header_meta = { "object": str(blob["name"]), "sample_rate": f"{sample_rate:.6f}", @@ -318,6 +320,9 @@ def _save_object_track_csv( writer.writerow(OBJECT_CSV_HEADER) for frame in range(num_frames): q = quats_wxyz[frame] + # Repeat the constant object dimensions on every row. This keeps a + # clipped or concatenated CSV self-describing even when its OBJ + # sidecar or metadata header is not available to the consumer. writer.writerow([ f"{times[frame]:.6f}", f"{positions[frame, 0]:.6f}", @@ -327,6 +332,9 @@ def _save_object_track_csv( f"{q[2]:.6f}", f"{q[3]:.6f}", f"{q[0]:.6f}", + f"{extents[0]:.6f}", + f"{extents[1]:.6f}", + f"{extents[2]:.6f}", ]) return path diff --git a/hhtools/web/frontend/components.json b/hhtools/web/frontend/components.json new file mode 100644 index 00000000..b741d4cb --- /dev/null +++ b/hhtools/web/frontend/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "css": "src/styles/tailwind.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/hhtools/web/frontend/index.html b/hhtools/web/frontend/index.html new file mode 100644 index 00000000..6d5e6502 --- /dev/null +++ b/hhtools/web/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + + Human-Humanoid Tools + + +
+ + + diff --git a/hhtools/web/frontend/package-lock.json b/hhtools/web/frontend/package-lock.json new file mode 100644 index 00000000..f2c11465 --- /dev/null +++ b/hhtools/web/frontend/package-lock.json @@ -0,0 +1,3564 @@ +{ + "name": "hhtools-webui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hhtools-webui", + "version": "0.1.0", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-slot": "^1.3.3", + "@radix-ui/react-tooltip": "^1.2.16", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.39.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "tailwind-merge": "^3.6.0", + "three": "^0.185.1" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.5", + "@types/three": "^0.185.4", + "@vitejs/plugin-react": "^6.1.1", + "jsdom": "^30.0.1", + "tailwindcss": "^4.3.3", + "typescript": "^5.9.3", + "vite": "^8.2.2", + "vitest": "^4.1.11" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.0.tgz", + "integrity": "sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.8.tgz", + "integrity": "sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.146.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", + "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", + "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", + "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", + "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.16.tgz", + "integrity": "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", + "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz", + "integrity": "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", + "integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz", + "integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz", + "integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz", + "integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz", + "integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz", + "integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz", + "integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz", + "integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz", + "integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz", + "integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz", + "integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz", + "integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz", + "integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz", + "integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz", + "integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.185.4", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.4.tgz", + "integrity": "sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lucide-react": { + "version": "1.39.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.39.0.tgz", + "integrity": "sha512-y8nXoEwvqqIsF927NBWXODa4bfMrcUeEb/9sgpwFqg0gUjgn3j5Hznk+v7STmPgZ2iQ11JKlbQGdFRuTOwvYkA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz", + "integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.146.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.5", + "@rolldown/binding-android-arm64": "1.2.5", + "@rolldown/binding-darwin-arm64": "1.2.5", + "@rolldown/binding-darwin-x64": "1.2.5", + "@rolldown/binding-freebsd-x64": "1.2.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", + "@rolldown/binding-linux-arm64-gnu": "1.2.5", + "@rolldown/binding-linux-arm64-musl": "1.2.5", + "@rolldown/binding-linux-ppc64-gnu": "1.2.5", + "@rolldown/binding-linux-s390x-gnu": "1.2.5", + "@rolldown/binding-linux-x64-gnu": "1.2.5", + "@rolldown/binding-linux-x64-musl": "1.2.5", + "@rolldown/binding-openharmony-arm64": "1.2.5", + "@rolldown/binding-win32-arm64-msvc": "1.2.5", + "@rolldown/binding-win32-x64-msvc": "1.2.5" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/three": { + "version": "0.185.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz", + "integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.11", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.11.tgz", + "integrity": "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.11" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.11", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.11.tgz", + "integrity": "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/hhtools/web/frontend/package.json b/hhtools/web/frontend/package.json new file mode 100644 index 00000000..b3be1a20 --- /dev/null +++ b/hhtools/web/frontend/package.json @@ -0,0 +1,39 @@ +{ + "name": "hhtools-webui", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "tsc --noEmit && vite build", + "typecheck": "tsc --noEmit", + "typecheck:test": "tsc --noEmit -p tsconfig.test.json", + "test": "npm run typecheck:test && vitest run" + }, + "dependencies": { + "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-slot": "^1.3.3", + "@radix-ui/react-tooltip": "^1.2.16", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.39.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "tailwind-merge": "^3.6.0", + "three": "^0.185.1" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.5", + "@types/three": "^0.185.4", + "@vitejs/plugin-react": "^6.1.1", + "jsdom": "^30.0.1", + "tailwindcss": "^4.3.3", + "typescript": "^5.9.3", + "vite": "^8.2.2", + "vitest": "^4.1.11" + } +} diff --git a/hhtools/web/frontend/public/hhtools-robot.svg b/hhtools/web/frontend/public/hhtools-robot.svg new file mode 100644 index 00000000..59a47fb1 --- /dev/null +++ b/hhtools/web/frontend/public/hhtools-robot.svg @@ -0,0 +1,13 @@ + + hhtools robot + + + + + + + + + + + diff --git a/hhtools/web/frontend/public/robot-icons/ATTRIBUTION.md b/hhtools/web/frontend/public/robot-icons/ATTRIBUTION.md new file mode 100644 index 00000000..dfc290fa --- /dev/null +++ b/hhtools/web/frontend/public/robot-icons/ATTRIBUTION.md @@ -0,0 +1,45 @@ +# Robot Library icon sources + +These 128 px WebP thumbnails identify the six robot presets curated by +HHTools. They are deterministic zero-pose renders made from the official URDF +and mesh assets listed below, rather than vendor logos or promotional photos. +The reproducible renderer is `scripts/render_robot_library_icons.py`. + +For every thumbnail, HHTools contributors made the following changes on +2026-08-28: assembled the upstream meshes through the upstream URDF, rendered a +fixed orthographic three-quarter projection, applied depth shading and a +coloured identification tile, resized the result to 128 x 128 pixels, and +encoded it as a lossless WebP. Each thumbnail remains available under the +license shown beside it; those licenses do not change the Apache-2.0 license of +the surrounding HHTools application. + +- **Unitree G1** (`unitree-g1.webp`) — rendered from the official 29-DoF model + in [`unitreerobotics/unitree_ros`](https://github.com/unitreerobotics/unitree_ros/tree/7d6075f7f58588b189b940130e3edab3c839b2df/robots/g1_description), + under the bundled [BSD-3-Clause license](licenses/unitree-g1-BSD-3-Clause.txt). +- **ROBOTO_ORIGIN (RPO)** (`roboto-origin.webp`) — rendered from + `urdf/rpo.urdf` and `meshes/` in + [`Roboparty/rpo_description`](https://github.com/Roboparty/rpo_description/tree/37aac9ca665e92731444a1618320078e7ba21569), + under the bundled + [CERN-OHL-W-2.0 license](licenses/roboto-origin-CERN-OHL-W-2.0.txt). +- **AgiBot X2 Ultra** (`agibot-x2.webp`) — rendered from the official v1.4 X2 + model in + [`AgibotTech/agibot_x2_urdf`](https://github.com/AgibotTech/agibot_x2_urdf/tree/77f43eb0904dae4c48ccd9154fee824f8ffd4d38/X2_URDF-v1.4.0), + under the bundled [Mulan PSL v2](licenses/agibot-x2-MulanPSL-2.0.txt). +- **Asimov 1** (`asimov-1.webp`) — rendered from `sim-model/urdf/asimov_1.urdf` + and `sim-model/assets/meshes/` in + [`menloresearch/asimov-1`](https://github.com/menloresearch/asimov-1/tree/b8420ffe99159065152aa1321a03147c0962f251/sim-model), + under the bundled + [CERN-OHL-S-2.0 license](licenses/asimov-1-CERN-OHL-S-2.0.txt). +- **Fourier GR-2** (`fourier-gr2.webp`) — rendered from + `GRX/GR2/gr2v3_8_7/basic_urdf/` in + [`FFTAI/Wiki-GRx-Models`](https://github.com/FFTAI/Wiki-GRx-Models/tree/7d96c758f048fe1bf92b3258864d94771ae0c093/GRX/GR2/gr2v3_8_7/basic_urdf), + under the bundled [GPL-3.0 license](licenses/fourier-gr2-GPL-3.0.txt). +- **Berkeley Humanoid Lite** (`berkeley-humanoid-lite.webp`) — rendered from + the official description in + [`HybridRobotics/Berkeley-Humanoid-Lite-Assets`](https://github.com/HybridRobotics/Berkeley-Humanoid-Lite-Assets/tree/fc90fedd008b1e56a22e3c5221548d6b24f49707/data/robots/berkeley_humanoid/berkeley_humanoid_lite), + under the bundled + [CC BY-SA 4.0 license](licenses/berkeley-humanoid-lite-CC-BY-SA-4.0.txt). + +Names and trademarks belong to their respective owners. Inclusion identifies +compatible models and does not imply endorsement. The unmodified HHTools robot +mark remains the icon for every user-imported model. diff --git a/hhtools/web/frontend/public/robot-icons/agibot-x2.webp b/hhtools/web/frontend/public/robot-icons/agibot-x2.webp new file mode 100644 index 00000000..d67c7545 Binary files /dev/null and b/hhtools/web/frontend/public/robot-icons/agibot-x2.webp differ diff --git a/hhtools/web/frontend/public/robot-icons/asimov-1.webp b/hhtools/web/frontend/public/robot-icons/asimov-1.webp new file mode 100644 index 00000000..a27b53df Binary files /dev/null and b/hhtools/web/frontend/public/robot-icons/asimov-1.webp differ diff --git a/hhtools/web/frontend/public/robot-icons/berkeley-humanoid-lite.webp b/hhtools/web/frontend/public/robot-icons/berkeley-humanoid-lite.webp new file mode 100644 index 00000000..1995020b Binary files /dev/null and b/hhtools/web/frontend/public/robot-icons/berkeley-humanoid-lite.webp differ diff --git a/hhtools/web/frontend/public/robot-icons/fourier-gr2.webp b/hhtools/web/frontend/public/robot-icons/fourier-gr2.webp new file mode 100644 index 00000000..d721dbc8 Binary files /dev/null and b/hhtools/web/frontend/public/robot-icons/fourier-gr2.webp differ diff --git a/hhtools/web/frontend/public/robot-icons/licenses/agibot-x2-MulanPSL-2.0.txt b/hhtools/web/frontend/public/robot-icons/licenses/agibot-x2-MulanPSL-2.0.txt new file mode 100644 index 00000000..adf05533 --- /dev/null +++ b/hhtools/web/frontend/public/robot-icons/licenses/agibot-x2-MulanPSL-2.0.txt @@ -0,0 +1,89 @@ +木兰宽松许可证, 第2版 + +2020年1月 http://license.coscl.org.cn/MulanPSL2 + +您对"软件"的复制、使用、修改及分发受木兰宽松许可证,第2版("本许可证")的如下条款的约束: + +0. 定义 + +"软件" 是指由"贡献"构成的许可在"本许可证"下的程序和相关文档的集合。 + +"贡献" 是指由任一"贡献者"许可在"本许可证"下的受版权法保护的作品。 + +"贡献者" 是指将受版权法保护的作品许可在"本许可证"下的自然人或"法人实体"。 + +"法人实体" 是指提交贡献的机构及其"关联实体"。 + +"关联实体" 是指,对"本许可证"下的行为方而言,控制、受控制或与其共同受控制的机构,此处的控制是指有受控方或共同受控方至少50%直接或间接的投票权、资金或其他有价证券。 + +1. 授予版权许可 + +每个"贡献者"根据"本许可证"授予您永久性的、全球性的、免费的、非独占的、不可撤销的版权许可,您可以复制、使用、修改、分发其"贡献",不论修改与否。 + +2. 授予专利许可 + +每个"贡献者"根据"本许可证"授予您永久性的、全球性的、免费的、非独占的、不可撤销的(根据本条规定撤销除外)专利许可,供您制造、委托制造、使用、许诺销售、销售、进口其"贡献"或以其他方式转移其"贡献"。前述专利许可仅限于"贡献者"现在或将来拥有或控制的其"贡献"本身或其"贡献"与许可"贡献"时的"软件"结合而将必然会侵犯的专利权利要求,不包括对"贡献"的修改或包含"贡献"的其他结合。如果您或您的"关联实体"直接或间接地,就"软件"或其中的"贡献"对任何人发起专利侵权诉讼(包括反诉或交叉诉讼)或其他专利维权行动,指控其侵犯专利权,则"本许可证"授予您对"软件"的专利许可自您提起诉讼或发起维权行动之日终止。 + +3. 无商标许可 + +"本许可证"不提供对"贡献者"的商品名称、商标、服务标志或产品名称的商标许可,但您为满足第4条规定的声明义务而必须使用除外。 + +4. 分发限制 + +您可以在任何媒介中将"软件"以源程序形式或可执行形式重新分发,不论修改与否,但您必须向接收者提供"本许可证"的副本,并保留"软件"中的版权、商标、专利及免责声明。 + +5. 免责声明与责任限制 + +"软件"及其中的"贡献"在提供时不带任何明示或默示的担保。在任何情况下,"贡献者"或版权所有者不对任何人因使用"软件"或其中的"贡献"而引发的任何直接或间接损失承担责任,不论因何种原因导致或者基于何种法律理论,即使其曾被建议有此种损失的可能性。 + +6. 语言 + +"本许可证"以中英文双语表述,中英文版本具有同等法律效力。如果中英文版本存在任何冲突不一致,以中文版为准。 + +条款结束 + +--- + +Mulan Permissive Software License, Version 2 (Mulan PSL v2) + +January 2020 http://license.coscl.org.cn/MulanPSL2 + +Your reproduction, use, modification and distribution of the Software shall be subject to Mulan PSL v2 (this License) with the following terms and conditions: + +0. Definition + +Software means the program and related documents which are licensed under this License and comprise all Contribution(s). + +Contribution means the copyrightable work licensed by a particular Contributor under this License. + +Contributor means the Individual or Legal Entity who licenses its copyrightable work under this License. + +Legal Entity means the entity making a Contribution and all its Affiliates. + +Affiliates means entities that control, are controlled by, or are under common control with the acting entity under this License, 'control' means direct or indirect ownership of at least fifty percent (50%) of the voting power, capital or other securities of controlled or commonly controlled entity. + +1. Grant of Copyright License + +Subject to the terms and conditions of this License, each Contributor hereby grants to you a perpetual, worldwide, royalty-free, non-exclusive, irrevocable copyright license to reproduce, use, modify, or distribute its Contribution, with modification or not. + +2. Grant of Patent License + +Subject to the terms and conditions of this License, each Contributor hereby grants to you a perpetual, worldwide, royalty-free, non-exclusive, irrevocable (except for revocation under this Section) patent license to make, have made, use, offer for sale, sell, import or otherwise transfer its Contribution, where such patent license is only limited to the patent claims owned or controlled by such Contributor now or in future which will be necessarily infringed by its Contribution alone, or by combination of the Contribution with the Software to which the Contribution was contributed. The patent license shall not apply to any modification of the Contribution, and any other combination which includes the Contribution. If you or your Affiliates directly or indirectly institute patent litigation (including a cross claim or counterclaim in a litigation) or other patent enforcement activities against any individual or entity by alleging that the Software or any Contribution in it infringes patents, then any patent license granted to you under this License for the Software shall terminate as of the date such litigation or activity is filed or taken. + +3. No Trademark License + +No trademark license is granted to use the trade names, trademarks, service marks, or product names of Contributor, except as required to fulfill notice requirements in section 4. + +4. Distribution Restriction + +You may distribute the Software in any medium with or without modification, whether in source or executable forms, provided that you provide recipients with a copy of this License and retain copyright, patent, trademark and disclaimer statements in the Software. + +5. Disclaimer of Warranty and Limitation of Liability + +THE SOFTWARE AND CONTRIBUTION IN IT ARE PROVIDED WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED. IN NO EVENT SHALL ANY CONTRIBUTOR OR COPYRIGHT HOLDER BE LIABLE TO YOU FOR ANY DAMAGES, INCLUDING, BUT NOT LIMITED TO ANY DIRECT, OR INDIRECT, SPECIAL OR CONSEQUENTIAL DAMAGES ARISING FROM YOUR USE OR INABILITY TO USE THE SOFTWARE OR THE CONTRIBUTION IN IT, NO MATTER HOW IT'S CAUSED OR BASED ON WHICH LEGAL THEORY, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +6. Language + +THIS LICENSE IS WRITTEN IN BOTH CHINESE AND ENGLISH, AND THE CHINESE VERSION AND ENGLISH VERSION SHALL HAVE THE SAME LEGAL EFFECT. IN THE CASE OF DIVERGENCE BETWEEN THE CHINESE AND ENGLISH VERSIONS, THE CHINESE VERSION SHALL PREVAIL. + +END OF THE TERMS AND CONDITIONS diff --git a/hhtools/web/frontend/public/robot-icons/licenses/asimov-1-CERN-OHL-S-2.0.txt b/hhtools/web/frontend/public/robot-icons/licenses/asimov-1-CERN-OHL-S-2.0.txt new file mode 100644 index 00000000..0f109220 --- /dev/null +++ b/hhtools/web/frontend/public/robot-icons/licenses/asimov-1-CERN-OHL-S-2.0.txt @@ -0,0 +1,289 @@ +CERN Open Hardware Licence Version 2 - Strongly Reciprocal + + +Preamble + +CERN has developed this licence to promote collaboration among +hardware designers and to provide a legal tool which supports the +freedom to use, study, modify, share and distribute hardware designs +and products based on those designs. Version 2 of the CERN Open +Hardware Licence comes in three variants: CERN-OHL-P (permissive); and +two reciprocal licences: CERN-OHL-W (weakly reciprocal) and this +licence, CERN-OHL-S (strongly reciprocal). + +The CERN-OHL-S is copyright CERN 2020. Anyone is welcome to use it, in +unmodified form only. + +Use of this Licence does not imply any endorsement by CERN of any +Licensor or their designs nor does it imply any involvement by CERN in +their development. + + +1 Definitions + + 1.1 'Licence' means this CERN-OHL-S. + + 1.2 'Compatible Licence' means + + a) any earlier version of the CERN Open Hardware licence, or + + b) any version of the CERN-OHL-S, or + + c) any licence which permits You to treat the Source to which + it applies as licensed under CERN-OHL-S provided that on + Conveyance of any such Source, or any associated Product You + treat the Source in question as being licensed under + CERN-OHL-S. + + 1.3 'Source' means information such as design materials or digital + code which can be applied to Make or test a Product or to + prepare a Product for use, Conveyance or sale, regardless of its + medium or how it is expressed. It may include Notices. + + 1.4 'Covered Source' means Source that is explicitly made available + under this Licence. + + 1.5 'Product' means any device, component, work or physical object, + whether in finished or intermediate form, arising from the use, + application or processing of Covered Source. + + 1.6 'Make' means to create or configure something, whether by + manufacture, assembly, compiling, loading or applying Covered + Source or another Product or otherwise. + + 1.7 'Available Component' means any part, sub-assembly, library or + code which: + + a) is licensed to You as Complete Source under a Compatible + Licence; or + + b) is available, at the time a Product or the Source containing + it is first Conveyed, to You and any other prospective + licensees + + i) as a physical part with sufficient rights and + information (including any configuration and + programming files and information about its + characteristics and interfaces) to enable it either to + be Made itself, or to be sourced and used to Make the + Product; or + ii) as part of the normal distribution of a tool used to + design or Make the Product. + + 1.8 'Complete Source' means the set of all Source necessary to Make + a Product, in the preferred form for making modifications, + including necessary installation and interfacing information + both for the Product, and for any included Available Components. + If the format is proprietary, it must also be made available in + a format (if the proprietary tool can create it) which is + viewable with a tool available to potential licensees and + licensed under a licence approved by the Free Software + Foundation or the Open Source Initiative. Complete Source need + not include the Source of any Available Component, provided that + You include in the Complete Source sufficient information to + enable a recipient to Make or source and use the Available + Component to Make the Product. + + 1.9 'Source Location' means a location where a Licensor has placed + Covered Source, and which that Licensor reasonably believes will + remain easily accessible for at least three years for anyone to + obtain a digital copy. + + 1.10 'Notice' means copyright, acknowledgement and trademark notices, + Source Location references, modification notices (subsection + 3.3(b)) and all notices that refer to this Licence and to the + disclaimer of warranties that are included in the Covered + Source. + + 1.11 'Licensee' or 'You' means any person exercising rights under + this Licence. + + 1.12 'Licensor' means a natural or legal person who creates or + modifies Covered Source. A person may be a Licensee and a + Licensor at the same time. + + 1.13 'Convey' means to communicate to the public or distribute. + + +2 Applicability + + 2.1 This Licence governs the use, copying, modification, Conveying + of Covered Source and Products, and the Making of Products. By + exercising any right granted under this Licence, You irrevocably + accept these terms and conditions. + + 2.2 This Licence is granted by the Licensor directly to You, and + shall apply worldwide and without limitation in time. + + 2.3 You shall not attempt to restrict by contract or otherwise the + rights granted under this Licence to other Licensees. + + 2.4 This Licence is not intended to restrict fair use, fair dealing, + or any other similar right. + + +3 Copying, Modifying and Conveying Covered Source + + 3.1 You may copy and Convey verbatim copies of Covered Source, in + any medium, provided You retain all Notices. + + 3.2 You may modify Covered Source, other than Notices, provided that + You irrevocably undertake to make that modified Covered Source + available from a Source Location should You Convey a Product in + circumstances where the recipient does not otherwise receive a + copy of the modified Covered Source. In each case subsection 3.3 + shall apply. + + You may only delete Notices if they are no longer applicable to + the corresponding Covered Source as modified by You and You may + add additional Notices applicable to Your modifications. + Including Covered Source in a larger work is modifying the + Covered Source, and the larger work becomes modified Covered + Source. + + 3.3 You may Convey modified Covered Source (with the effect that You + shall also become a Licensor) provided that You: + + a) retain Notices as required in subsection 3.2; + + b) add a Notice to the modified Covered Source stating that You + have modified it, with the date and brief description of how + You have modified it; + + c) add a Source Location Notice for the modified Covered Source + if You Convey in circumstances where the recipient does not + otherwise receive a copy of the modified Covered Source; and + + d) license the modified Covered Source under the terms and + conditions of this Licence (or, as set out in subsection + 8.3, a later version, if permitted by the licence of the + original Covered Source). Such modified Covered Source must + be licensed as a whole, but excluding Available Components + contained in it, which remain licensed under their own + applicable licences. + + +4 Making and Conveying Products + +You may Make Products, and/or Convey them, provided that You either +provide each recipient with a copy of the Complete Source or ensure +that each recipient is notified of the Source Location of the Complete +Source. That Complete Source is Covered Source, and You must +accordingly satisfy Your obligations set out in subsection 3.3. If +specified in a Notice, the Product must visibly and securely display +the Source Location on it or its packaging or documentation in the +manner specified in that Notice. + + +5 Research and Development + +You may Convey Covered Source, modified Covered Source or Products to +a legal entity carrying out development, testing or quality assurance +work on Your behalf provided that the work is performed on terms which +prevent the entity from both using the Source or Products for its own +internal purposes and Conveying the Source or Products or any +modifications to them to any person other than You. Any modifications +made by the entity shall be deemed to be made by You pursuant to +subsection 3.2. + + +6 DISCLAIMER AND LIABILITY + + 6.1 DISCLAIMER OF WARRANTY -- The Covered Source and any Products + are provided 'as is' and any express or implied warranties, + including, but not limited to, implied warranties of + merchantability, of satisfactory quality, non-infringement of + third party rights, and fitness for a particular purpose or use + are disclaimed in respect of any Source or Product to the + maximum extent permitted by law. The Licensor makes no + representation that any Source or Product does not or will not + infringe any patent, copyright, trade secret or other + proprietary right. The entire risk as to the use, quality, and + performance of any Source or Product shall be with You and not + the Licensor. This disclaimer of warranty is an essential part + of this Licence and a condition for the grant of any rights + granted under this Licence. + + 6.2 EXCLUSION AND LIMITATION OF LIABILITY -- The Licensor shall, to + the maximum extent permitted by law, have no liability for + direct, indirect, special, incidental, consequential, exemplary, + punitive or other damages of any character including, without + limitation, procurement of substitute goods or services, loss of + use, data or profits, or business interruption, however caused + and on any theory of contract, warranty, tort (including + negligence), product liability or otherwise, arising in any way + in relation to the Covered Source, modified Covered Source + and/or the Making or Conveyance of a Product, even if advised of + the possibility of such damages, and You shall hold the + Licensor(s) free and harmless from any liability, costs, + damages, fees and expenses, including claims by third parties, + in relation to such use. + + +7 Patents + + 7.1 Subject to the terms and conditions of this Licence, each + Licensor hereby grants to You a perpetual, worldwide, + non-exclusive, no-charge, royalty-free, irrevocable (except as + stated in subsections 7.2 and 8.4) patent licence to Make, have + Made, use, offer to sell, sell, import, and otherwise transfer + the Covered Source and Products, where such licence applies only + to those patent claims licensable by such Licensor that are + necessarily infringed by exercising rights under the Covered + Source as Conveyed by that Licensor. + + 7.2 If You institute patent litigation against any entity (including + a cross-claim or counterclaim in a lawsuit) alleging that the + Covered Source or a Product constitutes direct or contributory + patent infringement, or You seek any declaration that a patent + licensed to You under this Licence is invalid or unenforceable + then any rights granted to You under this Licence shall + terminate as of the date such process is initiated. + + +8 General + + 8.1 If any provisions of this Licence are or subsequently become + invalid or unenforceable for any reason, the remaining + provisions shall remain effective. + + 8.2 You shall not use any of the name (including acronyms and + abbreviations), image, or logo by which the Licensor or CERN is + known, except where needed to comply with section 3, or where + the use is otherwise allowed by law. Any such permitted use + shall be factual and shall not be made so as to suggest any kind + of endorsement or implication of involvement by the Licensor or + its personnel. + + 8.3 CERN may publish updated versions and variants of this Licence + which it considers to be in the spirit of this version, but may + differ in detail to address new problems or concerns. New + versions will be published with a unique version number and a + variant identifier specifying the variant. If the Licensor has + specified that a given variant applies to the Covered Source + without specifying a version, You may treat that Covered Source + as being released under any version of the CERN-OHL with that + variant. If no variant is specified, the Covered Source shall be + treated as being released under CERN-OHL-S. The Licensor may + also specify that the Covered Source is subject to a specific + version of the CERN-OHL or any later version in which case You + may apply this or any later version of CERN-OHL with the same + variant identifier published by CERN. + + 8.4 This Licence shall terminate with immediate effect if You fail + to comply with any of its terms and conditions. + + 8.5 However, if You cease all breaches of this Licence, then Your + Licence from any Licensor is reinstated unless such Licensor has + terminated this Licence by giving You, while You remain in + breach, a notice specifying the breach and requiring You to cure + it within 30 days, and You have failed to come into compliance + in all material respects by the end of the 30 day period. Should + You repeat the breach after receipt of a cure notice and + subsequent reinstatement, this Licence will terminate + immediately and permanently. Section 6 shall continue to apply + after any termination. + + 8.6 This Licence shall not be enforceable except by a Licensor + acting as such, and third party beneficiary rights are + specifically excluded. diff --git a/hhtools/web/frontend/public/robot-icons/licenses/berkeley-humanoid-lite-CC-BY-SA-4.0.txt b/hhtools/web/frontend/public/robot-icons/licenses/berkeley-humanoid-lite-CC-BY-SA-4.0.txt new file mode 100644 index 00000000..7d4f96c5 --- /dev/null +++ b/hhtools/web/frontend/public/robot-icons/licenses/berkeley-humanoid-lite-CC-BY-SA-4.0.txt @@ -0,0 +1,427 @@ +Attribution-ShareAlike 4.0 International + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution-ShareAlike 4.0 International Public +License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution-ShareAlike 4.0 International Public License ("Public +License"). To the extent this Public License may be interpreted as a +contract, You are granted the Licensed Rights in consideration of Your +acceptance of these terms and conditions, and the Licensor grants You +such rights in consideration of benefits the Licensor receives from +making the Licensed Material available under these terms and +conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. BY-SA Compatible License means a license listed at + creativecommons.org/compatiblelicenses, approved by Creative + Commons as essentially the equivalent of this Public License. + + d. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + + e. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + f. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + g. License Elements means the license attributes listed in the name + of a Creative Commons Public License. The License Elements of this + Public License are Attribution and ShareAlike. + + h. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + i. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + j. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + k. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + l. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + m. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part; and + + b. produce, reproduce, and Share Adapted Material. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. Additional offer from the Licensor -- Adapted Material. + Every recipient of Adapted Material from You + automatically receives an offer from the Licensor to + exercise the Licensed Rights in the Adapted Material + under the conditions of the Adapter's License You apply. + + c. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + b. ShareAlike. + + In addition to the conditions in Section 3(a), if You Share + Adapted Material You produce, the following conditions also apply. + + 1. The Adapter's License You apply must be a Creative Commons + license with the same License Elements, this version or + later, or a BY-SA Compatible License. + + 2. You must include the text of, or the URI or hyperlink to, the + Adapter's License You apply. You may satisfy this condition + in any reasonable manner based on the medium, means, and + context in which You Share Adapted Material. + + 3. You may not offer or impose any additional or different terms + or conditions on, or apply any Effective Technological + Measures to, Adapted Material that restrict exercise of the + rights granted under the Adapter's License You apply. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material, + including for purposes of Section 3(b); and + + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + + +======================================================================= + +Creative Commons is not a party to its public +licenses. Notwithstanding, Creative Commons may elect to apply one of +its public licenses to material it publishes and in those instances +will be considered the “Licensor.” The text of the Creative Commons +public licenses is dedicated to the public domain under the CC0 Public +Domain Dedication. Except for the limited purpose of indicating that +material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the +public licenses. + +Creative Commons may be contacted at creativecommons.org. diff --git a/hhtools/web/frontend/public/robot-icons/licenses/fourier-gr2-GPL-3.0.txt b/hhtools/web/frontend/public/robot-icons/licenses/fourier-gr2-GPL-3.0.txt new file mode 100644 index 00000000..94a9ed02 --- /dev/null +++ b/hhtools/web/frontend/public/robot-icons/licenses/fourier-gr2-GPL-3.0.txt @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/hhtools/web/frontend/public/robot-icons/licenses/roboto-origin-CERN-OHL-W-2.0.txt b/hhtools/web/frontend/public/robot-icons/licenses/roboto-origin-CERN-OHL-W-2.0.txt new file mode 100644 index 00000000..0f882860 --- /dev/null +++ b/hhtools/web/frontend/public/robot-icons/licenses/roboto-origin-CERN-OHL-W-2.0.txt @@ -0,0 +1,310 @@ +CERN Open Hardware Licence Version 2 - Weakly Reciprocal + +Preamble + +CERN has developed this licence to promote collaboration among +hardware designers and to provide a legal tool which supports the +freedom to use, study, modify, share and distribute hardware designs +and products based on those designs. Version 2 of the CERN Open +Hardware Licence comes in three variants: CERN-OHL-P (permissive); and +two reciprocal licences: this licence, CERN- OHL-W (weakly reciprocal) +and CERN-OHL-S (strongly reciprocal). + +The CERN-OHL-W is copyright CERN 2020. Anyone is welcome to use it, in +unmodified form only. + +Use of this Licence does not imply any endorsement by CERN of any +Licensor or their designs nor does it imply any involvement by CERN in +their development. + + +1 Definitions + + 1.1 'Licence' means this CERN-OHL-W. + + 1.2 'Compatible Licence' means + + a) any earlier version of the CERN Open Hardware licence, or + + b) any version of the CERN-OHL-S or the CERN-OHL-W, or + + c) any licence which permits You to treat the Source to which + it applies as licensed under CERN-OHL-S or CERN-OHL-W + provided that on Conveyance of any such Source, or any + associated Product You treat the Source in question as being + licensed under CERN-OHL-S or CERN-OHL-W as appropriate. + + 1.3 'Source' means information such as design materials or digital + code which can be applied to Make or test a Product or to + prepare a Product for use, Conveyance or sale, regardless of its + medium or how it is expressed. It may include Notices. + + 1.4 'Covered Source' means Source that is explicitly made available + under this Licence. + + 1.5 'Product' means any device, component, work or physical object, + whether in finished or intermediate form, arising from the use, + application or processing of Covered Source. + + 1.6 'Make' means to create or configure something, whether by + manufacture, assembly, compiling, loading or applying Covered + Source or another Product or otherwise. + + 1.7 'Available Component' means any part, sub-assembly, library or + code which: + + a) is licensed to You as Complete Source under a Compatible + Licence; or + + b) is available, at the time a Product or the Source containing + it is first Conveyed, to You and any other prospective + licensees + + i) with sufficient rights and information (including any + configuration and programming files and information + about its characteristics and interfaces) to enable it + either to be Made itself, or to be sourced and used to + Make the Product; or + ii) as part of the normal distribution of a tool used to + design or Make the Product. + + 1.8 'External Material' means anything (including Source) which: + + a) is only combined with Covered Source in such a way that it + interfaces with the Covered Source using a documented + interface which is described in the Covered Source; and + + b) is not a derivative of or contains Covered Source, or, if it + is, it is solely to the extent necessary to facilitate such + interfacing. + + 1.9 'Complete Source' means the set of all Source necessary to Make + a Product, in the preferred form for making modifications, + including necessary installation and interfacing information + both for the Product, and for any included Available Components. + If the format is proprietary, it must also be made available in + a format (if the proprietary tool can create it) which is + viewable with a tool available to potential licensees and + licensed under a licence approved by the Free Software + Foundation or the Open Source Initiative. Complete Source need + not include the Source of any Available Component, provided that + You include in the Complete Source sufficient information to + enable a recipient to Make or source and use the Available + Component to Make the Product. + + 1.10 'Source Location' means a location where a Licensor has placed + Covered Source, and which that Licensor reasonably believes will + remain easily accessible for at least three years for anyone to + obtain a digital copy. + + 1.11 'Notice' means copyright, acknowledgement and trademark notices, + Source Location references, modification notices (subsection + 3.3(b)) and all notices that refer to this Licence and to the + disclaimer of warranties that are included in the Covered + Source. + + 1.12 'Licensee' or 'You' means any person exercising rights under + this Licence. + + 1.13 'Licensor' means a natural or legal person who creates or + modifies Covered Source. A person may be a Licensee and a + Licensor at the same time. + + 1.14 'Convey' means to communicate to the public or distribute. + + +2 Applicability + + 2.1 This Licence governs the use, copying, modification, Conveying + of Covered Source and Products, and the Making of Products. By + exercising any right granted under this Licence, You irrevocably + accept these terms and conditions. + + 2.2 This Licence is granted by the Licensor directly to You, and + shall apply worldwide and without limitation in time. + + 2.3 You shall not attempt to restrict by contract or otherwise the + rights granted under this Licence to other Licensees. + + 2.4 This Licence is not intended to restrict fair use, fair dealing, + or any other similar right. + + +3 Copying, modifying and Conveying Covered Source + + 3.1 You may copy and Convey verbatim copies of Covered Source, in + any medium, provided You retain all Notices. + + 3.2 You may modify Covered Source, other than Notices, provided that + You irrevocably undertake to make that modified Covered Source + available from a Source Location should You Convey a Product in + circumstances where the recipient does not otherwise receive a + copy of the modified Covered Source. In each case subsection 3.3 + shall apply. + + You may only delete Notices if they are no longer applicable to + the corresponding Covered Source as modified by You and You may + add additional Notices applicable to Your modifications. + + 3.3 You may Convey modified Covered Source (with the effect that You + shall also become a Licensor) provided that You: + + a) retain Notices as required in subsection 3.2; + + b) add a Notice to the modified Covered Source stating that You + have modified it, with the date and brief description of how + You have modified it; + + c) add a Source Location Notice for the modified Covered Source + if You Convey in circumstances where the recipient does not + otherwise receive a copy of the modified Covered Source; and + + d) license the modified Covered Source under the terms and + conditions of this Licence (or, as set out in subsection + 8.3, a later version, if permitted by the licence of the + original Covered Source). Such modified Covered Source must + be licensed as a whole, but excluding Available Components + contained in it or External Material to which it is + interfaced, which remain licensed under their own applicable + licences. + + +4 Making and Conveying Products + + 4.1 You may Make Products, and/or Convey them, provided that You + either provide each recipient with a copy of the Complete Source + or ensure that each recipient is notified of the Source Location + of the Complete Source. That Complete Source includes Covered + Source and You must accordingly satisfy Your obligations set out + in subsection 3.3. If specified in a Notice, the Product must + visibly and securely display the Source Location on it or its + packaging or documentation in the manner specified in that + Notice. + + 4.2 Where You Convey a Product which incorporates External Material, + the Complete Source for that Product which You are required to + provide under subsection 4.1 need not include any Source for the + External Material. + + 4.3 You may license Products under terms of Your choice, provided + that such terms do not restrict or attempt to restrict any + recipients' rights under this Licence to the Covered Source. + + +5 Research and Development + +You may Convey Covered Source, modified Covered Source or Products to +a legal entity carrying out development, testing or quality assurance +work on Your behalf provided that the work is performed on terms which +prevent the entity from both using the Source or Products for its own +internal purposes and Conveying the Source or Products or any +modifications to them to any person other than You. Any modifications +made by the entity shall be deemed to be made by You pursuant to +subsection 3.2. + + +6 DISCLAIMER AND LIABILITY + + 6.1 DISCLAIMER OF WARRANTY -- The Covered Source and any Products + are provided 'as is' and any express or implied warranties, + including, but not limited to, implied warranties of + merchantability, of satisfactory quality, non-infringement of + third party rights, and fitness for a particular purpose or use + are disclaimed in respect of any Source or Product to the + maximum extent permitted by law. The Licensor makes no + representation that any Source or Product does not or will not + infringe any patent, copyright, trade secret or other + proprietary right. The entire risk as to the use, quality, and + performance of any Source or Product shall be with You and not + the Licensor. This disclaimer of warranty is an essential part + of this Licence and a condition for the grant of any rights + granted under this Licence. + + 6.2 EXCLUSION AND LIMITATION OF LIABILITY -- The Licensor shall, to + the maximum extent permitted by law, have no liability for + direct, indirect, special, incidental, consequential, exemplary, + punitive or other damages of any character including, without + limitation, procurement of substitute goods or services, loss of + use, data or profits, or business interruption, however caused + and on any theory of contract, warranty, tort (including + negligence), product liability or otherwise, arising in any way + in relation to the Covered Source, modified Covered Source + and/or the Making or Conveyance of a Product, even if advised of + the possibility of such damages, and You shall hold the + Licensor(s) free and harmless from any liability, costs, + damages, fees and expenses, including claims by third parties, + in relation to such use. + + +7 Patents + + 7.1 Subject to the terms and conditions of this Licence, each + Licensor hereby grants to You a perpetual, worldwide, + non-exclusive, no-charge, royalty-free, irrevocable (except as + stated in subsections 7.2 and 8.4) patent license to Make, have + Made, use, offer to sell, sell, import, and otherwise transfer + the Covered Source and Products, where such licence applies only + to those patent claims licensable by such Licensor that are + necessarily infringed by exercising rights under the Covered + Source as Conveyed by that Licensor. + + 7.2 If You institute patent litigation against any entity (including + a cross-claim or counterclaim in a lawsuit) alleging that the + Covered Source or a Product constitutes direct or contributory + patent infringement, or You seek any declaration that a patent + licensed to You under this Licence is invalid or unenforceable + then any rights granted to You under this Licence shall + terminate as of the date such process is initiated. + + +8 General + + 8.1 If any provisions of this Licence are or subsequently become + invalid or unenforceable for any reason, the remaining + provisions shall remain effective. + + 8.2 You shall not use any of the name (including acronyms and + abbreviations), image, or logo by which the Licensor or CERN is + known, except where needed to comply with section 3, or where + the use is otherwise allowed by law. Any such permitted use + shall be factual and shall not be made so as to suggest any kind + of endorsement or implication of involvement by the Licensor or + its personnel. + + 8.3 CERN may publish updated versions and variants of this Licence + which it considers to be in the spirit of this version, but may + differ in detail to address new problems or concerns. New + versions will be published with a unique version number and a + variant identifier specifying the variant. If the Licensor has + specified that a given variant applies to the Covered Source + without specifying a version, You may treat that Covered Source + as being released under any version of the CERN-OHL with that + variant. If no variant is specified, the Covered Source shall be + treated as being released under CERN-OHL-S. The Licensor may + also specify that the Covered Source is subject to a specific + version of the CERN-OHL or any later version in which case You + may apply this or any later version of CERN-OHL with the same + variant identifier published by CERN. + + You may treat Covered Source licensed under CERN-OHL-W as + licensed under CERN-OHL-S if and only if all Available + Components referenced in the Covered Source comply with the + corresponding definition of Available Component for CERN-OHL-S. + + 8.4 This Licence shall terminate with immediate effect if You fail + to comply with any of its terms and conditions. + + 8.5 However, if You cease all breaches of this Licence, then Your + Licence from any Licensor is reinstated unless such Licensor has + terminated this Licence by giving You, while You remain in + breach, a notice specifying the breach and requiring You to cure + it within 30 days, and You have failed to come into compliance + in all material respects by the end of the 30 day period. Should + You repeat the breach after receipt of a cure notice and + subsequent reinstatement, this Licence will terminate + immediately and permanently. Section 6 shall continue to apply + after any termination. + + 8.6 This Licence shall not be enforceable except by a Licensor + acting as such, and third party beneficiary rights are + specifically excluded. diff --git a/hhtools/web/frontend/public/robot-icons/licenses/unitree-g1-BSD-3-Clause.txt b/hhtools/web/frontend/public/robot-icons/licenses/unitree-g1-BSD-3-Clause.txt new file mode 100644 index 00000000..6dfdadaa --- /dev/null +++ b/hhtools/web/frontend/public/robot-icons/licenses/unitree-g1-BSD-3-Clause.txt @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2016-2022 HangZhou YuShu TECHNOLOGY CO.,LTD. ("Unitree Robotics") +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/hhtools/web/frontend/public/robot-icons/roboto-origin.webp b/hhtools/web/frontend/public/robot-icons/roboto-origin.webp new file mode 100644 index 00000000..2eb8a872 Binary files /dev/null and b/hhtools/web/frontend/public/robot-icons/roboto-origin.webp differ diff --git a/hhtools/web/frontend/public/robot-icons/unitree-g1.webp b/hhtools/web/frontend/public/robot-icons/unitree-g1.webp new file mode 100644 index 00000000..e5a13f67 Binary files /dev/null and b/hhtools/web/frontend/public/robot-icons/unitree-g1.webp differ diff --git a/hhtools/web/frontend/src/base/common/disposable.ts b/hhtools/web/frontend/src/base/common/disposable.ts new file mode 100644 index 00000000..a80dc851 --- /dev/null +++ b/hhtools/web/frontend/src/base/common/disposable.ts @@ -0,0 +1,23 @@ +/** Minimal VS Code-style lifecycle contract for long-lived workbench services. */ +export interface IDisposable { + dispose(): void; +} + +/** Collects related subscriptions and releases them in reverse creation order. */ +export class DisposableStore implements IDisposable { + readonly #items = new Set(); + + add(item: T): T { + this.#items.add(item); + return item; + } + + dispose(): void { + for (const item of [...this.#items].reverse()) item.dispose(); + this.#items.clear(); + } +} + +export function toDisposable(dispose: () => void): IDisposable { + return { dispose }; +} diff --git a/hhtools/web/frontend/src/components/ui/button.tsx b/hhtools/web/frontend/src/components/ui/button.tsx new file mode 100644 index 00000000..04efeaf0 --- /dev/null +++ b/hhtools/web/frontend/src/components/ui/button.tsx @@ -0,0 +1,54 @@ +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors outline-none disabled:pointer-events-none disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-ring", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:opacity-90", + secondary: "bg-secondary text-secondary-foreground hover:brightness-95", + ghost: "hover:bg-muted hover:text-foreground", + outline: "border bg-background hover:bg-muted", + destructive: "bg-red-600 text-white hover:bg-red-700", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +/** Project-owned shadcn button primitive used by both browser and Electron. */ +export function Button({ + className, + variant, + size, + asChild = false, + ...props +}: ButtonProps) { + const Component = asChild ? Slot : "button"; + return ( + + ); +} + +export { buttonVariants }; diff --git a/hhtools/web/frontend/src/components/ui/dialog.tsx b/hhtools/web/frontend/src/components/ui/dialog.tsx new file mode 100644 index 00000000..ea893015 --- /dev/null +++ b/hhtools/web/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,64 @@ +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +export const Dialog = DialogPrimitive.Root; +export const DialogTrigger = DialogPrimitive.Trigger; +export const DialogClose = DialogPrimitive.Close; + +export function DialogContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + {children} + + + + + + ); +} + +export function DialogHeader(props: React.HTMLAttributes) { + return ( +
+ ); +} + +export function DialogTitle( + props: React.ComponentProps, +) { + return ( + + ); +} + +export function DialogDescription( + props: React.ComponentProps, +) { + return ( + + ); +} diff --git a/hhtools/web/frontend/src/components/ui/tooltip.tsx b/hhtools/web/frontend/src/components/ui/tooltip.tsx new file mode 100644 index 00000000..f57c5fc7 --- /dev/null +++ b/hhtools/web/frontend/src/components/ui/tooltip.tsx @@ -0,0 +1,27 @@ +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +export const TooltipProvider = TooltipPrimitive.Provider; +export const Tooltip = TooltipPrimitive.Root; +export const TooltipTrigger = TooltipPrimitive.Trigger; + +export function TooltipContent({ + className, + sideOffset = 6, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} diff --git a/hhtools/web/frontend/src/env.d.ts b/hhtools/web/frontend/src/env.d.ts new file mode 100644 index 00000000..745256e9 --- /dev/null +++ b/hhtools/web/frontend/src/env.d.ts @@ -0,0 +1,247 @@ +/// + +import type { + CalibrationEditorCommandDetail, + CalibrationEditorStateDetail, + ComparisonCommandDetail, + ComparisonStateDetail, + DataAnalysisStateDetail, + HhAppBridge, + ImportCommandDetail, + JobHistoryCommandDetail, + JobHistoryStateDetail, + PlaybackCommandDetail, + PlaybackUiState, + ResultDiagnosticsDetail, + UploadFile, + VideoToMotionStateDetail, + WorkflowStateDetail, + GvhmrOptionalComponentState, +} from "./runtime/types"; +import type { GuidedTour } from "./runtime/tutorial"; + +// These id unions are an explicit compile-time contract between declarative +// React markup and the temporary imperative runtime. Removing or renaming a +// port requires migrating its runtime consumer in the same change. +export type HHToolsInputId = + | "lib-search" + | "robot-library-search" + | "rt-retarget-fps" + | "rt-export-fps" + | "rt-export-t-start" + | "rt-export-t-end" + | "rt-csv-header" + | "batch-size" + | "batch-retarget-fps" + | "batch-export-fps" + | "batch-export-t-start" + | "batch-export-t-end" + | "batch-csv-header" + | "batch-out" + | "r2r-source-fps" + | "r2r-retarget-fps" + | "r2r-export-fps" + | "r2r-export-t-start" + | "r2r-export-t-end" + | "r2r-csv-header" + | "r2r-batch-export-fps" + | "r2r-batch-source-fps" + | "r2r-batch-retarget-fps" + | "r2r-batch-t-start" + | "r2r-batch-t-end" + | "r2r-batch-out" + | "r2r-batch-csv-header" + | "dv-user-source-root" + | "dv-source" + | "dv-force" + | "dv-robot-export-files" + | "dv-subset-ratio" + | "dv-subset-alpha"; + +export type HHToolsSelectId = + | "lib-category" + | "h2r-robot-select" + | "rt-ref-select" + | "rt-backend" + | "rt-export-format" + | "batch-backend" + | "batch-format" + | "r2r-source-select" + | "r2r-target-select" + | "r2r-backend" + | "r2r-export-format" + | "r2r-batch-backend" + | "r2r-batch-format" + | "r2r-batch-source-select" + | "r2r-batch-target-select" + | "dv-embedding" + | "dv-robot-select" + | "dv-view-dim"; + +export type HHToolsCanvasId = + "three-canvas" | "dv-hist-canvas" | "dv-scatter-canvas"; + +export type HHToolsKnownId = + | HHToolsInputId + | HHToolsSelectId + | HHToolsCanvasId + | HHToolsButtonId + | `basket-${string}` + | `batch-${string}` + | `boot-${string}` + | `calib-${string}` + | `dv-${string}` + | `lib-${string}` + | `load-${string}` + | `motion-${string}` + | `gvhmr-${string}` + | `r2r-${string}` + | `robot-${string}` + | `rt-${string}` + | `stage${string}` + | `tg-${string}` + | `tour-${string}` + | `view-${string}` + | "add-to-basket" + | "recalib-btn" + | "retarget-btn" + | "toast" + | "ui-build"; + +export type HHToolsButtonId = + | "toggle-sidebar" + | "toggle-inspector" + | "view-reset-btn" + | "tg-skeleton" + | "tg-mesh" + | "tg-env" + | "tg-scaled" + | "tg-scaled-env" + | "tg-robot" + | "r2r-tg-src-robot" + | "r2r-tg-src-skel" + | "r2r-tg-src-env" + | "r2r-tg-tgt-robot" + | "r2r-tg-tgt-skel" + | "r2r-tg-tgt-env" + | "lib-link-path" + | "add-to-basket" + | "robot-pick-urdf" + | "robot-pick-mesh-folder" + | "h2r-robot-load" + | "recalib-btn" + | "calib-zero" + | "calib-restore" + | "calib-cancel" + | "calib-save" + | "retarget-btn" + | "rt-export-btn" + | "basket-clear" + | "batch-run" + | "r2r-source-load" + | "r2r-target-load" + | "r2r-calib-btn" + | "r2r-calib-zero" + | "r2r-calib-cancel" + | "r2r-calib-save" + | "r2r-retarget-btn" + | "r2r-export-btn" + | "r2r-basket-clear" + | "r2r-batch-pick-file" + | "r2r-batch-pick-folder" + | "r2r-batch-source-load" + | "r2r-batch-target-load" + | "r2r-batch-run" + | "dv-pick-folder" + | "dv-pick-robot-folder" + | "dv-clear-upload" + | "dv-analyze" + | "dv-clear-tags" + | "dv-clear-brush" + | "dv-scatter-reset" + | "dv-human-basket" + | "dv-export-robot" + | "dv-export-json" + | "dv-clear-sel" + | "tour-skip" + | "tour-next"; + +export type HHToolsElementForId = Id extends HHToolsInputId + ? HTMLInputElement + : Id extends HHToolsSelectId + ? HTMLSelectElement + : Id extends HHToolsCanvasId + ? HTMLCanvasElement + : Id extends HHToolsButtonId + ? HTMLButtonElement + : HTMLElement; + +declare global { + interface Document { + /** The React workbench owns these elements before the compatibility runtime starts. */ + getElementById( + elementId: Id, + ): HHToolsElementForId; + } + + interface HTMLElement { + _timer?: ReturnType; + } + + interface File { + _relpath?: string; + } + + interface Window { + hhtoolsDesktop?: { + getRuntimeState: () => Promise; + getOptionalComponents: () => Promise<{ + gvhmr: GvhmrOptionalComponentState; + }>; + restartBackend: () => Promise; + setupGvhmr: () => Promise<{ + action: "cancelled" | "configured" | "guide-opened"; + state: GvhmrOptionalComponentState; + }>; + selectDirectory: () => Promise; + openExternal: (url: string) => Promise; + onRuntimeStateChanged: (listener: (state: unknown) => void) => () => void; + }; + __hhtoolsReady?: boolean; + showBoot?: (message: string) => void; + __hhPanelLayout?: { + revealBoth: () => void; + reset: () => void; + }; + __hhUi?: { + setActivePanel: (panel: string) => void; + requestPanel: (panel: string) => void; + }; + __hhApp?: HhAppBridge; + __hh?: Record; + __hhTour?: GuidedTour; + } + + interface WindowEventMap { + "hhtools:calibration-editor-command": CustomEvent; + "hhtools:calibration-editor-state": CustomEvent; + "hhtools:comparison-command": CustomEvent; + "hhtools:comparison-state": CustomEvent; + "hhtools:data-analysis-state": CustomEvent; + "hhtools:job-history-command": CustomEvent; + "hhtools:job-history-state": CustomEvent; + "hhtools:import-command": CustomEvent; + "hhtools:job-spec-import-request": CustomEvent; + "hhtools:motion-profile-request": CustomEvent< + "mimic" | "intermimic" | "meshmimic" + >; + "hhtools:panel-request": CustomEvent; + "hhtools:playback-command": CustomEvent; + "hhtools:playback-state": CustomEvent>; + "hhtools:result-diagnostics": CustomEvent; + "hhtools:video-to-motion-state": CustomEvent; + "hhtools:workflow-state": CustomEvent; + } +} + +export {}; diff --git a/hhtools/web/frontend/src/hooks/use-locale-text.ts b/hhtools/web/frontend/src/hooks/use-locale-text.ts new file mode 100644 index 00000000..14f99e2f --- /dev/null +++ b/hhtools/web/frontend/src/hooks/use-locale-text.ts @@ -0,0 +1,14 @@ +import { useCallback } from "react"; + +import type { WorkspaceLocale } from "@/runtime/types"; + +export type LocaleText = (english: string, chinese: string) => string; + +/** Stable bilingual copy selector shared by all workbench contributions. */ +export function useLocaleText(locale: WorkspaceLocale): LocaleText { + return useCallback( + (english: string, chinese: string) => + locale === "zh-CN" ? chinese : english, + [locale], + ); +} diff --git a/hhtools/web/frontend/src/hooks/use-window-event.ts b/hhtools/web/frontend/src/hooks/use-window-event.ts new file mode 100644 index 00000000..8caaddc8 --- /dev/null +++ b/hhtools/web/frontend/src/hooks/use-window-event.ts @@ -0,0 +1,22 @@ +import { useEffect, useRef } from "react"; + +import { + windowEventBus, + type HHToolsWindowEventName, +} from "@/platform/events/browser/window-event-bus"; + +/** Subscribe once while always invoking the latest React callback. */ +export function useWindowEvent( + type: K, + listener: (event: WindowEventMap[K]) => void, +): void { + const listenerRef = useRef(listener); + listenerRef.current = listener; + + useEffect(() => { + const subscription = windowEventBus.on(type, (event) => + listenerRef.current(event), + ); + return () => subscription.dispose(); + }, [type]); +} diff --git a/hhtools/web/frontend/src/main.tsx b/hhtools/web/frontend/src/main.tsx new file mode 100644 index 00000000..e64907d9 --- /dev/null +++ b/hhtools/web/frontend/src/main.tsx @@ -0,0 +1,18 @@ +import { createRoot } from "react-dom/client"; + +import { TooltipProvider } from "@/components/ui/tooltip"; +import { Workbench } from "@/workbench/browser/workbench"; +import "./styles/tailwind.css"; +import "./webui.css"; + +const root = document.getElementById("app-root"); +if (!root) throw new Error("Missing #app-root mount point"); + +// Keep the entry point deliberately small: host detection, service startup, +// routing, and feature state all belong to the workbench composition root. +// Electron loads this same bundle; its extra capabilities arrive via preload. +createRoot(root).render( + + + , +); diff --git a/hhtools/web/frontend/src/platform/events/browser/window-event-bus.ts b/hhtools/web/frontend/src/platform/events/browser/window-event-bus.ts new file mode 100644 index 00000000..3729c3ca --- /dev/null +++ b/hhtools/web/frontend/src/platform/events/browser/window-event-bus.ts @@ -0,0 +1,31 @@ +import { toDisposable, type IDisposable } from "@/base/common/disposable"; + +export type HHToolsWindowEventName = Extract< + keyof WindowEventMap, + `hhtools:${string}` +>; +type EventDetail = + WindowEventMap[K] extends CustomEvent ? Detail : never; + +/** Typed boundary around legacy CustomEvents. React components consume this + * service instead of registering ad-hoc global listeners throughout the tree. + */ +export class WindowEventBus { + emit( + type: K, + detail: EventDetail, + ): void { + window.dispatchEvent(new CustomEvent(type, { detail })); + } + + on( + type: K, + listener: (event: WindowEventMap[K]) => void, + ): IDisposable { + const wrapped = listener as EventListener; + window.addEventListener(type, wrapped); + return toDisposable(() => window.removeEventListener(type, wrapped)); + } +} + +export const windowEventBus = new WindowEventBus(); diff --git a/hhtools/web/frontend/src/platform/host/browser/browser-host-service.ts b/hhtools/web/frontend/src/platform/host/browser/browser-host-service.ts new file mode 100644 index 00000000..ec146c86 --- /dev/null +++ b/hhtools/web/frontend/src/platform/host/browser/browser-host-service.ts @@ -0,0 +1,34 @@ +import type { + HostKind, + IHostService, +} from "@/platform/host/common/host-service"; +import type { GvhmrOptionalComponentState } from "@/runtime/types"; + +/** One host adapter keeps the React renderer identical in browser and Electron. + * Electron-only operations remain behind the preload's narrow typed API. + */ +export class BrowserHostService implements IHostService { + readonly isDesktop = window.hhtoolsDesktop !== undefined; + readonly kind: HostKind = this.isDesktop ? "desktop" : "web"; + + async selectDirectory(): Promise { + if (window.hhtoolsDesktop?.selectDirectory) + return window.hhtoolsDesktop.selectDirectory(); + return null; + } + + async openExternal(url: string): Promise { + if (window.hhtoolsDesktop?.openExternal) { + await window.hhtoolsDesktop.openExternal(url); + return; + } + window.open(url, "_blank", "noopener,noreferrer"); + } + + async getGvhmrComponent(): Promise { + if (!window.hhtoolsDesktop) return null; + return (await window.hhtoolsDesktop.getOptionalComponents()).gvhmr; + } +} + +export const hostService = new BrowserHostService(); diff --git a/hhtools/web/frontend/src/platform/host/common/host-service.ts b/hhtools/web/frontend/src/platform/host/common/host-service.ts new file mode 100644 index 00000000..e85a7c92 --- /dev/null +++ b/hhtools/web/frontend/src/platform/host/common/host-service.ts @@ -0,0 +1,19 @@ +import type { GvhmrOptionalComponentState } from "@/runtime/types"; + +export type HostKind = "web" | "desktop"; + +/** + * Capabilities that differ between a normal browser and Electron. + * + * Workbench components depend on this interface instead of the Electron + * preload object, which keeps the renderer portable and straightforward to + * test. Add host-specific operations here only when both implementations can + * provide a safe, well-defined fallback. + */ +export interface IHostService { + readonly kind: HostKind; + readonly isDesktop: boolean; + selectDirectory(): Promise; + openExternal(url: string): Promise; + getGvhmrComponent(): Promise; +} diff --git a/hhtools/web/frontend/src/runtime/calibration-editor.ts b/hhtools/web/frontend/src/runtime/calibration-editor.ts new file mode 100644 index 00000000..3c39e5d3 --- /dev/null +++ b/hhtools/web/frontend/src/runtime/calibration-editor.ts @@ -0,0 +1,73 @@ +/** + * Pure calibration-editor rules shared by the React controls and the legacy + * runtime. This module deliberately has no DOM or Three.js dependency: joint + * grouping, filtering, and angle conversion can therefore be tested in + * isolation while the runtime remains the owner of the actual robot pose. + */ + +import type { + CalibrationAngleUnit, + CalibrationJointRegion, +} from './types' + +const LEFT_TOKEN = /(^|[_\-.])(left|l)(?=[_\-.]|$)/ +const RIGHT_TOKEN = /(^|[_\-.])(right|r)(?=[_\-.]|$)/ + +function normalizedJointName(name: string): string { + return name.trim().toLowerCase().replace(/\s+/g, '_') +} + +function hasAny(name: string, tokens: readonly string[]): boolean { + return tokens.some((token) => name.includes(token)) +} + +/** Classify common URDF joint names without assuming one vendor naming scheme. */ +export function classifyCalibrationJoint(name: string): CalibrationJointRegion { + const normalized = normalizedJointName(name) + const left = normalized.startsWith('left') || LEFT_TOKEN.test(normalized) + const right = normalized.startsWith('right') || RIGHT_TOKEN.test(normalized) + + // Specific end effectors win before the broader arm/leg rules. + if (hasAny(normalized, ['finger', 'thumb', 'hand', 'gripper'])) return 'hands' + if (hasAny(normalized, ['head', 'neck', 'antenna'])) return 'head' + + const arm = hasAny(normalized, ['shoulder', 'elbow', 'wrist', 'arm']) + if (arm && left) return 'left-arm' + if (arm && right) return 'right-arm' + + const leg = hasAny(normalized, ['hip', 'knee', 'ankle', 'leg', 'foot', 'toe']) + if (leg && left) return 'left-leg' + if (leg && right) return 'right-leg' + + if (hasAny(normalized, ['pelvis', 'waist', 'torso', 'spine', 'chest', 'trunk', 'root'])) { + return 'torso' + } + return 'other' +} + +export function calibrationJointMatches( + name: string, + query: string, + region: CalibrationJointRegion | 'all', +): boolean { + const normalizedQuery = query.trim().toLowerCase() + const queryMatches = !normalizedQuery || name.toLowerCase().includes(normalizedQuery) + return queryMatches && (region === 'all' || classifyCalibrationJoint(name) === region) +} + +/** Runtime and backend angles are radians; conversion happens only at the UI edge. */ +export function angleForDisplay(valueRad: number, unit: CalibrationAngleUnit): number { + return unit === 'deg' ? valueRad * 180 / Math.PI : valueRad +} + +export function angleFromDisplay(value: number, unit: CalibrationAngleUnit): number { + return unit === 'deg' ? value * Math.PI / 180 : value +} + +export function formatCalibrationAngle( + valueRad: number, + unit: CalibrationAngleUnit, + precision = 3, +): string { + return angleForDisplay(valueRad, unit).toFixed(unit === 'deg' ? Math.max(1, precision - 1) : precision) +} diff --git a/hhtools/web/frontend/src/runtime/command-registry.ts b/hhtools/web/frontend/src/runtime/command-registry.ts new file mode 100644 index 00000000..d964438e --- /dev/null +++ b/hhtools/web/frontend/src/runtime/command-registry.ts @@ -0,0 +1,377 @@ +/** + * Builds the single command model consumed by menus, the command palette, and + * keyboard routes. Most commands publish typed application intents so React + * chrome does not need to know which compatibility-runtime control implements + * an action. The returned list is a snapshot and should be rebuilt when its + * context (active panel, locale, theme, or capabilities) changes. + */ + +import type { + ComparisonPreset, + ImportCommandTarget, + WorkspaceLocale, + WorkspacePanelId, + WorkspaceTheme, + WorkflowId, +} from './types' + +export type DesktopMenuId = 'file' | 'workflows' | 'analysis' | 'settings' | 'help' +export type DesktopSubmenuId = 'file-import' | 'file-export' + +export interface ApplicationCommand { + id: string + group: string + label: string + detail: string + keywords: string + shortcut?: string + menu?: DesktopMenuId + submenu?: DesktopSubmenuId + dividerBefore?: boolean + enabled?: boolean + disabledReason?: string + run: () => void +} + +export interface CommandRegistryContext { + activePanel: WorkspacePanelId + openSettings: () => void + openAbout?: () => void + theme?: WorkspaceTheme + toggleTheme?: () => void + applicationMode?: boolean + locale?: WorkspaceLocale + canExportResult?: boolean + exportResult?: () => void + canExitApplication?: boolean + exitApplication?: () => void +} + +export const DESKTOP_MENUS: ReadonlyArray<{ id: DesktopMenuId; label: string }> = [ + { id: 'file', label: 'File' }, + { id: 'workflows', label: 'Workflows' }, + { id: 'analysis', label: 'Analysis' }, + { id: 'settings', label: 'Settings' }, + { id: 'help', label: 'Help' }, +] + +const PANEL_COMMANDS: ReadonlyArray<{ + panel: WorkspacePanelId + label: string + detail: string + enLabel: string + enDetail: string + zhLabel: string + zhDetail: string + shortcut: string + menu?: DesktopMenuId +}> = [ + { + panel: 'motion', label: '动作 Motion', detail: '导入与检查人体动作', + enLabel: 'Motion', enDetail: 'Import and inspect human motion', + zhLabel: '动作', zhDetail: '导入与检查人体动作', shortcut: 'Alt+1', + }, + { + panel: 'robot-assets', label: '机器人 Robot', detail: '管理机器人模型', + enLabel: 'Robot', enDetail: 'Manage robot models and assets', + zhLabel: '机器人', zhDetail: '管理机器人模型与资产', shortcut: 'Alt+2', + }, + { + panel: 'video-to-motion', label: 'Video to Motion', detail: '使用 GVHMR 从视频生成人体动作', + enLabel: 'Video to Motion', enDetail: 'Generate human motion from a video with GVHMR', + zhLabel: '视频生成动作', zhDetail: '使用 GVHMR 从视频生成人体动作', + shortcut: 'Alt+7', menu: 'workflows', + }, + { + panel: 'h2r', label: 'Human to Robot', detail: '人体动作重映射到机器人', + enLabel: 'Human to Robot', enDetail: 'Retarget human motion to a robot', + zhLabel: '人体到机器人', zhDetail: '将人体动作重映射到机器人', + shortcut: 'Alt+3', menu: 'workflows', + }, + { + panel: 'r2r', label: 'Robot to Robot', detail: '机器人轨迹跨本体重映射', + enLabel: 'Robot to Robot', enDetail: 'Retarget trajectories across robot embodiments', + zhLabel: '机器人到机器人', zhDetail: '在不同机器人本体间重映射轨迹', + shortcut: 'Alt+4', menu: 'workflows', + }, + { + panel: 'batch', label: 'Batch', detail: '批量 Retarget 与导出', + enLabel: 'Batch', enDetail: 'Run batch retargeting and export', + zhLabel: '批量处理', zhDetail: '批量执行动作重映射与导出', + shortcut: 'Alt+5', menu: 'workflows', + }, + { + panel: 'dataset-viz', label: 'Data Analysis', detail: '分析动作与机器人轨迹数据', + enLabel: 'Data Analysis', enDetail: 'Analyze motion and robot trajectory datasets', + zhLabel: '数据分析', zhDetail: '分析动作与机器人轨迹数据', + shortcut: 'Alt+6', menu: 'analysis', + }, +] + +function localize(locale: WorkspaceLocale, en: string, zh: string): string { + return locale === 'zh-CN' ? zh : en +} + +// Custom events are the temporary, typed seam between React-owned chrome and +// the imperative workflow runtime. Keeping them here prevents callers from +// coupling themselves to legacy DOM ids. +function requestPanel(panel: WorkspacePanelId): void { + window.dispatchEvent(new CustomEvent('hhtools:panel-request', { detail: panel })) +} + +function requestImport(target: ImportCommandTarget): void { + window.dispatchEvent(new CustomEvent('hhtools:import-command', { detail: { target } })) +} + +function playback(action: 'toggle' | 'loop'): void { + window.dispatchEvent(new CustomEvent('hhtools:playback-command', { detail: { action } })) +} + +function workflowForPanel(panel: WorkspacePanelId): WorkflowId | null { + return panel === 'h2r' || panel === 'r2r' ? panel : null +} + +function compare(workflow: WorkflowId, preset: ComparisonPreset): void { + window.dispatchEvent(new CustomEvent('hhtools:comparison-command', { + detail: { workflow, preset }, + })) +} + +function importCommand(options: { + id: string + label: string + detail: string + target: ImportCommandTarget + dividerBefore?: boolean +}, locale: WorkspaceLocale): ApplicationCommand { + return { + ...options, + group: localize(locale, 'File', '文件'), + menu: 'file', + submenu: 'file-import', + keywords: `file import upload ${options.label} ${options.detail}`, + run: () => requestImport(options.target), + } +} + +export function createApplicationCommands( + context: CommandRegistryContext, +): ApplicationCommand[] { + const commands: ApplicationCommand[] = [] + const locale = context.locale ?? (context.applicationMode ? 'en' : 'zh-CN') + + // Desktop-like File/Settings/Help commands are intentionally absent from the + // compact embedded mode, while workflow and playback commands remain shared. + if (context.applicationMode) { + commands.push( + importCommand({ + id: 'import-motion-file', + label: localize(locale, 'Import Motion File', '导入动作文件'), + detail: localize(locale, 'Import BVH, GLB, NPZ, and other motion files', '导入 BVH、GLB、NPZ 等动作文件'), + target: 'motion-file', + }, locale), + importCommand({ + id: 'import-motion-folder', + label: localize(locale, 'Import Motion Folder', '导入动作文件夹'), + detail: localize(locale, 'Import a general motion dataset folder', '导入通用动作数据目录'), + target: 'motion-folder', + }, locale), + importCommand({ + id: 'import-video-file', + label: localize(locale, 'Import Video', '导入视频'), + detail: localize(locale, 'Select a video for the Video to Motion workflow', '为视频生成动作工作流选择视频'), + target: 'video-file', + dividerBefore: true, + }, locale), + importCommand({ + id: 'import-robot-urdf', + label: localize(locale, 'Import Robot URDF', '导入机器人 URDF'), + detail: localize(locale, 'Select a robot URDF description file', '选择机器人 URDF 描述文件'), + target: 'robot-urdf', + dividerBefore: true, + }, locale), + importCommand({ + id: 'import-robot-mesh-folder', + label: localize(locale, 'Import Robot Mesh Folder', '导入机器人 Mesh 文件夹'), + detail: localize(locale, 'Select the mesh folder referenced by the URDF', '选择与 URDF 配套的 meshes 目录'), + target: 'robot-mesh-folder', + }, locale), + importCommand({ + id: 'import-robot-trajectory', + label: localize(locale, 'Import Robot Trajectory', '导入机器人轨迹'), + detail: localize(locale, 'Import the source robot trajectory for R2R', '导入 R2R 源机器人轨迹'), + target: 'robot-trajectory', + dividerBefore: true, + }, locale), + importCommand({ + id: 'import-dataset-folder', + label: localize(locale, 'Import Dataset Folder', '导入数据集文件夹'), + detail: localize(locale, 'Select a dataset folder to analyze', '选择要分析的数据集目录'), + target: 'dataset-folder', + }, locale), + importCommand({ + id: 'import-job-spec', + label: localize(locale, 'Import JobSpec', '导入 JobSpec'), + detail: localize(locale, 'Import a reproducible JobSpec JSON file', '导入可验证和重放的 JobSpec JSON'), + target: 'job-spec', + }, locale), + { + id: 'export-current-result', + group: localize(locale, 'File', '文件'), + label: localize(locale, 'Current Result…', '当前结果……'), + detail: localize(locale, 'Download the result of the active workflow', '下载当前工作流的处理结果'), + keywords: 'file export result download 文件 导出 结果 下载', + menu: 'file', + submenu: 'file-export', + enabled: context.canExportResult === true, + disabledReason: context.canExportResult === true + ? undefined + : localize(locale, 'No exportable result', '暂无可导出的结果'), + run: context.exportResult ?? (() => undefined), + }, + { + id: 'exit-application', + group: localize(locale, 'File', '文件'), + label: localize(locale, 'Exit', '退出'), + detail: localize(locale, 'Close HHTOOLS', '关闭 HHTOOLS'), + keywords: 'file exit quit close 文件 退出 关闭', + menu: 'file', + dividerBefore: true, + enabled: context.canExitApplication === true, + disabledReason: context.canExitApplication === true + ? undefined + : localize(locale, 'Desktop app only', '仅桌面应用可用'), + run: context.exitApplication ?? (() => undefined), + }, + { + id: 'open-settings', + group: localize(locale, 'Settings', '设置'), + label: localize(locale, 'Settings', '设置'), + detail: localize( + locale, + 'Configure language, workspace layout, and background jobs', + '调整语言、工作区布局与后台任务调度', + ), + keywords: 'settings preferences language layout jobs queue concurrency 设置 语言 布局 任务 队列 并发', + menu: 'settings', + run: context.openSettings, + }, + { + id: 'toggle-theme', + group: localize(locale, 'Settings', '设置'), + label: context.theme === 'dark' + ? localize(locale, 'Light Mode', '浅色模式') + : localize(locale, 'Dark Mode', '深色模式'), + detail: context.theme === 'dark' + ? localize(locale, 'Switch to the light appearance', '切换为浅色外观') + : localize(locale, 'Switch to the dark appearance', '切换为深色外观'), + keywords: 'theme light dark appearance 主题 浅色 深色 外观', + menu: 'settings', + run: context.toggleTheme ?? (() => undefined), + }, + ) + } + + commands.push(...PANEL_COMMANDS.map((item) => ({ + id: `panel-${item.panel}`, + group: context.applicationMode + ? item.menu === 'workflows' + ? localize(locale, 'Workflows', '工作流') + : item.menu === 'analysis' + ? localize(locale, 'Analysis', '分析') + : localize(locale, 'Workspace', '工作区') + : item.menu === 'workflows' ? 'Workflows' : item.menu === 'analysis' ? 'Analysis' : '工作区', + label: context.applicationMode ? localize(locale, item.enLabel, item.zhLabel) : item.label, + detail: context.applicationMode ? localize(locale, item.enDetail, item.zhDetail) : item.detail, + shortcut: item.shortcut, + keywords: `${item.panel} ${item.label} ${item.detail}`, + menu: item.menu, + run: () => requestPanel(item.panel), + }))) + + if (context.applicationMode) { + commands.push({ + id: 'help-tutorial', + group: localize(locale, 'Help', '帮助'), + label: localize(locale, 'Tutorial', '操作教程'), + detail: localize(locale, 'Run the interactive tutorial again', '重新运行交互式操作教程'), + keywords: 'help tutorial tour 教程 帮助', + menu: 'help', + run: () => window.__hhTour?.start(0), + }, { + id: 'help-about', + group: localize(locale, 'Help', '帮助'), + label: localize(locale, 'About hhtools', '关于 hhtools'), + detail: localize(locale, 'Project, license, source, and contact information', '查看项目、许可、源码与联系信息'), + keywords: 'help about authors contributors license source contact 关于 作者 贡献者 许可 源码 联系', + menu: 'help', + dividerBefore: true, + run: context.openAbout ?? (() => undefined), + }) + } + + commands.push( + { + id: 'playback-toggle', + group: context.applicationMode ? localize(locale, 'Playback', '播放') : '播放', + label: context.applicationMode ? localize(locale, 'Play / Pause', '播放 / 暂停') : '播放 / 暂停', + detail: context.applicationMode ? localize(locale, 'Control the active timeline', '控制当前时间轴') : '控制当前时间轴', + shortcut: 'Space', + keywords: 'play pause 播放 暂停 时间轴', + run: () => playback('toggle'), + }, + { + id: 'playback-loop', + group: context.applicationMode ? localize(locale, 'Playback', '播放') : '播放', + label: context.applicationMode ? localize(locale, 'Toggle Loop', '切换循环播放') : '切换循环播放', + detail: context.applicationMode ? localize(locale, 'Toggle looping for the active timeline', '切换当前时间轴循环状态') : '切换当前时间轴循环状态', + keywords: 'loop 循环', + run: () => playback('loop'), + }, + { + id: 'view-reset', + group: context.applicationMode ? localize(locale, 'View', '视图') : '视图', + label: context.applicationMode ? localize(locale, 'Reset 3D View', '重置 3D 视角') : '重置 3D 视角', + detail: context.applicationMode ? localize(locale, 'Return to the default camera position', '回到当前对象的默认相机位置') : '回到当前对象的默认相机位置', + shortcut: 'F', + keywords: 'camera reset focus 相机 视角 重置', + run: () => document.getElementById('view-reset-btn')?.click(), + }, + { + id: 'panels-reveal', + group: context.applicationMode ? localize(locale, 'View', '视图') : '视图', + label: context.applicationMode ? localize(locale, 'Show Side Panels', '显示左右面板') : '显示左右面板', + detail: context.applicationMode ? localize(locale, 'Restore navigation and inspector panels', '恢复导航栏与控制面板') : '恢复导航栏与控制面板', + keywords: 'sidebar inspector panel 显示 面板 导航', + run: () => window.__hhPanelLayout?.revealBoth(), + }, + ) + + const workflow = workflowForPanel(context.activePanel) + // Comparison layers exist only for the two retarget workspaces, so avoid + // exposing commands that cannot have an effect on other active panels. + if (workflow) { + const comparisonCommands: Array<[ComparisonPreset, string, string, string]> = [ + ['source', 'Source Only', '只看源数据', 'Alt+S'], + ['target', 'Target Only', '只看缩放目标', 'Alt+T'], + ['result', 'Result Only', '只看机器人结果', 'Alt+R'], + ['overlay', 'Overlay', '叠加对比', 'Alt+O'], + ] + for (const [preset, enLabel, zhLabel, shortcut] of comparisonCommands) { + const label = context.applicationMode ? localize(locale, enLabel, zhLabel) : zhLabel + commands.push({ + id: `compare-${preset}`, + group: context.applicationMode ? localize(locale, 'Result Comparison', '结果对比') : '结果对比', + label, + detail: context.applicationMode + ? localize(locale, `${workflow.toUpperCase()} result view`, `${workflow.toUpperCase()} 结果视图`) + : `${workflow.toUpperCase()} 结果视图`, + shortcut, + keywords: `${preset} compare 对比 ${label}`, + run: () => compare(workflow, preset), + }) + } + } + + return commands +} diff --git a/hhtools/web/frontend/src/runtime/dataset-viz.ts b/hhtools/web/frontend/src/runtime/dataset-viz.ts new file mode 100644 index 00000000..8c565745 --- /dev/null +++ b/hhtools/web/frontend/src/runtime/dataset-viz.ts @@ -0,0 +1,1805 @@ +// Dataset Visualization & Analysis compatibility contribution. +// Workbench mounts its stable canvas/control ports before this module loads. +// Keep new UI state in React; this file remains focused on the existing plotting +// and analysis orchestration until that domain receives a dedicated service. + +import type { + DataAnalysisKind, + DataAnalysisStage, + DatasetCatalog, + DatasetClip, + DatasetSummary, + DatasetUploadSummary, + HhAppBridge, + JobResult, + LibraryEntry, + UploadFile, +} from './types' +import type { HHToolsElementForId, HHToolsKnownId } from '../env' + +type DataKind = DataAnalysisKind +type UploadDataKind = Extract +type TagMode = 'and' | 'or' +type NumericRange = { lo: number; hi: number } +type PlotPadding = { l: number; r: number; t: number; b: number } +type NumericHistogramLayout = { + kind: 'num' + pad: PlotPadding + plotW: number + plotH: number + bw: number + nbins: number + edges: number[] + metric: string + min: number + max: number +} +type CategoryHistogramLayout = { + kind: 'cat' + pad: PlotPadding + plotW: number + plotH: number + bw: number + keys: string[] + counts: Record + dim: string +} +type HistogramLayout = NumericHistogramLayout | CategoryHistogramLayout + +/** + * Module-singleton store for the compatibility implementation. React receives + * only the small `DataAnalysisStateDetail` projection used by pipeline chrome; + * filtering, selection, plot geometry, and upload state remain private here. + */ +type DatasetState = { + clips: DatasetClip[] + summary: DatasetSummary | null + catalog: DatasetCatalog | null + dataKind: DataKind + activeTags: Set + tagMode: TagMode + selected: Set + subsetIds: Set + viewDim: string + histBrush: NumericRange | null + catBrush: Set | null + analyzeSource: string + analyzeSourceRoot: string + uploadSummary: DatasetUploadSummary | null + embeddingName: string + previewRobot: string + scatterView: { + scale: number + panX: number + panY: number + dragging: boolean + dragMoved: boolean + lastX: number + lastY: number + } + histLayout: HistogramLayout | null + histDrag: { active: boolean; startBin: number } + subsetTimer: ReturnType | null + hoverClipId: string | null + hoverBin: number + analysisStage: DataAnalysisStage + analysisProgress: number + analysisMessage: string +} + +/** + * Resolve the bridge created by `webui-runtime.ts`. LegacyRuntimeService loads + * that module first, after React has committed all stable DOM mount points. + */ +const bridge = (): HhAppBridge => { + if (!window.__hhApp) throw new Error('The WebUI bridge is not ready') + return window.__hhApp +}; + +const DV_USER_ROOT_KEY = "hh.dvUserSourceRoot"; + +const state: DatasetState = { + clips: [], + summary: null, + catalog: null, + dataKind: "unknown", + activeTags: new Set(), + tagMode: "or", + selected: new Set(), + subsetIds: new Set(), + viewDim: "num:complexity", + histBrush: null, + catBrush: null, + analyzeSource: "", + analyzeSourceRoot: "", + uploadSummary: null, + embeddingName: "handcrafted", + previewRobot: "", + scatterView: { scale: 1, panX: 0, panY: 0, dragging: false, dragMoved: false, lastX: 0, lastY: 0 }, + histLayout: null, + histDrag: { active: false, startBin: -1 }, + subsetTimer: null, + hoverClipId: null, + hoverBin: -1, + analysisStage: "idle", + analysisProgress: 0, + analysisMessage: "", +}; + +const $ = (id: Id): HHToolsElementForId => + document.getElementById(id) + +function textNodeElement( + tag: Tag, + className: string, + value: unknown, +): HTMLElementTagNameMap[Tag] { + const element = document.createElement(tag); + if (className) element.className = className; + element.textContent = String(value ?? ""); + return element; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function canvasContext(canvas: HTMLCanvasElement): CanvasRenderingContext2D { + const context = canvas.getContext('2d') + if (!context) throw new Error('Canvas 2D is not available') + return context +} +const QUALITY_TAGS = ["quality_ok", "quality_warn", "quality_bad"]; +const DYN_TAGS = ["static", "low_dynamic", "mid_dynamic", "high_dynamic", "burst"]; +const CAT_DIMS = ["cluster_id", "folder_label", "quality_band", "dynamics_band", "source_kind"]; +const CATEGORICAL = ["#6366f1", "#14b8a6", "#f472b6", "#fb923c", "#38bdf8", "#a78bfa", "#34d399", "#fbbf24", "#f87171", "#64748b"]; + +/** Publish only renderer-safe progress, not the mutable dataset store itself. */ +function emitAnalysisState(): void { + window.dispatchEvent(new CustomEvent('hhtools:data-analysis-state', { + detail: { + dataKind: state.dataKind, + clipCount: state.uploadSummary?.clip_count || 0, + stage: state.analysisStage, + progress: state.analysisProgress, + message: state.analysisMessage, + hasResults: state.analysisStage === 'completed' && okClips().length > 0, + }, + })); +} + +function setAnalysisState( + stage: DataAnalysisStage, + progress = state.analysisProgress, + message = state.analysisMessage, +): void { + state.analysisStage = stage; + state.analysisProgress = Math.max(0, Math.min(1, progress)); + state.analysisMessage = message; + emitAnalysisState(); +} + +/** Logseq-style graph palette — soft, distinct hues per cluster. */ +const GRAPH_PALETTE = [ + "#5b7cfa", "#2dd4bf", "#e879f9", "#fb7185", "#fbbf24", + "#38bdf8", "#a3e635", "#c084fc", "#f97316", "#94a3b8", +]; + +function hexToRgb(hex: string): { r: number; g: number; b: number } { + const h = hex.replace("#", ""); + return { + r: parseInt(h.slice(0, 2), 16), + g: parseInt(h.slice(2, 4), 16), + b: parseInt(h.slice(4, 6), 16), + }; +} + +function rgba(hex: string, a: number): string { + const { r, g, b } = hexToRgb(hex); + return `rgba(${r},${g},${b},${a})`; +} + +function clusterColor(clusterId: string | number | undefined, colorMap: Map): string { + const k = String(clusterId ?? "?"); + if (!colorMap.has(k)) colorMap.set(k, GRAPH_PALETTE[colorMap.size % GRAPH_PALETTE.length]); + return colorMap.get(k)!; +} + +function roundRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + r: number, +): void { + if (ctx.roundRect) { ctx.roundRect(x, y, w, h, r); return; } + ctx.rect(x, y, w, h); +} + +// ------------------------------------------------------------------ catalog +async function loadCatalog(): Promise { + if (state.catalog) return state.catalog; + try { state.catalog = await bridge().API.get("/api/dataset/catalog"); } + catch { state.catalog = {}; } + applyCatalogTexts(); + return state.catalog; +} + +function applyCatalogTexts() { + const c = state.catalog || {}; + const grid = $("dv-format-grid"); + if (grid) { + grid.innerHTML = + `
人体拖入含 BVH / NPZ / PKL / NPY / GLB 的文件夹即可(AMASS、ACCAD、CMU、LAFAN、OMOMO、parc_ms、holosoma 等;支持 mimic / intermimic / meshmimic 多级目录)
` + + `
机器人拖入 retarget 导出的轨迹 CSV / PKL / NPZ 文件夹(含 *_export、terrain / object 侧车)
` + + `
请勿混合拖入人体与机器人数据;一次分析只支持一种。
`; + } +} + +function detectDataKind(): DataKind { + const clips = okClips(); + const hasHuman = clips.some((c) => c.source_kind !== "robot"); + const hasRobot = clips.some((c) => c.source_kind === "robot"); + if (hasHuman && hasRobot) return "mixed"; + if (hasRobot) return "robot"; + if (hasHuman) return "human"; + return "unknown"; +} + +function dataKindLabel(kind: DataKind): string { + const zh = document.documentElement.lang === 'zh-CN'; + const labels: Record = { + human: ['Motion', '动作'], + robot: ['Robot', '机器人'], + mixed: ['Mixed', '混合 ⚠'], + unknown: ['', ''], + }; + return labels[kind][zh ? 1 : 0]; +} + +function updateKindBadge() { + if (okClips().length) state.dataKind = detectDataKind(); + const badge = $("dv-kind-badge"); + if (!badge) return; + badge.hidden = state.dataKind === "unknown"; + badge.textContent = dataKindLabel(state.dataKind); + badge.className = "dv-card-badge" + (state.dataKind === "mixed" ? " warn" : ""); + const humanBtn = $("dv-human-basket"); + const robotBtn = $("dv-export-robot"); + const humanOk = state.dataKind === "human"; + const robotOk = state.dataKind === "robot"; + if (humanBtn) { + humanBtn.hidden = false; + humanBtn.disabled = !humanOk; + humanBtn.title = humanOk ? "" : "当前为机器人数据,无法加入人体批量篮子"; + } + if (robotBtn) { + robotBtn.hidden = false; + robotBtn.disabled = !robotOk; + robotBtn.title = robotOk + ? "导出选中机器人 clip;可勾选是否打包轨迹文件" + : "当前为人体数据,无法导出机器人轨迹"; + } + const robotOpts = $("dv-robot-export-opts"); + if (robotOpts) robotOpts.hidden = !robotOk; + const userRootWrap = $("dv-user-root-wrap"); + const needsRoot = !!state.analyzeSource + || okClips().some((c) => looksLikeTempSourcePath(c.source_path)); + if (userRootWrap) userRootWrap.hidden = !needsRoot; + syncRobotExportLabel(); + void refreshRobotPreviewUI(); +} + +function syncRobotExportLabel() { + const btn = $("dv-export-robot"); + const pack = $("dv-robot-export-files")?.checked !== false; + if (!btn || btn.disabled) return; + btn.textContent = pack ? "导出机器人数据 (ZIP)" : "导出机器人清单 (JSON)"; +} + +// ------------------------------------------------------------------ clip helpers +function okClips(): DatasetClip[] { + return state.clips.filter((c) => !c.error && c.metrics && Object.keys(c.metrics).length); +} + +function clipMatchesTags(clip: DatasetClip): boolean { + if (!state.activeTags.size) return true; + const tags = new Set(clip.tags || []); + if (state.tagMode === "and") { + for (const t of state.activeTags) if (!tags.has(t)) return false; + return true; + } + for (const t of state.activeTags) if (tags.has(t)) return true; + return false; +} + +function clipCategory(clip: DatasetClip, dim: string): string { + switch (dim) { + case "cluster_id": return String(clip.cluster_id ?? "?"); + case "folder_label": return clip.folder_label || "?"; + case "source_kind": return clip.source_kind || "?"; + case "quality_band": + for (const t of QUALITY_TAGS) if ((clip.tags || []).includes(t)) return t; + return "—"; + case "dynamics_band": + for (const t of DYN_TAGS) if ((clip.tags || []).includes(t)) return t; + return "—"; + default: return "?"; + } +} + +function parseViewDim(): { kind: string; key: string } { + const [kind, key] = (state.viewDim || "num:complexity").split(":"); + return { kind, key }; +} + +function clipInBrush(clip: DatasetClip): boolean { + const { kind, key } = parseViewDim(); + if (kind === "cat") { + if (!state.catBrush?.size) return true; + return state.catBrush.has(clipCategory(clip, key)); + } + if (!state.histBrush) return true; + const value = Number(clip.metrics?.[key]); + if (!Number.isFinite(value)) return false; + return value >= state.histBrush.lo && value <= state.histBrush.hi; +} + +function tagFilteredClips(): DatasetClip[] { return okClips().filter(clipMatchesTags); } +function visibleClips(): DatasetClip[] { return tagFilteredClips().filter(clipInBrush); } + +/** Export the union of algorithm recommendations and explicit user additions. */ +function exportTargetIds(): string[] { + return [...new Set([...state.subsetIds, ...manualSelectedIds()])]; +} + +function manualSelectedIds(): string[] { + return [...state.selected].filter((id) => !state.subsetIds.has(id)); +} + +function isManualSelectable(id: string): boolean { + return !state.subsetIds.has(id); +} + +function pruneManualSelection() { + for (const id of state.subsetIds) state.selected.delete(id); +} + +function entryFromClip(clip: DatasetClip): LibraryEntry { + const sp = clip.source_path || ""; + const seq = sp.split("/").pop(); + return { + dataset: clip.dataset, + folder_label: clip.folder_label, + sequence_id: seq, + source_path: sp, + stem: (seq || "").replace(/\.[^.]+$/, ""), + }; +} + +function inferDefaultRobot() { + const counts = new Map(); + for (const c of okClips()) { + if (c.source_kind !== "robot") continue; + const p = String(c.metrics?.robot_preset || "").trim(); + if (p) counts.set(p, (counts.get(p) || 0) + 1); + } + if (!counts.size) return state.previewRobot || ""; + return [...counts.entries()].sort((a, b) => b[1] - a[1])[0][0]; +} + +async function refreshRobotPreviewUI() { + const box = $("dv-robot-preview"); + if (!box) return; + const show = state.dataKind === "robot"; + box.hidden = !show; + if (!show) return; + const inferred = inferDefaultRobot(); + const hint = $("dv-robot-hint"); + if (hint) { + hint.textContent = inferred + ? `已从 CSV 推断:${inferred} · 点击散点/列表 ▶ 用 mesh 播放` + : "未检测到 robot meta,请手动选择机器人 preset"; + } + const pick = state.previewRobot || inferred; + const val = await bridge().populateDvRobotSelect?.(pick); + if (val) state.previewRobot = val; +} + +async function previewClip(clip: DatasetClip): Promise { + const entry = entryFromClip(clip); + if (clip.source_kind === "robot" || clip.dataset === "robot") { + await bridge().previewRobotClip(entry, state.previewRobot); + } else { + await bridge().loadLibraryEntry(entry); + } +} + +// ------------------------------------------------- subset FPS (farthest-point sampling) +// Here FPS means farthest-point sampling, not frames per second. The algorithm +// balances coverage in embedding space against motion complexity. +function rankNormalize(values: number[]): number[] { + const n = values.length; + if (n <= 1) return values.map(() => 0); + const order = values.map((_, i) => i).sort((a, b) => values[a] - values[b]); + const ranks = new Array(n); + order.forEach((idx, r) => { ranks[idx] = r; }); + return ranks.map((r) => r / (n - 1)); +} + +function globalWeightedFps( + embeddings: number[][], + complexity: number[], + k: number, + alpha: number, +): number[] { + // alpha=1 favours geometric coverage; alpha=0 favours complex clips. + const n = embeddings.length; + if (!n || k <= 0) return []; + k = Math.min(k, n); + const cHat = rankNormalize(complexity); + let anchor = 0; + for (let i = 1; i < n; i++) if (cHat[i] > cHat[anchor]) anchor = i; + const selected = [anchor]; + let dist = embeddings.map((e) => { + let s = 0; + for (let j = 0; j < e.length; j++) { const d = e[j] - embeddings[anchor][j]; s += d * d; } + return Math.sqrt(s); + }); + dist[anchor] = -Infinity; + while (selected.length < k) { + const finite = dist.filter((d) => isFinite(d)); + const dMax = finite.length ? Math.max(...finite) : 0; + let best = -1, bestScore = -Infinity; + for (let i = 0; i < n; i++) { + if (selected.includes(i)) continue; + const score = alpha * (dMax > 1e-12 ? dist[i] / dMax : 0) + (1 - alpha) * cHat[i]; + if (score > bestScore) { bestScore = score; best = i; } + } + if (best < 0) break; + selected.push(best); + for (let i = 0; i < n; i++) { + let s = 0; + for (let j = 0; j < embeddings[i].length; j++) { + const d = embeddings[i][j] - embeddings[best][j]; s += d * d; + } + dist[i] = Math.min(dist[i], Math.sqrt(s)); + } + for (const s of selected) dist[s] = -Infinity; + } + return selected; +} + +function recomputeSubset() { + const flt = visibleClips().filter((c) => c.embedding); + if (!flt.length) { state.subsetIds = new Set(); return; } + const ratio = parseInt($("dv-subset-ratio").value, 10) / 100; + const alpha = parseInt($("dv-subset-alpha").value, 10) / 100; + const k = Math.max(1, Math.round(flt.length * ratio)); + const idx = globalWeightedFps( + flt.map((c) => c.embedding as number[]), + flt.map((c) => Number(c.metrics?.complexity || 0)), + k, alpha, + ); + state.subsetIds = new Set(idx.map((i) => flt[i].clip_id)); + pruneManualSelection(); +} + +function scheduleSubset() { + if (state.subsetTimer !== null) clearTimeout(state.subsetTimer); + state.subsetTimer = setTimeout(() => { + recomputeSubset(); + renderScatter(); + renderSelbar(); + renderOverview(); + }, 60); +} + +// ------------------------------------------------------------------ upload / analyze +// Directory uploads use the non-standard FileSystemEntry API so relative paths +// survive multipart upload; the backend needs them to identify dataset layouts +// and their object/terrain sidecars. +function walkEntry(entry: FileSystemEntry, out: UploadFile[], prefix = ""): Promise { + return new Promise((resolve) => { + if (entry.isFile) { + (entry as FileSystemFileEntry).file((file) => { + const upload = file as UploadFile; + upload._relpath = prefix + upload.name; + out.push(upload); + resolve(); + }); + } else if (entry.isDirectory) { + (entry as FileSystemDirectoryEntry).createReader().readEntries(async (entries) => { + await Promise.all(entries.map((e) => walkEntry(e, out, prefix + entry.name + "/"))); + resolve(); + }); + } else resolve(); + }); +} + +function guessUploadKind(files: UploadFile[]): DataKind { + let csv = 0, motion = 0, robotCsv = 0; + for (const f of files) { + const n = (f._relpath || f.name || "").toLowerCase(); + const base = n.split("/").pop() ?? ""; + if (base.startsWith("object_") && base.endsWith(".csv")) continue; + if (n.endsWith(".csv")) { + csv++; + if (/root_x|dof_/.test(f.name || "")) robotCsv++; + } else if (/\.(bvh|npz|pkl|npy|glb|pt)$/.test(n)) motion++; + } + if (csv && motion) return "mixed"; + if (csv) return "robot"; + return "human"; +} + +function resolveUploadKind(info: DatasetUploadSummary | null | undefined, fallback: DataKind = "unknown"): DataKind { + const r = info?.robot_count || 0; + const h = info?.human_count || 0; + if (r && !h) return "robot"; + if (h && !r) return "human"; + if (r && h) return "mixed"; + return fallback; +} + +const uploadAreaIds: Record = { + human: { dropzone: 'dv-dropzone', icon: 'dv-drop-icon', label: 'dv-drop-label' }, + robot: { dropzone: 'dv-dropzone-robot', icon: 'dv-drop-icon-robot', label: 'dv-drop-label-robot' }, +}; + +function uploadAreaText(kind: UploadDataKind): string { + const zh = document.documentElement.lang === 'zh-CN'; + if (kind === 'robot') return zh ? '拖入机器人轨迹文件夹' : 'Drop a robot trajectory folder here'; + return zh ? '拖入动作数据集文件夹' : 'Drop a motion dataset folder here'; +} + +function resetUploadArea(kind: UploadDataKind): void { + const ids = uploadAreaIds[kind]; + $(ids.dropzone)?.classList.remove('ok', 'busy', 'err', 'hover'); + if ($(ids.icon)) $(ids.icon).textContent = kind === 'robot' ? 'R' : 'M'; + if ($(ids.label)) $(ids.label).textContent = uploadAreaText(kind); +} + +function resetUploadAreas(): void { + resetUploadArea('human'); + resetUploadArea('robot'); +} + +function markUploadArea(kind: UploadDataKind, status: 'busy' | 'ok' | 'err', clipCount = 0): void { + resetUploadAreas(); + const ids = uploadAreaIds[kind]; + $(ids.dropzone)?.classList.add(status); + if (status === 'ok') { + if ($(ids.icon)) $(ids.icon).textContent = '✓'; + if ($(ids.label)) { + $(ids.label).textContent = document.documentElement.lang === 'zh-CN' + ? `已加载 ${clipCount} 个 clip,可继续追加` + : `${clipCount} clips loaded; drop more to append`; + } + } +} + +function renderUploadBasket(info: DatasetUploadSummary): void { + const n = info?.clip_count || 0; + const kind = resolveUploadKind(info, state.dataKind); + const basket = $("dv-upload-basket"); + + if (!n) { + if (basket) basket.hidden = true; + resetUploadAreas(); + $("dv-source-display").textContent = "未指定目录"; + setAnalysisState('idle', 0, ''); + return; + } + + state.uploadSummary = info; + state.dataKind = kind; + const kindLabel = dataKindLabel(kind); + if ($("dv-kind-badge")) { + $("dv-kind-badge").hidden = false; + $("dv-kind-badge").textContent = kindLabel; + $("dv-kind-badge").className = "dv-card-badge" + (kind === "mixed" ? " warn" : ""); + } + $("dv-source-display").textContent = + `${kindLabel} · ${n} clip`; + + if (kind === 'human' || kind === 'robot') markUploadArea(kind, 'ok', n); + else { + resetUploadAreas(); + $("dv-dropzone")?.classList.add('err'); + $("dv-dropzone-robot")?.classList.add('err'); + } + + if (basket) { + basket.hidden = false; + $("dv-basket-summary").textContent = + `${kindLabel} · ${n} clip · ${Object.keys(info.folders || {}).length} 组`; + const list = $("dv-basket-list"); + if (list) { + list.innerHTML = ""; + const clips = info.clips || []; + const byFolder = new Map(); + for (const c of clips) { + const f = c.folder_label || "—"; + if (!byFolder.has(f)) byFolder.set(f, []); + byFolder.get(f).push(c.clip_id.split("/").pop()); + } + for (const [folder, names] of [...byFolder.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { + const row = document.createElement("li"); + row.className = "dv-basket-item"; + const folderEl = document.createElement("span"); + folderEl.className = "dv-basket-folder"; + folderEl.textContent = folder; + const metaEl = document.createElement("span"); + metaEl.className = "dv-basket-meta"; + metaEl.textContent = `${names.length} clip`; + const namesEl = document.createElement("span"); + namesEl.className = "dv-basket-names"; + namesEl.textContent = `${names.slice(0, 3).join(" · ")}${names.length > 3 ? " …" : ""}`; + const rmBtn = document.createElement("button"); + rmBtn.type = "button"; + rmBtn.className = "dv-basket-remove btn-link"; + rmBtn.title = "移除此文件夹"; + rmBtn.textContent = "×"; + rmBtn.onclick = (ev) => { + ev.stopPropagation(); + removeBasketFolder(folder); + }; + row.append(folderEl, metaEl, namesEl, rmBtn); + list.appendChild(row); + } + } + } + updateKindBadge(); + setAnalysisState('idle', 0, ''); +} + +function clearUploadBasket() { + state.analyzeSource = ""; + state.uploadSummary = null; + state.dataKind = "unknown"; + $("dv-source").value = ""; + $("dv-upload-basket").hidden = true; + resetUploadAreas(); + $("dv-source-display").textContent = "未指定目录"; + $("dv-status").textContent = ""; + updateKindBadge(); + setAnalysisState('idle', 0, ''); +} + +async function removeBasketFolder(folderLabel: string): Promise { + const { API, toast } = bridge(); + if (!state.analyzeSource) { + toast("当前无上传批次", true); + return; + } + try { + const info = await API.post("/api/dataset/upload/remove", { + source: state.analyzeSource, + folder_label: folderLabel, + }); + if (!info.clip_count) { + clearUploadBasket(); + if (state.clips.length) { + state.clips = []; + state.summary = null; + state.selected.clear(); + state.subsetIds.clear(); + if ($("dv-results")) $("dv-results").hidden = true; + } + toast(`已移除「${folderLabel}」,批次已空`); + return; + } + state.analyzeSource = info.source || state.analyzeSource; + $("dv-source").value = state.analyzeSource; + renderUploadBasket(info); + if (state.clips.length) { + state.clips = state.clips.filter((c) => c.folder_label !== folderLabel); + for (const id of [...state.selected]) { + if (!state.clips.some((c) => c.clip_id === id)) state.selected.delete(id); + } + for (const id of [...state.subsetIds]) { + if (!state.clips.some((c) => c.clip_id === id)) state.subsetIds.delete(id); + } + recomputeSubset(); + renderAll(); + } + toast(`已移除「${folderLabel}」`); + } catch (error) { + toast(errorMessage(error), true); + } +} + +async function ingestDroppedFiles(files: UploadFile[], expectedKind: UploadDataKind): Promise { + const { uploadFilesXHR, toast } = bridge(); + if (!files?.length) return; + const kind = guessUploadKind(files); + if (kind === "mixed") { + toast("请勿同时拖入人体动作与机器人 CSV,请分开分析", true); + markUploadArea(expectedKind, 'err'); + setAnalysisState('failed', 0, 'Motion and robot data must be analyzed separately'); + return; + } + if (kind !== expectedKind) { + toast( + expectedKind === 'robot' + ? '这里仅接收机器人轨迹;人体动作请使用 Motion 上传区' + : '这里仅接收人体动作;机器人轨迹请使用 Robot 上传区', + true, + ); + markUploadArea(expectedKind, 'err'); + setAnalysisState('failed', 0, 'Selected data does not match this upload area'); + return; + } + if (state.analyzeSource && state.dataKind !== "unknown" && state.dataKind !== kind) { + toast("与当前批次类型不同,请先点「清空批次」再拖入", true); + markUploadArea(expectedKind, 'err'); + setAnalysisState('failed', 0, 'Clear the current batch before switching data types'); + return; + } + + const dropzone = $(uploadAreaIds[expectedKind].dropzone); + markUploadArea(expectedKind, 'busy'); + $("dv-status").textContent = `上传 ${files.length} 个文件…`; + setAnalysisState('uploading', 0, `Uploading ${files.length} files`); + const appendTo = state.analyzeSource || undefined; + syncUserRootField(); + const userRoot = getUserSourceRoot(); + try { + const info = await uploadFilesXHR("/api/dataset/upload", files, { + appendTo, + userSourceRoot: userRoot || undefined, + }); + state.analyzeSource = info.source || ""; + $("dv-source").value = state.analyzeSource; + if (info.user_source_root) setUserSourceRoot(info.user_source_root); + const n = info.clip_count || 0; + if (!n) { + dropzone?.classList.remove("busy"); + dropzone?.classList.add("err"); + toast("未识别到可分析 clip:人体请拖入含 BVH/NPZ/PKL 等的文件夹;机器人请拖入轨迹 CSV/PKL/NPZ 文件夹", true); + $("dv-source-display").textContent = appendTo ? "追加后仍无 clip" : "未识别到 clip"; + $("dv-status").textContent = ""; + setAnalysisState('failed', 0, 'No analyzable clips found'); + return; + } + renderUploadBasket(info); + $("dv-status").textContent = appendTo + ? `追加成功 · 当前共 ${n} clip` + : `加载成功 · ${n} clip`; + toast(appendTo ? `已追加,当前共 ${n} 个 clip` : `已加载 ${n} 个 clip`); + } catch (error) { + dropzone?.classList.remove("busy"); + dropzone?.classList.add("err"); + toast(errorMessage(error), true); + $("dv-status").textContent = "上传失败"; + setAnalysisState('failed', 0, errorMessage(error)); + } +} + +async function pickFolder(expectedKind: UploadDataKind) { + const inp = document.createElement("input"); + inp.type = "file"; inp.multiple = true; inp.webkitdirectory = true; inp.style.display = "none"; + inp.onchange = () => { + const files = Array.from(inp.files || []) as UploadFile[]; + for (const f of files) f._relpath = f.webkitRelativePath || f.name; + document.body.removeChild(inp); + ingestDroppedFiles(files, expectedKind); + }; + document.body.appendChild(inp); + inp.click(); +} + +/** Start a backend analysis job and project its polled progress into React. */ +async function runAnalysis(): Promise { + const { API, toast } = bridge(); + await loadCatalog(); + const source = state.analyzeSource || $("dv-source").value.trim(); + const prog = $("dv-progress"); + prog.style.display = "block"; + const progressBar = prog.querySelector(".bar"); + if (!progressBar) throw new Error('Dataset progress bar is missing'); + progressBar.style.width = "4%"; + $("dv-analyze").disabled = true; + $("dv-status").textContent = "分析中…"; + setAnalysisState('running', 0.04, 'Analyzing'); + try { + const body: { embedding: string; force: boolean; source?: string } = { + embedding: $("dv-embedding").value, + force: $("dv-force").checked, + }; + if (source) body.source = source; + const { job_id } = await API.post("/api/dataset/analyze", body); + let result: JobResult | null = null; + while (true) { + const j = await API.get(`/api/job/${job_id}`); + const progress = j.progress || 0; + progressBar.style.width = `${Math.round(progress * 100)}%`; + $("dv-status").textContent = j.message || "分析中…"; + setAnalysisState('running', progress, j.message || 'Analyzing'); + if (j.status === "done") { result = j.result ?? null; break; } + if (j.status === "error") throw new Error(j.error || "失败"); + await new Promise((r) => setTimeout(r, 400)); + } + if (!result) throw new Error('Analysis completed without a result'); + state.clips = result.clips || []; + state.summary = result.summary || null; + state.analyzeSourceRoot = result.meta?.source_root || state.analyzeSource || ""; + state.embeddingName = result.meta?.embedding || $("dv-embedding")?.value || "handcrafted"; + state.activeTags.clear(); + state.selected.clear(); + state.histBrush = null; + state.catBrush = null; + resetScatterView(false); + $("dv-results").hidden = false; + if ($("dv-results-empty")) $("dv-results-empty").hidden = true; + const resultsStep = $("dv-step-results"); + if (resultsStep instanceof HTMLDetailsElement) resultsStep.open = true; + $("dv-status").textContent = `完成 · ${result.summary?.num_ok || 0} clip`; + updateKindBadge(); + await refreshRobotPreviewUI(); + if (state.dataKind === "mixed") { + toast("检测到人体与机器人混合数据,建议分开目录分析", true); + $("dv-status").textContent += " · ⚠ 混合数据"; + } + buildViewDimOptions(); + recomputeSubset(); + renderAll(); + setAnalysisState('completed', 1, 'Completed'); + } catch (error) { + const message = errorMessage(error); + toast(message, true); + $("dv-status").textContent = "失败:" + message; + setAnalysisState('failed', state.analysisProgress, message); + } finally { + $("dv-analyze").disabled = false; + setTimeout(() => { prog.style.display = "none"; }, 500); + } +} + +// ------------------------------------------------------------------ info panels +function renderTagInfo() { + const box = $("dv-tag-info"); + if (!state.activeTags.size) { box.hidden = true; return; } + box.hidden = false; + const tags = state.catalog?.tags || {}; + const cards = [...state.activeTags].map((t) => { + const info = tags[t] || {}; + const card = document.createElement("div"); + card.className = "dv-info-card"; + card.appendChild(textNodeElement("b", "", info.title || t)); + if (info.desc) card.appendChild(textNodeElement("p", "", info.desc)); + if (info.formula) card.appendChild(textNodeElement("code", "", info.formula)); + return card; + }); + box.replaceChildren(...cards); +} + +function renderMetricInfo() { + const { kind, key } = parseViewDim(); + const c = state.catalog || {}; + const info = kind === "cat" ? (c.categories?.[key] || {}) : (c.metrics?.[key] || {}); + const parts = []; + if (info.desc) { + parts.push(textNodeElement( + "span", + "", + `${info.title || key}${info.unit ? ` (${info.unit})` : ""} — ${info.desc}`, + )); + } else { + parts.push(textNodeElement("span", "", info.title || key)); + } + if (key === "cluster_id" && kind === "cat") { + const cl = c.clustering || {}; + const emb = state.embeddingName === "pae" ? "档B PAE" : "档A 手工特征"; + const detail = document.createElement("div"); + detail.className = "dv-info-detail"; + detail.append( + textNodeElement("b", "", `聚类输入(${emb})`), + document.createTextNode(` ${cl.handcrafted_inputs || info.detail || ""}`), + ); + parts.push(detail); + if (cl.algorithm) parts.push(textNodeElement("div", "dv-info-detail", cl.algorithm)); + } else if (info.detail) { + parts.push(textNodeElement("div", "dv-info-detail", info.detail)); + } + if (info.formula) parts.push(textNodeElement("code", "dv-info-formula", info.formula)); + $("dv-metric-info").replaceChildren(...parts); +} + +function brushBinRange(lay: HistogramLayout | null): { i0: number; i1: number } | null { + if (!lay || lay.kind !== "num" || !state.histBrush) return null; + const { edges, nbins } = lay; + let i0 = nbins, i1 = -1; + for (let i = 0; i < nbins; i++) { + if (edges[i + 1] >= state.histBrush.lo && edges[i] <= state.histBrush.hi) { + i0 = Math.min(i0, i); i1 = Math.max(i1, i); + } + } + return i1 >= i0 ? { i0, i1 } : null; +} + +function formatBrushRange(): string { + if (state.histBrush) { + const lo = state.histBrush.lo; + const hi = state.histBrush.hi; + const fmt = (v: number) => (Math.abs(v) >= 10 || Number.isInteger(v) ? v.toFixed(2) : v.toFixed(3)); + return `${fmt(lo)} – ${fmt(hi)}`; + } + if (state.catBrush?.size) return [...state.catBrush].join(", "); + return ""; +} + +function buildViewDimOptions() { + const sel = $("dv-view-dim"); + sel.innerHTML = ""; + const ogN = document.createElement("optgroup"); + ogN.label = "数值指标"; + for (const k of state.summary?.numeric_keys || []) { + const o = document.createElement("option"); + o.value = `num:${k}`; + o.textContent = state.catalog?.metrics?.[k]?.title || k; + ogN.appendChild(o); + } + sel.appendChild(ogN); + const ogC = document.createElement("optgroup"); + ogC.label = "类别"; + for (const k of CAT_DIMS) { + const o = document.createElement("option"); + o.value = `cat:${k}`; + o.textContent = state.catalog?.categories?.[k]?.title || k; + ogC.appendChild(o); + } + sel.appendChild(ogC); + if ([...sel.options].some((o) => o.value === state.viewDim)) sel.value = state.viewDim; + else if (sel.options.length) state.viewDim = sel.value = sel.options[0].value; +} + +// ------------------------------------------------------------------ histogram +function canvasXY(ev: MouseEvent, canvas: HTMLCanvasElement): { x: number; y: number } { + const rect = canvas.getBoundingClientRect(); + return { + x: (ev.clientX - rect.left) * (canvas.width / rect.width), + y: (ev.clientY - rect.top) * (canvas.height / rect.height), + }; +} + +function binAtX(x: number, lay: HistogramLayout): number { + if (!lay || x < lay.pad.l || x > lay.pad.l + lay.plotW) return -1; + const i = Math.floor((x - lay.pad.l) / lay.bw); + if (lay.kind === "cat") return i >= 0 && i < lay.keys.length ? i : -1; + return i >= 0 && i < lay.nbins ? i : -1; +} + +function applyBinBrush(lay: HistogramLayout, i0: number, i1: number): void { + const a = Math.min(i0, i1), b = Math.max(i0, i1); + if (lay.kind === "cat") { + state.catBrush = new Set(lay.keys.slice(a, b + 1)); + state.histBrush = null; + } else { + state.histBrush = { lo: lay.edges[a], hi: lay.edges[b + 1] }; + state.catBrush = null; + } + recomputeSubset(); + renderAll(); +} + +function renderDistribution() { + const { kind, key } = parseViewDim(); + state.histLayout = null; + if (kind === "cat") renderCategoryBars(key); + else renderNumericHistogram(key); + renderMetricInfo(); +} + +function renderNumericHistogram(metric: string): void { + const canvas = $("dv-hist-canvas"); + const ctx = canvasContext(canvas); + const W = canvas.width, H = canvas.height; + ctx.clearRect(0, 0, W, H); + const hist = state.summary?.histograms?.[metric]; + const info = state.catalog?.metrics?.[metric] || {}; + if (!hist) return; + + const { edges, counts: allCounts, min, max, mean, median } = hist; + const nbins = edges.length - 1; + const tagCounts = new Array(nbins).fill(0); + for (const c of tagFilteredClips()) { + const v = Number(c.metrics?.[metric]); + if (!Number.isFinite(v)) continue; + let b = Math.floor(((v - min) / (max - min)) * nbins); + b = Math.max(0, Math.min(nbins - 1, b)); + tagCounts[b]++; + } + + const pad = { l: 52, r: 16, t: 20, b: 44 }; + const plotW = W - pad.l - pad.r, plotH = H - pad.t - pad.b; + const maxC = Math.max(1, ...allCounts, ...tagCounts); + const gap = 3; + const bw = plotW / nbins; + state.histLayout = { pad, plotW, plotH, bw, nbins, edges, metric, kind: "num", min, max }; + const brushRange = brushBinRange(state.histLayout); + + ctx.strokeStyle = "#e0e0e5"; ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(pad.l, pad.t); ctx.lineTo(pad.l, pad.t + plotH); + ctx.lineTo(pad.l + plotW, pad.t + plotH); ctx.stroke(); + + ctx.fillStyle = "#8e8e93"; ctx.font = "11px -apple-system,sans-serif"; + ctx.textAlign = "right"; + ctx.fillText("clip 数", pad.l - 6, pad.t + 4); + ctx.fillText(String(maxC), pad.l - 6, pad.t + 12); + ctx.textAlign = "left"; + + for (let i = 0; i < nbins; i++) { + const x = pad.l + i * bw + gap / 2; + const w = Math.max(2, bw - gap); + const hAll = (allCounts[i] / maxC) * plotH; + const hTag = (tagCounts[i] / maxC) * plotH; + const yBase = pad.t + plotH; + const inBrush = brushRange && i >= brushRange.i0 && i <= brushRange.i1; + + ctx.fillStyle = inBrush ? "#dce8f8" : "#eef2f7"; + ctx.beginPath(); roundRect(ctx, x, yBase - hAll, w, hAll, 3); ctx.fill(); + + const isHover = state.hoverBin === i; + if (hTag > 0) { + ctx.fillStyle = inBrush ? (isHover ? "#004999" : "#005bb5") : (isHover ? "#0066cc" : "#0a84ff"); + ctx.beginPath(); roundRect(ctx, x, yBase - hTag, w, hTag, 3); ctx.fill(); + } + if (inBrush) { + ctx.strokeStyle = "#0a84ff"; ctx.lineWidth = 1.5; + ctx.beginPath(); roundRect(ctx, x, yBase - Math.max(hAll, hTag, 2), w, Math.max(hAll, hTag, 2), 3); ctx.stroke(); + } + } + + if (brushRange) { + const x0 = pad.l + brushRange.i0 * bw; + const x1 = pad.l + (brushRange.i1 + 1) * bw; + ctx.fillStyle = "rgba(10,132,255,0.06)"; + ctx.fillRect(x0, pad.t, x1 - x0, plotH); + ctx.setLineDash([5, 4]); + ctx.strokeStyle = "#0a84ff"; + ctx.lineWidth = 2; + ctx.strokeRect(x0 + 1, pad.t + 1, x1 - x0 - 2, plotH - 2); + ctx.setLineDash([]); + ctx.fillStyle = "#0a84ff"; + ctx.font = "bold 10px -apple-system,sans-serif"; + ctx.textAlign = "center"; + ctx.fillText("刷选范围", (x0 + x1) / 2, pad.t + 12); + ctx.textAlign = "left"; + } + + ctx.fillStyle = "#6e6e73"; ctx.font = "10px -apple-system,sans-serif"; + ctx.textAlign = "center"; + const tickStep = Math.max(1, Math.floor(nbins / 5)); + for (let i = 0; i < nbins; i += tickStep) { + ctx.fillText(edges[i].toFixed(1), pad.l + i * bw + bw / 2, H - 22); + } + ctx.fillText(max.toFixed(1), pad.l + plotW, H - 22); + ctx.textAlign = "left"; + ctx.fillStyle = "#1d1d1f"; ctx.font = "12px -apple-system,sans-serif"; + ctx.fillText(info.title || metric, pad.l, 14); + + $("dv-hist-stats").textContent = brushRange + ? `刷选 ${formatBrushRange()} · μ ${mean} · med ${median}` + : `μ ${mean} · med ${median}`; + $("dv-hist-axis-hint").textContent = brushRange + ? "虚线框 = 刷选范围(联动散点);深蓝柱 = 框内 bin" + : "拖拽柱形刷选 · 浅灰=全库 · 蓝色=Stage I 后"; +} + +function renderCategoryBars(dim: string): void { + const canvas = $("dv-hist-canvas"); + const ctx = canvasContext(canvas); + const W = canvas.width, H = canvas.height; + ctx.clearRect(0, 0, W, H); + const info = state.catalog?.categories?.[dim] || {}; + const counts: Record = {}; + for (const c of tagFilteredClips()) { + const k = clipCategory(c, dim); + counts[k] = (counts[k] || 0) + 1; + } + const keys = Object.keys(counts).sort((a, b) => counts[b] - counts[a]); + if (!keys.length) return; + + const pad = { l: 52, r: 16, t: 20, b: 52 }; + const plotW = W - pad.l - pad.r, plotH = H - pad.t - pad.b; + const maxC = Math.max(1, ...Object.values(counts)); + const gap = 6; + const bw = plotW / keys.length; + state.histLayout = { pad, plotW, plotH, bw, keys, counts, dim, kind: "cat" }; + + ctx.strokeStyle = "#e0e0e5"; + ctx.beginPath(); + ctx.moveTo(pad.l, pad.t); ctx.lineTo(pad.l, pad.t + plotH); + ctx.lineTo(pad.l + plotW, pad.t + plotH); ctx.stroke(); + + keys.forEach((k, i) => { + const x = pad.l + i * bw + gap / 2; + const w = Math.max(4, bw - gap); + const h = (counts[k] / maxC) * plotH; + const sel = state.catBrush?.has(k); + ctx.fillStyle = sel ? "#005bb5" : (state.hoverBin === i ? "#7eb8f7" : "#c9def8"); + ctx.beginPath(); roundRect(ctx, x, pad.t + plotH - h, w, h, 4); ctx.fill(); + if (sel) { + ctx.strokeStyle = "#0a84ff"; ctx.lineWidth = 2; ctx.setLineDash([4, 3]); + ctx.strokeRect(x - 1, pad.t + plotH - h - 1, w + 2, h + 2); + ctx.setLineDash([]); + } + ctx.fillStyle = "#444"; ctx.font = "10px -apple-system,sans-serif"; + ctx.textAlign = "center"; + const label = k.length > 8 ? k.slice(0, 7) + "…" : k; + ctx.fillText(label, x + w / 2, pad.t + plotH + 14); + ctx.fillText(String(counts[k]), x + w / 2, pad.t + plotH + 28); + }); + ctx.textAlign = "left"; + ctx.fillStyle = "#1d1d1f"; ctx.font = "12px -apple-system,sans-serif"; + ctx.fillText(info.title || dim, pad.l, 14); + $("dv-hist-stats").textContent = state.catBrush?.size + ? `刷选 ${formatBrushRange()}` + : ""; + $("dv-hist-axis-hint").textContent = state.catBrush?.size + ? "虚线框 = 已选类别(联动散点)" + : "点击或拖拽柱形刷选散点"; +} + +// ------------------------------------------------------------------ scatter +type ScatterPoint = { clip: DatasetClip; px: number; py: number } +type ScatterBounds = { minX: number; maxX: number; minY: number; maxY: number } + +let scatterPts: ScatterPoint[] = []; +let scatterBounds: ScatterBounds | null = null; + +function resetScatterView(render = true): void { + state.scatterView = { scale: 1, panX: 0, panY: 0, dragging: false, dragMoved: false, lastX: 0, lastY: 0 }; + if (render) renderScatter(); +} + +function worldToScreen(x: number, y: number, W: number, H: number): { px: number; py: number } { + const b = scatterBounds; + if (!b) return { px: W / 2, py: H / 2 }; + const sv = state.scatterView; + const pad = 36; + const sx = (W - 2 * pad) / Math.max(1e-6, b.maxX - b.minX); + const sy = (H - 2 * pad) / Math.max(1e-6, b.maxY - b.minY); + const cx = W / 2, cy = H / 2; + let px = pad + (x - b.minX) * sx; + let py = H - (pad + (y - b.minY) * sy); + px = (px - cx) * sv.scale + cx + sv.panX; + py = (py - cy) * sv.scale + cy + sv.panY; + return { px, py }; +} + +function drawScatterNode( + ctx: CanvasRenderingContext2D, + px: number, + py: number, + color: string, + opts: { hover?: boolean; selected?: boolean; subset?: boolean; dimmed?: boolean } = {}, +): void { + const { hover, selected, subset, dimmed } = opts; + const baseR = hover ? 7.5 : 6; + ctx.save(); + ctx.globalAlpha = dimmed ? (hover ? 0.55 : 0.14) : 1; + + if (hover) { + ctx.shadowBlur = 18; + ctx.shadowColor = rgba(color, 0.55); + } else if (subset && !dimmed) { + ctx.shadowBlur = 10; + ctx.shadowColor = rgba("#ff9f0a", 0.45); + } + + // soft halo + ctx.beginPath(); + ctx.arc(px, py, baseR + 3, 0, Math.PI * 2); + ctx.fillStyle = rgba(color, hover ? 0.28 : 0.16); + ctx.fill(); + + // radial fill — lighter center like graph nodes + const grad = ctx.createRadialGradient(px - baseR * 0.25, py - baseR * 0.3, 0, px, py, baseR); + grad.addColorStop(0, rgba(color, 0.95)); + grad.addColorStop(0.55, color); + grad.addColorStop(1, rgba(color, 0.82)); + ctx.beginPath(); + ctx.arc(px, py, baseR, 0, Math.PI * 2); + ctx.fillStyle = grad; + ctx.fill(); + + ctx.shadowBlur = 0; + ctx.strokeStyle = hover ? "#fff" : rgba("#fff", 0.88); + ctx.lineWidth = hover ? 2.5 : 1.8; + ctx.stroke(); + + if (subset && !dimmed) { + ctx.strokeStyle = "#ff9f0a"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(px, py, baseR + 5, 0, Math.PI * 2); + ctx.stroke(); + } + if (selected) { + ctx.strokeStyle = "#1d1d1f"; + ctx.lineWidth = 2.2; + ctx.beginPath(); + ctx.arc(px, py, baseR + 1.5, 0, Math.PI * 2); + ctx.stroke(); + } + ctx.restore(); +} + +function updateScatterTooltip( + clip: DatasetClip | null | undefined, + px = 0, + py = 0, + dimmed = false, +): void { + const tip = $("dv-scatter-tip"); + if (!tip) return; + if (!clip) { + tip.hidden = true; + return; + } + tip.hidden = false; + const m = clip.metrics || {}; + const tags = []; + if (state.subsetIds.has(clip.clip_id)) tags.push("推荐"); + if (state.selected.has(clip.clip_id) && isManualSelectable(clip.clip_id)) tags.push("手动"); + if (dimmed) tags.push("刷选外"); + const suffix = tags.length ? ` · ${tags.join("/")}` : ""; + tip.textContent = `${clip.clip_id} · C ${m.complexity ?? "—"}${suffix}`; + tip.style.left = `${px}px`; + tip.style.top = `${py + 14}px`; + tip.style.transform = "translate(-50%, 0)"; +} + +function hitScatterPoint(x: number, y: number, maxD2 = 576): ScatterPoint | null { + let best: ScatterPoint | null = null, bestD = maxD2; + for (const p of scatterPts) { + const d = (p.px - x) ** 2 + (p.py - y) ** 2; + if (d < bestD) { bestD = d; best = p; } + } + return best; +} + +function renderScatter() { + const canvas = $("dv-scatter-canvas"); + const ctx = canvasContext(canvas); + const W = canvas.width, H = canvas.height; + ctx.clearRect(0, 0, W, H); + scatterPts = []; + const all = okClips().filter( + (clip): clip is DatasetClip & { scatter: [number, number] } => Array.isArray(clip.scatter), + ); + if (!all.length) { + ctx.fillStyle = "#999"; ctx.fillText("无散点", 20, 30); + updateScatterTooltip(null); + return; + } + + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + for (const c of all) { + minX = Math.min(minX, c.scatter[0]); maxX = Math.max(maxX, c.scatter[0]); + minY = Math.min(minY, c.scatter[1]); maxY = Math.max(maxY, c.scatter[1]); + } + scatterBounds = { minX, maxX, minY, maxY }; + // Preserve the complete embedding context: filtered points remain visible but + // are dimmed instead of disappearing and rescaling the plot unexpectedly. + const visIds = new Set(visibleClips().map((c) => c.clip_id)); + const colorMap = new Map(); + let hoverPt: ScatterPoint | null = null; + + for (const c of all) { + const { px, py } = worldToScreen(c.scatter[0], c.scatter[1], W, H); + scatterPts.push({ clip: c, px, py }); + const inVis = visIds.has(c.clip_id); + const color = clusterColor(c.cluster_id, colorMap); + const isHover = state.hoverClipId === c.clip_id; + if (isHover) hoverPt = { clip: c, px, py }; + drawScatterNode(ctx, px, py, color, { + hover: isHover, + selected: state.selected.has(c.clip_id) && isManualSelectable(c.clip_id), + subset: state.subsetIds.has(c.clip_id), + dimmed: !inVis, + }); + } + + updateScatterTooltip( + hoverPt?.clip, + hoverPt?.px, + hoverPt?.py, + Boolean(hoverPt && !visIds.has(hoverPt.clip.clip_id)), + ); + + const leg = $("dv-legend"); + const clusters = [...colorMap.entries()].sort((a, b) => a[0].localeCompare(b[0])); + const legendItem = (label: string, color: string, hint = false): HTMLSpanElement => { + const item = textNodeElement("span", `dv-legend-item${hint ? " hint" : ""}`, label); + if (color) { + const marker = document.createElement("i"); + marker.style.background = color; + item.prepend(marker); + } + return item; + }; + const items = [ + legendItem("推荐子集", "#ff9f0a"), + legendItem("手动补选", "#1d1d1f"), + legendItem("淡色=刷选外", "", true), + ...clusters.slice(0, 6).map(([key, color]) => legendItem(`簇 ${key}`, color)), + ]; + if (clusters.length > 6) items.push(legendItem(`+${clusters.length - 6}`, "", true)); + leg.replaceChildren(...items); +} + +function renderClipList() { + const vis = visibleClips(); + $("dv-list-count").textContent = `${vis.length}`; + const box = $("dv-clip-list"); + box.replaceChildren(); + for (const c of vis.slice(0, 60)) { + const row = document.createElement("div"); + row.className = "dv-clip-row" + + (state.selected.has(c.clip_id) && isManualSelectable(c.clip_id) ? " sel" : "") + + (state.subsetIds.has(c.clip_id) ? " subset" : ""); + const m = c.metrics || {}; + const playButton = textNodeElement("button", "dv-cr-play", "▶"); + playButton.type = "button"; + row.append( + textNodeElement("span", "dv-cr-id", c.clip_id), + textNodeElement("span", "dv-cr-meta", `${m.s_phy ?? "—"} · ${m.complexity ?? "—"}`), + playButton, + ); + row.onclick = (ev) => { + if (ev.target instanceof Element && ev.target.closest(".dv-cr-play")) { + showDetail(c); + previewClip(c); + } else toggleSelect(c.clip_id); + }; + box.appendChild(row); + } +} + +function renderOverview() { + const s = state.summary; + if (!s) return; + const stat = (value: number, label: string, accent = false): HTMLDivElement => { + const pill = document.createElement("div"); + pill.className = `dv-stat-pill${accent ? " accent" : ""}`; + pill.append(textNodeElement("b", "", value), textNodeElement("span", "", label)); + return pill; + }; + $("dv-overview").replaceChildren( + stat(s.num_ok, "clip"), + stat(tagFilteredClips().length, "Stage I"), + stat(visibleClips().length, "刷选"), + stat(state.subsetIds.size, "推荐", true), + ); +} + +function renderChips() { + const counts = state.summary?.tag_counts || {}; + const box = $("dv-chips"); + box.replaceChildren(); + const groups: Array<[string, string[]]> = [ + ["质量", QUALITY_TAGS.filter((t) => counts[t])], + ["动态", DYN_TAGS.filter((t) => counts[t])], + ["其他", Object.keys(counts).filter((t) => !QUALITY_TAGS.includes(t) && !DYN_TAGS.includes(t))], + ]; + for (const [label, tags] of groups) { + if (!tags.length) continue; + const hdr = document.createElement("div"); + hdr.className = "dv-chip-group-label"; + hdr.textContent = label; + box.appendChild(hdr); + for (const tag of tags) { + const chip = document.createElement("button"); + chip.type = "button"; + chip.className = "dv-chip" + (state.activeTags.has(tag) ? " on" : ""); + chip.append( + document.createTextNode(tag), + textNodeElement("span", "dv-chip-n", counts[tag]), + ); + chip.onclick = () => { + state.activeTags.has(tag) ? state.activeTags.delete(tag) : state.activeTags.add(tag); + recomputeSubset(); renderAll(); + }; + box.appendChild(chip); + } + } + renderTagInfo(); +} + +function renderSelbar() { + const sub = state.subsetIds.size; + const manual = manualSelectedIds().length; + $("dv-selbar").textContent = + `推荐 ${sub} 个` + (manual ? ` · 手动补选 ${manual} 个(不含推荐)` : ""); +} + +function renderAll() { + renderOverview(); + renderChips(); + renderDistribution(); + renderScatter(); + renderClipList(); + renderSelbar(); + updateKindBadge(); +} + +function showDetail(clip: DatasetClip): void { + const m = clip.metrics || {}; + $("dv-clip-detail").textContent = + `${clip.clip_id} · S_phy ${m.s_phy ?? "—"} · C(x) ${m.complexity ?? "—"}`; +} + +function toggleSelect(id: string): void { + if (!isManualSelectable(id)) { + bridge().toast?.("该 clip 已在推荐子集中,无需手动补选", false); + return; + } + state.selected.has(id) ? state.selected.delete(id) : state.selected.add(id); + renderScatter(); renderClipList(); renderSelbar(); +} + +// ------------------------------------------------------------------ hist interaction +function setupHistInteraction() { + const canvas = $("dv-hist-canvas"); + + canvas.addEventListener("mousemove", (ev) => { + const lay = state.histLayout; + if (!lay) return; + const { x } = canvasXY(ev, canvas); + const bin = binAtX(x, lay); + if (bin !== state.hoverBin) { + state.hoverBin = bin; + renderDistribution(); + } + if (state.histDrag.active && bin >= 0) { + applyBinBrush(lay, state.histDrag.startBin, bin); + } + }); + + canvas.addEventListener("mousedown", (ev) => { + const lay = state.histLayout; + if (!lay) return; + const bin = binAtX(canvasXY(ev, canvas).x, lay); + if (bin < 0) return; + state.histDrag = { active: true, startBin: bin }; + if (!ev.shiftKey) applyBinBrush(lay, bin, bin); + }); + + window.addEventListener("mouseup", () => { + state.histDrag.active = false; + }); + + canvas.addEventListener("mouseleave", () => { + if (state.hoverBin >= 0) { state.hoverBin = -1; renderDistribution(); } + }); +} + +function setupScatterNav() { + const canvas = $("dv-scatter-canvas"); + canvas.addEventListener("wheel", (ev) => { + ev.preventDefault(); + state.scatterView.scale = Math.max(0.25, Math.min(10, + state.scatterView.scale * (ev.deltaY > 0 ? 0.9 : 1.1))); + renderScatter(); + }, { passive: false }); + + canvas.addEventListener("mousemove", (ev) => { + if (state.scatterView.dragging) return; + const { x, y } = canvasXY(ev, canvas); + const hit = hitScatterPoint(x, y); + const id = hit ? hit.clip.clip_id : null; + if (id !== state.hoverClipId) { + state.hoverClipId = id; + renderScatter(); + } + canvas.style.cursor = id ? "pointer" : "grab"; + }); + + canvas.addEventListener("mouseleave", () => { + if (state.hoverClipId) { + state.hoverClipId = null; + renderScatter(); + } + canvas.style.cursor = "grab"; + }); + + canvas.addEventListener("mousedown", (ev) => { + if (ev.button !== 0) return; + state.scatterView.dragging = true; + state.scatterView.dragMoved = false; + state.scatterView.lastX = ev.clientX; + state.scatterView.lastY = ev.clientY; + canvas.style.cursor = "grabbing"; + }); + + window.addEventListener("mousemove", (ev) => { + if (!state.scatterView.dragging) return; + const dx = ev.clientX - state.scatterView.lastX; + const dy = ev.clientY - state.scatterView.lastY; + if (Math.abs(dx) + Math.abs(dy) > 4) state.scatterView.dragMoved = true; + state.scatterView.panX += dx; + state.scatterView.panY += dy; + state.scatterView.lastX = ev.clientX; + state.scatterView.lastY = ev.clientY; + renderScatter(); + }); + + window.addEventListener("mouseup", () => { + if (state.scatterView.dragging) { + state.scatterView.dragging = false; + canvas.style.cursor = state.hoverClipId ? "pointer" : "grab"; + } + }); + + canvas.addEventListener("click", (ev) => { + if (state.scatterView.dragMoved) return; + const { x, y } = canvasXY(ev, canvas); + const hit = hitScatterPoint(x, y); + if (!hit) return; + const id = hit.clip.clip_id; + showDetail(hit.clip); + previewClip(hit.clip); + if (!isManualSelectable(id)) return; + if (ev.shiftKey) { + toggleSelect(id); + return; + } + if (!state.selected.has(id)) { + state.selected.clear(); + state.selected.add(id); + } else if (state.selected.size === 1) { + state.selected.delete(id); + } else { + state.selected.clear(); + state.selected.add(id); + } + renderScatter(); + renderClipList(); + renderSelbar(); + }); +} + +// Uploaded clips may point at a server-side temporary directory. Exports include +// this user-supplied real root so the backend can create a reusable manifest. +function getUserSourceRoot() { + return ($("dv-user-source-root")?.value || localStorage.getItem(DV_USER_ROOT_KEY) || "").trim(); +} + +function setUserSourceRoot(v: unknown): void { + const val = String(v || "").trim(); + const inp = $("dv-user-source-root"); + if (inp) inp.value = val; + if (val) localStorage.setItem(DV_USER_ROOT_KEY, val); + else localStorage.removeItem(DV_USER_ROOT_KEY); +} + +function syncUserRootField() { + const inp = $("dv-user-source-root"); + if (inp && !inp.value.trim()) { + const saved = localStorage.getItem(DV_USER_ROOT_KEY); + if (saved) inp.value = saved; + } +} + +function exportManifestPayload(ids: string[], extra: Record = {}) { + return { + clips: state.clips, + ids, + analyze_source: state.analyzeSourceRoot || state.analyzeSource || "", + user_source_root: getUserSourceRoot(), + format: "json", + ...extra, + }; +} + +function looksLikeTempSourcePath(p: unknown): boolean { + return /\/tmp\/|hhtools_web_up/i.test(String(p || "")); +} + +function needsUserSourceRoot(ids: string[]): boolean { + return state.clips.some((c) => ids.includes(c.clip_id) && looksLikeTempSourcePath(c.source_path)); +} + +function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.style.display = "none"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.setTimeout(() => URL.revokeObjectURL(url), 2000); +} + +async function exportManifest(ids: string[], filename: string): Promise { + const { toast } = bridge(); + if (!ids.length) { toast("没有可导出的 clip", true); return false; } + const needsRoot = needsUserSourceRoot(ids); + const userRoot = getUserSourceRoot(); + if (needsRoot && !userRoot) { + toast("请先填写「本地数据目录」(如 /home/motions),manifest 才能写入真实路径", true); + $("dv-user-source-root")?.focus(); + return false; + } + const payload = exportManifestPayload(ids); + const r = await fetch("/api/dataset/export_manifest", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!r.ok) throw new Error("导出失败"); + const blob = await r.blob(); + downloadBlob(blob, filename); + return true; +} + +function addHumanToBasket() { + if ($("dv-human-basket")?.disabled) return; + const ids = new Set(exportTargetIds()); + const clips = okClips().filter((c) => ids.has(c.clip_id) && c.source_kind !== "robot"); + if (!clips.length) { bridge().toast?.("没有可加入的人体 clip", true); return; } + bridge().addToBasket?.(clips.map(entryFromClip)); +} + +async function exportRobotData() { + if ($("dv-export-robot")?.disabled) return; + const ids = exportTargetIds().filter((id) => { + const c = state.clips.find((x) => x.clip_id === id); + return c?.source_kind === "robot"; + }); + if (!ids.length) { + bridge().toast?.("没有可导出的机器人 clip", true); + return; + } + const packFiles = $("dv-robot-export-files")?.checked !== false; + const { toast } = bridge(); + $("dv-export-robot").disabled = true; + try { + if (!packFiles) { + const ok = await exportManifest(ids, "robot_subset_manifest.json"); + if (ok) toast(`已导出 ${ids.length} 条机器人 clip 清单 (JSON)`); + return; + } + const r = await fetch("/api/dataset/export_robot_zip", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(exportManifestPayload(ids)), + }); + if (!r.ok) { + let msg = "打包失败"; + try { + const j = await r.json(); + msg = j.detail || msg; + } catch { + msg = (await r.text()) || msg; + } + throw new Error(msg); + } + const blob = await r.blob(); + const cd = r.headers.get("Content-Disposition") || ""; + const m = cd.match(/filename="?([^";]+)"?/); + const filename = m ? m[1] : "robot_subset_export.zip"; + downloadBlob(blob, filename); + toast(`已打包 ${ids.length} 个机器人 clip 文件夹`); + } catch (error) { + toast(errorMessage(error), true); + } finally { + updateKindBadge(); + } +} + +function setupDropzone( + id: 'dv-dropzone' | 'dv-dropzone-robot', + expectedKind: UploadDataKind, +): void { + const el = $(id); + if (!el) return; + ["dragenter", "dragover"].forEach((ev) => + el.addEventListener(ev, (e) => { e.preventDefault(); el.classList.add("hover"); })); + ["dragleave", "drop"].forEach((ev) => + el.addEventListener(ev, (e) => { e.preventDefault(); el.classList.remove("hover"); })); + el.addEventListener("drop", async (event) => { + const dropEvent = event as DragEvent; + if (!dropEvent.dataTransfer) return; + const files: UploadFile[] = []; + const walks: Promise[] = []; + for (const it of dropEvent.dataTransfer.items) { + const entry = it.webkitGetAsEntry?.(); + if (entry) walks.push(walkEntry(entry, files)); + else { + const file = it.getAsFile(); + if (file) files.push(file as UploadFile); + } + } + await Promise.all(walks); + if (files.length) ingestDroppedFiles(files, expectedKind); + }); +} + +/** + * Bind once after React has mounted the compatibility DOM. This module currently + * has no dispose lifecycle, so it relies on ES modules being evaluated once. + */ +function bind() { + loadCatalog(); + syncUserRootField(); + updateKindBadge(); + setupDropzone('dv-dropzone', 'human'); + setupDropzone('dv-dropzone-robot', 'robot'); + setupHistInteraction(); + setupScatterNav(); + $("dv-pick-folder")?.addEventListener("click", () => void pickFolder('human')); + $("dv-pick-robot-folder")?.addEventListener("click", () => void pickFolder('robot')); + $("dv-clear-upload")?.addEventListener("click", clearUploadBasket); + $("dv-analyze")?.addEventListener("click", runAnalysis); + $("dv-clear-tags")?.addEventListener("click", () => { + state.activeTags.clear(); recomputeSubset(); renderAll(); + }); + $("dv-clear-brush")?.addEventListener("click", () => { + state.histBrush = null; state.catBrush = null; recomputeSubset(); renderAll(); + }); + document.querySelectorAll('input[name="dv-tagmode"]').forEach((r) => { + r.addEventListener("change", () => { + const selected = document.querySelector('input[name="dv-tagmode"]:checked'); + if (selected?.value === 'and' || selected?.value === 'or') state.tagMode = selected.value; + recomputeSubset(); renderAll(); + }); + }); + $("dv-view-dim")?.addEventListener("change", (event) => { + state.viewDim = (event.currentTarget as HTMLSelectElement).value; + state.histBrush = null; state.catBrush = null; + recomputeSubset(); renderAll(); + }); + $("dv-scatter-reset")?.addEventListener("click", () => resetScatterView()); + $("dv-subset-ratio")?.addEventListener("input", (event) => { + $("dv-subset-pct").textContent = (event.currentTarget as HTMLInputElement).value + "%"; + scheduleSubset(); + }); + $("dv-subset-alpha")?.addEventListener("input", (event) => { + const value = (event.currentTarget as HTMLInputElement).value; + $("dv-subset-alpha-val").textContent = (parseInt(value, 10) / 100).toFixed(2); + scheduleSubset(); + }); + $("dv-human-basket")?.addEventListener("click", addHumanToBasket); + $("dv-export-robot")?.addEventListener("click", exportRobotData); + $("dv-robot-export-files")?.addEventListener("change", syncRobotExportLabel); + $("dv-user-source-root")?.addEventListener("change", (event) => { + setUserSourceRoot((event.currentTarget as HTMLInputElement).value); + }); + $("dv-user-source-root")?.addEventListener("input", (event) => { + setUserSourceRoot((event.currentTarget as HTMLInputElement).value); + }); + $("dv-export-json")?.addEventListener("click", async () => { + try { + await exportManifest(exportTargetIds(), "dataset_manifest.json"); + } catch (error) { bridge().toast(errorMessage(error), true); } + }); + $("dv-clear-sel")?.addEventListener("click", () => { + state.selected.clear(); renderScatter(); renderClipList(); renderSelbar(); + }); + $("dv-robot-select")?.addEventListener("change", (event) => { + state.previewRobot = (event.currentTarget as HTMLSelectElement).value; + }); + document.querySelector('.nav-item[data-panel="dataset-viz"]')?.addEventListener("click", () => { + const root = bridge().getLibrarySourceRoot(); + if (root && $("dv-drop-hint")) $("dv-drop-hint").textContent = `留空 = ${root}`; + }); + emitAnalysisState(); +} + +if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", bind); +else bind(); + +// Mark this file as an ES module so Vite can load it after the React tree is mounted. +export {}; diff --git a/hhtools/web/frontend/src/runtime/robot-library-catalog.ts b/hhtools/web/frontend/src/runtime/robot-library-catalog.ts new file mode 100644 index 00000000..d9af28e5 --- /dev/null +++ b/hhtools/web/frontend/src/runtime/robot-library-catalog.ts @@ -0,0 +1,65 @@ +/** + * Product metadata for robots bundled and supported by HHTools. The backend is + * still authoritative for availability and deletability; this frontend catalog + * contributes only curated labels and artwork. + */ + +/** The generic HHTools mark remains the fallback for user-imported robots. */ +export const DEFAULT_ROBOT_LIBRARY_ICON = "./hhtools-robot.svg" + +export interface CuratedRobotLibraryItem { + en: string + zh: string + icon: string +} + +/** + * Product-curated robots shown as built-in in the local Robot Library. + * + * Keep this catalog limited to models deliberately supported by HHTools. A + * robot uploaded by a user must not gain built-in status merely because its + * display name resembles one of these entries. + */ +export const CURATED_ROBOT_LIBRARY_ITEMS: Readonly> = { + g1_29dof: { + en: "Unitree G1", + zh: "宇树 G1", + icon: "./robot-icons/unitree-g1.webp", + }, + roboto_origin: { + en: "ROBOTO_ORIGIN (RPO)", + zh: "ROBOTO_ORIGIN (RPO)", + icon: "./robot-icons/roboto-origin.webp", + }, + agibot_x2_ultra: { + en: "AgiBot X2", + zh: "智元 X2", + icon: "./robot-icons/agibot-x2.webp", + }, + asimov_1: { + en: "Asimov 1", + zh: "Asimov 1", + icon: "./robot-icons/asimov-1.webp", + }, + fourier_gr2: { + en: "Fourier GR-2", + zh: "傅利叶 GR-2", + icon: "./robot-icons/fourier-gr2.webp", + }, + berkeley_humanoid_lite: { + en: "Berkeley Humanoid Lite", + zh: "伯克利 Humanoid Lite", + icon: "./robot-icons/berkeley-humanoid-lite.webp", + }, +} + +export function curatedRobotLibraryItem(name: string): CuratedRobotLibraryItem | undefined { + // Do not read inherited keys such as "constructor" as robot identifiers. + return Object.prototype.hasOwnProperty.call(CURATED_ROBOT_LIBRARY_ITEMS, name) + ? CURATED_ROBOT_LIBRARY_ITEMS[name] + : undefined +} + +export function robotLibraryIcon(name: string): string { + return curatedRobotLibraryItem(name)?.icon ?? DEFAULT_ROBOT_LIBRARY_ICON +} diff --git a/hhtools/web/frontend/src/runtime/robot-library-order.ts b/hhtools/web/frontend/src/runtime/robot-library-order.ts new file mode 100644 index 00000000..69e547f4 --- /dev/null +++ b/hhtools/web/frontend/src/runtime/robot-library-order.ts @@ -0,0 +1,56 @@ +/** + * Pure presentation ordering for Robot Library summaries. It stays separate + * from the catalog and backend flags so display priority cannot accidentally + * grant built-in status or change whether a robot may be deleted. + */ + +/** Minimal robot summary shape needed by the Robot Library sorter. */ +interface NamedRobotSummary { + name: string +} + +/** + * Product-level placement in the Robot Library. + * + * This is deliberately separate from the built-in/deletable classification: + * placing an imported robot near the top must not silently turn it into a + * bundled model or hide its delete action. + */ +const PINNED_FIRST_ORDER: Readonly> = { + g1_29dof: 0, + roboto_origin: 1, + agibot_x2_ultra: 2, +} + +/** Models requested at the end of the Library, independent of UI language. */ +const PINNED_LAST = new Set(["berkeley_humanoid_lite"]) + +function pinnedFirstOrder(name: string): number | undefined { + return Object.prototype.hasOwnProperty.call(PINNED_FIRST_ORDER, name) + ? PINNED_FIRST_ORDER[name] + : undefined +} + +/** Return a new three-part ordering: pinned first, localized, then pinned last. */ +export function sortRobotLibrarySummaries( + summaries: readonly T[], + labelFor: (summary: T) => string, +): T[] { + // Sort a copy: API responses may also be observed by workflow selectors. + return [...summaries].sort((left, right) => { + const leftPinned = pinnedFirstOrder(left.name) + const rightPinned = pinnedFirstOrder(right.name) + + if (leftPinned != null || rightPinned != null) { + if (leftPinned == null) return 1 + if (rightPinned == null) return -1 + return leftPinned - rightPinned + } + + const leftLast = PINNED_LAST.has(left.name) + const rightLast = PINNED_LAST.has(right.name) + if (leftLast !== rightLast) return leftLast ? 1 : -1 + + return labelFor(left).localeCompare(labelFor(right)) + }) +} diff --git a/hhtools/web/frontend/src/runtime/tutorial.ts b/hhtools/web/frontend/src/runtime/tutorial.ts new file mode 100644 index 00000000..e5d36ecc --- /dev/null +++ b/hhtools/web/frontend/src/runtime/tutorial.ts @@ -0,0 +1,439 @@ +/** + * Imperative overlay adapter for the first-run guide. The step definitions are + * declarative, but highlighting and positioning remain here because they must + * measure DOM nodes after React has laid out the requested workspace panel. + * React owns the anchors; this module temporarily reveals them and restores + * their previous state when a step is left. + */ + +const STORAGE_KEY = "hhtools.web.tutorial.v2.seen"; +const LEGACY_DONE_STORAGE_KEY = "hhtools.web.tutorial.v1.done"; + +type ToastFunction = (message: string, isError?: boolean) => void; +type TourPlacement = "top" | "right" | "bottom" | "left"; +type TutorialStorage = Pick; + +interface LocalizedCopy { + en: string; + zh: string; +} + +interface TourStepContext { + revealViewHud: (visible: boolean) => void; + revealExportCard: (visible: boolean) => void; + revealDetails: (detailsId: string, visible: boolean) => void; +} + +interface TourStep { + id: string; + panel: string; + anchor: string; + title: LocalizedCopy; + body: LocalizedCopy; + placement: TourPlacement; + last?: boolean; + beforeShow?: (context: TourStepContext) => void; + afterLeave?: (context: TourStepContext) => void; +} + +function copy(en: string, zh: string): LocalizedCopy { + return { en, zh }; +} + +function localized(value: LocalizedCopy): string { + return document.documentElement.lang.toLowerCase().startsWith("zh") ? value.zh : value.en; +} + +function browserStorage(): TutorialStorage | undefined { + try { + return window.localStorage; + } catch { + return undefined; + } +} + +/** + * Treat the legacy completion flag as seen so existing users do not receive + * the revised first-run guide again after upgrading. + */ +export function hasSeenFirstRunTutorial(storage: TutorialStorage | undefined = browserStorage()): boolean { + if (!storage) return false; + try { + return storage.getItem(STORAGE_KEY) === "1" || storage.getItem(LEGACY_DONE_STORAGE_KEY) === "1"; + } catch { + return false; + } +} + +/** Record the automatic guide as soon as it is scheduled, not only when it finishes. */ +export function markFirstRunTutorialSeen(storage: TutorialStorage | undefined = browserStorage()): void { + if (!storage) return; + try { + storage.setItem(STORAGE_KEY, "1"); + } catch { + // Storage can be unavailable in a private or restricted renderer. + } +} + +/** + * Ordered product journey; selectors are part of the React/runtime DOM contract. + * Hooks that reveal an element must restore it with the matching leave hook. + */ +const STEPS: readonly TourStep[] = [ + { + id: "welcome", + panel: "motion", + anchor: "#topbar", + title: copy("1. Welcome to hhtools", "1. 欢迎使用 hhtools"), + body: copy( + "This guide introduces the workspace in its recommended order: Motion → Robot → Calibration → Retarget → Preview → Export. Use the top menu for application commands and the left navigation to switch assets, workflows, and analysis tools.", + "本教程按推荐顺序介绍工作区:动作 → 机器人 → 标定 → Retarget → 预览 → 导出。顶部菜单用于应用命令,左侧导航用于切换资产、工作流与分析工具。", + ), + placement: "bottom", + }, + { + id: "motion", + panel: "motion", + anchor: "#tour-motion-import", + title: copy("2. Import motion", "2. 导入动作"), + body: copy( + "Choose the matching motion profile, then import a file or folder:
mimic: BVH / GLB / NPZ and common motion datasets
intermimic: human-object interaction clips
meshmimic: terrain-aware motion clips
You can also drop compatible data directly onto the 3D stage.", + "先选择对应的动作类型,再导入文件或文件夹:
mimic:BVH / GLB / NPZ 与常见动作数据集
intermimic:人体与物体交互动作
meshmimic:包含地形的动作
也可以把兼容数据直接拖到中间 3D 舞台。", + ), + placement: "left", + }, + { + id: "motion-library", + panel: "motion", + anchor: "#tour-motion-library", + title: copy("3. Reuse the Motion Library", "3. 复用动作资源库"), + body: copy( + "The Motion Library lists reusable clips without requiring another upload. Filter by motion type, search by name, or choose a different local library directory. Select a row to load it into the stage.", + "动作资源库会列出可直接复用的 clip,无需重复上传。你可以按类型筛选、按名称搜索,或切换本地资源库目录;选择一行即可加载到舞台。", + ), + placement: "left", + }, + { + id: "robot", + panel: "robot-assets", + anchor: "#tour-robot-import", + title: copy("4. Import or load a robot", "4. 导入或加载机器人"), + body: copy( + "Import the robot .urdf first, then its meshes/ directory. Built-in and previously registered robots can be loaded directly from the robot library, keeping reusable robot assets separate from a workflow run.", + "先导入机器人的 .urdf,再导入对应的 meshes/ 目录。内置或已经注册的机器人可直接从机器人资源库加载,让可复用资产与具体工作流分开管理。", + ), + placement: "left", + }, + { + id: "calibration", + panel: "h2r", + anchor: "#tour-calibration", + title: copy("5. Calibrate the target robot", "5. 标定目标机器人"), + body: copy( + "Before the first retarget, align the gray robot with the blue reference skeleton. Select a joint in the 3D stage or use the controls in this step, then save the calibration for this robot and source reference.", + "首次 Retarget 前,需要把灰色机器人对齐到蓝色参考骨架。可以在 3D 舞台选择关节,或使用本步骤中的控制项进行调整,最后保存当前机器人与源参考骨架的标定。", + ), + placement: "left", + beforeShow: ({ revealDetails }) => revealDetails("h2r-step-calibration", true), + afterLeave: ({ revealDetails }) => revealDetails("h2r-step-calibration", false), + }, + { + id: "retarget", + panel: "h2r", + anchor: "#h2r-step-result", + title: copy("6. Run Human → Robot", "6. 执行人体 → 机器人"), + body: copy( + "With a motion, robot, and calibration ready, choose the solver and optional Retarget FPS, then start Retarget. Newton IK handles regular motion; Interaction-Mesh handles clips with interaction objects or terrain.", + "动作、机器人和标定就绪后,选择求解器与可选的 Retarget FPS,再开始 Retarget。Newton IK 适合常规动作,Interaction-Mesh 适合带交互物体或地形的动作。", + ), + placement: "left", + beforeShow: ({ revealDetails }) => revealDetails("h2r-step-result", true), + afterLeave: ({ revealDetails }) => revealDetails("h2r-step-result", false), + }, + { + id: "view", + panel: "motion", + anchor: "#view-hud", + title: copy("7. Inspect the 3D layers", "7. 检查 3D 显示层"), + body: copy( + "Use the stage controls to compare the source skeleton or body, objects and terrain, the calibrated reference, and the retargeted robot. Multiple layers can remain visible for alignment checks.", + "使用舞台控制项对比源骨架或身体、物体与地形、标定后的参考层以及 Retarget 机器人。多个显示层可以同时打开,便于检查对齐效果。", + ), + placement: "bottom", + beforeShow: ({ revealViewHud }) => revealViewHud(true), + afterLeave: ({ revealViewHud }) => revealViewHud(false), + }, + { + id: "export", + panel: "h2r", + anchor: "#rt-export-card", + title: copy("8. Export the result", "8. 导出结果"), + body: copy( + "After Retarget finishes, the Result step exposes export controls. Choose CSV or PKL, adjust the output range or FPS when needed, and download the generated trajectory.", + "Retarget 完成后,结果步骤会显示导出控制项。可以选择 CSV 或 PKL,并按需调整导出区间或 FPS,然后下载生成的轨迹。", + ), + placement: "left", + beforeShow: ({ revealDetails, revealExportCard }) => { + revealDetails("h2r-step-result", true); + revealExportCard(true); + }, + afterLeave: ({ revealDetails, revealExportCard }) => { + revealExportCard(false); + revealDetails("h2r-step-result", false); + }, + }, + { + id: "done", + panel: "motion", + anchor: '[data-menu-trigger="help"]', + title: copy("9. Tutorial complete", "9. 教程完成"), + body: copy( + "The guide is shown automatically only on the first launch. To review it later, open Help → Tutorial. Video → Motion, Robot → Robot, Batch, and Data Analysis are available as separate workspaces in the left navigation.", + "教程只会在首次启动时自动显示。以后需要复习时,请打开顶部 帮助 → 操作教程。视频 → 动作、机器人 → 机器人、批量处理和数据分析均可从左侧导航进入。", + ), + placement: "bottom", + last: true, + }, +]; + +function switchPanel(panelId: string | undefined): void { + if (!panelId) return; + window.__hhUi?.requestPanel(panelId); +} + +export class GuidedTour { + private readonly _toast: ToastFunction; + private readonly root: HTMLElement; + private readonly highlight: HTMLElement; + private readonly popover: HTMLElement; + private readonly titleEl: HTMLElement; + private readonly bodyEl: HTMLElement; + private readonly stepEl: HTMLElement; + private readonly nextBtn: HTMLButtonElement; + private readonly skipBtn: HTMLButtonElement; + private readonly _onResize: () => void; + private idx = 0; + private active = false; + + constructor(toastFn: ToastFunction) { + this._toast = toastFn; + this.root = document.getElementById("tour-root"); + this.highlight = document.getElementById("tour-highlight"); + this.popover = document.getElementById("tour-popover"); + this.titleEl = document.getElementById("tour-title"); + this.bodyEl = document.getElementById("tour-body"); + this.stepEl = document.getElementById("tour-step"); + this.nextBtn = document.getElementById("tour-next"); + this.skipBtn = document.getElementById("tour-skip"); + this._onResize = () => { if (this.active) this._positionCurrent(); }; + window.addEventListener("resize", this._onResize); + this.skipBtn?.addEventListener("click", () => this.finish(true)); + this.nextBtn?.addEventListener("click", () => this.next()); + } + + hasBeenShown(): boolean { + return hasSeenFirstRunTutorial(); + } + + markShown(): void { + markFirstRunTutorialSeen(); + } + + revealViewHud(on: boolean): void { + const hud = document.getElementById("view-hud"); + if (!hud) return; + if (on) { + hud.classList.remove("hidden"); + hud.dataset.tourForced = "1"; + return; + } + if (!hud.dataset.tourForced) return; + const runtime = window.__hh as { player?: { active?: boolean } } | undefined; + const motionLoaded = runtime?.player?.active; + // The shared workspace keeps view controls available before a motion is loaded. + if (!motionLoaded) hud.classList.remove("hidden"); + delete hud.dataset.tourForced; + } + + revealExportCard(on: boolean): void { + const card = document.getElementById("rt-export-card"); + if (!card) return; + if (on) { + if (!card.dataset.tourForced) { + card.dataset.tourPrevDisplay = card.style.display || "none"; + } + card.style.display = "block"; + card.dataset.tourForced = "1"; + return; + } + if (!card.dataset.tourForced) return; + card.style.display = card.dataset.tourPrevDisplay || "none"; + delete card.dataset.tourForced; + delete card.dataset.tourPrevDisplay; + } + + revealDetails(detailsId: string, on: boolean): void { + const details = document.getElementById(detailsId); + if (!(details instanceof HTMLDetailsElement)) return; + if (on) { + if (!details.dataset.tourForced) { + details.dataset.tourWasOpen = details.open ? "1" : "0"; + } + details.open = true; + details.dataset.tourForced = "1"; + return; + } + if (!details.dataset.tourForced) return; + details.open = details.dataset.tourWasOpen === "1"; + delete details.dataset.tourForced; + delete details.dataset.tourWasOpen; + } + + private _stepCtx(): TourStepContext { + return { + revealViewHud: (v) => this.revealViewHud(v), + revealExportCard: (v) => this.revealExportCard(v), + revealDetails: (id, v) => this.revealDetails(id, v), + }; + } + + maybeAutoStart(): void { + if (this.hasBeenShown()) return; + // Mark before scheduling so a refresh during the guide does not replay it. + this.markShown(); + requestAnimationFrame(() => { + setTimeout(() => this.start(0), 400); + }); + } + + start(fromIdx = 0): void { + if (this.active) { + STEPS[this.idx]?.afterLeave?.(this._stepCtx()); + } + this.markShown(); + this.idx = fromIdx; + this.active = true; + this.root?.classList.add("active"); + document.body.classList.add("tour-active"); + // Panel visibility is reactive in React; mutating CSS classes here would be overwritten. + window.__hhPanelLayout?.revealBoth(); + this._showStep(); + } + + finish(skipped = false): void { + this.active = false; + const step = STEPS[this.idx]; + step?.afterLeave?.(this._stepCtx()); + this.root?.classList.remove("active"); + document.body.classList.remove("tour-active"); + this.highlight?.classList.remove("visible"); + this.popover?.classList.remove("visible"); + this.markShown(); + if (!skipped) this._toast?.(localized(copy("Tutorial complete.", "教程已完成。"))); + else this._toast?.(localized(copy("Tutorial skipped. Reopen it from Help → Tutorial.", "已跳过教程,可从帮助 → 操作教程重新打开。"))); + } + + next(): void { + const step = STEPS[this.idx]; + step?.afterLeave?.(this._stepCtx()); + if (step?.last) { + this.finish(false); + return; + } + this.idx += 1; + this._showStep(); + } + + private _showStep(): void { + const step = STEPS[this.idx]; + if (!step) { + this.finish(false); + return; + } + this.highlight?.classList.remove("visible"); + this.popover?.classList.remove("visible"); + switchPanel(step.panel); + step.beforeShow?.(this._stepCtx()); + // One frame lets React commit the panel change; the second lets the browser + // calculate its new layout before getBoundingClientRect() is sampled. + requestAnimationFrame(() => { + requestAnimationFrame(() => this._positionCurrent()); + }); + this.titleEl.textContent = localized(step.title); + // Tutorial copy is a compile-time constant and intentionally supports only + // its embedded / markup. Never pass file names or API text here. + this.bodyEl.innerHTML = localized(step.body); + this.stepEl.textContent = `${this.idx + 1} / ${STEPS.length}`; + this.skipBtn.textContent = localized(copy("Skip tutorial", "跳过教程")); + this.nextBtn.textContent = step.last + ? localized(copy("Finish", "完成")) + : localized(copy("Next", "下一步")); + } + + private _positionCurrent(): void { + const step = STEPS[this.idx]; + if (!step) return; + const el = document.querySelector(step.anchor); + if (!el) { + this._centerPopover(); + return; + } + el.scrollIntoView({ block: "nearest", behavior: "auto" }); + const rect = el.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + this._centerPopover(); + return; + } + const pad = 8; + const h = this.highlight; + h.style.left = `${Math.max(0, rect.left - pad)}px`; + h.style.top = `${Math.max(0, rect.top - pad)}px`; + h.style.width = `${rect.width + pad * 2}px`; + h.style.height = `${rect.height + pad * 2}px`; + h.classList.add("visible"); + + const pop = this.popover; + const margin = 14; + const pw = pop.offsetWidth || 320; + const ph = pop.offsetHeight || 160; + let left = 0; + let top = 0; + if (step.placement === "left") { + left = rect.left - pw - margin; + top = rect.top + rect.height / 2 - ph / 2; + } else if (step.placement === "right") { + left = rect.right + margin; + top = rect.top + rect.height / 2 - ph / 2; + } else if (step.placement === "top") { + left = rect.left + rect.width / 2 - pw / 2; + top = rect.top - ph - margin; + } else { + left = rect.left + rect.width / 2 - pw / 2; + top = rect.bottom + margin; + } + const vw = window.innerWidth; + const vh = window.innerHeight; + left = Math.min(vw - pw - 12, Math.max(12, left)); + top = Math.min(vh - ph - 12, Math.max(56, top)); + pop.style.left = `${left}px`; + pop.style.top = `${top}px`; + pop.classList.add("visible"); + } + + private _centerPopover(): void { + this.highlight?.classList.remove("visible"); + const pop = this.popover; + const pw = pop.offsetWidth || 320; + const ph = pop.offsetHeight || 160; + pop.style.left = `${Math.max(12, (window.innerWidth - pw) / 2)}px`; + pop.style.top = `${Math.max(56, (window.innerHeight - ph) / 2)}px`; + pop.classList.add("visible"); + } +} + +export function initTutorial(toastFn: ToastFunction): GuidedTour { + const tour = new GuidedTour(toastFn); + // Help-menu commands use this narrow global bridge to restart the singleton. + window.__hhTour = tour; + return tour; +} diff --git a/hhtools/web/frontend/src/runtime/types.ts b/hhtools/web/frontend/src/runtime/types.ts new file mode 100644 index 00000000..29a814f3 --- /dev/null +++ b/hhtools/web/frontend/src/runtime/types.ts @@ -0,0 +1,1003 @@ +/** + * Shared protocol types at the React ↔ compatibility runtime ↔ FastAPI seams. + * This file contains data shapes only: importing it must never start a job, + * touch the DOM, or create Three.js objects. Snake_case fields intentionally + * mirror backend JSON; camelCase fields describe renderer-owned UI state. + */ + +import type * as THREE from 'three' + +// --------------------------------------------------------------------------- +// Geometry, scene, and motion payloads returned by FastAPI. + +export type Vec3 = [number, number, number] +export type Quaternion = [number, number, number, number] +export type Matrix4Data = [ + number, number, number, number, + number, number, number, number, + number, number, number, number, + number, number, number, number, +] + +export interface TerrainPayload { + vertices: Vec3[] + faces: [number, number, number][] +} + +export interface SceneObjectPayload { + color?: [number, number, number] + extents: Vec3 + opacity?: number + positions: Vec3[] + quaternions: Quaternion[] + has_mesh?: boolean + source_index?: number + scale?: number + mesh_file?: string +} + +export interface ScenePayload { + terrain?: TerrainPayload | null + objects?: SceneObjectPayload[] +} + +export interface BodyMeshPayload { + available: boolean + vertices_gz_b64: string + num_verts: number + num_frames: number + triangles: [number, number, number][] + reason?: string +} + +export interface MotionPayload extends ScenePayload { + name: string + token: string + positions: Vec3[][] + parent_indices: number[] + exclude_joint_indices?: number[] + frame_indices?: number[] + playback_frames?: number + playback_duration?: number + num_frames_total?: number + duration?: number + framerate?: number + sample_rate?: number + source_format?: string + bone_names?: string[] + dataset?: string + suggested_reference?: string + suggested_backend?: string + has_terrain?: boolean + body_mesh?: BodyMeshPayload + library_entry?: LibraryEntry + linked_folder?: string + materialize_mode?: 'symlink' | 'hardlink' | 'copy' | string + meta?: Record +} + +export interface PlaybackPayload { + playback_duration?: number + playback_frames?: number + positions?: unknown[] + frames?: unknown[] + num_frames_total?: number + framerate?: number + sample_rate?: number + duration?: number +} + +export interface PlaybackUiState { + visible: boolean + active: boolean + playing: boolean + loop: boolean + progress: number + speed: number + label: string +} + +// --------------------------------------------------------------------------- +// Workbench identities and typed CustomEvent payloads. + +export type PlaybackAction = 'toggle' | 'seek' | 'speed' | 'loop' + +export type WorkspacePanelId = + | 'motion' + | 'robot-assets' + | 'video-to-motion' + | 'h2r' + | 'batch' + | 'r2r' + | 'dataset-viz' + +export type WorkflowId = 'h2r' | 'r2r' + +export type WorkspaceLocale = 'en' | 'zh-CN' + +export type WorkspaceTheme = 'light' | 'dark' + +export type ComparisonPreset = 'source' | 'target' | 'result' | 'overlay' + +export type ImportCommandTarget = + | 'motion-file' + | 'motion-folder' + | 'video-file' + | 'robot-urdf' + | 'robot-mesh-folder' + | 'robot-trajectory' + | 'dataset-folder' + | 'job-spec' + +export interface ImportCommandDetail { + target: ImportCommandTarget +} + +export type CalibrationJointRegion = + | 'torso' + | 'left-arm' + | 'right-arm' + | 'left-leg' + | 'right-leg' + | 'head' + | 'hands' + | 'other' + +export type CalibrationAngleUnit = 'rad' | 'deg' +export type CalibrationComparisonMode = 'current' | 'saved' | 'zero' + +export type CalibrationEditorCommand = + | 'search' + | 'region' + | 'unit' + | 'comparison' + | 'reset-region' + | 'mapped-only' + | 'labels' + | 'mapping-lines' + | 'source-opacity' + | 'robot-opacity' + +export interface CalibrationEditorCommandDetail { + workflow: WorkflowId + command: CalibrationEditorCommand + value?: string | number | boolean +} + +export interface CalibrationEditorStateDetail { + workflow: WorkflowId + active: boolean + totalJoints: number + visibleJoints: number + mappedLandmarks: number + canUseSaved: boolean + query: string + region: CalibrationJointRegion | 'all' + unit: CalibrationAngleUnit + comparison: CalibrationComparisonMode + mappedOnly: boolean + labels: boolean + mappingLines: boolean + sourceOpacity: number + robotOpacity: number +} + +export type WorkflowNodeState = + | 'missing' + | 'validating' + | 'ready' + | 'running' + | 'completed' + | 'warning' + | 'failed' + +export interface WorkflowNodeStatus { + id: string + label: string + state: WorkflowNodeState + detail: string + panel: WorkspacePanelId +} + +export interface WorkflowStateDetail { + workflow: WorkflowId + nodes: WorkflowNodeStatus[] + blockedReason: string | null +} + +export type VideoToMotionStage = + | 'idle' + | 'uploading' + | 'running' + | 'completed' + | 'failed' + +/** Custom checkpoints are forwarded as selected and remain best-effort. */ +export type GvhmrWeightSource = 'official' | 'custom' + +export interface VideoToMotionResultSummary { + name: string + frames: number | null + duration: number | null + framerate: number | null +} + +/** Renderer-safe state for the GVHMR workflow; the selected File stays private. */ +export interface VideoToMotionStateDetail { + videoName: string | null + weightSource: GvhmrWeightSource + checkpointName: string | null + runtimeState: 'checking' | 'ready' | 'unavailable' + runtimeMessage: string + environmentConfirmed: boolean + stage: VideoToMotionStage + progress: number + message: string + result: VideoToMotionResultSummary | null +} + +export type DataAnalysisKind = 'human' | 'robot' | 'mixed' | 'unknown' + +export type DataAnalysisStage = 'idle' | 'uploading' | 'running' | 'completed' | 'failed' + +/** Minimal renderer state for the dataset-analysis workflow navigation. */ +export interface DataAnalysisStateDetail { + dataKind: DataAnalysisKind + clipCount: number + stage: DataAnalysisStage + progress: number + message: string + hasResults: boolean +} + +export interface PlaybackCommandDetail { + action: PlaybackAction + value?: number +} + +// --------------------------------------------------------------------------- +// Robot models, trajectories, and calibration sessions. + +export interface RobotFrame { + root?: [number, number, number, number, number, number, number] + mesh_z_lift?: number + links: Record +} + +export interface RobotTrajectoryPayload { + frames: RobotFrame[] + frame_indices?: number[] + duration?: number + playback_duration?: number + playback_frames?: number + num_frames_total?: number + framerate?: number + sample_rate?: number +} + +export interface RobotJointMeta { + name: string + lower?: number + upper?: number + value?: number + parent?: string +} + +export interface RobotPayload { + name: string + display_name: string + links: string[] + mesh_to_link?: Record + link_transforms_zero: Record + ground_offset_z?: number + glb_base64?: string | null + joints?: RobotJointMeta[] + joint_limits?: RobotJointLimit[] + actuated_joints?: string[] + num_dof?: number + ik_map?: Record + ik_prewarmed?: boolean +} + +export interface RobotSummary { + name: string + display_name: string + has_urdf: boolean + num_dof: number + builtin?: boolean + deletable: boolean +} + +export interface RobotsResponse { + robots: RobotSummary[] + library_dir: string +} + +export interface RobotJointLimit { + name: string + lower?: number + upper?: number + value?: number + type?: string + child_link?: string + parent_link?: string + axis?: Vec3 +} + +export interface CalibrationReferencePayload { + positions: Vec3[][] + parent_indices: number[] + exclude_joint_indices?: number[] + color?: number + bone_names?: string[] + canonical_names?: string[] + quaternions?: Quaternion[][] +} + +export interface JointWorldPayload { + pivot?: Vec3 + axis?: Vec3 +} + +export interface CalibrationSession { + reference?: CalibrationReferencePayload + reference_pose?: CalibrationReferencePayload + joint_q?: Record + saved_joint_q?: Record + limits?: RobotJointLimit[] + joint_limits?: RobotJointLimit[] + joint_world?: Record + ground_offset_z?: number + reference_name?: string + has_saved_calibration?: boolean +} + +export interface FkPreviewResponse { + links: string[] + link_transforms: Record + joint_world: Record + ground_offset_z: number +} + +export interface CalibrationStatus { + calibrated: boolean + bundled?: boolean + path?: string | null + joint_q?: Record | null +} + +// --------------------------------------------------------------------------- +// Motion library entries and retarget workflow results. + +export type MotionCategory = 'motion' | 'object' | 'terrain' + +/** + * Pipeline-level meaning of a library item. + * + * A human motion is a skeleton/body-space reference consumed by H2R. A robot + * trajectory contains root pose plus source-robot DoF samples and is consumed + * by R2R. Keeping this separate from `motion_category` prevents a visually + * similar clip from crossing workflow boundaries. + */ +export type LibraryAssetKind = 'human_motion' | 'robot_trajectory' + +export interface LibraryEntry { + dataset?: string + folder_label?: string + sequence_id?: string + stem?: string + source_path: string + label?: string + name?: string + display_name?: string + origin?: string + reference?: string + upload_profile?: string + export_subdir?: string + token?: string + suggested_backend?: string + /** Stable backend-provided UX category; never infer it from dataset labels. */ + motion_category?: MotionCategory + /** Stable backend-provided pipeline boundary. */ + asset_kind?: LibraryAssetKind +} + +export interface LibraryResponse { + source_root: string + motions_library_root: string + folders: string[] + entries: LibraryEntry[] +} + +export interface JobStartResponse { + job_id: string + linked?: boolean + folder_label?: string + materialize_mode?: 'pending' | 'symlink' | 'hardlink' | 'copy' | string +} + +export interface RobotExportPreviewResult { + name: string + robot: string + trajectory: RobotTrajectoryPayload + num_frames: number + framerate: number + preview_token?: string + scaled_scene?: ScenePayload +} + +export interface TrackingDiagnosticPoint { + frame: number + time_s: number + mean_error_m: number + max_error_m: number + source_contacts: number + target_contacts: number +} + +export interface EffectorDiagnostic { + canonical: string + target_link: string + sample_count: number + mean_error_m: number + p95_error_m: number + max_error_m: number +} + +export interface FootContactDiagnostic { + side: 'left' | 'right' + canonical: string + target_link: string + agreement_ratio: number + recall_ratio: number + source_contact_ratio: number + target_contact_ratio: number + target_slide_mean_mps: number + target_slide_p95_mps: number +} + +export interface ContactDiagnostics { + available: boolean + reason?: string + agreement_ratio?: number + recall_ratio?: number + target_slide_mean_mps?: number + target_slide_p95_mps?: number + feet: FootContactDiagnostic[] +} + +export interface ResultDiagnostics { + schema_version: number + available: boolean + reason?: string + frame_count?: number + mapped_effectors?: number + requested_effectors?: number + tracking?: { + mean_error_m: number + p95_error_m: number + max_error_m: number + effectors: EffectorDiagnostic[] + series: TrackingDiagnosticPoint[] + } + contact?: ContactDiagnostics +} + +export interface ResultDiagnosticsDetail { + workflow: WorkflowId + diagnostics: ResultDiagnostics | null + comparisonPreset: ComparisonPreset +} + +export interface ComparisonCommandDetail { + workflow: WorkflowId + preset: ComparisonPreset +} + +export interface ComparisonStateDetail { + workflow: WorkflowId + preset: ComparisonPreset +} + +export interface RetargetResult { + motion_source_fps?: number + retarget_fps?: number + source_fps?: number + num_frames: number + trajectory: RobotTrajectoryPayload + scaled_preview?: MotionPayload + scaled_scene?: ScenePayload + diagnostics?: ResultDiagnostics + export_token: string + has_scene?: boolean + stem?: string +} + +export interface R2rSourceTrajectoryResult { + token: string + name?: string + has_scene?: boolean + suggested_backend?: string + trajectory: RobotTrajectoryPayload + skeleton_preview?: MotionPayload + num_frames: number + framerate: number + scaled_scene?: ScenePayload + upload_profile?: string +} + +export interface R2rBasketUploadResult { + entries: LibraryEntry[] + profile?: string +} + +export interface BatchFailure { + stage?: string + stem?: string + reason?: string + log_rel?: string + stash_error?: string +} + +export interface BatchRetargetResult { + solver_mode?: string + failures?: BatchFailure[] + written?: string[] + download_name?: string + failure_log?: string +} + +// --------------------------------------------------------------------------- +// Background-job history, replay, and scheduler settings. + +export type JobStatus = 'pending' | 'running' | 'done' | 'error' + +export type JobParameterValue = string | number | boolean + +export interface JobHistoryRecord { + id: string + kind: string + status: JobStatus + progress: number + clip_progress: number + message: string + error: string | null + created_at: number + finished_at: number | null + duration_seconds: number + parameters: Record + result_summary: Record + can_download: boolean + can_copy_cli: boolean + can_retry: boolean + retry_reason: string | null + can_retry_failed: boolean + failed_item_count: number + parent_job_id: string | null + scope: 'current_session' | 'persistent' +} + +export interface JobListResponse { + jobs: JobHistoryRecord[] + session_only: boolean + persistence: 'disk' +} + +export interface JobCliResponse { + available: boolean + command: string | null + reason: string | null +} + +export interface JobReplayCapability { + available: boolean + reason: string | null + source_count: number +} + +export interface JobSpec { + schema_version: number + kind: string + request: Record +} + +export interface JobSpecValidationResponse { + spec: JobSpec + replay: JobReplayCapability +} + +export interface JobReplayResponse { + job_id: string + parent_job_id: string | null + spec: JobSpec +} + +export interface JobConfigResponse { + schema_version: number + job_id: string + kind: string + status: JobStatus + created_at: number + finished_at: number | null + scope: 'current_session' | 'persistent' + request: Record + cli: JobCliResponse + spec: JobSpec + replay: JobReplayCapability + parent_job_id: string | null +} + +export interface JobHistoryStateDetail { + jobs: JobHistoryRecord[] + loading: boolean + error: string | null +} + +export type JobHistoryCommandDetail = + | { command: 'refresh' } + | { command: 'copy-config'; jobId: string } + | { command: 'copy-cli'; jobId: string } + | { command: 'download-config'; jobId: string } + | { command: 'download'; jobId: string; filename?: string } + +export interface JobResult { + motion?: MotionPayload + payload?: MotionPayload + preview?: MotionPayload + trajectory?: RobotTrajectoryPayload + robot_trajectory?: RobotTrajectoryPayload + scaled_scene?: ScenePayload + token?: string + export_token?: string + artifact_path?: string + download_name?: string + written?: string[] + failures?: Array> + clips?: DatasetClip[] + summary?: DatasetSummary + meta?: { + source_root?: string + embedding?: string + [key: string]: unknown + } + [key: string]: unknown +} + +export interface JobResponse { + id: string + kind: string + status: JobStatus + progress: number + clip_progress?: number + message?: string + result?: JobResult | null + error?: string | null + created_at?: number + finished_at?: number | null + duration_seconds?: number + parameters?: Record + result_summary?: Record + can_download?: boolean +} + +export interface JobAdmissionSettings { + max_running_jobs: number + max_queued_jobs: number +} + +export interface JobAdmissionSnapshot extends JobAdmissionSettings { + running_jobs: number + queued_jobs: number + reserved_jobs: number + /** Whether this client satisfies the backend's local-admin boundary. */ + editable?: boolean +} + +export interface MotionLibrarySettingsSnapshot { + root: string + default_root: string + editable: boolean + /** Optional server hint explaining why an otherwise valid root is read-only. */ + readonly_reason?: string | null + /** Optional origin of the effective value, for example default/settings/environment. */ + source?: string | null +} + +export interface GvhmrOptionalComponentState { + requested: boolean + configured: boolean + root?: string + guideUrl: string + estimatedAdditionalBytes: number +} + +/** Base GVHMR paths; capability fields augment this shape near the API map below. */ +export interface GvhmrRuntimeStatus { + ready: boolean + missing: string[] + root: string + body_models_root: string + image: string +} + +export interface HealthResponse { + ok: boolean + ui_build?: string + job_scheduler?: JobAdmissionSnapshot + source_root?: string + save_dir?: string + motions_library_root?: string + ui_features?: { + merged_robot_panel?: boolean + view_hud?: boolean + scaled_skeleton_toggle?: boolean + recalib_button?: boolean + } +} + +export interface ScaledPreviewResponse { + preview: MotionPayload + scaled_scene?: ScenePayload +} + +// --------------------------------------------------------------------------- +// Dataset-analysis payloads and export metadata. + +export interface DatasetMetricSummary { + min?: number + max?: number + mean?: number + median?: number + lo?: number + hi?: number + [key: string]: number | undefined +} + +export interface DatasetClip { + clip_id: string + source_path?: string + source_kind?: 'human' | 'robot' | string + folder_label?: string + cluster_id?: string | number + tags?: string[] + metrics?: Record + embedding?: number[] + scatter?: [number, number] + error?: string + dataset?: string + stem?: string + reference?: string + upload_profile?: string + export_subdir?: string +} + +export interface HistogramData { + edges: number[] + counts: number[] + min: number + max: number + mean: number + median: number +} + +export interface DatasetSummary { + num_ok: number + numeric_keys: string[] + tag_counts: Record + histograms: Record +} + +export interface DatasetCatalogEntry { + title?: string + desc?: string + detail?: string + formula?: string + unit?: string + [key: string]: unknown +} + +export interface DatasetAnalysisResult { + clips: DatasetClip[] + source_root?: string + folder_label?: string + numeric_keys?: string[] + metrics?: Record + categories?: Record> + histograms?: Record + clustering?: { + colors?: Record + [key: string]: unknown + } + tags?: Record + summary?: DatasetSummary + meta?: { + source_root?: string + embedding?: string + [key: string]: unknown + } + [key: string]: unknown +} + +export interface DatasetCatalog { + tags?: Record + metrics?: Record + categories?: Record + clustering?: DatasetCatalogEntry & { + handcrafted_inputs?: string + algorithm?: string + } + [key: string]: unknown +} + +export interface DatasetUploadSummary { + source?: string + source_root?: string + folder_label?: string + folders?: Record + files?: Array<{ name?: string; path?: string; folder_label?: string }> + clips?: DatasetClip[] + count?: number + clip_count?: number + robot_count?: number + human_count?: number + user_source_root?: string + [key: string]: unknown +} + +export interface BasketResponse { + basket: LibraryEntry[] +} + +export interface BasicResponse { + ok?: boolean + path?: string + deleted?: string + removed?: string + motions_library_root?: string + folder_label?: string + clip_count?: number + [key: string]: unknown +} + +/** + * Additional GVHMR capability fields. TypeScript declaration merging combines + * this block with the base path fields above into one response contract. + */ +export interface GvhmrRuntimeStatus { + ready: boolean + missing: string[] + checks: Record + root: string + body_models_root: string + image: string + uses_official_weights: boolean + supports_custom_weights: boolean + training_enabled: boolean +} + +// --------------------------------------------------------------------------- +// Typed HTTP route maps and the deliberately narrow cross-module bridge. + +/** + * Infer the response shape for known GET routes; unknown routes stay generic. + * This is compile-time guidance only and does not validate JSON at runtime. + */ +export type ApiGetResponse = + Url extends '/api/health' ? HealthResponse + : Url extends '/api/video-to-motion/status' ? GvhmrRuntimeStatus + : Url extends '/api/settings/job-admission' ? JobAdmissionSnapshot + : Url extends '/api/settings/motion-library' ? MotionLibrarySettingsSnapshot + : Url extends '/api/library' ? LibraryResponse + : Url extends '/api/robots' ? RobotsResponse + : Url extends '/api/calibration/references' ? { references: string[] } + : Url extends `/api/calibration/status${string}` ? CalibrationStatus + : Url extends `/api/r2r/calibration/status${string}` ? CalibrationStatus + : Url extends '/api/jobs' ? JobListResponse + : Url extends `/api/job/${string}/config` ? JobConfigResponse + : Url extends `/api/job/${string}/cli` ? JobCliResponse + : Url extends `/api/job/${string}` ? JobResponse + : Url extends '/api/basket' ? BasketResponse + : Url extends '/api/dataset/catalog' ? DatasetCatalog + : Record + +export type ApiPostResponse = + Url extends '/api/robot/select' ? RobotPayload + : Url extends '/api/robot/fk_preview' ? FkPreviewResponse + : Url extends '/api/calibration/session' ? CalibrationSession + : Url extends '/api/r2r/calibration/session' ? CalibrationSession + : Url extends '/api/scaled_preview' ? ScaledPreviewResponse + : Url extends '/api/motion/load_library' ? JobStartResponse + : Url extends '/api/r2r/source/library' ? JobStartResponse + : Url extends '/api/dataset/preview_robot' ? JobStartResponse + : Url extends '/api/dataset/analyze' ? JobStartResponse + : Url extends '/api/retarget' ? JobStartResponse + : Url extends '/api/batch/retarget' ? JobStartResponse + : Url extends '/api/r2r/retarget' ? JobStartResponse + : Url extends '/api/r2r/batch/retarget' ? JobStartResponse + : Url extends '/api/jobs/spec/validate' ? JobSpecValidationResponse + : Url extends '/api/jobs/replay' ? JobReplayResponse + : Url extends '/api/basket/add' ? BasketResponse + : Url extends '/api/basket/clear' ? BasketResponse + : Url extends '/api/library/link' ? BasicResponse + : Url extends '/api/dataset/upload/remove' ? DatasetUploadSummary + : BasicResponse + +export interface UploadOptions { + profile?: string + name?: string +} + +export interface ApiClient { + get(url: Url): Promise> + post(url: Url, body?: unknown): Promise> + upload( + url: Url, + files: Iterable, + options?: UploadOptions, + ): Promise> + delete(url: Url): Promise +} + +export type ApiUploadResponse = + Url extends '/api/robot/upload' ? RobotPayload : Record + +export interface UploadFile extends File { + _relpath?: string +} + +export interface HhAppBridge { + API: ApiClient + toast: (message: string, isError?: boolean) => void + loadLibraryEntry: (entry: LibraryEntry) => Promise + loadHumanMotionEntry: (entry: LibraryEntry) => Promise + loadR2rLibraryEntry: (entry: LibraryEntry) => Promise + pickR2rTrajectory: (options?: { folder?: boolean }) => Promise + previewRobotClip: ( + entry: LibraryEntry, + robotName?: string, + ) => Promise + populateDvRobotSelect: (preferred?: string) => Promise + addToBasket: (entries: LibraryEntry[], options?: { silent?: boolean }) => void + switchInspectorPanel: (panelId: string) => void + getLibrarySourceRoot: () => string + refreshLibrary: () => Promise + pickFiles: (options?: { folder?: boolean; accept?: string }) => Promise + collectDroppedFiles: (dataTransfer: DataTransfer | null) => Promise + waitMotionJob: ( + jobId: string, + onProgress?: (fraction: number, message: string) => void, + options?: { uploadFrac?: number }, + ) => Promise + uploadFilesXHR: ( + url: Url, + files: Iterable, + options?: { + profile?: string + appendTo?: string + libraryFolderLabel?: string + userSourceRoot?: string + staticCam?: boolean + fMm?: number + checkpoint?: UploadFile + }, + onProgress?: (progress: number | null, loaded: number, total: number) => void, + ) => Promise< + Url extends '/api/dataset/upload' + ? DatasetUploadSummary + : Url extends `${string}upload${string}` + ? JobStartResponse + : Record + > +} + +/** Common surface implemented by every object driven by the shared timeline. */ +export interface PlaybackView { + group: THREE.Group + joints?: unknown[] | null + trajectory?: RobotTrajectoryPayload | null + numFrames: number + clipDuration?: number | null + heavy?: boolean + setFrame(frame: number): void + setFrameFrac?(frame: number): void +} diff --git a/hhtools/web/frontend/src/runtime/webui-runtime.ts b/hhtools/web/frontend/src/runtime/webui-runtime.ts new file mode 100644 index 00000000..1e8d94b1 --- /dev/null +++ b/hhtools/web/frontend/src/runtime/webui-runtime.ts @@ -0,0 +1,9373 @@ +/** + * HHTools compatibility domain runtime, loaded only after React has committed + * the stable DOM ports declared by Workbench. + * + * This module owns same-origin FastAPI/job orchestration, the shared Three.js + * stage and timeline, H2R/R2R/Batch/Video-to-Motion sessions, and the remaining + * imperative DOM adapters. IK, FK, dataset analysis, and video inference stay on + * the backend; the browser uploads, polls, coordinates, and visualizes results. + * + * New UI state belongs in React services/components. Until each domain is moved, + * typed window events and `window.__hhApp` are the explicit migration seams. + */ + + +/** Parse a positive FPS from a number input, or ``null`` to mean “use default”. */ +function parseOptionalFps(el: HTMLInputElement | null): number | null { + if (!el) return null; + const v = parseFloat(el.value); + return v > 0 && Number.isFinite(v) ? v : null; +} + +/** Non-negative seconds for export window; empty → null (natural bound). */ +function parseOptionalTime(el: HTMLInputElement | null): number | null { + if (!el || el.value === "" || el.value == null) return null; + const v = parseFloat(el.value); + return Number.isFinite(v) && v >= 0 ? v : null; +} + +function appendExportTimeParams(url: string, tStartElId: string, tEndElId: string): string { + const t0 = parseOptionalTime(document.getElementById(tStartElId) as HTMLInputElement); + const t1 = parseOptionalTime(document.getElementById(tEndElId) as HTMLInputElement); + if (t0 != null) url += `&t_start=${encodeURIComponent(t0)}`; + if (t1 != null) url += `&t_end=${encodeURIComponent(t1)}`; + return url; +} + +/** Create a text-only element for values that may originate from files or API responses. */ +function textElement( + tag: Tag, + className: string, + value: unknown, +): HTMLElementTagNameMap[Tag] { + const element = document.createElement(tag); + if (className) element.className = className; + element.textContent = String(value ?? ""); + return element; +} + +/** Render a plain message without allowing user-controlled strings to become markup. */ +function renderTextMessage(container: HTMLElement, message: unknown): void { + container.replaceChildren(textElement("div", "hint", message)); + const messageElement = container.firstElementChild as HTMLElement | null; + if (messageElement) messageElement.style.padding = "12px"; +} + +function runtimeText(en: string, zh: string): string { + return document.documentElement.lang.toLowerCase().startsWith("zh") ? zh : en; +} + +function renderSpinnerStatus(container: HTMLElement | null, message: unknown): void { + if (!container) return; + const spinner = document.createElement("span"); + spinner.className = "spin"; + container.replaceChildren(spinner, document.createTextNode(` ${String(message ?? "")}`)); +} + +function renderMetaRows( + container: HTMLElement | null, + rows: ReadonlyArray, +): void { + if (!container) return; + const elements = rows.map(([label, value]) => { + const row = document.createElement("div"); + row.className = "meta-row"; + row.append(textElement("span", "k", label), textElement("span", "v", value)); + return row; + }); + container.replaceChildren(...elements); +} + +function renderStatusChip(container: HTMLElement | null, text: unknown, className = ""): void { + if (!container) return; + const chip = document.createElement("span"); + chip.className = `status-chip ${className}`.trim(); + chip.append(textElement("span", "dot", ""), document.createTextNode(String(text ?? ""))); + container.replaceChildren(chip); +} + +type ValidationTone = "ok" | "warn" | "error"; + +/** Render compact, text-only validation rows without introducing an HTML injection path. */ +function renderValidationSummary( + container: HTMLElement | null, + rows: ReadonlyArray, +): void { + if (!container) return; + container.replaceChildren( + ...rows.map(([tone, message]) => textElement("div", `validation-line ${tone}`, message)), + ); +} + +/** Playback timeline when long clips are downsampled for the browser payload. */ +function effectivePlaybackDuration(payload: PlaybackPayload | null | undefined): number { + if (payload == null) return 1; + if (payload.playback_duration != null && Number.isFinite(payload.playback_duration)) { + return Math.max(0.1, payload.playback_duration); + } + const nPlay = payload.playback_frames + ?? payload.positions?.length + ?? payload.frames?.length + ?? payload.num_frames_total; + const nTotal = payload.num_frames_total ?? nPlay ?? 1; + const fps = payload.framerate || payload.sample_rate || 30; + // Always span the FULL clip duration — downsampled frames are interpolated + // across it, so never shorten the timeline to the downsampled frame count + // (that made long, heavily-downsampled clips play several times too fast). + const d = payload.duration; + if (d != null && d > 0) return Math.max(0.1, d); + return Math.max(0.1, (nTotal - 1) / fps); +} + +function isPlaybackPreview(payload: PlaybackPayload | null | undefined): boolean { + if (!payload) return false; + const nPlay = payload.playback_frames + ?? payload.positions?.length + ?? payload.frames?.length + ?? 0; + const nTotal = payload.num_frames_total ?? nPlay; + return nTotal > nPlay && nPlay > 0; +} + +/** + * Downsampled clips spread sparse keys across the full timeline; linear blend + * between distant source keys can turn a LAFAN direction change into a slide. + */ +function resolvePlaybackFrame( + frameIndices: number[] | null | undefined, + fi: number, + max: number, +): { ia: number; ib: number; t: number } { + const f0 = Math.min(max, Math.floor(fi)); + const t = fi - f0; + if (t <= 1e-5 || f0 >= max) return { ia: f0, ib: f0, t: 0 }; + const ib = f0 + 1; + const gap = frameIndices && frameIndices.length > ib + ? frameIndices[ib] - frameIndices[f0] + : 1; + if (gap > 1) { + const pick = t >= 0.5 ? ib : f0; + return { ia: pick, ib: pick, t: 0 }; + } + return { ia: f0, ib, t }; +} + +function updateRetargetFpsPlaceholder() { + const inp = document.getElementById("rt-retarget-fps"); + if (!inp) return; + const src = state.motion?.framerate; + inp.placeholder = src + ? runtimeText(`Blank = source ${src.toFixed(0)} fps`, `留空 = 原始 ${src.toFixed(0)} fps`) + : runtimeText("Blank = source motion frame rate", "留空 = 动作原始帧率"); +} + +import * as THREE from "three"; +import { OrbitControls } from "three/addons/controls/OrbitControls.js"; +import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js"; +import { + angleForDisplay, + angleFromDisplay, + calibrationJointMatches, + classifyCalibrationJoint, + formatCalibrationAngle, +} from "./calibration-editor"; +import { initTutorial } from "./tutorial"; +import { + loadWorkspacePreferences, + updateWorkspacePreferences, +} from "./workspace-preferences"; +import { + curatedRobotLibraryItem, + DEFAULT_ROBOT_LIBRARY_ICON, + robotLibraryIcon, +} from "./robot-library-catalog"; +import { sortRobotLibrarySummaries } from "./robot-library-order"; +import type { + ApiClient, + ApiGetResponse, + ApiPostResponse, + ApiUploadResponse, + BatchFailure, + BatchRetargetResult, + BodyMeshPayload, + CalibrationAngleUnit, + CalibrationComparisonMode, + CalibrationEditorCommandDetail, + CalibrationEditorStateDetail, + CalibrationJointRegion, + CalibrationReferencePayload, + ComparisonPreset, + GvhmrRuntimeStatus, + GvhmrWeightSource, + JobConfigResponse, + JobHistoryStateDetail, + JobListResponse, + JobResponse, + JobResult, + JobStartResponse, + JointWorldPayload, + LibraryEntry, + MotionCategory, + Matrix4Data, + MotionPayload, + PlaybackUiState, + PlaybackPayload, + PlaybackView, + RobotPayload, + RobotSummary, + RobotExportPreviewResult, + RetargetResult, + ResultDiagnostics, + R2rBasketUploadResult, + R2rSourceTrajectoryResult, + RobotJointLimit, + RobotTrajectoryPayload, + SceneObjectPayload, + ScenePayload, + TerrainPayload, + UploadFile, + Vec3, + VideoToMotionResultSummary, + VideoToMotionStateDetail, + WorkflowNodeState, + WorkflowNodeStatus, + WorkflowStateDetail, + WorkflowId, +} from "./types"; + +type ProgressCallback = (fraction: number | null, loaded: number, total: number) => void; +type JobProgressCallback = (fraction: number, message: string) => void; + +interface UploadFilesXhrOptions { + profile?: string; + appendTo?: string; + libraryFolderLabel?: string; + userSourceRoot?: string; + staticCam?: boolean; + fMm?: number; + checkpoint?: UploadFile; +} + +type UploadFilesXhrResponse = + Url extends "/api/dataset/upload" + ? import("./types").DatasetUploadSummary + : Url extends `${string}upload${string}` + ? JobStartResponse + : Record; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +interface OrbitSettingsSnapshot { + minDistance: number; + maxDistance: number; + zoomSpeed: number; +} + +interface ViewVisibilitySnapshot { + skel: boolean; + body: boolean; + scaled: boolean; + scaledEnv: boolean; + env: boolean; + robot: boolean; + playing: boolean; + t: number; + playbar: boolean; +} + +interface CalibrationSliderRow { + row: HTMLElement; + range: HTMLInputElement; + num: HTMLInputElement; + lo: number; + hi: number; + region: CalibrationJointRegion; +} + +/** H2R/shared-stage session state; the R2R workflow owns an isolated state below. */ +interface AppState { + motion: MotionPayload | null; + libraryEntry: LibraryEntry | null; + robot: RobotPayload | null; + reference: string | null; + calibration: boolean; + calibrationMode: boolean; + calibNeedsCameraFocus: boolean; + calibOrbitSaved: OrbitSettingsSnapshot | null; + calibLimits: RobotJointLimit[] | null; + calibRestore: ViewVisibilitySnapshot | null; + exportToken: string | null; + exportSrcFps: number | null; + exportHasScene: boolean; + calibQ: Record; + calibSliderRows: Record; + calibBaselineQ: Record | null; + calibDraftQ: Record | null; + calibHasSaved: boolean; + robotTrajectory: RobotTrajectoryPayload | null; + robotPanelLocked: boolean; +} + +// ----------------------------------------------------------------- API helpers +// FastAPI's `detail` can be a string OR (for 422 validation errors) an array of +// objects. Flatten whatever we get into a human-readable string so the UI never +// shows the useless "[object Object]". +function apiDetailMessage(detail: unknown): string | undefined { + let msg: string | undefined; + if (typeof detail === "string") msg = detail; + else if (Array.isArray(detail)) { + msg = detail + .map((item) => { + if (item && typeof item === "object" && "msg" in item) return String(item.msg); + return JSON.stringify(item); + }) + .join("; "); + } else if (detail && typeof detail === "object") { + msg = "msg" in detail ? String(detail.msg) : JSON.stringify(detail); + } + return msg; +} + +async function httpError(r: Response): Promise { + let detail: unknown; + try { + detail = (await r.json()).detail; + } catch { + detail = null; + } + const msg = apiDetailMessage(detail); + return new Error(msg || `${r.status} ${r.statusText}`); +} + +/** + * Same-origin FastAPI transport used by the compatibility runtime. Expensive + * endpoints normally return a job id; callers then poll `/api/job/:id` before + * committing the resulting payload to workflow and scene state. + */ +const API: ApiClient = { + async get(url: Url): Promise> { + const r = await fetch(url); + if (!r.ok) throw await httpError(r); + return await r.json() as ApiGetResponse; + }, + async post(url: Url, body?: unknown): Promise> { + const r = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body || {}), + }); + if (!r.ok) throw await httpError(r); + return await r.json() as ApiPostResponse; + }, + async upload( + url: Url, + files: Iterable, + { profile, name }: { profile?: string; name?: string } = {}, + ): Promise> { + const fd = new FormData(); + for (const f of files) fd.append("files", f, f._relpath || f.name); + const qs = []; + if (profile) qs.push(`profile=${encodeURIComponent(profile)}`); + if (name) qs.push(`name=${encodeURIComponent(name)}`); + const u = qs.length ? `${url}?${qs.join("&")}` : url; + const r = await fetch(u, { method: "POST", body: fd }); + if (!r.ok) throw await httpError(r); + return await r.json() as ApiUploadResponse; + }, + async delete(url: Url) { + const r = await fetch(url, { method: "DELETE" }); + if (!r.ok) throw await httpError(r); + return await r.json(); + }, +}; + +/** Trigger a file save into the browser's default download folder. */ +async function triggerBrowserDownload(url: string, filename?: string | null): Promise { + const r = await fetch(url); + if (!r.ok) throw await httpError(r); + const blob = await r.blob(); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = filename || "download"; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(a.href), 2000); +} + +// Stop the browser from navigating to / downloading a file when a drop misses +// a dropzone (the default behaviour the user hit). +["dragover", "drop"].forEach((ev) => + window.addEventListener(ev, (e) => { e.preventDefault(); }, false) +); + +const TOAST_MS = 3200; +const TOAST_ERR_EXTRA_MS = 5000; + +function toast(msg: unknown, isErr = false): void { + const t = document.getElementById("toast"); + t.textContent = String(msg); + t.className = isErr ? "show err" : "show"; + clearTimeout(t._timer); + const hideMs = isErr ? TOAST_MS + TOAST_ERR_EXTRA_MS : TOAST_MS; + t._timer = setTimeout(() => (t.className = isErr ? "err" : ""), hideMs); +} + +// ---------------------------------------------------------- shared job drawer +// React owns the drawer UI. This store polls backend history and exchanges +// immutable snapshots/commands with it through typed window events. +let jobHistoryState: JobHistoryStateDetail = { + jobs: [], + loading: false, + error: null, +}; +let jobHistoryRefresh: Promise | null = null; + +function publishJobHistoryState(): void { + window.dispatchEvent( + new CustomEvent("hhtools:job-history-state", { + detail: { + jobs: [...jobHistoryState.jobs], + loading: jobHistoryState.loading, + error: jobHistoryState.error, + }, + }), + ); +} + +function refreshJobHistory(): Promise { + if (jobHistoryRefresh) return jobHistoryRefresh; + jobHistoryState = { ...jobHistoryState, loading: true, error: null }; + publishJobHistoryState(); + jobHistoryRefresh = (async () => { + try { + const response: JobListResponse = await API.get("/api/jobs"); + jobHistoryState = { jobs: response.jobs, loading: false, error: null }; + } catch (error) { + jobHistoryState = { + ...jobHistoryState, + loading: false, + error: errorMessage(error), + }; + } finally { + jobHistoryRefresh = null; + publishJobHistoryState(); + } + })(); + return jobHistoryRefresh; +} + +async function writeClipboardText(value: string): Promise { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + return; + } + const textarea = document.createElement("textarea"); + textarea.value = value; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + const copied = document.execCommand("copy"); + textarea.remove(); + if (!copied) throw new Error(runtimeText( + "The browser blocked copying. View the configuration in developer tools.", + "浏览器未允许复制,请在开发者工具中查看配置", + )); +} + +async function handleJobHistoryCommand( + event: WindowEventMap["hhtools:job-history-command"], +): Promise { + const detail = event.detail; + if (detail.command === "refresh") { + await refreshJobHistory(); + return; + } + if (detail.command === "copy-config") { + try { + const config: JobConfigResponse = await API.get(`/api/job/${detail.jobId}/config`); + await writeClipboardText(JSON.stringify(config, null, 2)); + toast(runtimeText("Effective job configuration copied", "任务有效配置已复制")); + } catch (error) { + toast(runtimeText( + `Unable to copy configuration: ${errorMessage(error)}`, + `复制配置失败:${errorMessage(error)}`, + ), true); + } + return; + } + if (detail.command === "copy-cli") { + try { + const cli = await API.get(`/api/job/${detail.jobId}/cli`); + if (!cli.available || !cli.command) { + throw new Error(cli.reason || runtimeText( + "This job has no equivalent CLI command", + "该任务没有等价 CLI 命令", + )); + } + await writeClipboardText(cli.command); + toast(runtimeText("Equivalent CLI command copied", "等价 CLI 命令已复制")); + } catch (error) { + toast(runtimeText( + `Unable to copy CLI command: ${errorMessage(error)}`, + `复制 CLI 失败:${errorMessage(error)}`, + ), true); + } + return; + } + if (detail.command === "download-config") { + try { + await triggerBrowserDownload( + `/api/job/${detail.jobId}/config/download`, + `hhtools-job-${detail.jobId}.json`, + ); + toast(runtimeText("Job configuration download started", "任务配置已开始下载")); + } catch (error) { + toast(runtimeText( + `Unable to save configuration: ${errorMessage(error)}`, + `保存配置失败:${errorMessage(error)}`, + ), true); + } + return; + } + try { + await triggerBrowserDownload( + `/api/job/${detail.jobId}/download`, + detail.filename || `hhtools-${detail.jobId}.zip`, + ); + toast(runtimeText("Job result download started", "任务结果已开始下载")); + } catch (error) { + toast(runtimeText( + `Download failed: ${errorMessage(error)}`, + `下载失败:${errorMessage(error)}`, + ), true); + } +} + +function installJobHistoryBridge(): void { + window.addEventListener("hhtools:job-history-command", (event) => { + void handleJobHistoryCommand(event); + }); + void refreshJobHistory(); + window.setInterval(() => void refreshJobHistory(), 2500); +} + +// ----------------------------------------------------------------- loading bar +function fmtBytes(n: number): string { + if (!n) return "0 B"; + const u = ["B", "KB", "MB", "GB"]; + let i = 0; + while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } + return `${n.toFixed(i ? 1 : 0)} ${u[i]}`; +} + +function showLoading(label?: string): void { + const o = document.getElementById("load-overlay"); + if (!o) return; + document.getElementById("load-label").textContent = label + || runtimeText("Loading…", "加载中…"); + document.getElementById("load-sub").textContent = ""; + document.getElementById("load-bar").style.width = "0%"; + o.classList.remove("hidden"); + o.classList.add("indet"); // server still computing → animated sweep +} + +/** ``frac`` in [0,1] for a determinate bar, or ``null`` for indeterminate. */ +function setLoadingProgress(frac: number | null, sub?: string | null): void { + const o = document.getElementById("load-overlay"); + if (!o) return; + const bar = document.getElementById("load-bar"); + if (frac == null) { + o.classList.add("indet"); + } else { + o.classList.remove("indet"); + bar.style.width = `${Math.max(2, Math.min(100, frac * 100)).toFixed(0)}%`; + } + if (sub != null) document.getElementById("load-sub").textContent = sub; +} + +function hideLoading(): void { + const o = document.getElementById("load-overlay"); + if (!o) return; + o.classList.add("hidden"); + o.classList.remove("indet"); +} + +// Read a (large) JSON response as a stream so the load bar reflects real +// download progress. The server computes FK / bakes the SMPL mesh before the +// first byte, so `onProgress(null, …)` (indeterminate) covers that wait, then +// the determinate bar tracks the payload transfer — the part that actually +// scales with clip length. +async function readJsonStream(r: Response, onProgress?: ProgressCallback): Promise { + const total = Number(r.headers.get("Content-Length") || 0); + if (!r.body || !total) { + if (onProgress) onProgress(null, 0, 0); + return await r.json() as T; + } + const reader = r.body.getReader(); + let received = 0; + const chunks: Uint8Array[] = []; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + received += value.length; + if (onProgress) onProgress(received / total, received, total); + } + const all = new Uint8Array(received); + let pos = 0; + for (const c of chunks) { all.set(c, pos); pos += c.length; } + return JSON.parse(new TextDecoder("utf-8").decode(all)) as T; +} + +async function postJsonWithProgress( + url: string, + body: unknown, + onProgress?: ProgressCallback, +): Promise { + if (onProgress) onProgress(null, 0, 0); + const r = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body || {}), + }); + if (!r.ok) throw await httpError(r); + return readJsonStream(r, onProgress); +} + +async function uploadWithProgress( + url: string, + files: Iterable, + { profile }: { profile?: string } = {}, + onProgress?: ProgressCallback, +): Promise { + const fd = new FormData(); + for (const f of files) fd.append("files", f, f._relpath || f.name); + const qs = []; + if (profile) qs.push(`profile=${encodeURIComponent(profile)}`); + const u = qs.length ? `${url}?${qs.join("&")}` : url; + if (onProgress) onProgress(null, 0, 0); + const r = await fetch(u, { method: "POST", body: fd }); + if (!r.ok) throw await httpError(r); + return readJsonStream(r, onProgress); +} + +/** Upload files with real byte progress, then return the JSON body (``{job_id}``). */ +function uploadFilesXHR( + url: Url, + files: Iterable, + { + profile, + appendTo, + libraryFolderLabel, + userSourceRoot, + staticCam, + fMm, + checkpoint, + }: UploadFilesXhrOptions = {}, + onUploadProgress?: ProgressCallback, +): Promise> { + return new Promise>((resolve, reject) => { + const fd = new FormData(); + for (const f of files) fd.append("files", f, f._relpath || f.name); + if (checkpoint) fd.append("checkpoint", checkpoint, checkpoint.name); + const qs = new URLSearchParams(); + if (profile) qs.set("profile", profile); + if (appendTo) qs.set("append_to", appendTo); + if (libraryFolderLabel) qs.set("library_folder_label", libraryFolderLabel); + if (userSourceRoot) qs.set("user_source_root", userSourceRoot); + if (staticCam !== undefined) qs.set("static_cam", String(staticCam)); + if (fMm !== undefined) qs.set("f_mm", String(fMm)); + const q = qs.toString() ? `?${qs.toString()}` : ""; + const xhr = new XMLHttpRequest(); + xhr.upload.onprogress = (e) => { + if (e.lengthComputable && onUploadProgress) { + onUploadProgress(e.loaded / e.total, e.loaded, e.total); + } + }; + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { resolve(JSON.parse(xhr.responseText) as UploadFilesXhrResponse); } + catch (err) { reject(err); } + return; + } + // XHR is required for byte-progress events, so unwrap FastAPI's detail + // payload here just as the fetch-based helpers do above. + let message = xhr.responseText || `upload failed (${xhr.status})`; + try { + const payload = JSON.parse(xhr.responseText) as { detail?: unknown }; + message = apiDetailMessage(payload.detail) || message; + } catch { + // Non-JSON responses (proxy errors, disconnects) are already readable. + } + reject(new Error(message)); + }; + xhr.onerror = () => reject(new Error("upload failed")); + xhr.open("POST", url + q); + xhr.send(fd); + }); +} + +function formatJobProgress(job: JobResponse, prefix = ""): string { + const pct = Math.round(Math.max(0, Math.min(100, (job.progress || 0) * 100))); + const msg = job.message || runtimeText("Processing…", "处理中…"); + return `${prefix}${msg} (${pct}%)`; +} + +/** + * Shared long-job completion boundary. `uploadFrac` reserves the first part of + * a combined progress bar for XHR upload; backend progress fills the remainder. + */ +async function waitMotionJob( + jobId: string, + onProgress?: JobProgressCallback, + { uploadFrac = 0 }: { uploadFrac?: number } = {}, +): Promise { + while (true) { + const j = await API.get(`/api/job/${jobId}`); + if (onProgress) { + const frac = uploadFrac + (j.progress || 0) * (1 - uploadFrac); + onProgress(frac, formatJobProgress(j)); + } + if (j.status === "done") { + if (!j.result) throw new Error(j.error || "motion load failed"); + // Job result shape depends on the endpoint that created the job. The + // caller supplies that endpoint-specific contract at this API boundary. + return j.result as Result; + } + if (j.status === "error") throw new Error(j.error || "motion load failed"); + await new Promise((r) => setTimeout(r, 350)); + } +} + +// ----------------------------------------------------------------- 3D scene +// One persistent WebGL canvas is shared by every workflow. Views are stable +// groups under this scene; loading data updates them instead of remounting React. +const canvas = document.getElementById("three-canvas"); +const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }); +renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); +const scene = new THREE.Scene(); +const camera = new THREE.PerspectiveCamera(50, 1, 0.01, 200); +camera.position.set(2.6, 1.9, 3.2); +const orbit = new OrbitControls(camera, renderer.domElement); +orbit.target.set(0, 0.9, 0); +orbit.enableDamping = true; +orbit.dampingFactor = 0.08; +orbit.zoomSpeed = 0.028; +orbit.zoomToCursor = true; +orbit.screenSpacePanning = true; +// OrbitControls uses pow(0.95, zoomSpeed*deltaY) — one wheel notch (±100) jumps ~5× +// at default speeds. Use linear dolly steps for continuous zoom instead. +orbit.enableZoom = false; +const _smoothZoomOffset = new THREE.Vector3(); +function smoothOrbitWheel(event: WheelEvent): void { + if (!orbit.enabled) return; + let delta = event.deltaY; + if (event.deltaMode === 1) delta *= 16; + else if (event.deltaMode === 2) delta *= 400; + const step = THREE.MathUtils.clamp(-delta / 120, -2.5, 2.5); + const scale = Math.pow(0.968, step); + _smoothZoomOffset.copy(camera.position).sub(orbit.target); + const dist = _smoothZoomOffset.length(); + if (dist < 1e-6) return; + const next = THREE.MathUtils.clamp(dist * scale, orbit.minDistance, orbit.maxDistance); + _smoothZoomOffset.setLength(next); + camera.position.copy(orbit.target).add(_smoothZoomOffset); + orbit.update(); + _orbitManualUntil = performance.now() + 2800; + event.preventDefault(); +} +renderer.domElement.addEventListener("wheel", smoothOrbitWheel, { passive: false }); + +scene.add(new THREE.AmbientLight(0xffffff, 0.55)); +scene.add(new THREE.HemisphereLight(0xffffff, 0x8899aa, 1.35)); +const key = new THREE.DirectionalLight(0xffffff, 1.5); +key.position.set(3, 6, 4); +scene.add(key); +const fill = new THREE.DirectionalLight(0xffffff, 0.85); +fill.position.set(-3, 4, -2); +scene.add(fill); + +// World group: hhtools is Z-up; rotate so Z maps to three.js Y (up). +const world = new THREE.Group(); +world.rotation.x = -Math.PI / 2; +scene.add(world); + +// Spatial axes in the motion frame (X=red, Y=green, Z=blue in hhtools Z-up). +const axes = new THREE.AxesHelper(1.2); +world.add(axes); + +// Environment (terrain + interaction objects) lives in its own group so it +// stays visible regardless of which figure (skeleton / mesh / robot) is shown. +const env = new THREE.Group(); +world.add(env); +const scaledEnvGroup = new THREE.Group(); +world.add(scaledEnvGroup); + +// Triangulated heightfield mesh (matches Viser TerrainHeightfieldRenderer). +function buildTerrainMesh(t: TerrainPayload | null | undefined): THREE.Mesh | null { + if (!t?.vertices?.length || !t?.faces?.length) return null; + const pos = new Float32Array(t.vertices.length * 3); + for (let i = 0; i < t.vertices.length; i++) { + pos[i * 3] = t.vertices[i][0]; + pos[i * 3 + 1] = t.vertices[i][1]; + pos[i * 3 + 2] = t.vertices[i][2]; + } + const geo = new THREE.BufferGeometry(); + geo.setAttribute("position", new THREE.BufferAttribute(pos, 3)); + geo.setIndex(t.faces.flat()); + geo.computeVertexNormals(); + return new THREE.Mesh( + geo, + // flatShading keeps stair risers looking like sharp steps instead of + // smooth-shaded ramps; the user reported stairs rendering as slopes. + new THREE.MeshStandardMaterial({ + color: 0x9a9aa0, roughness: 0.95, side: THREE.DoubleSide, flatShading: true, + }) + ); +} + +// Ground grid (in three.js Y-up space, so add outside world). +const grid = new THREE.GridHelper(20, 40, 0x99a0ab, 0xd2d6dd); +grid.material.opacity = 0.35; +grid.material.transparent = true; +scene.add(grid); + +function resize(): void { + const w = canvas.clientWidth, h = canvas.clientHeight; + if (w === 0 || h === 0) return; + renderer.setSize(w, h, false); + camera.aspect = w / h; + camera.updateProjectionMatrix(); +} +window.addEventListener("resize", resize); +new ResizeObserver(resize).observe(document.getElementById("stage")); + +// ----------------------------------------------------------------- render loop +const clock = new THREE.Clock(); +const _camFocus = new THREE.Vector3(); +const _defaultCamTarget = new THREE.Vector3(0, 0.9, 0); +const _defaultCamOffset = new THREE.Vector3(2.6, 1.0, 3.2); +const _viewFocusBox = new THREE.Box3(); +const _viewFocusTmp = new THREE.Box3(); +let _orbitManualUntil = 0; +orbit.addEventListener("start", () => { _orbitManualUntil = performance.now() + 2800; }); +orbit.addEventListener("end", () => { _orbitManualUntil = performance.now() + 2800; }); + +function getViewFocus(out = new THREE.Vector3()): THREE.Vector3 { + const candidates: Array = [ + robot.links?.length ? robot.group : null, + scaledSkel.joints ? scaledSkel.group : null, + skel.joints ? skel.group : null, + mesh.ready ? mesh.group : null, + env.children.length ? env : null, + scaledEnvGroup.children.length ? scaledEnvGroup : null, + ]; + let has = false; + for (const g of candidates) { + if (!g) continue; + _viewFocusTmp.setFromObject(g); + if (_viewFocusTmp.isEmpty()) continue; + if (!has) { + _viewFocusBox.copy(_viewFocusTmp); + has = true; + } else { + _viewFocusBox.union(_viewFocusTmp); + } + if (g === robot.group) break; + } + if (!has) { + out.copy(_defaultCamTarget); + return out; + } + _viewFocusBox.getCenter(out); + return out; +} + +function resetDefaultView(): void { + focusRobotView({ resetOffset: true }); +} + +function calibRobotGroup(): THREE.Group { + return r2r.calibrating ? r2rTgt.group : robot.group; +} + +/** Frame robot (+ reference skeleton during calibration) with sane orbit limits. */ +function focusRobotView({ resetOffset = false }: { resetOffset?: boolean } = {}): void { + const focusGroups = [calibRobotGroup()]; + if ((state.calibrationMode || r2r.calibrating) && refSkel.group.visible) { + focusGroups.push(refSkel.group); + } + let has = false; + for (const g of focusGroups) { + if (!g?.visible) continue; + _viewFocusTmp.setFromObject(g); + if (_viewFocusTmp.isEmpty()) continue; + if (!has) { + _viewFocusBox.copy(_viewFocusTmp); + has = true; + } else { + _viewFocusBox.union(_viewFocusTmp); + } + } + if (!has) { + getViewFocus(_camFocus); + orbit.target.copy(_camFocus); + if (resetOffset) camera.position.copy(_camFocus).add(_defaultCamOffset); + orbit.update(); + _orbitManualUntil = performance.now() + 2800; + return; + } + _viewFocusBox.getCenter(_camFocus); + orbit.target.copy(_camFocus); + if (resetOffset) { + const size = _viewFocusBox.getSize(new THREE.Vector3()); + const span = Math.max(0.55, size.length()); + const dist = Math.max(1.35, span * 0.9); + camera.position.copy(_camFocus).add( + new THREE.Vector3(dist * 0.58, dist * 0.44, dist * 0.68), + ); + } + orbit.update(); + _orbitManualUntil = performance.now() + 2800; +} + +/** Orbit distance limits scaled to the visible robot (calibration zoom range). */ +function calibOrbitDistanceLimits(): { minDistance: number; maxDistance: number } { + let has = false; + for (const g of [calibRobotGroup(), refSkel.group.visible ? refSkel.group : null]) { + if (!g) continue; + _viewFocusTmp.setFromObject(g); + if (_viewFocusTmp.isEmpty()) continue; + if (!has) { + _viewFocusBox.copy(_viewFocusTmp); + has = true; + } else { + _viewFocusBox.union(_viewFocusTmp); + } + } + const span = has ? Math.max(0.75, _viewFocusBox.getSize(new THREE.Vector3()).length()) : 1.6; + return { + minDistance: Math.max(0.28, span * 0.12), + maxDistance: Math.max(span * 6, 18), + }; +} + +function applyCalibOrbitLimits({ snapCamera = false }: { snapCamera?: boolean } = {}): void { + const lim = calibOrbitDistanceLimits(); + orbit.minDistance = lim.minDistance; + orbit.maxDistance = lim.maxDistance; + if (!snapCamera) return; + const dist = camera.position.distanceTo(orbit.target); + if (dist < lim.minDistance || dist > lim.maxDistance) { + const dir = camera.position.clone().sub(orbit.target); + if (dir.lengthSq() < 1e-8) dir.set(0.58, 0.44, 0.68); + dir.normalize().multiplyScalar(Math.min(lim.maxDistance, Math.max(lim.minDistance, dist))); + camera.position.copy(orbit.target).add(dir); + orbit.update(); + } +} + +function animate(): void { + requestAnimationFrame(animate); + const dt = clock.getDelta(); + player.update(dt); + // Follow the retargeted robot in world space; pause while the user orbits. + // On loop wrap the robot teleports (start ≠ end). Always hard-snap the + // camera with that wrap — even during the post-orbit "manual" grace period — + // otherwise the robot flies across the viewport while the camera stays put. + // + // Target and camera must translate by the *same* delta. Lerping only + // ``orbit.target`` leaves the eye behind; at walk speed the lag settles + // near the hard-snap threshold (~0.5 m) and the view stutter-snaps every + // few frames. + const loopSnap = player._justLooped; + player._justLooped = false; + if ( + !state.calibrationMode && + robot.group.visible && robot.trajectory && + (loopSnap || performance.now() > _orbitManualUntil) + ) { + robot.group.getWorldPosition(_camFocus); + const dx = _camFocus.x - orbit.target.x; + const dy = _camFocus.y - orbit.target.y; + const dz = _camFocus.z - orbit.target.z; + const jumpSq = dx * dx + dy * dy + dz * dz; + const a = (loopSnap || jumpSq > 0.25) ? 1 : Math.min(1, dt * 12); + const ox = dx * a; + const oy = dy * a; + const oz = dz * a; + orbit.target.x += ox; + orbit.target.y += oy; + orbit.target.z += oz; + camera.position.x += ox; + camera.position.y += oy; + camera.position.z += oz; + } + if ((state.calibrationMode || r2r.calibrating) && calibManip.active && !calibManip._hudCardDrag) { + calibManip._positionTags(); + refSkel.updateOverlay(r2r.calibrating ? r2rTgt : robot); + } + orbit.update(); + renderer.render(scene, camera); +} +resize(); +// NOTE: the render loop is started at the very bottom of this module, after +// `player` is defined — calling animate() here would hit the const TDZ. + +// ================================================================= SKELETON +class SkeletonView implements PlaybackView { + readonly group: THREE.Group; + joints: Vec3[][] | null = null; + parents: number[] = []; + spheres: Array> = []; + lineGeom: THREE.BufferGeometry | null = null; + lines: THREE.LineSegments | null = null; + frameIndices: number[] | null | undefined = null; + color = 0x0a84ff; + exclude = new Set(); + clipDuration = 1; + + constructor() { + this.group = new THREE.Group(); + world.add(this.group); + } + clear(): void { + while (this.group.children.length) this.group.remove(this.group.children[0]); + this.spheres = []; + this.joints = null; + } + load(motion: MotionPayload, color = 0x0a84ff): void { + this.clear(); + this.color = color; + this.joints = motion.positions; // (F, J, 3) + this.parents = motion.parent_indices; + this.exclude = new Set(motion.exclude_joint_indices || []); + this.frameIndices = motion.frame_indices; + this.clipDuration = effectivePlaybackDuration(motion); + const J = this.parents.length; + const mat = new THREE.MeshStandardMaterial({ color, roughness: 0.5, metalness: 0.1 }); + const sphereGeo = new THREE.SphereGeometry(0.028, 12, 12); + for (let j = 0; j < J; j++) { + const s = new THREE.Mesh(sphereGeo, mat); + if (this.exclude.has(j)) s.visible = false; + this.group.add(s); + this.spheres.push(s); + } + let segCount = 0; + for (let j = 0; j < J; j++) { + const p = this.parents[j]; + if (p < 0 || this.exclude.has(j) || this.exclude.has(p)) continue; + segCount++; + } + const positions = new Float32Array(segCount * 2 * 3); + this.lineGeom = new THREE.BufferGeometry(); + this.lineGeom.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + this.lines = new THREE.LineSegments( + this.lineGeom, + new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.7 }) + ); + this.group.add(this.lines); + this.setFrame(0); + } + get numFrames(): number { + return this.joints ? this.joints.length : 0; + } + setFrame(f: number): void { + this.setFrameFrac(f); + } + setFrameFrac(fi: number): void { + if (!this.joints || !this.lineGeom) return; + const max = this.joints.length - 1; + const { ia, ib, t } = resolvePlaybackFrame(this.frameIndices, fi, max); + const fr = this.joints[ia]; + if (!fr) return; + const blend = t > 1e-5 && ia !== ib; + const nxt = blend ? this.joints[ib] : undefined; + for (let j = 0; j < this.spheres.length; j++) { + if (nxt) { + this.spheres[j].position.set( + fr[j][0] + (nxt[j][0] - fr[j][0]) * t, + fr[j][1] + (nxt[j][1] - fr[j][1]) * t, + fr[j][2] + (nxt[j][2] - fr[j][2]) * t, + ); + } else { + this.spheres[j].position.set(fr[j][0], fr[j][1], fr[j][2]); + } + } + const position = this.lineGeom.getAttribute("position") as THREE.BufferAttribute; + const arr = position.array; + let k = 0; + for (let j = 0; j < this.parents.length; j++) { + const p = this.parents[j]; + if (p < 0 || this.exclude.has(j) || this.exclude.has(p)) continue; + if (nxt) { + arr[k++] = fr[j][0] + (nxt[j][0] - fr[j][0]) * t; + arr[k++] = fr[j][1] + (nxt[j][1] - fr[j][1]) * t; + arr[k++] = fr[j][2] + (nxt[j][2] - fr[j][2]) * t; + arr[k++] = fr[p][0] + (nxt[p][0] - fr[p][0]) * t; + arr[k++] = fr[p][1] + (nxt[p][1] - fr[p][1]) * t; + arr[k++] = fr[p][2] + (nxt[p][2] - fr[p][2]) * t; + } else { + arr[k++] = fr[j][0]; arr[k++] = fr[j][1]; arr[k++] = fr[j][2]; + arr[k++] = fr[p][0]; arr[k++] = fr[p][1]; arr[k++] = fr[p][2]; + } + } + position.needsUpdate = true; + } +} + +interface ReferenceLandmarkMapping { + semantic: string; + targetLink: string; + index: number; + label: HTMLElement; + line: SVGLineElement; +} + +interface ReferenceAlignmentDiagnostic { + semantic: string; + targetLink: string; + positionResidualM: number; + verticalResidualM: number; + rotationResidualDeg: number | null; +} + +const CANONICAL_LANDMARK_LABELS: Record = { + hips: ["Hips", "髋部"], + chest: ["Chest", "胸部"], + neck: ["Neck", "颈部"], + head: ["Head", "头部"], + left_hip: ["Left hip", "左髋"], + right_hip: ["Right hip", "右髋"], + left_knee: ["Left knee", "左膝"], + right_knee: ["Right knee", "右膝"], + left_ankle: ["Left ankle", "左踝"], + right_ankle: ["Right ankle", "右踝"], + left_shoulder: ["Left shoulder", "左肩"], + right_shoulder: ["Right shoulder", "右肩"], + left_elbow: ["Left elbow", "左肘"], + right_elbow: ["Right elbow", "右肘"], + left_wrist: ["Left wrist", "左腕"], + right_wrist: ["Right wrist", "右腕"], +}; + +function normalizedSemanticName(value: unknown): string { + return String(value ?? "").trim().toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +function ikMapTargetLink(value: unknown): string | null { + if (typeof value === "string") return value; + if (!value || typeof value !== "object") return null; + const candidate = value as Record; + for (const key of ["t_body", "link", "body", "target"]) { + if (typeof candidate[key] === "string") return candidate[key] as string; + } + return null; +} + +// Blue reference T-pose shown only during calibration (Viser ReferenceSkeletonRenderer). +class ReferenceSkeletonView { + readonly group: THREE.Group; + readonly labelRoot: HTMLElement; + readonly lineRoot: SVGSVGElement; + spheres: Array> = []; + parents: number[] = []; + boneNames: string[] = []; + canonicalNames: string[] = []; + referenceQuaternions: Array<[number, number, number, number]> = []; + exclude = new Set(); + mappings: ReferenceLandmarkMapping[] = []; + lineGeom: THREE.BufferGeometry | null = null; + lines: THREE.LineSegments | null = null; + mappedMaterial: THREE.MeshStandardMaterial | null = null; + contextMaterial: THREE.MeshStandardMaterial | null = null; + mappedOnly = true; + labelsVisible = true; + mappingLinesVisible = true; + sourceOpacity = 0.82; + + constructor() { + this.group = new THREE.Group(); + this.group.visible = false; + this.labelRoot = document.getElementById("calib-landmark-labels"); + this.lineRoot = document.querySelector("#calib-mapping-overlay")!; + world.add(this.group); + } + + clear(): void { + while (this.group.children.length) this.group.remove(this.group.children[0]); + this.labelRoot.replaceChildren(); + this.lineRoot.replaceChildren(); + this.spheres = []; + this.parents = []; + this.boneNames = []; + this.canonicalNames = []; + this.referenceQuaternions = []; + this.exclude = new Set(); + this.mappings = []; + this.lineGeom = null; + this.lines = null; + this.mappedMaterial = null; + this.contextMaterial = null; + this.group.visible = false; + } + + load(ref: CalibrationReferencePayload | null | undefined): void { + this.clear(); + if (!ref?.positions?.length) return; + const color = ref.color != null ? ref.color : 0x5eb3ff; + const fr = ref.positions[0]; + this.parents = ref.parent_indices; + this.boneNames = ref.bone_names?.slice() ?? this.parents.map((_, index) => `joint_${index}`); + this.canonicalNames = ref.canonical_names?.slice() ?? this.boneNames.slice(); + this.referenceQuaternions = ref.quaternions?.[0]?.slice() ?? []; + this.exclude = new Set(ref.exclude_joint_indices || []); + const jointCount = this.parents.length; + this.mappedMaterial = new THREE.MeshStandardMaterial({ + color, + roughness: 0.34, + metalness: 0.03, + emissive: 0x0a4d92, + emissiveIntensity: 0.62, + transparent: true, + opacity: this.sourceOpacity, + }); + this.contextMaterial = new THREE.MeshStandardMaterial({ + color, + roughness: 0.48, + metalness: 0.02, + emissive: 0x1a3a66, + emissiveIntensity: 0.18, + transparent: true, + opacity: this.sourceOpacity * 0.32, + }); + const sphereGeo = new THREE.SphereGeometry(0.022, 12, 12); + for (let index = 0; index < jointCount; index++) { + const sphere = new THREE.Mesh(sphereGeo, this.contextMaterial); + if (this.exclude.has(index)) sphere.visible = false; + this.group.add(sphere); + this.spheres.push(sphere); + } + let segmentCount = 0; + for (let index = 0; index < jointCount; index++) { + const parent = this.parents[index]; + if (parent < 0 || this.exclude.has(index) || this.exclude.has(parent)) continue; + segmentCount++; + } + const positions = new Float32Array(segmentCount * 2 * 3); + this.lineGeom = new THREE.BufferGeometry(); + this.lineGeom.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + this.lines = new THREE.LineSegments( + this.lineGeom, + new THREE.LineBasicMaterial({ + color, + transparent: true, + opacity: this.sourceOpacity * 0.38, + }), + ); + this.group.add(this.lines); + for (let index = 0; index < jointCount; index++) { + if (this.exclude.has(index)) continue; + this.spheres[index].position.set(fr[index][0], fr[index][1], fr[index][2]); + } + const position = this.lineGeom.getAttribute("position") as THREE.BufferAttribute; + const array = position.array; + let offset = 0; + for (let index = 0; index < jointCount; index++) { + const parent = this.parents[index]; + if (parent < 0 || this.exclude.has(index) || this.exclude.has(parent)) continue; + array[offset++] = fr[index][0]; array[offset++] = fr[index][1]; array[offset++] = fr[index][2]; + array[offset++] = fr[parent][0]; array[offset++] = fr[parent][1]; array[offset++] = fr[parent][2]; + } + position.needsUpdate = true; + this.group.visible = true; + this.applyDisplayOptions(); + } + + configureMappings(ikMap: Record | null | undefined): number { + this.labelRoot.replaceChildren(); + this.lineRoot.replaceChildren(); + this.mappings = []; + const canonicalIndex = new Map(); + this.canonicalNames.forEach((name, index) => canonicalIndex.set(normalizedSemanticName(name), index)); + this.boneNames.forEach((name, index) => { + const key = normalizedSemanticName(name); + if (!canonicalIndex.has(key)) canonicalIndex.set(key, index); + }); + + for (const [semantic, rawTarget] of Object.entries(ikMap ?? {})) { + const targetLink = ikMapTargetLink(rawTarget); + const index = canonicalIndex.get(normalizedSemanticName(semantic)); + if (!targetLink || index == null || this.exclude.has(index)) continue; + + const label = document.createElement("span"); + label.className = "calib-landmark-label"; + const primary = document.createElement("strong"); + const labels = CANONICAL_LANDMARK_LABELS[semantic]; + primary.textContent = labels + ? runtimeText(labels[0], labels[1]) + : semantic.replaceAll("_", " "); + label.append(primary, document.createTextNode(` · ${targetLink}`)); + this.labelRoot.appendChild(label); + + const line = document.createElementNS("http://www.w3.org/2000/svg", "line"); + this.lineRoot.appendChild(line); + this.mappings.push({ semantic, targetLink, index, label, line }); + } + this.applyDisplayOptions(); + return this.mappings.length; + } + + setDisplayOptions({ + mappedOnly, + labels, + mappingLines, + sourceOpacity, + }: { + mappedOnly?: boolean; + labels?: boolean; + mappingLines?: boolean; + sourceOpacity?: number; + }): void { + if (mappedOnly != null) this.mappedOnly = mappedOnly; + if (labels != null) this.labelsVisible = labels; + if (mappingLines != null) this.mappingLinesVisible = mappingLines; + if (sourceOpacity != null) this.sourceOpacity = Math.min(1, Math.max(0.1, sourceOpacity)); + this.applyDisplayOptions(); + } + + private applyDisplayOptions(): void { + const mappedIndices = new Set(this.mappings.map((mapping) => mapping.index)); + this.spheres.forEach((sphere, index) => { + const mapped = mappedIndices.has(index); + sphere.material = mapped && this.mappedMaterial ? this.mappedMaterial : this.contextMaterial ?? sphere.material; + sphere.scale.setScalar(mapped ? 1.12 : 0.62); + sphere.visible = !this.exclude.has(index) && (mapped || !this.mappedOnly); + }); + if (this.mappedMaterial) this.mappedMaterial.opacity = this.sourceOpacity; + if (this.contextMaterial) this.contextMaterial.opacity = this.sourceOpacity * 0.32; + if (this.lines) this.lines.material.opacity = this.sourceOpacity * 0.38; + this.labelRoot.style.display = this.labelsVisible ? "block" : "none"; + this.lineRoot.style.display = this.mappingLinesVisible ? "block" : "none"; + } + + updateOverlay(robotView: RobotView): void { + const active = this.group.visible && this.mappings.length > 0; + const width = this.labelRoot.clientWidth; + const height = this.labelRoot.clientHeight; + if (!active || width <= 0 || height <= 0) { + for (const mapping of this.mappings) { + mapping.label.style.display = "none"; + mapping.line.style.display = "none"; + } + return; + } + + const referencePoint = new THREE.Vector3(); + const targetPoint = new THREE.Vector3(); + for (const mapping of this.mappings) { + this.spheres[mapping.index].getWorldPosition(referencePoint); + if (!robotView.getLinkWorldPosition(mapping.targetLink, targetPoint)) { + mapping.label.style.display = "none"; + mapping.line.style.display = "none"; + continue; + } + const referenceNdc = referencePoint.clone().project(camera); + const targetNdc = targetPoint.clone().project(camera); + const visible = referenceNdc.z >= -1 && referenceNdc.z <= 1 + && targetNdc.z >= -1 && targetNdc.z <= 1; + if (!visible) { + mapping.label.style.display = "none"; + mapping.line.style.display = "none"; + continue; + } + const rx = (referenceNdc.x * 0.5 + 0.5) * width; + const ry = (-referenceNdc.y * 0.5 + 0.5) * height; + const tx = (targetNdc.x * 0.5 + 0.5) * width; + const ty = (-targetNdc.y * 0.5 + 0.5) * height; + mapping.label.style.display = this.labelsVisible ? "block" : "none"; + mapping.label.style.left = `${rx}px`; + mapping.label.style.top = `${ry}px`; + mapping.line.style.display = this.mappingLinesVisible ? "block" : "none"; + mapping.line.setAttribute("x1", String(rx)); + mapping.line.setAttribute("y1", String(ry)); + mapping.line.setAttribute("x2", String(tx)); + mapping.line.setAttribute("y2", String(ty)); + } + } + + alignmentDiagnostics(robotView: RobotView): ReferenceAlignmentDiagnostic[] { + const referencePosition = new THREE.Vector3(); + const targetPosition = new THREE.Vector3(); + const targetQuaternion = new THREE.Quaternion(); + const worldQuaternion = new THREE.Quaternion(); + world.getWorldQuaternion(worldQuaternion); + return this.mappings.flatMap((mapping) => { + this.spheres[mapping.index].getWorldPosition(referencePosition); + if (!robotView.getLinkWorldPosition(mapping.targetLink, targetPosition)) return []; + let rotationResidualDeg: number | null = null; + const rawQuaternion = this.referenceQuaternions[mapping.index]; + if (rawQuaternion && robotView.getLinkWorldQuaternion(mapping.targetLink, targetQuaternion)) { + const referenceQuaternion = worldQuaternion.clone().multiply( + new THREE.Quaternion(rawQuaternion[0], rawQuaternion[1], rawQuaternion[2], rawQuaternion[3]), + ); + const dot = Math.min(1, Math.abs(referenceQuaternion.dot(targetQuaternion))); + rotationResidualDeg = 2 * Math.acos(dot) * 180 / Math.PI; + } + return [{ + semantic: mapping.semantic, + targetLink: mapping.targetLink, + positionResidualM: referencePosition.distanceTo(targetPosition), + verticalResidualM: Math.abs(referencePosition.z - targetPosition.z), + rotationResidualDeg, + }]; + }); + } + + headingResidualDeg(robotView: RobotView): number | null { + const findMapping = (semantic: string) => this.mappings.find( + (mapping) => normalizedSemanticName(mapping.semantic) === normalizedSemanticName(semantic), + ); + const candidates: Array = [ + ["left_shoulder", "right_shoulder"], + ["left_hip", "right_hip"], + ]; + const refLeft = new THREE.Vector3(); + const refRight = new THREE.Vector3(); + const targetLeft = new THREE.Vector3(); + const targetRight = new THREE.Vector3(); + for (const [leftName, rightName] of candidates) { + const left = findMapping(leftName); + const right = findMapping(rightName); + if (!left || !right) continue; + this.spheres[left.index].getWorldPosition(refLeft); + this.spheres[right.index].getWorldPosition(refRight); + if (!robotView.getLinkWorldPosition(left.targetLink, targetLeft)) continue; + if (!robotView.getLinkWorldPosition(right.targetLink, targetRight)) continue; + const referenceAxis = refRight.clone().sub(refLeft).setZ(0); + const targetAxis = targetRight.clone().sub(targetLeft).setZ(0); + if (referenceAxis.lengthSq() < 1e-8 || targetAxis.lengthSq() < 1e-8) continue; + return referenceAxis.angleTo(targetAxis) * 180 / Math.PI; + } + return null; + } +} + +// ================================================================= ENVIRONMENT (terrain + interaction objects) +// Owns the static terrain mesh AND the per-frame object props. Crucially this +// is a *separate* view from the skeleton: in Viser the objects follow the clip +// even when the stick figure is hidden, so object animation must NOT be tied to +// SkeletonView visibility (the previous bug: hiding the skeleton froze props). +class EnvView { + readonly group: THREE.Group; + objectMeshes: THREE.Object3D[] = []; + objectTraj: SceneObjectPayload[] = []; + joints: SceneObjectPayload[] | null = null; + clipDuration = 1; + + constructor() { + this.group = env; // reuse the existing env group (child of world) + } + clear(): void { + while (this.group.children.length) this.group.remove(this.group.children[0]); + this.objectMeshes = []; + this.objectTraj = []; + this.joints = null; + } + load(motion: MotionPayload): void { + this.clear(); + this.clipDuration = effectivePlaybackDuration(motion); + if (motion.terrain) { + const m = buildTerrainMesh(motion.terrain); + if (m) this.group.add(m); + } + (motion.objects || []).forEach((o, i) => this._buildObject(o, i, motion.token)); + // Mark as animatable so the shared player drives setFrame each tick. + this.joints = this.objectTraj.length ? this.objectTraj : null; + this.setFrame(0); + } + private _buildObject(o: SceneObjectPayload, i: number, token: string): void { + const c = o.color ? (o.color[0] << 16) | (o.color[1] << 8) | o.color[2] : 0xff9f0a; + const box = new THREE.Mesh( + new THREE.BoxGeometry(o.extents[0], o.extents[1], o.extents[2]), + new THREE.MeshStandardMaterial({ + color: c, transparent: true, opacity: o.opacity ?? 0.55, roughness: 0.6, + }) + ); + this.group.add(box); + this.objectMeshes.push(box); + this.objectTraj.push(o); + if (o.has_mesh && token) { + const loader = new GLTFLoader(); + loader.load( + `/api/object_glb?token=${token}&index=${i}`, + (gltf) => { + const real = gltf.scene; + // GLB from /api/object_glb is already centred + scaled on the server. + box.geometry.dispose(); + box.visible = false; + this.group.add(real); + this.objectMeshes[i] = real; + }, + undefined, + () => {} // keep box on failure + ); + } + } + get numFrames(): number { + return this.objectTraj.length && this.objectTraj[0].positions + ? this.objectTraj[0].positions.length : 0; + } + setFrame(f: number): void { + for (let i = 0; i < this.objectMeshes.length; i++) { + const o = this.objectTraj[i]; + if (!o || !o.positions[f]) continue; + const m = this.objectMeshes[i]; + m.position.set(o.positions[f][0], o.positions[f][1], o.positions[f][2]); + const q = o.quaternions[f]; + m.quaternion.set(q[0], q[1], q[2], q[3]); // backend sends xyzw + } + } +} + +// Scaled terrain + props in the robot retarget frame (teal tint, co-located with robot). +class ScaledEnvView { + readonly group: THREE.Group; + objectMeshes: THREE.Object3D[] = []; + objectTraj: SceneObjectPayload[] = []; + joints: SceneObjectPayload[] | null = null; + motionToken: string | null | undefined = null; + clipDuration = 1; + private _objectGlbUrl: ((object: SceneObjectPayload, index: number) => string | null) | null = null; + + constructor(group: THREE.Group = scaledEnvGroup) { + this.group = group; + this.group.visible = false; + } + clear(): void { + while (this.group.children.length) this.group.remove(this.group.children[0]); + this.objectMeshes = []; + this.objectTraj = []; + this.joints = null; + } + load( + scene: ScenePayload | null | undefined, + motionToken?: string | null, + opts: { + duration?: number; + objectGlbUrl?: (object: SceneObjectPayload, index: number) => string | null; + } = {}, + ): void { + this.clear(); + if (!scene) return; + this.motionToken = motionToken; + this._objectGlbUrl = opts.objectGlbUrl || null; + this.clipDuration = Math.max(0.1, opts.duration ?? state.motion?.duration ?? 1); + if (scene.terrain) { + const m = buildTerrainMesh(scene.terrain); + if (m) { + m.material = new THREE.MeshStandardMaterial({ + color: 0x5c7a9e, roughness: 0.9, side: THREE.DoubleSide, flatShading: true, + transparent: true, opacity: 0.92, + }); + this.group.add(m); + } + } + (scene.objects || []).forEach((o, i) => this._buildObject(o, i)); + this.joints = this.objectTraj.length ? this.objectTraj : null; + this.setFrame(0); + } + private _buildObject(o: SceneObjectPayload, i: number): void { + const c = o.color ? (o.color[0] << 16) | (o.color[1] << 8) | o.color[2] : 0x6a9fd4; + const box = new THREE.Mesh( + new THREE.BoxGeometry(o.extents[0], o.extents[1], o.extents[2]), + new THREE.MeshStandardMaterial({ + color: c, transparent: true, opacity: o.opacity ?? 0.7, roughness: 0.55, + }) + ); + this.group.add(box); + this.objectMeshes.push(box); + this.objectTraj.push(o); + const srcIdx = o.source_index ?? i; + const glbUrl = this._objectGlbUrl + ? this._objectGlbUrl(o, srcIdx) + : (this.motionToken + ? `/api/object_glb?token=${this.motionToken}&index=${srcIdx}${ + o.scale != null && Number.isFinite(o.scale) + ? `&scale=${encodeURIComponent(o.scale)}` : "" + }` + : null); + if (o.has_mesh && glbUrl) { + const loader = new GLTFLoader(); + loader.load( + glbUrl, + (gltf) => { + const real = gltf.scene; + box.geometry.dispose(); + box.visible = false; + this.group.add(real); + this.objectMeshes[i] = real; + }, + undefined, + () => {} + ); + } + } + get numFrames(): number { + return this.objectTraj.length && this.objectTraj[0].positions + ? this.objectTraj[0].positions.length : 0; + } + setFrame(f: number): void { + this.setFrameFrac(f); + } + setFrameFrac(fi: number): void { + if (!this.objectTraj.length) return; + const max = this.numFrames - 1; + const { ia, ib, t } = resolvePlaybackFrame(null, fi, max); + for (let i = 0; i < this.objectMeshes.length; i++) { + const o = this.objectTraj[i]; + if (!o?.positions?.length) continue; + const fr = o.positions[ia]; + if (!fr) continue; + const m = this.objectMeshes[i]; + const blend = t > 1e-5 && ia !== ib && o.positions[ib]; + if (blend) { + const nxt = o.positions[ib]; + m.position.set( + fr[0] + (nxt[0] - fr[0]) * t, + fr[1] + (nxt[1] - fr[1]) * t, + fr[2] + (nxt[2] - fr[2]) * t, + ); + const qa = o.quaternions[ia]; + const qb = o.quaternions[ib]; + m.quaternion.set(qa[0], qa[1], qa[2], qa[3]); + _robotRootQuatB.set(qb[0], qb[1], qb[2], qb[3]); + m.quaternion.slerp(_robotRootQuatB, t); + } else { + m.position.set(fr[0], fr[1], fr[2]); + const q = o.quaternions[ia]; + m.quaternion.set(q[0], q[1], q[2], q[3]); + } + } + } +} + +// ================================================================= BODY MESH +// Per-bone tube + joint-bead "pseudo body" mesh, rebuilt from the same joint +// positions as the skeleton — works for ANY format (no SMPL weights needed). +// Mirrors hhtools.viewer.renderers.capsule_mesh. +const _SEG = 6; // fewer tube segments → smoother LAFAN / long clips +interface PrimitiveGeometryData { + verts: Vec3[]; + faces: Array<[number, number, number]>; +} + +function _unitCylinder(segments: number): PrimitiveGeometryData { + const verts: Vec3[] = []; + for (let r = 0; r < 2; r++) + for (let i = 0; i < segments; i++) { + const a = (i / segments) * Math.PI * 2; + verts.push([Math.cos(a), Math.sin(a), r]); // bottom ring z=0, top ring z=1 + } + const faces: Array<[number, number, number]> = []; + for (let i = 0; i < segments; i++) { + const j = (i + 1) % segments; + faces.push([i, j, i + segments], [j, j + segments, i + segments]); + } + return { verts, faces }; +} +function _unitIcosphere(): PrimitiveGeometryData { + const t = (1 + Math.sqrt(5)) / 2; + const verts: Vec3[] = ([ + [-1, t, 0], [1, t, 0], [-1, -t, 0], [1, -t, 0], + [0, -1, t], [0, 1, t], [0, -1, -t], [0, 1, -t], + [t, 0, -1], [t, 0, 1], [-t, 0, -1], [-t, 0, 1], + ] as Vec3[]).map((v): Vec3 => { + const n = Math.hypot(...v); + return [v[0] / n, v[1] / n, v[2] / n]; + }); + const faces: Array<[number, number, number]> = [ + [0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11], + [1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8], + [3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9], + [4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1], + ]; + return { verts, faces }; +} +class CapsuleMeshView { + readonly group: THREE.Group; + readonly heavy = false; + joints: Vec3[][] | null = null; + frameIndices: number[] | null | undefined = null; + mesh: THREE.Mesh | null = null; + readonly boneRadius = 0.035; + readonly jointRadius = 0.05; + readonly cyl = _unitCylinder(_SEG); + readonly sph = _unitIcosphere(); + edges: Array<[number, number]> = []; + visibleJoints: number[] = []; + numJoints = 0; + positions = new Float32Array(); + clipDuration = 1; + + constructor() { + this.group = new THREE.Group(); + this.group.visible = false; + world.add(this.group); + } + get ready(): boolean { return this.mesh != null && this.joints != null; } + clear(): void { + if (this.mesh) { this.group.remove(this.mesh); this.mesh.geometry.dispose(); this.mesh = null; } + this.joints = null; + this.frameIndices = null; + } + load(motion: MotionPayload): void { + this.clear(); + this.joints = motion.positions; + this.frameIndices = motion.frame_indices; + this.clipDuration = effectivePlaybackDuration(motion); + const parents = motion.parent_indices; + const exclude = new Set(motion.exclude_joint_indices || []); + this.edges = []; + for (let j = 0; j < parents.length; j++) { + const p = parents[j]; + if (p < 0 || exclude.has(j) || exclude.has(p)) continue; + this.edges.push([p, j]); + } + this.visibleJoints = []; + for (let j = 0; j < parents.length; j++) { + if (!exclude.has(j)) this.visibleJoints.push(j); + } + this.numJoints = this.visibleJoints.length; + // build index buffer once + const vpb = this.cyl.verts.length; // verts per bone + const vpj = this.sph.verts.length; // verts per joint + const totalBoneV = this.edges.length * vpb; + const idx: number[] = []; + this.edges.forEach((_, e) => this.cyl.faces.forEach((f) => + idx.push(f[0] + e * vpb, f[1] + e * vpb, f[2] + e * vpb))); + for (let j = 0; j < this.numJoints; j++) + this.sph.faces.forEach((f) => idx.push( + f[0] + totalBoneV + j * vpj, f[1] + totalBoneV + j * vpj, f[2] + totalBoneV + j * vpj)); + const nVerts = totalBoneV + this.numJoints * vpj; + this.positions = new Float32Array(nVerts * 3); + const geo = new THREE.BufferGeometry(); + geo.setAttribute("position", new THREE.BufferAttribute(this.positions, 3)); + geo.setIndex(idx); + this.mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ + color: 0xf7a470, roughness: 0.6, metalness: 0.05, + side: THREE.DoubleSide, flatShading: true, + })); + this.group.add(this.mesh); + this.setFrame(0); + } + get numFrames(): number { return this.joints ? this.joints.length : 0; } + setFrame(f: number): void { + this.setFrameFrac(f); + } + setFrameFrac(fi: number): void { + if (!this.mesh || !this.joints) return; + const max = this.joints.length - 1; + const { ia, ib, t } = resolvePlaybackFrame(this.frameIndices, fi, max); + const fr = this.joints[ia]; + if (!fr) return; + const blend = t > 1e-5 && ia !== ib; + const nxt = blend ? this.joints[ib] : undefined; + const pos = this.positions; + let o = 0; + const r = this.boneRadius; + for (const [pi, ci] of this.edges) { + let sx = fr[pi][0], sy = fr[pi][1], sz = fr[pi][2]; + let ex = fr[ci][0], ey = fr[ci][1], ez = fr[ci][2]; + if (nxt) { + sx += (nxt[pi][0] - sx) * t; + sy += (nxt[pi][1] - sy) * t; + sz += (nxt[pi][2] - sz) * t; + ex += (nxt[ci][0] - ex) * t; + ey += (nxt[ci][1] - ey) * t; + ez += (nxt[ci][2] - ez) * t; + } + const s = [sx, sy, sz], e = [ex, ey, ez]; + let dx = e[0] - s[0], dy = e[1] - s[1], dz = e[2] - s[2]; + let len = Math.hypot(dx, dy, dz) || 1e-6; + dx /= len; dy /= len; dz /= len; + // orthonormal basis + let rx, ry, rz; + if (Math.abs(dx) < 0.9) { rx = 1; ry = 0; rz = 0; } else { rx = 0; ry = 1; rz = 0; } + let ax = dy * rz - dz * ry, ay = dz * rx - dx * rz, az = dx * ry - dy * rx; + let an = Math.hypot(ax, ay, az) || 1; ax /= an; ay /= an; az /= an; + const ux = dy * az - dz * ay, uy = dz * ax - dx * az, uz = dx * ay - dy * ax; + for (const v of this.cyl.verts) { + pos[o++] = s[0] + ax * (v[0] * r) + ux * (v[1] * r) + dx * (v[2] * len); + pos[o++] = s[1] + ay * (v[0] * r) + uy * (v[1] * r) + dy * (v[2] * len); + pos[o++] = s[2] + az * (v[0] * r) + uz * (v[1] * r) + dz * (v[2] * len); + } + } + const jr = this.jointRadius; + for (const j of this.visibleJoints) { + let cx = fr[j][0], cy = fr[j][1], cz = fr[j][2]; + if (nxt) { + cx += (nxt[j][0] - cx) * t; + cy += (nxt[j][1] - cy) * t; + cz += (nxt[j][2] - cz) * t; + } + for (const v of this.sph.verts) { + pos[o++] = cx + v[0] * jr; pos[o++] = cy + v[1] * jr; pos[o++] = cz + v[2] * jr; + } + } + this.mesh.geometry.attributes.position.needsUpdate = true; + } +} + +// ================================================================= SCALED SKELETON (pre-IK, robot-calibrated) +class ScaledSkeletonView { + readonly group: THREE.Group; + joints: Vec3[][] | null = null; + parents: number[] = []; + frameIndices: number[] | null | undefined = null; + spheres: Array> = []; + lineGeom: THREE.BufferGeometry | null = null; + lines: THREE.LineSegments | null = null; + readonly color: number; + clipDuration = 1; + + constructor(color = 0xffb020) { + this.color = color; + this.group = new THREE.Group(); + this.group.visible = false; + world.add(this.group); + } + clear(): void { + while (this.group.children.length) this.group.remove(this.group.children[0]); + this.spheres = []; + this.joints = null; + this.frameIndices = null; + } + load(motion: MotionPayload): void { + this.clear(); + this.joints = motion.positions; + this.parents = motion.parent_indices; + this.frameIndices = motion.frame_indices; + this.clipDuration = effectivePlaybackDuration(motion); + const J = this.parents.length; + const mat = new THREE.MeshStandardMaterial({ + color: this.color, roughness: 0.45, metalness: 0.15, emissive: 0x442200, + }); + const sphereGeo = new THREE.SphereGeometry(0.026, 10, 10); + for (let j = 0; j < J; j++) { + const s = new THREE.Mesh(sphereGeo, mat); + this.group.add(s); + this.spheres.push(s); + } + const segCount = this.parents.filter((p) => p >= 0).length; + const positions = new Float32Array(segCount * 2 * 3); + this.lineGeom = new THREE.BufferGeometry(); + this.lineGeom.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + this.lines = new THREE.LineSegments( + this.lineGeom, + new THREE.LineBasicMaterial({ color: this.color, transparent: true, opacity: 0.85 }) + ); + this.group.add(this.lines); + this.setFrame(0); + } + get numFrames(): number { return this.joints ? this.joints.length : 0; } + setFrame(f: number): void { + this.setFrameFrac(f); + } + setFrameFrac(fi: number): void { + if (!this.joints || !this.lineGeom) return; + const max = this.joints.length - 1; + const { ia, ib, t } = resolvePlaybackFrame(this.frameIndices, fi, max); + const fr = this.joints[ia]; + if (!fr) return; + const blend = t > 1e-5 && ia !== ib; + const nxt = blend ? this.joints[ib] : undefined; + for (let j = 0; j < this.spheres.length; j++) { + if (nxt) { + this.spheres[j].position.set( + fr[j][0] + (nxt[j][0] - fr[j][0]) * t, + fr[j][1] + (nxt[j][1] - fr[j][1]) * t, + fr[j][2] + (nxt[j][2] - fr[j][2]) * t, + ); + } else { + this.spheres[j].position.set(fr[j][0], fr[j][1], fr[j][2]); + } + } + const position = this.lineGeom.getAttribute("position") as THREE.BufferAttribute; + const arr = position.array; + let k = 0; + for (let j = 0; j < this.parents.length; j++) { + const p = this.parents[j]; + if (p < 0) continue; + if (nxt) { + arr[k++] = fr[j][0] + (nxt[j][0] - fr[j][0]) * t; + arr[k++] = fr[j][1] + (nxt[j][1] - fr[j][1]) * t; + arr[k++] = fr[j][2] + (nxt[j][2] - fr[j][2]) * t; + arr[k++] = fr[p][0] + (nxt[p][0] - fr[p][0]) * t; + arr[k++] = fr[p][1] + (nxt[p][1] - fr[p][1]) * t; + arr[k++] = fr[p][2] + (nxt[p][2] - fr[p][2]) * t; + } else { + arr[k++] = fr[j][0]; arr[k++] = fr[j][1]; arr[k++] = fr[j][2]; + arr[k++] = fr[p][0]; arr[k++] = fr[p][1]; arr[k++] = fr[p][2]; + } + } + position.needsUpdate = true; + } +} + +// ================================================================= SKINNED BODY (SMPL / baked) +class BakedMeshView { + readonly group: THREE.Group; + readonly heavy = true; + mesh: THREE.Mesh | null = null; + verts: Float32Array | null = null; + numVerts = 0; + ready = false; + clipDuration: number | null = null; + + constructor() { + this.group = new THREE.Group(); + this.group.visible = false; + world.add(this.group); + } + clear(): void { + if (this.mesh) { + this.group.remove(this.mesh); + this.mesh.geometry.dispose(); + this.mesh = null; + } + this.verts = null; + this.ready = false; + } + async load(bodyMesh: BodyMeshPayload | null | undefined): Promise { + this.clear(); + if (!bodyMesh?.available) return; + try { + const bin = Uint8Array.from(atob(bodyMesh.vertices_gz_b64), (c) => c.charCodeAt(0)); + const ds = new DecompressionStream("gzip"); + const buf = await new Response(new Blob([bin]).stream().pipeThrough(ds)).arrayBuffer(); + this.verts = new Float32Array(buf); + this.numVerts = bodyMesh.num_verts; + const numFrames = bodyMesh.num_frames; + const expected = numFrames * this.numVerts * 3; + if (this.verts.length !== expected) { + console.warn("baked mesh vertex buffer size mismatch", this.verts.length, expected); + return; + } + this.clipDuration = null; // driven by skeleton timeline + const idx = bodyMesh.triangles.flat(); + const geo = new THREE.BufferGeometry(); + geo.setAttribute( + "position", + new THREE.BufferAttribute(this.verts.slice(0, this.numVerts * 3), 3) + ); + geo.setIndex(idx); + geo.computeVertexNormals(); + this.mesh = new THREE.Mesh( + geo, + new THREE.MeshStandardMaterial({ + color: 0xb4c8dc, roughness: 0.55, metalness: 0.05, + side: THREE.DoubleSide, flatShading: true, + }) + ); + this.group.add(this.mesh); + this.ready = true; + this.setFrame(0); + } catch (e) { + console.warn("baked mesh decode failed", e); + this.ready = false; + } + } + get numFrames(): number { + return this.ready && this.numVerts && this.verts + ? this.verts.length / (this.numVerts * 3) + : 0; + } + setFrame(f: number): void { + this.setFrameFrac(f); + } + setFrameFrac(fi: number): void { + if (!this.ready || !this.mesh || !this.verts) return; + const max = this.numFrames - 1; + const f0 = Math.min(max, Math.floor(fi)); + const off0 = f0 * this.numVerts * 3; + const attr = this.mesh.geometry.attributes.position; + const t = fi - f0; + if (t <= 1e-5 || f0 >= max) { + attr.array.set(this.verts.subarray(off0, off0 + this.numVerts * 3)); + } else { + const off1 = (f0 + 1) * this.numVerts * 3; + const dst = attr.array; + const a = this.verts; + const n = this.numVerts * 3; + for (let i = 0; i < n; i++) { + dst[i] = a[off0 + i] + (a[off1 + i] - a[off0 + i]) * t; + } + } + attr.needsUpdate = true; + } +} + +// ================================================================= ROBOT +const _robotLinkDelta = new THREE.Matrix4(); +const _robotMeshMat = new THREE.Matrix4(); +const _robotLinkMat = new THREE.Matrix4(); +const _robotWorldLinkMat = new THREE.Matrix4(); +const _robotRootQuatB = new THREE.Quaternion(); +const _robotMatB = new THREE.Matrix4(); +const _robotPosA = new THREE.Vector3(); +const _robotPosB = new THREE.Vector3(); +const _robotQuatA = new THREE.Quaternion(); +const _robotQuatB2 = new THREE.Quaternion(); +const _robotScaleA = new THREE.Vector3(); +const _robotScaleB = new THREE.Vector3(); + +interface RobotLinkMeshEntry { + mesh: THREE.Mesh; + baked: THREE.Matrix4; +} + +class RobotView { + readonly group: THREE.Group; + linkMeshes: Record = {}; + meshToLink: Record = {}; + zeroInv: Record = {}; + zero: Record = {}; + currentLinkTransforms: Record = {}; + links: string[] = []; + trajectory: RobotTrajectoryPayload | null = null; + frameIndices: number[] | null | undefined = null; + groundOffset = 0; + clipDuration = 1; + readonly heavy = true; + + constructor() { + this.group = new THREE.Group(); + world.add(this.group); + this.group.visible = false; + } + clear(): void { + while (this.group.children.length) this.group.remove(this.group.children[0]); + this.linkMeshes = {}; + this.meshToLink = {}; + this.zeroInv = {}; + this.currentLinkTransforms = {}; + this.trajectory = null; + } + setVisible(v: boolean): void { + this.group.visible = v; + } + // No trajectory yet: drop the robot on the ground at its zero/T-pose. + applyStatic(): void { + this.group.position.set(0, 0, this.groundOffset); + this.group.quaternion.identity(); + for (const link of this.links) { + const entry = this.linkMeshes[link]; + if (!entry) continue; + for (const { mesh, baked } of entry) mesh.matrix.copy(baked); + } + this.currentLinkTransforms = this.zero; + this.group.updateMatrixWorld(true); + } + async load(robot: RobotPayload): Promise { + this.clear(); + this.links = robot.links; + this.meshToLink = robot.mesh_to_link || {}; + this.zero = robot.link_transforms_zero; + this.currentLinkTransforms = this.zero; + this.groundOffset = robot.ground_offset_z || 0; + for (const link of this.links) { + const m = mat4(this.zero[link]); + this.zeroInv[link] = m.clone().invert(); + } + if (!robot.glb_base64) { + // fall back to link-frame skeleton + this._buildLinkSkeleton(); + this.applyStatic(); + return; + } + const bytes = Uint8Array.from(atob(robot.glb_base64), (c) => c.charCodeAt(0)); + const loader = new GLTFLoader(); + await new Promise((resolve) => { + loader.parse(bytes.buffer as ArrayBuffer, "", (gltf) => { + gltf.scene.updateMatrixWorld(true); + const meshes: THREE.Mesh[] = []; + gltf.scene.traverse((node) => { + const candidate = node as THREE.Mesh; + if (candidate.isMesh) meshes.push(candidate); + }); + for (const mesh of meshes) { + const link = this._linkForNode(mesh); + if (!link) continue; + mesh.userData.hhtoolsLink = link; + const baked = mesh.matrixWorld.clone(); + mesh.matrixAutoUpdate = false; + // trimesh→GLB exports frequently omit vertex normals; without them + // any lit material renders pure black. Compute them once here. + const g = mesh.geometry; + if (g && !g.getAttribute("normal")) { + g.computeVertexNormals(); + } + applyRobotMaterial(mesh); + this.group.add(mesh); + mesh.matrix.copy(baked); + mesh.updateMatrixWorld(true); + (this.linkMeshes[link] ||= []).push({ mesh, baked }); + } + this.group.updateMatrixWorld(true); + resolve(); + }, () => { this._buildLinkSkeleton(); resolve(); }); + }); + this.applyStatic(); + } + private _normPickKey(s: unknown): string { + return String(s ?? "").toLowerCase().replace(/[^a-z0-9]/g, ""); + } + private _meshBasename(name: unknown): string { + const base = String(name || "").split(/[/\\]/).pop(); + return (base ?? "").replace(/\.[^.]+$/, ""); + } + _linkForNode(node: THREE.Object3D): string | null { + const names = this.links; + let cur: THREE.Object3D | null = node; + while (cur) { + const tagged = cur.userData?.hhtoolsLink; + if (tagged) return tagged; + const raw = cur.name || ""; + if (this.meshToLink[raw]) return this.meshToLink[raw]; + const base = this._meshBasename(raw); + if (this.meshToLink[base]) return this.meshToLink[base]; + const cn = this._normPickKey(raw); + for (const l of names) { + if (this._normPickKey(l) === cn && cn) return l; + } + const sn = this._normPickKey(base); + if (sn) { + for (const l of names) { + const ln = this._normPickKey(l); + const lc = ln.endsWith("link") ? ln.slice(0, -4) : ln; + if (lc === sn || ln === sn) return l; + } + } + cur = cur.parent; + } + return null; + } + private _buildLinkSkeleton(): void { + const geo = new THREE.SphereGeometry(0.02, 8, 8); + const matl = new THREE.MeshStandardMaterial({ color: 0xb8bdc6 }); + for (const link of this.links) { + const s = new THREE.Mesh(geo, matl); + s.matrixAutoUpdate = false; + s.matrix.copy(mat4(this.zero[link])); + this.group.add(s); + (this.linkMeshes[link] ||= []).push({ mesh: s, baked: mat4(this.zero[link]) }); + } + } + setTrajectory(traj: RobotTrajectoryPayload): void { + this.trajectory = traj; + this.frameIndices = traj.frame_indices; + this.clipDuration = effectivePlaybackDuration(traj); + // IK root + mesh_z_lift (align mesh sole to yellow overlay foot when present). + this.setFrame(0); + } + get numFrames(): number { + return this.trajectory ? this.trajectory.frames.length : 0; + } + setFrame(f: number): void { + this.setFrameFrac(f); + } + setFrameFrac(fi: number): void { + if (!this.trajectory) return; + const max = this.trajectory.frames.length - 1; + const { ia, ib, t } = resolvePlaybackFrame(this.frameIndices, fi, max); + const frame = this.trajectory.frames[ia]; + if (!frame) return; + const nxtFrame = t > 1e-5 && ia !== ib ? this.trajectory.frames[ib] : null; + const root = frame.root; + const liftA = frame.mesh_z_lift || 0; + const liftB = nxtFrame?.mesh_z_lift ?? liftA; + const meshLift = liftA + (liftB - liftA) * t; + if (root) { + if (t > 1e-5 && ia !== ib) { + const nxt = this.trajectory.frames[ib]?.root; + if (nxt) { + this.group.position.set( + root[0] + (nxt[0] - root[0]) * t, + root[1] + (nxt[1] - root[1]) * t, + root[2] + (nxt[2] - root[2]) * t + meshLift, + ); + this.group.quaternion.set(root[3], root[4], root[5], root[6]); + _robotRootQuatB.set(nxt[3], nxt[4], nxt[5], nxt[6]); + this.group.quaternion.slerp(_robotRootQuatB, t); + } else { + this.group.position.set(root[0], root[1], root[2] + meshLift); + this.group.quaternion.set(root[3], root[4], root[5], root[6]); + } + } else { + this.group.position.set(root[0], root[1], root[2] + meshLift); + this.group.quaternion.set(root[3], root[4], root[5], root[6]); + } + } + this._applyLinkTransforms(frame.links, nxtFrame ? nxtFrame.links : null, t); + } + /** Pose link meshes from FK (calibration preview) or trajectory frame. */ + private _applyLinkTransforms( + linkTransforms: Record, + nextTransforms: Record | null = null, + t = 0, + ): void { + this.currentLinkTransforms = linkTransforms; + const lerp = nextTransforms != null && t > 1e-5; + for (const link of this.links) { + const entry = this.linkMeshes[link]; + if (!entry || !linkTransforms[link]) continue; + mat4Into(linkTransforms[link], _robotLinkMat); + if (lerp && nextTransforms[link]) { + mat4Into(nextTransforms[link], _robotMatB); + _robotLinkMat.decompose(_robotPosA, _robotQuatA, _robotScaleA); + _robotMatB.decompose(_robotPosB, _robotQuatB2, _robotScaleB); + _robotPosA.lerp(_robotPosB, t); + _robotQuatA.slerp(_robotQuatB2, t); + _robotLinkMat.compose(_robotPosA, _robotQuatA, _robotScaleA); + } + _robotLinkDelta.copy(_robotLinkMat).multiply(this.zeroInv[link]); + for (const { mesh, baked } of entry) { + _robotMeshMat.copy(_robotLinkDelta).multiply(baked); + mesh.matrix.copy(_robotMeshMat); + } + } + this.group.updateMatrixWorld(true); + } + /** Static calibration pose on the ground (no floating-base trajectory yet). */ + applyCalibPose( + linkTransforms: Record, + groundZ?: number | null, + ): void { + const z = groundZ != null && Number.isFinite(groundZ) ? groundZ : this.groundOffset; + this.group.position.set(0, 0, z); + this.group.quaternion.identity(); + this._applyLinkTransforms(linkTransforms); + } + + getLinkWorldPosition(link: string, out: THREE.Vector3): boolean { + const transform = this.currentLinkTransforms[link] ?? this.zero[link]; + if (!transform) return false; + mat4Into(transform, _robotLinkMat); + this.group.updateMatrixWorld(true); + _robotWorldLinkMat.copy(this.group.matrixWorld).multiply(_robotLinkMat); + out.setFromMatrixPosition(_robotWorldLinkMat); + return true; + } + + getLinkWorldQuaternion(link: string, out: THREE.Quaternion): boolean { + const transform = this.currentLinkTransforms[link] ?? this.zero[link]; + if (!transform) return false; + mat4Into(transform, _robotLinkMat); + this.group.updateMatrixWorld(true); + _robotWorldLinkMat.copy(this.group.matrixWorld).multiply(_robotLinkMat); + _robotWorldLinkMat.decompose(_robotPosA, out, _robotScaleA); + return true; + } + + setOpacity(value: number): void { + const opacity = Math.min(1, Math.max(0.1, value)); + this.group.traverse((node) => { + const mesh = node as THREE.Mesh; + if (!mesh.isMesh) return; + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; + for (const material of materials) { + if (!(material instanceof THREE.MeshStandardMaterial)) continue; + material.opacity = opacity; + material.transparent = opacity < 0.999; + material.depthWrite = opacity >= 0.55; + material.needsUpdate = true; + } + }); + } + + /** Calibration pick/hover: tint link meshes (hover = soft blue, selected = accent). */ + setCalibHighlights({ + hover = null, + selected = null, + }: { hover?: string | null; selected?: string | null } = {}): void { + const BASE = { color: 0xc8ccd4, emissive: 0x6b7280, emissiveIntensity: 0.55 }; + const HOVER = { color: 0xd6e4ff, emissive: 0x3b82f6, emissiveIntensity: 0.92 }; + const SELECT = { color: 0xbfdbfe, emissive: 0x1d4ed8, emissiveIntensity: 1.15 }; + for (const [link, entries] of Object.entries(this.linkMeshes)) { + let pal = BASE; + if (selected && link === selected) pal = SELECT; + else if (hover && link === hover) pal = HOVER; + for (const { mesh } of entries) { + const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; + for (const m of mats) { + if (!(m instanceof THREE.MeshStandardMaterial)) continue; + m.color.setHex(pal.color); + m.emissive.setHex(pal.emissive); + m.emissiveIntensity = pal.emissiveIntensity; + } + } + } + } +} + +function applyRobotMaterial(mesh: THREE.Mesh): void { + // Light brushed-metal look. A bright emissive floor guarantees the robot is + // clearly visible even if a mesh still ends up without usable normals. + const make = () => new THREE.MeshStandardMaterial({ + color: 0xc8ccd4, + emissive: 0x6b7280, + emissiveIntensity: 0.55, + roughness: 0.6, + metalness: 0.15, + side: THREE.DoubleSide, + vertexColors: false, + }); + if (Array.isArray(mesh.material)) { + mesh.material = mesh.material.map(() => make()); + } else { + mesh.material = make(); + } +} + +function mat4Into(flat: Matrix4Data, out: THREE.Matrix4): THREE.Matrix4 { + // backend sends row-major flattened 4x4; three.js wants column-major. + return out.set( + flat[0], flat[1], flat[2], flat[3], + flat[4], flat[5], flat[6], flat[7], + flat[8], flat[9], flat[10], flat[11], + flat[12], flat[13], flat[14], flat[15] + ); +} + +function mat4(flat: Matrix4Data): THREE.Matrix4 { + return mat4Into(flat, new THREE.Matrix4()); +} + +// ================================================================= PLAYER +const initialWorkspacePreferences = loadWorkspacePreferences(); +const comparisonPresets: Record = { + ...initialWorkspacePreferences.comparisonPresets, +}; +const skel = new SkeletonView(); +const refSkel = new ReferenceSkeletonView(); +const mesh = new CapsuleMeshView(); +const skin = new BakedMeshView(); +const scaledSkel = new ScaledSkeletonView(); +const envView = new EnvView(); +const scaledEnv = new ScaledEnvView(); +const robot = new RobotView(); +const ALL_VIEWS: PlaybackView[] = [skel, mesh, skin, scaledSkel, envView, scaledEnv, robot]; +let playbarVisible = false; + +interface PlayerController { + playing: boolean; + loop: boolean; + t: number; + duration: number; + active: boolean; + speed: number; + _justLooped: boolean; + _heavyTick: number; + ready(duration: number): void; + _applyFrac(frac: number, options?: { force?: boolean }): void; + update(dt: number): void; + setPlaying(playing: boolean): void; + seek(fraction: number): void; + setSpeed(multiplier: number): void; + refreshFrame(): void; + _syncScrub(fraction: number): void; +} + +function publishPlaybackState(extra: Partial = {}): void { + const src = state.motion || state.robotTrajectory; + let label = `${player.t.toFixed(2)} / ${player.duration.toFixed(2)} s`; + const sourceDuration = src?.duration ?? 0; + if (isPlaybackPreview(src) && sourceDuration > player.duration + 0.5) { + label += runtimeText( + ` (preview; source ${sourceDuration.toFixed(1)} s)`, + `(预览,原片 ${sourceDuration.toFixed(1)} s)`, + ); + } + window.dispatchEvent(new CustomEvent("hhtools:playback-state", { + detail: { + visible: playbarVisible, + active: player.active, + playing: player.playing, + loop: player.loop, + progress: player.duration > 0 ? player.t / player.duration : 0, + speed: player.speed, + label, + ...extra, + }, + })); +} + +function bodyUsesSkin(): boolean { + return skin.ready; +} +function setBodyVisible(on: boolean): void { + const btn = document.getElementById("tg-mesh"); + btn.classList.toggle("on", on); + if (on && bodyUsesSkin()) { + skin.group.visible = true; + mesh.group.visible = false; + } else { + skin.group.visible = false; + mesh.group.visible = on; + } + player.refreshFrame(); +} +function bodyIsVisible(): boolean { + return skin.group.visible || mesh.group.visible; +} + +// All animatable views share ONE timeline (fraction of clip duration) so the +// human skeleton / body-mesh and the retargeted robot stay frame-synced and +// can be shown together. +const player: PlayerController = { + playing: false, + loop: initialWorkspacePreferences.playbackLoop, + t: 0, + duration: 0, + active: false, + speed: initialWorkspacePreferences.playbackSpeed, + // Set by update() on a loop wrap; consumed by animate() to hard-snap the camera. + _justLooped: false, + ready(duration: number) { + this.duration = Math.max(0.1, duration || 1); + this.t = 0; + this.active = true; + this._justLooped = false; + revealStage(); + }, + _heavyTick: 0, + _applyFrac(frac: number, { force = false }: { force?: boolean } = {}) { + if (r2r.calibrating || (state.calibrationMode && !r2r.active)) return; + if (!force) this._heavyTick = (this._heavyTick + 1) % 2; + const robotReady = Boolean(robot.trajectory && robot.trajectory.frames?.length); + for (const v of ALL_VIEWS) { + if (v.numFrames <= 0) continue; + // Yellow overlay / scaled env only track playback once retarget has produced + // a robot trajectory; otherwise they animate against a frozen robot pose. + if ((v === scaledSkel || v === scaledEnv) && !robotReady) continue; + // env views animate even when "invisible" to the HUD — except scaledEnv + // which follows its toggle. + if (!v.group.visible) continue; + // Heavy views (robot mesh / baked body) update every other frame while + // playing — but NEVER skip on a forced seek / loop wrap, or the robot + // stays at the last frame for one tick while the timeline is already + // back at the start (looks like a global teleport). + if (!force && this.playing && v.heavy && this._heavyTick === 1) continue; + const fi = frac * (v.numFrames - 1); + if (v.setFrameFrac) v.setFrameFrac(fi); + else v.setFrame(Math.min(v.numFrames - 1, Math.floor(fi))); + } + }, + update(dt: number) { + if (!this.playing || !this.active) return; + // Cap dt so a backgrounded tab cannot leap many seconds and land mid-clip + // after a modulo wrap (reads as a random global jump on the 2nd play). + const step = Math.min(Math.max(0, dt), 0.1) * this.speed; + this.t += step; + let looped = false; + if (this.t >= this.duration) { + if (this.loop) { + // Exact restart at t=0 — do NOT use ``t % duration``. Overshoot + // remainder lands mid-first-frame (or much later after a large dt), + // which looks like the robot teleporting to a wrong global pose + // when the clip wraps for the second playthrough. + this.t = 0; + looped = true; + this._justLooped = true; + } else { + this.t = this.duration; + this.setPlaying(false); + } + } + const frac = this.duration > 0 ? this.t / this.duration : 0; + this._applyFrac(frac, { force: looped }); + this._syncScrub(frac); + }, + setPlaying(p: boolean) { + this.playing = p && this.active; + publishPlaybackState(); + }, + seek(frac: number) { + if (!this.active) return; + const f = Math.min(1, Math.max(0, Number(frac) || 0)); + this.t = f * this.duration; + this._justLooped = false; + this._applyFrac(f, { force: true }); + this._syncScrub(f); + }, + setSpeed(mult: number) { + const m = Math.min(4, Math.max(0.1, Number(mult) || 1)); + this.speed = m; + updateWorkspacePreferences({ playbackSpeed: m }); + publishPlaybackState(); + }, + // Re-pose whatever is currently visible at the current cursor (after a toggle). + refreshFrame() { + if (this.active) this._applyFrac(this.t / this.duration, { force: true }); + }, + _syncScrub(frac: number) { + publishPlaybackState({ progress: frac }); + }, +}; + +function revealStage(): void { + _setPlaybarVisible(true); + document.getElementById("view-reset-btn")?.classList.remove("hidden"); + document.getElementById("view-hud").classList.remove("hidden"); + document.getElementById("stage-empty").style.display = "none"; +} + +document.getElementById("view-reset-btn")?.addEventListener("click", resetDefaultView); + +window.addEventListener("hhtools:playback-command", (event) => { + const { action, value } = event.detail || {}; + if (action === "toggle") player.setPlaying(!player.playing); + else if (action === "seek") player.seek(value ?? 0); + else if (action === "speed") player.setSpeed(value ?? 1); + else if (action === "loop") { + player.loop = !player.loop; + updateWorkspacePreferences({ playbackLoop: player.loop }); + publishPlaybackState(); + } +}); + +// ----------------------------------------------------------------- view toggles +function motionHasEnvironment(payload: MotionPayload | null | undefined): boolean { + if (!payload) return false; + if (payload.has_terrain || payload.terrain) return true; + if (Array.isArray(payload.objects) && payload.objects.length > 0) return true; + const meta = payload.meta; + if (meta && typeof meta === "object") { + if (meta.terrain_mesh) return true; + if (Number(meta.num_objects) > 0) return true; + } + return false; +} + +function syncEnvToggleButton(): void { + const btn = document.getElementById("tg-env"); + if (!btn) return; + const available = motionHasEnvironment(state.motion); + btn.disabled = !available; + if (!available) { + btn.classList.remove("on"); + return; + } + btn.classList.toggle("on", envView.group.visible); +} + +type ViewToggleButtonId = + | "tg-skeleton" + | "tg-mesh" + | "tg-env" + | "tg-scaled" + | "tg-scaled-env" + | "tg-robot"; + +function setViewVisible(view: PlaybackView, btnId: ViewToggleButtonId, on: boolean): void { + if (state.calibrationMode) { + const blocked = new Set(["tg-skeleton", "tg-scaled", "tg-scaled-env", "tg-env"]); + if (blocked.has(btnId) && on) return; + } + view.group.visible = on; + document.getElementById(btnId).classList.toggle("on", on); + if (btnId === "tg-env") syncEnvToggleButton(); + player.refreshFrame(); +} + +function emitResultDiagnostics( + workflow: WorkflowId, + diagnostics: ResultDiagnostics | null, +): void { + window.dispatchEvent(new CustomEvent("hhtools:result-diagnostics", { + detail: { + workflow, + diagnostics, + comparisonPreset: comparisonPresets[workflow], + }, + })); +} + +function clearResultDiagnostics(workflow: WorkflowId): void { + emitResultDiagnostics(workflow, null); +} + +function emitComparisonState(workflow: WorkflowId): void { + window.dispatchEvent(new CustomEvent("hhtools:comparison-state", { + detail: { workflow, preset: comparisonPresets[workflow] }, + })); +} + +/** Apply a repeatable H2R visibility preset without changing any trajectory data. */ +function applyH2rComparisonPreset(preset: ComparisonPreset): void { + comparisonPresets.h2r = preset; + const showSource = preset === "source" || preset === "overlay"; + const showTarget = preset === "target" || preset === "overlay"; + const showResult = preset === "result" || preset === "overlay"; + + setViewVisible(skel, "tg-skeleton", showSource && skel.numFrames > 0); + // The opaque body is useful by itself, but hides the diagnostic overlays. + setBodyVisible(preset === "source" && Boolean(state.motion)); + setViewVisible( + envView, + "tg-env", + preset === "source" && motionHasEnvironment(state.motion), + ); + setViewVisible(scaledSkel, "tg-scaled", showTarget && scaledSkel.numFrames > 0); + setViewVisible( + scaledEnv, + "tg-scaled-env", + (showTarget || showResult) && ( + scaledEnv.numFrames > 0 || scaledEnv.group.children.length > 0 + ), + ); + setViewVisible(robot, "tg-robot", showResult && Boolean(robot.trajectory)); + emitComparisonState("h2r"); +} + +document.getElementById("tg-skeleton").onclick = () => + setViewVisible(skel, "tg-skeleton", !skel.group.visible); +document.getElementById("tg-mesh").onclick = () => setBodyVisible(!bodyIsVisible()); +document.getElementById("tg-env").onclick = (e) => { + if ((e.currentTarget as HTMLButtonElement).disabled) return; + setViewVisible(envView, "tg-env", !envView.group.visible); +}; +document.getElementById("tg-scaled").onclick = (e) => { + if ((e.currentTarget as HTMLButtonElement).disabled) return; + setViewVisible(scaledSkel, "tg-scaled", !scaledSkel.group.visible); +}; +document.getElementById("tg-scaled-env").onclick = (e) => { + if ((e.currentTarget as HTMLButtonElement).disabled) return; + setViewVisible(scaledEnv, "tg-scaled-env", !scaledEnv.group.visible); +}; +document.getElementById("tg-robot").onclick = (e) => { + if ((e.currentTarget as HTMLButtonElement).disabled) return; + setViewVisible(robot, "tg-robot", !robot.group.visible); +}; + +async function refreshScaledPreview(): Promise { + const btnSkel = document.getElementById("tg-scaled"); + const btnEnv = document.getElementById("tg-scaled-env"); + if (!state.motion || !state.robot || !state.calibration) { + scaledSkel.clear(); + scaledEnv.clear(); + btnSkel.disabled = true; + btnEnv.disabled = true; + setViewVisible(scaledSkel, "tg-scaled", false); + setViewVisible(scaledEnv, "tg-scaled-env", false); + return; + } + try { + const data = await API.post("/api/scaled_preview", { + robot: state.robot.name, + motion_token: state.motion.token, + reference: state.reference, + }); + const preview = data.preview ?? data; + scaledSkel.load(preview); + btnSkel.disabled = false; + if (data.scaled_scene) { + scaledEnv.load(data.scaled_scene, state.motion.token); + btnEnv.disabled = false; + } else { + scaledEnv.clear(); + btnEnv.disabled = true; + } + // Preload yellow overlay data but keep it hidden until a retarget completes + // (or the user explicitly toggles it on). Playing motion against a frozen + // calibration / zero robot makes the overlay look collapsed inside the mesh. + if (!state.robotTrajectory) { + setViewVisible(scaledSkel, "tg-scaled", false); + setViewVisible(scaledEnv, "tg-scaled-env", false); + } + if (player.active) player.refreshFrame(); + } catch (e) { + scaledSkel.clear(); + scaledEnv.clear(); + btnSkel.disabled = true; + btnEnv.disabled = true; + console.warn("scaled preview", errorMessage(e)); + } +} + +// ================================================================= H2R STATE +/** + * Canonical H2R session state. Loading another motion or robot invalidates the + * calibration, trajectory, diagnostics, and export values derived from the old + * pair. Compatibility DOM controls are projections, never another state source. + */ +const state: AppState = { + motion: null, // serialized payload incl token + libraryEntry: null, // resource-library row for batch basket + robot: null, // serialized robot + reference: null, + calibration: false, + calibrationMode: false, + calibNeedsCameraFocus: false, + calibOrbitSaved: null, + calibLimits: null, + calibRestore: null, + exportToken: null, + calibQ: {}, + calibSliderRows: {}, + calibBaselineQ: null, + calibDraftQ: null, + calibHasSaved: false, + exportSrcFps: null, + exportHasScene: false, + robotTrajectory: null, + robotPanelLocked: false, +}; + +interface CalibrationEditorUiState { + query: string; + region: CalibrationJointRegion | "all"; + unit: CalibrationAngleUnit; + comparison: CalibrationComparisonMode; + mappedOnly: boolean; + labels: boolean; + mappingLines: boolean; + sourceOpacity: number; + robotOpacity: number; +} + +function createCalibrationEditorUiState(): CalibrationEditorUiState { + return { + query: "", + region: "all", + unit: "rad", + comparison: "current", + mappedOnly: true, + labels: true, + mappingLines: true, + sourceOpacity: 0.82, + robotOpacity: 0.72, + }; +} + +const calibrationEditorUi: Record = { + h2r: createCalibrationEditorUiState(), + r2r: createCalibrationEditorUiState(), +}; + +type WorkflowRunState = "idle" | "running" | "completed" | "failed"; + +let h2rRunState: WorkflowRunState = "idle"; + +function emitWorkflowState(detail: WorkflowStateDetail): void { + window.dispatchEvent(new CustomEvent("hhtools:workflow-state", { detail })); +} + +function workflowNode( + id: string, + label: string, + stateName: WorkflowNodeState, + detail: string, + panel: WorkflowNodeStatus["panel"], +): WorkflowNodeStatus { + return { id, label, state: stateName, detail, panel }; +} + +function h2rBlockedReason(): string | null { + if (!state.motion) return runtimeText( + "Source motion is missing. Load a clip from Motion first.", + "缺少源 Motion:请先在“动作 Motion”中加载一个 clip。", + ); + if (!state.robot) return runtimeText( + "Target robot is missing. Load a robot model from the Robot Library first.", + "缺少目标机器人:请先在机器人库中加载 Robot Model。", + ); + if (!state.reference) return runtimeText( + "The source reference format was not recognized. Check the motion format or select a reference pose manually.", + "未识别源参考格式:请检查 Motion 格式或手动选择参考姿态。", + ); + if (!state.calibration) { + return runtimeText( + `Calibration is missing for ${state.robot.display_name} + ${referenceLabel(state.reference)}.`, + `缺少 ${state.robot.display_name} + ${referenceLabel(state.reference)} 标定配置。`, + ); + } + if (state.robotPanelLocked || h2rRunState === "running") return runtimeText( + "Retarget is running. Wait for the current task to finish.", + "Retarget 正在运行,请等待当前任务完成。", + ); + return null; +} + +function publishH2rWorkflowState(): void { + const blockedReason = h2rBlockedReason(); + const solverReady = blockedReason == null || h2rRunState === "running"; + const solverState: WorkflowNodeState = h2rRunState === "running" + ? "running" + : h2rRunState === "failed" + ? "failed" + : state.exportToken + ? "completed" + : solverReady + ? "ready" + : "missing"; + const resultState: WorkflowNodeState = state.exportToken + ? "completed" + : h2rRunState === "failed" + ? "failed" + : "missing"; + const calibrationState: WorkflowNodeState = state.calibrationMode + ? "running" + : state.calibration + ? "ready" + : state.robot && state.reference + ? "warning" + : "missing"; + + const nodes: WorkflowNodeStatus[] = [ + workflowNode( + "motion", + runtimeText("Motion", "动作"), + state.motion ? "ready" : "missing", + state.motion?.name || runtimeText("Not selected", "未选择"), + "motion", + ), + workflowNode( + "robot", + runtimeText("Robot", "机器人"), + state.robot ? "ready" : "missing", + state.robot?.display_name || runtimeText("Not selected", "未选择"), + "robot-assets", + ), + workflowNode( + "calibration", + runtimeText("Calibration", "标定"), + calibrationState, + state.calibrationMode + ? runtimeText("Editing", "正在编辑") + : state.calibration + ? referenceLabel(state.reference) + : runtimeText("Not ready", "未就绪"), + "h2r", + ), + workflowNode( + "solver", + runtimeText("Solver", "求解"), + solverState, + h2rRunState === "running" + ? runtimeText("Running", "运行中") + : state.exportToken + ? runtimeText("Completed", "已完成") + : solverReady + ? runtimeText("Ready to run", "可以运行") + : runtimeText("Waiting for input", "等待输入"), + "h2r", + ), + workflowNode( + "result", + runtimeText("Result", "结果"), + resultState, + state.exportToken + ? runtimeText("Ready to preview/export", "可预览/导出") + : h2rRunState === "failed" + ? runtimeText("Run failed", "运行失败") + : runtimeText("No result yet", "尚无结果"), + "h2r", + ), + ]; + + const runButton = document.getElementById("retarget-btn"); + if (runButton) runButton.disabled = blockedReason != null; + const reason = document.getElementById("retarget-disabled-reason"); + if (reason) reason.textContent = blockedReason || ""; + emitWorkflowState({ workflow: "h2r", nodes, blockedReason }); +} + +function renderRobotValidation(robotPayload: RobotPayload): void { + const mappings = Object.entries(robotPayload.ik_map ?? {}); + const mappedLinks = mappings + .map(([, target]) => ikMapTargetLink(target)) + .filter((target): target is string => Boolean(target)); + const knownLinks = new Set(robotPayload.links ?? []); + const unresolved = mappedLinks.filter((link) => !knownLinks.has(link)); + const dofCount = robotPayload.num_dof ?? robotPayload.joints?.length ?? 0; + renderValidationSummary(document.getElementById("robot-validation-summary"), [ + [dofCount > 0 ? "ok" : "error", runtimeText( + `${dofCount} controllable DoF`, + `${dofCount} 个可控 DoF`, + )], + [mappings.length > 0 ? "ok" : "warn", runtimeText( + `ik_map: ${mappings.length}/17 semantic slots`, + `ik_map:${mappings.length}/17 个语义槽位`, + )], + [unresolved.length === 0 ? "ok" : "error", unresolved.length === 0 + ? runtimeText( + "All target links in ik_map resolve in the robot model", + "ik_map 中的目标 link 均可解析", + ) + : runtimeText( + `${unresolved.length} ik_map links do not resolve in the robot model`, + `${unresolved.length} 个 ik_map link 无法在 Robot Model 中解析`, + )], + ]); +} + +function renderMotionValidation(payload: MotionPayload): void { + const frameCount = payload.num_frames_total ?? payload.positions.length; + const frameRate = payload.framerate ?? payload.sample_rate ?? 0; + const boneCount = payload.bone_names?.length ?? payload.parent_indices.length; + const sceneParts: string[] = []; + if (payload.has_terrain || payload.terrain) sceneParts.push(runtimeText("terrain", "地形")); + if (payload.objects?.length) { + sceneParts.push(runtimeText( + `${payload.objects.length} interaction object${payload.objects.length === 1 ? "" : "s"}`, + `${payload.objects.length} 个交互物体`, + )); + } + + renderValidationSummary(document.getElementById("motion-validation-summary"), [ + [frameCount > 0 ? "ok" : "error", frameCount > 0 + ? runtimeText(`Playable trajectory: ${frameCount} frames`, `轨迹可播放:${frameCount} 帧`) + : runtimeText("The trajectory has no playable frames", "轨迹不包含可播放帧")], + [frameRate > 0 ? "ok" : "warn", frameRate > 0 + ? runtimeText(`Valid timeline: ${frameRate.toFixed(1)} FPS`, `时间轴有效:${frameRate.toFixed(1)} FPS`) + : runtimeText( + "Frame rate was not detected; the default timeline will be used", + "未识别帧率,将使用默认时间轴", + )], + [boneCount > 0 ? "ok" : "error", boneCount > 0 + ? runtimeText(`Skeleton hierarchy: ${boneCount} nodes`, `骨架层级:${boneCount} 个节点`) + : runtimeText("Skeleton hierarchy was not detected", "未识别骨架层级")], + ["ok", sceneParts.length > 0 + ? runtimeText(`Scene data: ${sceneParts.join(", ")}`, `场景附属数据:${sceneParts.join("、")}`) + : runtimeText( + "Motion only: no terrain or interaction objects", + "纯动作轨迹:无地形或交互物体", + )], + ]); +} + +function renderMotionDetails(payload: MotionPayload): void { + document.getElementById("motion-meta-card").style.display = "block"; + document.getElementById("motion-name").textContent = payload.name; + const previewNote = isPlaybackPreview(payload) + ? runtimeText( + ` (preview: ${payload.playback_frames ?? payload.positions.length} frames / ${effectivePlaybackDuration(payload).toFixed(1)} s)`, + `(预览 ${payload.playback_frames ?? payload.positions.length} 帧 / ${effectivePlaybackDuration(payload).toFixed(1)} s)`, + ) + : ""; + const motionRows: Array<[string, unknown]> = [ + [runtimeText("Format", "格式"), payload.source_format], + [runtimeText("Frames", "帧数"), payload.num_frames_total], + [runtimeText("Frame rate", "帧率"), `${(payload.framerate ?? payload.sample_rate ?? 30).toFixed(1)}`], + [runtimeText("Duration", "时长"), `${effectivePlaybackDuration(payload).toFixed(2)} s${previewNote}`], + [runtimeText("Skeleton", "骨骼"), payload.bone_names?.length ?? payload.parent_indices.length], + ]; + if (payload.objects?.length) { + motionRows.push([runtimeText("Interaction objects", "交互物体"), payload.objects.length]); + } + if (payload.has_terrain) motionRows.push([runtimeText("Terrain", "地形"), runtimeText("Yes", "有")]); + motionRows.push([ + runtimeText("Body mesh", "身体 mesh"), + payload.body_mesh?.available + ? runtimeText("SMPL / skin", "SMPL / 皮肤") + : payload.body_mesh?.reason || runtimeText("Tubular approximation", "管状近似"), + ]); + renderMetaRows(document.getElementById("motion-meta"), motionRows); + renderMotionValidation(payload); +} + +function updateH2rCalibrationValidation(): void { + const scope = document.getElementById("calibration-scope"); + if (scope) { + scope.textContent = state.robot && state.reference + ? runtimeText( + `Scope: ${state.robot.display_name} + ${referenceLabel(state.reference)}`, + `配置范围:${state.robot.display_name} + ${referenceLabel(state.reference)}`, + ) + : runtimeText( + "Scope: target robot + source reference", + "配置范围:目标机器人 + 源参考格式", + ); + } + if (!state.robot) { + renderValidationSummary(document.getElementById("calibration-validation-summary"), []); + return; + } + + const mappings = Object.entries(state.robot.ik_map ?? {}); + const knownLinks = new Set(state.robot.links ?? []); + const unresolved = mappings.filter(([, target]) => { + const link = ikMapTargetLink(target); + return link != null && !knownLinks.has(link); + }); + const limits = new Map((state.calibLimits ?? []).map((limit) => [limit.name, limit])); + const nearLimit = Object.entries(state.calibQ).filter(([joint, value]) => { + const limit = limits.get(joint); + if (limit?.lower == null || limit.upper == null || limit.upper <= limit.lower) return false; + const span = limit.upper - limit.lower; + return value - limit.lower < span * 0.03 || limit.upper - value < span * 0.03; + }); + const changed = Object.values(state.calibQ).filter((value) => Math.abs(value) > 1e-4).length; + + renderValidationSummary(document.getElementById("calibration-validation-summary"), [ + [mappings.length > 0 ? "ok" : "warn", runtimeText( + `Semantic mapping: ${mappings.length}/17 ik_map slots`, + `语义映射:${mappings.length}/17 个 ik_map 槽位`, + )], + [unresolved.length === 0 ? "ok" : "error", unresolved.length === 0 + ? runtimeText("All mapped robot links resolve", "映射的机器人 link 均可解析") + : runtimeText( + `${unresolved.length} mapped links cannot be resolved`, + `${unresolved.length} 个映射 link 无法解析`, + )], + [nearLimit.length === 0 ? "ok" : "warn", nearLimit.length === 0 + ? runtimeText("No joints are near their limits", "当前关节均未接近限位") + : runtimeText( + `${nearLimit.length} joints are near their URDF limits`, + `${nearLimit.length} 个关节接近 URDF 限位`, + )], + ["ok", runtimeText( + `Current edit: ${changed} non-zero joints`, + `当前编辑:${changed} 个非零关节`, + )], + ...calibrationDiagnosticRows(robot), + ]); +} + +const REFERENCE_LABELS: Record = { + smpl: "SMPL", + smplx: "SMPL-X", + gvhmr: "GVHMR", + soma_bvh: "SOMA BVH", + lafan_bvh: "LAFAN / Mixamo BVH", + mocap_bvh: "MOCAP BVH (Spine3 chest)", + xsens_mocap: "Xsens mocap BVH", + glb: "GLB / GLTF", +}; + +/** Mirror server ``_DATASET_TO_REFERENCE`` for basket rows without ``reference``. */ +const DATASET_TO_REFERENCE: Record = { + amass: "smpl", + motion_x: "smplx", + phuma: "smpl", + lafan: "lafan_bvh", + mocap: "mocap_bvh", + soma: "soma_bvh", + xsens_mocap: "xsens_mocap", + gvhmr: "gvhmr", + omomo: "smplx", + meshmimic_holosoma: "smplx", + glb: "glb", + unified_npz: "smpl", + parc_ms: "smpl", +}; + +function entryReference(e: LibraryEntry | null | undefined, fallback = "smpl"): string { + const datasetReference = e?.dataset ? DATASET_TO_REFERENCE[e.dataset] : undefined; + return (e?.reference || "").trim() || datasetReference || fallback; +} + +function referenceLabel(ref: string | null | undefined): string { + return (ref ? REFERENCE_LABELS[ref] : undefined) || ref || "—"; +} + +/** Human-readable adapter / dataset id (basket ``dataset`` field). */ +const DATASET_LABELS: Record = { + soma: ["SOMA BVH", "SOMA BVH"], + lafan: ["LAFAN / Mixamo BVH", "LAFAN / Mixamo BVH"], + mocap: ["MOCAP BVH (Spine3 chest)", "MOCAP BVH(Spine3 胸部)"], + xsens_mocap: ["Xsens mocap BVH", "Xsens mocap BVH"], + amass: ["AMASS (SMPL parameters)", "AMASS(SMPL 参数)"], + motion_x: ["Motion-X (SMPL-X)", "Motion-X(SMPL-X)"], + phuma: ["PHUMA (SMPL)", "PHUMA(SMPL)"], + gvhmr: ["GVHMR (SMPL-H)", "GVHMR(SMPL-H)"], + omomo: ["OMOMO (SMPL-X)", "OMOMO(SMPL-X)"], + glb: ["GLB skeleton", "GLB 骨骼"], + parc_ms: ["parc_ms / meshmimic", "parc_ms / meshmimic"], + meshmimic_holosoma: ["holosoma NPY", "holosoma NPY"], + unified_npz: ["hhtools NPZ", "hhtools NPZ"], + unknown: ["Unknown", "未识别"], +}; + +/** + * What each calibration reference means for retarget (not the same as SMPL weights). + * ``reference`` = which saved calibration YAML + reference T-pose to use. + */ +const REFERENCE_HELP: Record = { + soma_bvh: { + input: "SOMA 统一比例骨架 .bvh(关节名如 Hips、LeftUpLeg;来自 SOMA / soma-retargeter)", + calib: "标定参考「SOMA BVH」— 对齐蓝色 SOMA 标准骨架与机器人", + file: "retarget_calibration_soma_bvh.yaml", + }, + lafan_bvh: { + input: "LAFAN / Mixamo 风格 .bvh(如 Hips、LeftLeg)", + calib: "标定参考「LAFAN / Mixamo BVH」— 对齐蓝色 LAFAN 参考骨架", + file: "retarget_calibration_lafan_bvh.yaml", + }, + mocap_bvh: { + input: "四节脊柱 MOCAP .bvh(Hips、Spine3 挂肩、LeftToeBase)", + calib: "标定参考「MOCAP BVH」— 对齐蓝色 MOCAP 参考骨架(Spine3 = chest)", + file: "retarget_calibration_mocap_bvh.yaml", + }, + xsens_mocap: { + input: "Xsens MVN / 生物力学 .bvh(如 Hips、LeftHip、LeftKnee、Chest)", + calib: "标定参考「Xsens mocap BVH」— 对齐蓝色 Xsens 参考骨架", + file: "retarget_calibration_xsens_mocap.yaml", + }, + smpl: { + input: "AMASS / SMPL 参数 .npz(poses + trans,需 SMPL 体模)", + calib: "标定参考「SMPL」— 对齐蓝色 SMPL T-pose 参考骨架", + file: "retarget_calibration_smpl.yaml", + }, + smplx: { + input: "SMPL-X 参数或 OMOMO / Motion-X 等", + calib: "标定参考「SMPL-X」— 对齐蓝色 SMPL-X 参考骨架", + file: "retarget_calibration_smplx.yaml", + }, + gvhmr: { + input: "GVHMR / HMR4D 输出的 .pt 或 SMPL-H 轨迹", + calib: "标定参考「GVHMR」— 对齐蓝色 GVHMR 参考骨架", + file: "retarget_calibration_gvhmr.yaml", + }, + glb: { + input: "带骨骼的 .glb / .gltf", + calib: "标定参考「GLB / GLTF」— 对齐蓝色 GLB 第 0 帧参考骨架", + file: "retarget_calibration_glb.yaml", + }, +}; + +function datasetLabel(ds: string | null | undefined): string { + if (!ds || ds === "unknown") return runtimeText("Unknown", "未识别"); + const labels = DATASET_LABELS[ds]; + return labels ? runtimeText(labels[0], labels[1]) : ds; +} + +let referenceCatalog: string[] = []; + +async function loadReferenceCatalog(): Promise { + try { + const { references } = await API.get("/api/calibration/references"); + referenceCatalog = references?.length ? references : Object.keys(REFERENCE_LABELS); + } catch { + referenceCatalog = Object.keys(REFERENCE_LABELS); + } + populateRefSelect(); +} + +function populateRefSelect(): void { + const sel = document.getElementById("rt-ref-select"); + if (!sel) return; + const prev = state.reference || sel.value; + sel.innerHTML = ""; + const blank = document.createElement("option"); + blank.value = ""; + blank.textContent = "—"; + sel.appendChild(blank); + for (const ref of referenceCatalog) { + const opt = document.createElement("option"); + opt.value = ref; + opt.textContent = REFERENCE_LABELS[ref] || ref; + sel.appendChild(opt); + } + if (prev && [...sel.options].some((o) => o.value === prev)) sel.value = prev; + syncRefSelect(); +} + +function syncRefSelect(): void { + const sel = document.getElementById("rt-ref-select"); + if (!sel) return; + if (state.reference && [...sel.options].some((o) => o.value === state.reference)) { + sel.value = state.reference; + } else if (!state.reference) { + sel.value = ""; + } + sel.disabled = !state.robot; + const hint = document.getElementById("rt-ref-hint"); + if (!hint) return; + if (state.motion?.dataset) { + const ref = state.reference || "—"; + hint.textContent = runtimeText( + `Detected dataset: ${state.motion.dataset} → suggested reference ${ref}`, + `自动识别数据集: ${state.motion.dataset} → 建议参考 ${ref}`, + ); + hint.style.display = "block"; + } else { + hint.textContent = ""; + hint.style.display = "none"; + } +} + +async function onReferenceChange(newRef: string): Promise { + if (!newRef || newRef === state.reference) return; + const wasCalibrating = state.calibrationMode; + const savedQ = wasCalibrating ? { ...state.calibQ } : null; + if (wasCalibrating) await exitCalibrationMode(); + state.reference = newRef; + syncRefSelect(); + if (wasCalibrating && state.robot && state.motion) { + await enterCalibrationMode(savedQ); + } else { + await refreshRetargetPanel(); + } +} + +function updatePills(): void { + document.getElementById("motion-pill").textContent = state.motion + ? `🎞 ${state.motion.name}` : runtimeText("No motion loaded", "未加载动作"); + document.getElementById("robot-pill").textContent = state.robot + ? `🤖 ${state.robot.display_name}` : runtimeText("No robot loaded", "未加载机器人"); +} + +// ================================================================= NAVIGATION BRIDGE +let inspectorPanelSwitchHook: ((panelId: string) => void) | null = null; + +function switchInspectorPanel(panelId: string): void { + if (!panelId) return; + const normalizedPanelId = panelId === "robot" ? "h2r" : panelId; + window.__hhUi?.setActivePanel(normalizedPanelId); + inspectorPanelSwitchHook?.(normalizedPanelId); + if (normalizedPanelId === "batch") { + renderBasket({ refreshCompatibility: false }); + void syncBatchRefHint(); + } +} + +window.addEventListener("hhtools:panel-request", (event) => { + switchInspectorPanel(event.detail); +}); + +/** After a robot is loaded, jump to the panel that matches the current workflow. */ +async function routeAfterRobotLoad(): Promise { + if (!state.motion) { + switchInspectorPanel("robot-assets"); + await refreshRetargetPanel(); + return; + } + switchInspectorPanel("h2r"); + await refreshRetargetPanel(); +} + +// ================================================================= MOTION +/** + * Commit a newly loaded human motion, invalidate prior H2R results, and rebuild + * every source-motion Three.js layer before publishing the new workflow state. + */ +async function loadMotionPayload(payload: MotionPayload): Promise { + state.motion = payload; + state.libraryEntry = payload.library_entry || null; + state.reference = payload.suggested_reference ?? null; + syncRefSelect(); + state.exportToken = null; + clearResultDiagnostics("h2r"); + state.calibration = false; + h2rRunState = "idle"; + // In calibration mode only the robot + blue reference T-pose should be visible. + if (state.calibrationMode) { + state.robotTrajectory = null; + robot.trajectory = null; + scaledSkel.clear(); + scaledEnv.clear(); + player.setPlaying(false); + await refreshRetargetPanel(); + _applyCalibSceneLayout(); + toast(runtimeText( + `Loaded ${payload.name} (calibration mode)`, + `已加载 ${payload.name}(标定模式)`, + )); + updatePills(); + return; + } + skel.load(payload, 0x0a84ff); + mesh.load(payload); + envView.load(payload); + const hasEnv = motionHasEnvironment(payload); + if (hasEnv) { + setViewVisible(envView, "tg-env", true); + } else { + envView.clear(); + envView.group.visible = false; + syncEnvToggleButton(); + } + await skin.load(payload.body_mesh); + // Terrain/objects clips default to the interaction-mesh backend (matches Viser + // "Auto"); pure skeletal clips stay on Newton IK. + if (payload.suggested_backend) { + const rb = document.getElementById("rt-backend"); + const bb = document.getElementById("batch-backend"); + if (rb) rb.value = payload.suggested_backend; + if (bb) bb.value = payload.suggested_backend; + } + // A fresh motion invalidates any previous retarget result. + state.robotTrajectory = null; + robot.trajectory = null; + if (state.robot) robot.applyStatic(); + // parc_ms / skeletal-only: default skeleton lines (capsules collapse when FK rest is wrong). + const isParcMs = + payload.meta?.dataset === "parc_ms" || + payload.meta?.source_format === "parc_ms_pkl"; + const hasSkin = Boolean(payload.body_mesh?.available); + const showSkeleton = isParcMs || !hasSkin; + setViewVisible(skel, "tg-skeleton", showSkeleton); + setBodyVisible(!showSkeleton || hasSkin); + setViewVisible(robot, "tg-robot", false); + player.ready(effectivePlaybackDuration(payload)); + player.setPlaying(true); + renderMotionDetails(payload); + updatePills(); + updateRetargetFpsPlaceholder(); + if (state.robot) switchInspectorPanel("h2r"); + await refreshRetargetPanel(); + toast(runtimeText(`Loaded ${payload.name}`, `已加载 ${payload.name}`)); +} + +function datasetSceneGlbUrl(token: string | null | undefined, o: SceneObjectPayload): string | null { + const mesh = o.mesh_file || ""; + if (!token || !mesh) return null; + return `/api/dataset/scene_glb?token=${encodeURIComponent(token)}&mesh=${encodeURIComponent(mesh)}`; +} + +async function loadRobotExportPreview(result: RobotExportPreviewResult): Promise { + if (state.calibrationMode) { + toast(runtimeText( + "Robot trajectories cannot be previewed in calibration mode", + "标定模式下无法预览机器人轨迹", + ), true); + return; + } + + state.motion = null; + state.libraryEntry = null; + state.exportToken = null; + clearResultDiagnostics("h2r"); + skel.clear(); + mesh.clear(); + skin.clear(); + envView.clear(); + envView.group.visible = false; + + const robotName = result.robot; + if (!state.robot || state.robot.name !== robotName) { + const robotData = await API.post("/api/robot/select", { name: robotName }); + state.robot = robotData; + await robot.load(robotData); + } + + state.robotTrajectory = result.trajectory; + robot.setTrajectory(result.trajectory); + + scaledSkel.clear(); + scaledSkel.group.visible = false; + const clipDur = Math.max(0.1, (result.num_frames - 1) / (result.framerate || 30)); + if (result.scaled_scene) { + scaledEnv.load(result.scaled_scene, result.preview_token, { + duration: clipDur, + objectGlbUrl: (o) => datasetSceneGlbUrl(result.preview_token, o), + }); + document.getElementById("tg-scaled-env").disabled = false; + setViewVisible(scaledEnv, "tg-scaled-env", true); + } else { + scaledEnv.clear(); + scaledEnv.group.visible = false; + syncEnvToggleButton(); + } + + setViewVisible(skel, "tg-skeleton", false); + setBodyVisible(false); + setViewVisible(mesh, "tg-mesh", false); + setViewVisible(scaledSkel, "tg-scaled", false); + document.getElementById("tg-scaled").disabled = true; + document.getElementById("tg-robot").disabled = false; + setViewVisible(robot, "tg-robot", true); + + document.getElementById("motion-meta-card").style.display = "none"; + player.ready(robot.clipDuration || clipDur); + player.setPlaying(true); + robot.group.getWorldPosition(_camFocus); + orbit.target.copy(_camFocus); + _orbitManualUntil = 0; + revealStage(); + updatePills(); + toast(runtimeText( + `Playing robot mesh: ${result.name}`, + `机器人 mesh 播放:${result.name}`, + )); +} + +async function previewRobotClip( + entry: LibraryEntry, + robotName?: string, +): Promise { + const label = entry.stem || entry.sequence_id || ""; + showLoading(runtimeText( + `Loading robot trajectory ${label}`.trim(), + `加载机器人轨迹 ${label}`.trim(), + )); + try { + const body: { source_path: string; robot?: string } = { source_path: entry.source_path }; + if (robotName) body.robot = robotName; + const { job_id } = await API.post("/api/dataset/preview_robot", body); + const result = await waitMotionJob(job_id, (frac, sub) => { + setLoadingProgress(frac, sub); + }); + setLoadingProgress(1, runtimeText("Building robot scene…", "构建机器人场景…")); + await loadRobotExportPreview(result); + return result; + } catch (e) { + toast(errorMessage(e), true); + throw e; + } finally { + hideLoading(); + } +} + +async function populateDvRobotSelect(preferred?: string): Promise { + const sel = document.getElementById("dv-robot-select"); + if (!sel) return preferred || ""; + const data = await API.get("/api/robots"); + const prev = preferred || sel.value; + sel.innerHTML = ""; + for (const r of data.robots || []) { + if (!r.has_urdf) continue; + const opt = document.createElement("option"); + opt.value = r.name; + opt.textContent = r.display_name || r.name; + sel.appendChild(opt); + } + if (prev && [...sel.options].some((o) => o.value === prev)) { + sel.value = prev; + } else if (sel.options.length) { + sel.selectedIndex = 0; + } + return sel.value; +} + +async function loadLibraryEntryRequest( + entry: LibraryEntry, + options: { usage?: "human_to_robot"; rethrow?: boolean } = {}, +): Promise { + const label = entry.stem || entry.sequence_id || ""; + showLoading(runtimeText(`Loading motion… ${label}`, `加载动作中… ${label}`).trim()); + try { + const body = options.usage ? { ...entry, usage: options.usage } : entry; + const { job_id } = await API.post("/api/motion/load_library", body); + const payload = await waitMotionJob(job_id, (frac, sub) => { + setLoadingProgress(frac, sub); + }); + setLoadingProgress(1, runtimeText("Building scene…", "构建场景…")); + await loadMotionPayload(payload); + } catch (e) { + toast(errorMessage(e), true); + if (options.rethrow) throw e; + } finally { + hideLoading(); + } +} + +async function loadLibraryEntry(entry: LibraryEntry): Promise { + await loadLibraryEntryRequest(entry); +} + +/** H2R loads only human reference motion; the backend verifies the real file. */ +async function loadHumanMotionEntry(entry: LibraryEntry): Promise { + await loadLibraryEntryRequest(entry, { usage: "human_to_robot", rethrow: true }); +} + +// library navigator +let libMotionsRoot = ""; + +async function linkLibraryPath(): Promise { + const hint = libMotionsRoot + ? runtimeText( + `Link to the library directory (${libMotionsRoot})`, + `链接到资源库目录(${libMotionsRoot})`, + ) + : runtimeText("Link to the current library directory", "链接到当前资源库目录"); + const path = window.prompt(hint, ""); + if (!path?.trim()) return; + try { + const data = await API.post("/api/library/link", { path: path.trim() }); + if (data.motions_library_root) libMotionsRoot = data.motions_library_root; + await refreshLibrary(); + if (data.folder_label) setLibrarySearch(data.folder_label); + toast(runtimeText( + `Linked: ${data.folder_label} (${data.clip_count} clips)`, + `已链接:${data.folder_label}(${data.clip_count} 个动作)`, + )); + } catch (e) { + toast(errorMessage(e), true); + } +} + +// library navigator +let libEntries: LibraryEntry[] = []; +let libSourceRoot = ""; +let libCategoryFilter: "all" | MotionCategory = "all"; +const libCategoryCopy: Record = { + motion: { en: "Motion", zh: "动作" }, + object: { en: "Object", zh: "物体" }, + terrain: { en: "Terrain", zh: "地形" }, +}; + +function libraryCategoryLabel(category: MotionCategory): string { + const copy = libCategoryCopy[category]; + return runtimeText(copy.en, copy.zh); +} + +function normalizedMotionCategory(entry: LibraryEntry): MotionCategory { + const category = entry.motion_category; + return category === "object" || category === "terrain" ? category : "motion"; +} + +function selectLibraryCategory(category: "all" | MotionCategory): void { + libCategoryFilter = category; + renderLibrary(); +} + +function setLibrarySearch(value: string): void { + const input = document.getElementById("lib-search"); + if (input.value === value) { + renderLibrary(); + return; + } + // Dispatching a real input event keeps the React SearchField state, its + // clear affordance, and the imperative library renderer in one state. + input.value = value; + input.dispatchEvent(new Event("input", { bubbles: true })); +} + +async function refreshLibrary(): Promise { + const list = document.getElementById("lib-list"); + try { + const data = await API.get("/api/library"); + libEntries = data.entries || []; + libSourceRoot = data.source_root || ""; + if (data.motions_library_root) libMotionsRoot = data.motions_library_root; + renderLibrary(); + } catch (e) { + renderTextMessage(list, runtimeText( + `Unable to read the library: ${errorMessage(e)}`, + `无法读取资源库:${errorMessage(e)}`, + )); + } +} +function renderLibrary(): void { + const query = document.getElementById("lib-search").value || ""; + const tokens = query.toLowerCase().split(/\s+/).filter(Boolean); + const list = document.getElementById("lib-list"); + list.replaceChildren(); + const filtered = libEntries.filter((e) => { + if (libCategoryFilter !== "all" && normalizedMotionCategory(e) !== libCategoryFilter) { + return false; + } + const category = normalizedMotionCategory(e); + const categoryCopy = libCategoryCopy[category]; + // Search both languages so switching the workspace locale never changes + // which rows match an existing query. + const hay = [ + e.folder_label || "", + e.stem || "", + category, + categoryCopy.en, + categoryCopy.zh, + ].join(" ").toLowerCase(); + return tokens.every((t) => hay.includes(t)); + }); + + if (!libEntries.length) { + renderTextMessage( + list, + runtimeText( + "No recognizable motions are available. Choose a library directory or link an external dataset directory.", + "资源库中还没有可识别的动作。请选择资源库目录,或链接一个外部数据集目录。", + ), + ); + return; + } + if (!filtered.length) { + renderTextMessage(list, runtimeText( + `No results match “${query}”`, + `没有匹配「${query}」的结果`, + )); + return; + } + for (const e of filtered.slice(0, 300)) { + const row = document.createElement("div"); + row.className = "lib-row"; + const category = normalizedMotionCategory(e); + const categoryBadge = textElement("span", "lr-category", libraryCategoryLabel(category)); + categoryBadge.dataset.category = category; + const loadButton = document.createElement("button"); + loadButton.type = "button"; + loadButton.className = "lr-load"; + loadButton.setAttribute( + "aria-label", + runtimeText( + `Load motion ${[e.folder_label, e.stem].filter(Boolean).join(" ")}`, + `加载动作 ${[e.folder_label, e.stem].filter(Boolean).join(" ")}`, + ), + ); + loadButton.append( + categoryBadge, + textElement("span", "lr-folder", e.folder_label), + textElement("span", "lr-stem", e.stem), + ); + const addButton = textElement("button", "lr-add", "+"); + addButton.type = "button"; + addButton.title = runtimeText("Add to basket", "加入篮子"); + addButton.setAttribute("aria-label", runtimeText( + `Add ${e.stem || "motion"} to basket`, + `将 ${e.stem || "动作"} 加入篮子`, + )); + row.append(loadButton, addButton); + loadButton.onclick = () => loadLibraryEntry(e); + addButton.onclick = () => addToBasket([e]); + list.appendChild(row); + } + if (filtered.length > 300) { + const more = document.createElement("div"); + more.className = "hint"; + more.style.padding = "8px 10px"; + more.textContent = runtimeText( + `… ${filtered.length - 300} more. Keep typing to narrow the results.`, + `… 还有 ${filtered.length - 300} 条,继续输入以缩小范围`, + ); + list.appendChild(more); + } +} +document.getElementById("lib-search").oninput = () => renderLibrary(); +document.getElementById("lib-category").onchange = (event) => { + const category = (event.currentTarget as HTMLSelectElement).value; + if (category === "all" || category === "motion" || category === "object" || category === "terrain") { + selectLibraryCategory(category); + } +}; +window.addEventListener("hhtools:workspace-locale-change", () => { + renderLibrary(); + renderRobotLibrary(); + populateH2rRobotSelect(); + populateBatchRobotSelect(); + renderBasket(); + const batchRobotStatus = document.getElementById("batch-robot"); + if (batchRobotStatus) batchRobotStatus.textContent = state.robot?.display_name + || runtimeText("Not loaded", "未加载"); + renderBatchResultCard(); + renderBatchFailures(lastBatchResult); + void r2rPopulateSelects(); + updateRobotImportStatus(); + const motionMetaCard = document.getElementById("motion-meta-card"); + // Calibration-only loads intentionally keep the details card hidden. A + // locale change should translate visible details, not alter that UI state. + if (state.motion && motionMetaCard?.style.display !== "none") { + renderMotionDetails(state.motion); + } + const robotMetaCard = document.getElementById("robot-meta-card"); + if (state.robot && robotMetaCard?.style.display !== "none") { + renderRobotDetails(state.robot); + } + // Workflow nodes and blocked reasons are emitted by the imperative runtime, + // so republish them after React switches locale instead of leaving stale copy. + publishH2rWorkflowState(); + publishR2rWorkflowState(); + updateH2rCalibrationValidation(); + updateR2rCalibrationValidation(); + syncRefSelect(); + updatePills(); + updateRetargetFpsPlaceholder(); + publishPlaybackState(); + updateCalibRestoreButton(); + if (state.calibrationMode) { + refSkel.configureMappings(state.robot?.ik_map ?? {}); + syncCalibrationNumberInputs("h2r"); + emitCalibrationEditorState("h2r"); + } + if (r2r.calibrating) { + refSkel.configureMappings(r2r.targetPayload?.ik_map ?? {}); + syncCalibrationNumberInputs("r2r"); + emitCalibrationEditorState("r2r"); + } + setR2rRobotStatus("source", r2r.sourcePayload + ? runtimeText( + `Source robot: ${r2r.sourcePayload.display_name}`, + `源机器人:${r2r.sourcePayload.display_name}`, + ) + : runtimeText("Not loaded", "未加载")); + setR2rRobotStatus("target", r2r.targetPayload + ? runtimeText( + `Target robot: ${r2r.targetPayload.display_name}`, + `目标机器人:${r2r.targetPayload.display_name}`, + ) + : runtimeText("Not loaded", "未加载")); + const r2rTrajectoryStatus = document.getElementById("r2r-traj-status"); + if (r2rTrajectoryStatus) { + r2rTrajectoryStatus.textContent = r2rTrajectoryState === "validating" + ? runtimeText("Validating trajectory…", "正在校验机器人轨迹……") + : r2r.sourceToken + ? runtimeText(`Loaded: ${r2r.sourceStem || "trajectory"}`, `已加载:${r2r.sourceStem || "轨迹"}`) + : ""; + } + const h2rStatus = document.getElementById("rt-status"); + if (h2rStatus && h2rRunState !== "idle") { + h2rStatus.textContent = h2rRunState === "running" + ? runtimeText("Retargeting…", "正在 retarget…") + : h2rRunState === "completed" + ? runtimeText("Retarget complete; ready to export", "Retarget 完成,可导出") + : ""; + } + const r2rStatus = document.getElementById("r2r-status"); + if (r2rStatus && r2rRunState !== "idle") { + r2rStatus.textContent = r2rRunState === "running" + ? runtimeText("Retargeting…", "正在 retarget…") + : r2rRunState === "completed" + ? runtimeText("R2R retarget complete", "R2R Retarget 完成") + : ""; + } + r2rRenderBasket(); + void r2rUpdateRetargetBtn(); + if (state.calibrationMode && state.reference) updateCalibBanner(state.reference); + if (r2r.calibrating) updateR2rCalibBanner(); +}); + +// ================================================ FILE IMPORT (folder-aware) +function readAllDirectoryEntries( + reader: FileSystemDirectoryReader, +): Promise { + return new Promise((resolve, reject) => { + const entries: FileSystemEntry[] = []; + const readBatch = (): void => { + reader.readEntries((batch: FileSystemEntry[]) => { + if (!batch.length) { + resolve(entries); + return; + } + entries.push(...batch); + readBatch(); + }, reject); + }; + readBatch(); + }); +} + +function walkEntry( + entry: FileSystemEntry, + out: UploadFile[], + prefix = "", +): Promise { + return new Promise((resolve, reject) => { + if (entry.isFile) { + (entry as FileSystemFileEntry).file((file: File) => { + const uploadFile = file as UploadFile; + uploadFile._relpath = prefix + uploadFile.name; + out.push(uploadFile); + resolve(); + }, reject); + } else if (entry.isDirectory) { + const reader = (entry as FileSystemDirectoryEntry).createReader(); + readAllDirectoryEntries(reader) + .then((entries) => Promise.all( + entries.map((e) => walkEntry(e, out, `${prefix}${entry.name}/`)), + )) + .then(() => resolve()) + .catch(reject); + } else { + resolve(); + } + }); +} + +async function collectDroppedFiles(dataTransfer: DataTransfer | null): Promise { + const files: UploadFile[] = []; + // Prefer the entry API: it recurses into dropped folders AND distinguishes a + // real file from a *directory*. A dropped folder shows up in + // ``dataTransfer.files`` as a single zero-byte, type-less File whose body + // cannot be read — appending it to FormData makes the upload ``fetch`` reject + // with "Failed to fetch". ``webkitGetAsEntry`` must be called synchronously + // while the drop event's items are still alive, so capture every entry first, + // then walk them. + const items = dataTransfer?.items; + if (items?.length) { + const entries: FileSystemEntry[] = []; + const looseFiles: UploadFile[] = []; + for (const it of items) { + const entry = it.webkitGetAsEntry?.(); + if (entry) entries.push(entry); + else { + const file = it.getAsFile?.(); + if (file) looseFiles.push(file as UploadFile); + } + } + if (entries.length) await Promise.all(entries.map((e) => walkEntry(e, files))); + for (const f of looseFiles) { + f._relpath = f._relpath || f.webkitRelativePath || f.name; + files.push(f); + } + if (files.length) return files; + } + // Fallback for browsers without the entry API: a flat file list only. Best- + // effort skip of a dropped folder, which surfaces here as a zero-byte, + // type-less, extension-less File that would break the upload fetch. + if (dataTransfer?.files?.length) { + for (const f of dataTransfer.files) { + if (!f) continue; + const looksLikeDir = f.size === 0 && !f.type && !/\.[^/.]+$/.test(f.name || ""); + if (looksLikeDir) continue; + f._relpath = f._relpath || f.webkitRelativePath || f.name; + files.push(f); + } + } + return files; +} + +function setupDropzone( + el: HTMLElement, + onFiles: (files: UploadFile[], context: DropContext) => void | Promise, + captureDropContext?: () => DropContext, +): void { + ["dragenter", "dragover"].forEach((ev) => + el.addEventListener(ev, (event) => { event.preventDefault(); el.classList.add("hover"); }) + ); + ["dragleave", "drop"].forEach((ev) => + el.addEventListener(ev, (event) => { event.preventDefault(); el.classList.remove("hover"); }) + ); + el.addEventListener("drop", (event) => { + const dropEvent = event as DragEvent; + dropEvent.stopPropagation(); + el.classList.remove("hover"); + // Snapshot mutable UI state before recursively walking a potentially large + // folder; changing a selector mid-walk must not reinterpret this drop. + const dropContext = captureDropContext?.() as DropContext; + void collectDroppedFiles(dropEvent.dataTransfer).then((files) => { + if (files.length) void onFiles(files, dropContext); + }); + }); +} +// Hidden based file / folder picker (for environments where native +// drag-drop is awkward). Folder picker preserves relative paths via +// webkitRelativePath so mesh subdirs + sidecars survive. Native `accept` +// filtering applies only to individual files: a folder must retain required +// .obj sidecars, and its full structure is validated by the server instead. +function pickFiles( + { folder = false, accept = "" }: { folder?: boolean; accept?: string } = {}, +): Promise { + return new Promise((resolve) => { + const inp = document.createElement("input"); + inp.type = "file"; + inp.multiple = true; + if (folder) inp.webkitdirectory = true; + else if (accept) inp.accept = accept; + inp.style.display = "none"; + inp.onchange = () => { + const files = Array.from(inp.files || []) as UploadFile[]; + for (const f of files) f._relpath = f.webkitRelativePath || f.name; + document.body.removeChild(inp); + resolve(files); + }; + document.body.appendChild(inp); + inp.click(); + }); +} + +function inferLibraryFolderLabel(files: UploadFile[]): string | undefined { + if (!files?.length) return undefined; + const rels = files.map((f) => f._relpath || f.name); + const first = rels[0]; + if (first && rels.some((path) => path.includes("/"))) return first.split("/")[0]; + return undefined; +} + +async function ingestMotionFiles( + files: UploadFile[], + profile = "mimic", +): Promise { + if (!files || !files.length) return null; + const libraryFolderLabel = inferLibraryFolderLabel(files); + showLoading(runtimeText( + `Linking and parsing… (${files.length} files)`, + `链接并解析中…(${files.length} 个文件)`, + )); + try { + const uploadResp = await uploadFilesXHR( + "/api/motion/upload", + files, + { profile, libraryFolderLabel }, + () => {}, + ); + const { job_id, linked, folder_label, materialize_mode } = uploadResp; + const payload = await waitMotionJob(job_id, (frac, sub) => { + setLoadingProgress(frac, sub); + }, { uploadFrac: 0 }); + setLoadingProgress(1, runtimeText("Building scene…", "构建场景…")); + await loadMotionPayload(payload); + if (linked || folder_label || payload.linked_folder) { + await refreshLibrary(); + const label = folder_label || payload.linked_folder; + if (label) setLibrarySearch(label); + } + const resolvedMaterializeMode = materialize_mode === "pending" + ? payload.materialize_mode + : materialize_mode; + const modeHint = resolvedMaterializeMode === "symlink" + ? { en: "Symlinked", zh: "软链接" } + : resolvedMaterializeMode === "hardlink" + ? { en: "Hard-linked", zh: "硬链接" } + : { en: "Copied", zh: "已复制" }; + if (payload.library_entry) { + addToBasket([payload.library_entry]); + toast(runtimeText( + `${modeHint.en} and loaded: ${payload.name} (Library · ${folder_label || payload.linked_folder})`, + `已${modeHint.zh}并加载:${payload.name}(资源库 · ${folder_label || payload.linked_folder})`, + )); + } else if (linked || payload.linked_folder) { + toast(runtimeText( + `${modeHint.en} to the Library: ${payload.linked_folder || folder_label}; loaded the first clip`, + `已${modeHint.zh}到资源库:${payload.linked_folder || folder_label},已加载首条 clip`, + )); + } + return payload; + } catch (e) { + toast(errorMessage(e), true); + return null; + } finally { + hideLoading(); + } +} + +function initMotionImportZone(): void { + const dropzone = document.getElementById("motion-drop-shared"); + if (dropzone) { + setupDropzone( + dropzone, + async (files, profile) => { + await ingestMotionFiles(files, profile); + }, + () => dropzone.dataset.profile || "mimic", + ); + } + document.querySelectorAll("[data-pick]").forEach((btn) => { + btn.onclick = async () => { + const profile = btn.dataset.pick || "mimic"; + const folder = btn.dataset.folder === "1"; + const accept = btn.dataset.accept || ""; + await ingestMotionFiles(await pickFiles({ folder, accept }), profile); + }; + }); +} +initMotionImportZone(); + +// ========================================================= VIDEO TO MOTION +const GVHMR_VIDEO_ACCEPT = + "video/mp4,video/quicktime,video/x-matroska,video/x-msvideo,video/webm,.m4v"; +const GVHMR_VIDEO_EXTENSIONS = new Set([".mp4", ".mov", ".mkv", ".avi", ".webm", ".m4v"]); + +/** + * Video-to-Motion state machine. `renderGvhmrWorkspace` projects it both to the + * compatibility DOM and to an immutable React event; the selected File and its + * object URL intentionally remain private to this runtime. + */ +interface GvhmrWorkspaceState { + file: UploadFile | null; + previewUrl: string | null; + previewDuration: number | null; + weightSource: GvhmrWeightSource; + checkpoint: UploadFile | null; + runtimeState: VideoToMotionStateDetail["runtimeState"]; + runtimeMissing: string[]; + runtimeError: string | null; + environmentConfirmed: boolean; + stage: VideoToMotionStateDetail["stage"]; + progress: number; + message: string; + result: VideoToMotionResultSummary | null; +} + +const gvhmrWorkspace: GvhmrWorkspaceState = { + file: null, + previewUrl: null, + previewDuration: null, + weightSource: "official", + checkpoint: null, + runtimeState: "checking", + runtimeMissing: [], + runtimeError: null, + environmentConfirmed: false, + stage: "idle", + progress: 0, + message: "", + result: null, +}; + +function gvhmrText(en: string, zh: string): string { + return document.documentElement.lang === "zh-CN" ? zh : en; +} + +function gvhmrRuntimeMessage(): string { + if (gvhmrWorkspace.runtimeState === "checking") { + return gvhmrText("Checking…", "检查中……"); + } + if (gvhmrWorkspace.runtimeState === "ready") { + if (gvhmrWorkspace.weightSource === "custom") { + return gvhmrWorkspace.checkpoint?.name + ? gvhmrText("Ready · custom weights (best effort)", "已就绪 · 自定义权重(不保证兼容)") + : gvhmrText("Ready · select custom weights", "已就绪 · 请选择自定义权重"); + } + return gvhmrText("Ready · official weights", "已就绪 · 官方权重"); + } + return gvhmrWorkspace.runtimeMissing[0] + || gvhmrWorkspace.runtimeError + || gvhmrText("GVHMR runtime is unavailable", "GVHMR 推理环境不可用"); +} + +function gvhmrPublicState(): VideoToMotionStateDetail { + return { + videoName: gvhmrWorkspace.file?.name ?? null, + weightSource: gvhmrWorkspace.weightSource, + checkpointName: gvhmrWorkspace.checkpoint?.name ?? null, + runtimeState: gvhmrWorkspace.runtimeState, + runtimeMessage: gvhmrRuntimeMessage(), + environmentConfirmed: gvhmrWorkspace.environmentConfirmed, + stage: gvhmrWorkspace.stage, + progress: gvhmrWorkspace.progress, + message: gvhmrWorkspace.message, + result: gvhmrWorkspace.result, + }; +} + +function renderGvhmrWorkspace(): void { + const isBusy = gvhmrWorkspace.stage === "uploading" || gvhmrWorkspace.stage === "running"; + const hasSelectedWeights = gvhmrWorkspace.weightSource === "official" + || Boolean(gvhmrWorkspace.checkpoint); + const canRun = Boolean(gvhmrWorkspace.file) + && gvhmrWorkspace.runtimeState === "ready" + && gvhmrWorkspace.environmentConfirmed + && hasSelectedWeights + && !isBusy; + const runtimeMessage = gvhmrRuntimeMessage(); + + const runtimeStatus = document.getElementById("gvhmr-runtime-status"); + if (runtimeStatus) { + runtimeStatus.textContent = gvhmrWorkspace.runtimeState === "ready" + ? gvhmrText( + "GVHMR runtime ready · official weights are the default", + "GVHMR 推理环境已就绪 · 默认使用官方权重", + ) + : runtimeMessage; + runtimeStatus.classList.toggle("error", gvhmrWorkspace.runtimeState === "unavailable"); + runtimeStatus.title = gvhmrWorkspace.runtimeMissing.join("\n") || gvhmrWorkspace.runtimeError || ""; + } + + const selection = document.getElementById("gvhmr-video-selection"); + if (selection) selection.style.display = gvhmrWorkspace.file ? "flex" : "none"; + const videoName = document.getElementById("gvhmr-video-name"); + if (videoName) videoName.textContent = gvhmrWorkspace.file?.name ?? "—"; + const videoMeta = document.getElementById("gvhmr-video-meta"); + if (videoMeta) { + const parts = gvhmrWorkspace.file + ? [fmtBytes(gvhmrWorkspace.file.size), gvhmrWorkspace.file.type || gvhmrText("Video", "视频")] + : []; + if (gvhmrWorkspace.previewDuration != null) { + parts.push(`${gvhmrWorkspace.previewDuration.toFixed(1)} s`); + } + videoMeta.textContent = parts.join(" · "); + } + + const workflowVideo = document.getElementById("gvhmr-workflow-video"); + if (workflowVideo) { + workflowVideo.textContent = gvhmrWorkspace.file?.name + ?? gvhmrText("Not selected", "未选择"); + } + const workflowRuntime = document.getElementById("gvhmr-workflow-runtime"); + if (workflowRuntime) { + workflowRuntime.textContent = runtimeMessage; + workflowRuntime.classList.toggle("error", gvhmrWorkspace.runtimeState === "unavailable"); + } + const weightSource = document.getElementById("gvhmr-weight-source") as HTMLSelectElement | null; + if (weightSource) weightSource.value = gvhmrWorkspace.weightSource; + const confirmEnvironment = document.getElementById("gvhmr-confirm-environment") as HTMLButtonElement | null; + if (confirmEnvironment) { + confirmEnvironment.disabled = gvhmrWorkspace.runtimeState !== "ready" + || !gvhmrWorkspace.file + || !hasSelectedWeights + || gvhmrWorkspace.environmentConfirmed + || isBusy; + confirmEnvironment.textContent = gvhmrWorkspace.environmentConfirmed + ? gvhmrText("Confirmed", "已确认") + : gvhmrText("Confirm", "确认环境"); + } + const customCheckpoint = document.getElementById("gvhmr-custom-checkpoint"); + if (customCheckpoint) { + customCheckpoint.style.display = gvhmrWorkspace.weightSource === "custom" ? "flex" : "none"; + } + const checkpointName = document.getElementById("gvhmr-checkpoint-name"); + if (checkpointName) { + checkpointName.textContent = gvhmrWorkspace.checkpoint?.name + ?? gvhmrText("No checkpoint selected", "尚未选择权重"); + } + const workflowCheckpoint = document.getElementById("gvhmr-workflow-checkpoint"); + if (workflowCheckpoint) { + workflowCheckpoint.textContent = gvhmrWorkspace.weightSource === "official" + ? gvhmrText("Official GVHMR (default)", "GVHMR 官方权重(默认)") + : gvhmrWorkspace.checkpoint?.name + ?? gvhmrText("Custom checkpoint not selected", "尚未选择自定义权重"); + } + + const runButton = document.getElementById("gvhmr-run") as HTMLButtonElement | null; + if (runButton) { + runButton.disabled = !canRun; + runButton.textContent = isBusy + ? gvhmrText("Generating…", "生成中……") + : gvhmrText("Start GVHMR", "开始 GVHMR 推理"); + } + const importResult = document.getElementById("gvhmr-import-result") as HTMLButtonElement | null; + if (importResult) importResult.disabled = isBusy; + const disabledReason = document.getElementById("gvhmr-disabled-reason"); + if (disabledReason) { + let reason = ""; + if (gvhmrWorkspace.runtimeState === "checking") { + reason = gvhmrText("Checking the GVHMR runtime.", "正在检查 GVHMR 推理环境。"); + } else if (gvhmrWorkspace.runtimeState === "unavailable") { + reason = runtimeMessage; + } else if (!gvhmrWorkspace.file) { + reason = gvhmrText("Select a video first.", "请先选择视频。"); + } else if (gvhmrWorkspace.weightSource === "custom" && !gvhmrWorkspace.checkpoint) { + reason = gvhmrText( + "Select a custom checkpoint or switch back to official weights.", + "请选择自定义 checkpoint,或切回官方权重。", + ); + } else if (!gvhmrWorkspace.environmentConfirmed) { + reason = gvhmrText("Confirm the runtime environment.", "请确认运行环境。"); + } + disabledReason.textContent = reason; + disabledReason.style.display = reason && !isBusy ? "block" : "none"; + } + + const progress = document.getElementById("gvhmr-progress"); + if (progress) { + progress.style.display = gvhmrWorkspace.stage === "idle" ? "none" : "block"; + const bar = progress.querySelector(".bar"); + if (bar) bar.style.width = `${Math.round(Math.max(0, Math.min(1, gvhmrWorkspace.progress)) * 100)}%`; + } + const status = document.getElementById("gvhmr-status"); + if (status) { + status.textContent = gvhmrWorkspace.message; + status.classList.toggle("error", gvhmrWorkspace.stage === "failed"); + } + + const resultCard = document.getElementById("gvhmr-result-card"); + if (resultCard) resultCard.style.display = gvhmrWorkspace.result ? "block" : "none"; + const resultEmpty = document.getElementById("gvhmr-result-empty"); + if (resultEmpty) resultEmpty.style.display = gvhmrWorkspace.result ? "none" : "block"; + const resultName = document.getElementById("gvhmr-result-name"); + if (resultName) resultName.textContent = gvhmrWorkspace.result?.name ?? "—"; + const resultFrames = document.getElementById("gvhmr-result-frames"); + if (resultFrames) { + resultFrames.textContent = gvhmrWorkspace.result?.frames == null + ? "—" + : String(gvhmrWorkspace.result.frames); + } + const resultDuration = document.getElementById("gvhmr-result-duration"); + if (resultDuration) { + const result = gvhmrWorkspace.result; + const parts = result?.duration == null ? [] : [`${result.duration.toFixed(2)} s`]; + if (result?.framerate != null) parts.push(`${result.framerate.toFixed(2)} fps`); + resultDuration.textContent = parts.join(" · ") || "—"; + } + + window.dispatchEvent(new CustomEvent("hhtools:video-to-motion-state", { + detail: gvhmrPublicState(), + })); +} + +function selectGvhmrCheckpoint(files: UploadFile[]): void { + if (!files.length) return; + if (files.length !== 1) { + toast(gvhmrText("Select one checkpoint at a time.", "每次只能选择一个权重文件。"), true); + return; + } + const file = files[0]; + gvhmrWorkspace.checkpoint = file; + gvhmrWorkspace.weightSource = "custom"; + gvhmrWorkspace.environmentConfirmed = false; + gvhmrWorkspace.stage = "idle"; + gvhmrWorkspace.progress = 0; + gvhmrWorkspace.message = ""; + gvhmrWorkspace.result = null; + renderGvhmrWorkspace(); +} + +function selectGvhmrVideo(files: UploadFile[]): void { + if (!files.length) return; + if (files.length !== 1) { + toast(gvhmrText("Select one video at a time.", "GVHMR 每次只处理一个视频。"), true); + return; + } + const file = files[0]; + const suffix = file.name.slice(file.name.lastIndexOf(".")).toLowerCase(); + if (!GVHMR_VIDEO_EXTENSIONS.has(suffix)) { + toast(gvhmrText( + "Supported formats: MP4, MOV, MKV, AVI, WebM, and M4V.", + "支持 MP4、MOV、MKV、AVI、WebM 和 M4V 视频。", + ), true); + return; + } + + if (gvhmrWorkspace.previewUrl) URL.revokeObjectURL(gvhmrWorkspace.previewUrl); + gvhmrWorkspace.file = file; + gvhmrWorkspace.previewUrl = URL.createObjectURL(file); + gvhmrWorkspace.previewDuration = null; + gvhmrWorkspace.stage = "idle"; + gvhmrWorkspace.progress = 0; + gvhmrWorkspace.message = ""; + gvhmrWorkspace.result = null; + + const preview = document.getElementById("gvhmr-video-preview") as HTMLVideoElement | null; + if (preview) { + preview.src = gvhmrWorkspace.previewUrl; + preview.onloadedmetadata = () => { + gvhmrWorkspace.previewDuration = Number.isFinite(preview.duration) ? preview.duration : null; + renderGvhmrWorkspace(); + }; + preview.load(); + } + renderGvhmrWorkspace(); +} + +async function refreshGvhmrRuntime(): Promise { + gvhmrWorkspace.runtimeState = "checking"; + gvhmrWorkspace.runtimeError = null; + renderGvhmrWorkspace(); + try { + const runtime: GvhmrRuntimeStatus = await API.get("/api/video-to-motion/status"); + gvhmrWorkspace.runtimeState = runtime.ready ? "ready" : "unavailable"; + gvhmrWorkspace.runtimeMissing = runtime.missing ?? []; + } catch (error) { + gvhmrWorkspace.runtimeState = "unavailable"; + gvhmrWorkspace.runtimeMissing = []; + gvhmrWorkspace.runtimeError = errorMessage(error); + } + renderGvhmrWorkspace(); +} + +function gvhmrFocalLength(): number | undefined { + const input = document.getElementById("gvhmr-f-mm") as HTMLInputElement | null; + const raw = input?.value.trim() ?? ""; + if (!raw) return undefined; + const value = Number(raw); + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(gvhmrText( + "Focal length must be a positive integer.", + "焦距必须是正整数。", + )); + } + return value; +} + +function gvhmrResultSummary(payload: MotionPayload): VideoToMotionResultSummary { + return { + name: payload.name, + frames: payload.playback_frames ?? payload.num_frames_total ?? payload.positions.length ?? null, + duration: payload.playback_duration ?? payload.duration ?? null, + framerate: payload.framerate ?? payload.sample_rate ?? null, + }; +} + +async function importExistingGvhmrResult(): Promise { + const files = await pickFiles({ accept: ".pt" }); + if (!files.length) return; + if (files.length !== 1) { + toast(gvhmrText("Select one GVHMR result at a time.", "每次只能选择一个 GVHMR 结果。"), true); + return; + } + + gvhmrWorkspace.stage = "uploading"; + gvhmrWorkspace.progress = 0; + gvhmrWorkspace.message = gvhmrText( + `Importing ${files[0].name}…`, + `正在导入 ${files[0].name}……`, + ); + gvhmrWorkspace.result = null; + renderGvhmrWorkspace(); + + const payload = await ingestMotionFiles(files, "mimic"); + if (!payload) { + gvhmrWorkspace.stage = "failed"; + gvhmrWorkspace.message = gvhmrText( + "GVHMR result import failed.", + "GVHMR 结果导入失败。", + ); + renderGvhmrWorkspace(); + return; + } + + gvhmrWorkspace.stage = "completed"; + gvhmrWorkspace.progress = 1; + gvhmrWorkspace.message = gvhmrText( + "Existing GVHMR result imported.", + "已有 GVHMR 结果已导入。", + ); + gvhmrWorkspace.result = gvhmrResultSummary(payload); + renderGvhmrWorkspace(); +} + +async function runGvhmrVideoToMotion(): Promise { + const file = gvhmrWorkspace.file; + if (!file || gvhmrWorkspace.runtimeState !== "ready" || !gvhmrWorkspace.environmentConfirmed) { + renderGvhmrWorkspace(); + return; + } + if (gvhmrWorkspace.weightSource === "custom" && !gvhmrWorkspace.checkpoint) { + toast(gvhmrText( + "Select a custom checkpoint or switch back to official weights.", + "请选择自定义 checkpoint,或切回官方权重。", + ), true); + renderGvhmrWorkspace(); + return; + } + + let fMm: number | undefined; + try { + fMm = gvhmrFocalLength(); + } catch (error) { + toast(errorMessage(error), true); + return; + } + const staticCam = (document.getElementById("gvhmr-static-cam") as HTMLInputElement | null)?.checked ?? true; + + gvhmrWorkspace.stage = "uploading"; + gvhmrWorkspace.progress = 0; + gvhmrWorkspace.message = gvhmrText(`Uploading ${file.name}…`, `正在上传 ${file.name}……`); + gvhmrWorkspace.result = null; + renderGvhmrWorkspace(); + showLoading(gvhmrWorkspace.message); + + try { + const { job_id } = await uploadFilesXHR( + "/api/video-to-motion/upload", + [file], + { + staticCam, + fMm, + checkpoint: gvhmrWorkspace.weightSource === "custom" + ? gvhmrWorkspace.checkpoint ?? undefined + : undefined, + }, + (fraction, loaded, total) => { + gvhmrWorkspace.progress = (fraction ?? 0) * 0.08; + gvhmrWorkspace.message = total > 0 + ? gvhmrText( + `Uploading ${fmtBytes(loaded)} / ${fmtBytes(total)}`, + `上传 ${fmtBytes(loaded)} / ${fmtBytes(total)}`, + ) + : gvhmrText("Uploading video…", "正在上传视频……"); + setLoadingProgress(gvhmrWorkspace.progress, gvhmrWorkspace.message); + renderGvhmrWorkspace(); + }, + ); + gvhmrWorkspace.stage = "running"; + renderGvhmrWorkspace(); + const payload = await waitMotionJob(job_id, (fraction, message) => { + gvhmrWorkspace.stage = "running"; + gvhmrWorkspace.progress = 0.08 + fraction * 0.92; + gvhmrWorkspace.message = message; + setLoadingProgress(gvhmrWorkspace.progress, message); + renderGvhmrWorkspace(); + }, { uploadFrac: 0 }); + setLoadingProgress(1, gvhmrText("Building the motion preview…", "正在构建动作预览……")); + await loadMotionPayload(payload); + await refreshLibrary(); + if (payload.library_entry) addToBasket([payload.library_entry], { silent: true }); + + gvhmrWorkspace.stage = "completed"; + gvhmrWorkspace.progress = 1; + gvhmrWorkspace.message = gvhmrText("Motion generated successfully.", "视频动作生成完成。"); + gvhmrWorkspace.result = gvhmrResultSummary(payload); + renderGvhmrWorkspace(); + toast(gvhmrText( + `GVHMR motion generated and loaded: ${payload.name}`, + `GVHMR 动作已生成并加载:${payload.name}`, + )); + } catch (error) { + gvhmrWorkspace.stage = "failed"; + gvhmrWorkspace.message = errorMessage(error); + renderGvhmrWorkspace(); + toast(gvhmrWorkspace.message, true); + } finally { + hideLoading(); + } +} + +function initGvhmrWorkspace(): void { + const pickButton = document.getElementById("video-pick-file") as HTMLButtonElement | null; + const runButton = document.getElementById("gvhmr-run") as HTMLButtonElement | null; + const weightSource = document.getElementById("gvhmr-weight-source") as HTMLSelectElement | null; + const confirmEnvironment = document.getElementById("gvhmr-confirm-environment") as HTMLButtonElement | null; + const pickCheckpoint = document.getElementById("gvhmr-pick-checkpoint") as HTMLButtonElement | null; + const importResult = document.getElementById("gvhmr-import-result") as HTMLButtonElement | null; + const dropzone = document.getElementById("video-drop-shared"); + if (!pickButton || !runButton || !weightSource || !confirmEnvironment || !dropzone) return; + + pickButton.onclick = async () => { + selectGvhmrVideo(await pickFiles({ accept: GVHMR_VIDEO_ACCEPT })); + }; + runButton.onclick = () => void runGvhmrVideoToMotion(); + if (importResult) importResult.onclick = () => void importExistingGvhmrResult(); + weightSource.onchange = () => { + gvhmrWorkspace.weightSource = weightSource.value === "custom" ? "custom" : "official"; + gvhmrWorkspace.environmentConfirmed = false; + gvhmrWorkspace.stage = "idle"; + gvhmrWorkspace.progress = 0; + gvhmrWorkspace.message = ""; + gvhmrWorkspace.result = null; + renderGvhmrWorkspace(); + }; + confirmEnvironment.onclick = () => { + if (!gvhmrWorkspace.file || gvhmrWorkspace.runtimeState !== "ready") return; + if (gvhmrWorkspace.weightSource === "custom" && !gvhmrWorkspace.checkpoint) return; + gvhmrWorkspace.environmentConfirmed = true; + renderGvhmrWorkspace(); + }; + if (pickCheckpoint) { + pickCheckpoint.onclick = async () => { + // Custom checkpoints are an explicit best-effort escape hatch. Do not + // guess compatibility from a filename suffix; the runtime owns loading. + selectGvhmrCheckpoint(await pickFiles()); + }; + } + setupDropzone(dropzone, (files) => selectGvhmrVideo(files)); + window.addEventListener("hhtools:workspace-locale-change", renderGvhmrWorkspace); + renderGvhmrWorkspace(); + void refreshGvhmrRuntime(); +} + +initGvhmrWorkspace(); +setupDropzone(document.getElementById("stage"), (files) => { + void ingestMotionFiles(files, "mimic"); +}); + +document.getElementById("add-to-basket").onclick = () => { + if (state.libraryEntry) { + addToBasket([state.libraryEntry]); + return; + } + toast(runtimeText( + "Load a motion from the Library before adding it to the basket, or use + on a library row.", + "请从资源库加载动作后再加入篮子,或使用资源库列表行的 +", + ), true); +}; + +// ================================================================= ROBOT +let _robotPanelLockDepth = 0; +let robotSummaries: RobotSummary[] = []; +let robotLibraryDir = ""; +let robotLoadingName = ""; + +function isBuiltinRobot(summary: RobotSummary): boolean { + return summary.builtin === true || curatedRobotLibraryItem(summary.name) != null; +} + +function robotSummaryLabel(summary: RobotSummary): string { + const copy = curatedRobotLibraryItem(summary.name); + return copy ? runtimeText(copy.en, copy.zh) : summary.display_name || summary.name; +} + +function sortedRobotSummaries(): RobotSummary[] { + return sortRobotLibrarySummaries(robotSummaries, robotSummaryLabel); +} + +/** Keep a compact workflow robot picker in sync with the shared Robot Library. */ +function populateWorkflowRobotSelect( + selectId: string, + loadButtonId: string, + preferredName?: string, +): void { + const select = document.getElementById(selectId) as HTMLSelectElement | null; + const loadButton = document.getElementById(loadButtonId) as HTMLButtonElement | null; + if (!select || !loadButton) return; + + const preferred = preferredName || select.value || state.robot?.name; + const placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = runtimeText("Select a robot…", "选择机器人……"); + select.replaceChildren(placeholder); + + for (const summary of sortedRobotSummaries()) { + const option = document.createElement("option"); + option.value = summary.name; + option.textContent = `${robotSummaryLabel(summary)} (${summary.num_dof} DoF)`; + option.disabled = !summary.has_urdf; + select.appendChild(option); + } + + if (preferred && [...select.options].some((option) => option.value === preferred && !option.disabled)) { + select.value = preferred; + } else { + select.value = ""; + } + const selectedRobotIsLoaded = Boolean(select.value && select.value === state.robot?.name); + select.disabled = state.robotPanelLocked || Boolean(robotLoadingName); + loadButton.disabled = select.disabled || !select.value || selectedRobotIsLoaded; + loadButton.textContent = selectedRobotIsLoaded + ? runtimeText( + selectId === "batch-robot-select" ? "Target robot loaded" : "Robot loaded", + selectId === "batch-robot-select" ? "目标机器人已加载" : "机器人已加载", + ) + : runtimeText( + selectId === "batch-robot-select" ? "Load target robot" : "Load robot", + selectId === "batch-robot-select" ? "加载目标机器人" : "加载机器人", + ); +} + +function populateH2rRobotSelect(preferredName?: string): void { + populateWorkflowRobotSelect("h2r-robot-select", "h2r-robot-load", preferredName); +} + +function populateBatchRobotSelect(preferredName?: string): void { + populateWorkflowRobotSelect("batch-robot-select", "batch-robot-load", preferredName); +} + +/** + * Reference-counted workspace lock: overlapping async workflows may share the + * robot controls, so one completion must not unlock another caller's operation. + */ +function setRobotPanelLocked(locked: boolean): void { + if (locked) _robotPanelLockDepth++; + else _robotPanelLockDepth = Math.max(0, _robotPanelLockDepth - 1); + const busy = _robotPanelLockDepth > 0; + state.robotPanelLocked = busy; + + for (const id of ["robot-pick-urdf", "robot-pick-mesh-folder"]) { + const el = document.getElementById(id) as HTMLButtonElement | null; + if (el) el.disabled = busy; + } + for (const id of ["robot-drop-urdf", "robot-drop-mesh"]) { + document.getElementById(id)?.classList.toggle("disabled", busy); + } + populateH2rRobotSelect(); + populateBatchRobotSelect(); + renderRobotLibrary(); + publishH2rWorkflowState(); + updateBatchRunAvailability(); +} + +function renderRobotDetails(robotData: RobotPayload): void { + document.getElementById("robot-name").textContent = robotData.display_name; + renderMetaRows(document.getElementById("robot-meta"), [ + [runtimeText("Links", "链接"), robotData.links.length], + [runtimeText("Degrees of freedom", "自由度"), robotData.num_dof ?? robotData.joints?.length ?? 0], + [runtimeText("ik_map slots", "ik_map 槽位"), Object.keys(robotData.ik_map ?? {}).length], + ]); + renderRobotValidation(robotData); +} + +interface ApplyRobotOptions { + /** Batch selects a target in place instead of navigating away from its task builder. */ + stayOnCurrentPanel?: boolean; +} + +async function applyRobot( + robotData: RobotPayload, + { stayOnCurrentPanel = false }: ApplyRobotOptions = {}, +): Promise { + if (state.robotPanelLocked) { + toast(runtimeText( + "Retargeting is running. Wait for it to finish before switching robots.", + "Retarget 进行中,请等待完成后再切换机器人", + ), true); + return; + } + state.robot = robotData; + state.exportToken = null; + state.robotTrajectory = null; + clearResultDiagnostics("h2r"); + state.calibration = false; + h2rRunState = "idle"; + document.getElementById("rt-export-card").style.display = "none"; + populateH2rRobotSelect(robotData.name); + populateBatchRobotSelect(robotData.name); + await robot.load(robotData); + document.getElementById("robot-meta-card").style.display = "block"; + renderRobotDetails(robotData); + renderRobotLibrary(); + document.getElementById("batch-robot").textContent = robotData.display_name; + renderBasket(); + updatePills(); + const tgRobot = document.getElementById("tg-robot"); + tgRobot.disabled = false; + setViewVisible(robot, "tg-robot", true); + revealStage(); + // Await so state.calibration is fresh; refreshRetargetPanel itself loads the + // scaled skeleton/scene when a calibration already exists (no retarget needed). + if (state.calibrationMode) { + switchInspectorPanel("h2r"); + await enterCalibrationMode(state.calibQ); + toast(runtimeText( + `Robot loaded in calibration pose: ${robotData.display_name}`, + `机器人已加载(标定姿态):${robotData.display_name}`, + )); + return; + } + if (stayOnCurrentPanel) await refreshRetargetPanel(); + else await routeAfterRobotLoad(); + toast( + state.motion + ? runtimeText( + `Robot loaded: ${robotData.display_name}`, + `机器人已加载:${robotData.display_name}`, + ) + : runtimeText( + `Robot loaded: ${robotData.display_name} — load a motion next`, + `机器人已加载:${robotData.display_name} — 请先加载动作`, + ), + ); +} + +async function refreshRobotList(): Promise { + try { + const data = await API.get("/api/robots"); + robotSummaries = data.robots || []; + robotLibraryDir = data.library_dir || ""; + populateH2rRobotSelect(); + populateBatchRobotSelect(); + renderRobotLibrary(); + } catch (e) { + renderTextMessage( + document.getElementById("robot-library-list"), + runtimeText( + `Unable to read the Robot Library: ${errorMessage(e)}`, + `无法读取机器人库:${errorMessage(e)}`, + ), + ); + } +} + +function renderRobotLibrary(): void { + const list = document.getElementById("robot-library-list"); + if (!list) return; + const input = document.getElementById("robot-library-search") as HTMLInputElement | null; + const query = input?.value.trim().toLowerCase() || ""; + const tokens = query.split(/\s+/).filter(Boolean); + const filtered = sortedRobotSummaries().filter((summary) => { + const builtin = isBuiltinRobot(summary); + const haystack = [ + summary.name, + summary.display_name, + robotSummaryLabel(summary), + summary.num_dof, + builtin ? "builtin built-in included 内置 预置" : "imported custom uploaded 导入 自定义 上传", + ].join(" ").toLowerCase(); + return tokens.every((token) => haystack.includes(token)); + }); + + list.replaceChildren(); + if (!robotSummaries.length) { + renderTextMessage(list, runtimeText( + "No robot models are available. Import a complete robot folder above.", + "机器人库中暂无模型,请从上方导入完整的机器人文件夹。", + )); + return; + } + if (!filtered.length) { + renderTextMessage(list, runtimeText( + `No robots match “${input?.value || ""}”`, + `没有匹配「${input?.value || ""}」的机器人`, + )); + return; + } + + for (const summary of filtered) { + const builtin = isBuiltinRobot(summary); + const active = state.robot?.name === summary.name; + const unavailable = !summary.has_urdf; + const row = document.createElement("div"); + row.className = "lib-row robot-lib-row"; + row.classList.toggle("is-active", active); + row.classList.toggle("is-unavailable", unavailable); + + const loadButton = document.createElement("button"); + loadButton.type = "button"; + loadButton.className = "lr-load robot-library-load"; + loadButton.disabled = unavailable || state.robotPanelLocked || Boolean(robotLoadingName); + loadButton.setAttribute("aria-label", runtimeText( + `Load robot ${robotSummaryLabel(summary)}`, + `加载机器人 ${robotSummaryLabel(summary)}`, + )); + if (active) loadButton.setAttribute("aria-current", "true"); + + const icon = document.createElement("img"); + icon.className = "robot-library-icon"; + icon.src = robotLibraryIcon(summary.name); + // Broken or unavailable curated artwork must degrade to the same generic + // mark used by user imports, never to a browser broken-image glyph. + icon.onerror = () => { + icon.onerror = null; + icon.src = DEFAULT_ROBOT_LIBRARY_ICON; + }; + icon.alt = ""; + icon.setAttribute("aria-hidden", "true"); + + const copy = document.createElement("span"); + copy.className = "robot-library-copy"; + copy.append( + textElement("strong", "robot-library-name", robotSummaryLabel(summary)), + textElement("small", "robot-library-meta", runtimeText( + `${summary.num_dof} DoF · ${builtin ? "Built-in" : "Imported"}${unavailable ? " · URDF missing" : ""}`, + `${summary.num_dof} DoF · ${builtin ? "内置" : "已导入"}${unavailable ? " · 缺少 URDF" : ""}`, + )), + ); + loadButton.append(icon, copy); + if (robotLoadingName === summary.name) { + loadButton.append(textElement("span", "robot-library-state", runtimeText("Loading…", "加载中……"))); + } else if (active) { + loadButton.append(textElement("span", "robot-library-state", runtimeText("Loaded", "已加载"))); + } + loadButton.onclick = () => loadRobotSummary(summary); + row.appendChild(loadButton); + + if (summary.deletable && !builtin) { + const deleteButton = textElement("button", "robot-library-delete", "×"); + deleteButton.type = "button"; + deleteButton.disabled = state.robotPanelLocked || Boolean(robotLoadingName); + deleteButton.title = runtimeText("Remove from Robot Library", "从机器人库删除"); + deleteButton.setAttribute("aria-label", runtimeText( + `Delete robot ${robotSummaryLabel(summary)}`, + `删除机器人 ${robotSummaryLabel(summary)}`, + )); + deleteButton.onclick = (event) => { + event.stopPropagation(); + void deleteRobotSummary(summary); + }; + row.appendChild(deleteButton); + } + list.appendChild(row); + } + + const hint = document.getElementById("robot-library-hint"); + if (hint) { + hint.textContent = runtimeText( + "Imported robot models stay in the local library.", + "导入的机器人模型会保存在本机资源库。", + ); + hint.title = robotLibraryDir; + } +} + +async function loadRobotSummary( + summary: RobotSummary, + options: ApplyRobotOptions = {}, +): Promise { + if (state.robotPanelLocked) { + toast(runtimeText( + "Retargeting is running. Wait for it to finish before switching robots.", + "Retarget 进行中,请等待完成后再切换机器人", + ), true); + return; + } + if (options.stayOnCurrentPanel && state.calibrationMode) { + toast(runtimeText( + "Finish or cancel the current calibration before changing the Batch target robot.", + "请先保存或取消当前标定,再更换 Batch 目标机器人。", + ), true); + return; + } + if (!summary.has_urdf || robotLoadingName) return; + robotLoadingName = summary.name; + renderRobotLibrary(); + toast(runtimeText("Loading robot…", "加载机器人……")); + try { + await applyRobot(await API.post("/api/robot/select", { name: summary.name }), options); + } catch (e) { + toast(errorMessage(e), true); + } finally { + robotLoadingName = ""; + populateH2rRobotSelect(); + populateBatchRobotSelect(); + renderRobotLibrary(); + } +} + +async function deleteRobotSummary(summary: RobotSummary): Promise { + if (state.robotPanelLocked) { + toast(runtimeText( + "Retargeting is running. Wait for it to finish before editing the library.", + "Retarget 进行中,请等待完成后再操作", + ), true); + return; + } + const label = robotSummaryLabel(summary); + if (!confirm(runtimeText( + `Remove “${label}” from the Robot Library?\nThis permanently deletes its local folder and cannot be undone.`, + `确定从机器人库删除「${label}」?\n将永久删除对应目录,不可恢复。`, + ))) return; + toast(runtimeText("Removing robot…", "删除机器人……")); + try { + await API.delete(`/api/robot/${encodeURIComponent(summary.name)}`); + if (state.robot?.name === summary.name) { + state.robot = null; + state.exportToken = null; + state.robotTrajectory = null; + clearResultDiagnostics("h2r"); + h2rRunState = "idle"; + robot.group.visible = false; + document.getElementById("robot-meta-card").style.display = "none"; + document.getElementById("robot-pill").textContent = runtimeText("No robot loaded", "未加载机器人"); + document.getElementById("batch-robot").textContent = runtimeText("Not loaded", "未加载"); + renderBasket(); + refreshRetargetPanel(); + } + await refreshRobotList(); + toast(runtimeText( + `Removed from Robot Library: ${label}`, + `已从机器人库删除:${label}`, + )); + } catch (e) { toast(errorMessage(e), true); } +} + +const robotSearchInput = document.getElementById("robot-library-search") as HTMLInputElement | null; +if (robotSearchInput) robotSearchInput.oninput = renderRobotLibrary; + +const h2rRobotSelect = document.getElementById("h2r-robot-select"); +if (h2rRobotSelect) { + h2rRobotSelect.onchange = () => populateH2rRobotSelect(); +} +const h2rRobotLoadButton = document.getElementById("h2r-robot-load"); +if (h2rRobotLoadButton) { + h2rRobotLoadButton.onclick = async () => { + const name = h2rRobotSelect?.value; + const summary = robotSummaries.find((candidate) => candidate.name === name); + if (summary) await loadRobotSummary(summary); + }; +} + +const batchRobotSelect = document.getElementById("batch-robot-select") as HTMLSelectElement | null; +if (batchRobotSelect) { + batchRobotSelect.onchange = () => { + populateBatchRobotSelect(); + updateBatchRunAvailability(); + }; +} +const batchRobotLoadButton = document.getElementById("batch-robot-load") as HTMLButtonElement | null; +if (batchRobotLoadButton) { + batchRobotLoadButton.onclick = async () => { + const summary = robotSummaries.find((candidate) => candidate.name === batchRobotSelect?.value); + if (summary) await loadRobotSummary(summary, { stayOnCurrentPanel: true }); + }; +} + +interface RobotImportState { + urdf: UploadFile | null; + meshes: UploadFile[]; +} + +const robotImport: RobotImportState = { urdf: null, meshes: [] }; + +function isUrdfFile(f: UploadFile): boolean { + return (f._relpath || f.name).toLowerCase().endsWith(".urdf"); +} +function isMeshFile(f: UploadFile): boolean { + const p = (f._relpath || f.name).toLowerCase(); + return /\.(stl|obj|dae|ply|glb|gltf)$/i.test(p); +} +function updateRobotImportStatus(): void { + const el = document.getElementById("robot-import-status"); + if (!el) return; + const parts: string[] = []; + if (robotImport.urdf) parts.push(`URDF: ${robotImport.urdf.name || "robot.urdf"}`); + if (robotImport.meshes.length) parts.push(runtimeText( + `Assets: ${robotImport.meshes.length} files`, + `资源:${robotImport.meshes.length} 个文件`, + )); + if (robotImport.urdf && !robotImport.meshes.length) { + parts.push(runtimeText( + "Choose the matching mesh folder to continue", + "请继续选择对应的 mesh 文件夹", + )); + } + el.textContent = parts.length + ? parts.join(" · ") + : runtimeText("No URDF selected.", "尚未选择 URDF。"); +} + +async function tryUploadRobot(): Promise { + if (state.robotPanelLocked) { + toast(runtimeText( + "Retargeting is running. Wait for it to finish before switching robots.", + "Retarget 进行中,请等待完成后再切换机器人", + ), true); + return; + } + if (!robotImport.urdf) { + toast(runtimeText("Choose a .urdf file first.", "请先选择 .urdf 文件。"), true); + return; + } + // The backend wipes the upload dir on every call, so URDF + meshes MUST be + // sent together. ``name`` is passed as a query param so the temp dir matches + // the URDF stem (the registered preset name still comes from the URDF's + // ```` during scaffolding). + const files = [robotImport.urdf, ...robotImport.meshes]; + const name = (robotImport.urdf.name || "robot") + .replace(/\.urdf$/i, "") + .replace(/[^a-z0-9_]/gi, "_") + .toLowerCase(); + toast(runtimeText( + `Importing robot… (${files.length} files)`, + `导入机器人……(${files.length} 个文件)`, + )); + try { + const robotData = await API.upload("/api/robot/upload", files, { name }); + await applyRobot(robotData); + // The backend persists the imported preset; refreshing exposes it through + // the same Robot Library used for the bundled G1 and X2 models. + await refreshRobotList(); + robotImport.urdf = null; + robotImport.meshes = []; + updateRobotImportStatus(); + toast(runtimeText( + `Robot added to the library: ${robotData.display_name || robotData.name}`, + `机器人已加入资源库:${robotData.display_name || robotData.name}`, + )); + } catch (e) { toast(errorMessage(e), true); } +} + +function ingestRobotUrdf(files: UploadFile[]): void { + if (state.robotPanelLocked) { + toast(runtimeText( + "Retargeting is running. Wait for it to finish before switching robots.", + "Retarget 进行中,请等待完成后再切换机器人", + ), true); + return; + } + if (!files?.length) return; + const urdf = files.find(isUrdfFile); + if (!urdf) { + toast(runtimeText("No .urdf file was found.", "未找到 .urdf 文件。"), true); + return; + } + robotImport.urdf = urdf; + const extra = files.filter((f) => f !== urdf && (isMeshFile(f) || !isUrdfFile(f))); + if (extra.length) robotImport.meshes = [...robotImport.meshes, ...extra]; + updateRobotImportStatus(); + // Only upload now when the same drop already carried the meshes (a whole + // robot folder). A bare .urdf drop must WAIT for step 2 (the meshes/ folder) + // — uploading immediately used to register a mesh-less robot and reset the + // stored URDF, so the subsequent meshes drop hit the "add a .urdf first" guard. + if (robotImport.meshes.length) { + void tryUploadRobot(); + } else { + toast(runtimeText( + "URDF selected. Choose the matching mesh folder to finish importing.", + "已读取 URDF,请继续选择对应的 mesh 文件夹完成导入。", + )); + } +} + +function ingestRobotMesh(files: UploadFile[]): void { + if (state.robotPanelLocked) { + toast(runtimeText( + "Retargeting is running. Wait for it to finish before switching robots.", + "Retarget 进行中,请等待完成后再切换机器人", + ), true); + return; + } + if (!files?.length) return; + const meshes = files.filter((f) => !isUrdfFile(f)); + if (!meshes.length) { + toast(runtimeText("No mesh assets were found.", "未找到 mesh 资源。"), true); + return; + } + if (!robotImport.urdf) { + toast(runtimeText( + "Choose the robot URDF before selecting its mesh folder.", + "请先选择机器人 URDF,再选择对应的 mesh 文件夹。", + ), true); + return; + } + robotImport.meshes = meshes; + updateRobotImportStatus(); + void tryUploadRobot(); +} + +setupDropzone(document.getElementById("robot-drop-urdf"), ingestRobotUrdf); +setupDropzone(document.getElementById("robot-drop-mesh"), ingestRobotMesh); +document.getElementById("robot-pick-urdf").onclick = async () => + ingestRobotUrdf(await pickFiles()); +document.getElementById("robot-pick-mesh-folder").onclick = async () => + ingestRobotMesh(await pickFiles({ folder: true })); + +// ================================================================= CALIBRATION 3D MANIPULATOR +const _hhtoolsWorld = new THREE.Vector3(); +const _hhtoolsAxis = new THREE.Vector3(); +const _projScratch = new THREE.Vector3(); +const _dragPlane = new THREE.Plane(); +const _arcRef = new THREE.Vector3(); +const _arcCross = new THREE.Vector3(); + +/** Map hhtools Z-up coordinates to three.js world (inside the rotated ``world`` group). */ +function hhtoolsToWorldVec3( + x: number, + y: number, + z: number, + out = _hhtoolsWorld, +): THREE.Vector3 { + out.set(x, y, z); + return out.applyMatrix4(world.matrixWorld); +} + +/** Point on a rotation arc: pivot + R·(cos θ·ref + sin θ·(axis×ref)). */ +function arcPointWorld( + pivot: THREE.Vector3, + axis: THREE.Vector3, + ref: THREE.Vector3, + angle: number, + radius: number, +): THREE.Vector3 { + const c = Math.cos(angle); + const s = Math.sin(angle); + _arcCross.crossVectors(axis, ref); + return pivot.clone() + .add(ref.clone().multiplyScalar(c * radius)) + .add(_arcCross.multiplyScalar(s * radius)); +} + +interface CalibrationJointMeta { + child_link?: string; + lower: number; + upper: number; + type: string; +} + +type CalibrationJointWorld = JointWorldPayload; + +interface CalibrationChangeOptions { + from: string; + live?: boolean; +} + +interface CalibrationPreviewOptions { + live?: boolean; + flush?: boolean; +} + +interface CalibrationContext { + robotView: RobotView; + getQ: () => Record; + getSliderRows: () => Record; + jointChange: ( + name: string, + value: string | number, + options: CalibrationChangeOptions, + ) => void; + previewFk: (options?: CalibrationPreviewOptions) => void | Promise; +} + +interface CalibrationHudTag { + el: HTMLElement; + input: HTMLInputElement; + nameEl: HTMLElement; + unitEl: HTMLElement; + loEl: HTMLElement; + hiEl: HTMLElement; + track: HTMLElement; + thumb: HTMLElement; + fill: HTMLElement; +} + +interface CalibrationLimitGizmo { + group: THREE.Group; + arc: THREE.Line; + loTick: THREE.Mesh; + hiTick: THREE.Mesh; + curTick: THREE.Mesh; + needle: THREE.Line; +} + +interface HudLayout { + ox: number; + oy: number; + w: number; + h: number; + cardW: number; + cardH: number; + pad: number; +} + +interface Point2D { + x: number; + y: number; +} + +class CalibManipulator { + readonly canvas: HTMLCanvasElement; + readonly hud: HTMLElement; + readonly stage: HTMLElement; + active = false; + readonly raycaster = new THREE.Raycaster(); + readonly pointer = new THREE.Vector2(); + jointMeta: Record = {}; + linkToJoint: Record = {}; + jointToLink: Record = {}; + jointWorld: Record = {}; + selected: string | null = null; + hoveredLink: string | null = null; + hoveredJoint: string | null = null; + dragging = false; + angleUnit: CalibrationAngleUnit = "rad"; + _dragRef: THREE.Vector3 | null = null; + _dragStartQ = 0; + readonly _tags = new Map(); + _limitGroup: CalibrationLimitGizmo | null = null; + _pickScreen: Point2D | null = null; + _pickAnchor: THREE.Vector3 | null = null; + _hudPinned: Point2D | null = null; + _hudCardDrag: boolean | null = null; + _hudTrackDrag: string | null = null; + _ctx: CalibrationContext | null = null; + readonly _onDown: (event: PointerEvent) => void; + readonly _onMove: (event: PointerEvent) => void; + readonly _onUp: (event: PointerEvent) => void; + + constructor({ + canvasEl, + hudEl, + stageEl, + }: { + canvasEl: HTMLCanvasElement; + hudEl: HTMLElement; + stageEl: HTMLElement; + }) { + this.canvas = canvasEl; + this.hud = hudEl; + this.stage = stageEl; + this._onDown = (event) => this._pointerDown(event); + this._onMove = (event) => this._pointerMove(event); + this._onUp = () => this._pointerUp(); + } + + private _defaultCtx(): CalibrationContext { + return { + robotView: robot, + getQ: () => state.calibQ, + getSliderRows: () => state.calibSliderRows, + jointChange: (name, val, opts) => setCalibJointValue(name, val, opts), + previewFk: (opts) => previewCalibPose(opts), + }; + } + + /** Active calibration methods share one context for their full lifetime. */ + private get context(): CalibrationContext { + if (!this._ctx) throw new Error("Calibration manipulator is not active"); + return this._ctx; + } + + start(limitsList: RobotJointLimit[], ctx: CalibrationContext | null = null): void { + this.active = true; + this._ctx = ctx || this._defaultCtx(); + this.jointMeta = {}; + this.linkToJoint = {}; + this.jointToLink = {}; + for (const L of limitsList || []) { + if (!L.name || L.type === "fixed") continue; + const lo = L.lower != null ? L.lower : -Math.PI; + const hi = L.upper != null ? L.upper : Math.PI; + this.jointMeta[L.name] = { + child_link: L.child_link, + lower: lo, + upper: hi, + type: L.type || "revolute", + }; + if (L.child_link) { + this.linkToJoint[L.child_link] = L.name; + this.jointToLink[L.name] = L.child_link; + } + } + this.hud.classList.remove("hidden"); + this.hud.setAttribute("aria-hidden", "false"); + this.stage.classList.add("calib-pickable"); + this._initLimitGizmo(); + this._buildTags(); + this.canvas.addEventListener("pointerdown", this._onDown); + window.addEventListener("pointermove", this._onMove); + window.addEventListener("pointerup", this._onUp); + window.addEventListener("pointercancel", this._onUp); + } + + stop(): void { + this.active = false; + this.selected = null; + this.hoveredLink = null; + this.hoveredJoint = null; + this.dragging = false; + this._pickScreen = null; + this._pickAnchor = null; + this._hudPinned = null; + this._hudCardDrag = null; + this.hud.innerHTML = ""; + this.hud.classList.add("hidden"); + this.hud.setAttribute("aria-hidden", "true"); + this.stage.classList.remove("calib-pickable", "calib-dragging", "calib-hover-joint"); + this._tags.clear(); + this._disposeLimitGizmo(); + (this._ctx?.robotView || robot).setCalibHighlights({}); + this._ctx = null; + document.getElementById("calib-hover-hint")?.classList.remove("show"); + this.canvas.removeEventListener("pointerdown", this._onDown); + window.removeEventListener("pointermove", this._onMove); + window.removeEventListener("pointerup", this._onUp); + window.removeEventListener("pointercancel", this._onUp); + orbit.enabled = true; + } + + private _initLimitGizmo(): void { + this._disposeLimitGizmo(); + const g = new THREE.Group(); + const arcMat = new THREE.LineBasicMaterial({ color: 0x94a3b8, transparent: true, opacity: 0.85 }); + const loMat = new THREE.MeshBasicMaterial({ color: 0xef4444 }); + const hiMat = new THREE.MeshBasicMaterial({ color: 0xef4444 }); + const curMat = new THREE.MeshBasicMaterial({ color: 0x2563eb }); + const needleMat = new THREE.LineBasicMaterial({ color: 0x2563eb, linewidth: 2 }); + const tickGeo = new THREE.SphereGeometry(0.012, 10, 10); + this._limitGroup = { + group: g, + arc: new THREE.Line(new THREE.BufferGeometry(), arcMat), + loTick: new THREE.Mesh(tickGeo, loMat), + hiTick: new THREE.Mesh(tickGeo.clone(), hiMat), + curTick: new THREE.Mesh(tickGeo.clone(), curMat), + needle: new THREE.Line(new THREE.BufferGeometry(), needleMat), + }; + g.add(this._limitGroup.arc, this._limitGroup.loTick, this._limitGroup.hiTick, + this._limitGroup.curTick, this._limitGroup.needle); + g.visible = false; + world.add(g); + } + + private _disposeLimitGizmo(): void { + if (!this._limitGroup) return; + world.remove(this._limitGroup.group); + this._limitGroup.arc.geometry.dispose(); + this._limitGroup.needle.geometry.dispose(); + this._limitGroup.loTick.geometry.dispose(); + this._limitGroup.hiTick.geometry.dispose(); + this._limitGroup.curTick.geometry.dispose(); + this._limitGroup = null; + } + + private _buildTags(): void { + this.hud.innerHTML = ""; + this._tags.clear(); + for (const name of Object.keys(this.jointMeta)) { + const meta = this.jointMeta[name]; + const card = document.createElement("div"); + card.className = "calib-hud-card"; + card.dataset.joint = name; + + const head = document.createElement("div"); + head.className = "calib-hud-head calib-hud-drag-handle"; + head.title = runtimeText("Drag the title bar to move the control", "拖动标题栏移动控件"); + const grip = document.createElement("span"); + grip.className = "calib-hud-grip"; + grip.setAttribute("aria-hidden", "true"); + grip.textContent = "⋮⋮"; + const nameEl = document.createElement("span"); + nameEl.className = "joint-name"; + nameEl.textContent = name; + nameEl.title = name; + const unit = document.createElement("span"); + unit.className = "joint-unit"; + unit.textContent = this.angleUnit; + head.append(grip, nameEl, unit); + + const limitRow = document.createElement("div"); + limitRow.className = "calib-limit-row"; + const loEl = document.createElement("span"); + loEl.className = "limit-end limit-lo"; + loEl.textContent = formatCalibrationAngle(meta.lower, this.angleUnit, 2); + const track = document.createElement("div"); + track.className = "limit-track"; + const fill = document.createElement("div"); + fill.className = "limit-fill"; + const thumb = document.createElement("div"); + thumb.className = "limit-thumb"; + track.appendChild(fill); + track.appendChild(thumb); + const hiEl = document.createElement("span"); + hiEl.className = "limit-end limit-hi"; + hiEl.textContent = formatCalibrationAngle(meta.upper, this.angleUnit, 2); + limitRow.append(loEl, track, hiEl); + + const input = document.createElement("input"); + input.type = "number"; + input.className = "calib-angle-input"; + input.step = this.angleUnit === "deg" ? "0.1" : "0.001"; + input.value = "0.000"; + input.min = String(angleForDisplay(meta.lower, this.angleUnit)); + input.max = String(angleForDisplay(meta.upper, this.angleUnit)); + input.addEventListener("input", () => { + this.context.jointChange(name, input.value, { from: "hud-input", live: true }); + }); + input.addEventListener("change", () => { + this.context.jointChange(name, input.value, { from: "hud-input" }); + }); + input.addEventListener("keydown", (ev) => { + if (ev.key === "Enter") input.blur(); + ev.stopPropagation(); + }); + input.addEventListener("pointerdown", (ev) => ev.stopPropagation()); + + card.append(head, limitRow, input); + this.hud.appendChild(card); + this._bindHudCardDrag(card, head); + this._bindHudTrackDrag(name, track, thumb, meta); + this._tags.set(name, { el: card, input, nameEl, unitEl: unit, loEl, hiEl, track, thumb, fill }); + } + } + + setAngleUnit(unit: CalibrationAngleUnit): void { + this.angleUnit = unit; + for (const [joint, tag] of this._tags) { + const meta = this.jointMeta[joint]; + if (!meta) continue; + tag.unitEl.textContent = unit; + tag.loEl.textContent = formatCalibrationAngle(meta.lower, unit, 2); + tag.hiEl.textContent = formatCalibrationAngle(meta.upper, unit, 2); + tag.input.min = String(angleForDisplay(meta.lower, unit)); + tag.input.max = String(angleForDisplay(meta.upper, unit)); + tag.input.step = unit === "deg" ? "0.1" : "0.001"; + this.updateHudValue(joint, this.context.getQ()[joint] ?? 0); + } + } + + private _hudLayout(): HudLayout { + const canvasRect = this.canvas.getBoundingClientRect(); + const hudRect = this.hud.getBoundingClientRect(); + return { + ox: canvasRect.left - hudRect.left, + oy: canvasRect.top - hudRect.top, + w: canvasRect.width, + h: canvasRect.height, + cardW: 180, + cardH: 112, + pad: 14, + }; + } + + private _applyHudPin( + el: HTMLElement, + x: number, + y: number, + layout: HudLayout = this._hudLayout(), + ): Point2D { + const { w, h, cardW, cardH, pad } = layout; + const clamped = this._clampHudCard(x, y, w, h, cardW, cardH, pad); + el.classList.remove("screen-docked", "screen-pick"); + el.classList.add("user-pinned", "visible"); + el.style.left = `${clamped.x}px`; + el.style.top = `${clamped.y}px`; + return clamped; + } + + private _bindHudCardDrag(card: HTMLElement, head: HTMLElement): void { + const onDown = (e: PointerEvent): void => { + if (e.button !== 0) return; + e.stopPropagation(); + e.preventDefault(); + const layout = this._hudLayout(); + const hudRect = this.hud.getBoundingClientRect(); + const cardRect = card.getBoundingClientRect(); + card.classList.add("user-pinned", "is-dragging"); + const anchorX = cardRect.left - hudRect.left + cardRect.width * 0.5; + const anchorY = cardRect.top - hudRect.top + cardRect.height * 0.5; + const start = { px: e.clientX, py: e.clientY, ax: anchorX, ay: anchorY }; + this._hudCardDrag = true; + orbit.enabled = false; + try { head.setPointerCapture(e.pointerId); } catch { /* ignore */ } + const onMove = (ev: PointerEvent): void => { + const x = start.ax + (ev.clientX - start.px); + const y = start.ay + (ev.clientY - start.py); + this._hudPinned = { x, y }; + this._applyHudPin(card, x, y, layout); + }; + const onUp = (ev: PointerEvent): void => { + this._hudCardDrag = false; + card.classList.remove("is-dragging"); + orbit.enabled = true; + try { head.releasePointerCapture(ev.pointerId); } catch { /* ignore */ } + head.removeEventListener("pointermove", onMove); + head.removeEventListener("pointerup", onUp); + head.removeEventListener("pointercancel", onUp); + }; + head.addEventListener("pointermove", onMove); + head.addEventListener("pointerup", onUp); + head.addEventListener("pointercancel", onUp); + }; + head.addEventListener("pointerdown", onDown); + } + + private _bindHudTrackDrag( + name: string, + track: HTMLElement, + thumb: HTMLElement, + meta: CalibrationJointMeta, + ): void { + const tag = () => this._tags.get(name); + const paintThumb = (clientX: number): number => { + const rect = track.getBoundingClientRect(); + const t = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)); + const pct = `${(t * 100).toFixed(2)}%`; + const row = tag(); + if (row) { + row.thumb.style.left = pct; + row.fill.style.width = pct; + } + return meta.lower + t * (meta.upper - meta.lower); + }; + const move = (clientX: number): void => { + const val = paintThumb(clientX); + this.context.jointChange(name, val, { from: "hud-track", live: true }); + }; + const onDown = (e: PointerEvent): void => { + e.stopPropagation(); + e.preventDefault(); + this._hudTrackDrag = name; + this.setSelected(name); + const row = tag(); + row?.el.classList.add("track-dragging"); + this.stage.classList.add("calib-dragging"); + orbit.enabled = false; + try { track.setPointerCapture(e.pointerId); } catch { /* ignore */ } + move(e.clientX); + const onMove = (ev: PointerEvent): void => { + if (this._hudTrackDrag === name) move(ev.clientX); + }; + const onUp = (ev: PointerEvent): void => { + if (this._hudTrackDrag !== name) return; + this._hudTrackDrag = null; + row?.el.classList.remove("track-dragging"); + this.stage.classList.remove("calib-dragging"); + orbit.enabled = true; + this.context.previewFk({ flush: true }); + try { track.releasePointerCapture(ev.pointerId); } catch { /* ignore */ } + track.removeEventListener("pointermove", onMove); + track.removeEventListener("pointerup", onUp); + track.removeEventListener("pointercancel", onUp); + }; + track.addEventListener("pointermove", onMove); + track.addEventListener("pointerup", onUp); + track.addEventListener("pointercancel", onUp); + }; + track.addEventListener("pointerdown", onDown); + thumb.addEventListener("pointerdown", onDown); + } + + setSelected(jointName: string, { scrollPanel = false }: { scrollPanel?: boolean } = {}): void { + if (!this.active) return; + this.selected = jointName; + for (const [j, { el }] of this._tags) { + el.classList.toggle("visible", j === jointName); + } + const sliderRows = this.context.getSliderRows(); + for (const [j, rowRec] of Object.entries(sliderRows)) { + rowRec.row?.classList.toggle("selected", j === jointName); + } + this._syncHighlights(); + this._updateLimitGizmo(); + if (scrollPanel && jointName && sliderRows[jointName]?.row) { + sliderRows[jointName].row.scrollIntoView({ block: "nearest", behavior: "smooth" }); + } + } + + private _syncHighlights(): void { + const selLink = this.selected ? this.jointToLink[this.selected] : null; + const hovLink = this.hoveredLink; + this.context.robotView.setCalibHighlights({ hover: hovLink, selected: selLink }); + this.stage.classList.toggle("calib-hover-joint", !!(this.hoveredJoint && !this.dragging)); + } + + updateHudValue( + jointName: string, + value: string | number, + { + live = false, + syncInput = true, + }: { live?: boolean; syncInput?: boolean } = {}, + ): void { + const tag = this._tags.get(jointName); + if (!tag) return; + const x = parseFloat(String(value)); + if (!Number.isFinite(x)) return; + const meta = this.jointMeta[jointName]; + if (syncInput) { + tag.input.value = formatCalibrationAngle(x, this.angleUnit, live ? 4 : 3); + } + if (meta) { + const span = meta.upper - meta.lower; + const t = span > 1e-9 ? (x - meta.lower) / span : 0.5; + const pct = `${Math.min(100, Math.max(0, t * 100)).toFixed(1)}%`; + tag.thumb.style.left = pct; + tag.fill.style.width = pct; + const atLo = Math.abs(x - meta.lower) < 0.008; + const atHi = Math.abs(x - meta.upper) < 0.008; + tag.el.classList.toggle("at-limit-lo", atLo); + tag.el.classList.toggle("at-limit-hi", atHi); + } + if (jointName === this.selected) this._updateLimitGizmo(); + } + + updateJointWorld(jointWorld: Record | null | undefined): void { + this.jointWorld = jointWorld || {}; + this._positionTags(); + if (this.selected) this._updateLimitGizmo(); + } + + private _perpRef(axis: THREE.Vector3, pivot: THREE.Vector3): THREE.Vector3 { + const camDir = camera.position.clone().sub(pivot).normalize(); + _arcRef.crossVectors(axis, camDir); + if (_arcRef.lengthSq() < 1e-8) _arcRef.crossVectors(axis, new THREE.Vector3(0, 1, 0)); + return _arcRef.normalize(); + } + + private _updateLimitGizmo(): void { + if (!this._limitGroup || !this.selected) { + if (this._limitGroup) this._limitGroup.group.visible = false; + return; + } + const joint = this.selected; + const meta = this.jointMeta[joint]; + const jw = this.jointWorld[joint]; + if (!meta || !jw?.pivot || !jw?.axis || meta.type === "prismatic") { + this._limitGroup.group.visible = false; + return; + } + const q = this.context.getQ()[joint] ?? 0; + const pivot = hhtoolsToWorldVec3(jw.pivot[0], jw.pivot[1], jw.pivot[2], new THREE.Vector3()); + const axis = hhtoolsToWorldVec3(jw.axis[0], jw.axis[1], jw.axis[2], _hhtoolsAxis).normalize(); + const ref = this._perpRef(axis, pivot); + const R = 0.11; + + const steps = 36; + const arcPts: THREE.Vector3[] = []; + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const ang = meta.lower + (meta.upper - meta.lower) * t; + arcPts.push(arcPointWorld(pivot, axis, ref, ang, R)); + } + this._limitGroup.arc.geometry.setFromPoints(arcPts); + + const loP = arcPointWorld(pivot, axis, ref, meta.lower, R); + const hiP = arcPointWorld(pivot, axis, ref, meta.upper, R); + const curP = arcPointWorld(pivot, axis, ref, q, R); + this._limitGroup.loTick.position.copy(loP); + this._limitGroup.hiTick.position.copy(hiP); + this._limitGroup.curTick.position.copy(curP); + this._limitGroup.needle.geometry.setFromPoints([pivot, curP]); + + this._limitGroup.group.visible = true; + } + + private _clampHudCard( + sx: number, + sy: number, + w: number, + h: number, + cardW: number, + cardH: number, + pad: number, + ): Point2D { + return { + x: Math.min(w - pad - cardW * 0.5, Math.max(pad + cardW * 0.5, sx)), + y: Math.min(h - pad, Math.max(pad + cardH, sy)), + }; + } + + private _projectToHud( + worldPoint: THREE.Vector3, + w: number, + h: number, + ox: number, + oy: number, + out = new THREE.Vector3(), + ): Point2D & { inFront: boolean } { + out.copy(worldPoint).project(camera); + return { + x: (out.x * 0.5 + 0.5) * w + ox, + y: (-out.y * 0.5 + 0.5) * h + oy, + inFront: out.z >= -1 && out.z <= 1, + }; + } + + _positionTags(): void { + if (!this.active || this._hudCardDrag) return; + const layout = this._hudLayout(); + const { ox, oy, w, h } = layout; + const _proj = new THREE.Vector3(); + for (const [name, { el }] of this._tags) { + if (!this.selected || name !== this.selected) { + el.classList.remove("visible", "screen-docked", "screen-pick", "user-pinned", "is-dragging"); + continue; + } + const jw = this.jointWorld[name]; + if (!jw?.pivot) continue; + + if (this._hudPinned) { + this._applyHudPin(el, this._hudPinned.x, this._hudPinned.y, layout); + continue; + } + + let sx = w * 0.72 + ox; + let sy = h * 0.38 + oy; + let mode = "screen-docked"; + + const anchor = this._pickAnchor; + if (anchor) { + const hit = this._projectToHud(anchor, w, h, ox, oy, _proj); + if (hit.inFront) { + sx = hit.x; + sy = hit.y - 18; + mode = "screen-pick"; + } + } else { + const pivot = hhtoolsToWorldVec3(jw.pivot[0], jw.pivot[1], jw.pivot[2], _proj); + const hit = this._projectToHud(pivot, w, h, ox, oy, _proj); + if (hit.inFront) { + sx = hit.x; + sy = hit.y - 18; + mode = "anchored"; + } + } + + const clamped = this._clampHudCard(sx, sy, w, h, layout.cardW, layout.cardH, layout.pad); + el.classList.remove("user-pinned"); + el.classList.toggle("screen-docked", mode === "screen-docked"); + el.classList.toggle("screen-pick", mode === "screen-pick"); + el.style.left = `${clamped.x}px`; + el.style.top = `${clamped.y}px`; + el.classList.add("visible"); + } + } + + private _pointerNdc(clientX: number, clientY: number): void { + const rect = this.canvas.getBoundingClientRect(); + this.pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1; + this.pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1; + } + + private _pickMeshes(clientX: number, clientY: number): THREE.Intersection[] { + this._pointerNdc(clientX, clientY); + this.raycaster.setFromCamera(this.pointer, camera); + const meshes: THREE.Object3D[] = []; + this._ctx?.robotView.group.traverse((node) => { + const candidate = node as THREE.Mesh; + if (candidate.isMesh && candidate.visible) meshes.push(candidate); + }); + return this.raycaster.intersectObjects(meshes, false); + } + + private _pickLink(clientX: number, clientY: number): string | null { + const hits = this._pickMeshes(clientX, clientY); + if (!hits.length) return null; + return this._ctx?.robotView._linkForNode(hits[0].object) ?? null; + } + + private _jointForLink(link: string | null): string | null { + if (!link) return null; + return this.linkToJoint[link] || null; + } + + private _updateHover(clientX: number, clientY: number): void { + const link = this._pickLink(clientX, clientY); + const joint = this._jointForLink(link); + this.hoveredLink = link; + this.hoveredJoint = joint; + this._syncHighlights(); + if (joint && joint !== this.selected) { + const hint = document.getElementById("calib-hover-hint"); + if (hint) { + hint.textContent = joint; + hint.classList.add("show"); + } + } else { + document.getElementById("calib-hover-hint")?.classList.remove("show"); + } + } + + private _pointerDown(e: PointerEvent): void { + if (!this.active || e.button !== 0) return; + if (e.target instanceof Element && e.target.closest(".calib-hud-card")) return; + const hits = this._pickMeshes(e.clientX, e.clientY); + const joint = this._jointForLink( + hits.length ? this._ctx?.robotView._linkForNode(hits[0].object) ?? null : null, + ); + if (!joint) { + this.selected = null; + for (const { el } of this._tags.values()) el.classList.remove("visible"); + for (const rowRec of Object.values(this.context.getSliderRows())) { + rowRec.row?.classList.remove("selected"); + } + this._updateLimitGizmo(); + this._syncHighlights(); + orbit.enabled = true; + return; + } + e.preventDefault(); + this._pickScreen = { x: e.clientX, y: e.clientY }; + this._pickAnchor = hits[0].point.clone(); + this._hudPinned = null; + this.setSelected(joint, { scrollPanel: true }); + const meta = this.jointMeta[joint]; + if (!meta || meta.type === "prismatic") { + orbit.enabled = false; + return; + } + this.dragging = true; + this._dragRef = null; + this._dragStartQ = this.context.getQ()[joint] ?? 0; + this.stage.classList.add("calib-dragging"); + orbit.enabled = false; + try { this.canvas.setPointerCapture(e.pointerId); } catch { /* ignore */ } + } + + private _pointerMove(e: PointerEvent): void { + if (!this.active) return; + if (this.dragging && this.selected) { + this._applyDrag(e.clientX, e.clientY); + } else { + this._updateHover(e.clientX, e.clientY); + } + this._positionTags(); + } + + private _pointerUp(): void { + if (!this.dragging) return; + this.dragging = false; + this._dragRef = null; + this.stage.classList.remove("calib-dragging"); + orbit.enabled = true; + this.context.previewFk({ flush: true }); + } + + private _applyDrag(clientX: number, clientY: number): void { + const joint = this.selected; + if (!joint || !this._ctx) return; + const jw = this.jointWorld[joint]; + const meta = this.jointMeta[joint]; + if (!jw?.pivot || !jw?.axis || !meta) return; + + const pivot = hhtoolsToWorldVec3(jw.pivot[0], jw.pivot[1], jw.pivot[2], new THREE.Vector3()); + const axis = hhtoolsToWorldVec3(jw.axis[0], jw.axis[1], jw.axis[2], _hhtoolsAxis).normalize(); + + this._pointerNdc(clientX, clientY); + this.raycaster.setFromCamera(this.pointer, camera); + _dragPlane.setFromNormalAndCoplanarPoint(axis, pivot); + if (!this.raycaster.ray.intersectPlane(_dragPlane, _projScratch)) return; + + const vec = _projScratch.clone().sub(pivot); + const len = vec.length(); + if (len < 1e-6) return; + vec.divideScalar(len); + + if (!this._dragRef) { + this._dragRef = vec.clone(); + return; + } + + const cross = new THREE.Vector3().crossVectors(this._dragRef, vec); + const sinA = axis.dot(cross); + const cosA = this._dragRef.dot(vec); + const delta = Math.atan2(sinA, cosA); + const newQ = Math.min(meta.upper, Math.max(meta.lower, this._dragStartQ + delta)); + this._ctx.jointChange(joint, newQ, { from: "drag", live: true }); + } +} + +const calibManip = new CalibManipulator({ + canvasEl: document.getElementById("three-canvas"), + hudEl: document.getElementById("calib-hud"), + stageEl: document.getElementById("stage"), +}); + +// ================================================================= RETARGET / CALIBRATION +function setCalChip(text: unknown, cls = ""): void { + renderStatusChip(document.getElementById("rt-cal"), text, cls); +} + +function _snapshotVis(): ViewVisibilitySnapshot { + return { + skel: skel.group.visible, + body: bodyIsVisible(), + scaled: scaledSkel.group.visible, + scaledEnv: scaledEnv.group.visible, + env: envView.group.visible, + robot: robot.group.visible, + playing: player.playing, + t: player.t, + playbar: playbarVisible, + }; +} + +function _setPlaybarVisible(on: boolean): void { + playbarVisible = Boolean(on); + publishPlaybackState(); +} + +function _setCalibViewTogglesDisabled(disabled: boolean): void { + for (const id of ["tg-skeleton", "tg-mesh", "tg-env", "tg-scaled", "tg-scaled-env"]) { + const btn = document.getElementById(id) as HTMLButtonElement | null; + if (btn) btn.disabled = disabled; + } +} + +function _restoreViewToggleButtons(): void { + const skBtn = document.getElementById("tg-skeleton"); + const meshBtn = document.getElementById("tg-mesh"); + if (skBtn) skBtn.disabled = false; + if (meshBtn) meshBtn.disabled = false; + syncEnvToggleButton(); + const scaledReady = !!(state.motion && state.robot && state.calibration); + const ss = document.getElementById("tg-scaled"); + const se = document.getElementById("tg-scaled-env"); + if (ss) ss.disabled = !scaledReady; + if (se) se.disabled = !scaledReady; +} + +function updateCalibBanner(_reference: string): void { + const el = document.getElementById("calib-banner"); + if (!el) return; + const message = document.createElement("span"); + message.append( + document.createTextNode(runtimeText( + "Calibration mode · Align the grey robot to the ", + "标定模式 · 请将灰色机器人对齐到", + )), + textElement("b", "", runtimeText("blue reference skeleton", "蓝色参考骨架")), + document.createTextNode(runtimeText( + ". Drag joints or use the right-side sliders, then save.", + " · 点击关节拖动或右栏滑块调整,完成后保存", + )), + ); + el.replaceChildren(textElement("span", "dot", ""), message); +} + +function updateR2rCalibBanner(): void { + const el = document.getElementById("calib-banner"); + if (!el) return; + const src = r2r.sourcePayload?.display_name || r2r.sourceName + || runtimeText("source robot", "源机器人"); + const tgt = r2r.targetPayload?.display_name || r2r.targetName + || runtimeText("target robot", "目标机器人"); + const message = document.createElement("span"); + message.append( + document.createTextNode(runtimeText("R2R calibration · Align ", "R2R 标定 · 将")), + textElement("b", "", tgt), + document.createTextNode(runtimeText(" to the ", "对齐到")), + textElement("b", "", runtimeText(`blue ${src} reference pose`, `蓝色 ${src} 参考姿态`)), + document.createTextNode(runtimeText( + ". Drag joints or use the right-side sliders, then save.", + " · 点击关节拖动或右侧滑块调整,完成后保存", + )), + ); + el.replaceChildren(textElement("span", "dot", ""), message); +} + +function _applyCalibSceneLayout(): void { + state.robotTrajectory = null; + robot.trajectory = null; + clearResultDiagnostics("h2r"); + scaledSkel.clear(); + scaledEnv.clear(); + setViewVisible(skel, "tg-skeleton", false); + setBodyVisible(false); + setViewVisible(envView, "tg-env", false); + setViewVisible(scaledSkel, "tg-scaled", false); + setViewVisible(scaledEnv, "tg-scaled-env", false); + setViewVisible(robot, "tg-robot", true); + robot.applyStatic(); + refSkel.group.visible = true; + player.setPlaying(false); + _setPlaybarVisible(false); + _setCalibViewTogglesDisabled(true); +} + +function _restoreVis(snap: ViewVisibilitySnapshot | null): void { + if (!snap) return; + refSkel.clear(); + refSkel.group.visible = false; + setViewVisible(skel, "tg-skeleton", snap.skel); + setBodyVisible(snap.body); + setViewVisible(envView, "tg-env", snap.env); + setViewVisible(scaledSkel, "tg-scaled", snap.scaled); + setViewVisible(scaledEnv, "tg-scaled-env", snap.scaledEnv); + setViewVisible(robot, "tg-robot", snap.robot); + _setPlaybarVisible(snap.playbar); + _restoreViewToggleButtons(); + player.t = snap.t; + player.setPlaying(snap.playing); + player.refreshFrame(); +} + +async function enterCalibrationMode( + initialQ: Record | null = null, +): Promise { + if (!state.robot || !state.reference) return; + const calCard = document.getElementById("calib-card"); + calCard.style.display = "block"; + document.getElementById("retarget-btn").disabled = true; + setCalChip(runtimeText("Calibrating…", "标定中…"), "warn"); + + if (!state.calibrationMode) { + state.calibRestore = _snapshotVis(); + } + state.calibrationMode = true; + state.calibNeedsCameraFocus = true; + state.calibOrbitSaved = { + minDistance: orbit.minDistance, + maxDistance: orbit.maxDistance, + zoomSpeed: orbit.zoomSpeed, + }; + orbit.zoomSpeed = 0.022; + applyCalibOrbitLimits(); + updateCalibBanner(state.reference); + document.getElementById("calib-banner")?.classList.remove("hidden"); + _applyCalibSceneLayout(); + publishH2rWorkflowState(); + toast(runtimeText( + "Calibration mode started. Align the robot to the blue reference skeleton.", + "已进入标定模式:请对齐蓝色参考骨架", + )); + if (player.active) player.seek(0); + + let session: import("./types").CalibrationSession; + try { + session = await API.post("/api/calibration/session", { + robot: state.robot.name, + reference: state.reference, + motion_token: state.motion?.token || null, + }); + } catch (e) { + state.calibrationMode = false; + state.calibNeedsCameraFocus = false; + if (state.calibOrbitSaved) { + orbit.minDistance = state.calibOrbitSaved.minDistance; + orbit.maxDistance = state.calibOrbitSaved.maxDistance; + orbit.zoomSpeed = state.calibOrbitSaved.zoomSpeed ?? orbit.zoomSpeed; + state.calibOrbitSaved = null; + } + document.getElementById("calib-banner")?.classList.add("hidden"); + const snap = state.calibRestore; + state.calibRestore = null; + _restoreVis(snap); + publishH2rWorkflowState(); + toast(errorMessage(e), true); + return; + } + + state.calibLimits = session.joint_limits || []; + robot.groundOffset = session.ground_offset_z ?? robot.groundOffset; + if (!session.reference) throw new Error(runtimeText( + "Calibration session did not include a reference pose", + "标定会话未返回参考姿态", + )); + refSkel.load(session.reference); + refSkel.configureMappings(state.robot.ik_map ?? {}); + if (session.reference_name) updateCalibBanner(session.reference_name); + _applyCalibSceneLayout(); + + const q = initialQ && typeof initialQ === "object" + ? initialQ + : (session.joint_q || {}); + state.calibHasSaved = !!session.has_saved_calibration; + state.calibBaselineQ = state.calibHasSaved ? { ...q } : null; + state.calibDraftQ = { ...q }; + calibrationEditorUi.h2r.comparison = "current"; + updateCalibRestoreButton(); + calibManip.start(state.calibLimits); + await buildCalibSliders(q, state.calibLimits); + applyCalibrationVisualization("h2r"); + updateH2rCalibrationValidation(); + publishH2rWorkflowState(); + calCard.scrollIntoView({ behavior: "smooth", block: "nearest" }); +} + +function updateCalibRestoreButton(): void { + const btn = document.getElementById("calib-restore"); + if (!btn) return; + btn.disabled = !state.calibHasSaved; + btn.title = state.calibHasSaved + ? runtimeText("Restore the last saved calibration", "恢复到上次保存的标定值") + : runtimeText( + "No saved calibration yet; save one before resetting", + "尚无已保存标定(保存后可重置)", + ); +} + +async function exitCalibrationMode(): Promise { + state.calibrationMode = false; + state.calibNeedsCameraFocus = false; + if (state.calibOrbitSaved) { + orbit.minDistance = state.calibOrbitSaved.minDistance; + orbit.maxDistance = state.calibOrbitSaved.maxDistance; + orbit.zoomSpeed = state.calibOrbitSaved.zoomSpeed ?? orbit.zoomSpeed; + state.calibOrbitSaved = null; + } + calibManip.stop(); + robot.setOpacity(1); + state.calibSliderRows = {}; + document.getElementById("calib-banner")?.classList.add("hidden"); + state.calibLimits = null; + state.calibBaselineQ = null; + state.calibDraftQ = null; + state.calibHasSaved = false; + calibrationEditorUi.h2r.comparison = "current"; + const snap = state.calibRestore; + state.calibRestore = null; + _restoreVis(snap); + if (robot.trajectory) { + robot.setFrame(0); + } else { + robot.applyStatic(); + } + publishH2rWorkflowState(); + emitCalibrationEditorState("h2r"); +} + +function setCalibJointValue( + jointName: string, + value: string | number, + { from, live = false }: CalibrationChangeOptions, +): void { + const limByName: Record = {}; + for (const L of state.calibLimits || []) limByName[L.name] = L; + const lim = limByName[jointName]; + let lo = lim?.lower != null ? lim.lower : -Math.PI; + let hi = lim?.upper != null ? lim.upper : Math.PI; + if (hi <= lo) { lo = -Math.PI; hi = Math.PI; } + let x = parseFloat(String(value)); + if (!Number.isFinite(x)) return; + if (from === "number" || from === "hud-input") { + x = angleFromDisplay(x, calibrationEditorUi.h2r.unit); + } + x = Math.min(hi, Math.max(lo, x)); + state.calibQ[jointName] = x; + + const row = state.calibSliderRows[jointName]; + const prec = live ? 4 : 3; + if (row) { + if (from === "slider") { + row.range.value = String(x); + row.num.value = formatCalibrationAngle(x, calibrationEditorUi.h2r.unit, prec); + } else if (from === "number") { + row.range.value = String(x); + if (!live) row.num.value = formatCalibrationAngle(x, calibrationEditorUi.h2r.unit, prec); + } else if (from !== "hud-input") { + row.range.value = String(x); + row.num.value = formatCalibrationAngle(x, calibrationEditorUi.h2r.unit, prec); + } + const span = hi - lo; + row.row.classList.toggle("near-limit", span > 0 && (x - lo < span * 0.03 || hi - x < span * 0.03)); + } + if (from === "hud-input") { + calibManip.updateHudValue(jointName, x, { live, syncInput: false }); + } else { + calibManip.updateHudValue(jointName, x, { live }); + } + if (from === "slider" || from === "number") calibManip.setSelected(jointName); + markCalibrationEdited("h2r"); + updateH2rCalibrationValidation(); + previewCalibPose({ live }); +} + +async function buildCalibSliders( + initialQ: Record, + limitsList: RobotJointLimit[] | null, +): Promise { + const box = document.getElementById("calib-sliders"); + box.replaceChildren(); + state.calibQ = {}; + state.calibSliderRows = {}; + if (!state.robot) return; + + const limByName: Record = {}; + for (const L of limitsList || []) limByName[L.name] = L; + + const q = initialQ; + const joints = (limitsList || []).map((L) => L.name) + .filter(Boolean) + .concat((state.robot.actuated_joints ?? []).filter((joint) => !limByName[joint])); + + const seen = new Set(); + for (const j of joints) { + if (seen.has(j)) continue; + seen.add(j); + const lim = limByName[j]; + let lo = lim?.lower != null ? lim.lower : -Math.PI; + let hi = lim?.upper != null ? lim.upper : Math.PI; + if (hi <= lo) { lo = -Math.PI; hi = Math.PI; } + let v = q[j] != null ? Number(q[j]) : 0; + v = Math.min(hi, Math.max(lo, v)); + state.calibQ[j] = v; + + const row = document.createElement("div"); + row.className = "slider-row"; + const region = classifyCalibrationJoint(j); + row.dataset.region = region; + const label = textElement("label", "", j); + label.title = j; + const range = document.createElement("input"); + range.type = "range"; + range.min = String(lo); + range.max = String(hi); + range.step = "0.001"; + range.value = String(v); + const num = document.createElement("input"); + num.type = "number"; + num.className = "calib-num"; + num.min = String(angleForDisplay(lo, calibrationEditorUi.h2r.unit)); + num.max = String(angleForDisplay(hi, calibrationEditorUi.h2r.unit)); + num.step = calibrationEditorUi.h2r.unit === "deg" ? "0.1" : "0.001"; + num.value = formatCalibrationAngle(v, calibrationEditorUi.h2r.unit); + row.append(label, range, num); + + state.calibSliderRows[j] = { row, range, num, lo, hi, region }; + const span = hi - lo; + row.classList.toggle("near-limit", span > 0 && (v - lo < span * 0.03 || hi - v < span * 0.03)); + calibManip.updateHudValue(j, v); + + range.oninput = () => setCalibJointValue(j, range.value, { from: "slider", live: true }); + num.oninput = () => setCalibJointValue(j, num.value, { from: "number", live: true }); + num.onchange = () => setCalibJointValue(j, num.value, { from: "number" }); + num.onkeydown = (ev: KeyboardEvent) => { + if (ev.key === "Enter") { setCalibJointValue(j, num.value, { from: "number" }); num.blur(); } + }; + row.onclick = () => { + calibManip._pickScreen = null; + calibManip._pickAnchor = null; + calibManip._hudPinned = null; + calibManip.setSelected(j); + }; + box.appendChild(row); + } + if (calibrationEditorUi.h2r.comparison === "current") state.calibDraftQ = { ...state.calibQ }; + syncCalibrationNumberInputs("h2r"); + applyCalibrationRowFilter("h2r"); + updateH2rCalibrationValidation(); + previewCalibPose(); +} + +// Coalesce rapid slider/pointer edits to one FK request per animation frame. If +// a request is already in flight, retain one follow-up that reads the latest q. +let calibFkRaf = 0; +let calibFkInFlight = false; +let calibFkQueued = false; + +function previewCalibPose( + { live = false, flush = false }: CalibrationPreviewOptions = {}, +): void { + if (!state.robot || !state.calibrationMode) return; + if (flush) { + if (calibFkRaf) cancelAnimationFrame(calibFkRaf); + calibFkRaf = 0; + _runCalibFk(); + return; + } + if (calibFkRaf) return; + calibFkRaf = requestAnimationFrame(() => { + calibFkRaf = 0; + _runCalibFk(); + }); +} + +async function _runCalibFk(): Promise { + const activeRobot = state.robot; + if (!activeRobot || !state.calibrationMode) return; + if (calibFkInFlight) { + calibFkQueued = true; + return; + } + calibFkInFlight = true; + calibFkQueued = false; + try { + const data = await API.post("/api/robot/fk_preview", { + robot: activeRobot.name, + joint_q: state.calibQ, + }); + robot.applyCalibPose(data.link_transforms, data.ground_offset_z); + refSkel.updateOverlay(robot); + if (calibManip.active) { + calibManip.updateJointWorld(data.joint_world); + } + updateH2rCalibrationValidation(); + if (state.calibrationMode && state.calibNeedsCameraFocus) { + state.calibNeedsCameraFocus = false; + applyCalibOrbitLimits({ snapCamera: true }); + focusRobotView({ resetOffset: true }); + } + } catch (e) { + console.warn("calib FK preview", errorMessage(e)); + } finally { + calibFkInFlight = false; + if (calibFkQueued) previewCalibPose(); + } +} + +/** + * Reconcile the current H2R robot/reference pair with saved calibration. This + * is orchestration, not a pure render: a missing calibration may open the editor. + */ +async function refreshRetargetPanel(): Promise { + document.getElementById("rt-motion").textContent = state.motion + ? state.motion.name + : runtimeText("Not loaded", "未加载"); + document.getElementById("rt-robot").textContent = state.robot + ? state.robot.display_name + : runtimeText("Not loaded", "未加载"); + syncRefSelect(); + if (state.calibrationMode) { + publishH2rWorkflowState(); + return; + } + const calCard = document.getElementById("calib-card"); + const btn = document.getElementById("retarget-btn"); + const recal = document.getElementById("recalib-btn"); + recal.disabled = !(state.robot && state.reference); + if (!state.robot || !state.reference) { + setCalChip("—", ""); + calCard.style.display = "none"; + btn.disabled = true; + publishH2rWorkflowState(); + return; + } + try { + const st = await API.get( + `/api/calibration/status?robot=${encodeURIComponent(state.robot.name)}&reference=${encodeURIComponent(state.reference)}` + ); + state.calibration = st.calibrated; + if (st.calibrated) { + setCalChip( + st.bundled && !st.path + ? runtimeText("Built-in scale parameters", "内置缩放参数") + : runtimeText("Calibrated", "已标定"), + "ok", + ); + calCard.style.display = "none"; + btn.disabled = !state.motion; + if (state.motion) await refreshScaledPreview(); + } else { + setCalChip(runtimeText( + "Not calibrated — calibration required", + "未标定 — 请先标定", + ), "warn"); + btn.disabled = true; + if (state.motion) { + await enterCalibrationMode(st.joint_q || null); + } else { + calCard.style.display = "none"; + } + } + } catch (e) { + setCalChip(runtimeText("Not calibrated", "未标定"), "warn"); + btn.disabled = true; + if (state.motion) { + await enterCalibrationMode(null); + } else { + calCard.style.display = "none"; + } + } + publishH2rWorkflowState(); +} + +document.getElementById("rt-ref-select")?.addEventListener("change", (ev) => { + const val = (ev.currentTarget as HTMLSelectElement).value; + if (!val) return; + onReferenceChange(val); +}); + +document.getElementById("recalib-btn").onclick = async () => { + if (!state.robot || !state.reference) return; + let jq: Record | null = null; + try { + const st = await API.get( + `/api/calibration/status?robot=${encodeURIComponent(state.robot.name)}&reference=${encodeURIComponent(state.reference)}` + ); + jq = st.joint_q || null; + } catch { /* session seeds from yaml */ } + await enterCalibrationMode(jq); +}; + +document.getElementById("calib-zero").onclick = async () => { + await applyCalibrationComparison("h2r", "zero"); + toast(runtimeText("Reset to the URDF zero pose", "已归零(URDF 零位)")); +}; + +document.getElementById("calib-restore").onclick = async () => { + if (!state.calibHasSaved || !state.calibBaselineQ) { + toast(runtimeText( + "There is no saved calibration to restore", + "尚无已保存标定可恢复", + ), true); + return; + } + await applyCalibrationComparison("h2r", "saved"); + toast(runtimeText( + "Restored the last saved calibration", + "已恢复到上次保存的标定", + )); +}; + +document.getElementById("calib-cancel").onclick = async () => { + await exitCalibrationMode(); + document.getElementById("calib-card").style.display = "none"; + toast(runtimeText("Calibration cancelled", "已取消标定")); + refreshRetargetPanel(); +}; + +document.getElementById("calib-save").onclick = async () => { + if (!state.robot) return; + try { + const savedQ = { ...state.calibQ }; + const scope = `${state.robot.display_name} + ${referenceLabel(state.reference)}`; + const response = await API.post("/api/calibration/save", { + robot: state.robot.name, + reference: state.reference, + joint_q: savedQ, + motion_token: state.motion?.token || null, + }); + state.calibBaselineQ = { ...savedQ }; + state.calibHasSaved = true; + await exitCalibrationMode(); + document.getElementById("calib-card").style.display = "none"; + state.calibration = true; + // Robot still holds the last calibration FK pose until retarget supplies a + // trajectory; do not resume motion playback with the yellow overlay yet. + player.setPlaying(false); + robot.applyStatic(); + setViewVisible(scaledSkel, "tg-scaled", false); + setViewVisible(scaledEnv, "tg-scaled-env", false); + refreshRetargetPanel(); + renderCalibrationSaveSummary("calibration-save-summary", scope, response.path ?? null, savedQ); + updateH2rCalibrationValidation(); + publishH2rWorkflowState(); + void syncBatchRefHint(); + const changed = Object.values(savedQ).filter((value) => Math.abs(value) > 1e-4).length; + toast(runtimeText( + `Calibration saved: ${changed} non-zero joints. Run Retarget before playing the preview.`, + `标定已保存:${changed} 个非零关节 — 请点击 Retarget 后再播放预览`, + )); + } catch (e) { toast(errorMessage(e), true); } +}; + +type CompletedJob = Omit & { + status: "done"; + result: Result; +}; + +async function pollJob( + jobId: string, + onProgress?: (job: JobResponse) => void, +): Promise> { + while (true) { + const j = await API.get(`/api/job/${jobId}`); + if (onProgress) onProgress(j); + if (j.status === "done") { + if (!j.result) throw new Error(j.error || "job completed without a result"); + return { ...j, status: "done", result: j.result as Result }; + } + if (j.status === "error") throw new Error(j.error || "job failed"); + await new Promise((r) => setTimeout(r, 700)); + } +} + +function setRetargetProgress( + progressElement: HTMLElement, + bar: HTMLElement, + job: JobResponse, +): void { + const p = job.progress || 0; + const indet = job.status === "running" && p < 0.1; + progressElement.classList.toggle("indet", indet); + if (!indet) { + bar.style.width = `${Math.max(2, p * 100).toFixed(0)}%`; + } +} + +document.getElementById("retarget-btn").onclick = async () => { + if (!state.motion || !state.robot) return; + const retargetRobotName = state.robot.name; + const prog = document.getElementById("rt-progress"); + const bar = prog.querySelector(".bar"); + const status = document.getElementById("rt-status"); + if (!bar) throw new Error("Retarget progress bar is missing"); + prog.style.display = "block"; + prog.classList.add("indet"); + bar.style.width = "0%"; + const firstHint = !state.robot.ik_prewarmed; + renderSpinnerStatus( + status, + firstHint + ? runtimeText( + "Retargeting… The first run for a new robot is slower, and progress may pause briefly.", + "正在 retarget…(新机器人首次较慢,进度条可能短暂不动)", + ) + : runtimeText("Retargeting…", "正在 retarget…"), + ); + document.getElementById("retarget-btn").disabled = true; + h2rRunState = "running"; + clearResultDiagnostics("h2r"); + setRobotPanelLocked(true); + publishH2rWorkflowState(); + try { + const retargetFps = parseOptionalFps(document.getElementById("rt-retarget-fps")); + const body: { + robot: string; + motion_token: string; + reference: string | null; + backend: string; + foot_clamp_anti_penetration: boolean; + retarget_fps?: number; + } = { + robot: retargetRobotName, + motion_token: state.motion.token, + reference: state.reference, + backend: document.getElementById("rt-backend").value, + foot_clamp_anti_penetration: false, + }; + if (retargetFps) body.retarget_fps = retargetFps; + const { job_id } = await API.post("/api/retarget", body); + const j = await pollJob(job_id, (jp) => { + setRetargetProgress(prog, bar, jp); + const msg = jp.message || (firstHint + ? runtimeText( + "Compiling the first retarget for this robot. This may take a moment…", + "新机器人首次 retarget 编译中,请耐心等待…", + ) + : runtimeText("Retargeting…", "正在 retarget…")); + renderSpinnerStatus(status, msg); + }); + if (state.robot?.name !== retargetRobotName) { + prog.classList.remove("indet"); + status.textContent = ""; + h2rRunState = "failed"; + toast(runtimeText( + "Retarget completed, but the robot changed while it was running. The result was discarded; run Retarget again.", + "Retarget 已完成,但过程中机器人已变更,结果已丢弃。请重新执行 Retarget。", + ), true); + return; + } + prog.classList.remove("indet"); + bar.style.width = "100%"; + if (state.robot) state.robot.ik_prewarmed = true; + const srcFps = j.result.motion_source_fps ?? state.motion?.framerate; + const rtFps = j.result.retarget_fps ?? j.result.source_fps; + const effectiveRtFps = rtFps ?? 30; + status.textContent = runtimeText( + `Completed: ${j.result.num_frames} frames @ ${effectiveRtFps.toFixed(1)} fps` + + (srcFps && Math.abs(srcFps - effectiveRtFps) > 0.5 + ? ` (source motion ${srcFps.toFixed(1)} fps)` + : ""), + `完成:${j.result.num_frames} 帧 @ ${effectiveRtFps.toFixed(1)} fps` + + (srcFps && Math.abs(srcFps - effectiveRtFps) > 0.5 + ? `(动作原始 ${srcFps.toFixed(1)} fps)` + : ""), + ); + state.robotTrajectory = j.result.trajectory; + robot.setTrajectory(j.result.trajectory); + // Always restart the shared timeline at t=0. Previously we only called + // ``ready`` when inactive, so an in-progress source scrub kept ``t`` near + // the end — the first "play" of the retarget was already finishing, and + // the first loop wrap looked like a mysterious global jump. + player.ready(robot.clipDuration); + player.refreshFrame(); + document.getElementById("tg-robot").disabled = false; + if (j.result.scaled_preview) { + scaledSkel.load(j.result.scaled_preview); + document.getElementById("tg-scaled").disabled = false; + } else { + await refreshScaledPreview(); + } + if (j.result.scaled_scene) { + scaledEnv.load(j.result.scaled_scene, state.motion.token); + document.getElementById("tg-scaled-env").disabled = false; + setViewVisible(scaledEnv, "tg-scaled-env", true); + } + setViewVisible(skel, "tg-skeleton", true); + setBodyVisible(true); + setViewVisible(scaledSkel, "tg-scaled", true); + setViewVisible(robot, "tg-robot", true); + applyH2rComparisonPreset(comparisonPresets.h2r); + emitResultDiagnostics("h2r", j.result.diagnostics ?? { + schema_version: 1, + available: false, + reason: runtimeText( + "The current result did not return usable tracking/contact diagnostics.", + "当前结果未返回可用的 tracking/contact 诊断。", + ), + }); + player.setPlaying(true); + robot.group.getWorldPosition(_camFocus); + orbit.target.copy(_camFocus); + _orbitManualUntil = 0; + state.exportToken = j.result.export_token; + state.exportSrcFps = j.result.source_fps ?? null; + state.exportHasScene = Boolean(j.result.has_scene); + document.getElementById("rt-export-card").style.display = "block"; + const fpsInput = document.getElementById("rt-export-fps"); + fpsInput.value = ""; + const tStartEl = document.getElementById("rt-export-t-start"); + const tEndEl = document.getElementById("rt-export-t-end"); + if (tStartEl) tStartEl.value = ""; + if (tEndEl) tEndEl.value = ""; + const eff = j.result.retarget_fps ?? j.result.source_fps ?? 30; + fpsInput.placeholder = runtimeText( + `Blank = ${eff.toFixed(0)} fps (Retarget result)`, + `留空 = ${eff.toFixed(0)} fps(Retarget 结果)`, + ); + const clipSrc = j.result.motion_source_fps ?? state.motion?.framerate; + const exportHint = document.createDocumentFragment(); + exportHint.append( + document.createTextNode(runtimeText("Current cache: ", "当前缓存:")), + textElement("b", "", `${eff.toFixed(1)} fps`), + document.createTextNode(runtimeText( + " (Retarget solve frame rate)", + "(Retarget 求解帧率)", + )), + ); + if (clipSrc && Math.abs(clipSrc - eff) > 0.5) { + exportHint.append( + document.createTextNode(runtimeText("; source motion ", ";动作文件原始 ")), + textElement("b", "", `${clipSrc.toFixed(1)} fps`), + ); + } + exportHint.append( + document.createTextNode(runtimeText(". ", "。")), + textElement("b", "", runtimeText("Export FPS", "导出 FPS")), + document.createTextNode(runtimeText( + " only interpolates the robot trajectory; it does not solve it again.", + " 仅插值机器人轨迹,不重新求解。", + )), + ); + const bundleHint = document.getElementById("rt-export-bundle-hint"); + if (bundleHint) bundleHint.style.display = j.result.has_scene ? "block" : "none"; + if (j.result.has_scene) { + exportHint.append(document.createTextNode(runtimeText( + " Results with terrain or objects are packaged as ZIP (data file + OBJ).", + " 含地形/物体时将打包为 ZIP(数据文件 + OBJ)。", + ))); + } + document.getElementById("rt-export-srcfps").replaceChildren(exportHint); + h2rRunState = "completed"; + publishH2rWorkflowState(); + toast(runtimeText("Retarget complete; ready to export", "Retarget 完成,可导出")); + } catch (e) { + status.textContent = ""; + prog.classList.remove("indet"); + h2rRunState = "failed"; + toast(errorMessage(e), true); + } finally { + setRobotPanelLocked(false); + publishH2rWorkflowState(); + } +}; +function csvHeaderEnabled(elId: string): boolean { + const el = document.getElementById(elId) as HTMLInputElement | null; + return el ? el.checked : true; +} + +document.getElementById("rt-export-btn").onclick = async () => { + if (!state.exportToken) return; + const fps = parseFloat(document.getElementById("rt-export-fps").value); + const fmt = document.getElementById("rt-export-format")?.value || "csv"; + let url = `/api/export/${state.exportToken}?fmt=${encodeURIComponent(fmt)}`; + if (fps && fps > 0) url += `&fps=${fps}`; + if (!csvHeaderEnabled("rt-csv-header")) url += "&csv_header=0"; + url = appendExportTimeParams(url, "rt-export-t-start", "rt-export-t-end"); + const name = state.exportHasScene || fmt === "pkl" + ? `${state.motion?.name || "clip"}_export.zip` + : `${state.motion?.name || "clip"}.csv`; + try { + await triggerBrowserDownload(url, name); + toast(runtimeText( + "Download started (saved to the browser's default download directory)", + "已开始下载(保存到浏览器默认下载目录)", + )); + } catch (e) { toast(errorMessage(e), true); } +}; + +// ================================================================= BATCH +let basket: LibraryEntry[] = []; +let batchBasketQuery = ""; +let batchBasketCategory: "all" | MotionCategory = "all"; +let batchSelectedPaths = new Set(); +let batchMissingReferences = new Set(); +let batchCompatibilityPending = false; +let batchCompatibilityRevision = 0; +let batchRunning = false; +let lastBatchJobId: string | null = null; +let lastBatchDownloadName: string | null = null; +let lastBatchFailureCount = 0; +let lastBatchResult: BatchRetargetResult | null = null; + +function basketEntryKey(entry: LibraryEntry): string { + return entry.source_path || entry.token || [entry.folder_label, entry.stem].filter(Boolean).join("/"); +} + +function basketEntryTitle(entry: LibraryEntry): string { + return entry.stem || entry.sequence_id || entry.display_name || entry.label || entry.name + || runtimeText("Untitled motion", "未命名动作"); +} + +function basketEntryContext(entry: LibraryEntry): string { + const parts = [entry.folder_label, entry.dataset ? datasetLabel(entry.dataset) : ""].filter(Boolean); + return [...new Set(parts)].join(" · ") || runtimeText("Imported motion", "导入动作"); +} + +function visibleBasketEntries(): LibraryEntry[] { + const tokens = batchBasketQuery.toLowerCase().split(/\s+/).filter(Boolean); + return basket.filter((entry) => { + const category = normalizedMotionCategory(entry); + if (batchBasketCategory !== "all" && category !== batchBasketCategory) return false; + const haystack = [ + basketEntryTitle(entry), + basketEntryContext(entry), + category, + libraryCategoryLabel(category), + entryReference(entry, "smpl"), + ].join(" ").toLowerCase(); + return tokens.every((token) => haystack.includes(token)); + }); +} + +interface BatchReferenceGroup { + count: number; + datasets: Set; +} + +/** + * Check calibration once per reference format represented in the basket. The + * revision guard discards responses started for an older basket/robot choice. + */ +async function syncBatchRefHint(): Promise { + const revision = ++batchCompatibilityRevision; + const el = document.getElementById("batch-ref-hint"); + if (!el) return; + if (!basket.length) { + batchCompatibilityPending = false; + batchMissingReferences.clear(); + el.replaceChildren(textElement("p", "batch-compatibility-empty", runtimeText( + "Add inputs to check calibration compatibility.", + "添加输入动作后会在这里检查标定兼容性。", + ))); + updateBatchRunAvailability(); + return; + } + const groups = new Map(); + for (const e of basket) { + // The reference must be intrinsic to each entry. Previewing another motion + // changes state.reference, so using that mutable global would corrupt the batch. + const ref = entryReference(e, "smpl"); + if (!groups.has(ref)) groups.set(ref, { count: 0, datasets: new Set() }); + const g = groups.get(ref); + if (!g) continue; + g.count += 1; + g.datasets.add(e.dataset || "unknown"); + } + + batchCompatibilityPending = Boolean(state.robot?.name); + batchMissingReferences.clear(); + updateBatchRunAvailability(); + + const checks = await Promise.all([...groups].map(async ([ref, group]) => { + if (!state.robot?.name) return { ref, group, calibrated: null as boolean | null, unavailable: false }; + try { + const status = await API.get( + `/api/calibration/status?robot=${encodeURIComponent(state.robot.name)}` + + `&reference=${encodeURIComponent(ref)}`, + ); + return { ref, group, calibrated: Boolean(status.calibrated), unavailable: false }; + } catch { + return { ref, group, calibrated: false, unavailable: true }; + } + })); + if (revision !== batchCompatibilityRevision) return; + + const missing = new Set(); + const blocks = checks.map(({ ref, group, calibrated, unavailable }) => { + if (calibrated === false) missing.add(ref); + const block = document.createElement("div"); + block.className = `batch-compatibility-row${calibrated ? " is-ready" : calibrated === false ? " is-missing" : ""}`; + + const copy = document.createElement("div"); + copy.className = "batch-compatibility-copy"; + copy.append( + textElement("strong", "", referenceLabel(ref)), + textElement("small", "", runtimeText( + `${group.count} clips · ${[...group.datasets].map(datasetLabel).join(", ")}`, + `${group.count} 条 · ${[...group.datasets].map(datasetLabel).join("、")}`, + )), + ); + block.appendChild(copy); + const controls = document.createElement("div"); + controls.className = "batch-compatibility-controls"; + + if (calibrated === true) { + controls.append(textElement("span", "batch-compatibility-status ok", runtimeText("Ready", "已就绪"))); + } else if (calibrated === false) { + controls.append(textElement( + "span", + "batch-compatibility-status warn", + unavailable ? runtimeText("Check failed", "检查失败") : runtimeText("Calibration needed", "需要标定"), + )); + const action = textElement("button", "batch-compatibility-action", runtimeText("Calibrate", "去标定")); + action.type = "button"; + action.onclick = async () => { + switchInspectorPanel("h2r"); + await onReferenceChange(ref); + (document.getElementById("recalib-btn") as HTMLButtonElement | null)?.click(); + }; + controls.appendChild(action); + } else { + controls.append(textElement("span", "batch-compatibility-status", runtimeText( + "Select a robot", + "请选择机器人", + ))); + } + block.appendChild(controls); + return block; + }); + batchMissingReferences = missing; + batchCompatibilityPending = false; + el.replaceChildren(...blocks); + updateBatchRunAvailability(); +} + +function updateBatchSelectionState(visible = visibleBasketEntries()): void { + const visibleKeys = visible.map(basketEntryKey); + const selectedVisible = visibleKeys.filter((key) => batchSelectedPaths.has(key)).length; + const selectAll = document.getElementById("batch-select-all") as HTMLInputElement | null; + if (selectAll) { + selectAll.checked = Boolean(visibleKeys.length) && selectedVisible === visibleKeys.length; + selectAll.indeterminate = selectedVisible > 0 && selectedVisible < visibleKeys.length; + selectAll.disabled = batchRunning || !visibleKeys.length; + } + const selectedCount = document.getElementById("batch-selected-count"); + if (selectedCount) selectedCount.textContent = runtimeText( + `${batchSelectedPaths.size} selected`, + `已选择 ${batchSelectedPaths.size} 条`, + ); + const removeSelected = document.getElementById("batch-remove-selected") as HTMLButtonElement | null; + if (removeSelected) removeSelected.disabled = batchRunning || !batchSelectedPaths.size; +} + +function updateBatchSettingsNote(): void { + const backend = (document.getElementById("batch-backend") as HTMLSelectElement | null)?.value || "newton"; + const format = (document.getElementById("batch-format") as HTMLSelectElement | null)?.value || "pkl"; + const note = document.getElementById("batch-settings-note"); + const batchSizeField = document.getElementById("batch-size-field"); + const csvHeaderRow = document.getElementById("batch-csv-header-row"); + const hasSceneInputs = basket.some((entry) => normalizedMotionCategory(entry) !== "motion"); + + if (batchSizeField) batchSizeField.hidden = backend !== "newton"; + if (csvHeaderRow) csvHeaderRow.hidden = format !== "csv"; + if (!note) return; + const base = backend === "interaction_mesh" + ? runtimeText("Interaction-Mesh processes clips sequentially.", "Interaction-Mesh 会逐条处理动作。") + : runtimeText("Newton uses GPU chunks; leave batch size empty for automatic tuning.", "Newton 使用 GPU 分块;批大小留空可自动调节。") + const recommendation = hasSceneInputs && backend === "newton" + ? runtimeText(" Scene inputs are present; verify whether Interaction-Mesh is required.", " 清单中包含场景动作,请确认是否应使用 Interaction-Mesh。") + : ""; + note.textContent = base + recommendation; + note.classList.toggle("warn", Boolean(recommendation)); +} + +function updateBatchRunAvailability(): void { + const runButton = document.getElementById("batch-run") as HTMLButtonElement | null; + const reason = document.getElementById("batch-disabled-reason"); + const summary = document.getElementById("batch-run-summary"); + if (!runButton || !reason || !summary) return; + + const startInput = document.getElementById("batch-export-t-start") as HTMLInputElement | null; + const endInput = document.getElementById("batch-export-t-end") as HTMLInputElement | null; + const start = parseOptionalTime(startInput); + const end = parseOptionalTime(endInput); + const invalidStart = Boolean(startInput?.value) && start == null; + const invalidEnd = Boolean(endInput?.value) && end == null; + let disabledReason = ""; + if (batchRunning) disabledReason = runtimeText("A batch task is running.", "批量任务正在运行。") + else if (state.robotPanelLocked) disabledReason = runtimeText( + "Another retarget task is using the workspace.", + "另一个重定向任务正在占用工作区。", + ) + else if (state.calibrationMode) disabledReason = runtimeText( + "Finish or cancel the current calibration first.", + "请先保存或取消当前标定。", + ) + else if (!basket.length) disabledReason = runtimeText("Add at least one motion.", "请至少添加一条动作。") + else if (!state.robot) disabledReason = runtimeText("Select and load a target robot.", "请选择并加载目标机器人。") + else if ((document.getElementById("batch-robot-select") as HTMLSelectElement | null)?.value !== state.robot.name) { + disabledReason = runtimeText("Load the selected target robot.", "请加载当前选择的目标机器人。"); + } + else if (batchCompatibilityPending) disabledReason = runtimeText("Checking calibration compatibility…", "正在检查标定兼容性……") + else if (batchMissingReferences.size) disabledReason = runtimeText( + `Complete calibration for ${[...batchMissingReferences].map(referenceLabel).join(", ")}.`, + `请先完成 ${[...batchMissingReferences].map(referenceLabel).join("、")} 标定。`, + ) + else if (invalidStart || invalidEnd) disabledReason = runtimeText( + "Enter a valid non-negative time range.", + "请输入有效的非负时间范围。", + ) + else if (start != null && end != null && start > end) disabledReason = runtimeText( + "Start time cannot be later than end time.", + "起始时间不能晚于截止时间。", + ); + + runButton.disabled = Boolean(disabledReason); + reason.textContent = disabledReason; + reason.hidden = !disabledReason; + const target = state.robot?.display_name || runtimeText("no target", "未选择目标"); + const output = ((document.getElementById("batch-out") as HTMLInputElement | null)?.value || "batch_export") + .replace(/\.zip$/i, ""); + summary.textContent = basket.length + ? runtimeText( + `${basket.length} clips → ${target} → ${output}.zip`, + `${basket.length} 条动作 → ${target} → ${output}.zip`, + ) + : runtimeText("No inputs selected.", "尚未选择输入动作。"); +} + +function setBatchDraftLocked(locked: boolean): void { + batchRunning = locked; + for (const id of [ + "batch-library-open", "batch-pick-file", "batch-pick-folder", "batch-select-all", "batch-remove-selected", + "basket-clear", "batch-robot-select", "batch-robot-load", "batch-backend", "batch-format", + "batch-size", "batch-retarget-fps", "batch-export-fps", "batch-export-t-start", + "batch-export-t-end", "batch-csv-header", "batch-out", + ]) { + const control = document.getElementById(id) as HTMLButtonElement | HTMLInputElement | HTMLSelectElement | null; + if (control) control.disabled = locked; + } + document.getElementById("basket-drop")?.classList.toggle("is-locked", locked); + renderBasket({ refreshCompatibility: false }); +} + +function renderBasket({ refreshCompatibility = true }: { refreshCompatibility?: boolean } = {}): void { + const list = document.getElementById("basket-list"); + if (!list) return; + list.replaceChildren(); + const currentKeys = new Set(basket.map(basketEntryKey)); + batchSelectedPaths = new Set([...batchSelectedPaths].filter((key) => currentKeys.has(key))); + const visible = visibleBasketEntries(); + + if (!basket.length) { + const empty = document.createElement("div"); + empty.className = "batch-basket-empty"; + empty.append( + textElement("strong", "", runtimeText("No motions yet", "还没有动作")), + textElement("span", "", runtimeText( + "Add from the Library, import files, or drop a folder here.", + "可以从资源库添加、导入文件,或把文件夹拖到这里。", + )), + ); + list.appendChild(empty); + } else if (!visible.length) { + const empty = textElement("div", "batch-basket-empty", runtimeText( + "No inputs match the current search and filter.", + "没有符合当前搜索与筛选条件的动作。", + )); + list.appendChild(empty); + } + + for (const entry of visible) { + const key = basketEntryKey(entry); + const row = document.createElement("div"); + row.className = `batch-basket-row${batchSelectedPaths.has(key) ? " is-selected" : ""}`; + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = batchSelectedPaths.has(key); + checkbox.disabled = batchRunning; + checkbox.setAttribute("aria-label", runtimeText( + `Select ${basketEntryTitle(entry)}`, + `选择 ${basketEntryTitle(entry)}`, + )); + checkbox.onchange = () => { + if (checkbox.checked) batchSelectedPaths.add(key); + else batchSelectedPaths.delete(key); + row.classList.toggle("is-selected", checkbox.checked); + updateBatchSelectionState(visible); + }; + + const identity = document.createElement("div"); + identity.className = "batch-basket-copy"; + identity.append( + textElement("strong", "batch-basket-title", basketEntryTitle(entry)), + textElement("span", "batch-basket-context", basketEntryContext(entry)), + ); + const category = normalizedMotionCategory(entry); + const categoryBadge = textElement("span", "batch-category-tag", libraryCategoryLabel(category)); + categoryBadge.dataset.category = category; + const reference = textElement("span", "batch-basket-reference", referenceLabel(entryReference(entry, "smpl"))); + const removeButton = textElement("button", "batch-basket-remove", "×"); + removeButton.type = "button"; + removeButton.disabled = batchRunning; + removeButton.title = runtimeText("Remove from batch", "从批量清单移除"); + removeButton.setAttribute("aria-label", runtimeText( + `Remove ${basketEntryTitle(entry)} from batch`, + `从批量清单移除 ${basketEntryTitle(entry)}`, + )); + removeButton.onclick = () => { + basket = basket.filter((candidate) => basketEntryKey(candidate) !== key); + batchSelectedPaths.delete(key); + void syncBasket(); + }; + row.append(checkbox, identity, categoryBadge, reference, removeButton); + list.appendChild(row); + } + + const count = document.getElementById("basket-count"); + if (count) count.textContent = String(basket.length); + const inspectorCount = document.getElementById("batch-inspector-count"); + if (inspectorCount) inspectorCount.textContent = String(basket.length); + const badge = document.getElementById("basket-badge"); + if (badge) { + badge.textContent = String(basket.length); + badge.style.display = basket.length ? "inline-block" : "none"; + } + const clearButton = document.getElementById("basket-clear") as HTMLButtonElement | null; + if (clearButton) clearButton.disabled = batchRunning || !basket.length; + updateBatchSelectionState(visible); + updateBatchSettingsNote(); + updateBatchRunAvailability(); + if (refreshCompatibility) void syncBatchRefHint(); +} +async function syncBasket(): Promise { + renderBasket(); + window.dispatchEvent(new CustomEvent("hhtools:batch-basket-changed")); +} +function addToBasket( + entries: LibraryEntry[], + { silent = false }: { silent?: boolean } = {}, +): void { + if (batchRunning) { + if (!silent) toast(runtimeText( + "Wait for the current Batch task before changing its inputs.", + "当前 Batch 任务结束后才能修改输入清单。", + ), true); + return; + } + let added = 0; + for (const e of entries) { + if (!basket.find((x) => basketEntryKey(x) === basketEntryKey(e))) { + basket.push(e); + added++; + } + } + renderBasket(); + window.dispatchEvent(new CustomEvent("hhtools:batch-basket-changed")); + if (!silent) toast(runtimeText( + `${added} added${entries.length - added ? ` · ${entries.length - added} duplicates skipped` : ""}`, + `已添加 ${added} 条${entries.length - added ? ` · 跳过 ${entries.length - added} 条重复项` : ""}`, + )); +} + +window.addEventListener("hhtools:batch-filter", (event) => { + const detail = (event as CustomEvent<{ query?: unknown; category?: unknown }>).detail ?? {}; + batchBasketQuery = typeof detail.query === "string" ? detail.query : ""; + batchBasketCategory = detail.category === "motion" || detail.category === "object" || detail.category === "terrain" + ? detail.category + : "all"; + renderBasket({ refreshCompatibility: false }); +}); + +document.getElementById("batch-select-all")?.addEventListener("change", (event) => { + const checked = (event.currentTarget as HTMLInputElement).checked; + for (const entry of visibleBasketEntries()) { + const key = basketEntryKey(entry); + if (checked) batchSelectedPaths.add(key); + else batchSelectedPaths.delete(key); + } + renderBasket({ refreshCompatibility: false }); +}); +document.getElementById("batch-remove-selected")?.addEventListener("click", () => { + basket = basket.filter((entry) => !batchSelectedPaths.has(basketEntryKey(entry))); + batchSelectedPaths.clear(); + void syncBasket(); +}); +document.getElementById("basket-clear")?.addEventListener("click", () => { + basket = []; + batchSelectedPaths.clear(); + void syncBasket(); +}); +document.getElementById("batch-pick-file")?.addEventListener("click", async () => { + await ingestBasketFiles(await pickFiles(), "auto"); +}); +document.getElementById("batch-pick-folder")?.addEventListener("click", async () => { + await ingestBasketFiles(await pickFiles({ folder: true }), "auto"); +}); + +async function ingestBasketFiles(files: UploadFile[], profile = "auto"): Promise { + if (!files || !files.length) return; + showLoading(runtimeText( + `Uploading to the session cache… (${files.length} files)`, + `上传到会话缓存…(${files.length} 个文件)`, + )); + try { + const { job_id } = await uploadFilesXHR( + "/api/basket/upload", + files, + { profile }, + (frac, recv, total) => { + setLoadingProgress((frac ?? 0) * 0.35, runtimeText( + `Uploading ${fmtBytes(recv)} / ${fmtBytes(total)}`, + `上传 ${fmtBytes(recv)} / ${fmtBytes(total)}`, + )); + }, + ); + const payload = await waitMotionJob<{ entries: LibraryEntry[] }>(job_id, (frac, sub) => { + setLoadingProgress(0.35 + frac * 0.65, sub); + }, { uploadFrac: 0.35 }); + const entries = payload.entries || []; + if (!entries.length) { + toast(runtimeText( + "No retargetable clips were recognized.", + "未识别到可重定向的 clip。", + ), true); + return; + } + addToBasket(entries, { silent: true }); + toast(runtimeText( + `${entries.length} clips cached for this session.`, + `已缓存 ${entries.length} 个 clip(关闭 Web 后自动清除)`, + )); + } catch (e) { + toast(errorMessage(e), true); + } finally { + hideLoading(); + } +} + +setupDropzone(document.getElementById("basket-drop"), (files) => { + if (batchRunning) { + toast(runtimeText( + "Wait for the current Batch task before changing its inputs.", + "当前 Batch 任务结束后才能修改输入清单。", + ), true); + return; + } + return ingestBasketFiles(files, "auto"); +}); + +for (const id of [ + "batch-backend", "batch-format", "batch-size", "batch-retarget-fps", "batch-export-fps", + "batch-export-t-start", "batch-export-t-end", "batch-csv-header", "batch-out", +]) { + document.getElementById(id)?.addEventListener("input", () => { + updateBatchSettingsNote(); + updateBatchRunAvailability(); + }); + document.getElementById(id)?.addEventListener("change", () => { + updateBatchSettingsNote(); + updateBatchRunAvailability(); + }); +} + +const BATCH_STAGE_LABELS: Record = { + load: { en: "Load", zh: "加载" }, + retarget: { en: "Retarget", zh: "重定向" }, + export: { en: "Export", zh: "导出" }, +}; + +function renderBatchFailures(result: BatchRetargetResult | null): void { + const box = document.getElementById("batch-failures"); + if (!box) return; + const failures = result?.failures || []; + if (!failures.length) { + box.classList.add("hidden"); + box.replaceChildren(); + return; + } + box.classList.remove("hidden"); + const heading = textElement("h4", "", runtimeText( + `Failures (${failures.length})`, + `失败明细(${failures.length})`, + )); + const list = document.createElement("ul"); + list.className = "batch-fail-list"; + for (const failure of failures) { + const stageCopy = failure.stage ? BATCH_STAGE_LABELS[failure.stage] : undefined; + const stage = (stageCopy ? runtimeText(stageCopy.en, stageCopy.zh) : undefined) + || failure.stage + || runtimeText("Unknown stage", "未知阶段"); + const item = document.createElement("li"); + item.append( + textElement("b", "", failure.stem || runtimeText("Untitled clip", "未命名 clip")), + document.createTextNode(" "), + textElement("span", "tag", stage), + textElement("div", "reason", failure.reason || runtimeText("Unknown error", "未知错误")), + ); + if (failure.log_rel) { + const logLine = document.createElement("div"); + logLine.className = "sub"; + logLine.append( + document.createTextNode(runtimeText("Copied → ", "已复制 → ")), + textElement("code", "", failure.log_rel), + ); + item.append(logLine); + } else if (failure.stash_error) { + item.append(textElement("div", "sub warn", runtimeText( + `Unable to copy the source file: ${failure.stash_error}`, + `未能复制源文件:${failure.stash_error}`, + ))); + } + list.append(item); + } + const children: Node[] = [heading, list]; + const failureLog = result?.failure_log; + if (failureLog) { + const hint = document.createElement("p"); + hint.className = "hint"; + hint.append( + document.createTextNode(runtimeText("Failure data: ", "失败数据目录:")), + textElement("code", "", failureLog), + document.createElement("br"), + document.createTextNode(runtimeText( + "After fixing the inputs, drop this folder (or a child folder) into the list to retry. See ", + "修复后可将该文件夹(或其中子目录)拖入上方清单重试;也可打开 ", + )), + textElement("code", "", "失败说明.txt"), + document.createTextNode(" / "), + textElement("code", "", "failures.json"), + document.createTextNode(runtimeText(" for details.", " 查看详情。")), + ); + children.push(hint); + } + box.replaceChildren(...children); +} + +function renderBatchResultCard(result: BatchRetargetResult | null = lastBatchResult): void { + const card = document.getElementById("batch-result-card"); + if (!card || !result || !lastBatchJobId) return; + const successCount = result.written?.length ?? 0; + const failureCount = result.failures?.length ?? 0; + const title = document.getElementById("batch-result-title"); + const summary = document.getElementById("batch-result-summary"); + const downloadButton = document.getElementById("batch-result-download") as HTMLButtonElement | null; + const retryButton = document.getElementById("batch-result-retry") as HTMLButtonElement | null; + if (title) title.textContent = failureCount + ? runtimeText("Batch completed with failures", "批量任务完成,但有失败项") + : runtimeText("Batch complete", "批量任务完成"); + if (summary) summary.textContent = runtimeText( + `${successCount} succeeded${failureCount ? ` · ${failureCount} failed` : ""}`, + `${successCount} 条成功${failureCount ? ` · ${failureCount} 条失败` : ""}`, + ); + if (downloadButton) downloadButton.hidden = !result.download_name; + if (retryButton) retryButton.hidden = !failureCount; + card.classList.remove("hidden"); +} + +function setBatchProgress( + job: Pick, +): void { + const totalProg = document.getElementById("batch-progress-total"); + const clipProg = document.getElementById("batch-progress-clip"); + if (!totalProg || !clipProg) return; + const totalBar = totalProg.querySelector(".bar"); + const clipBar = clipProg.querySelector(".bar"); + if (!totalBar || !clipBar) return; + const totalP = job.progress || 0; + const clipP = job.clip_progress ?? 0; + const totalPercent = Math.max(0, Math.min(100, totalP * 100)); + const clipPercent = Math.max(0, Math.min(100, clipP * 100)); + const totalIndet = job.status === "running" && totalP < 0.01; + const clipIndet = job.status === "running" && clipP < 0.02 && totalP < 0.99; + totalProg.classList.toggle("indet", totalIndet); + clipProg.classList.toggle("indet", clipIndet); + totalProg.setAttribute("aria-valuenow", totalPercent.toFixed(0)); + clipProg.setAttribute("aria-valuenow", clipPercent.toFixed(0)); + totalProg.setAttribute("aria-valuetext", totalIndet + ? runtimeText("Starting", "正在启动") + : `${totalPercent.toFixed(0)}%`); + clipProg.setAttribute("aria-valuetext", clipIndet + ? runtimeText("Preparing current chunk", "正在准备当前批次") + : `${clipPercent.toFixed(0)}%`); + if (!totalIndet) { + totalBar.style.width = `${totalPercent.toFixed(0)}%`; + } else { + totalBar.style.width = "0%"; + } + if (!clipIndet) { + clipBar.style.width = `${clipPercent.toFixed(0)}%`; + } else { + clipBar.style.width = "0%"; + } +} + +document.getElementById("batch-run").onclick = async () => { + updateBatchRunAvailability(); + const runButton = document.getElementById("batch-run") as HTMLButtonElement; + if (!basket.length || !state.robot || runButton.disabled) return; + const batchRobotName = state.robot.name; + const progStack = document.getElementById("batch-progress-stack"); + const status = document.getElementById("batch-status"); + const failBox = document.getElementById("batch-failures"); + const resultCard = document.getElementById("batch-result-card"); + if (failBox) { + failBox.classList.add("hidden"); + failBox.replaceChildren(); + } + resultCard?.classList.add("hidden"); + lastBatchJobId = null; + lastBatchDownloadName = null; + lastBatchFailureCount = 0; + lastBatchResult = null; + progStack?.classList.remove("hidden"); + setBatchProgress({ status: "running", progress: 0, clip_progress: 0 }); + renderSpinnerStatus(status, runtimeText("Starting batch task…", "正在启动批量任务……")); + setBatchDraftLocked(true); + setRobotPanelLocked(true); + try { + const batchBody: { + robot: string; + reference: string; + backend: string; + out_dir: string; + format: string; + csv_header: boolean; + entries: LibraryEntry[]; + foot_clamp_anti_penetration: boolean; + batch_size?: number; + retarget_fps?: number; + export_fps?: number; + t_start?: number; + t_end?: number; + } = { + robot: batchRobotName, + // Each entry carries its own reference. This is only the stable fallback + // for older entries that have neither reference nor dataset metadata. + reference: "smpl", + backend: document.getElementById("batch-backend").value, + out_dir: document.getElementById("batch-out").value || "batch_export", + format: document.getElementById("batch-format").value, + csv_header: csvHeaderEnabled("batch-csv-header"), + entries: basket, + foot_clamp_anti_penetration: false, + }; + const batchSizeRaw = parseInt(document.getElementById("batch-size")?.value, 10); + if (Number.isFinite(batchSizeRaw) && batchSizeRaw >= 1) { + batchBody.batch_size = Math.min(256, batchSizeRaw); + } + const rtFps = parseOptionalFps(document.getElementById("batch-retarget-fps")); + const exFps = parseOptionalFps(document.getElementById("batch-export-fps")); + if (rtFps) batchBody.retarget_fps = rtFps; + if (exFps) batchBody.export_fps = exFps; + const t0 = parseOptionalTime(document.getElementById("batch-export-t-start")); + const t1 = parseOptionalTime(document.getElementById("batch-export-t-end")); + if (t0 != null) batchBody.t_start = t0; + if (t1 != null) batchBody.t_end = t1; + const { job_id } = await API.post("/api/batch/retarget", batchBody); + lastBatchJobId = job_id; + window.dispatchEvent(new CustomEvent("hhtools:job-history-command", { + detail: { command: "refresh" }, + })); + const j = await pollJob(job_id, (jp) => { + setBatchProgress(jp); + status.textContent = jp.message || ""; + }); + setBatchProgress({ status: "done", progress: 1, clip_progress: 1 }); + const r = j.result; + const modeNote = r.solver_mode ? ` · ${r.solver_mode}` : ""; + const partialNote = (r.failures?.length && r.written?.length) + ? runtimeText(" (ZIP contains successful items only)", "(ZIP 仅含成功项,失败见下方)") : ""; + const successCount = r.written?.length ?? 0; + const failureCount = r.failures?.length ?? 0; + lastBatchDownloadName = r.download_name || null; + lastBatchFailureCount = failureCount; + lastBatchResult = r; + status.textContent = runtimeText(`Complete: ${successCount} clips`, `完成:${successCount} 个 clip`) + + (failureCount ? runtimeText(`, ${failureCount} failed`, `,${failureCount} 个失败`) : "") + + partialNote + + modeNote + + (r.download_name ? runtimeText(` — downloading ${r.download_name}`, ` — 正在下载 ${r.download_name}`) : ""); + renderBatchFailures(r); + renderBatchResultCard(r); + if (r.download_name) { + try { + await triggerBrowserDownload(`/api/job/${job_id}/download`, r.download_name); + } catch (e) { toast(errorMessage(e), true); } + } + toast( + runtimeText( + `Batch complete: ${successCount} succeeded${failureCount ? `, ${failureCount} failed` : ""}`, + `批量完成:${successCount} 个${failureCount ? `,${failureCount} 失败(见下方明细)` : ""}`, + ), + Boolean(failureCount), + ); + window.dispatchEvent(new CustomEvent("hhtools:job-history-command", { + detail: { command: "refresh" }, + })); + } catch (e) { + status.textContent = runtimeText( + `Batch failed: ${errorMessage(e)}`, + `批量任务失败:${errorMessage(e)}`, + ); + renderBatchFailures(null); + toast(errorMessage(e), true); + } finally { + setBatchDraftLocked(false); + setRobotPanelLocked(false); + void syncBatchRefHint(); + } +}; + +document.getElementById("batch-result-download")?.addEventListener("click", async () => { + if (!lastBatchJobId || !lastBatchDownloadName) return; + try { + await triggerBrowserDownload(`/api/job/${lastBatchJobId}/download`, lastBatchDownloadName); + } catch (error) { + toast(errorMessage(error), true); + } +}); + +document.getElementById("batch-result-retry")?.addEventListener("click", async () => { + if (!lastBatchJobId || !lastBatchFailureCount) return; + try { + const started = await API.post("/api/jobs/replay", { + job_id: lastBatchJobId, + failed_only: true, + }); + toast(runtimeText( + `Created failed-item retry task ${started.job_id}`, + `已创建失败项重试任务 ${started.job_id}`, + )); + window.dispatchEvent(new CustomEvent("hhtools:job-history-command", { + detail: { command: "refresh" }, + })); + } catch (error) { + toast(errorMessage(error), true); + } +}); + +document.getElementById("batch-result-tasks")?.addEventListener("click", () => { + (document.querySelector(".job-drawer-summary") as HTMLButtonElement | null)?.click(); + window.dispatchEvent(new CustomEvent("hhtools:job-history-command", { + detail: { command: "refresh" }, + })); +}); + +// Wrap every cannot use ::after — wrap in .select-wrap for the chevron */ +.select-wrap { + position: relative; + display: block; + min-width: 0; +} +.row > .select-wrap { flex: 1; } +.select-wrap::after { + content: ""; + position: absolute; + right: 14px; + top: 50%; + margin-top: -2px; + border: 5px solid transparent; + border-top: 6px solid var(--ink-tertiary); + pointer-events: none; +} +.select-wrap:focus-within::after { + border-top-color: var(--accent); +} +select.search { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + padding-right: 36px; + cursor: pointer; + background-color: var(--panel-solid); +} +.spin { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--hairline); + border-top-color: var(--accent); border-radius: 50%; animation: spin 0.7s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } + +/* guided tour */ +.tour-root { + position: fixed; + inset: 0; + z-index: 260; + display: none; + pointer-events: none; +} +.tour-root.active { + display: block; + pointer-events: auto; +} +.tour-root.active::before { + content: ""; + position: fixed; + inset: 0; + z-index: 260; + pointer-events: auto; +} +.tour-highlight { + position: fixed; + z-index: 261; + border-radius: var(--radius, 12px); + box-shadow: 0 0 0 9999px rgba(15, 23, 42, 0.52); + border: 2px solid var(--accent); + pointer-events: none; + opacity: 0; + transition: opacity 0.18s ease; +} +.tour-highlight.visible { opacity: 1; } +.tour-popover { + position: fixed; + z-index: 262; + width: min(320px, calc(100vw - 24px)); + padding: 14px 16px 16px; + border-radius: var(--radius); + background: var(--panel-solid); + color: var(--ink); + border: 1px solid var(--hairline); + box-shadow: var(--shadow); + opacity: 0; + pointer-events: none; + transition: opacity 0.18s ease; +} +.tour-popover.visible { opacity: 1; pointer-events: auto; } +.tour-popover-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} +.tour-step-badge { + font-size: 11px; + font-weight: 700; + color: var(--ink-tertiary); + letter-spacing: 0.02em; +} +.tour-skip { + border: none; + background: var(--panel); + color: var(--ink-secondary, #555); + font-size: 12px; + font-weight: 600; + cursor: pointer; + padding: 5px 10px; + border-radius: 8px; + box-shadow: inset 0 0 0 1px var(--hairline); +} +.tour-skip:hover { + color: var(--ink); + background: var(--accent-soft, #e8f0fe); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 35%, var(--hairline)); +} +.tour-next { width: 100%; } +.tour-title { + margin: 0 0 8px; + font-size: 15px; + font-weight: 700; + line-height: 1.35; +} +.tour-body { + margin: 0 0 14px; + font-size: 13px; + line-height: 1.55; + color: var(--ink-secondary, #555); +} +.tour-body code { + font-size: 11px; + padding: 1px 5px; + border-radius: 4px; + background: var(--panel); +} +body.tour-active { overflow: hidden; } + +/* ===================================================== dataset visualization */ +.panel-dataset-viz { font-size: 13px; } +.panel-dataset-viz > h2 { margin-bottom: 12px; } + +.dv-card { + background: var(--panel-solid); + border: 1px solid var(--hairline); + border-radius: var(--radius-sm); + padding: 12px 14px; + margin-bottom: 12px; +} +.dv-card-head { + display: flex; align-items: center; justify-content: space-between; + gap: 8px; margin-bottom: 10px; flex-wrap: wrap; +} +.dv-card-title { font-size: 13px; font-weight: 600; color: var(--ink); } +.dv-card-badge { + font-size: 11px; padding: 2px 8px; border-radius: var(--radius-xs); + background: var(--accent-soft); color: var(--accent); font-weight: 500; +} +.dv-card-badge.warn { background: rgba(255,159,10,0.15); color: #c93400; } + +.btn-link { + border: none; background: none; padding: 0; + color: var(--accent); font-size: 12px; cursor: pointer; +} +.btn-link:hover { text-decoration: underline; } + +.dv-dropzone { + border: 2px dashed var(--hairline); border-radius: var(--radius-sm); + padding: 14px 10px; text-align: center; background: var(--bg); + margin-bottom: 8px; transition: border-color 0.15s, background 0.15s; +} +.dv-dropzone.hover, +.data-analysis-upload.hover { border-color: var(--accent); background: var(--accent-soft); } +.dv-dropzone.busy, +.data-analysis-upload.busy { border-color: var(--accent); opacity: 0.85; } +.dv-dropzone.ok, +.data-analysis-upload.ok { + border-color: #30d158; border-style: solid; + background: rgba(48, 209, 88, 0.08); +} +.dv-dropzone.ok .dv-drop-icon::after { content: ""; } +.data-analysis-upload.ok .dz-glyph { + border-color: #30d158; + color: #248a3d; +} +.dv-dropzone.err, +.data-analysis-upload.err { border-color: #ff375f; background: rgba(255, 55, 95, 0.06); } +.data-analysis-upload.err .dz-glyph { + border-color: #ff375f; + color: #d70015; +} +.dv-drop-inner { + display: flex; flex-direction: column; align-items: center; gap: 8px; font-size: 13px; +} +.dv-drop-icon { font-size: 22px; line-height: 1; } +.dv-drop-hint { font-size: 11px; margin-top: 6px; } + +.dv-upload-basket { + margin: 10px 0; + padding: 10px 12px; + border: 1px solid var(--hairline); + border-radius: var(--radius-sm); + background: var(--bg); +} +.dv-basket-head { + display: flex; justify-content: space-between; align-items: center; + margin-bottom: 8px; gap: 8px; +} +.dv-basket-title { font-size: 12px; font-weight: 600; color: var(--ink); } +.dv-basket-list { + list-style: none; margin: 0; padding: 0; + max-height: 140px; overflow-y: auto; + display: flex; flex-direction: column; gap: 6px; +} +.dv-basket-item { + display: grid; grid-template-columns: 1fr auto auto; + grid-template-rows: auto auto; + gap: 2px 8px; padding: 6px 8px; + border-radius: 6px; background: var(--panel-solid); + border: 1px solid var(--hairline); font-size: 11px; +} +.dv-basket-folder { font-weight: 600; color: var(--ink); grid-column: 1; } +.dv-basket-meta { color: var(--accent); font-weight: 500; grid-column: 2; grid-row: 1; } +.dv-basket-remove { + grid-column: 3; grid-row: 1; + padding: 0 4px; font-size: 16px; line-height: 1; + color: var(--ink-tertiary); align-self: start; +} +.dv-basket-remove:hover { color: #c93400; } +.dv-basket-names { + grid-column: 1 / -1; color: var(--ink-tertiary); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.dv-source-display { font-size: 12px; color: var(--ink-secondary); margin-bottom: 8px; word-break: break-all; } +.dv-support-compact { font-size: 11px; margin-bottom: 10px; } +.dv-format-grid { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; } +.dv-format-item { display: grid; grid-template-columns: 48px 1fr; gap: 8px; font-size: 11px; } +.dv-format-item b { color: var(--ink); } +.dv-format-item span { color: var(--ink-secondary); line-height: 1.4; } +.dv-format-warn { + font-size: 11px; color: #c93400; padding: 6px 8px; border-radius: 6px; + background: rgba(255,159,10,0.1); +} + +.dv-toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end; } +.dv-robot-preview { + display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end; + margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border, #e5e5ea); +} +.dv-robot-preview[hidden] { display: none !important; } +.dv-robot-preview .dv-field { flex: 1 1 180px; max-width: 100%; } +.dv-robot-preview .hint { flex: 1 1 140px; font-size: 11px; line-height: 1.35; margin-bottom: 2px; } +.dv-field { display: flex; flex-direction: column; gap: 3px; flex: 1 1 120px; } +.dv-field > span { + font-size: 10px; color: var(--ink-tertiary); + text-transform: uppercase; letter-spacing: 0.03em; +} +.dv-field select, .dv-select { + padding: 7px 9px; border: 1px solid var(--hairline); + border-radius: var(--radius-sm); background: var(--panel-solid); + color: var(--ink); font: 13px var(--font); +} +.dv-select-grow { flex: 1; min-width: 0; width: 100%; } +.dv-check { display: flex; align-items: center; gap: 5px; font-size: 12px; color: var(--ink-secondary); } +.dv-progress { margin-top: 8px; } +.dv-status { font-size: 12px; color: var(--ink-secondary); margin-top: 6px; min-height: 18px; } + +.dv-overview { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; } +.dv-stat-pill { + flex: 1; min-width: 64px; padding: 8px 10px; border-radius: var(--radius-sm); + background: var(--panel-solid); border: 1px solid var(--hairline); text-align: center; +} +.dv-stat-pill b { display: block; font-size: 18px; color: var(--ink); line-height: 1.2; } +.dv-stat-pill span { font-size: 10px; color: var(--ink-tertiary); text-transform: uppercase; } +.dv-stat-pill.accent { border-color: rgba(255,159,10,0.35); background: rgba(255,159,10,0.08); } +.dv-stat-pill.accent b { color: #c93400; } + +.dv-tagmode { display: flex; gap: 10px; align-items: center; font-size: 12px; } +.dv-tagmode label { display: flex; align-items: center; gap: 4px; cursor: pointer; } + +.dv-chip-group-label { + width: 100%; font-size: 10px; color: var(--ink-tertiary); + margin: 6px 0 4px; letter-spacing: 0.04em; +} +.dv-chips { display: flex; flex-wrap: wrap; gap: 5px; } +.dv-chip { + display: inline-flex; align-items: center; gap: 4px; + padding: 3px 8px; border: 1px solid var(--hairline); + border-radius: var(--radius-xs); background: var(--bg); + color: var(--ink); font: 11px var(--font); cursor: pointer; +} +.dv-chip:hover { border-color: var(--accent); } +.dv-chip.on { background: var(--accent); color: #fff; border-color: var(--accent); } +.dv-chip-n { + font-size: 10px; padding: 0 4px; border-radius: var(--radius-xs); + background: rgba(0,0,0,0.06); color: var(--ink-secondary); +} +.dv-chip.on .dv-chip-n { background: rgba(255,255,255,0.25); color: #fff; } + +.dv-info-panel { margin-top: 8px; } +.dv-info-compact { font-size: 11px; color: var(--ink-secondary); line-height: 1.45; margin-bottom: 6px; } +.dv-info-detail { font-size: 11px; color: var(--ink-secondary); line-height: 1.45; margin-top: 6px; } +.dv-info-formula { display: block; margin-top: 4px; font-size: 10px; color: var(--ink-tertiary); } +.dv-info-card { + padding: 8px 10px; border-radius: var(--radius-sm); + background: var(--bg); font-size: 12px; line-height: 1.45; + border-left: 3px solid var(--accent); +} +.dv-info-card b { display: block; margin-bottom: 2px; } +.dv-info-card code { display: block; margin-top: 4px; font-size: 10px; color: var(--ink-tertiary); } + +.dv-row-tight { margin-bottom: 6px; } +.dv-chart-wrap { + border-radius: var(--radius-sm); overflow: hidden; + border: 1px solid var(--hairline); background: #fafafa; +} +:root[data-theme="dark"] .dv-chart-wrap { background: #1c1c1e; } +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .dv-chart-wrap { background: #1c1c1e; } +} +.dv-canvas { display: block; width: 100%; height: auto; } +.dv-chart-footer { + display: flex; justify-content: space-between; gap: 8px; + margin-top: 4px; font-size: 10px; flex-wrap: wrap; +} +.dv-chart-stats { color: var(--ink-secondary); font-weight: 500; } + +.dv-scatter-wrap { position: relative; border: 1px solid var(--hairline); border-radius: var(--radius-sm); } +.dv-scatter { cursor: grab; background: #fafafa; } +.dv-scatter-tip { + position: absolute; pointer-events: none; z-index: 2; + transform: translate(-50%, 0); + padding: 4px 8px; border-radius: 6px; + background: rgba(29, 29, 31, 0.88); color: #fff; + font-size: 10px; white-space: nowrap; max-width: 220px; + overflow: hidden; text-overflow: ellipsis; + box-shadow: 0 2px 8px rgba(0,0,0,0.18); +} +:root[data-theme="dark"] .dv-scatter { background: #1c1c1e; } +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .dv-scatter { background: #1c1c1e; } +} +.dv-scatter-toolbar { + display: flex; flex-wrap: wrap; justify-content: space-between; + align-items: center; gap: 6px; margin-top: 6px; +} +.dv-legend { display: flex; flex-wrap: wrap; gap: 8px; } +.dv-legend-item { + display: inline-flex; align-items: center; gap: 4px; + font-size: 10px; color: var(--ink-secondary); +} +.dv-legend-item i { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } + +.dv-clip-list-wrap { margin-top: 8px; } +.dv-list-head { + display: flex; justify-content: space-between; align-items: center; + font-size: 12px; font-weight: 600; margin-bottom: 4px; +} +.dv-clip-list { + max-height: 140px; overflow-y: auto; + border: 1px solid var(--hairline); border-radius: var(--radius-sm); +} +.dv-clip-row { + display: flex; align-items: center; gap: 6px; + padding: 5px 8px; font-size: 11px; cursor: pointer; + border-bottom: 1px solid var(--hairline); +} +.dv-clip-row:last-child { border-bottom: none; } +.dv-clip-row:hover { background: var(--accent-soft); } +.dv-clip-row.subset { border-left: 3px solid #ff9f0a; } +.dv-clip-row.sel { background: rgba(0,0,0,0.03); } +.dv-cr-id { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; } +.dv-cr-meta { color: var(--ink-tertiary); font-size: 10px; flex-shrink: 0; } +.dv-cr-play { + border: none; background: var(--accent-soft); color: var(--accent); + border-radius: 4px; padding: 2px 6px; cursor: pointer; font-size: 10px; +} + +.dv-slider-block { margin-bottom: 12px; } +.dv-slider-row { + display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; +} +.dv-slider-label { font-size: 12px; color: var(--ink); font-weight: 500; } +.dv-slider-val { font-size: 13px; font-weight: 600; color: var(--accent); } +.dv-range { width: 100%; accent-color: var(--accent); } +.dv-tip { + display: inline-flex; align-items: center; justify-content: center; + width: 14px; height: 14px; border-radius: 50%; + background: var(--code-bg); color: var(--ink-tertiary); + font-size: 10px; cursor: help; margin-left: 4px; +} +.dv-alpha-hint { font-size: 10px; margin: 4px 0 0; line-height: 1.4; } + +.dv-selbar { + padding: 8px 10px; border-radius: var(--radius-sm); + background: var(--accent-soft); font-size: 12px; + margin-bottom: 10px; text-align: center; +} +.dv-actions-grid { + display: grid; grid-template-columns: 1fr 1fr; gap: 8px; +} +.dv-robot-export-opts { + display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; + margin-top: 10px; padding: 8px 10px; border-radius: 8px; + background: var(--surface-2, #f5f5f7); +} +.dv-user-root { margin-top: 8px; } +.dv-user-root .hint { font-size: 11px; line-height: 1.35; } +.dv-input { + width: 100%; padding: 7px 9px; border-radius: 8px; border: 1px solid var(--border, #d2d2d7); + font-size: 12px; font-family: inherit; background: var(--bg, #fff); +} +.dv-user-root[hidden] { display: none !important; } +.dv-robot-export-opts .hint { font-size: 11px; line-height: 1.35; } +.dv-actions-grid .btn { width: 100%; font-size: 12px; padding: 9px 10px; } +.dv-clip-detail { + margin-top: 8px; font-size: 11px; color: var(--ink-secondary); + padding: 6px 8px; background: var(--bg); border-radius: 6px; +} + +.r2r-batch-stage-toolbar { + grid-template-columns: auto auto 1fr; +} +.r2r-batch-basket-columns, +.r2r-batch-basket-row { + grid-template-columns: minmax(220px, 1fr) 140px 64px; + min-width: 440px; +} +.r2r-batch-basket-row .batch-basket-main { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ----------------------------------------------------------------- control geometry + Primary commands and application navigation use a square silhouette. + Transient selection surfaces retain a small radius to preserve hierarchy. */ +#app button, +.tour-root button { + border-radius: var(--button-radius); +} + +/* The primary transport control is intentionally circular even though the + rest of the application uses compact rectangular controls. */ +#app button#play-btn { + border-radius: 50%; +} + +#app button.nav-item, +#app button.desktop-menu-trigger, +#app button.desktop-menu-item { + border-radius: var(--button-radius); +} + +#app button.command-palette-trigger, +#app button.command-palette-item, +#app button.motion-picker-row { + border-radius: var(--menu-radius); +} + +.desktop-menu-popup { + border-radius: var(--button-radius); +} + +.command-palette, +.motion-picker-dialog, +.job-spec-dialog, +.workspace-settings-dialog { + border-radius: var(--menu-radius); +} + +#app select { + border-radius: var(--menu-radius); +} diff --git a/hhtools/web/frontend/src/workbench/browser/components/about-dialog.tsx b/hhtools/web/frontend/src/workbench/browser/components/about-dialog.tsx new file mode 100644 index 00000000..b6db706e --- /dev/null +++ b/hhtools/web/frontend/src/workbench/browser/components/about-dialog.tsx @@ -0,0 +1,70 @@ +import type { WorkspaceLocale } from "@/runtime/types"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface AboutDialogProps { + open: boolean; + locale: WorkspaceLocale; + onOpenChange(open: boolean): void; +} + +export function AboutDialog({ open, locale, onOpenChange }: AboutDialogProps) { + const zh = locale === "zh-CN"; + return ( + + + + + Human-Humanoid Tools + + + {zh + ? "人形机器人动作重映射与数据集分析工具" + : "Humanoid motion retargeting and dataset analysis"} + + +
+
+
{zh ? "作者与贡献者" : "Authors and contributors"}
+
jaggerShen {zh ? "与" : "and"} hhtools contributors
+
+
+
{zh ? "年份" : "Year"}
+
2026
+
+
+
{zh ? "源代码" : "Source code"}
+
+ + github.com/Roboparty/human-humanoid-tools + +
+
+
+
{zh ? "许可证" : "License"}
+
Apache-2.0
+
+
+
+

{zh ? "联系" : "Contact"}

+ shenyaojie@roboparty.com + + sunlancheng@roboparty.com + +
+
+
+ ); +} diff --git a/hhtools/web/frontend/src/workbench/browser/components/application-chrome.tsx b/hhtools/web/frontend/src/workbench/browser/components/application-chrome.tsx new file mode 100644 index 00000000..db73c5ad --- /dev/null +++ b/hhtools/web/frontend/src/workbench/browser/components/application-chrome.tsx @@ -0,0 +1,325 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Search } from "lucide-react"; + +import { + createApplicationCommands, + DESKTOP_MENUS, + type ApplicationCommand, + type DesktopMenuId, +} from "@/runtime/command-registry"; +import type { + WorkspaceLocale, + WorkspacePanelId, + WorkspaceTheme, +} from "@/runtime/types"; +import { cn } from "@/lib/utils"; + +interface ApplicationChromeProps { + activePanel: WorkspacePanelId; + locale: WorkspaceLocale; + theme: WorkspaceTheme; + onOpenSettings(): void; + onOpenAbout(): void; + onToggleTheme(): void; +} + +function useApplicationCommands( + props: ApplicationChromeProps, +): ApplicationCommand[] { + // Menus, keyboard shortcuts, and Ctrl+K are projections of one registry. + // Adding a command there automatically keeps every command surface aligned. + return useMemo( + () => + createApplicationCommands({ + activePanel: props.activePanel, + locale: props.locale, + theme: props.theme, + applicationMode: true, + openSettings: props.onOpenSettings, + openAbout: props.onOpenAbout, + toggleTheme: props.onToggleTheme, + canExitApplication: window.hhtoolsDesktop !== undefined, + exitApplication: () => window.close(), + canExportResult: true, + exportResult: () => { + // Export buttons still belong to the compatibility runtime. This + // adapter is the only chrome-level place allowed to invoke them. + const ids: Partial> = { + h2r: "rt-export-btn", + r2r: "r2r-export-btn", + batch: "batch-result-download", + "dataset-viz": "dv-export-json", + }; + const button = document.getElementById(ids[props.activePanel] ?? ""); + if (button instanceof HTMLButtonElement && !button.disabled) + button.click(); + }, + }), + [props], + ); +} + +const labels: Record> = { + en: { + file: "File", + workflows: "Workflows", + analysis: "Analysis", + settings: "Settings", + help: "Help", + }, + "zh-CN": { + file: "文件", + workflows: "工作流", + analysis: "分析", + settings: "设置", + help: "帮助", + }, +}; + +/** Compact desktop menubar built from the same command registry as Ctrl+K. */ +export function DesktopMenuBar(props: ApplicationChromeProps) { + const commands = useApplicationCommands(props); + const [openMenu, setOpenMenu] = useState(null); + const root = useRef(null); + useEffect(() => { + const close = (event: PointerEvent) => { + if (!root.current?.contains(event.target as Node)) setOpenMenu(null); + }; + document.addEventListener("pointerdown", close); + return () => document.removeEventListener("pointerdown", close); + }, []); + + return ( + + ); +} + +function CommandMenuItem({ + command, + onRun, +}: { + command: ApplicationCommand; + onRun(): void; +}) { + return ( +
+ {command.dividerBefore && ( +
+ )} + +
+ ); +} + +/** VS Code-style command surface with one registry shared by menus and keys. */ +export function CommandPalette(props: ApplicationChromeProps) { + const commands = useApplicationCommands(props); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [selected, setSelected] = useState(0); + const input = useRef(null); + const text = (en: string, zh: string) => (props.locale === "zh-CN" ? zh : en); + const filtered = useMemo(() => { + const needle = query.trim().toLowerCase(); + return needle + ? commands.filter((command) => + `${command.label} ${command.detail} ${command.keywords}` + .toLowerCase() + .includes(needle), + ) + : commands; + }, [commands, query]); + + useEffect(() => { + const keydown = (event: KeyboardEvent) => { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") { + event.preventDefault(); + setOpen((current) => !current); + setQuery(""); + setSelected(0); + return; + } + if (!open) return; + if (event.key === "Escape") { + event.preventDefault(); + setOpen(false); + } + if (event.key === "ArrowDown") { + event.preventDefault(); + setSelected((current) => Math.min(filtered.length - 1, current + 1)); + } + if (event.key === "ArrowUp") { + event.preventDefault(); + setSelected((current) => Math.max(0, current - 1)); + } + if (event.key === "Enter" && filtered[selected]?.enabled !== false) { + event.preventDefault(); + filtered[selected]?.run(); + setOpen(false); + } + }; + window.addEventListener("keydown", keydown); + return () => window.removeEventListener("keydown", keydown); + }, [filtered, open, selected]); + useEffect(() => { + if (open) requestAnimationFrame(() => input.current?.focus()); + }, [open]); + + return ( + <> + + {open && ( +
{ + if (event.target === event.currentTarget) setOpen(false); + }} + > +
+
+
+
+ {filtered.map((command, index) => ( + + ))} + {filtered.length === 0 && ( +

+ {text("No matching commands", "没有匹配的命令")} +

+ )} +
+
+
+ )} + + ); +} diff --git a/hhtools/web/frontend/src/workbench/browser/components/batch-workflow.tsx b/hhtools/web/frontend/src/workbench/browser/components/batch-workflow.tsx new file mode 100644 index 00000000..62ca3ac4 --- /dev/null +++ b/hhtools/web/frontend/src/workbench/browser/components/batch-workflow.tsx @@ -0,0 +1,909 @@ +import { useEffect, useState } from "react"; + +import { useLocaleText } from "@/hooks/use-locale-text"; +import type { + MotionCategory, + WorkspaceLocale, + WorkspacePanelId, +} from "@/runtime/types"; +import { MotionPickerDialog } from "./motion-picker-dialog"; +import { SearchField } from "./search-field"; +import type { VideoBatchModel, VideoBatchStatus } from "../use-video-batch"; + +export type BatchMode = "v2m" | "h2r" | "r2r"; + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 ** 2).toFixed(1)} MB`; +} + +function videoStatusLabel( + status: VideoBatchStatus, + locale: WorkspaceLocale, +): string { + const labels: Record = { + queued: ["Queued", "等待处理"], + uploading: ["Uploading", "正在上传"], + running: ["Generating motion", "正在生成动作"], + done: ["Motion ready", "动作已生成"], + error: ["Failed", "处理失败"], + }; + return labels[status][locale === "zh-CN" ? 1 : 0]; +} + +/** + * Center-stage half of Batch. All three modes stay mounted because the legacy + * IK runtime retains references to their stable elements while modes switch. + */ +export function BatchStage({ + active, + mode, + locale, + videoBatch, +}: { + active: boolean; + mode: BatchMode; + locale: WorkspaceLocale; + videoBatch: VideoBatchModel; +}) { + return ( + <> +
+ +
+
+ +
+
+ +
+ + ); +} + +function RobotBatchStage({ locale }: { locale: WorkspaceLocale }) { + const text = useLocaleText(locale); + return ( +
+
+
+

{text("Robot trajectory inputs", "机器人轨迹输入")}

+

+ {text( + "Build a trajectory set for one source and target robot pair.", + "为同一组源机器人和目标机器人整理待转换轨迹。", + )} +

+
+
+ 0 + {text("trajectories", "条轨迹")} +
+
+
+ + +
+
+ +
+
+
+ + {text("No robot trajectories selected", "尚未选择机器人轨迹")} + + + +
+
+ ); +} + +function HumanBatchStage({ locale }: { locale: WorkspaceLocale }) { + const text = useLocaleText(locale); + const [query, setQuery] = useState(""); + const [category, setCategory] = useState<"all" | MotionCategory>("all"); + useEffect(() => { + // Basket rows are still rendered by the compatibility runtime; publish + // React filter state as data rather than reaching into those rows here. + window.dispatchEvent( + new CustomEvent("hhtools:batch-filter", { detail: { query, category } }), + ); + }, [category, query]); + return ( +
+
+
+

{text("Batch inputs", "批量输入")}

+

+ {text( + "Build and validate the clip set before submitting a task.", + "先整理并检查动作清单,再提交批量任务。", + )} +

+
+
+ 0 + {text("clips", "条动作")} +
+
+
+ + + + + +
+
+ +
+
+
+ + + {text("0 selected", "已选择 0 条")} + + + + +
+
+ ); +} + +function VideoBatchStage({ + locale, + model, +}: { + locale: WorkspaceLocale; + model: VideoBatchModel; +}) { + const text = useLocaleText(locale); + return ( +
+
+
+

{text("Video inputs", "视频输入")}

+

+ {text( + "Each video becomes an independent GVHMR task.", + "每个视频会作为一项独立的 GVHMR 任务处理。", + )} +

+
+
+ {model.videos.length} + {text("videos", "个视频")} +
+
+
+ + +
+
event.preventDefault()} + onDragOver={(event) => event.preventDefault()} + onDrop={(event) => { + event.preventDefault(); + void model.dropVideos(event.dataTransfer); + }} + > +
+ {model.videos.length === 0 && ( +
+ {text("Add videos to begin", "添加视频以开始")} +
+ )} + {model.videos.map((item) => ( +
+
+ {item.file.name} + + {item.file._relpath || + item.file.webkitRelativePath || + item.file.name} + +
+ + {formatBytes(item.file.size)} + +
+ + {videoStatusLabel(item.status, locale)} + + {item.message && {item.message}} + {item.progress > 0 && item.progress < 1 && ( +
+
+
+ )} +
+
+ +
+
+ ))} +
+
+
+ + {text( + `${model.completedCount} ready · ${model.errorCount} failed`, + `已完成 ${model.completedCount} 个 · 失败 ${model.errorCount} 个`, + )} + + + +
+
+ ); +} + +export function BatchWorkflow({ + mode, + onModeChange, + locale, + onRequestPanel, + videoBatch, +}: { + mode: BatchMode; + onModeChange(mode: BatchMode): void; + locale: WorkspaceLocale; + onRequestPanel(panel: WorkspacePanelId): void; + videoBatch: VideoBatchModel; +}) { + const text = useLocaleText(locale); + const [pickerOpen, setPickerOpen] = useState(false); + useEffect(() => { + const openPicker = () => setPickerOpen(true); + window.addEventListener("hhtools:batch-library-request", openPicker); + return () => + window.removeEventListener("hhtools:batch-library-request", openPicker); + }, []); + return ( +
+

{text("Batch", "批量处理")}

+
+ {(["v2m", "h2r", "r2r"] as const).map((item) => ( + + ))} +
+ {/* The inspector shares the same V2M model as the center-stage list, so + progress cannot diverge between independently managed views. */} +
+ +
+
+ +
+
+ +
+ setPickerOpen(false)} + onImport={() => { + setPickerOpen(false); + onRequestPanel("motion"); + }} + /> +
+ ); +} + +function HumanBatchInspector({ + locale, + onRequestPanel, +}: { + locale: WorkspaceLocale; + onRequestPanel(panel: WorkspacePanelId): void; +}) { + const text = useLocaleText(locale); + return ( +
+
+ {text("1. Inputs", "1. 输入动作")} + + 0 {text("clips", "条")} + +
+
+ + + {text("2. Target robot & compatibility", "2. 目标机器人与兼容性")} + + +
+
+ + +
+ +

+ {text("Not loaded", "未加载")} +

+ {kind === "target" && ( +

+ )} +

+
+ ); + return ( +
+
+ {text("1. Source trajectories", "1. 源轨迹")} + + 0{" "} + {text("trajectories", "条")} + +
+ {robot("source")} + {robot("target")} + + +
+ ); +} + +function BatchSettings({ + locale, + prefix, +}: { + locale: WorkspaceLocale; + prefix: "batch" | "r2r-batch"; +}) { + const text = useLocaleText(locale); + const r2r = prefix === "r2r-batch"; + return ( +
+ + + {text( + r2r ? "4. Run settings" : "3. Run settings", + r2r ? "4. 运行设置" : "3. 运行设置", + )} + + +
+
+ + +
+ {!r2r &&

} +

+ {text("Advanced settings", "高级设置")} +
+ {!r2r && ( + + )} +
+ {r2r && ( + + )} + +
+ +
+ + +
+ + +
+
+
+
+ ); +} + +function BatchRun({ + locale, + prefix, +}: { + locale: WorkspaceLocale; + prefix: "batch" | "r2r-batch"; +}) { + const text = useLocaleText(locale); + const r2r = prefix === "r2r-batch"; + if (r2r) + return ( +
+

+ {text("No source trajectories selected.", "尚未选择源轨迹。")} +

+ +

+ {text( + "Add trajectories and load both robots first.", + "请先添加轨迹并加载源机器人和目标机器人。", + )} +

+
+
+
+

+

+ ); + return ( +
+

+ {text("No inputs selected.", "尚未选择输入动作。")} +

+ +

+ {text( + "Add motions and select a target robot first.", + "请先添加动作并选择目标机器人。", + )} +

+
+
+
+
+
+
+
+
+

+

+ + {text("Batch complete", "批量任务完成")} + +

+ + + +

+
+
+ ); +} + +function VideoBatchInspector({ + locale, + model, +}: { + locale: WorkspaceLocale; + model: VideoBatchModel; +}) { + const text = useLocaleText(locale); + return ( +
+
+ {text("1. Videos", "1. 视频")} + + {model.videos.length}{" "} + {text("videos", "个")} + +
+
+ + {text("2. Environment", "2. 运行环境")} + +
+ + +

+ {model.runtimeChecking + ? text("Checking GVHMR…", "正在检查 GVHMR……") + : model.runtime?.ready + ? text( + "GVHMR official runtime is ready.", + "GVHMR 官方运行环境已就绪。", + ) + : model.runtime?.missing?.[0] || + model.runtimeError || + text( + "GVHMR runtime is unavailable.", + "GVHMR 运行环境不可用。", + )} +

+ +
+
+
+ + {text("3. Generate motions", "3. 生成动作")} + +
+ + + model.setFocalLength(event.currentTarget.value) + } + /> + +
+
+
+

+ {model.statusMessage || + text( + "Generated motions are added to H2R batch automatically.", + "生成的动作会自动加入 H2R 批量清单。", + )} +

+
+
+
+ ); +} diff --git a/hhtools/web/frontend/src/workbench/browser/components/calibration-editor-controls.tsx b/hhtools/web/frontend/src/workbench/browser/components/calibration-editor-controls.tsx new file mode 100644 index 00000000..0d6cd3d4 --- /dev/null +++ b/hhtools/web/frontend/src/workbench/browser/components/calibration-editor-controls.tsx @@ -0,0 +1,243 @@ +import { useMemo, useState } from "react"; + +import { useLocaleText } from "@/hooks/use-locale-text"; +import { useWindowEvent } from "@/hooks/use-window-event"; +import { windowEventBus } from "@/platform/events/browser/window-event-bus"; +import type { + CalibrationEditorCommand, + CalibrationEditorStateDetail, + CalibrationJointRegion, + WorkspaceLocale, + WorkflowId, +} from "@/runtime/types"; +import { cn } from "@/lib/utils"; + +function initialState(workflow: WorkflowId): CalibrationEditorStateDetail { + return { + workflow, + active: false, + totalJoints: 0, + visibleJoints: 0, + mappedLandmarks: 0, + canUseSaved: false, + query: "", + region: "all", + unit: "rad", + comparison: "current", + mappedOnly: true, + labels: true, + mappingLines: true, + sourceOpacity: 0.82, + robotOpacity: 0.72, + }; +} + +/** + * Event-driven projection of the calibration domain state. + * + * The solver remains the source of truth: it publishes immutable snapshots and + * this component emits typed intents. That prevents a second calibration model + * from growing inside React during the staged runtime migration. + */ +export function CalibrationEditorControls({ + workflow, + locale, +}: { + workflow: WorkflowId; + locale: WorkspaceLocale; +}) { + const text = useLocaleText(locale); + const [state, setState] = useState(() => initialState(workflow)); + useWindowEvent("hhtools:calibration-editor-state", (event) => { + if (event.detail.workflow === workflow) setState(event.detail); + }); + const send = ( + command: CalibrationEditorCommand, + value?: string | number | boolean, + ): void => { + windowEventBus.emit("hhtools:calibration-editor-command", { + workflow, + command, + value, + }); + }; + const regions = useMemo< + Array<{ value: CalibrationJointRegion | "all"; label: string }> + >( + () => [ + { value: "all", label: text("All", "全部") }, + { value: "torso", label: text("Torso", "躯干") }, + { value: "left-arm", label: text("Left arm", "左臂") }, + { value: "right-arm", label: text("Right arm", "右臂") }, + { value: "left-leg", label: text("Left leg", "左腿") }, + { value: "right-leg", label: text("Right leg", "右腿") }, + { value: "head", label: text("Head", "头部") }, + { value: "hands", label: text("Hands", "手部") }, + ], + [text], + ); + + return ( +
+
+ + + {state.visibleJoints} / {state.totalJoints} + +
+ + +
+
+
+ {regions.map((region) => ( + + ))} +
+
+ + {text("Pose comparison", "姿态对照")} + +
+ + + +
+ +
+
+ + {text("Stage display", "舞台显示")} + + + + + + {text( + `${state.mappedLandmarks} mapped`, + `${state.mappedLandmarks} 个映射`, + )} + +
+
+ + +
+
+ ); +} diff --git a/hhtools/web/frontend/src/workbench/browser/components/data-analysis-panel.tsx b/hhtools/web/frontend/src/workbench/browser/components/data-analysis-panel.tsx new file mode 100644 index 00000000..ce98ceb4 --- /dev/null +++ b/hhtools/web/frontend/src/workbench/browser/components/data-analysis-panel.tsx @@ -0,0 +1,401 @@ +import type { ReactNode } from "react"; + +import { useLocaleText } from "@/hooks/use-locale-text"; +import type { WorkspaceLocale } from "@/runtime/types"; +import { DataAnalysisPipeline } from "./data-analysis-pipeline"; + +/** Dataset analysis workbench contribution with stable canvas/runtime mounts. */ +export function DataAnalysisPanel({ locale }: { locale: WorkspaceLocale }) { + const text = useLocaleText(locale); + return ( +
+

{text("Data Analysis", "数据分析")}

+ +
+ + {text("1. Select data", "1. 选择数据")} + +
+
+ + +
+

+ {text( + "You can append folders of the same type to the current batch.", + "可向当前批次继续追加同一类型的文件夹。", + )} +

+ +
+ {text("No folder selected", "未指定目录")} +
+ +
+ {text("Supported formats", "支持格式")} +
+
+ +
+
+
+ + {text("2. Configure", "2. 分析配置")} + +
+
+ + +
+
+
+
+ + {text("3. Analyze", "3. 运行分析")} + +
+ +
+
+
+
+
+
+
+ + {text("4. Results", "4. 分析结果")} + +
+

+ {text( + "Run an analysis to view metrics, clusters, and recommended subsets.", + "运行分析后可查看指标、聚类与推荐子集。", + )} +

+ +
+
+
+ ); +} + +function UploadZone({ + id, + iconId, + labelId, + buttonId, + title, + hint, + button, +}: { + id: string; + iconId: string; + labelId: string; + buttonId: string; + title: string; + hint: string; + button: string; +}) { + return ( +
+
+ {title[0]} +
+
{title}
+
+ {hint} +
+ +
+ ); +} + +function AnalysisCard({ + title, + action, + children, +}: { + title: string; + action?: ReactNode; + children: ReactNode; +}) { + return ( +
+
+ {title} + {action} +
+ {children} +
+ ); +} diff --git a/hhtools/web/frontend/src/workbench/browser/components/data-analysis-pipeline.tsx b/hhtools/web/frontend/src/workbench/browser/components/data-analysis-pipeline.tsx new file mode 100644 index 00000000..97fccb3a --- /dev/null +++ b/hhtools/web/frontend/src/workbench/browser/components/data-analysis-pipeline.tsx @@ -0,0 +1,109 @@ +import { useMemo, useState } from "react"; + +import { useLocaleText } from "@/hooks/use-locale-text"; +import { useWindowEvent } from "@/hooks/use-window-event"; +import type { DataAnalysisStateDetail, WorkspaceLocale } from "@/runtime/types"; +import { PipelineNav, type PipelineNode } from "./pipeline-nav"; + +const initialState: DataAnalysisStateDetail = { + dataKind: "unknown", + clipCount: 0, + stage: "idle", + progress: 0, + message: "", + hasResults: false, +}; + +export function DataAnalysisPipeline({ locale }: { locale: WorkspaceLocale }) { + const text = useLocaleText(locale); + const [state, setState] = useState(initialState); + useWindowEvent("hhtools:data-analysis-state", (event) => + setState(event.detail), + ); + + const nodes = useMemo(() => { + const hasSource = state.clipCount > 0; + const processing = state.stage === "uploading" || state.stage === "running"; + const kindLabel = + state.dataKind === "robot" + ? text("Robot", "机器人") + : state.dataKind === "human" + ? text("Motion", "动作") + : text("No data", "未选择"); + const open = + (target: string): (() => void) => + () => + requestAnimationFrame(() => { + const element = document.getElementById(target); + if (element instanceof HTMLDetailsElement) element.open = true; + element?.scrollIntoView({ block: "nearest", behavior: "smooth" }); + }); + return [ + { + id: "source", + label: text("Select Data", "选择数据"), + detail: hasSource + ? `${kindLabel} · ${state.clipCount} clips` + : kindLabel, + state: + state.stage === "uploading" + ? "running" + : hasSource + ? "completed" + : "ready", + activate: open("dv-step-source"), + }, + { + id: "configure", + label: text("Configure", "分析配置"), + detail: text("Embedding and cache", "特征与缓存设置"), + state: + state.stage === "running" || state.stage === "completed" + ? "completed" + : hasSource + ? "ready" + : "missing", + activate: open("dv-step-configure"), + }, + { + id: "analyze", + label: text("Analyze", "运行分析"), + detail: processing + ? `${Math.round(state.progress * 100)}%` + : state.stage === "completed" + ? text("Completed", "已完成") + : state.stage === "failed" + ? text("Failed", "失败") + : text("Not started", "未开始"), + state: + state.stage === "running" + ? "running" + : state.stage === "completed" + ? "completed" + : state.stage === "failed" + ? "failed" + : hasSource + ? "ready" + : "missing", + activate: open("dv-step-analyze"), + }, + { + id: "results", + label: text("Results", "分析结果"), + detail: state.hasResults + ? text("Ready", "可查看") + : text("No results", "暂无结果"), + state: state.hasResults ? "completed" : "missing", + activate: open("dv-step-results"), + }, + ]; + }, [state, text]); + + return ( + + ); +} diff --git a/hhtools/web/frontend/src/workbench/browser/components/human-to-robot-workflow.tsx b/hhtools/web/frontend/src/workbench/browser/components/human-to-robot-workflow.tsx new file mode 100644 index 00000000..7c55b9d5 --- /dev/null +++ b/hhtools/web/frontend/src/workbench/browser/components/human-to-robot-workflow.tsx @@ -0,0 +1,300 @@ +import { useState } from "react"; + +import { CalibrationEditorControls } from "./calibration-editor-controls"; +import { MotionPickerDialog } from "./motion-picker-dialog"; +import { ResultEvaluationPanel } from "./result-evaluation-panel"; +import { WorkflowPipeline } from "./workflow-pipeline"; +import type { WorkspaceLocale, WorkspacePanelId } from "@/runtime/types"; +import { useLocaleText } from "@/hooks/use-locale-text"; + +/** + * Declarative H2R inspector. React owns composition and local dialogs; stable + * element ids are ports consumed by the temporary IK compatibility runtime. + */ +export function HumanToRobotWorkflow({ + locale, + onRequestPanel, +}: { + locale: WorkspaceLocale; + onRequestPanel(panel: WorkspacePanelId): void; +}) { + const text = useLocaleText(locale); + const [pickerOpen, setPickerOpen] = useState(false); + return ( +
+

{text("Human → Robot", "人体 → 机器人")}

+ +
+ + {text("1. Motion", "1. 动作")} + +
+
+ + {text("Not loaded", "未加载")} + + +
+
+
+
+ + {text("2. Target robot", "2. 目标机器人")} + +
+
+ + + + +

+

+
+ {text("Calibration", "标定")} + + + — + + +
+ +
+
+
+

+ {text( + "Target robot + source reference", + "目标机器人 + 源参考格式", + )} +

+
+ +
+
+ + + + +
+
+
+
+
+ + {text("4. Result", "4. 结果")} + +
+
+ + +
+ +

+ {text("Select a motion and robot first.", "请先加载动作与机器人。")} +

+
+
+
+

+ +

+
+ + +
+
+ + +
+ +

+

+ +

+
+
+ setPickerOpen(false)} + onImport={() => { + setPickerOpen(false); + onRequestPanel("motion"); + }} + /> +
+ ); +} diff --git a/hhtools/web/frontend/src/workbench/browser/components/job-drawer.tsx b/hhtools/web/frontend/src/workbench/browser/components/job-drawer.tsx new file mode 100644 index 00000000..3ae3c308 --- /dev/null +++ b/hhtools/web/frontend/src/workbench/browser/components/job-drawer.tsx @@ -0,0 +1,688 @@ +import { + useEffect, + useRef, + useState, + type CSSProperties, + type PointerEvent, +} from "react"; +import { createPortal } from "react-dom"; +import { ChevronDown, ChevronUp, RefreshCw, X } from "lucide-react"; + +import { useLocaleText } from "@/hooks/use-locale-text"; +import { useWindowEvent } from "@/hooks/use-window-event"; +import { cn } from "@/lib/utils"; +import { windowEventBus } from "@/platform/events/browser/window-event-bus"; +import type { + JobConfigResponse, + JobHistoryCommandDetail, + JobHistoryRecord, + JobParameterValue, + JobReplayCapability, + JobSpecValidationResponse, + JobStatus, + WorkspaceLocale, +} from "@/runtime/types"; + +const HEIGHT_KEY = "hhtools-desktop-job-panel-height-v1"; +const MIN_HEIGHT = 180; + +const KIND_LABELS: Record = { + dataset_analyze: ["Dataset Analysis", "数据集分析"], + dataset_robot_preview: ["Robot Trajectory Preview", "机器人轨迹预览"], + motion_load: ["Load Motion", "加载动作"], + motion_link: ["Link Motion", "导入动作"], + basket_upload: ["Import Batch Motions", "导入批量动作"], + retarget: ["H2R Retarget", "H2R Retarget"], + batch: ["H2R Batch", "H2R 批量任务"], + r2r_source_upload: ["Load Source Robot Trajectory", "加载源机器人轨迹"], + r2r_retarget: ["R2R Retarget", "R2R Retarget"], + r2r_basket_upload: ["Import R2R Batch Trajectories", "导入 R2R 批量轨迹"], + r2r_batch: ["R2R Batch", "R2R 批量任务"], +}; + +const STATUS_LABELS: Record = { + pending: ["Pending", "等待中"], + running: ["Running", "运行中"], + done: ["Completed", "已完成"], + error: ["Failed", "失败"], +}; + +const PARAMETER_LABELS: Record = { + robot: ["Robot", "机器人"], + target: ["Target Robot", "目标机器人"], + target_robot: ["Target Robot", "目标机器人"], + source_robot: ["Source Robot", "源机器人"], + source: ["Source", "数据源"], + profile: ["Profile", "配置"], + reference: ["Reference Skeleton", "参考骨架"], + backend: ["Solver", "求解器"], + embedding: ["Feature Space", "特征空间"], + format: ["Format", "格式"], + retarget_fps: ["Retarget FPS", "Retarget FPS"], + export_fps: ["Export FPS", "Export FPS"], + source_fps: ["Source FPS", "Source FPS"], + batch_size: ["Batch Size", "Batch Size"], + out_dir: ["Output Directory", "输出目录"], + folder_label: ["Folder", "目录"], + library_folder_label: ["Library Folder", "资源目录"], + entry_count: ["Entries", "条目"], + file_count: ["Files", "文件"], +}; + +/** Bottom task panel and JobSpec editor, modelled after VS Code's docked panel. */ +export function JobDrawer({ locale }: { locale: WorkspaceLocale }) { + const text = useLocaleText(locale); + const [open, setOpen] = useState(false); + const [height, setHeight] = useState(() => + Math.max(MIN_HEIGHT, Number(localStorage.getItem(HEIGHT_KEY)) || 300), + ); + const [jobs, setJobs] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [editorOpen, setEditorOpen] = useState(false); + const [editorTitle, setEditorTitle] = useState(""); + const [editorText, setEditorText] = useState(""); + const [editorBusy, setEditorBusy] = useState(false); + const [editorError, setEditorError] = useState(null); + const [editorValidation, setEditorValidation] = + useState(null); + const importInput = useRef(null); + const stopResizeRef = useRef<(() => void) | null>(null); + + // Job history has one runtime-owned poller. This view consumes immutable + // snapshots and emits commands, avoiding a second polling/download layer. + const dispatch = (detail: JobHistoryCommandDetail) => + windowEventBus.emit("hhtools:job-history-command", detail); + const bridge = () => { + if (!window.__hhApp) + throw new Error( + text( + "The WebUI is not ready yet. Try again shortly.", + "WebUI 尚未准备完成,请稍后重试", + ), + ); + return window.__hhApp; + }; + const messageOf = (value: unknown) => + value instanceof Error ? value.message : String(value); + const localized = (labels: [string, string] | undefined, fallback: string) => + labels ? text(labels[0], labels[1]) : fallback; + + useWindowEvent("hhtools:job-history-state", (event) => { + setJobs(event.detail.jobs); + setLoading(event.detail.loading); + setError(event.detail.error); + }); + useEffect(() => { + const keydown = (event: KeyboardEvent) => { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "j") { + event.preventDefault(); + setOpen((current) => !current); + } + }; + const importRequest = () => importInput.current?.click(); + window.addEventListener("keydown", keydown); + window.addEventListener("hhtools:job-spec-import-request", importRequest); + dispatch({ command: "refresh" }); + return () => { + window.removeEventListener("keydown", keydown); + window.removeEventListener( + "hhtools:job-spec-import-request", + importRequest, + ); + stopResizeRef.current?.(); + }; + }, []); + + const startResize = (event: PointerEvent) => { + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + const startY = event.clientY; + const startHeight = height; + let nextHeight = startHeight; + const previousCursor = document.body.style.cursor; + const previousUserSelect = document.body.style.userSelect; + document.body.style.cursor = "row-resize"; + document.body.style.userSelect = "none"; + const move = (moveEvent: globalThis.PointerEvent) => { + nextHeight = Math.max( + MIN_HEIGHT, + Math.min( + window.innerHeight - 160, + startHeight + startY - moveEvent.clientY, + ), + ); + setHeight(nextHeight); + }; + const stop = () => { + document.body.style.cursor = previousCursor; + document.body.style.userSelect = previousUserSelect; + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", stop); + window.removeEventListener("pointercancel", stop); + localStorage.setItem(HEIGHT_KEY, String(Math.round(nextHeight))); + stopResizeRef.current = null; + }; + stopResizeRef.current?.(); + stopResizeRef.current = stop; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", stop, { once: true }); + window.addEventListener("pointercancel", stop, { once: true }); + }; + + const resetEditorValidation = () => { + setEditorError(null); + setEditorValidation(null); + }; + const openEditor = (title: string, value: unknown) => { + // Imported full configs and duplicated jobs converge on one JobSpec editor; + // server validation normalizes both before replay is allowed. + setEditorTitle(title); + setEditorText(JSON.stringify(value, null, 2)); + resetEditorValidation(); + setEditorOpen(true); + }; + const closeEditor = () => { + if (!editorBusy) setEditorOpen(false); + }; + const importConfig = async (file: File | undefined) => { + if (!file) return; + try { + openEditor( + `${text("Import configuration", "导入配置")} · ${file.name}`, + JSON.parse(await file.text()) as unknown, + ); + } catch (cause) { + bridge().toast( + `${text("Unable to read configuration", "读取配置失败")}:${messageOf(cause)}`, + true, + ); + } + }; + const duplicateForEdit = async (job: JobHistoryRecord) => { + try { + const config: JobConfigResponse = await bridge().API.get( + `/api/job/${job.id}/config`, + ); + openEditor( + `${text("Duplicate and edit", "复制编辑")} · ${localized(KIND_LABELS[job.kind], job.kind)}`, + config.spec, + ); + } catch (cause) { + bridge().toast( + `${text("Unable to read task configuration", "读取任务配置失败")}:${messageOf(cause)}`, + true, + ); + } + }; + const validateEditor = + async (): Promise => { + setEditorBusy(true); + resetEditorValidation(); + try { + let parsed: unknown; + try { + parsed = JSON.parse(editorText) as unknown; + } catch (cause) { + throw new Error( + `${text("Invalid JSON", "JSON 格式错误")}:${messageOf(cause)}`, + ); + } + // The server, not the client, decides whether referenced source files are + // still replayable. React only presents that capability result. + const result = await bridge().API.post( + "/api/jobs/spec/validate", + parsed, + ); + setEditorValidation(result.replay); + setEditorText(JSON.stringify(result.spec, null, 2)); + return result; + } catch (cause) { + setEditorError(messageOf(cause)); + return null; + } finally { + setEditorBusy(false); + } + }; + const runEditor = async () => { + const validated = await validateEditor(); + if (!validated?.replay.available) return; + setEditorBusy(true); + try { + const started = await bridge().API.post("/api/jobs/replay", { + spec: validated.spec, + }); + bridge().toast(`${text("Created task", "已创建任务")} ${started.job_id}`); + setEditorOpen(false); + dispatch({ command: "refresh" }); + } catch (cause) { + setEditorError(messageOf(cause)); + } finally { + setEditorBusy(false); + } + }; + const retry = async (job: JobHistoryRecord, failedOnly = false) => { + if (failedOnly ? !job.can_retry_failed : !job.can_retry) return; + try { + const started = await bridge().API.post("/api/jobs/replay", { + job_id: job.id, + failed_only: failedOnly, + }); + bridge().toast( + `${text(failedOnly ? "Created failed-item retry task" : "Created retry task", failedOnly ? "已创建失败项重试任务" : "已创建重试任务")} ${started.job_id}`, + ); + dispatch({ command: "refresh" }); + } catch (cause) { + bridge().toast( + `${text("Retry failed", "重试失败")}:${messageOf(cause)}`, + true, + ); + } + }; + const formatTime = (timestamp: number) => + !Number.isFinite(timestamp) || timestamp <= 0 + ? text("Unknown time", "时间未知") + : new Intl.DateTimeFormat(locale === "en" ? "en-US" : "zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).format(new Date(timestamp * 1000)); + const formatDuration = (seconds: number) => { + if (!Number.isFinite(seconds) || seconds < 0) return ""; + if (seconds < 60) + return `${Math.max(1, Math.round(seconds))} ${text("sec", "秒")}`; + return `${Math.floor(seconds / 60)} ${text("min", "分")} ${Math.round(seconds % 60)} ${text("sec", "秒")}`; + }; + const resultText = (job: JobHistoryRecord) => { + const parts: string[] = []; + if (typeof job.result_summary.success_count === "number") + parts.push( + `${job.result_summary.success_count} ${text("succeeded", "成功")}`, + ); + if ( + typeof job.result_summary.failure_count === "number" && + job.result_summary.failure_count > 0 + ) + parts.push( + `${job.result_summary.failure_count} ${text("failed", "失败")}`, + ); + if (typeof job.result_summary.num_frames === "number") + parts.push(`${job.result_summary.num_frames} ${text("frames", "帧")}`); + return parts.join(" · "); + }; + const parameterEntries = (job: JobHistoryRecord) => + Object.entries(job.parameters).slice(0, 6) as Array< + [string, JobParameterValue] + >; + const style = open + ? ({ "--job-panel-height": `${height}px` } as CSSProperties) + : undefined; + + return ( + <> +
+ {open && ( +
+ {editorOpen && + createPortal( +
{ + if (event.target === event.currentTarget) closeEditor(); + }} + > +
+
+
+ {editorTitle} + + JobSpec v1 ·{" "} + {text( + "Validate changes before running a new task", + "修改后先验证,再作为新任务运行", + )} + +
+ +
+