chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
# Bundled RenderDoc native bindings must not be committed (use system install).
|
||||
cli_anything/renderdoc/native/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
@@ -0,0 +1,83 @@
|
||||
# HARNESS.md – RenderDoc CLI Harness Specification
|
||||
|
||||
## Overview
|
||||
|
||||
This harness wraps the **RenderDoc** graphics debugger Python API into a Click-based
|
||||
CLI tool called `cli-anything-renderdoc`. It enables headless, scriptable analysis
|
||||
of GPU frame captures (`.rdc` files) without requiring the RenderDoc GUI.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
agent-harness/
|
||||
├── HARNESS.md # This file
|
||||
├── RENDERDOC.md # Software-specific SOP
|
||||
├── setup.py # PEP 420 namespace package
|
||||
└── cli_anything/ # NO __init__.py (namespace package)
|
||||
└── renderdoc/ # HAS __init__.py
|
||||
├── renderdoc_cli.py # Main CLI entry point (Click)
|
||||
├── core/
|
||||
│ ├── capture.py # Capture file open/close/metadata/convert
|
||||
│ ├── actions.py # Draw call / action tree navigation
|
||||
│ ├── textures.py # Texture listing, pixel picking, export
|
||||
│ ├── pipeline.py # Pipeline state, shader export, diff, cbuffers
|
||||
│ ├── resources.py # Buffer/resource enumeration and reading
|
||||
│ ├── mesh.py # Vertex input/output decoding
|
||||
│ └── counters.py # GPU performance counters
|
||||
├── utils/
|
||||
│ ├── output.py # JSON/table output formatting
|
||||
│ └── errors.py # Error handling
|
||||
├── skills/
|
||||
│ └── SKILL.md # AI-discoverable skill definition
|
||||
└── tests/
|
||||
├── TEST.md # Test plan and results
|
||||
├── test_core.py # Unit tests (mock-based, no renderdoc dep)
|
||||
└── test_full_e2e.py # E2E tests (requires renderdoc + .rdc files)
|
||||
```
|
||||
|
||||
## Command Groups
|
||||
|
||||
| Group | Commands |
|
||||
|-------------|--------------------------------------------------|
|
||||
| `capture` | `info`, `thumb`, `convert` |
|
||||
| `actions` | `list`, `summary`, `find`, `get` |
|
||||
| `textures` | `list`, `get`, `save`, `save-outputs`, `pick` |
|
||||
| `pipeline` | `state`, `shader-export`, `cbuffer`, `diff` |
|
||||
| `resources` | `list`, `buffers`, `read-buffer` |
|
||||
| `mesh` | `inputs`, `outputs` |
|
||||
| `counters` | `list`, `fetch` |
|
||||
|
||||
## Global Options
|
||||
|
||||
- `--capture / -c <path>`: Path to the `.rdc` capture file (or `$RENDERDOC_CAPTURE`)
|
||||
- `--json`: Output in JSON format (machine-readable)
|
||||
- `--debug`: Show tracebacks on errors
|
||||
- `--version`: Show version
|
||||
|
||||
## Patterns
|
||||
|
||||
1. **Lazy loading**: `renderdoc` module is only imported when a command runs, not at
|
||||
CLI parse time. This allows `--help` to work without renderdoc installed.
|
||||
2. **CaptureHandle**: Single context manager that owns the CaptureFile and
|
||||
ReplayController lifecycle.
|
||||
3. **Dict-based returns**: Every core function returns plain `dict`/`list` for
|
||||
direct JSON serialisation.
|
||||
4. **Dual output**: `_output()` helper picks JSON or human-readable based on `--json`.
|
||||
5. **Error dicts**: Errors returned as `{"error": "message"}` rather than exceptions
|
||||
at the boundary layer.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- **Unit tests** (`test_core.py`): Test all core module functions using mocks.
|
||||
No dependency on `renderdoc` module. Uses synthetic data.
|
||||
- **E2E tests** (`test_full_e2e.py`): Require a real RenderDoc installation and
|
||||
at least one `.rdc` capture file. Test full CLI invocation via subprocess.
|
||||
- **Subprocess tests**: Invoke the CLI with
|
||||
`python -m cli_anything.renderdoc.renderdoc_cli` from `agent-harness/` (see
|
||||
`test_core.py` / `test_full_e2e.py`).
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Required**: `click>=8.0`, `prompt-toolkit>=3.0`, `python>=3.10`
|
||||
- **Optional** (runtime): `renderdoc` Python module (from RenderDoc installation)
|
||||
- **Test**: `pytest>=7.0`
|
||||
@@ -0,0 +1,81 @@
|
||||
# RENDERDOC.md – Software-Specific SOP
|
||||
|
||||
## About RenderDoc
|
||||
|
||||
RenderDoc is a free, open-source GPU frame capture and analysis tool for
|
||||
Vulkan, D3D11, D3D12, OpenGL, and OpenGL ES. It captures a single frame
|
||||
of GPU commands and allows detailed inspection of every draw call, resource,
|
||||
shader, and pipeline state.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Capture File (.rdc)
|
||||
A binary file containing all GPU state and commands for a single frame.
|
||||
Contains embedded sections (textures, shaders, structured data, thumbnails).
|
||||
|
||||
### Actions
|
||||
GPU operations recorded in the capture. Hierarchical tree structure:
|
||||
- **PushMarker / PopMarker**: Debug groups (like `RenderPass: ForwardOpaque`)
|
||||
- **Drawcall**: Actual draw calls with triangle/vertex counts
|
||||
- **Clear**: Clear render target/depth
|
||||
- **Dispatch**: Compute shader dispatch
|
||||
- **Copy/Resolve**: Resource copy operations
|
||||
- **Present**: Frame present
|
||||
|
||||
### Resources
|
||||
GPU resources tracked by unique `ResourceId`:
|
||||
- **Textures**: 2D, 3D, cube, array textures with mips
|
||||
- **Buffers**: Vertex, index, constant, structured buffers
|
||||
- **Shaders**: Compiled shader programs
|
||||
|
||||
### Pipeline State
|
||||
At any event, the complete GPU pipeline is inspectable:
|
||||
- Bound shaders (VS, HS, DS, GS, PS, CS)
|
||||
- Vertex inputs (attribute layout)
|
||||
- Render targets and depth target
|
||||
- Viewports and scissors
|
||||
- Blend, depth/stencil, rasterizer state
|
||||
- Shader resources (textures, buffers, samplers)
|
||||
- Constant buffer contents
|
||||
|
||||
### Replay
|
||||
Captures can be replayed on the local GPU. The ReplayController allows:
|
||||
- Setting the current event (seeking to any draw call)
|
||||
- Reading back textures, buffers, mesh data
|
||||
- Picking pixels
|
||||
- Fetching GPU counters
|
||||
- Disassembling shaders
|
||||
|
||||
## CLI Coverage Map
|
||||
|
||||
| RenderDoc Feature | CLI Command | Status |
|
||||
|-----------------------------|---------------------------|-----------|
|
||||
| Open/close capture | `capture info` | ✅ Done |
|
||||
| Capture metadata | `capture info` | ✅ Done |
|
||||
| List sections | `capture info` | ✅ Done |
|
||||
| Extract thumbnail | `capture thumb` | ✅ Done |
|
||||
| Convert capture | `capture convert` | ✅ Done |
|
||||
| List all actions | `actions list` | ✅ Done |
|
||||
| Action summary | `actions summary` | ✅ Done |
|
||||
| Find actions by name | `actions find` | ✅ Done |
|
||||
| Get single action | `actions get` | ✅ Done |
|
||||
| Filter draw calls only | `actions list --draws-only` | ✅ Done |
|
||||
| List textures | `textures list` | ✅ Done |
|
||||
| Get texture details | `textures get` | ✅ Done |
|
||||
| Save texture to file | `textures save` | ✅ Done |
|
||||
| Save render target outputs | `textures save-outputs` | ✅ Done |
|
||||
| Pick pixel value | `textures pick` | ✅ Done |
|
||||
| Pipeline state | `pipeline state` | ✅ Done |
|
||||
| Shader disassembly | `pipeline shader-export` | ✅ Done |
|
||||
| Constant buffer contents | `pipeline cbuffer` | ✅ Done |
|
||||
| Pipeline diff | `pipeline diff` | ✅ Done |
|
||||
| List resources | `resources list` | ✅ Done |
|
||||
| List buffers | `resources buffers` | ✅ Done |
|
||||
| Read buffer data | `resources read-buffer` | ✅ Done |
|
||||
| Vertex inputs | `mesh inputs` | ✅ Done |
|
||||
| Post-VS outputs | `mesh outputs` | ✅ Done |
|
||||
| GPU counters list | `counters list` | ✅ Done |
|
||||
| Fetch counter results | `counters fetch` | ✅ Done |
|
||||
| Remote capture | — | ❌ Future |
|
||||
| Shader debugging | — | ❌ Future |
|
||||
| Resource diff | — | ❌ Future |
|
||||
@@ -0,0 +1,129 @@
|
||||
# cli-anything-renderdoc
|
||||
|
||||
Command-line interface for [RenderDoc](https://renderdoc.org/) graphics debugger.
|
||||
|
||||
Provides headless, scriptable analysis of GPU frame captures (`.rdc` files)
|
||||
without requiring the RenderDoc GUI.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd renderdoc/agent-harness
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
**Prerequisites**: RenderDoc must be installed and its Python module must be
|
||||
importable. Add the RenderDoc Python directory to `PYTHONPATH`:
|
||||
|
||||
```bash
|
||||
# Windows (typical)
|
||||
set PYTHONPATH=C:\Program Files\RenderDoc
|
||||
# Linux (typical)
|
||||
export PYTHONPATH=/opt/renderdoc/lib
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Show capture info
|
||||
cli-anything-renderdoc --capture frame.rdc capture info
|
||||
|
||||
# List all draw calls
|
||||
cli-anything-renderdoc -c frame.rdc actions list --draws-only
|
||||
|
||||
# Get action summary
|
||||
cli-anything-renderdoc -c frame.rdc actions summary
|
||||
|
||||
# Save a texture as PNG
|
||||
cli-anything-renderdoc -c frame.rdc textures save <resourceId> -o output.png
|
||||
|
||||
# Pick a pixel
|
||||
cli-anything-renderdoc -c frame.rdc textures pick <resourceId> 100 200
|
||||
|
||||
# Get pipeline state at a draw call
|
||||
cli-anything-renderdoc -c frame.rdc pipeline state 42
|
||||
|
||||
# Get shader disassembly
|
||||
cli-anything-renderdoc -c frame.rdc pipeline shader-export 42 --stage Fragment
|
||||
|
||||
# List GPU counters
|
||||
cli-anything-renderdoc -c frame.rdc counters list
|
||||
|
||||
# Read buffer data
|
||||
cli-anything-renderdoc -c frame.rdc resources read-buffer <resourceId> --format float32
|
||||
|
||||
# JSON output for all commands
|
||||
cli-anything-renderdoc -c frame.rdc --json actions list
|
||||
```
|
||||
|
||||
## Preview Bundles
|
||||
|
||||
RenderDoc exposes preview bundles for honest capture inspection and diffing.
|
||||
|
||||
```bash
|
||||
# Capture a preview bundle
|
||||
cli-anything-renderdoc -c frame.rdc --json preview capture --recipe quick --event-id 42
|
||||
|
||||
# Capture a diff preview bundle
|
||||
cli-anything-renderdoc -c frame.rdc --json preview diff 100 200
|
||||
|
||||
# Return the latest existing bundle
|
||||
cli-anything-renderdoc -c frame.rdc --json preview latest --recipe quick
|
||||
```
|
||||
|
||||
Preview bundles typically contain thumbnails, output-target images, and
|
||||
pipeline/action JSON. Diff bundles also contain `pipeline_diff.json`.
|
||||
|
||||
Inspect or open them with:
|
||||
|
||||
```bash
|
||||
cli-hub previews inspect /path/to/bundle
|
||||
cli-hub previews html /path/to/bundle -o page.html
|
||||
cli-hub previews open /path/to/bundle
|
||||
```
|
||||
|
||||
## Command Reference
|
||||
|
||||
### Global Options
|
||||
|
||||
| Option | Description |
|
||||
|-------------|--------------------------------------|
|
||||
| `--capture` | Path to `.rdc` capture file |
|
||||
| `--json` | JSON output mode |
|
||||
| `--debug` | Show error tracebacks |
|
||||
| `--version` | Show version |
|
||||
|
||||
### Commands
|
||||
|
||||
| Group | Command | Description |
|
||||
|-------------|----------------|--------------------------------------------|
|
||||
| `capture` | `info` | Show metadata and sections |
|
||||
| `capture` | `thumb` | Extract thumbnail image |
|
||||
| `capture` | `convert` | Convert capture format |
|
||||
| `actions` | `list` | List all actions / draw calls |
|
||||
| `actions` | `summary` | Count actions by type |
|
||||
| `actions` | `find` | Search actions by name |
|
||||
| `actions` | `get` | Get single action details |
|
||||
| `textures` | `list` | List all textures |
|
||||
| `textures` | `get` | Get texture details |
|
||||
| `textures` | `save` | Export texture to image file |
|
||||
| `textures` | `save-outputs` | Save all render targets at an event |
|
||||
| `textures` | `pick` | Read pixel value |
|
||||
| `pipeline` | `state` | Full pipeline state at event |
|
||||
| `pipeline` | `shader-export` | Export shader source / disassembly |
|
||||
| `pipeline` | `cbuffer` | Constant buffer contents |
|
||||
| `pipeline` | `diff` | Compare pipeline state between events |
|
||||
| `resources` | `list` | List all resources |
|
||||
| `resources` | `buffers` | List buffer resources |
|
||||
| `resources` | `read-buffer` | Read raw buffer data |
|
||||
| `mesh` | `inputs` | Vertex shader input data |
|
||||
| `mesh` | `outputs` | Post-VS output data |
|
||||
| `counters` | `list` | Available GPU counters |
|
||||
| `counters` | `fetch` | Fetch counter results |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|-------------------|--------------------------------|
|
||||
| `RENDERDOC_CAPTURE` | Default capture file path |
|
||||
| `PYTHONPATH` | Must include RenderDoc Python |
|
||||
@@ -0,0 +1,6 @@
|
||||
"""RenderDoc CLI harness - command-line interface for RenderDoc graphics debugger."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
# The ``renderdoc`` module is loaded lazily by core/CLI code when needed.
|
||||
# Install RenderDoc and add its Python bindings directory to PYTHONPATH.
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Allow running as python -m cli_anything.renderdoc"""
|
||||
from cli_anything.renderdoc.renderdoc_cli import main
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
"""Core modules for RenderDoc CLI harness."""
|
||||
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
Action (draw call) inspection: list, search, navigate the action tree.
|
||||
|
||||
Works with a CaptureHandle's ReplayController to enumerate draw calls,
|
||||
clears, dispatches, and other GPU actions recorded in a frame capture.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
try:
|
||||
import renderdoc as rd
|
||||
|
||||
HAS_RD = True
|
||||
except ImportError:
|
||||
rd = None # type: ignore[assignment]
|
||||
HAS_RD = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Action helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _action_to_dict(action, structured_file=None) -> Dict[str, Any]:
|
||||
"""Serialise one ActionDescription to a plain dict."""
|
||||
name = action.customName
|
||||
if not name and structured_file is not None:
|
||||
name = action.GetName(structured_file)
|
||||
flags_list = _decode_flags(action.flags)
|
||||
return {
|
||||
"eventId": action.eventId,
|
||||
"actionId": action.actionId,
|
||||
"name": name or "",
|
||||
"flags": flags_list,
|
||||
"numIndices": action.numIndices,
|
||||
"numInstances": action.numInstances,
|
||||
"indexOffset": action.indexOffset,
|
||||
"baseVertex": action.baseVertex,
|
||||
"vertexOffset": action.vertexOffset,
|
||||
"instanceOffset": action.instanceOffset,
|
||||
"outputs": [str(o) for o in action.outputs],
|
||||
"depthOut": str(action.depthOut),
|
||||
"children_count": len(action.children),
|
||||
}
|
||||
|
||||
|
||||
def _decode_flags(flags) -> List[str]:
|
||||
"""Decode ActionFlags bitmask into list of human-readable names."""
|
||||
if rd is None:
|
||||
return []
|
||||
names = []
|
||||
flag_map = {
|
||||
rd.ActionFlags.Clear: "Clear",
|
||||
rd.ActionFlags.Drawcall: "Drawcall",
|
||||
rd.ActionFlags.Dispatch: "Dispatch",
|
||||
rd.ActionFlags.CmdList: "CmdList",
|
||||
rd.ActionFlags.SetMarker: "SetMarker",
|
||||
rd.ActionFlags.PushMarker: "PushMarker",
|
||||
rd.ActionFlags.PopMarker: "PopMarker",
|
||||
rd.ActionFlags.Present: "Present",
|
||||
rd.ActionFlags.MultiAction: "MultiAction",
|
||||
rd.ActionFlags.Copy: "Copy",
|
||||
rd.ActionFlags.Resolve: "Resolve",
|
||||
rd.ActionFlags.GenMips: "GenMips",
|
||||
rd.ActionFlags.PassBoundary: "PassBoundary",
|
||||
rd.ActionFlags.Indexed: "Indexed",
|
||||
rd.ActionFlags.Instanced: "Instanced",
|
||||
rd.ActionFlags.Auto: "Auto",
|
||||
rd.ActionFlags.Indirect: "Indirect",
|
||||
rd.ActionFlags.ClearColor: "ClearColor",
|
||||
rd.ActionFlags.ClearDepthStencil: "ClearDepthStencil",
|
||||
rd.ActionFlags.BeginPass: "BeginPass",
|
||||
rd.ActionFlags.EndPass: "EndPass",
|
||||
}
|
||||
for flag_val, flag_name in flag_map.items():
|
||||
if flags & flag_val:
|
||||
names.append(flag_name)
|
||||
return names
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flat enumeration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _flatten_actions(actions, out: list, structured_file=None, depth: int = 0):
|
||||
"""Recursively flatten action tree."""
|
||||
for a in actions:
|
||||
d = _action_to_dict(a, structured_file)
|
||||
d["depth"] = depth
|
||||
out.append(d)
|
||||
if len(a.children) > 0:
|
||||
_flatten_actions(a.children, out, structured_file, depth + 1)
|
||||
|
||||
|
||||
def list_actions(controller, flat: bool = True) -> List[Dict[str, Any]]:
|
||||
"""Return all actions in the capture.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
controller : rd.ReplayController
|
||||
flat : bool
|
||||
If True (default), return a flat list with a ``depth`` key.
|
||||
If False, return only root-level actions.
|
||||
"""
|
||||
sf = controller.GetStructuredFile()
|
||||
root_actions = controller.GetRootActions()
|
||||
if flat:
|
||||
result: List[Dict[str, Any]] = []
|
||||
_flatten_actions(root_actions, result, sf)
|
||||
return result
|
||||
return [_action_to_dict(a, sf) for a in root_actions]
|
||||
|
||||
|
||||
def find_action_by_event(controller, event_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Find a single action by its eventId."""
|
||||
sf = controller.GetStructuredFile()
|
||||
# Use the flat list and filter
|
||||
all_actions: List[Dict[str, Any]] = []
|
||||
_flatten_actions(controller.GetRootActions(), all_actions, sf)
|
||||
for a in all_actions:
|
||||
if a["eventId"] == event_id:
|
||||
return a
|
||||
return None
|
||||
|
||||
|
||||
def find_actions_by_name(controller, pattern: str) -> List[Dict[str, Any]]:
|
||||
"""Find actions whose name contains *pattern* (case-insensitive)."""
|
||||
sf = controller.GetStructuredFile()
|
||||
all_actions: List[Dict[str, Any]] = []
|
||||
_flatten_actions(controller.GetRootActions(), all_actions, sf)
|
||||
pat = pattern.lower()
|
||||
return [a for a in all_actions if pat in a["name"].lower()]
|
||||
|
||||
|
||||
def get_drawcalls_only(controller) -> List[Dict[str, Any]]:
|
||||
"""Return only actual draw calls (not markers, clears, etc.)."""
|
||||
all_actions = list_actions(controller, flat=True)
|
||||
return [a for a in all_actions if "Drawcall" in a["flags"]]
|
||||
|
||||
|
||||
def action_summary(controller) -> Dict[str, Any]:
|
||||
"""High-level summary: counts of different action types."""
|
||||
all_actions = list_actions(controller, flat=True)
|
||||
summary = {
|
||||
"total_actions": len(all_actions),
|
||||
"drawcalls": 0,
|
||||
"clears": 0,
|
||||
"dispatches": 0,
|
||||
"copies": 0,
|
||||
"markers": 0,
|
||||
"presents": 0,
|
||||
}
|
||||
for a in all_actions:
|
||||
flags = a["flags"]
|
||||
if "Drawcall" in flags:
|
||||
summary["drawcalls"] += 1
|
||||
if "Clear" in flags:
|
||||
summary["clears"] += 1
|
||||
if "Dispatch" in flags:
|
||||
summary["dispatches"] += 1
|
||||
if "Copy" in flags:
|
||||
summary["copies"] += 1
|
||||
if "PushMarker" in flags or "SetMarker" in flags:
|
||||
summary["markers"] += 1
|
||||
if "Present" in flags:
|
||||
summary["presents"] += 1
|
||||
return summary
|
||||
@@ -0,0 +1,207 @@
|
||||
"""
|
||||
Capture management: open, inspect metadata, list sections, convert captures.
|
||||
|
||||
This module wraps the renderdoc Python API for capture file operations.
|
||||
It works in two modes:
|
||||
1. LIVE mode: when `renderdoc` is importable (RenderDoc installed)
|
||||
2. MOCK mode: when `renderdoc` is NOT importable (unit-test / offline)
|
||||
|
||||
Every public function returns plain Python dicts/lists for JSON serialisation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import renderdoc – gracefully degrade if unavailable
|
||||
# ---------------------------------------------------------------------------
|
||||
try:
|
||||
import renderdoc as rd
|
||||
|
||||
HAS_RD = True
|
||||
except ImportError:
|
||||
rd = None # type: ignore[assignment]
|
||||
HAS_RD = False
|
||||
|
||||
|
||||
def _require_rd():
|
||||
if not HAS_RD:
|
||||
raise RuntimeError(
|
||||
"renderdoc Python module not available. "
|
||||
"Ensure RenderDoc is installed and its Python bindings are on PYTHONPATH."
|
||||
)
|
||||
|
||||
|
||||
def _api_properties_summary(props: Any) -> Dict[str, Any]:
|
||||
"""JSON-friendly subset of CaptureFile.APIProperties / GetAPIProperties."""
|
||||
out: Dict[str, Any] = {"api": str(props.pipelineType)}
|
||||
if hasattr(props, "degraded"):
|
||||
out["degraded"] = bool(props.degraded)
|
||||
driver = None
|
||||
for attr in ("localRenderer", "vendor"):
|
||||
if hasattr(props, attr):
|
||||
val = getattr(props, attr)
|
||||
if val is not None and str(val):
|
||||
driver = str(val)
|
||||
break
|
||||
out["driver"] = driver if driver is not None else str(props.pipelineType)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global RenderDoc replay API (InitialiseReplay / ShutdownReplay) — refcounted
|
||||
# ---------------------------------------------------------------------------
|
||||
_replay_refcount = 0
|
||||
|
||||
|
||||
def _ensure_replay_api():
|
||||
"""Initialise RenderDoc replay once; pair with ``_release_replay_api`` per handle."""
|
||||
global _replay_refcount
|
||||
_require_rd()
|
||||
if _replay_refcount == 0:
|
||||
rd.InitialiseReplay(rd.GlobalEnvironment(), [])
|
||||
_replay_refcount += 1
|
||||
|
||||
|
||||
def _release_replay_api():
|
||||
"""Shut down replay when the last CaptureHandle in the process closes."""
|
||||
global _replay_refcount
|
||||
if not HAS_RD or _replay_refcount <= 0:
|
||||
return
|
||||
_replay_refcount -= 1
|
||||
if _replay_refcount == 0:
|
||||
rd.ShutdownReplay()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Capture file handle wrapper
|
||||
# ---------------------------------------------------------------------------
|
||||
class CaptureHandle:
|
||||
"""Wraps an open renderdoc CaptureFile + optional ReplayController."""
|
||||
|
||||
def __init__(self, path: str):
|
||||
_require_rd()
|
||||
self.path = os.path.abspath(path)
|
||||
if not os.path.isfile(self.path):
|
||||
raise FileNotFoundError(f"Capture file not found: {self.path}")
|
||||
|
||||
_ensure_replay_api()
|
||||
try:
|
||||
self._cap = rd.OpenCaptureFile()
|
||||
result = self._cap.OpenFile(self.path, "", None)
|
||||
if result != rd.ResultCode.Succeeded:
|
||||
raise RuntimeError(f"Failed to open capture: {result}")
|
||||
except Exception:
|
||||
_release_replay_api()
|
||||
raise
|
||||
|
||||
self._controller: Any = None
|
||||
self._closed = False
|
||||
|
||||
# -- lazy replay init ---------------------------------------------------
|
||||
def _ensure_replay(self):
|
||||
if self._controller is not None:
|
||||
return
|
||||
if not self._cap.LocalReplaySupport():
|
||||
raise RuntimeError("Capture cannot be replayed locally")
|
||||
result, ctrl = self._cap.OpenCapture(rd.ReplayOptions(), None)
|
||||
if result != rd.ResultCode.Succeeded:
|
||||
raise RuntimeError(f"Failed to initialise replay: {result}")
|
||||
self._controller = ctrl
|
||||
|
||||
@property
|
||||
def controller(self):
|
||||
self._ensure_replay()
|
||||
return self._controller
|
||||
|
||||
# -- metadata -----------------------------------------------------------
|
||||
def metadata(self) -> Dict[str, Any]:
|
||||
"""Return capture-level metadata."""
|
||||
result: Dict[str, Any] = {"path": self.path}
|
||||
try:
|
||||
props = self._cap.APIProperties()
|
||||
result.update(_api_properties_summary(props))
|
||||
except AttributeError:
|
||||
self._ensure_replay()
|
||||
api_props = self._controller.GetAPIProperties()
|
||||
result.update(_api_properties_summary(api_props))
|
||||
try:
|
||||
result["replay_supported"] = self._cap.LocalReplaySupport()
|
||||
except AttributeError:
|
||||
result["replay_supported"] = self._controller is not None
|
||||
return result
|
||||
|
||||
# -- embedded sections --------------------------------------------------
|
||||
def list_sections(self) -> List[Dict[str, Any]]:
|
||||
"""List embedded sections in the capture."""
|
||||
count = self._cap.GetSectionCount()
|
||||
sections = []
|
||||
for i in range(count):
|
||||
props = self._cap.GetSectionProperties(i)
|
||||
sections.append({
|
||||
"index": i,
|
||||
"name": props.name,
|
||||
"type": str(props.type),
|
||||
"flags": int(props.flags),
|
||||
"uncompressed_size": props.uncompressedSize,
|
||||
"compressed_size": props.compressedSize,
|
||||
})
|
||||
return sections
|
||||
|
||||
# -- thumbnail ----------------------------------------------------------
|
||||
def thumbnail(self, output_path: str, max_dim: int = 0) -> Dict[str, Any]:
|
||||
"""Extract thumbnail from capture to output_path (PNG)."""
|
||||
thumb = self._cap.GetThumbnail(rd.FileType.PNG, max_dim)
|
||||
if thumb.type == rd.FileType.PNG and len(thumb.data) > 0:
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(bytes(thumb.data))
|
||||
return {"path": output_path, "size": len(thumb.data), "format": "PNG"}
|
||||
return {"error": "No thumbnail available"}
|
||||
|
||||
# -- convert capture format ---------------------------------------------
|
||||
def convert(self, output_path: str, export_format: str = "") -> Dict[str, Any]:
|
||||
"""Convert / re-save the capture."""
|
||||
result = self._cap.Convert(
|
||||
output_path, export_format, None, None
|
||||
)
|
||||
if result != rd.ResultCode.Succeeded:
|
||||
return {"error": f"Conversion failed: {result}"}
|
||||
return {"path": output_path, "format": export_format or "rdc"}
|
||||
|
||||
# -- cleanup ------------------------------------------------------------
|
||||
def close(self):
|
||||
if getattr(self, "_closed", False):
|
||||
return
|
||||
self._closed = True
|
||||
if self._controller is not None:
|
||||
self._controller.Shutdown()
|
||||
self._controller = None
|
||||
if self._cap is not None:
|
||||
self._cap.Shutdown()
|
||||
self._cap = None
|
||||
_release_replay_api()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# High-level convenience functions (stateless)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def open_capture(path: str) -> CaptureHandle:
|
||||
"""Open a capture file and return a CaptureHandle."""
|
||||
return CaptureHandle(path)
|
||||
|
||||
|
||||
def capture_info(path: str) -> Dict[str, Any]:
|
||||
"""Return metadata dict for a capture without starting replay."""
|
||||
with CaptureHandle(path) as cap:
|
||||
meta = cap.metadata()
|
||||
meta["sections"] = cap.list_sections()
|
||||
return meta
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
GPU performance counters: enumerate, fetch, and describe counters.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
try:
|
||||
import renderdoc as rd
|
||||
|
||||
HAS_RD = True
|
||||
except ImportError:
|
||||
rd = None # type: ignore[assignment]
|
||||
HAS_RD = False
|
||||
|
||||
|
||||
def list_counters(controller) -> List[Dict[str, Any]]:
|
||||
"""Enumerate all available GPU counters and their descriptions."""
|
||||
counters = controller.EnumerateCounters()
|
||||
result = []
|
||||
for c in counters:
|
||||
desc = controller.DescribeCounter(c)
|
||||
result.append({
|
||||
"counter": int(c),
|
||||
"name": str(desc.name),
|
||||
"description": str(desc.description),
|
||||
"resultByteWidth": desc.resultByteWidth,
|
||||
"resultType": str(desc.resultType),
|
||||
"unit": str(desc.unit),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def fetch_counters(
|
||||
controller,
|
||||
counter_ids: Optional[List[int]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Fetch counter results for specified counters (or SamplesPassed by default).
|
||||
|
||||
Returns per-event counter values.
|
||||
"""
|
||||
available = controller.EnumerateCounters()
|
||||
|
||||
if counter_ids is None:
|
||||
# Default to SamplesPassed if available
|
||||
if rd.GPUCounter.SamplesPassed in available:
|
||||
counter_ids = [int(rd.GPUCounter.SamplesPassed)]
|
||||
else:
|
||||
# Use first available counter
|
||||
if available:
|
||||
counter_ids = [int(available[0])]
|
||||
else:
|
||||
return {"error": "No counters available"}
|
||||
|
||||
valid_ids = [int(c) for c in available]
|
||||
try:
|
||||
rd_counters = [rd.GPUCounter(c) for c in counter_ids]
|
||||
except (ValueError, TypeError) as exc:
|
||||
return {
|
||||
"error": "Invalid counter id(s) %s: %s" % (counter_ids, exc),
|
||||
"valid_counter_ids": valid_ids,
|
||||
}
|
||||
|
||||
results = controller.FetchCounters(rd_counters)
|
||||
|
||||
# Group by counter
|
||||
output: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for r in results:
|
||||
counter_name = str(rd.GPUCounter(r.counter))
|
||||
if counter_name not in output:
|
||||
output[counter_name] = []
|
||||
|
||||
desc = controller.DescribeCounter(r.counter)
|
||||
if desc.resultByteWidth == 4:
|
||||
val = r.value.u32
|
||||
else:
|
||||
val = r.value.u64
|
||||
|
||||
output[counter_name].append({
|
||||
"eventId": r.eventId,
|
||||
"value": val,
|
||||
})
|
||||
|
||||
return {"counters": output, "total_results": len(results)}
|
||||
@@ -0,0 +1,459 @@
|
||||
"""
|
||||
Pipeline diff -- compare two pipeline snapshots and output only differences.
|
||||
|
||||
Usage:
|
||||
from core.diff import diff_pipeline
|
||||
result = diff_pipeline(controller_a, event_a, controller_b, event_b)
|
||||
|
||||
The result dict only contains sections that have at least one difference.
|
||||
Sections that are completely identical are either omitted or marked "SAME".
|
||||
|
||||
The snapshot format matches the output of ``dump_pipeline_for_diff``:
|
||||
|
||||
{
|
||||
"eventId": ...,
|
||||
"PipelineState": {
|
||||
"pipelineType": ...,
|
||||
"vertexInputs": [...],
|
||||
"outputTargets": [...],
|
||||
"depthTarget": {...},
|
||||
"viewport": {...},
|
||||
"rasterizer": {...},
|
||||
"blend": {...},
|
||||
"depthStencil": {...},
|
||||
"stages": {
|
||||
"Vertex": {
|
||||
"shader": "ResourceId::...",
|
||||
"entryPoint": "...",
|
||||
"ShaderReflection": { ... },
|
||||
"bindings": {
|
||||
"constantBlocks": [ { ..., "variables": [...] } ],
|
||||
"readOnlyResources": [...],
|
||||
"readWriteResources": [...],
|
||||
"samplers": [...]
|
||||
}
|
||||
},
|
||||
...
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from cli_anything.renderdoc.core.pipeline import dump_pipeline_for_diff
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FLOAT_TOL = 1e-6
|
||||
|
||||
|
||||
def _floats_equal(a, b) -> bool:
|
||||
"""Compare two floats with tolerance; handle NaN/Inf."""
|
||||
if isinstance(a, float) and isinstance(b, float):
|
||||
if math.isnan(a) and math.isnan(b):
|
||||
return True
|
||||
if math.isinf(a) and math.isinf(b):
|
||||
return a == b
|
||||
return abs(a - b) <= _FLOAT_TOL
|
||||
return a == b
|
||||
|
||||
|
||||
def _values_equal(a, b) -> bool:
|
||||
"""Deep equality check for plain JSON-like values."""
|
||||
if type(a) != type(b):
|
||||
return False
|
||||
if isinstance(a, dict):
|
||||
if set(a.keys()) != set(b.keys()):
|
||||
return False
|
||||
return all(_values_equal(a[k], b[k]) for k in a)
|
||||
if isinstance(a, list):
|
||||
if len(a) != len(b):
|
||||
return False
|
||||
return all(_values_equal(x, y) for x, y in zip(a, b))
|
||||
if isinstance(a, float):
|
||||
return _floats_equal(a, b)
|
||||
return a == b
|
||||
|
||||
|
||||
def _diff_dicts(
|
||||
a: Optional[Dict], b: Optional[Dict], label: str = "",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Compare two flat/nested dicts, return only differing keys.
|
||||
|
||||
Returns None if both are equal (or both None).
|
||||
"""
|
||||
if a is None and b is None:
|
||||
return None
|
||||
if a is None or b is None:
|
||||
return {"A": a, "B": b}
|
||||
|
||||
diffs: Dict[str, Any] = {}
|
||||
all_keys = sorted(set(list(a.keys()) + list(b.keys())))
|
||||
for k in all_keys:
|
||||
va = a.get(k)
|
||||
vb = b.get(k)
|
||||
if not _values_equal(va, vb):
|
||||
diffs[k] = {"A": va, "B": vb}
|
||||
|
||||
return diffs if diffs else None
|
||||
|
||||
|
||||
def _diff_lists(
|
||||
a: Optional[List], b: Optional[List], key_field: str = "name",
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Compare two lists of dicts by a key field, return only diffs.
|
||||
|
||||
Items present in A but not B get status "only_in_A", vice versa.
|
||||
Items present in both get per-field diff.
|
||||
Returns None if identical.
|
||||
"""
|
||||
if a is None and b is None:
|
||||
return None
|
||||
a = a or []
|
||||
b = b or []
|
||||
|
||||
a_map = {str(item.get(key_field, i)): item for i, item in enumerate(a)}
|
||||
b_map = {str(item.get(key_field, i)): item for i, item in enumerate(b)}
|
||||
all_keys = sorted(set(list(a_map.keys()) + list(b_map.keys())))
|
||||
|
||||
diffs = []
|
||||
for k in all_keys:
|
||||
va = a_map.get(k)
|
||||
vb = b_map.get(k)
|
||||
if va is None:
|
||||
diffs.append({"key": k, "status": "only_in_B", "B": vb})
|
||||
elif vb is None:
|
||||
diffs.append({"key": k, "status": "only_in_A", "A": va})
|
||||
else:
|
||||
d = _diff_dicts(va, vb)
|
||||
if d:
|
||||
diffs.append({"key": k, "status": "changed", "fields": d})
|
||||
|
||||
return diffs if diffs else None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CBuffer variable diff (recursive, handles struct members)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _diff_cbuffer_vars(
|
||||
vars_a: List[Dict], vars_b: List[Dict],
|
||||
) -> Optional[List[Dict]]:
|
||||
"""Compare two lists of cbuffer variables; return only diffs."""
|
||||
a_map = {v["name"]: v for v in vars_a}
|
||||
b_map = {v["name"]: v for v in vars_b}
|
||||
all_names = sorted(set(list(a_map.keys()) + list(b_map.keys())))
|
||||
|
||||
diffs = []
|
||||
for name in all_names:
|
||||
va = a_map.get(name)
|
||||
vb = b_map.get(name)
|
||||
if va is None:
|
||||
diffs.append({"name": name, "status": "only_in_B", "B": vb})
|
||||
elif vb is None:
|
||||
diffs.append({"name": name, "status": "only_in_A", "A": va})
|
||||
else:
|
||||
if "members" in va or "members" in vb:
|
||||
sub = _diff_cbuffer_vars(
|
||||
va.get("members", []),
|
||||
vb.get("members", []),
|
||||
)
|
||||
if sub:
|
||||
diffs.append({"name": name, "status": "changed", "members": sub})
|
||||
else:
|
||||
vals_a = va.get("values", [])
|
||||
vals_b = vb.get("values", [])
|
||||
if not _values_equal(vals_a, vals_b):
|
||||
diffs.append({
|
||||
"name": name,
|
||||
"status": "changed",
|
||||
"A": vals_a,
|
||||
"B": vals_b,
|
||||
})
|
||||
|
||||
return diffs if diffs else None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage-level diff (new structure)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _diff_bindings(bindings_a: Dict, bindings_b: Dict) -> Optional[Dict[str, Any]]:
|
||||
"""Diff the bindings sub-dict of a stage.
|
||||
|
||||
Handles: constantBlocks (with nested variable values),
|
||||
readOnlyResources, readWriteResources, samplers.
|
||||
"""
|
||||
result: Dict[str, Any] = {}
|
||||
has_diff = False
|
||||
|
||||
# --- constantBlocks ---
|
||||
cbs_a = bindings_a.get("constantBlocks", [])
|
||||
cbs_b = bindings_b.get("constantBlocks", [])
|
||||
|
||||
# Build maps keyed by index
|
||||
a_map = {cb.get("index", i): cb for i, cb in enumerate(cbs_a)}
|
||||
b_map = {cb.get("index", i): cb for i, cb in enumerate(cbs_b)}
|
||||
all_indices = sorted(set(list(a_map.keys()) + list(b_map.keys())))
|
||||
|
||||
cb_diffs = []
|
||||
var_diffs_all = []
|
||||
for idx in all_indices:
|
||||
ca = a_map.get(idx)
|
||||
cb_ = b_map.get(idx)
|
||||
if ca is None:
|
||||
cb_diffs.append({"index": idx, "status": "only_in_B", "B": cb_})
|
||||
elif cb_ is None:
|
||||
cb_diffs.append({"index": idx, "status": "only_in_A", "A": ca})
|
||||
else:
|
||||
# Compare binding metadata (resource, byteOffset, byteSize)
|
||||
ca_meta = {k: v for k, v in ca.items() if k not in ("variables",)}
|
||||
cb_meta = {k: v for k, v in cb_.items() if k not in ("variables",)}
|
||||
meta_diff = _diff_dicts(ca_meta, cb_meta)
|
||||
if meta_diff:
|
||||
cb_diffs.append({"index": idx, "status": "changed", "fields": meta_diff})
|
||||
|
||||
# Compare runtime variable values
|
||||
va_vars = ca.get("variables", [])
|
||||
vb_vars = cb_.get("variables", [])
|
||||
vdiff = _diff_cbuffer_vars(va_vars, vb_vars)
|
||||
if vdiff:
|
||||
var_diffs_all.append({"index": idx, "variables": vdiff})
|
||||
|
||||
cb_result: Dict[str, Any] = {}
|
||||
if cb_diffs:
|
||||
cb_result["metadata"] = cb_diffs
|
||||
if var_diffs_all:
|
||||
cb_result["variables"] = var_diffs_all
|
||||
|
||||
if cb_result:
|
||||
result["constantBlocks"] = cb_result
|
||||
has_diff = True
|
||||
else:
|
||||
result["constantBlocks"] = "SAME"
|
||||
|
||||
# --- readOnlyResources, readWriteResources, samplers ---
|
||||
for section in ("readOnlyResources", "readWriteResources", "samplers"):
|
||||
d = _diff_lists(
|
||||
bindings_a.get(section, []),
|
||||
bindings_b.get(section, []),
|
||||
key_field="index",
|
||||
)
|
||||
if d:
|
||||
result[section] = d
|
||||
has_diff = True
|
||||
else:
|
||||
result[section] = "SAME"
|
||||
|
||||
return result if has_diff else None
|
||||
|
||||
|
||||
def _diff_stages(stages_a: Dict, stages_b: Dict) -> Optional[Dict[str, Any]]:
|
||||
"""Diff the stages sub-dict of PipelineState.
|
||||
|
||||
For each stage present in either snapshot, compare:
|
||||
- shader / entryPoint (as a dict)
|
||||
- ShaderReflection (deep dict diff)
|
||||
- bindings (structured diff with variable values)
|
||||
"""
|
||||
all_names = sorted(set(list(stages_a.keys()) + list(stages_b.keys())))
|
||||
result: Dict[str, Any] = {}
|
||||
has_diff = False
|
||||
|
||||
for name in all_names:
|
||||
sa = stages_a.get(name)
|
||||
sb = stages_b.get(name)
|
||||
|
||||
if sa is None:
|
||||
result[name] = {"status": "only_in_B", "B": sb}
|
||||
has_diff = True
|
||||
continue
|
||||
if sb is None:
|
||||
result[name] = {"status": "only_in_A", "A": sa}
|
||||
has_diff = True
|
||||
continue
|
||||
|
||||
stage_result: Dict[str, Any] = {}
|
||||
stage_has_diff = False
|
||||
|
||||
# shader + entryPoint
|
||||
shader_dict_a = {"shader": sa.get("shader"), "entryPoint": sa.get("entryPoint")}
|
||||
shader_dict_b = {"shader": sb.get("shader"), "entryPoint": sb.get("entryPoint")}
|
||||
shader_diff = _diff_dicts(shader_dict_a, shader_dict_b)
|
||||
if shader_diff:
|
||||
stage_result["shader"] = shader_diff
|
||||
stage_has_diff = True
|
||||
else:
|
||||
stage_result["shader"] = "SAME"
|
||||
|
||||
# ShaderReflection
|
||||
refl_diff = _diff_dicts(
|
||||
sa.get("ShaderReflection"),
|
||||
sb.get("ShaderReflection"),
|
||||
)
|
||||
if refl_diff:
|
||||
stage_result["ShaderReflection"] = refl_diff
|
||||
stage_has_diff = True
|
||||
else:
|
||||
stage_result["ShaderReflection"] = "SAME"
|
||||
|
||||
# bindings
|
||||
bindings_diff = _diff_bindings(
|
||||
sa.get("bindings", {}),
|
||||
sb.get("bindings", {}),
|
||||
)
|
||||
if bindings_diff:
|
||||
stage_result["bindings"] = bindings_diff
|
||||
stage_has_diff = True
|
||||
else:
|
||||
stage_result["bindings"] = "SAME"
|
||||
|
||||
if stage_has_diff:
|
||||
result[name] = stage_result
|
||||
has_diff = True
|
||||
else:
|
||||
result[name] = "SAME"
|
||||
|
||||
return result if has_diff else None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core diff from two snapshot dicts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _diff_from_snapshots(snap_a: Dict[str, Any], snap_b: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Shared implementation: diff two dump_pipeline_for_diff snapshots."""
|
||||
ps_a = snap_a.get("PipelineState", {})
|
||||
ps_b = snap_b.get("PipelineState", {})
|
||||
|
||||
result: Dict[str, Any] = {
|
||||
"eventA": snap_a.get("eventId"),
|
||||
"eventB": snap_b.get("eventId"),
|
||||
}
|
||||
|
||||
has_diff = False
|
||||
|
||||
# pipelineType
|
||||
pt_a = ps_a.get("pipelineType")
|
||||
pt_b = ps_b.get("pipelineType")
|
||||
if pt_a != pt_b:
|
||||
result["pipelineType"] = {"A": pt_a, "B": pt_b}
|
||||
has_diff = True
|
||||
else:
|
||||
result["pipelineType"] = pt_a
|
||||
|
||||
# Simple sections: vertexInputs, outputTargets, depthTarget, viewport,
|
||||
# rasterizer, depthStencil
|
||||
for section, key_field in [
|
||||
("vertexInputs", "name"),
|
||||
("outputTargets", "index"),
|
||||
]:
|
||||
d = _diff_lists(
|
||||
ps_a.get(section, []),
|
||||
ps_b.get(section, []),
|
||||
key_field=key_field,
|
||||
)
|
||||
if d:
|
||||
result[section] = d
|
||||
has_diff = True
|
||||
else:
|
||||
result[section] = "SAME"
|
||||
|
||||
for section in ("depthTarget", "viewport", "rasterizer", "depthStencil"):
|
||||
d = _diff_dicts(ps_a.get(section), ps_b.get(section))
|
||||
if d:
|
||||
result[section] = d
|
||||
has_diff = True
|
||||
else:
|
||||
result[section] = "SAME"
|
||||
|
||||
# blend — nested: top-level dict keys + blends list
|
||||
blend_a = ps_a.get("blend")
|
||||
blend_b = ps_b.get("blend")
|
||||
if blend_a is None and blend_b is None:
|
||||
result["blend"] = "SAME"
|
||||
elif blend_a is None or blend_b is None:
|
||||
result["blend"] = {"A": blend_a, "B": blend_b}
|
||||
has_diff = True
|
||||
else:
|
||||
blend_diff: Dict[str, Any] = {}
|
||||
blend_has_diff = False
|
||||
# Top-level scalar keys
|
||||
for k in sorted(set(list(blend_a.keys()) + list(blend_b.keys()))):
|
||||
if k == "blends":
|
||||
continue
|
||||
va = blend_a.get(k)
|
||||
vb = blend_b.get(k)
|
||||
if not _values_equal(va, vb):
|
||||
blend_diff[k] = {"A": va, "B": vb}
|
||||
blend_has_diff = True
|
||||
# blends list
|
||||
blends_diff = _diff_lists(
|
||||
blend_a.get("blends", []),
|
||||
blend_b.get("blends", []),
|
||||
key_field="index",
|
||||
)
|
||||
if blends_diff:
|
||||
blend_diff["blends"] = blends_diff
|
||||
blend_has_diff = True
|
||||
if blend_has_diff:
|
||||
result["blend"] = blend_diff
|
||||
has_diff = True
|
||||
else:
|
||||
result["blend"] = "SAME"
|
||||
|
||||
# stages
|
||||
stages_diff = _diff_stages(
|
||||
ps_a.get("stages", {}),
|
||||
ps_b.get("stages", {}),
|
||||
)
|
||||
if stages_diff:
|
||||
result["stages"] = stages_diff
|
||||
has_diff = True
|
||||
else:
|
||||
result["stages"] = "SAME"
|
||||
|
||||
result["identical"] = not has_diff
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def diff_pipeline(
|
||||
controller_a,
|
||||
event_a: int,
|
||||
controller_b,
|
||||
event_b: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""Compare full pipeline state at two events (possibly from different captures).
|
||||
|
||||
Returns a dict containing only the dimensions that differ.
|
||||
Each section is either omitted (identical) or marked "SAME".
|
||||
"""
|
||||
snap_a = dump_pipeline_for_diff(controller_a, event_a)
|
||||
snap_b = dump_pipeline_for_diff(controller_b, event_b)
|
||||
return _diff_from_snapshots(snap_a, snap_b)
|
||||
|
||||
|
||||
def diff_pipeline_from_snapshots(
|
||||
snap_a: Dict[str, Any],
|
||||
snap_b: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Compare two pre-built pipeline snapshots (for testing or offline use).
|
||||
|
||||
Expects snapshots in the ``dump_pipeline_for_diff`` format::
|
||||
|
||||
{"eventId": ..., "PipelineState": { ... }}
|
||||
|
||||
Same logic as diff_pipeline but without needing live controllers.
|
||||
"""
|
||||
return _diff_from_snapshots(snap_a, snap_b)
|
||||
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
Mesh data decoding: vertex inputs, post-VS outputs, index buffers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct as _struct
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import click
|
||||
|
||||
try:
|
||||
import renderdoc as rd
|
||||
|
||||
HAS_RD = True
|
||||
except ImportError:
|
||||
rd = None # type: ignore[assignment]
|
||||
HAS_RD = False
|
||||
|
||||
|
||||
def _unpack_data(fmt, data):
|
||||
"""Unpack vertex data according to resource format."""
|
||||
if fmt.Special():
|
||||
return None # packed formats not supported
|
||||
|
||||
format_chars = {}
|
||||
format_chars[rd.CompType.UInt] = "xBHxIxxxL"
|
||||
format_chars[rd.CompType.SInt] = "xbhxixxxl"
|
||||
format_chars[rd.CompType.Float] = "xxexfxxxd"
|
||||
format_chars[rd.CompType.UNorm] = format_chars[rd.CompType.UInt]
|
||||
format_chars[rd.CompType.UScaled] = format_chars[rd.CompType.UInt]
|
||||
format_chars[rd.CompType.SNorm] = format_chars[rd.CompType.SInt]
|
||||
format_chars[rd.CompType.SScaled] = format_chars[rd.CompType.SInt]
|
||||
|
||||
vertex_format = str(fmt.compCount) + format_chars[fmt.compType][fmt.compByteWidth]
|
||||
value = _struct.unpack_from(vertex_format, data, 0)
|
||||
|
||||
if fmt.compType == rd.CompType.UNorm:
|
||||
divisor = float((2 ** (fmt.compByteWidth * 8)) - 1)
|
||||
value = tuple(float(i) / divisor for i in value)
|
||||
elif fmt.compType == rd.CompType.SNorm:
|
||||
max_neg = -float(2 ** (fmt.compByteWidth * 8)) / 2
|
||||
divisor = float(-(max_neg - 1))
|
||||
value = tuple(
|
||||
(float(i) if (i == max_neg) else (float(i) / divisor)) for i in value
|
||||
)
|
||||
|
||||
if fmt.BGRAOrder():
|
||||
value = tuple(value[i] for i in [2, 1, 0, 3])
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def get_mesh_inputs(controller, event_id: int, max_vertices: int = 100) -> Dict[str, Any]:
|
||||
"""Get vertex shader input data at a draw call.
|
||||
|
||||
Returns decoded vertex data for up to *max_vertices* vertices.
|
||||
"""
|
||||
controller.SetFrameEvent(event_id, True)
|
||||
state = controller.GetPipelineState()
|
||||
|
||||
ib = state.GetIBuffer()
|
||||
vbs = state.GetVBuffers()
|
||||
attrs = state.GetVertexInputs()
|
||||
|
||||
# get draw info
|
||||
action = None
|
||||
for a in _flatten(controller.GetRootActions()):
|
||||
if a.eventId == event_id:
|
||||
action = a
|
||||
break
|
||||
if action is None:
|
||||
return {"error": f"No action at event {event_id}"}
|
||||
|
||||
# Decode indices
|
||||
indices = _get_indices(controller, ib, action)
|
||||
num = min(len(indices), max_vertices)
|
||||
|
||||
attributes = []
|
||||
for attr in attrs:
|
||||
attr_data = {
|
||||
"name": str(attr.name),
|
||||
"format": str(attr.format),
|
||||
"vertices": [],
|
||||
}
|
||||
if attr.perInstance:
|
||||
attr_data["perInstance"] = True
|
||||
attributes.append(attr_data)
|
||||
continue
|
||||
|
||||
vb = vbs[attr.vertexBuffer]
|
||||
for i in range(num):
|
||||
idx = indices[i]
|
||||
offset = (
|
||||
attr.byteOffset
|
||||
+ vb.byteOffset
|
||||
+ (idx + action.vertexOffset) * vb.byteStride
|
||||
)
|
||||
data = controller.GetBufferData(vb.resourceId, offset, 64)
|
||||
try:
|
||||
val = _unpack_data(attr.format, bytes(data))
|
||||
attr_data["vertices"].append({"index": idx, "value": list(val) if val else None})
|
||||
except Exception as e:
|
||||
attr_data["vertices"].append({"index": idx, "error": str(e)})
|
||||
|
||||
attributes.append(attr_data)
|
||||
|
||||
return {
|
||||
"eventId": event_id,
|
||||
"numIndices": action.numIndices,
|
||||
"decoded_count": num,
|
||||
"attributes": attributes,
|
||||
}
|
||||
|
||||
|
||||
def get_mesh_outputs(controller, event_id: int, max_vertices: int = 100) -> Dict[str, Any]:
|
||||
"""Get post-vertex-shader output data at a draw call."""
|
||||
controller.SetFrameEvent(event_id, True)
|
||||
|
||||
postvs = controller.GetPostVSData(0, 0, rd.MeshDataStage.VSOut)
|
||||
if postvs.vertexResourceId == rd.ResourceId.Null():
|
||||
return {"eventId": event_id, "error": "No post-VS data available"}
|
||||
|
||||
vs = controller.GetPipelineState().GetShaderReflection(rd.ShaderStage.Vertex)
|
||||
if vs is None:
|
||||
return {"eventId": event_id, "error": "No vertex shader bound"}
|
||||
|
||||
outputs = []
|
||||
for attr in vs.outputSignature:
|
||||
outputs.append({
|
||||
"name": attr.semanticIdxName if attr.varName == "" else str(attr.varName),
|
||||
"compCount": attr.compCount,
|
||||
"systemValue": str(attr.systemValue),
|
||||
})
|
||||
|
||||
return {
|
||||
"eventId": event_id,
|
||||
"numIndices": postvs.numIndices,
|
||||
"outputs": outputs,
|
||||
}
|
||||
|
||||
|
||||
def _flatten(actions, out=None):
|
||||
if out is None:
|
||||
out = []
|
||||
for a in actions:
|
||||
out.append(a)
|
||||
if len(a.children) > 0:
|
||||
_flatten(a.children, out)
|
||||
return out
|
||||
|
||||
|
||||
def _get_indices(controller, ib, action):
|
||||
"""Decode index buffer."""
|
||||
if action.flags & rd.ActionFlags.Indexed and ib.resourceId != rd.ResourceId.Null():
|
||||
if action.numIndices <= 0:
|
||||
return []
|
||||
|
||||
idx_fmt = "B"
|
||||
if ib.byteStride == 2:
|
||||
idx_fmt = "H"
|
||||
elif ib.byteStride == 4:
|
||||
idx_fmt = "I"
|
||||
|
||||
start = ib.byteOffset + action.indexOffset * ib.byteStride
|
||||
length = action.numIndices * ib.byteStride
|
||||
ibdata = controller.GetBufferData(ib.resourceId, start, length)
|
||||
fmt_str = str(action.numIndices) + idx_fmt
|
||||
try:
|
||||
indices = _struct.unpack(fmt_str, bytes(ibdata))
|
||||
return [i + action.baseVertex for i in indices]
|
||||
except Exception as e:
|
||||
click.echo(f"Warning: failed to unpack indices: {e}", err=True)
|
||||
return list(range(action.numIndices))
|
||||
else:
|
||||
return list(range(action.numIndices))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,516 @@
|
||||
"""Preview bundle generation for the RenderDoc harness."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..utils.preview_bundle import (
|
||||
append_live_trajectory,
|
||||
artifact_record,
|
||||
bundle_root,
|
||||
finalize_bundle,
|
||||
find_latest_manifest,
|
||||
fingerprint_data,
|
||||
fingerprint_file,
|
||||
prepare_bundle,
|
||||
write_json,
|
||||
)
|
||||
from . import actions as actions_mod
|
||||
from . import diff as diff_mod
|
||||
from . import pipeline as pipeline_mod
|
||||
from . import textures as textures_mod
|
||||
|
||||
HARNESS_VERSION = "0.1.0"
|
||||
|
||||
RECIPES: Dict[str, Dict[str, Any]] = {
|
||||
"quick": {
|
||||
"description": "Capture thumbnail, output targets, and pipeline snapshot",
|
||||
"max_thumb_dim": 768,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def list_recipes() -> List[Dict[str, Any]]:
|
||||
"""Return available preview recipes."""
|
||||
recipes = [
|
||||
{
|
||||
"name": name,
|
||||
"description": config["description"],
|
||||
"bundle_kind": "capture",
|
||||
"artifacts": ["hero", "gallery", "metadata"],
|
||||
}
|
||||
for name, config in RECIPES.items()
|
||||
]
|
||||
recipes.append(
|
||||
{
|
||||
"name": "diff",
|
||||
"description": "Compare two pipeline states and their output targets",
|
||||
"bundle_kind": "diff",
|
||||
"artifacts": ["gallery", "diff", "metadata"],
|
||||
}
|
||||
)
|
||||
return recipes
|
||||
|
||||
|
||||
def _default_event_id(handle) -> Optional[int]:
|
||||
drawcalls = actions_mod.get_drawcalls_only(handle.controller)
|
||||
if not drawcalls:
|
||||
return None
|
||||
return int(drawcalls[-1]["eventId"])
|
||||
|
||||
|
||||
def _compact_diff(obj: Any) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
pruned = {}
|
||||
for key, value in obj.items():
|
||||
if value == "SAME":
|
||||
continue
|
||||
compacted = _compact_diff(value)
|
||||
if compacted is not None:
|
||||
pruned[key] = compacted
|
||||
return pruned or None
|
||||
if isinstance(obj, list):
|
||||
values = [_compact_diff(item) for item in obj if item != "SAME"]
|
||||
values = [item for item in values if item is not None]
|
||||
return values or None
|
||||
return obj
|
||||
|
||||
|
||||
def _count_differences(obj: Any) -> int:
|
||||
if isinstance(obj, dict):
|
||||
return sum(_count_differences(value) for value in obj.values()) or len(obj)
|
||||
if isinstance(obj, list):
|
||||
return sum(_count_differences(item) for item in obj) or len(obj)
|
||||
return 1
|
||||
|
||||
|
||||
def _trajectory_dir(capture_path: str, recipe: str, root_dir: Optional[str] = None) -> str:
|
||||
return str(
|
||||
bundle_root(
|
||||
"renderdoc",
|
||||
recipe,
|
||||
project_path=capture_path,
|
||||
root_dir=root_dir,
|
||||
).resolve()
|
||||
)
|
||||
|
||||
|
||||
def _attach_trajectory_ref(manifest: Dict[str, Any]) -> Dict[str, Any]:
|
||||
bundle_dir = manifest.get("_bundle_dir")
|
||||
context = manifest.get("context") or {}
|
||||
trajectory_ref = context.get("trajectory_path")
|
||||
if bundle_dir and trajectory_ref:
|
||||
trajectory_path = (Path(str(bundle_dir)) / str(trajectory_ref)).resolve()
|
||||
if trajectory_path.is_file():
|
||||
manifest["_trajectory_path"] = str(trajectory_path)
|
||||
return manifest
|
||||
|
||||
|
||||
def capture(
|
||||
handle,
|
||||
capture_path: str,
|
||||
recipe: str = "quick",
|
||||
*,
|
||||
event_id: Optional[int] = None,
|
||||
root_dir: Optional[str] = None,
|
||||
force: bool = False,
|
||||
command: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a capture preview bundle for a RenderDoc capture."""
|
||||
if recipe not in RECIPES:
|
||||
raise ValueError(
|
||||
f"Unknown preview recipe: {recipe!r}. Available: {', '.join(sorted(RECIPES))}"
|
||||
)
|
||||
|
||||
config = RECIPES[recipe]
|
||||
source_fingerprint = fingerprint_file(capture_path)
|
||||
prepared = prepare_bundle(
|
||||
software="renderdoc",
|
||||
recipe=recipe,
|
||||
bundle_kind="capture",
|
||||
source_fingerprint=source_fingerprint,
|
||||
options={"event_id": event_id or "auto", **config},
|
||||
harness_version=HARNESS_VERSION,
|
||||
project_path=capture_path,
|
||||
root_dir=root_dir,
|
||||
force=force,
|
||||
)
|
||||
if prepared["cached"]:
|
||||
manifest = dict(prepared["manifest"])
|
||||
manifest["cached"] = True
|
||||
return _attach_trajectory_ref(manifest)
|
||||
|
||||
bundle_dir = prepared["bundle_dir"]
|
||||
artifacts_dir = prepared["artifacts_dir"]
|
||||
trajectory_dir = _trajectory_dir(capture_path, recipe, root_dir=root_dir)
|
||||
trajectory_rel = os.path.relpath(Path(trajectory_dir) / "trajectory.json", Path(bundle_dir))
|
||||
warnings: List[str] = []
|
||||
artifacts: List[Dict[str, Any]] = []
|
||||
metadata = handle.metadata()
|
||||
action_summary = actions_mod.action_summary(handle.controller)
|
||||
|
||||
hero_path = os.path.join(artifacts_dir, "hero.png")
|
||||
thumb_result = handle.thumbnail(hero_path, config["max_thumb_dim"])
|
||||
if thumb_result.get("error"):
|
||||
warnings.append(str(thumb_result["error"]))
|
||||
elif os.path.isfile(hero_path):
|
||||
artifacts.append(
|
||||
artifact_record(
|
||||
bundle_dir,
|
||||
hero_path,
|
||||
artifact_id="hero",
|
||||
role="hero",
|
||||
kind="image",
|
||||
label="Capture thumbnail",
|
||||
renderdoc_format=thumb_result.get("format"),
|
||||
)
|
||||
)
|
||||
|
||||
chosen_event = event_id or _default_event_id(handle)
|
||||
output_count = 0
|
||||
if chosen_event is not None:
|
||||
outputs_dir = os.path.join(artifacts_dir, "outputs")
|
||||
output_results = textures_mod.save_action_outputs(handle.controller, chosen_event, outputs_dir, file_format="png")
|
||||
for index, item in enumerate(output_results):
|
||||
if item.get("error") or not item.get("path") or not os.path.isfile(item["path"]):
|
||||
warnings.append(item.get("error", f"missing output target {index}"))
|
||||
continue
|
||||
output_count += 1
|
||||
artifacts.append(
|
||||
artifact_record(
|
||||
bundle_dir,
|
||||
item["path"],
|
||||
artifact_id=f"output_{index:02d}",
|
||||
role="gallery",
|
||||
kind="image",
|
||||
label=item.get("label", f"Output {index}"),
|
||||
)
|
||||
)
|
||||
|
||||
pipeline_state = pipeline_mod.get_pipeline_state(handle.controller, chosen_event)
|
||||
pipeline_path = os.path.join(artifacts_dir, "pipeline_state.json")
|
||||
write_json(pipeline_path, pipeline_state)
|
||||
artifacts.append(
|
||||
artifact_record(
|
||||
bundle_dir,
|
||||
pipeline_path,
|
||||
artifact_id="pipeline_state",
|
||||
role="metadata",
|
||||
kind="json",
|
||||
label=f"Pipeline state at event {chosen_event}",
|
||||
media_type="application/json",
|
||||
)
|
||||
)
|
||||
else:
|
||||
warnings.append("No drawcall event found; skipped output-target and pipeline capture.")
|
||||
|
||||
summary_path = os.path.join(artifacts_dir, "action_summary.json")
|
||||
write_json(summary_path, action_summary)
|
||||
artifacts.append(
|
||||
artifact_record(
|
||||
bundle_dir,
|
||||
summary_path,
|
||||
artifact_id="action_summary",
|
||||
role="metadata",
|
||||
kind="json",
|
||||
label="Action summary",
|
||||
media_type="application/json",
|
||||
)
|
||||
)
|
||||
|
||||
summary = {
|
||||
"headline": f"RenderDoc preview captured from {os.path.basename(capture_path)}",
|
||||
"facts": {
|
||||
"recipe": recipe,
|
||||
"api": metadata.get("api"),
|
||||
"event_id": chosen_event,
|
||||
"drawcalls": action_summary.get("drawcalls", 0),
|
||||
"output_targets": output_count,
|
||||
},
|
||||
"warnings": warnings,
|
||||
"next_actions": [
|
||||
"Inspect the hero thumbnail for a quick capture-level sanity check.",
|
||||
"Inspect output targets and pipeline_state.json for the selected event.",
|
||||
],
|
||||
}
|
||||
|
||||
manifest = finalize_bundle(
|
||||
bundle_dir=bundle_dir,
|
||||
bundle_id=prepared["bundle_id"],
|
||||
bundle_kind="capture",
|
||||
software="renderdoc",
|
||||
recipe=recipe,
|
||||
source={
|
||||
"capture_path": os.path.abspath(capture_path),
|
||||
"capture_name": os.path.basename(capture_path),
|
||||
"capture_fingerprint": source_fingerprint,
|
||||
},
|
||||
artifacts=artifacts,
|
||||
summary=summary,
|
||||
cache_key=prepared["cache_key"],
|
||||
generator={
|
||||
"entry_point": "cli-anything-renderdoc",
|
||||
"harness_version": HARNESS_VERSION,
|
||||
"command": command or f"cli-anything-renderdoc -c {capture_path} preview capture --recipe {recipe}",
|
||||
},
|
||||
status="partial" if warnings else "ok",
|
||||
warnings=warnings or None,
|
||||
context={"event_id": chosen_event, "trajectory_path": trajectory_rel},
|
||||
metrics={
|
||||
"drawcalls": action_summary.get("drawcalls", 0),
|
||||
"output_targets": output_count,
|
||||
},
|
||||
labels=["gpu", "capture", "preview"],
|
||||
)
|
||||
trajectory = append_live_trajectory(
|
||||
trajectory_dir,
|
||||
software="renderdoc",
|
||||
recipe=recipe,
|
||||
bundle_manifest=manifest,
|
||||
publish_reason="capture",
|
||||
project_path=os.path.abspath(capture_path),
|
||||
project_name=os.path.basename(capture_path),
|
||||
session_name=f"{os.path.splitext(os.path.basename(capture_path))[0]}-{recipe}",
|
||||
command=command,
|
||||
stage_label=f"event-{chosen_event}" if chosen_event is not None else "capture",
|
||||
note=f"RenderDoc capture preview for event {chosen_event}" if chosen_event is not None else None,
|
||||
)
|
||||
manifest["_trajectory_path"] = trajectory["_trajectory_path"]
|
||||
manifest["cached"] = False
|
||||
return manifest
|
||||
|
||||
|
||||
def diff(
|
||||
handle_a,
|
||||
capture_path_a: str,
|
||||
event_a: int,
|
||||
handle_b,
|
||||
capture_path_b: str,
|
||||
event_b: int,
|
||||
*,
|
||||
compact: bool = True,
|
||||
root_dir: Optional[str] = None,
|
||||
force: bool = False,
|
||||
command: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a diff preview bundle for two RenderDoc events."""
|
||||
source_fingerprint = fingerprint_data(
|
||||
{
|
||||
"capture_a": fingerprint_file(capture_path_a),
|
||||
"event_a": event_a,
|
||||
"capture_b": fingerprint_file(capture_path_b),
|
||||
"event_b": event_b,
|
||||
}
|
||||
)
|
||||
prepared = prepare_bundle(
|
||||
software="renderdoc",
|
||||
recipe="diff",
|
||||
bundle_kind="diff",
|
||||
source_fingerprint=source_fingerprint,
|
||||
options={"compact": compact, "event_a": event_a, "event_b": event_b},
|
||||
harness_version=HARNESS_VERSION,
|
||||
project_path=capture_path_a,
|
||||
root_dir=root_dir,
|
||||
force=force,
|
||||
)
|
||||
if prepared["cached"]:
|
||||
manifest = dict(prepared["manifest"])
|
||||
manifest["cached"] = True
|
||||
return _attach_trajectory_ref(manifest)
|
||||
|
||||
bundle_dir = prepared["bundle_dir"]
|
||||
artifacts_dir = prepared["artifacts_dir"]
|
||||
trajectory_dir = _trajectory_dir(capture_path_a, "diff", root_dir=root_dir)
|
||||
trajectory_rel = os.path.relpath(Path(trajectory_dir) / "trajectory.json", Path(bundle_dir))
|
||||
warnings: List[str] = []
|
||||
artifacts: List[Dict[str, Any]] = []
|
||||
|
||||
thumb_a = os.path.join(artifacts_dir, "capture_a_thumb.png")
|
||||
thumb_a_result = handle_a.thumbnail(thumb_a, 512)
|
||||
if not thumb_a_result.get("error") and os.path.isfile(thumb_a):
|
||||
artifacts.append(
|
||||
artifact_record(
|
||||
bundle_dir,
|
||||
thumb_a,
|
||||
artifact_id="capture_a_thumb",
|
||||
role="gallery",
|
||||
kind="image",
|
||||
label=f"{os.path.basename(capture_path_a)} thumbnail",
|
||||
)
|
||||
)
|
||||
|
||||
thumb_b = os.path.join(artifacts_dir, "capture_b_thumb.png")
|
||||
thumb_b_result = handle_b.thumbnail(thumb_b, 512)
|
||||
if not thumb_b_result.get("error") and os.path.isfile(thumb_b):
|
||||
artifacts.append(
|
||||
artifact_record(
|
||||
bundle_dir,
|
||||
thumb_b,
|
||||
artifact_id="capture_b_thumb",
|
||||
role="gallery",
|
||||
kind="image",
|
||||
label=f"{os.path.basename(capture_path_b)} thumbnail",
|
||||
)
|
||||
)
|
||||
|
||||
outputs_a = textures_mod.save_action_outputs(
|
||||
handle_a.controller,
|
||||
event_a,
|
||||
os.path.join(artifacts_dir, "outputs_a"),
|
||||
file_format="png",
|
||||
)
|
||||
outputs_b = textures_mod.save_action_outputs(
|
||||
handle_b.controller,
|
||||
event_b,
|
||||
os.path.join(artifacts_dir, "outputs_b"),
|
||||
file_format="png",
|
||||
)
|
||||
for side, output_results in (("A", outputs_a), ("B", outputs_b)):
|
||||
for index, item in enumerate(output_results):
|
||||
if item.get("error") or not item.get("path") or not os.path.isfile(item["path"]):
|
||||
warnings.append(item.get("error", f"missing output target {side}{index}"))
|
||||
continue
|
||||
artifacts.append(
|
||||
artifact_record(
|
||||
bundle_dir,
|
||||
item["path"],
|
||||
artifact_id=f"{side.lower()}_output_{index:02d}",
|
||||
role="gallery",
|
||||
kind="image",
|
||||
label=f"{side} {item.get('label', f'Output {index}')}",
|
||||
)
|
||||
)
|
||||
|
||||
diff_data = diff_mod.diff_pipeline(handle_a.controller, event_a, handle_b.controller, event_b)
|
||||
if compact:
|
||||
diff_data = _compact_diff(diff_data) or {}
|
||||
|
||||
diff_path = os.path.join(artifacts_dir, "pipeline_diff.json")
|
||||
write_json(diff_path, diff_data)
|
||||
artifacts.append(
|
||||
artifact_record(
|
||||
bundle_dir,
|
||||
diff_path,
|
||||
artifact_id="pipeline_diff",
|
||||
role="diff",
|
||||
kind="json",
|
||||
label="Pipeline diff",
|
||||
media_type="application/json",
|
||||
)
|
||||
)
|
||||
|
||||
pipeline_a = pipeline_mod.get_pipeline_state(handle_a.controller, event_a)
|
||||
pipeline_a_path = os.path.join(artifacts_dir, "pipeline_a.json")
|
||||
write_json(pipeline_a_path, pipeline_a)
|
||||
artifacts.append(
|
||||
artifact_record(
|
||||
bundle_dir,
|
||||
pipeline_a_path,
|
||||
artifact_id="pipeline_a",
|
||||
role="metadata",
|
||||
kind="json",
|
||||
label=f"Pipeline state A at event {event_a}",
|
||||
media_type="application/json",
|
||||
)
|
||||
)
|
||||
|
||||
pipeline_b = pipeline_mod.get_pipeline_state(handle_b.controller, event_b)
|
||||
pipeline_b_path = os.path.join(artifacts_dir, "pipeline_b.json")
|
||||
write_json(pipeline_b_path, pipeline_b)
|
||||
artifacts.append(
|
||||
artifact_record(
|
||||
bundle_dir,
|
||||
pipeline_b_path,
|
||||
artifact_id="pipeline_b",
|
||||
role="metadata",
|
||||
kind="json",
|
||||
label=f"Pipeline state B at event {event_b}",
|
||||
media_type="application/json",
|
||||
)
|
||||
)
|
||||
|
||||
diff_count = _count_differences(diff_data)
|
||||
summary = {
|
||||
"headline": f"RenderDoc diff bundle created for events {event_a} vs {event_b}",
|
||||
"facts": {
|
||||
"event_a": event_a,
|
||||
"event_b": event_b,
|
||||
"capture_a": os.path.basename(capture_path_a),
|
||||
"capture_b": os.path.basename(capture_path_b),
|
||||
"difference_count": diff_count,
|
||||
},
|
||||
"warnings": warnings,
|
||||
"next_actions": [
|
||||
"Inspect pipeline_diff.json for resource, shader, and state changes.",
|
||||
"Compare A/B output-target images for visible regressions.",
|
||||
],
|
||||
}
|
||||
|
||||
manifest = finalize_bundle(
|
||||
bundle_dir=bundle_dir,
|
||||
bundle_id=prepared["bundle_id"],
|
||||
bundle_kind="diff",
|
||||
software="renderdoc",
|
||||
recipe="diff",
|
||||
source={
|
||||
"capture_path": os.path.abspath(capture_path_a),
|
||||
"capture_name": os.path.basename(capture_path_a),
|
||||
"capture_fingerprint": fingerprint_file(capture_path_a),
|
||||
},
|
||||
artifacts=artifacts,
|
||||
summary=summary,
|
||||
cache_key=prepared["cache_key"],
|
||||
generator={
|
||||
"entry_point": "cli-anything-renderdoc",
|
||||
"harness_version": HARNESS_VERSION,
|
||||
"command": command
|
||||
or f"cli-anything-renderdoc -c {capture_path_a} preview diff {event_a} {event_b} --capture-b {capture_path_b}",
|
||||
},
|
||||
status="partial" if warnings else "ok",
|
||||
warnings=warnings or None,
|
||||
context={"event_a": event_a, "event_b": event_b, "trajectory_path": trajectory_rel},
|
||||
metrics={"difference_count": diff_count},
|
||||
labels=["gpu", "capture", "diff", "preview"],
|
||||
source_bundles=[
|
||||
{"capture_path": os.path.abspath(capture_path_a), "event_id": event_a},
|
||||
{"capture_path": os.path.abspath(capture_path_b), "event_id": event_b},
|
||||
],
|
||||
)
|
||||
trajectory = append_live_trajectory(
|
||||
trajectory_dir,
|
||||
software="renderdoc",
|
||||
recipe="diff",
|
||||
bundle_manifest=manifest,
|
||||
publish_reason="diff",
|
||||
project_path=os.path.abspath(capture_path_a),
|
||||
project_name=os.path.basename(capture_path_a),
|
||||
session_name=f"{os.path.splitext(os.path.basename(capture_path_a))[0]}-diff",
|
||||
command=command,
|
||||
stage_label=f"diff-{event_a}-vs-{event_b}",
|
||||
note=f"Pipeline diff for events {event_a} vs {event_b}",
|
||||
)
|
||||
manifest["_trajectory_path"] = trajectory["_trajectory_path"]
|
||||
manifest["cached"] = False
|
||||
return manifest
|
||||
|
||||
|
||||
def latest(
|
||||
*,
|
||||
project_path: Optional[str] = None,
|
||||
recipe: Optional[str] = None,
|
||||
bundle_kind: Optional[str] = None,
|
||||
root_dir: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return the latest preview bundle manifest for RenderDoc."""
|
||||
manifest = find_latest_manifest(
|
||||
software="renderdoc",
|
||||
recipe=recipe,
|
||||
bundle_kind=bundle_kind,
|
||||
project_path=project_path,
|
||||
root_dir=root_dir,
|
||||
)
|
||||
if manifest is None:
|
||||
raise FileNotFoundError("No RenderDoc preview bundle found")
|
||||
return _attach_trajectory_ref(manifest)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Resource inspection: list buffers, get buffer data, enumerate all resources.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct as _struct
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
try:
|
||||
import renderdoc as rd
|
||||
|
||||
HAS_RD = True
|
||||
except ImportError:
|
||||
rd = None # type: ignore[assignment]
|
||||
HAS_RD = False
|
||||
|
||||
|
||||
def list_resources(controller) -> List[Dict[str, Any]]:
|
||||
"""List all resources in the capture."""
|
||||
resources = controller.GetResources()
|
||||
return [
|
||||
{
|
||||
"resourceId": str(r.resourceId),
|
||||
"name": str(r.name),
|
||||
"type": str(r.type),
|
||||
}
|
||||
for r in resources
|
||||
]
|
||||
|
||||
|
||||
def list_buffers(controller) -> List[Dict[str, Any]]:
|
||||
"""List all buffer resources."""
|
||||
buffers = controller.GetBuffers()
|
||||
return [
|
||||
{
|
||||
"resourceId": str(b.resourceId),
|
||||
"length": b.length,
|
||||
"creationFlags": int(b.creationFlags),
|
||||
}
|
||||
for b in buffers
|
||||
]
|
||||
|
||||
|
||||
def get_buffer_data(
|
||||
controller,
|
||||
resource_id_str: str,
|
||||
offset: int = 0,
|
||||
length: int = 0,
|
||||
fmt: str = "hex",
|
||||
) -> Dict[str, Any]:
|
||||
"""Read raw buffer data.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
fmt : str
|
||||
'hex' returns hex string, 'float32' unpacks as floats,
|
||||
'uint32' unpacks as unsigned ints, 'raw' returns byte list.
|
||||
"""
|
||||
buf_id = None
|
||||
for b in controller.GetBuffers():
|
||||
if str(b.resourceId) == resource_id_str:
|
||||
buf_id = b.resourceId
|
||||
break
|
||||
if buf_id is None:
|
||||
return {"error": f"Buffer {resource_id_str} not found"}
|
||||
|
||||
data = controller.GetBufferData(buf_id, offset, length)
|
||||
raw_bytes = bytes(data)
|
||||
|
||||
result: Dict[str, Any] = {
|
||||
"resourceId": resource_id_str,
|
||||
"offset": offset,
|
||||
"length": len(raw_bytes),
|
||||
}
|
||||
|
||||
if fmt == "hex":
|
||||
result["data"] = raw_bytes.hex()
|
||||
elif fmt == "float32":
|
||||
count = len(raw_bytes) // 4
|
||||
result["data"] = list(_struct.unpack(f"<{count}f", raw_bytes[: count * 4]))
|
||||
elif fmt == "uint32":
|
||||
count = len(raw_bytes) // 4
|
||||
result["data"] = list(_struct.unpack(f"<{count}I", raw_bytes[: count * 4]))
|
||||
elif fmt == "raw":
|
||||
result["data"] = list(raw_bytes)
|
||||
else:
|
||||
result["data"] = raw_bytes.hex()
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
Texture inspection and export.
|
||||
|
||||
List all textures in a capture, inspect individual texture metadata,
|
||||
pick pixel values, and save textures to disk in various formats.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
try:
|
||||
import renderdoc as rd
|
||||
|
||||
HAS_RD = True
|
||||
except ImportError:
|
||||
rd = None # type: ignore[assignment]
|
||||
HAS_RD = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Texture enumeration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _tex_to_dict(tex) -> Dict[str, Any]:
|
||||
"""Serialise TextureDescription to a plain dict."""
|
||||
return {
|
||||
"resourceId": str(tex.resourceId),
|
||||
"name": str(getattr(tex, "name", "")),
|
||||
"width": tex.width,
|
||||
"height": tex.height,
|
||||
"depth": tex.depth,
|
||||
"mips": tex.mips,
|
||||
"arraysize": tex.arraysize,
|
||||
"msQual": tex.msQual,
|
||||
"msSamp": tex.msSamp,
|
||||
"format": str(tex.format),
|
||||
"dimension": tex.dimension,
|
||||
"type": str(tex.type) if hasattr(tex, "type") else str(tex.dimension),
|
||||
"cubemap": getattr(tex, "cubemap", False),
|
||||
"byteSize": getattr(tex, "byteSize", 0),
|
||||
"creationFlags": int(getattr(tex, "creationFlags", 0)),
|
||||
}
|
||||
|
||||
|
||||
def list_textures(controller) -> List[Dict[str, Any]]:
|
||||
"""Return all textures in the capture."""
|
||||
textures = controller.GetTextures()
|
||||
return [_tex_to_dict(t) for t in textures]
|
||||
|
||||
|
||||
def get_texture(controller, resource_id_str: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get a single texture by resource ID string."""
|
||||
for tex in controller.GetTextures():
|
||||
if str(tex.resourceId) == resource_id_str:
|
||||
return _tex_to_dict(tex)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pixel picking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def pick_pixel(
|
||||
controller,
|
||||
resource_id_str: str,
|
||||
x: int,
|
||||
y: int,
|
||||
mip: int = 0,
|
||||
slice_idx: int = 0,
|
||||
sample: int = 0,
|
||||
) -> Dict[str, Any]:
|
||||
"""Pick a pixel value from a texture.
|
||||
|
||||
Returns dict with float, uint, and int value representations.
|
||||
"""
|
||||
# Find the resource ID
|
||||
tex_id = None
|
||||
for tex in controller.GetTextures():
|
||||
if str(tex.resourceId) == resource_id_str:
|
||||
tex_id = tex.resourceId
|
||||
break
|
||||
if tex_id is None:
|
||||
return {"error": f"Texture {resource_id_str} not found"}
|
||||
|
||||
sub = rd.Subresource(mip, slice_idx, sample)
|
||||
pix = controller.PickPixel(tex_id, x, y, sub, rd.CompType.Typeless)
|
||||
|
||||
return {
|
||||
"x": x,
|
||||
"y": y,
|
||||
"resourceId": resource_id_str,
|
||||
"float": list(pix.floatValue),
|
||||
"uint": list(pix.uintValue),
|
||||
"int": list(pix.intValue),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Texture export
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FORMAT_MAP = {}
|
||||
if HAS_RD and hasattr(rd, "FileType"):
|
||||
_FORMAT_MAP = {
|
||||
"png": rd.FileType.PNG,
|
||||
"jpg": rd.FileType.JPG,
|
||||
"jpeg": rd.FileType.JPG,
|
||||
"bmp": rd.FileType.BMP,
|
||||
"tga": rd.FileType.TGA,
|
||||
"hdr": rd.FileType.HDR,
|
||||
"exr": rd.FileType.EXR,
|
||||
"dds": rd.FileType.DDS,
|
||||
}
|
||||
|
||||
|
||||
def save_texture(
|
||||
controller,
|
||||
resource_id_str: str,
|
||||
output_path: str,
|
||||
file_format: str = "png",
|
||||
mip: int = 0,
|
||||
slice_idx: int = 0,
|
||||
alpha: str = "preserve",
|
||||
) -> Dict[str, Any]:
|
||||
"""Save a texture to disk.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
controller : ReplayController
|
||||
resource_id_str : str
|
||||
The resource ID as a string.
|
||||
output_path : str
|
||||
Destination file path.
|
||||
file_format : str
|
||||
One of: png, jpg, bmp, tga, hdr, exr, dds
|
||||
mip : int
|
||||
Mip level to save (-1 for all, DDS only).
|
||||
slice_idx : int
|
||||
Array slice to save (-1 for all, DDS only).
|
||||
alpha : str
|
||||
Alpha handling: 'preserve', 'discard', 'blend_checkerboard'
|
||||
"""
|
||||
fmt_lower = file_format.lower()
|
||||
if fmt_lower not in _FORMAT_MAP:
|
||||
return {"error": f"Unsupported format: {file_format}. Use: {list(_FORMAT_MAP.keys())}"}
|
||||
|
||||
tex_id = None
|
||||
for tex in controller.GetTextures():
|
||||
if str(tex.resourceId) == resource_id_str:
|
||||
tex_id = tex.resourceId
|
||||
break
|
||||
if tex_id is None:
|
||||
return {"error": f"Texture {resource_id_str} not found"}
|
||||
|
||||
save = rd.TextureSave()
|
||||
save.resourceId = tex_id
|
||||
save.destType = _FORMAT_MAP[fmt_lower]
|
||||
save.mip = mip
|
||||
save.slice.sliceIndex = slice_idx
|
||||
|
||||
alpha_lower = alpha.lower()
|
||||
if alpha_lower == "preserve":
|
||||
save.alpha = rd.AlphaMapping.Preserve
|
||||
elif alpha_lower == "discard":
|
||||
save.alpha = rd.AlphaMapping.Discard
|
||||
elif alpha_lower in ("blend", "blend_checkerboard", "checkerboard"):
|
||||
save.alpha = rd.AlphaMapping.BlendToCheckerboard
|
||||
else:
|
||||
save.alpha = rd.AlphaMapping.Preserve
|
||||
|
||||
output_path = os.path.abspath(output_path)
|
||||
controller.SaveTexture(save, output_path)
|
||||
|
||||
if os.path.isfile(output_path):
|
||||
return {
|
||||
"path": output_path,
|
||||
"format": fmt_lower,
|
||||
"size": os.path.getsize(output_path),
|
||||
}
|
||||
return {"error": "Failed to save texture (file not created)"}
|
||||
|
||||
|
||||
def save_action_outputs(
|
||||
controller,
|
||||
event_id: int,
|
||||
output_dir: str,
|
||||
file_format: str = "png",
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Save all render target outputs at a specific event.
|
||||
|
||||
Moves the replay to *event_id*, then saves each colour output and
|
||||
the depth output (if any) to *output_dir*.
|
||||
"""
|
||||
controller.SetFrameEvent(event_id, True)
|
||||
state = controller.GetPipelineState()
|
||||
targets = state.GetOutputTargets()
|
||||
depth = state.GetDepthTarget()
|
||||
|
||||
results = []
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
for i, t in enumerate(targets):
|
||||
if t.resourceId == rd.ResourceId.Null():
|
||||
continue
|
||||
rid = str(t.resourceId)
|
||||
fname = f"event{event_id}_rt{i}.{file_format}"
|
||||
path = os.path.join(output_dir, fname)
|
||||
r = save_texture(controller, rid, path, file_format)
|
||||
r["label"] = f"RT{i}"
|
||||
results.append(r)
|
||||
|
||||
if depth.resourceId != rd.ResourceId.Null():
|
||||
rid = str(depth.resourceId)
|
||||
fname = f"event{event_id}_depth.{file_format}"
|
||||
path = os.path.join(output_dir, fname)
|
||||
r = save_texture(controller, rid, path, file_format)
|
||||
r["label"] = "Depth"
|
||||
results.append(r)
|
||||
|
||||
return results
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,174 @@
|
||||
---
|
||||
name: cli-anything-renderdoc
|
||||
description: CLI harness for RenderDoc graphics debugger capture analysis
|
||||
version: 0.1.0
|
||||
command: cli-anything-renderdoc
|
||||
install: pip install cli-anything-renderdoc
|
||||
requires:
|
||||
- renderdoc (Python bindings from RenderDoc installation)
|
||||
- click>=8.0
|
||||
- prompt-toolkit>=3.0
|
||||
categories:
|
||||
- graphics
|
||||
- debugging
|
||||
- gpu
|
||||
- rendering
|
||||
---
|
||||
|
||||
# RenderDoc CLI Skill
|
||||
|
||||
Headless command-line analysis of RenderDoc GPU frame captures (`.rdc` files).
|
||||
|
||||
## Capabilities
|
||||
|
||||
- **Capture inspection**: metadata, sections, thumbnails, format conversion
|
||||
- **Action tree**: list/search/filter draw calls, clears, dispatches, markers
|
||||
- **Texture operations**: list, inspect, export (PNG/JPG/DDS/HDR/EXR), pixel picking
|
||||
- **Pipeline state**: full shader/RT/viewport state at any event
|
||||
- **Shader analysis**: export shader in human-readable form (HLSL/GLSL/disasm), constant buffer readback
|
||||
- **Resource inspection**: buffer/texture enumeration, raw data reading
|
||||
- **Mesh data**: vertex shader input/output decoding
|
||||
- **GPU counters**: enumerate and fetch hardware performance counters
|
||||
|
||||
## Command Groups
|
||||
|
||||
### capture
|
||||
```bash
|
||||
cli-anything-renderdoc -c frame.rdc capture info # Metadata + sections
|
||||
cli-anything-renderdoc -c frame.rdc capture thumb -o t.png # Extract thumbnail
|
||||
cli-anything-renderdoc -c frame.rdc capture convert -o out.rdc --format rdc
|
||||
```
|
||||
|
||||
### actions
|
||||
```bash
|
||||
cli-anything-renderdoc -c frame.rdc actions list # All actions
|
||||
cli-anything-renderdoc -c frame.rdc actions list --draws-only # Draw calls only
|
||||
cli-anything-renderdoc -c frame.rdc actions summary # Counts by type
|
||||
cli-anything-renderdoc -c frame.rdc actions find "Shadow" # Search by name
|
||||
cli-anything-renderdoc -c frame.rdc actions get 42 # Single action
|
||||
```
|
||||
|
||||
### textures
|
||||
```bash
|
||||
cli-anything-renderdoc -c frame.rdc textures list
|
||||
cli-anything-renderdoc -c frame.rdc textures get <id>
|
||||
cli-anything-renderdoc -c frame.rdc textures save <id> -o out.png --format png
|
||||
cli-anything-renderdoc -c frame.rdc textures save-outputs 42 -o ./renders/
|
||||
cli-anything-renderdoc -c frame.rdc textures pick <id> 100 200
|
||||
```
|
||||
|
||||
### pipeline
|
||||
```bash
|
||||
cli-anything-renderdoc -c frame.rdc pipeline state 42
|
||||
|
||||
# Export shader in human-readable form
|
||||
# Text shaders (GLSL/HLSL) → saved directly
|
||||
# Binary shaders (DXBC/SPIR-V) → embedded source (HLSL/GLSL) or disassembly
|
||||
cli-anything-renderdoc -c frame.rdc pipeline shader-export 42 --stage Fragment
|
||||
cli-anything-renderdoc -c frame.rdc pipeline shader-export 42 --stage Vertex -o ./shaders/
|
||||
|
||||
cli-anything-renderdoc -c frame.rdc pipeline cbuffer 42 --stage Vertex --index 0
|
||||
|
||||
# Compare pipeline state between two events
|
||||
# Default output: same directory as the capture file ; use -o to override
|
||||
cli-anything-renderdoc -c a.rdc pipeline diff 100 200 -b b.rdc
|
||||
cli-anything-renderdoc -c frame.rdc pipeline diff 100 200 # same capture
|
||||
cli-anything-renderdoc -c a.rdc pipeline diff 100 200 -b b.rdc -o result.json
|
||||
cli-anything-renderdoc -c a.rdc pipeline diff 100 200 -b b.rdc --no-compact
|
||||
```
|
||||
|
||||
### resources
|
||||
```bash
|
||||
cli-anything-renderdoc -c frame.rdc resources list
|
||||
cli-anything-renderdoc -c frame.rdc resources buffers
|
||||
cli-anything-renderdoc -c frame.rdc resources read-buffer <id> --format float32
|
||||
```
|
||||
|
||||
### mesh
|
||||
```bash
|
||||
cli-anything-renderdoc -c frame.rdc mesh inputs 42 --max-vertices 10
|
||||
cli-anything-renderdoc -c frame.rdc mesh outputs 42
|
||||
```
|
||||
|
||||
### counters
|
||||
```bash
|
||||
cli-anything-renderdoc -c frame.rdc counters list
|
||||
cli-anything-renderdoc -c frame.rdc counters fetch --ids 1,2,3
|
||||
```
|
||||
|
||||
## Preview Bundles
|
||||
|
||||
RenderDoc preview support is for truthful capture inspection rather than live
|
||||
creative preview.
|
||||
|
||||
```bash
|
||||
# List preview recipes
|
||||
cli-anything-renderdoc -c frame.rdc --json preview recipes
|
||||
|
||||
# Capture a preview bundle for the active capture or a specific event
|
||||
cli-anything-renderdoc -c frame.rdc --json preview capture --recipe quick --event-id 42
|
||||
|
||||
# Capture a diff preview bundle for two events or captures
|
||||
cli-anything-renderdoc -c frame.rdc --json preview diff 100 200
|
||||
cli-anything-renderdoc -c a.rdc --json preview diff 100 200 --capture-b b.rdc
|
||||
|
||||
# Return the latest existing bundle for the capture
|
||||
cli-anything-renderdoc -c frame.rdc --json preview latest --recipe quick
|
||||
```
|
||||
|
||||
Typical preview bundle contents:
|
||||
|
||||
- capture thumbnail
|
||||
- output-target images
|
||||
- `pipeline_state.json`
|
||||
- `action_summary.json`
|
||||
|
||||
Diff bundles additionally include:
|
||||
|
||||
- A/B thumbnails
|
||||
- A/B output-target images
|
||||
- `pipeline_diff.json`
|
||||
|
||||
RenderDoc does not currently expose live preview sessions. Instead, each
|
||||
capture/diff bundle appends to a stable recipe-level `trajectory.json`.
|
||||
|
||||
Viewer commands:
|
||||
|
||||
```bash
|
||||
cli-hub previews inspect /path/to/bundle
|
||||
cli-hub previews html /path/to/bundle -o page.html
|
||||
cli-hub previews open /path/to/bundle
|
||||
```
|
||||
|
||||
## JSON Mode
|
||||
|
||||
All commands support `--json` for machine-readable output:
|
||||
```bash
|
||||
cli-anything-renderdoc -c frame.rdc --json actions summary
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------------------|------------------------------|
|
||||
| `RENDERDOC_CAPTURE` | Default capture file path |
|
||||
| `PYTHONPATH` | Must include RenderDoc path |
|
||||
|
||||
## Agent Usage Notes
|
||||
|
||||
- **Use `pipeline shader-export` to extract shaders** — for binary shaders (DXBC/SPIR-V) it auto-exports embedded HLSL/GLSL source or falls back to disassembly; for text shaders (GLSL/HLSL) it saves the raw source directly
|
||||
- **Shader formats by capture API**:
|
||||
- D3D11 → DXBC binary, exported as embedded HLSL source (`.hlsl`) or bytecode asm (`.dxbc.asm`)
|
||||
- OpenGL/GLES → GLSL source text (`.glsl`), already human-readable
|
||||
- Vulkan → SPIR-V binary, exported as embedded GLSL source (`.glsl`) or SPIR-V asm (`.spv.asm`)
|
||||
- **Use `pipeline diff` to compare two events** — it writes a JSON file and prints only the path; use `-b` for a second capture
|
||||
- Always specify `--json` for programmatic consumption
|
||||
- Use `actions summary` first to understand capture complexity
|
||||
- Use `actions list --draws-only` to focus on actual rendering
|
||||
- Use `preview capture` or `preview diff` when the agent needs a portable,
|
||||
honest inspection bundle rather than raw replay calls alone
|
||||
- Read `_trajectory_path` from preview JSON if you need persistent capture/diff history
|
||||
- Use `cli-hub previews ...` only to inspect/open already-generated bundles
|
||||
- Pipeline state requires an event ID from the action list
|
||||
- Texture save supports: png, jpg, bmp, tga, hdr, exr, dds
|
||||
- Buffer data can be decoded as hex, float32, uint32, or raw bytes
|
||||
@@ -0,0 +1,109 @@
|
||||
# TEST.md – RenderDoc CLI Test Plan & Results
|
||||
|
||||
## Test Strategy
|
||||
|
||||
### Unit Tests (`test_core.py`)
|
||||
Mock-based tests for all core modules. No dependency on the `renderdoc` Python
|
||||
module or actual `.rdc` capture files. Tests synthetic data paths.
|
||||
|
||||
### E2E Tests (`test_full_e2e.py`)
|
||||
Full integration tests requiring:
|
||||
1. RenderDoc installed with Python bindings accessible
|
||||
2. A `.rdc` capture file (set via `RENDERDOC_TEST_CAPTURE` env var)
|
||||
|
||||
Skips gracefully if either prerequisite is missing.
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Unit tests (always runnable)
|
||||
cd renderdoc/agent-harness
|
||||
pytest cli_anything/renderdoc/tests/test_core.py -v
|
||||
|
||||
# E2E tests (requires RenderDoc + capture file)
|
||||
RENDERDOC_TEST_CAPTURE=/path/to/capture.rdc pytest cli_anything/renderdoc/tests/test_full_e2e.py -v
|
||||
|
||||
# All tests
|
||||
pytest cli_anything/renderdoc/tests/ -v
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
| Module | Tests | Status |
|
||||
|------------------------|-------|--------|
|
||||
| utils/output.py | 4 | ✅ Pass |
|
||||
| utils/errors.py | 2 | ✅ Pass |
|
||||
| core/actions.py | 9 | ✅ Pass |
|
||||
| core/textures.py | 4 | ✅ Pass |
|
||||
| core/resources.py | 5 | ✅ Pass |
|
||||
| core/diff.py | 12 | ✅ Pass |
|
||||
| CLI help (all groups) | 8 | ✅ Pass |
|
||||
| CLI subprocess | 1 | ✅ Pass |
|
||||
| **Total Unit** | **45**| **✅ All Pass** |
|
||||
| E2E (capture info) | 2 | ⏭️ Skip (no RD) |
|
||||
| E2E (actions) | 4 | ⏭️ Skip (no RD) |
|
||||
| E2E (textures) | 2 | ⏭️ Skip (no RD) |
|
||||
| E2E (resources) | 2 | ⏭️ Skip (no RD) |
|
||||
| E2E (pipeline) | 2 | ⏭️ Skip (no RD) |
|
||||
| E2E (counters) | 1 | ⏭️ Skip (no RD) |
|
||||
| E2E (workflow) | 1 | ⏭️ Skip (no RD) |
|
||||
| **Total E2E** | **14**| **⏭️ All Skip** |
|
||||
|
||||
## Test Results
|
||||
|
||||
```
|
||||
============================= test session starts =============================
|
||||
platform win32 -- Python 3.10.2, pytest-9.0.2
|
||||
|
||||
cli_anything/renderdoc/tests/test_core.py::TestOutputUtils::test_output_json PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestOutputUtils::test_output_table PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestOutputUtils::test_output_table_empty PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestOutputUtils::test_format_size PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestErrorUtils::test_handle_error PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestErrorUtils::test_handle_error_debug PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestActionsModule::test_decode_flags PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestActionsModule::test_decode_flags_multiple PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestActionsModule::test_action_to_dict PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestActionsModule::test_list_actions_flat PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestActionsModule::test_list_actions_root_only PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestActionsModule::test_find_actions_by_name PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestActionsModule::test_find_action_by_event PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestActionsModule::test_get_drawcalls_only PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestActionsModule::test_action_summary PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestTexturesModule::test_tex_to_dict PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestTexturesModule::test_list_textures PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestTexturesModule::test_get_texture_found PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestTexturesModule::test_get_texture_not_found PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestResourcesModule::test_list_resources PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestResourcesModule::test_list_buffers PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestResourcesModule::test_get_buffer_data_hex PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestResourcesModule::test_get_buffer_data_float32 PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestResourcesModule::test_get_buffer_data_not_found PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestCLIHelp::test_main_help PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestCLIHelp::test_capture_help PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestCLIHelp::test_actions_help PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestCLIHelp::test_textures_help PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestCLIHelp::test_pipeline_help PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestCLIHelp::test_resources_help PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestCLIHelp::test_mesh_help PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestCLIHelp::test_counters_help PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestCLISubprocess::test_cli_help_subprocess PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestDiffModule::test_identical_snapshots PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestDiffModule::test_different_viewport PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestDiffModule::test_float_tolerance PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestDiffModule::test_float_nan_equal PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestDiffModule::test_diff_lists_only_in_one_side PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestDiffModule::test_diff_dicts_missing_key PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestDiffModule::test_diff_dicts_identical PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestDiffModule::test_diff_dicts_none_inputs PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestDiffModule::test_stage_diff_shader_changed PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestDiffModule::test_cbuffer_variable_diff PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestDiffModule::test_cbuffer_variable_identical PASSED
|
||||
cli_anything/renderdoc/tests/test_core.py::TestDiffModule::test_output_table_extra_columns PASSED
|
||||
|
||||
============================= 45 passed in 0.15s ==============================
|
||||
|
||||
cli_anything/renderdoc/tests/test_full_e2e.py - 14 skipped (no RenderDoc)
|
||||
|
||||
============================= 59 total, 45 passed, 14 skipped ================
|
||||
```
|
||||
@@ -0,0 +1,797 @@
|
||||
"""
|
||||
Unit tests for RenderDoc CLI core modules.
|
||||
|
||||
These tests use mocks and synthetic data — no renderdoc dependency needed.
|
||||
Run with: pytest test_core.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch, PropertyMock
|
||||
|
||||
import pytest
|
||||
|
||||
# ===========================================================================
|
||||
# Test utils/output.py
|
||||
# ===========================================================================
|
||||
|
||||
class TestOutputUtils:
|
||||
def test_output_json(self):
|
||||
from cli_anything.renderdoc.utils.output import output_json
|
||||
import io
|
||||
buf = io.StringIO()
|
||||
output_json({"key": "value", "num": 42}, file=buf)
|
||||
result = json.loads(buf.getvalue())
|
||||
assert result["key"] == "value"
|
||||
assert result["num"] == 42
|
||||
|
||||
def test_output_table(self):
|
||||
from cli_anything.renderdoc.utils.output import output_table
|
||||
import io
|
||||
buf = io.StringIO()
|
||||
output_table(
|
||||
[["Alice", 30], ["Bob", 25]],
|
||||
["Name", "Age"],
|
||||
file=buf,
|
||||
)
|
||||
text = buf.getvalue()
|
||||
assert "Alice" in text
|
||||
assert "Bob" in text
|
||||
assert "Name" in text
|
||||
|
||||
def test_output_table_empty(self):
|
||||
from cli_anything.renderdoc.utils.output import output_table
|
||||
import io
|
||||
buf = io.StringIO()
|
||||
output_table([], ["Name"], file=buf)
|
||||
assert "(no data)" in buf.getvalue()
|
||||
|
||||
def test_format_size(self):
|
||||
from cli_anything.renderdoc.utils.output import format_size
|
||||
assert format_size(512) == "512 B"
|
||||
assert "KB" in format_size(2048)
|
||||
assert "MB" in format_size(2 * 1024 * 1024)
|
||||
assert "GB" in format_size(3 * 1024 * 1024 * 1024)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Test utils/errors.py
|
||||
# ===========================================================================
|
||||
|
||||
class TestErrorUtils:
|
||||
def test_handle_error(self):
|
||||
from cli_anything.renderdoc.utils.errors import handle_error
|
||||
result = handle_error(ValueError("test error"))
|
||||
assert result["error"] == "test error"
|
||||
assert result["type"] == "ValueError"
|
||||
assert "traceback" not in result
|
||||
|
||||
def test_handle_error_debug(self):
|
||||
from cli_anything.renderdoc.utils.errors import handle_error
|
||||
try:
|
||||
raise RuntimeError("boom")
|
||||
except RuntimeError as e:
|
||||
result = handle_error(e, debug=True)
|
||||
assert "traceback" in result
|
||||
assert "boom" in result["traceback"]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Test core/actions.py (with mock rd)
|
||||
# ===========================================================================
|
||||
|
||||
class MockActionFlags:
|
||||
Clear = 0x0001
|
||||
Drawcall = 0x0002
|
||||
Dispatch = 0x0004
|
||||
CmdList = 0x0008
|
||||
SetMarker = 0x0010
|
||||
PushMarker = 0x0020
|
||||
PopMarker = 0x0040
|
||||
Present = 0x0080
|
||||
MultiAction = 0x0100
|
||||
Copy = 0x0200
|
||||
Resolve = 0x0400
|
||||
GenMips = 0x0800
|
||||
PassBoundary = 0x1000
|
||||
Indexed = 0x2000
|
||||
Instanced = 0x4000
|
||||
Auto = 0x8000
|
||||
Indirect = 0x10000
|
||||
ClearColor = 0x20000
|
||||
ClearDepthStencil = 0x40000
|
||||
BeginPass = 0x80000
|
||||
EndPass = 0x100000
|
||||
|
||||
|
||||
def _make_mock_action(event_id, name, flags=0x0002, num_indices=100, children=None):
|
||||
action = MagicMock()
|
||||
action.eventId = event_id
|
||||
action.actionId = event_id
|
||||
action.customName = name
|
||||
action.GetName = MagicMock(return_value=name)
|
||||
action.flags = flags
|
||||
action.numIndices = num_indices
|
||||
action.numInstances = 1
|
||||
action.indexOffset = 0
|
||||
action.baseVertex = 0
|
||||
action.vertexOffset = 0
|
||||
action.instanceOffset = 0
|
||||
action.outputs = []
|
||||
action.depthOut = MagicMock()
|
||||
action.depthOut.__str__ = lambda s: "0"
|
||||
action.children = children or []
|
||||
action.next = None
|
||||
return action
|
||||
|
||||
|
||||
class TestActionsModule:
|
||||
@patch("cli_anything.renderdoc.core.actions.rd")
|
||||
def test_decode_flags(self, mock_rd):
|
||||
# Patch the flag values
|
||||
mock_rd.ActionFlags = MockActionFlags
|
||||
from cli_anything.renderdoc.core.actions import _decode_flags
|
||||
result = _decode_flags(0x0002) # Drawcall
|
||||
assert "Drawcall" in result
|
||||
|
||||
@patch("cli_anything.renderdoc.core.actions.rd")
|
||||
def test_decode_flags_multiple(self, mock_rd):
|
||||
mock_rd.ActionFlags = MockActionFlags
|
||||
from cli_anything.renderdoc.core.actions import _decode_flags
|
||||
result = _decode_flags(0x0002 | 0x2000) # Drawcall + Indexed
|
||||
assert "Drawcall" in result
|
||||
assert "Indexed" in result
|
||||
|
||||
@patch("cli_anything.renderdoc.core.actions.rd")
|
||||
def test_action_to_dict(self, mock_rd):
|
||||
mock_rd.ActionFlags = MockActionFlags
|
||||
from cli_anything.renderdoc.core.actions import _action_to_dict
|
||||
action = _make_mock_action(1, "Draw Triangle", 0x0002)
|
||||
d = _action_to_dict(action, None)
|
||||
assert d["eventId"] == 1
|
||||
assert d["name"] == "Draw Triangle"
|
||||
assert "Drawcall" in d["flags"]
|
||||
|
||||
@patch("cli_anything.renderdoc.core.actions.rd")
|
||||
def test_list_actions_flat(self, mock_rd):
|
||||
mock_rd.ActionFlags = MockActionFlags
|
||||
from cli_anything.renderdoc.core.actions import list_actions
|
||||
|
||||
child = _make_mock_action(2, "DrawIndexed", 0x0002)
|
||||
root = _make_mock_action(1, "RenderPass", 0x0020, children=[child])
|
||||
|
||||
controller = MagicMock()
|
||||
controller.GetRootActions.return_value = [root]
|
||||
controller.GetStructuredFile.return_value = MagicMock()
|
||||
|
||||
result = list_actions(controller, flat=True)
|
||||
assert len(result) == 2
|
||||
assert result[0]["eventId"] == 1
|
||||
assert result[1]["eventId"] == 2
|
||||
assert result[1]["depth"] == 1
|
||||
|
||||
@patch("cli_anything.renderdoc.core.actions.rd")
|
||||
def test_list_actions_root_only(self, mock_rd):
|
||||
mock_rd.ActionFlags = MockActionFlags
|
||||
from cli_anything.renderdoc.core.actions import list_actions
|
||||
|
||||
child = _make_mock_action(2, "DrawIndexed")
|
||||
root = _make_mock_action(1, "RenderPass", 0x0020, children=[child])
|
||||
|
||||
controller = MagicMock()
|
||||
controller.GetRootActions.return_value = [root]
|
||||
controller.GetStructuredFile.return_value = MagicMock()
|
||||
|
||||
result = list_actions(controller, flat=False)
|
||||
assert len(result) == 1
|
||||
|
||||
@patch("cli_anything.renderdoc.core.actions.rd")
|
||||
def test_find_actions_by_name(self, mock_rd):
|
||||
mock_rd.ActionFlags = MockActionFlags
|
||||
from cli_anything.renderdoc.core.actions import find_actions_by_name
|
||||
|
||||
a1 = _make_mock_action(1, "Clear RenderTarget", 0x0001)
|
||||
a2 = _make_mock_action(2, "DrawIndexed(100)", 0x0002)
|
||||
a3 = _make_mock_action(3, "DrawIndexed(200)", 0x0002)
|
||||
|
||||
controller = MagicMock()
|
||||
controller.GetRootActions.return_value = [a1, a2, a3]
|
||||
controller.GetStructuredFile.return_value = MagicMock()
|
||||
|
||||
result = find_actions_by_name(controller, "drawindex")
|
||||
assert len(result) == 2
|
||||
|
||||
@patch("cli_anything.renderdoc.core.actions.rd")
|
||||
def test_find_action_by_event(self, mock_rd):
|
||||
mock_rd.ActionFlags = MockActionFlags
|
||||
from cli_anything.renderdoc.core.actions import find_action_by_event
|
||||
|
||||
a1 = _make_mock_action(10, "Draw", 0x0002)
|
||||
controller = MagicMock()
|
||||
controller.GetRootActions.return_value = [a1]
|
||||
controller.GetStructuredFile.return_value = MagicMock()
|
||||
|
||||
result = find_action_by_event(controller, 10)
|
||||
assert result is not None
|
||||
assert result["eventId"] == 10
|
||||
|
||||
result = find_action_by_event(controller, 999)
|
||||
assert result is None
|
||||
|
||||
@patch("cli_anything.renderdoc.core.actions.rd")
|
||||
def test_get_drawcalls_only(self, mock_rd):
|
||||
mock_rd.ActionFlags = MockActionFlags
|
||||
from cli_anything.renderdoc.core.actions import get_drawcalls_only
|
||||
|
||||
a1 = _make_mock_action(1, "Clear", 0x0001) # Clear
|
||||
a2 = _make_mock_action(2, "Draw", 0x0002) # Drawcall
|
||||
a3 = _make_mock_action(3, "Marker", 0x0020) # PushMarker
|
||||
|
||||
controller = MagicMock()
|
||||
controller.GetRootActions.return_value = [a1, a2, a3]
|
||||
controller.GetStructuredFile.return_value = MagicMock()
|
||||
|
||||
result = get_drawcalls_only(controller)
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "Draw"
|
||||
|
||||
@patch("cli_anything.renderdoc.core.actions.rd")
|
||||
def test_action_summary(self, mock_rd):
|
||||
mock_rd.ActionFlags = MockActionFlags
|
||||
from cli_anything.renderdoc.core.actions import action_summary
|
||||
|
||||
actions = [
|
||||
_make_mock_action(1, "Clear", 0x0001),
|
||||
_make_mock_action(2, "Draw1", 0x0002),
|
||||
_make_mock_action(3, "Draw2", 0x0002),
|
||||
_make_mock_action(4, "Dispatch", 0x0004),
|
||||
_make_mock_action(5, "Copy", 0x0200),
|
||||
_make_mock_action(6, "Marker", 0x0020),
|
||||
_make_mock_action(7, "Present", 0x0080),
|
||||
]
|
||||
controller = MagicMock()
|
||||
controller.GetRootActions.return_value = actions
|
||||
controller.GetStructuredFile.return_value = MagicMock()
|
||||
|
||||
result = action_summary(controller)
|
||||
assert result["total_actions"] == 7
|
||||
assert result["drawcalls"] == 2
|
||||
assert result["clears"] == 1
|
||||
assert result["dispatches"] == 1
|
||||
assert result["copies"] == 1
|
||||
assert result["markers"] == 1
|
||||
assert result["presents"] == 1
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Test core/textures.py (mock-based)
|
||||
# ===========================================================================
|
||||
|
||||
class TestTexturesModule:
|
||||
def _make_mock_tex(self, rid="123", w=512, h=512, mips=1, fmt="R8G8B8A8_UNORM"):
|
||||
tex = MagicMock()
|
||||
tex.resourceId = MagicMock()
|
||||
tex.resourceId.__str__ = lambda s: rid
|
||||
tex.name = f"Texture_{rid}"
|
||||
tex.width = w
|
||||
tex.height = h
|
||||
tex.depth = 1
|
||||
tex.mips = mips
|
||||
tex.arraysize = 1
|
||||
tex.msQual = 0
|
||||
tex.msSamp = 1
|
||||
tex.format = MagicMock()
|
||||
tex.format.__str__ = lambda s: fmt
|
||||
tex.dimension = 2
|
||||
tex.type = MagicMock()
|
||||
tex.type.__str__ = lambda s: "Texture2D"
|
||||
tex.cubemap = False
|
||||
tex.byteSize = w * h * 4
|
||||
tex.creationFlags = 0
|
||||
return tex
|
||||
|
||||
def test_tex_to_dict(self):
|
||||
from cli_anything.renderdoc.core.textures import _tex_to_dict
|
||||
tex = self._make_mock_tex()
|
||||
d = _tex_to_dict(tex)
|
||||
assert d["resourceId"] == "123"
|
||||
assert d["width"] == 512
|
||||
assert d["height"] == 512
|
||||
assert d["mips"] == 1
|
||||
|
||||
def test_list_textures(self):
|
||||
from cli_anything.renderdoc.core.textures import list_textures
|
||||
controller = MagicMock()
|
||||
controller.GetTextures.return_value = [
|
||||
self._make_mock_tex("1", 256, 256),
|
||||
self._make_mock_tex("2", 1024, 1024),
|
||||
]
|
||||
result = list_textures(controller)
|
||||
assert len(result) == 2
|
||||
assert result[0]["width"] == 256
|
||||
assert result[1]["width"] == 1024
|
||||
|
||||
def test_get_texture_found(self):
|
||||
from cli_anything.renderdoc.core.textures import get_texture
|
||||
controller = MagicMock()
|
||||
controller.GetTextures.return_value = [
|
||||
self._make_mock_tex("42", 800, 600),
|
||||
]
|
||||
result = get_texture(controller, "42")
|
||||
assert result is not None
|
||||
assert result["width"] == 800
|
||||
|
||||
def test_get_texture_not_found(self):
|
||||
from cli_anything.renderdoc.core.textures import get_texture
|
||||
controller = MagicMock()
|
||||
controller.GetTextures.return_value = []
|
||||
result = get_texture(controller, "999")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Test core/resources.py (mock-based)
|
||||
# ===========================================================================
|
||||
|
||||
class TestResourcesModule:
|
||||
def test_list_resources(self):
|
||||
from cli_anything.renderdoc.core.resources import list_resources
|
||||
|
||||
r1 = MagicMock()
|
||||
r1.resourceId = MagicMock()
|
||||
r1.resourceId.__str__ = lambda s: "1"
|
||||
r1.name = "Backbuffer"
|
||||
r1.type = MagicMock()
|
||||
r1.type.__str__ = lambda s: "Texture"
|
||||
|
||||
controller = MagicMock()
|
||||
controller.GetResources.return_value = [r1]
|
||||
|
||||
result = list_resources(controller)
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "Backbuffer"
|
||||
|
||||
def test_list_buffers(self):
|
||||
from cli_anything.renderdoc.core.resources import list_buffers
|
||||
|
||||
b1 = MagicMock()
|
||||
b1.resourceId = MagicMock()
|
||||
b1.resourceId.__str__ = lambda s: "5"
|
||||
b1.length = 4096
|
||||
b1.creationFlags = 0
|
||||
|
||||
controller = MagicMock()
|
||||
controller.GetBuffers.return_value = [b1]
|
||||
|
||||
result = list_buffers(controller)
|
||||
assert len(result) == 1
|
||||
assert result[0]["length"] == 4096
|
||||
|
||||
def test_get_buffer_data_hex(self):
|
||||
from cli_anything.renderdoc.core.resources import get_buffer_data
|
||||
|
||||
b1 = MagicMock()
|
||||
b1.resourceId = MagicMock()
|
||||
b1.resourceId.__str__ = lambda s: "5"
|
||||
|
||||
controller = MagicMock()
|
||||
controller.GetBuffers.return_value = [b1]
|
||||
controller.GetBufferData.return_value = b"\x01\x02\x03\x04"
|
||||
|
||||
result = get_buffer_data(controller, "5", 0, 4, "hex")
|
||||
assert result["data"] == "01020304"
|
||||
assert result["length"] == 4
|
||||
|
||||
def test_get_buffer_data_float32(self):
|
||||
from cli_anything.renderdoc.core.resources import get_buffer_data
|
||||
|
||||
b1 = MagicMock()
|
||||
b1.resourceId = MagicMock()
|
||||
b1.resourceId.__str__ = lambda s: "5"
|
||||
|
||||
controller = MagicMock()
|
||||
controller.GetBuffers.return_value = [b1]
|
||||
test_data = struct.pack("<2f", 1.0, 2.5)
|
||||
controller.GetBufferData.return_value = test_data
|
||||
|
||||
result = get_buffer_data(controller, "5", 0, 8, "float32")
|
||||
assert len(result["data"]) == 2
|
||||
assert abs(result["data"][0] - 1.0) < 0.001
|
||||
assert abs(result["data"][1] - 2.5) < 0.001
|
||||
|
||||
def test_get_buffer_data_not_found(self):
|
||||
from cli_anything.renderdoc.core.resources import get_buffer_data
|
||||
|
||||
controller = MagicMock()
|
||||
controller.GetBuffers.return_value = []
|
||||
|
||||
result = get_buffer_data(controller, "999", 0, 4, "hex")
|
||||
assert "error" in result
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Test CLI entry point (Click testing)
|
||||
# ===========================================================================
|
||||
|
||||
class TestCLIHelp:
|
||||
"""Test that CLI help works without renderdoc installed."""
|
||||
|
||||
def test_main_help(self):
|
||||
from click.testing import CliRunner
|
||||
from cli_anything.renderdoc.renderdoc_cli import cli
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "RenderDoc CLI" in result.output
|
||||
|
||||
def test_capture_help(self):
|
||||
from click.testing import CliRunner
|
||||
from cli_anything.renderdoc.renderdoc_cli import cli
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["capture", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "info" in result.output
|
||||
|
||||
def test_actions_help(self):
|
||||
from click.testing import CliRunner
|
||||
from cli_anything.renderdoc.renderdoc_cli import cli
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["actions", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "list" in result.output
|
||||
|
||||
def test_textures_help(self):
|
||||
from click.testing import CliRunner
|
||||
from cli_anything.renderdoc.renderdoc_cli import cli
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["textures", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "save" in result.output
|
||||
|
||||
def test_pipeline_help(self):
|
||||
from click.testing import CliRunner
|
||||
from cli_anything.renderdoc.renderdoc_cli import cli
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["pipeline", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "state" in result.output
|
||||
|
||||
def test_resources_help(self):
|
||||
from click.testing import CliRunner
|
||||
from cli_anything.renderdoc.renderdoc_cli import cli
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["resources", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "buffers" in result.output
|
||||
|
||||
def test_mesh_help(self):
|
||||
from click.testing import CliRunner
|
||||
from cli_anything.renderdoc.renderdoc_cli import cli
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["mesh", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "inputs" in result.output
|
||||
|
||||
def test_counters_help(self):
|
||||
from click.testing import CliRunner
|
||||
from cli_anything.renderdoc.renderdoc_cli import cli
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["counters", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "fetch" in result.output
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Test subprocess invocation pattern
|
||||
# ===========================================================================
|
||||
|
||||
class TestCLISubprocess:
|
||||
"""Test CLI via subprocess from agent-harness root (namespace on cwd)."""
|
||||
|
||||
def test_cli_help_subprocess(self):
|
||||
import subprocess
|
||||
|
||||
harness_root = Path(__file__).resolve().parents[3]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "cli_anything.renderdoc.renderdoc_cli", "--help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
cwd=str(harness_root),
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "RenderDoc CLI" in result.stdout
|
||||
except FileNotFoundError:
|
||||
pytest.skip("CLI not installed")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Test core/diff.py (snapshot-based, no renderdoc needed)
|
||||
# ===========================================================================
|
||||
|
||||
class TestDiffModule:
|
||||
"""Unit tests for diff_pipeline_from_snapshots and helpers."""
|
||||
|
||||
@staticmethod
|
||||
def _make_snapshot(event_id, pipeline_state=None):
|
||||
"""Build a minimal snapshot dict."""
|
||||
return {
|
||||
"eventId": event_id,
|
||||
"PipelineState": pipeline_state or {},
|
||||
}
|
||||
|
||||
def test_identical_snapshots(self):
|
||||
from cli_anything.renderdoc.core.diff import diff_pipeline_from_snapshots
|
||||
|
||||
ps = {
|
||||
"pipelineType": "Graphics",
|
||||
"viewport": {"x": 0, "y": 0, "width": 1920, "height": 1080},
|
||||
"rasterizer": {"fillMode": "Solid"},
|
||||
"depthStencil": {"depthEnable": True},
|
||||
"stages": {},
|
||||
}
|
||||
snap = self._make_snapshot(100, ps)
|
||||
result = diff_pipeline_from_snapshots(snap, snap)
|
||||
assert result["identical"] is True
|
||||
|
||||
def test_different_viewport(self):
|
||||
from cli_anything.renderdoc.core.diff import diff_pipeline_from_snapshots
|
||||
|
||||
ps_a = {"viewport": {"x": 0, "y": 0, "width": 1920, "height": 1080}}
|
||||
ps_b = {"viewport": {"x": 0, "y": 0, "width": 1280, "height": 720}}
|
||||
result = diff_pipeline_from_snapshots(
|
||||
self._make_snapshot(1, ps_a),
|
||||
self._make_snapshot(2, ps_b),
|
||||
)
|
||||
assert result["identical"] is False
|
||||
assert "viewport" in result
|
||||
assert result["viewport"]["width"]["A"] == 1920
|
||||
assert result["viewport"]["width"]["B"] == 1280
|
||||
|
||||
def test_float_tolerance(self):
|
||||
from cli_anything.renderdoc.core.diff import _values_equal
|
||||
|
||||
assert _values_equal(1.0, 1.0 + 1e-9) is True
|
||||
assert _values_equal(1.0, 1.1) is False
|
||||
|
||||
def test_float_nan_equal(self):
|
||||
import math
|
||||
from cli_anything.renderdoc.core.diff import _values_equal
|
||||
|
||||
assert _values_equal(float("nan"), float("nan")) is True
|
||||
assert _values_equal(float("inf"), float("inf")) is True
|
||||
assert _values_equal(float("inf"), float("-inf")) is False
|
||||
|
||||
def test_diff_lists_only_in_one_side(self):
|
||||
from cli_anything.renderdoc.core.diff import diff_pipeline_from_snapshots
|
||||
|
||||
ps_a = {
|
||||
"vertexInputs": [
|
||||
{"name": "POSITION", "format": "R32G32B32_FLOAT"},
|
||||
],
|
||||
}
|
||||
ps_b = {
|
||||
"vertexInputs": [
|
||||
{"name": "POSITION", "format": "R32G32B32_FLOAT"},
|
||||
{"name": "TEXCOORD", "format": "R32G32_FLOAT"},
|
||||
],
|
||||
}
|
||||
result = diff_pipeline_from_snapshots(
|
||||
self._make_snapshot(1, ps_a),
|
||||
self._make_snapshot(2, ps_b),
|
||||
)
|
||||
assert result["identical"] is False
|
||||
assert isinstance(result["vertexInputs"], list)
|
||||
statuses = [d["status"] for d in result["vertexInputs"]]
|
||||
assert "only_in_B" in statuses
|
||||
|
||||
def test_diff_dicts_missing_key(self):
|
||||
from cli_anything.renderdoc.core.diff import _diff_dicts
|
||||
|
||||
a = {"x": 1, "y": 2}
|
||||
b = {"x": 1, "z": 3}
|
||||
result = _diff_dicts(a, b)
|
||||
assert result is not None
|
||||
assert "y" in result
|
||||
assert "z" in result
|
||||
|
||||
def test_diff_dicts_identical(self):
|
||||
from cli_anything.renderdoc.core.diff import _diff_dicts
|
||||
|
||||
a = {"x": 1, "y": 2}
|
||||
result = _diff_dicts(a, a)
|
||||
assert result is None
|
||||
|
||||
def test_diff_dicts_none_inputs(self):
|
||||
from cli_anything.renderdoc.core.diff import _diff_dicts
|
||||
|
||||
assert _diff_dicts(None, None) is None
|
||||
result = _diff_dicts(None, {"x": 1})
|
||||
assert result is not None
|
||||
assert result["A"] is None
|
||||
|
||||
def test_stage_diff_shader_changed(self):
|
||||
from cli_anything.renderdoc.core.diff import diff_pipeline_from_snapshots
|
||||
|
||||
ps_a = {
|
||||
"stages": {
|
||||
"Vertex": {
|
||||
"shader": "ResourceId::100",
|
||||
"entryPoint": "main",
|
||||
"ShaderReflection": {},
|
||||
"bindings": {"constantBlocks": [], "readOnlyResources": [],
|
||||
"readWriteResources": [], "samplers": []},
|
||||
},
|
||||
},
|
||||
}
|
||||
ps_b = {
|
||||
"stages": {
|
||||
"Vertex": {
|
||||
"shader": "ResourceId::200",
|
||||
"entryPoint": "main",
|
||||
"ShaderReflection": {},
|
||||
"bindings": {"constantBlocks": [], "readOnlyResources": [],
|
||||
"readWriteResources": [], "samplers": []},
|
||||
},
|
||||
},
|
||||
}
|
||||
result = diff_pipeline_from_snapshots(
|
||||
self._make_snapshot(1, ps_a),
|
||||
self._make_snapshot(2, ps_b),
|
||||
)
|
||||
assert result["identical"] is False
|
||||
assert result["stages"]["Vertex"]["shader"]["shader"]["A"] == "ResourceId::100"
|
||||
assert result["stages"]["Vertex"]["shader"]["shader"]["B"] == "ResourceId::200"
|
||||
|
||||
def test_cbuffer_variable_diff(self):
|
||||
from cli_anything.renderdoc.core.diff import _diff_cbuffer_vars
|
||||
|
||||
vars_a = [
|
||||
{"name": "color", "values": [1.0, 0.0, 0.0, 1.0]},
|
||||
{"name": "intensity", "values": [0.5]},
|
||||
]
|
||||
vars_b = [
|
||||
{"name": "color", "values": [0.0, 1.0, 0.0, 1.0]},
|
||||
{"name": "intensity", "values": [0.5]},
|
||||
]
|
||||
result = _diff_cbuffer_vars(vars_a, vars_b)
|
||||
assert result is not None
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "color"
|
||||
assert result[0]["status"] == "changed"
|
||||
|
||||
def test_cbuffer_variable_identical(self):
|
||||
from cli_anything.renderdoc.core.diff import _diff_cbuffer_vars
|
||||
|
||||
vars_a = [{"name": "x", "values": [1.0]}]
|
||||
result = _diff_cbuffer_vars(vars_a, vars_a)
|
||||
assert result is None
|
||||
|
||||
def test_output_table_extra_columns(self):
|
||||
"""Verify output_table truncates rows longer than headers."""
|
||||
from cli_anything.renderdoc.utils.output import output_table
|
||||
import io
|
||||
|
||||
buf = io.StringIO()
|
||||
output_table(
|
||||
[["Alice", 30, "extra_col"]],
|
||||
["Name", "Age"],
|
||||
file=buf,
|
||||
)
|
||||
text = buf.getvalue()
|
||||
assert "Alice" in text
|
||||
assert "extra_col" not in text
|
||||
|
||||
|
||||
class TestPreviewModule:
|
||||
def _make_handle(self, tmp_path: Path, name: str = "frame") -> tuple[MagicMock, Path]:
|
||||
capture_path = tmp_path / f"{name}.rdc"
|
||||
capture_path.write_bytes(b"RDC")
|
||||
handle = MagicMock()
|
||||
handle.controller = MagicMock()
|
||||
handle.metadata.return_value = {"api": "Vulkan", "replay_supported": True}
|
||||
|
||||
def _thumb(output_path, max_dim=0):
|
||||
Path(output_path).write_bytes(b"\x89PNG\r\n\x1a\nthumb")
|
||||
return {"path": output_path, "format": "PNG"}
|
||||
|
||||
handle.thumbnail.side_effect = _thumb
|
||||
return handle, capture_path
|
||||
|
||||
def test_capture_bundle(self, tmp_path, monkeypatch):
|
||||
from cli_anything.renderdoc.core import preview as preview_mod
|
||||
from cli_anything.renderdoc.core import actions as actions_mod
|
||||
from cli_anything.renderdoc.core import textures as textures_mod
|
||||
from cli_anything.renderdoc.core import pipeline as pipeline_mod
|
||||
|
||||
handle, capture_path = self._make_handle(tmp_path, "capture_a")
|
||||
|
||||
monkeypatch.setattr(actions_mod, "get_drawcalls_only", lambda controller: [{"eventId": 42}])
|
||||
monkeypatch.setattr(actions_mod, "action_summary", lambda controller: {"drawcalls": 5})
|
||||
|
||||
def fake_save_outputs(controller, event_id, output_dir, file_format="png"):
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
output_path = Path(output_dir) / f"event{event_id}_rt0.png"
|
||||
output_path.write_bytes(b"\x89PNG\r\n\x1a\nout")
|
||||
return [{"path": str(output_path), "label": "RT0"}]
|
||||
|
||||
monkeypatch.setattr(textures_mod, "save_action_outputs", fake_save_outputs)
|
||||
monkeypatch.setattr(pipeline_mod, "get_pipeline_state", lambda controller, event_id: {"eventId": event_id})
|
||||
|
||||
manifest = preview_mod.capture(handle, str(capture_path), root_dir=str(tmp_path))
|
||||
assert manifest["software"] == "renderdoc"
|
||||
assert manifest["recipe"] == "quick"
|
||||
assert manifest["status"] == "ok"
|
||||
assert any(item["role"] == "hero" for item in manifest["artifacts"])
|
||||
assert any(item["artifact_id"] == "pipeline_state" for item in manifest["artifacts"])
|
||||
assert os.path.isfile(manifest["_trajectory_path"])
|
||||
trajectory = json.loads(Path(manifest["_trajectory_path"]).read_text(encoding="utf-8"))
|
||||
assert trajectory["step_count"] == 1
|
||||
assert trajectory["steps"][0]["publish_reason"] == "capture"
|
||||
|
||||
def test_diff_bundle(self, tmp_path, monkeypatch):
|
||||
from cli_anything.renderdoc.core import preview as preview_mod
|
||||
from cli_anything.renderdoc.core import textures as textures_mod
|
||||
from cli_anything.renderdoc.core import pipeline as pipeline_mod
|
||||
from cli_anything.renderdoc.core import diff as diff_mod
|
||||
|
||||
handle_a, capture_a = self._make_handle(tmp_path, "capture_a")
|
||||
handle_b, capture_b = self._make_handle(tmp_path, "capture_b")
|
||||
|
||||
def fake_save_outputs(controller, event_id, output_dir, file_format="png"):
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
output_path = Path(output_dir) / f"event{event_id}_rt0.png"
|
||||
output_path.write_bytes(b"\x89PNG\r\n\x1a\nout")
|
||||
return [{"path": str(output_path), "label": "RT0"}]
|
||||
|
||||
monkeypatch.setattr(textures_mod, "save_action_outputs", fake_save_outputs)
|
||||
monkeypatch.setattr(pipeline_mod, "get_pipeline_state", lambda controller, event_id: {"eventId": event_id})
|
||||
monkeypatch.setattr(diff_mod, "diff_pipeline", lambda *args, **kwargs: {"identical": False, "blend": {"enabled": {"A": True, "B": False}}})
|
||||
|
||||
manifest = preview_mod.diff(
|
||||
handle_a,
|
||||
str(capture_a),
|
||||
100,
|
||||
handle_b,
|
||||
str(capture_b),
|
||||
200,
|
||||
root_dir=str(tmp_path),
|
||||
)
|
||||
assert manifest["software"] == "renderdoc"
|
||||
assert manifest["bundle_kind"] == "diff"
|
||||
assert any(item["artifact_id"] == "pipeline_diff" for item in manifest["artifacts"])
|
||||
assert os.path.isfile(manifest["_trajectory_path"])
|
||||
trajectory = json.loads(Path(manifest["_trajectory_path"]).read_text(encoding="utf-8"))
|
||||
assert trajectory["step_count"] == 1
|
||||
assert trajectory["steps"][0]["publish_reason"] == "diff"
|
||||
|
||||
def test_latest_bundle(self, tmp_path, monkeypatch):
|
||||
from cli_anything.renderdoc.core import preview as preview_mod
|
||||
from cli_anything.renderdoc.core import actions as actions_mod
|
||||
from cli_anything.renderdoc.core import textures as textures_mod
|
||||
from cli_anything.renderdoc.core import pipeline as pipeline_mod
|
||||
|
||||
handle, capture_path = self._make_handle(tmp_path, "capture_latest")
|
||||
|
||||
monkeypatch.setattr(actions_mod, "get_drawcalls_only", lambda controller: [{"eventId": 7}])
|
||||
monkeypatch.setattr(actions_mod, "action_summary", lambda controller: {"drawcalls": 1})
|
||||
|
||||
def fake_save_outputs(controller, event_id, output_dir, file_format="png"):
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
output_path = Path(output_dir) / f"event{event_id}_rt0.png"
|
||||
output_path.write_bytes(b"\x89PNG\r\n\x1a\nout")
|
||||
return [{"path": str(output_path), "label": "RT0"}]
|
||||
|
||||
monkeypatch.setattr(textures_mod, "save_action_outputs", fake_save_outputs)
|
||||
monkeypatch.setattr(pipeline_mod, "get_pipeline_state", lambda controller, event_id: {"eventId": event_id})
|
||||
|
||||
created = preview_mod.capture(handle, str(capture_path), root_dir=str(tmp_path))
|
||||
latest = preview_mod.latest(root_dir=str(tmp_path))
|
||||
assert latest["bundle_id"] == created["bundle_id"]
|
||||
assert os.path.isfile(latest["_trajectory_path"])
|
||||
@@ -0,0 +1,374 @@
|
||||
"""
|
||||
End-to-end tests for RenderDoc CLI.
|
||||
|
||||
These tests require:
|
||||
1. RenderDoc installed with Python bindings accessible
|
||||
2. A .rdc capture file (set via RENDERDOC_TEST_CAPTURE env var)
|
||||
|
||||
Skip gracefully if either is unavailable.
|
||||
|
||||
Run with: pytest test_full_e2e.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cli_anything.renderdoc.core.capture import open_capture
|
||||
from cli_anything.renderdoc.core import preview as preview_mod
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HARNESS_ROOT = str(Path(__file__).resolve().parents[3])
|
||||
|
||||
TEST_CAPTURE = os.environ.get("RENDERDOC_TEST_CAPTURE", "")
|
||||
HAS_CAPTURE = os.path.isfile(TEST_CAPTURE) if TEST_CAPTURE else False
|
||||
|
||||
try:
|
||||
import renderdoc as rd
|
||||
HAS_RD = True
|
||||
except ImportError:
|
||||
HAS_RD = False
|
||||
|
||||
skip_no_rd = pytest.mark.skipif(not HAS_RD, reason="renderdoc module not available")
|
||||
skip_no_cap = pytest.mark.skipif(not HAS_CAPTURE, reason="RENDERDOC_TEST_CAPTURE not set or file missing")
|
||||
|
||||
|
||||
def _run_cli(*args, json_mode=True) -> dict | list | str:
|
||||
"""Run CLI via module invocation and parse output."""
|
||||
cmd = [sys.executable, "-m", "cli_anything.renderdoc.renderdoc_cli"]
|
||||
if TEST_CAPTURE:
|
||||
cmd.extend(["--capture", TEST_CAPTURE])
|
||||
if json_mode:
|
||||
cmd.append("--json")
|
||||
cmd.extend(args)
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60, cwd=HARNESS_ROOT)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"CLI failed: {result.stderr}\n{result.stdout}")
|
||||
|
||||
if json_mode:
|
||||
return json.loads(result.stdout)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _artifact_path(manifest, artifact_id: str) -> str:
|
||||
for artifact in manifest["artifacts"]:
|
||||
if artifact["artifact_id"] == artifact_id:
|
||||
return os.path.join(manifest["_bundle_dir"], artifact["path"])
|
||||
raise KeyError(f"Artifact not found: {artifact_id}")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# E2E: Capture info
|
||||
# ===========================================================================
|
||||
|
||||
@skip_no_rd
|
||||
@skip_no_cap
|
||||
class TestCaptureE2E:
|
||||
def test_capture_info(self):
|
||||
data = _run_cli("capture", "info")
|
||||
assert "path" in data
|
||||
assert "api" in data
|
||||
assert "sections" in data
|
||||
assert isinstance(data["sections"], list)
|
||||
|
||||
def test_capture_thumb(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
output = os.path.join(tmpdir, "thumb.png")
|
||||
data = _run_cli("capture", "thumb", "--output", output)
|
||||
# May fail if no thumbnail - that's ok
|
||||
if "error" not in data:
|
||||
assert os.path.isfile(output)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# E2E: Actions
|
||||
# ===========================================================================
|
||||
|
||||
@skip_no_rd
|
||||
@skip_no_cap
|
||||
class TestActionsE2E:
|
||||
def test_actions_list(self):
|
||||
data = _run_cli("actions", "list")
|
||||
assert isinstance(data, list)
|
||||
assert len(data) > 0
|
||||
assert "eventId" in data[0]
|
||||
|
||||
def test_actions_summary(self):
|
||||
data = _run_cli("actions", "summary")
|
||||
assert "total_actions" in data
|
||||
assert data["total_actions"] > 0
|
||||
|
||||
def test_actions_draws_only(self):
|
||||
data = _run_cli("actions", "list", "--draws-only")
|
||||
assert isinstance(data, list)
|
||||
for a in data:
|
||||
assert "Drawcall" in a["flags"]
|
||||
|
||||
def test_actions_get(self):
|
||||
# First get list to find a valid eventId
|
||||
actions = _run_cli("actions", "list")
|
||||
if actions:
|
||||
eid = actions[0]["eventId"]
|
||||
data = _run_cli("actions", "get", str(eid))
|
||||
assert data["eventId"] == eid
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# E2E: Textures
|
||||
# ===========================================================================
|
||||
|
||||
@skip_no_rd
|
||||
@skip_no_cap
|
||||
class TestTexturesE2E:
|
||||
def test_textures_list(self):
|
||||
data = _run_cli("textures", "list")
|
||||
assert isinstance(data, list)
|
||||
if len(data) > 0:
|
||||
assert "resourceId" in data[0]
|
||||
assert "width" in data[0]
|
||||
|
||||
def test_textures_save(self):
|
||||
textures = _run_cli("textures", "list")
|
||||
if not textures:
|
||||
pytest.skip("No textures in capture")
|
||||
rid = textures[0]["resourceId"]
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
output = os.path.join(tmpdir, "tex.png")
|
||||
data = _run_cli("textures", "save", rid, "--output", output)
|
||||
if "error" not in data:
|
||||
assert os.path.isfile(output)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# E2E: Resources
|
||||
# ===========================================================================
|
||||
|
||||
@skip_no_rd
|
||||
@skip_no_cap
|
||||
class TestResourcesE2E:
|
||||
def test_resources_list(self):
|
||||
data = _run_cli("resources", "list")
|
||||
assert isinstance(data, list)
|
||||
|
||||
def test_resources_buffers(self):
|
||||
data = _run_cli("resources", "buffers")
|
||||
assert isinstance(data, list)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# E2E: Pipeline
|
||||
# ===========================================================================
|
||||
|
||||
@skip_no_rd
|
||||
@skip_no_cap
|
||||
class TestPipelineE2E:
|
||||
def test_pipeline_state(self):
|
||||
# Get first draw call
|
||||
draws = _run_cli("actions", "list", "--draws-only")
|
||||
if not draws:
|
||||
pytest.skip("No draw calls in capture")
|
||||
eid = draws[0]["eventId"]
|
||||
data = _run_cli("pipeline", "state", str(eid))
|
||||
assert "shaders" in data
|
||||
assert "eventId" in data
|
||||
|
||||
def test_pipeline_shader_export(self):
|
||||
draws = _run_cli("actions", "list", "--draws-only")
|
||||
if not draws:
|
||||
pytest.skip("No draw calls")
|
||||
eid = draws[0]["eventId"]
|
||||
data = _run_cli("pipeline", "shader-export", str(eid), "--stage", "Fragment")
|
||||
# May have error if no pixel shader - acceptable
|
||||
assert "eventId" in data or "error" in data
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# E2E: Counters
|
||||
# ===========================================================================
|
||||
|
||||
@skip_no_rd
|
||||
@skip_no_cap
|
||||
class TestCountersE2E:
|
||||
def test_counters_list(self):
|
||||
data = _run_cli("counters", "list")
|
||||
assert isinstance(data, list)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Workflow: Full analysis pipeline
|
||||
# ===========================================================================
|
||||
|
||||
@skip_no_rd
|
||||
@skip_no_cap
|
||||
class TestWorkflowE2E:
|
||||
def test_full_analysis_workflow(self):
|
||||
"""Simulate a typical analysis: info → list draws → inspect → export."""
|
||||
# Step 1: Capture info
|
||||
info = _run_cli("capture", "info")
|
||||
assert "api" in info
|
||||
|
||||
# Step 2: Action summary
|
||||
summary = _run_cli("actions", "summary")
|
||||
assert summary["total_actions"] > 0
|
||||
|
||||
# Step 3: Find draw calls
|
||||
draws = _run_cli("actions", "list", "--draws-only")
|
||||
if not draws:
|
||||
return # No draws to inspect
|
||||
|
||||
# Step 4: Inspect pipeline at first draw
|
||||
eid = draws[0]["eventId"]
|
||||
pipeline = _run_cli("pipeline", "state", str(eid))
|
||||
assert "shaders" in pipeline
|
||||
|
||||
# Step 5: List textures
|
||||
textures = _run_cli("textures", "list")
|
||||
assert isinstance(textures, list)
|
||||
|
||||
|
||||
@skip_no_rd
|
||||
@skip_no_cap
|
||||
class TestPreviewAPIE2E:
|
||||
def test_preview_capture_bundle(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with open_capture(TEST_CAPTURE) as handle:
|
||||
manifest = preview_mod.capture(
|
||||
handle,
|
||||
TEST_CAPTURE,
|
||||
root_dir=tmpdir,
|
||||
force=True,
|
||||
)
|
||||
|
||||
assert manifest["software"] == "renderdoc"
|
||||
assert manifest["bundle_kind"] == "capture"
|
||||
assert manifest["status"] in ("ok", "partial")
|
||||
|
||||
artifact_ids = {artifact["artifact_id"] for artifact in manifest["artifacts"]}
|
||||
assert "action_summary" in artifact_ids
|
||||
summary_path = _artifact_path(manifest, "action_summary")
|
||||
assert os.path.isfile(summary_path)
|
||||
|
||||
latest = preview_mod.latest(
|
||||
project_path=TEST_CAPTURE,
|
||||
recipe="quick",
|
||||
bundle_kind="capture",
|
||||
root_dir=tmpdir,
|
||||
)
|
||||
assert latest["bundle_id"] == manifest["bundle_id"]
|
||||
|
||||
print(f"\n RenderDoc preview bundle: {manifest['_bundle_dir']}")
|
||||
print(f" RenderDoc action summary: {summary_path}")
|
||||
|
||||
def test_preview_diff_bundle(self):
|
||||
draws = _run_cli("actions", "list", "--draws-only")
|
||||
if not draws:
|
||||
pytest.skip("No draw calls in capture")
|
||||
|
||||
event_a = int(draws[0]["eventId"])
|
||||
event_b = int(draws[-1]["eventId"])
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with open_capture(TEST_CAPTURE) as handle_a, open_capture(TEST_CAPTURE) as handle_b:
|
||||
manifest = preview_mod.diff(
|
||||
handle_a,
|
||||
TEST_CAPTURE,
|
||||
event_a,
|
||||
handle_b,
|
||||
TEST_CAPTURE,
|
||||
event_b,
|
||||
root_dir=tmpdir,
|
||||
force=True,
|
||||
)
|
||||
|
||||
assert manifest["software"] == "renderdoc"
|
||||
assert manifest["bundle_kind"] == "diff"
|
||||
diff_path = _artifact_path(manifest, "pipeline_diff")
|
||||
assert os.path.isfile(diff_path)
|
||||
with open(diff_path, "r", encoding="utf-8") as fh:
|
||||
diff_data = json.load(fh)
|
||||
assert isinstance(diff_data, dict)
|
||||
|
||||
latest = preview_mod.latest(
|
||||
project_path=TEST_CAPTURE,
|
||||
recipe="diff",
|
||||
bundle_kind="diff",
|
||||
root_dir=tmpdir,
|
||||
)
|
||||
assert latest["bundle_id"] == manifest["bundle_id"]
|
||||
|
||||
print(f"\n RenderDoc diff bundle: {manifest['_bundle_dir']}")
|
||||
print(f" RenderDoc pipeline diff: {diff_path}")
|
||||
|
||||
|
||||
@skip_no_rd
|
||||
@skip_no_cap
|
||||
class TestPreviewCLIE2E:
|
||||
def test_cli_preview_capture(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
manifest = _run_cli("preview", "capture", "--root-dir", tmpdir)
|
||||
assert manifest["software"] == "renderdoc"
|
||||
summary_path = _artifact_path(manifest, "action_summary")
|
||||
assert os.path.isfile(summary_path)
|
||||
|
||||
latest = _run_cli(
|
||||
"preview",
|
||||
"latest",
|
||||
"--recipe",
|
||||
"quick",
|
||||
"--bundle-kind",
|
||||
"capture",
|
||||
"--root-dir",
|
||||
tmpdir,
|
||||
)
|
||||
assert latest["bundle_id"] == manifest["bundle_id"]
|
||||
|
||||
print(f"\n RenderDoc preview bundle: {manifest['_bundle_dir']}")
|
||||
print(f" RenderDoc action summary: {summary_path}")
|
||||
|
||||
def test_cli_preview_diff(self):
|
||||
draws = _run_cli("actions", "list", "--draws-only")
|
||||
if not draws:
|
||||
pytest.skip("No draw calls in capture")
|
||||
|
||||
event_a = int(draws[0]["eventId"])
|
||||
event_b = int(draws[-1]["eventId"])
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
manifest = _run_cli(
|
||||
"preview",
|
||||
"diff",
|
||||
str(event_a),
|
||||
str(event_b),
|
||||
"--root-dir",
|
||||
tmpdir,
|
||||
)
|
||||
assert manifest["software"] == "renderdoc"
|
||||
assert manifest["bundle_kind"] == "diff"
|
||||
diff_path = _artifact_path(manifest, "pipeline_diff")
|
||||
assert os.path.isfile(diff_path)
|
||||
|
||||
latest = _run_cli(
|
||||
"preview",
|
||||
"latest",
|
||||
"--recipe",
|
||||
"diff",
|
||||
"--bundle-kind",
|
||||
"diff",
|
||||
"--root-dir",
|
||||
tmpdir,
|
||||
)
|
||||
assert latest["bundle_id"] == manifest["bundle_id"]
|
||||
|
||||
print(f"\n RenderDoc diff bundle: {manifest['_bundle_dir']}")
|
||||
print(f" RenderDoc pipeline diff: {diff_path}")
|
||||
@@ -0,0 +1 @@
|
||||
"""Utility modules for RenderDoc CLI harness."""
|
||||
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Error handling utilities.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import traceback
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
def handle_error(e: Exception, debug: bool = False) -> Dict[str, Any]:
|
||||
"""Convert an exception into an error dict.
|
||||
|
||||
If *debug* is True, includes the full traceback.
|
||||
"""
|
||||
result = {
|
||||
"error": str(e),
|
||||
"type": type(e).__name__,
|
||||
}
|
||||
if debug:
|
||||
result["traceback"] = traceback.format_exc()
|
||||
return result
|
||||
|
||||
|
||||
def die(message: str, code: int = 1):
|
||||
"""Print error message and exit."""
|
||||
sys.stderr.write(f"Error: {message}\n")
|
||||
sys.exit(code)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Output formatting: JSON and human-readable output helpers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
def output_json(data: Any, indent: int = 2, file=None):
|
||||
"""Write data as JSON to stdout or a file."""
|
||||
if file is None:
|
||||
file = sys.stdout
|
||||
json.dump(data, file, indent=indent, default=str)
|
||||
file.write("\n")
|
||||
|
||||
|
||||
def output_table(rows: list, headers: list, file=None):
|
||||
"""Print a simple ASCII table."""
|
||||
if file is None:
|
||||
file = sys.stdout
|
||||
|
||||
if not rows:
|
||||
file.write("(no data)\n")
|
||||
return
|
||||
|
||||
# Calculate column widths
|
||||
col_widths = [len(h) for h in headers]
|
||||
for row in rows:
|
||||
for i, val in enumerate(row):
|
||||
if i < len(col_widths):
|
||||
col_widths[i] = max(col_widths[i], len(str(val)))
|
||||
|
||||
# Header
|
||||
header_line = " ".join(str(h).ljust(col_widths[i]) for i, h in enumerate(headers))
|
||||
file.write(header_line + "\n")
|
||||
file.write(" ".join("-" * w for w in col_widths) + "\n")
|
||||
|
||||
# Rows
|
||||
for row in rows:
|
||||
truncated = row[:len(headers)]
|
||||
line = " ".join(str(v).ljust(col_widths[i]) for i, v in enumerate(truncated))
|
||||
file.write(line + "\n")
|
||||
|
||||
|
||||
def format_size(size_bytes: int) -> str:
|
||||
"""Format byte count as human-readable string."""
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} B"
|
||||
elif size_bytes < 1024 * 1024:
|
||||
return f"{size_bytes / 1024:.1f} KB"
|
||||
elif size_bytes < 1024 * 1024 * 1024:
|
||||
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
||||
return f"{size_bytes / (1024 * 1024 * 1024):.1f} GB"
|
||||
@@ -0,0 +1,468 @@
|
||||
"""Shared helpers for CLI-Anything preview bundles."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, Optional
|
||||
|
||||
PROTOCOL_VERSION = "preview-bundle/v1"
|
||||
TRAJECTORY_PROTOCOL_VERSION = "preview-trajectory/v1"
|
||||
|
||||
|
||||
def _slug(value: str) -> str:
|
||||
text = (value or "preview").strip().lower()
|
||||
text = re.sub(r"[^a-z0-9]+", "-", text)
|
||||
text = re.sub(r"-{2,}", "-", text).strip("-")
|
||||
return text or "preview"
|
||||
|
||||
|
||||
def _json_dumps(data: Any) -> str:
|
||||
return json.dumps(
|
||||
data,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
)
|
||||
|
||||
|
||||
def hash_data(data: Any) -> str:
|
||||
return hashlib.sha256(_json_dumps(data).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def fingerprint_data(data: Any) -> str:
|
||||
return f"sha256:{hash_data(data)}"
|
||||
|
||||
|
||||
def fingerprint_file(path: str) -> str:
|
||||
resolved = os.path.abspath(path)
|
||||
stat = os.stat(resolved)
|
||||
return fingerprint_data(
|
||||
{
|
||||
"path": resolved,
|
||||
"size": stat.st_size,
|
||||
"mtime_ns": stat.st_mtime_ns,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def bundle_root(
|
||||
software: str,
|
||||
recipe: str,
|
||||
project_path: Optional[str] = None,
|
||||
root_dir: Optional[str] = None,
|
||||
) -> Path:
|
||||
if root_dir:
|
||||
base = Path(root_dir).expanduser().resolve()
|
||||
elif project_path:
|
||||
base = Path(project_path).expanduser().resolve().parent / ".cli-anything" / "previews"
|
||||
else:
|
||||
base = Path.home() / ".cli-anything" / "previews"
|
||||
return base / _slug(software) / _slug(recipe)
|
||||
|
||||
|
||||
def build_cache_key(
|
||||
software: str,
|
||||
recipe: str,
|
||||
bundle_kind: str,
|
||||
source_fingerprint: str,
|
||||
options: Optional[Dict[str, Any]] = None,
|
||||
harness_version: Optional[str] = None,
|
||||
protocol_version: str = PROTOCOL_VERSION,
|
||||
) -> str:
|
||||
return fingerprint_data(
|
||||
{
|
||||
"protocol_version": protocol_version,
|
||||
"software": software,
|
||||
"recipe": recipe,
|
||||
"bundle_kind": bundle_kind,
|
||||
"source_fingerprint": source_fingerprint,
|
||||
"options": options or {},
|
||||
"harness_version": harness_version or "",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _iter_manifests(search_root: Path) -> Iterable[Path]:
|
||||
if not search_root.exists():
|
||||
return []
|
||||
return sorted(search_root.rglob("manifest.json"), reverse=True)
|
||||
|
||||
|
||||
def _load_json(path: Path) -> Dict[str, Any]:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
def find_cached_manifest(
|
||||
software: str,
|
||||
recipe: str,
|
||||
bundle_kind: str,
|
||||
cache_key: str,
|
||||
project_path: Optional[str] = None,
|
||||
root_dir: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
root = bundle_root(software, recipe, project_path=project_path, root_dir=root_dir)
|
||||
for manifest_path in _iter_manifests(root):
|
||||
try:
|
||||
manifest = _load_json(manifest_path)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
if (
|
||||
manifest.get("protocol_version") == PROTOCOL_VERSION
|
||||
and manifest.get("software") == software
|
||||
and manifest.get("recipe") == recipe
|
||||
and manifest.get("bundle_kind") == bundle_kind
|
||||
and manifest.get("status") in {"ok", "partial"}
|
||||
and manifest.get("cache_key") == cache_key
|
||||
):
|
||||
manifest["_manifest_path"] = str(manifest_path.resolve())
|
||||
manifest["_bundle_dir"] = str(manifest_path.parent.resolve())
|
||||
manifest["_summary_path"] = str(
|
||||
(manifest_path.parent / manifest.get("summary_path", "summary.json")).resolve()
|
||||
)
|
||||
return manifest
|
||||
return None
|
||||
|
||||
|
||||
def find_latest_manifest(
|
||||
software: str,
|
||||
recipe: Optional[str] = None,
|
||||
bundle_kind: Optional[str] = None,
|
||||
project_path: Optional[str] = None,
|
||||
root_dir: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if root_dir:
|
||||
search_root = Path(root_dir).expanduser().resolve() / _slug(software)
|
||||
elif project_path:
|
||||
search_root = Path(project_path).expanduser().resolve().parent / ".cli-anything" / "previews" / _slug(software)
|
||||
else:
|
||||
search_root = Path.home() / ".cli-anything" / "previews" / _slug(software)
|
||||
if recipe:
|
||||
search_root = search_root / _slug(recipe)
|
||||
for manifest_path in _iter_manifests(search_root):
|
||||
try:
|
||||
manifest = _load_json(manifest_path)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
if manifest.get("software") != software:
|
||||
continue
|
||||
if recipe and manifest.get("recipe") != recipe:
|
||||
continue
|
||||
if bundle_kind and manifest.get("bundle_kind") != bundle_kind:
|
||||
continue
|
||||
if manifest.get("status") not in {"ok", "partial"}:
|
||||
continue
|
||||
manifest["_manifest_path"] = str(manifest_path.resolve())
|
||||
manifest["_bundle_dir"] = str(manifest_path.parent.resolve())
|
||||
manifest["_summary_path"] = str(
|
||||
(manifest_path.parent / manifest.get("summary_path", "summary.json")).resolve()
|
||||
)
|
||||
return manifest
|
||||
return None
|
||||
|
||||
|
||||
def prepare_bundle(
|
||||
software: str,
|
||||
recipe: str,
|
||||
bundle_kind: str,
|
||||
source_fingerprint: str,
|
||||
options: Optional[Dict[str, Any]] = None,
|
||||
harness_version: Optional[str] = None,
|
||||
project_path: Optional[str] = None,
|
||||
root_dir: Optional[str] = None,
|
||||
force: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
cache_key = build_cache_key(
|
||||
software=software,
|
||||
recipe=recipe,
|
||||
bundle_kind=bundle_kind,
|
||||
source_fingerprint=source_fingerprint,
|
||||
options=options or {},
|
||||
harness_version=harness_version,
|
||||
)
|
||||
if not force:
|
||||
cached = find_cached_manifest(
|
||||
software=software,
|
||||
recipe=recipe,
|
||||
bundle_kind=bundle_kind,
|
||||
cache_key=cache_key,
|
||||
project_path=project_path,
|
||||
root_dir=root_dir,
|
||||
)
|
||||
if cached:
|
||||
return {
|
||||
"cached": True,
|
||||
"cache_key": cache_key,
|
||||
"bundle_id": cached.get("bundle_id"),
|
||||
"bundle_dir": cached["_bundle_dir"],
|
||||
"manifest_path": cached["_manifest_path"],
|
||||
"summary_path": os.path.join(cached["_bundle_dir"], cached.get("summary_path", "summary.json")),
|
||||
"manifest": cached,
|
||||
}
|
||||
|
||||
now = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
bundle_id = f"{now}_{cache_key.split(':', 1)[-1][:8]}_{_slug(recipe)}"
|
||||
out_dir = bundle_root(software, recipe, project_path=project_path, root_dir=root_dir) / bundle_id
|
||||
artifacts_dir = out_dir / "artifacts"
|
||||
artifacts_dir.mkdir(parents=True, exist_ok=False)
|
||||
return {
|
||||
"cached": False,
|
||||
"cache_key": cache_key,
|
||||
"bundle_id": bundle_id,
|
||||
"bundle_dir": str(out_dir.resolve()),
|
||||
"artifacts_dir": str(artifacts_dir.resolve()),
|
||||
"manifest_path": str((out_dir / "manifest.json").resolve()),
|
||||
"summary_path": str((out_dir / "summary.json").resolve()),
|
||||
}
|
||||
|
||||
|
||||
def artifact_record(
|
||||
bundle_dir: str,
|
||||
path: str,
|
||||
artifact_id: str,
|
||||
role: str,
|
||||
kind: str,
|
||||
label: str,
|
||||
media_type: Optional[str] = None,
|
||||
**extra: Any,
|
||||
) -> Dict[str, Any]:
|
||||
bundle_path = Path(bundle_dir).resolve()
|
||||
file_path = Path(path).resolve()
|
||||
rel_path = file_path.relative_to(bundle_path).as_posix()
|
||||
record: Dict[str, Any] = {
|
||||
"artifact_id": artifact_id,
|
||||
"role": role,
|
||||
"kind": kind,
|
||||
"label": label,
|
||||
"media_type": media_type or mimetypes.guess_type(str(file_path))[0] or "application/octet-stream",
|
||||
"path": rel_path,
|
||||
}
|
||||
if file_path.exists():
|
||||
record["bytes"] = file_path.stat().st_size
|
||||
record.update({k: v for k, v in extra.items() if v is not None})
|
||||
return record
|
||||
|
||||
|
||||
def write_json(path: str, data: Any) -> str:
|
||||
output_path = Path(path).resolve()
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, indent=2, ensure_ascii=False, default=str)
|
||||
fh.write("\n")
|
||||
return str(output_path)
|
||||
|
||||
|
||||
def finalize_bundle(
|
||||
bundle_dir: str,
|
||||
bundle_id: str,
|
||||
bundle_kind: str,
|
||||
software: str,
|
||||
recipe: str,
|
||||
source: Dict[str, Any],
|
||||
artifacts: list[Dict[str, Any]],
|
||||
summary: Dict[str, Any],
|
||||
cache_key: str,
|
||||
generator: Dict[str, Any],
|
||||
status: str = "ok",
|
||||
warnings: Optional[list[str]] = None,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
metrics: Optional[Dict[str, Any]] = None,
|
||||
labels: Optional[list[str]] = None,
|
||||
source_bundles: Optional[list[Dict[str, Any]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
bundle_path = Path(bundle_dir).resolve()
|
||||
summary_rel = "summary.json"
|
||||
summary_path = write_json(str(bundle_path / summary_rel), summary)
|
||||
manifest = {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"bundle_id": bundle_id,
|
||||
"bundle_kind": bundle_kind,
|
||||
"software": software,
|
||||
"recipe": recipe,
|
||||
"status": status,
|
||||
"created_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
"cache_key": cache_key,
|
||||
"generator": generator,
|
||||
"source": source,
|
||||
"summary_path": summary_rel,
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
if warnings:
|
||||
manifest["warnings"] = warnings
|
||||
if context:
|
||||
manifest["context"] = context
|
||||
if metrics:
|
||||
manifest["metrics"] = metrics
|
||||
if labels:
|
||||
manifest["labels"] = labels
|
||||
if source_bundles:
|
||||
manifest["source_bundles"] = source_bundles
|
||||
manifest_path = write_json(str(bundle_path / "manifest.json"), manifest)
|
||||
manifest["_manifest_path"] = manifest_path
|
||||
manifest["_bundle_dir"] = str(bundle_path)
|
||||
manifest["_summary_path"] = summary_path
|
||||
return manifest
|
||||
|
||||
|
||||
def _clean_none_fields(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {key: value for key, value in data.items() if value is not None}
|
||||
|
||||
|
||||
def live_trajectory_path(session_dir: str | Path) -> Path:
|
||||
return Path(session_dir).expanduser().resolve() / "trajectory.json"
|
||||
|
||||
|
||||
def load_live_trajectory(session_dir: str | Path) -> Dict[str, Any]:
|
||||
trajectory_path = live_trajectory_path(session_dir)
|
||||
if not trajectory_path.is_file():
|
||||
return {}
|
||||
return _load_json(trajectory_path)
|
||||
|
||||
|
||||
def summarize_trajectory(trajectory: Dict[str, Any], *, recent_steps: int = 3) -> Dict[str, Any]:
|
||||
steps = list(trajectory.get("steps") or [])
|
||||
latest = steps[-1] if steps else {}
|
||||
recent = steps[-max(1, int(recent_steps)):] if steps else []
|
||||
return _clean_none_fields(
|
||||
{
|
||||
"protocol_version": trajectory.get("protocol_version"),
|
||||
"software": trajectory.get("software"),
|
||||
"recipe": trajectory.get("recipe"),
|
||||
"step_count": trajectory.get("step_count", len(steps)),
|
||||
"current_step_id": trajectory.get("current_step_id"),
|
||||
"latest_command": latest.get("command"),
|
||||
"latest_publish_reason": latest.get("publish_reason"),
|
||||
"latest_bundle_id": latest.get("bundle_id"),
|
||||
"recent_steps": [
|
||||
_clean_none_fields(
|
||||
{
|
||||
"step_id": item.get("step_id"),
|
||||
"step_index": item.get("step_index"),
|
||||
"bundle_id": item.get("bundle_id"),
|
||||
"publish_reason": item.get("publish_reason"),
|
||||
"command": item.get("command"),
|
||||
"command_finished_at": item.get("command_finished_at"),
|
||||
"status": item.get("status"),
|
||||
"cached": item.get("cached"),
|
||||
}
|
||||
)
|
||||
for item in recent
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def build_live_history_item(
|
||||
bundle_manifest: Dict[str, Any],
|
||||
*,
|
||||
step_id: Optional[str] = None,
|
||||
step_index: Optional[int] = None,
|
||||
publish_reason: Optional[str] = None,
|
||||
command: Optional[str] = None,
|
||||
command_started_at: Optional[str] = None,
|
||||
command_finished_at: Optional[str] = None,
|
||||
source_fingerprint: Optional[str] = None,
|
||||
stage_label: Optional[str] = None,
|
||||
note: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
source = bundle_manifest.get("source") or {}
|
||||
resolved_command = command or (bundle_manifest.get("generator") or {}).get("command")
|
||||
resolved_fingerprint = (
|
||||
source_fingerprint
|
||||
or source.get("project_fingerprint")
|
||||
or source.get("capture_fingerprint")
|
||||
)
|
||||
created_at = bundle_manifest.get("created_at")
|
||||
return _clean_none_fields(
|
||||
{
|
||||
"step_id": step_id,
|
||||
"step_index": step_index,
|
||||
"bundle_id": bundle_manifest.get("bundle_id"),
|
||||
"bundle_dir": bundle_manifest.get("_bundle_dir"),
|
||||
"manifest_path": bundle_manifest.get("_manifest_path"),
|
||||
"summary_path": bundle_manifest.get("_summary_path"),
|
||||
"created_at": created_at,
|
||||
"status": bundle_manifest.get("status"),
|
||||
"cached": bool(bundle_manifest.get("cached")),
|
||||
"publish_reason": publish_reason,
|
||||
"command": resolved_command,
|
||||
"command_started_at": command_started_at or created_at,
|
||||
"command_finished_at": command_finished_at or created_at,
|
||||
"source_fingerprint": resolved_fingerprint,
|
||||
"stage_label": stage_label,
|
||||
"note": note,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def append_live_trajectory(
|
||||
session_dir: str | Path,
|
||||
*,
|
||||
software: str,
|
||||
recipe: str,
|
||||
bundle_manifest: Dict[str, Any],
|
||||
publish_reason: str,
|
||||
project_path: Optional[str] = None,
|
||||
project_name: Optional[str] = None,
|
||||
session_name: Optional[str] = None,
|
||||
command: Optional[str] = None,
|
||||
command_started_at: Optional[str] = None,
|
||||
command_finished_at: Optional[str] = None,
|
||||
source_fingerprint: Optional[str] = None,
|
||||
stage_label: Optional[str] = None,
|
||||
note: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
session_path = Path(session_dir).expanduser().resolve()
|
||||
existing = load_live_trajectory(session_path)
|
||||
steps = list(existing.get("steps") or [])
|
||||
finished_at = (
|
||||
command_finished_at
|
||||
or bundle_manifest.get("created_at")
|
||||
or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
)
|
||||
started_at = command_started_at or finished_at
|
||||
step_index = len(steps) + 1
|
||||
step_id = f"step-{step_index:04d}"
|
||||
step = build_live_history_item(
|
||||
bundle_manifest,
|
||||
step_id=step_id,
|
||||
step_index=step_index,
|
||||
publish_reason=publish_reason,
|
||||
command=command,
|
||||
command_started_at=started_at,
|
||||
command_finished_at=finished_at,
|
||||
source_fingerprint=source_fingerprint,
|
||||
stage_label=stage_label,
|
||||
note=note,
|
||||
)
|
||||
steps.append(step)
|
||||
|
||||
trajectory: Dict[str, Any] = dict(existing)
|
||||
trajectory.update(
|
||||
_clean_none_fields(
|
||||
{
|
||||
"protocol_version": TRAJECTORY_PROTOCOL_VERSION,
|
||||
"software": software,
|
||||
"recipe": recipe,
|
||||
"session_name": session_name or session_path.name,
|
||||
"project_path": project_path,
|
||||
"project_name": project_name,
|
||||
"created_at": existing.get("created_at", finished_at),
|
||||
"updated_at": finished_at,
|
||||
"step_count": len(steps),
|
||||
"current_step_id": step_id,
|
||||
}
|
||||
)
|
||||
)
|
||||
trajectory["steps"] = steps
|
||||
trajectory_path = write_json(str(live_trajectory_path(session_path)), trajectory)
|
||||
trajectory["_trajectory_path"] = trajectory_path
|
||||
trajectory["latest_step"] = step
|
||||
return trajectory
|
||||
@@ -0,0 +1,567 @@
|
||||
"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses.
|
||||
|
||||
Copy this file into your CLI package at:
|
||||
cli_anything/<software>/utils/repl_skin.py
|
||||
|
||||
Usage:
|
||||
from cli_anything.<software>.utils.repl_skin import ReplSkin
|
||||
|
||||
skin = ReplSkin("shotcut", version="1.0.0")
|
||||
skin.print_banner() # auto-detects repo-root or packaged SKILL.md
|
||||
prompt_text = skin.prompt(project_name="my_video.mlt", modified=True)
|
||||
skin.success("Project saved")
|
||||
skin.error("File not found")
|
||||
skin.warning("Unsaved changes")
|
||||
skin.info("Processing 24 clips...")
|
||||
skin.status("Track 1", "3 clips, 00:02:30")
|
||||
skin.table(headers, rows)
|
||||
skin.print_goodbye()
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ── ANSI color codes (no external deps for core styling) ──────────────
|
||||
|
||||
_RESET = "\033[0m"
|
||||
_BOLD = "\033[1m"
|
||||
_DIM = "\033[2m"
|
||||
_ITALIC = "\033[3m"
|
||||
_UNDERLINE = "\033[4m"
|
||||
|
||||
# Brand colors
|
||||
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
|
||||
_CYAN_BG = "\033[48;5;80m"
|
||||
_WHITE = "\033[97m"
|
||||
_GRAY = "\033[38;5;245m"
|
||||
_DARK_GRAY = "\033[38;5;240m"
|
||||
_LIGHT_GRAY = "\033[38;5;250m"
|
||||
|
||||
# Software accent colors — each software gets a unique accent
|
||||
_ACCENT_COLORS = {
|
||||
"gimp": "\033[38;5;214m", # warm orange
|
||||
"blender": "\033[38;5;208m", # deep orange
|
||||
"inkscape": "\033[38;5;39m", # bright blue
|
||||
"audacity": "\033[38;5;33m", # navy blue
|
||||
"libreoffice": "\033[38;5;40m", # green
|
||||
"obs_studio": "\033[38;5;55m", # purple
|
||||
"kdenlive": "\033[38;5;69m", # slate blue
|
||||
"shotcut": "\033[38;5;35m", # teal green
|
||||
}
|
||||
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
|
||||
|
||||
# Status colors
|
||||
_GREEN = "\033[38;5;78m"
|
||||
_YELLOW = "\033[38;5;220m"
|
||||
_RED = "\033[38;5;196m"
|
||||
_BLUE = "\033[38;5;75m"
|
||||
_MAGENTA = "\033[38;5;176m"
|
||||
|
||||
_SKILL_SOURCE_REPO = os.environ.get("CLI_ANYTHING_SKILL_REPO", "HKUDS/CLI-Anything")
|
||||
|
||||
# ── Brand icon ────────────────────────────────────────────────────────
|
||||
|
||||
# The cli-anything icon: a small colored diamond/chevron mark
|
||||
_ICON = f"{_CYAN}{_BOLD}◆{_RESET}"
|
||||
_ICON_SMALL = f"{_CYAN}▸{_RESET}"
|
||||
|
||||
# ── Box drawing characters ────────────────────────────────────────────
|
||||
|
||||
_H_LINE = "─"
|
||||
_V_LINE = "│"
|
||||
_TL = "╭"
|
||||
_TR = "╮"
|
||||
_BL = "╰"
|
||||
_BR = "╯"
|
||||
_T_DOWN = "┬"
|
||||
_T_UP = "┴"
|
||||
_T_RIGHT = "├"
|
||||
_T_LEFT = "┤"
|
||||
_CROSS = "┼"
|
||||
|
||||
|
||||
def _strip_ansi(text: str) -> str:
|
||||
"""Remove ANSI escape codes for length calculation."""
|
||||
import re
|
||||
return re.sub(r"\033\[[^m]*m", "", text)
|
||||
|
||||
|
||||
def _visible_len(text: str) -> int:
|
||||
"""Get visible length of text (excluding ANSI codes)."""
|
||||
return len(_strip_ansi(text))
|
||||
|
||||
|
||||
def _display_home_path(path: str) -> str:
|
||||
"""Display a path relative to the home directory when possible."""
|
||||
expanded = Path(path).expanduser().resolve()
|
||||
home = Path.home().resolve()
|
||||
try:
|
||||
relative = expanded.relative_to(home)
|
||||
return f"~/{relative.as_posix()}"
|
||||
except ValueError:
|
||||
return str(expanded)
|
||||
|
||||
|
||||
class ReplSkin:
|
||||
"""Unified REPL skin for cli-anything CLIs.
|
||||
|
||||
Provides consistent branding, prompts, and message formatting
|
||||
across all CLI harnesses built with the cli-anything methodology.
|
||||
"""
|
||||
|
||||
def __init__(self, software: str, version: str = "1.0.0",
|
||||
history_file: str | None = None, skill_path: str | None = None):
|
||||
"""Initialize the REPL skin.
|
||||
|
||||
Args:
|
||||
software: Software name (e.g., "gimp", "shotcut", "blender").
|
||||
version: CLI version string.
|
||||
history_file: Path for persistent command history.
|
||||
Defaults to ~/.cli-anything-<software>/history
|
||||
skill_path: Path to the SKILL.md file for agent discovery.
|
||||
Auto-detected from the repo-root skills/ tree when present,
|
||||
otherwise from the package's skills/ directory.
|
||||
Displayed in banner for AI agents to know where to read skill info.
|
||||
"""
|
||||
self.software = software.lower().replace("-", "_")
|
||||
self.display_name = software.replace("_", " ").title()
|
||||
self.version = version
|
||||
software_aliases = {"iterm2_ctl": "iterm2"}
|
||||
self.skill_slug = software_aliases.get(self.software, self.software).replace("_", "-")
|
||||
self.skill_id = f"cli-anything-{self.skill_slug}"
|
||||
self.skill_install_cmd = (
|
||||
f"npx skills add {_SKILL_SOURCE_REPO} --skill {self.skill_id} -g -y"
|
||||
)
|
||||
global_skill_root = Path(
|
||||
os.environ.get("CLI_ANYTHING_GLOBAL_SKILLS_DIR", str(Path.home() / ".agents" / "skills"))
|
||||
).expanduser()
|
||||
self.global_skill_path = str(global_skill_root / self.skill_id / "SKILL.md")
|
||||
|
||||
# Prefer repo-root canonical skills/<skill-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/skills/SKILL.md for installed harnesses.
|
||||
if skill_path is None:
|
||||
package_skill = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md"
|
||||
repo_skill = None
|
||||
for parent in Path(__file__).resolve().parents:
|
||||
candidate = parent / "skills" / self.skill_id / "SKILL.md"
|
||||
if candidate.is_file():
|
||||
repo_skill = candidate
|
||||
break
|
||||
if repo_skill and repo_skill.is_file():
|
||||
skill_path = str(repo_skill)
|
||||
elif package_skill.is_file():
|
||||
skill_path = str(package_skill)
|
||||
self.skill_path = skill_path
|
||||
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
|
||||
|
||||
# History file
|
||||
if history_file is None:
|
||||
hist_dir = Path.home() / f".cli-anything-{self.software}"
|
||||
hist_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.history_file = str(hist_dir / "history")
|
||||
else:
|
||||
self.history_file = history_file
|
||||
|
||||
# Detect terminal capabilities
|
||||
self._color = self._detect_color_support()
|
||||
|
||||
def _detect_color_support(self) -> bool:
|
||||
"""Check if terminal supports color."""
|
||||
if os.environ.get("NO_COLOR"):
|
||||
return False
|
||||
if os.environ.get("CLI_ANYTHING_NO_COLOR"):
|
||||
return False
|
||||
if not hasattr(sys.stdout, "isatty"):
|
||||
return False
|
||||
return sys.stdout.isatty()
|
||||
|
||||
def _c(self, code: str, text: str) -> str:
|
||||
"""Apply color code if colors are supported."""
|
||||
if not self._color:
|
||||
return text
|
||||
return f"{code}{text}{_RESET}"
|
||||
|
||||
# ── Banner ────────────────────────────────────────────────────────
|
||||
|
||||
def print_banner(self):
|
||||
"""Print the startup banner with branding."""
|
||||
import textwrap
|
||||
|
||||
inner = 72
|
||||
|
||||
def _box_line(content: str) -> str:
|
||||
"""Wrap content in box drawing, padding to inner width."""
|
||||
pad = inner - _visible_len(content)
|
||||
vl = self._c(_DARK_GRAY, _V_LINE)
|
||||
return f"{vl}{content}{' ' * max(0, pad)}{vl}"
|
||||
|
||||
def _meta_lines(label: str, value: str) -> list[str]:
|
||||
"""Wrap a metadata line for the banner box."""
|
||||
icon = self._c(_MAGENTA, "◇")
|
||||
label_text = self._c(_DARK_GRAY, label)
|
||||
prefix = f" {icon} {label_text} "
|
||||
available = max(12, inner - _visible_len(prefix))
|
||||
wrapped = textwrap.wrap(
|
||||
value,
|
||||
width=available,
|
||||
break_long_words=True,
|
||||
break_on_hyphens=False,
|
||||
) or [""]
|
||||
lines = [f"{prefix}{self._c(_LIGHT_GRAY, wrapped[0])}"]
|
||||
continuation_prefix = " " * _visible_len(prefix)
|
||||
for chunk in wrapped[1:]:
|
||||
lines.append(f"{continuation_prefix}{self._c(_LIGHT_GRAY, chunk)}")
|
||||
return lines
|
||||
|
||||
top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}")
|
||||
bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}")
|
||||
|
||||
# Title: ◆ cli-anything · Shotcut
|
||||
icon = self._c(_CYAN + _BOLD, "◆")
|
||||
brand = self._c(_CYAN + _BOLD, "cli-anything")
|
||||
dot = self._c(_DARK_GRAY, "·")
|
||||
name = self._c(self.accent + _BOLD, self.display_name)
|
||||
title = f" {icon} {brand} {dot} {name}"
|
||||
|
||||
ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}"
|
||||
tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}"
|
||||
empty = ""
|
||||
|
||||
meta_lines: list[str] = []
|
||||
meta_lines.extend(_meta_lines("Install:", self.skill_install_cmd))
|
||||
meta_lines.extend(_meta_lines("Global skill:", _display_home_path(self.global_skill_path)))
|
||||
print(top)
|
||||
print(_box_line(title))
|
||||
print(_box_line(ver))
|
||||
for line in meta_lines:
|
||||
print(_box_line(line))
|
||||
print(_box_line(empty))
|
||||
print(_box_line(tip))
|
||||
print(bot)
|
||||
print()
|
||||
|
||||
# ── Prompt ────────────────────────────────────────────────────────
|
||||
|
||||
def prompt(self, project_name: str = "", modified: bool = False,
|
||||
context: str = "") -> str:
|
||||
"""Build a styled prompt string for prompt_toolkit or input().
|
||||
|
||||
Args:
|
||||
project_name: Current project name (empty if none open).
|
||||
modified: Whether the project has unsaved changes.
|
||||
context: Optional extra context to show in prompt.
|
||||
|
||||
Returns:
|
||||
Formatted prompt string.
|
||||
"""
|
||||
parts = []
|
||||
|
||||
# Icon
|
||||
if self._color:
|
||||
parts.append(f"{_CYAN}◆{_RESET} ")
|
||||
else:
|
||||
parts.append("> ")
|
||||
|
||||
# Software name
|
||||
parts.append(self._c(self.accent + _BOLD, self.software))
|
||||
|
||||
# Project context
|
||||
if project_name or context:
|
||||
ctx = context or project_name
|
||||
mod = "*" if modified else ""
|
||||
parts.append(f" {self._c(_DARK_GRAY, '[')}")
|
||||
parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}"))
|
||||
parts.append(self._c(_DARK_GRAY, ']'))
|
||||
|
||||
parts.append(self._c(_GRAY, " ❯ "))
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
def prompt_tokens(self, project_name: str = "", modified: bool = False,
|
||||
context: str = ""):
|
||||
"""Build prompt_toolkit formatted text tokens for the prompt.
|
||||
|
||||
Use with prompt_toolkit's FormattedText for proper ANSI handling.
|
||||
|
||||
Returns:
|
||||
list of (style, text) tuples for prompt_toolkit.
|
||||
"""
|
||||
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
|
||||
tokens = []
|
||||
|
||||
tokens.append(("class:icon", "◆ "))
|
||||
tokens.append(("class:software", self.software))
|
||||
|
||||
if project_name or context:
|
||||
ctx = context or project_name
|
||||
mod = "*" if modified else ""
|
||||
tokens.append(("class:bracket", " ["))
|
||||
tokens.append(("class:context", f"{ctx}{mod}"))
|
||||
tokens.append(("class:bracket", "]"))
|
||||
|
||||
tokens.append(("class:arrow", " ❯ "))
|
||||
|
||||
return tokens
|
||||
|
||||
def get_prompt_style(self):
|
||||
"""Get a prompt_toolkit Style object matching the skin.
|
||||
|
||||
Returns:
|
||||
prompt_toolkit.styles.Style
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit.styles import Style
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
|
||||
|
||||
return Style.from_dict({
|
||||
"icon": "#5fdfdf bold", # cyan brand color
|
||||
"software": f"{accent_hex} bold",
|
||||
"bracket": "#585858",
|
||||
"context": "#bcbcbc",
|
||||
"arrow": "#808080",
|
||||
# Completion menu
|
||||
"completion-menu.completion": "bg:#303030 #bcbcbc",
|
||||
"completion-menu.completion.current": f"bg:{accent_hex} #000000",
|
||||
"completion-menu.meta.completion": "bg:#303030 #808080",
|
||||
"completion-menu.meta.completion.current": f"bg:{accent_hex} #000000",
|
||||
# Auto-suggest
|
||||
"auto-suggest": "#585858",
|
||||
# Bottom toolbar
|
||||
"bottom-toolbar": "bg:#1c1c1c #808080",
|
||||
"bottom-toolbar.text": "#808080",
|
||||
})
|
||||
|
||||
# ── Messages ──────────────────────────────────────────────────────
|
||||
|
||||
def success(self, message: str):
|
||||
"""Print a success message with green checkmark."""
|
||||
icon = self._c(_GREEN + _BOLD, "✓")
|
||||
print(f" {icon} {self._c(_GREEN, message)}")
|
||||
|
||||
def error(self, message: str):
|
||||
"""Print an error message with red cross."""
|
||||
icon = self._c(_RED + _BOLD, "✗")
|
||||
print(f" {icon} {self._c(_RED, message)}", file=sys.stderr)
|
||||
|
||||
def warning(self, message: str):
|
||||
"""Print a warning message with yellow triangle."""
|
||||
icon = self._c(_YELLOW + _BOLD, "⚠")
|
||||
print(f" {icon} {self._c(_YELLOW, message)}")
|
||||
|
||||
def info(self, message: str):
|
||||
"""Print an info message with blue dot."""
|
||||
icon = self._c(_BLUE, "●")
|
||||
print(f" {icon} {self._c(_LIGHT_GRAY, message)}")
|
||||
|
||||
def hint(self, message: str):
|
||||
"""Print a subtle hint message."""
|
||||
print(f" {self._c(_DARK_GRAY, message)}")
|
||||
|
||||
def section(self, title: str):
|
||||
"""Print a section header."""
|
||||
print()
|
||||
print(f" {self._c(self.accent + _BOLD, title)}")
|
||||
print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}")
|
||||
|
||||
# ── Status display ────────────────────────────────────────────────
|
||||
|
||||
def status(self, label: str, value: str):
|
||||
"""Print a key-value status line."""
|
||||
lbl = self._c(_GRAY, f" {label}:")
|
||||
val = self._c(_WHITE, f" {value}")
|
||||
print(f"{lbl}{val}")
|
||||
|
||||
def status_block(self, items: dict[str, str], title: str = ""):
|
||||
"""Print a block of status key-value pairs.
|
||||
|
||||
Args:
|
||||
items: Dict of label -> value pairs.
|
||||
title: Optional title for the block.
|
||||
"""
|
||||
if title:
|
||||
self.section(title)
|
||||
|
||||
max_key = max(len(k) for k in items) if items else 0
|
||||
for label, value in items.items():
|
||||
lbl = self._c(_GRAY, f" {label:<{max_key}}")
|
||||
val = self._c(_WHITE, f" {value}")
|
||||
print(f"{lbl}{val}")
|
||||
|
||||
def progress(self, current: int, total: int, label: str = ""):
|
||||
"""Print a simple progress indicator.
|
||||
|
||||
Args:
|
||||
current: Current step number.
|
||||
total: Total number of steps.
|
||||
label: Optional label for the progress.
|
||||
"""
|
||||
pct = int(current / total * 100) if total > 0 else 0
|
||||
bar_width = 20
|
||||
filled = int(bar_width * current / total) if total > 0 else 0
|
||||
bar = "█" * filled + "░" * (bar_width - filled)
|
||||
text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}"
|
||||
if label:
|
||||
text += f" {self._c(_LIGHT_GRAY, label)}"
|
||||
print(text)
|
||||
|
||||
# ── Table display ─────────────────────────────────────────────────
|
||||
|
||||
def table(self, headers: list[str], rows: list[list[str]],
|
||||
max_col_width: int = 40):
|
||||
"""Print a formatted table with box-drawing characters.
|
||||
|
||||
Args:
|
||||
headers: Column header strings.
|
||||
rows: List of rows, each a list of cell strings.
|
||||
max_col_width: Maximum column width before truncation.
|
||||
"""
|
||||
if not headers:
|
||||
return
|
||||
|
||||
# Calculate column widths
|
||||
col_widths = [min(len(h), max_col_width) for h in headers]
|
||||
for row in rows:
|
||||
for i, cell in enumerate(row):
|
||||
if i < len(col_widths):
|
||||
col_widths[i] = min(
|
||||
max(col_widths[i], len(str(cell))), max_col_width
|
||||
)
|
||||
|
||||
def pad(text: str, width: int) -> str:
|
||||
t = str(text)[:width]
|
||||
return t + " " * (width - len(t))
|
||||
|
||||
# Header
|
||||
header_cells = [
|
||||
self._c(_CYAN + _BOLD, pad(h, col_widths[i]))
|
||||
for i, h in enumerate(headers)
|
||||
]
|
||||
sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
|
||||
header_line = f" {sep.join(header_cells)}"
|
||||
print(header_line)
|
||||
|
||||
# Separator
|
||||
sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths]
|
||||
sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}")
|
||||
print(sep_line)
|
||||
|
||||
# Rows
|
||||
for row in rows:
|
||||
cells = []
|
||||
for i, cell in enumerate(row):
|
||||
if i < len(col_widths):
|
||||
cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i])))
|
||||
row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
|
||||
print(f" {row_sep.join(cells)}")
|
||||
|
||||
# ── Help display ──────────────────────────────────────────────────
|
||||
|
||||
def help(self, commands: dict[str, str]):
|
||||
"""Print a formatted help listing.
|
||||
|
||||
Args:
|
||||
commands: Dict of command -> description pairs.
|
||||
"""
|
||||
self.section("Commands")
|
||||
max_cmd = max(len(c) for c in commands) if commands else 0
|
||||
for cmd, desc in commands.items():
|
||||
cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}")
|
||||
desc_styled = self._c(_GRAY, f" {desc}")
|
||||
print(f"{cmd_styled}{desc_styled}")
|
||||
print()
|
||||
|
||||
# ── Goodbye ───────────────────────────────────────────────────────
|
||||
|
||||
def print_goodbye(self):
|
||||
"""Print a styled goodbye message."""
|
||||
print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n")
|
||||
|
||||
# ── Prompt toolkit session factory ────────────────────────────────
|
||||
|
||||
def create_prompt_session(self):
|
||||
"""Create a prompt_toolkit PromptSession with skin styling.
|
||||
|
||||
Returns:
|
||||
A configured PromptSession, or None if prompt_toolkit unavailable.
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.history import FileHistory
|
||||
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
|
||||
style = self.get_prompt_style()
|
||||
|
||||
session = PromptSession(
|
||||
history=FileHistory(self.history_file),
|
||||
auto_suggest=AutoSuggestFromHistory(),
|
||||
style=style,
|
||||
enable_history_search=True,
|
||||
)
|
||||
return session
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
def get_input(self, pt_session, project_name: str = "",
|
||||
modified: bool = False, context: str = "") -> str:
|
||||
"""Get input from user using prompt_toolkit or fallback.
|
||||
|
||||
Args:
|
||||
pt_session: A prompt_toolkit PromptSession (or None).
|
||||
project_name: Current project name.
|
||||
modified: Whether project has unsaved changes.
|
||||
context: Optional context string.
|
||||
|
||||
Returns:
|
||||
User input string (stripped).
|
||||
"""
|
||||
if pt_session is not None:
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
tokens = self.prompt_tokens(project_name, modified, context)
|
||||
return pt_session.prompt(FormattedText(tokens)).strip()
|
||||
else:
|
||||
raw_prompt = self.prompt(project_name, modified, context)
|
||||
return input(raw_prompt).strip()
|
||||
|
||||
# ── Toolbar builder ───────────────────────────────────────────────
|
||||
|
||||
def bottom_toolbar(self, items: dict[str, str]):
|
||||
"""Create a bottom toolbar callback for prompt_toolkit.
|
||||
|
||||
Args:
|
||||
items: Dict of label -> value pairs to show in toolbar.
|
||||
|
||||
Returns:
|
||||
A callable that returns FormattedText for the toolbar.
|
||||
"""
|
||||
def toolbar():
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
parts = []
|
||||
for i, (k, v) in enumerate(items.items()):
|
||||
if i > 0:
|
||||
parts.append(("class:bottom-toolbar.text", " │ "))
|
||||
parts.append(("class:bottom-toolbar.text", f" {k}: "))
|
||||
parts.append(("class:bottom-toolbar", v))
|
||||
return FormattedText(parts)
|
||||
return toolbar
|
||||
|
||||
|
||||
# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ─────────
|
||||
|
||||
_ANSI_256_TO_HEX = {
|
||||
"\033[38;5;33m": "#0087ff", # audacity navy blue
|
||||
"\033[38;5;35m": "#00af5f", # shotcut teal
|
||||
"\033[38;5;39m": "#00afff", # inkscape bright blue
|
||||
"\033[38;5;40m": "#00d700", # libreoffice green
|
||||
"\033[38;5;55m": "#5f00af", # obs purple
|
||||
"\033[38;5;69m": "#5f87ff", # kdenlive slate blue
|
||||
"\033[38;5;75m": "#5fafff", # default sky blue
|
||||
"\033[38;5;80m": "#5fd7d7", # brand cyan
|
||||
"\033[38;5;208m": "#ff8700", # blender deep orange
|
||||
"\033[38;5;214m": "#ffaf00", # gimp warm orange
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Setup for cli-anything-renderdoc package."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from setuptools import setup, find_namespace_packages
|
||||
|
||||
_README = Path(__file__).parent / "cli_anything" / "renderdoc" / "README.md"
|
||||
_long_desc = _README.read_text(encoding="utf-8") if _README.is_file() else ""
|
||||
|
||||
setup(
|
||||
name="cli-anything-renderdoc",
|
||||
version="0.1.0",
|
||||
description="CLI harness for RenderDoc graphics debugger",
|
||||
long_description=_long_desc,
|
||||
long_description_content_type="text/markdown",
|
||||
author="cli-anything",
|
||||
packages=find_namespace_packages(include=["cli_anything.*"]),
|
||||
python_requires=">=3.10",
|
||||
install_requires=[
|
||||
"click>=8.0",
|
||||
"prompt-toolkit>=3.0",
|
||||
],
|
||||
extras_require={
|
||||
"test": ["pytest>=7.0"],
|
||||
},
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"cli-anything-renderdoc=cli_anything.renderdoc.renderdoc_cli:main",
|
||||
],
|
||||
},
|
||||
package_data={
|
||||
"cli_anything.renderdoc": ["skills/*.md", "README.md"],
|
||||
},
|
||||
include_package_data=True,
|
||||
zip_safe=False,
|
||||
classifiers=[
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Topic :: Software Development :: Debuggers",
|
||||
],
|
||||
)
|
||||
Reference in New Issue
Block a user