Skill: ayakimovich/synthclaw (version 0.1.3)

--- name: synthclaw version: 0.1.3 license: MIT description: Render Blender files with agent-controlled procedural parameters for synthetic data generation. Use when generating training data with controlled variations, creating procedural image datasets, or automating Blender renders via natural language. Supports CYCLES (production) and EEVEE (fast testing) render engines. --- ## When to Use - Generate synthetic training data with controlled parameter variations - Create procedural image datasets with ground truth metadata - Automate rendering workflows for ML training data - When you need parameter-sweep renders without manual Blender interaction ## When NOT to Use - Real-time rendering or interactive preview needs (this is batch/offline) - Complex scene manipulation beyond Value Node adjustments - If Blender is not installed or unavailable in PATH ## Requirements - Blender 4.0+ installed and available in `$PATH` - Python 3.10+ for the synthclaw package - Cycles or EEVEE render engine (auto-selected) ## Configuration No additional configuration required. Ensure `blender` command is available: ```bash blender --version ``` ## Tools ### render_procedural_scene Adjusts procedural Value Nodes and renders a frame in Blender. **Parameters:** - `blend_file` (string, required): Absolute path to the .blend file - `parameters` (object, required): Key-value pairs of Value Node names and float values (e.g., `{"GrainScale": 2.5, "Roughness": 0.3}`) - `output_path` (string, required): Where to save the rendered image (e.g., `/path/to/output.png`) - `samples` (integer, optional): Cycles samples (default: 128). Ignored for EEVEE. - `engine` (string, optional): Render engine - `"CYCLES"` (default) or `"EEVEE"` - `timeout` (integer, optional): Custom timeout in seconds. Defaults: 1800 for CYCLES, 60 for EEVEE. - `reference_image` (string, optional): Complete path to a real-world reference image. Used for computing LPIPS similarity and Naturalness Delta. - `compute_metrics` (boolean, optional): Set to `true` to compute Naturalness/LPIPS metrics after rendering. Default `false`. **Returns:** - On success: `{"status": "success", "output": "/path/to/output.png", "log": "...", "engine": "CYCLES", "samples": 128, "metrics": {"naturalness_mean": 0.85, "lpips_alex": 0.12}}` - On error: `{"status": "error", "message": "..."}` **Examples:** *Production quality (CYCLES):* ```json { "blend_file": "/home/user/project/assets/test.blend", "output_path": "/home/user/output/render_01.png", "parameters": { "GrainScale": 3.0, "DisplacementStrength": 1.5 }, "engine": "CYCLES", "samples": 256 } ``` *Fast testing (EEVEE):* ```json { "blend_file": "/home/user/project/assets/test.blend", "output_path": "/home/user/output/test_render.png", "parameters": { "GrainScale": 3.0 }, "engine": "EEVEE" } ``` ### render_procedural_scene_fast Convenience function for fast EEVEE rendering. Same as `render_procedural_scene` with `engine="EEVEE"`. **Parameters:** - `blend_file` (string, required): Absolute path to the .blend file - `parameters` (object, required): Key-value pairs of Value Node names and float values - `output_path` (string, required): Where to save the rendered image ### render_procedural_scene_production Convenience function for production Cycles rendering. Same as `render_procedural_scene` with `engine="CYCLES"` and higher samples. **Parameters:** - `blend_file` (string, required): Absolute path to the .blend file - `parameters` (object, required): Key-value pairs of Value Node names and float values - `output_path` (string, required): Where to save the rendered image - `samples` (integer, optional): Cycles samples (default: 512) ### analyze_blend Analyzes a .blend file and returns available Value Nodes that can be manipulated. **Parameters:** - `blend_file` (string, required): Absolute path to the .blend file **Returns:** Dict containing `status`, a `complexity` object evaluating scene realism, and `value_nodes` (available parameter names with current values). ## Engine Comparison | Feature | CYCLES | EEVEE | |---------|--------|-------| | Quality | Photorealistic | Real-time | | Speed | Slow (minutes) | Fast (seconds) | | Timeout | 30 minutes | 1 minute | | Use case | Production | Testing | | Samples | Configurable | N/A | ## Safety & Limitations - **Headless execution:** Blender runs with `-b` flag for security - **Parameter validation:** Only float values accepted; non-numeric input is rejected - **No shell injection:** Uses `subprocess.run(shell=False)` with `--` separator - **CPU fallback:** Automatically uses CPU rendering for Cycles if no GPU available - **Timeout protection:** Long renders are killed after timeout to prevent hanging ## Files | File | Purpose | |------|---------| | `src/synthclaw/blender_skill.py` | OpenClaw execution wrapper with engine selection | | `scripts/agent_bridge.py` | Blender-side Python script (handles both engines) | | `scripts/analyze_blends.py` | Blender-side analysis script | | `config/render_schema.json` | Tool schema for LLM function calling | | `config/analyze_schema.json` | Schema for blend file analysis | ## Example Workflow 1. User: "Render with grain scale increased and surface rougher" 2. Agent calls `analyze_blend` to see available parameters 3. Agent calls `render_procedural_scene_fast` (EEVEE) for quick preview 4. If preview looks good, agent calls [REDACTED_SECRET] (CYCLES) for final output 5. Render completes, path returned to user ## Version Compatible with Blender 4.0+. Not backwards compatible with 2.7x.

path: README.md
content: 
# SynthClaw: OpenClaw Agent for Blender Procedural Data Render Skill

This project transforms static Blender files into **agentic tools**. By bridging OpenClaw with Blender’s Python API (`bpy`), an LLM can autonomously manipulate procedural nodes (like noise scale, roughness, or color intensity) and trigger high-quality Cycles renders based on natural language prompts.

## 🛠 Project Purpose

The goal is to move away from manual "slider-pushing." Instead of opening Blender to tweak a material, you can tell the agent:

> *"Generate a render where the texture is much grainier and the surface looks like wet marble."*

The agent identifies the correct **Value Nodes**, updates their defaults, and returns a rendered image for review.

### Supported Render Engines
- **CYCLES**: Production quality, photorealistic (slower, 30 min timeout)
- **EEVEE**: Fast real-time rendering (testing, 1 min timeout)

---

## 📂 Project Structure

This repository is designed as an installable Python package for OpenClaw agents:

* `src/synthclaw/`: Contains the OpenClaw execution wrappers (`blender_skill.py`, `analyze_skill.py`) that trigger background system processes.
* `scripts/`: Internal Blender execution code (`agent_bridge.py`, `analyze_blends.py`). These are loaded directly into Blender by the wrappers.
* `config/`: JSON tool schemas provided to your LLM agent (`render_schema.json`, `analyze_schema.json`).
* `assets/`: Contains `low.blend` and `high.blend` required for testing agent configurations.
* `tests/`: Automated unit tests for validating Blender environment setup and output.
* `pyproject.toml`: Local package definition.

---

## 🚀 Setup Instructions

### 1. Blender File Preparation (Blender 4.0+)

For the agent to "see" your controls, you must explicitly name your **Value Nodes**:

1. Open your `.blend` file.
2. In the **Shader Editor**, add an **Input > Value** node.
3. Connect it to your procedural math/texture inputs.
4. **Critical Step:** Select the Value node, press `N` for the sidebar, and under **Item > Name**, set a unique name (e.g., `GrainScale`).
* *Note: The script looks at the internal Name, not just the Label.*



### 2. Environment Configuration

Ensure `blender` is in your system `$PATH`. Test this by running:

```bash
blender --version

```

*Requirement: Version 4.0.0 or higher.*

### 3. Installation

1. Place `agent_bridge.py` in your project root or a known scripts directory.
2. Register the `blender_skill.py` function within your OpenClaw worker.
3. Update the `blend_file` path in your tool call to point to your specific `.blend` assets.

---

## 🤖 Agentic Workflow

### Node Manipulation and Rendering
1. **User Input:** "Make the displacement more aggressive."
2. **LLM Reasoning:** The LLM looks at the tool schema, sees a parameter named `DisplacementStrength`, and decides to increase its value from `1.0` to `2.5`.
3. **Execution:** OpenClaw runs Blender in **Headless Mode** (`-b`).
4. **Modification:** `agent_bridge.py` iterates through all materials, finds the node named `DisplacementStrength`, and updates it.
5. **Output:** A `.png` is rendered and the path is returned to the user.

### Scene Complexity Analysis
Before rendering, the agent can run `analyze_blend`. This script parses the `.blend` file without launching GUI overhead. It examines poly-count, material configurations, lighting rigs, and rendering setups to produce a **Complexity & Realism Score**. This gives agents foresight on the visual quality they can generate.

### Image Quality Metrics
When generating outputs, the tool supports quantitative evaluation of the rendered image:
- **Naturalness (GraNatPy):** When `compute_metrics: true` is passed, the skill leverages `granatpy` to compute the naturalness factor of the generated image.
- **Reference Image Similarity (LPIPS):** If an optional `reference_image` path (a real-world photo) is provided alongside `compute_metrics: true`, the skill dynamically compares the render against the real photo. It returns the Learned Perceptual Image Patch Similarity (LPIPS) score and the delta Naturalness Factor (dNf), measuring how close the synthetic image is to reality.

---

## ⚠️ Limitations & Safety

* **Cycles Rendering:** This skill defaults to the Cycles engine. If running on a headless server without a GPU, the script is configured to fallback to CPU rendering to prevent driver crashes.
* **Injection Safety:** The bridge uses `sys.argv` filtering. However, ensure the LLM is restricted from passing arbitrary string commands into the `parameters` dictionary.
* **Version Lock:** This project uses `bpy` syntax specific to the **4.0+** mesh and node system. It is not backwards compatible with 2.7x or early 2.8x versions.

---

## 📝 License

This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

path: config/analyze_schema.json
content: {
    "name": "analyze_blender_file",
    "description": "Headlessly analyzes a Blender file to extract its manipulatable procedural node parameters AND evaluates its mathematical complexity to return a natural-language description of how realistic the imagery would be.",
    "parameters": {
        "type": "object",
        "properties": {
            "blend_file": {
                "type": "string",
                "description": "Full path to .blend file (e.g., assets/high.blend)"
            }
        },
        "required": [
            "blend_file"
        ]
    }
}

path: config/render_schema.json
content: {
  "name": "render_procedural_scene",
  "description": "Renders a procedural scene. Pass node names as keys and floats as values.",
  "parameters": {
    "type": "object",
    "properties": {
      "blend_file": {
        "type": "string",
        "description": "Full path to .blend file"
      },
      "output_path": {
        "type": "string",
        "description": "Where to save the PNG"
      },
      "parameters": {
        "type": "object",
        "description": "Dictionary of Blender Value Node names and their new values.",
        "additionalProperties": {
          "type": "number"
        }
      },
      "reference_image": {
        "type": "string",
        "description": "Optional complete path to a real-world reference image for LPIPS and Naturalness Delta computation."
      },
      "compute_metrics": {
        "type": "boolean",
        "description": "Optional flag to compute and return image quality metrics. Default is false."
      }
    },
    "required": [
      "blend_file",
      "output_path",
      "parameters"
    ]
  }
}

path: pyproject.toml
content: [build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

[project]
name = "synthclaw"
version = "0.1.3"
description = "OpenClaw agent skill to headlessly interact with Blender procedural nodes."
readme = "README.md"
license = { file = "LICENSE" }
requires-python = ">=3.9"
dependencies = [
    "granatpy",
    "lpips"
]

[tool.setuptools.packages.find]
where = ["src"]


path: scripts/agent_bridge.py
content: import bpy
import sys
import os


def set_render_engine():
    """
    Set render engine BEFORE any rendering happens.
    Must be called before the render operation.
    """
    engine = os.environ.get("BLENDER_ENGINE", "CYCLES").upper()
    samples = int(os.environ.get("BLENDER_SAMPLES", "128"))
    
    if engine in ["EEVEE", "BLENDER_EEVEE"]:
        # Use EEVEE for fast testing
        bpy.context.scene.render.engine = 'BLENDER_EEVEE'
        print(f"[SynthClaw] Using EEVEE engine (fast mode)")
    else:
        # Use Cycles for production quality
        bpy.context.scene.render.engine = 'CYCLES'
        bpy.context.scene.cycles.samples = samples
        print(f"[SynthClaw] Using CYCLES engine with {samples} samples (production mode)")
        
        # CPU fallback for headless/server environments without GPU
        try:
            if bpy.context.scene.cycles.device not in ['CPU', 'GPU']:
                bpy.context.scene.cycles.device = 'CPU'
                print("[SynthClaw] Cycles device set to CPU (fallback)")
            else:
                print(f"[SynthClaw] Cycles device: {bpy.context.scene.cycles.device}")
        except Exception as e:
            print(f"[SynthClaw] Could not set Cycles device: {e}")
            bpy.context.scene.cycles.device = 'CPU'
            print("[SynthClaw] Falling back to CPU rendering")


def update_value_nodes(params):
    """
    Update Value Nodes in materials based on parameter dictionary.
    """
    updated_nodes = []
    missing_nodes = []
    
    for mat in bpy.data.materials:
        if not mat.use_nodes:
            continue
        
        nodes = mat.node_tree.nodes
        for key, val in params.items():
            if key in nodes and nodes[key].type == 'VALUE':
                try:
                    nodes[key].outputs[0].default_value = val
                    updated_nodes.append((key, val))
                    print(f"[SynthClaw] Updated Node '{key}' to {val}")
                except Exception as e:
                    print(f"[SynthClaw] Error updating node '{key}': {e}")
            elif key not in nodes:
                missing_nodes.append(key)
    
    if missing_nodes:
        print(f"[SynthClaw] Warning: Nodes not found: {', '.join(missing_nodes)}")
    
    return updated_nodes


def main():
    """
    Agent Bridge: Reads parameters from CLI, updates Value Nodes, sets engine, and renders.
    """
    # Parse CLI arguments
    try:
        args = sys.argv[sys.argv.index("--") + 1:]
    except ValueError:
        print("[SynthClaw] No parameters provided via CLI.")
        args = []
    
    # Parse key=value pairs
    params = {}
    for a in args:
        if '=' in a:
            try:
                k, v = a.split('=', 1)
                params[k] = float(v)
            except ValueError:
                print(f"[SynthClaw] Warning: Could not parse value for '{a}', skipping")
                continue
    
    # STEP 1: Set render engine FIRST (before any modifications)
    set_render_engine()
    
    # STEP 2: Update Value Nodes
    updated_nodes = update_value_nodes(params)
    
    # Print summary
    print(f"[SynthClaw] Updated {len(updated_nodes)} nodes:")
    for name, val in updated_nodes:
        print(f"  - {name}: {val}")
    
    print(f"[SynthClaw] Ready to render with {bpy.context.scene.render.engine}...")


if __name__ == "__main__":
    main()


path: scripts/analyze_blends.py
content: import bpy
import json


def assess_complexity():
    # 1. Geometry Complexity
    total_polygons = sum([len(m.polygons) for m in bpy.data.meshes])
    num_objects = len(bpy.data.objects)
    
    # 2. Material Complexity
    num_materials = len(bpy.data.materials)
    node_count = sum([len(m.node_tree.nodes) for m in bpy.data.materials if m.use_nodes])
    
    # 3. Lighting & Render Engine
    engine = bpy.context.scene.render.engine
    num_lights = len(bpy.data.lights)
    
    # Heuristic Scoring
    score = 0
    if engine == 'CYCLES': score += 3
    if total_polygons > 10000: score += 1
    if total_polygons > 100000: score += 1
    if num_materials > 3: score += 1
    if node_count > 15: score += 1
    if num_lights > 0: score += 1
    
    if score >= 6:
        rating = "Photorealistic"
        desc = f"Very high complexity scene using {engine}. Advanced geometry ({total_polygons} polys) and rich material node trees."
    elif score >= 4:
        rating = "High Quality"
        desc = f"Detailed scene capable of strong renders. Good lighting logic and {num_materials} materials."
    elif score >= 2:
        rating = "Medium (Stylized/Real-time)"
        desc = f"Moderate complexity using {engine}. Likely a real-time game asset or stylized object."
    else:
        rating = "Low"
        desc = "Very simple scene. Flat shading or extremely basic geometry. Not recommended for photorealism."
        
    return {
        "rating": rating,
        "score": score,
        "polygons": total_polygons,
        "objects": num_objects,
        "materials": num_materials,
        "lights": num_lights,
        "engine": engine,
        "description": desc
    }


def main():
    """
    Analyzes a Blender file and outputs realism metrics and available Value Nodes as JSON.
    """
    value_nodes = {}
    
    # Iterate through all materials
    for mat in bpy.data.materials:
        if not mat.use_nodes:
            continue
        
        nodes = mat.node_tree.nodes
        for node in nodes:
            # Look for Value nodes that can be manipulated
            if node.type == 'VALUE':
                node_name = node.name
                current_value = node.outputs[0].default_value
                value_nodes[node_name] = {
                    "value": current_value,
                    "material": mat.name
                }
    
    # Also check for geometry nodes if present
    try:
        if hasattr(bpy.data, "node_groups"):
            for group in bpy.data.node_groups:
                for node in group.nodes:
                    if node.type == 'VALUE':
                        node_name = node.name
                        current_value = node.outputs[0].default_value
                        value_nodes[f"{group.name}.{node_name}"] = {
                            "value": current_value,
                            "node_group": group.name
                        }
    except Exception:
        pass
    
    output_data = {
        "complexity": assess_complexity(),
        "value_nodes": value_nodes
    }
    
    # Output as JSON for easy parsing between markers
    print("---ANALYSIS_START---")
    print(json.dumps(output_data, indent=2))
    print("---ANALYSIS_END---")


if __name__ == "__main__":
    main()


path: scripts/create_test_blend.py
content: import bpy
import os

# Clear existing objects
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()

# Create a simple cube
bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0))
cube = bpy.context.active_object

# Create a new material
mat = bpy.data.materials.new(name="TestMaterial")
mat.use_nodes = True

# Clear default nodes
mat.node_tree.nodes.clear()

# Add nodes
output_node = mat.node_tree.nodes.new(type='ShaderNodeOutputMaterial')
output_node.location = (300, 0)

diffuse_node = mat.node_tree.nodes.new(type='ShaderNodeBsdfDiffuse')
diffuse_node.location = (0, 0)

# Add a Value node that can be controlled by the agent
value_node = mat.node_tree.nodes.new(type='ShaderNodeValue')
value_node.location = (-300, 0)
value_node.outputs[0].default_value = 0.5

# CRITICAL: Set the node name so the agent can find it
value_node.name = "AgentControl"

# Link nodes
mat.node_tree.links.new(value_node.outputs[0], diffuse_node.inputs['Color'])
mat.node_tree.links.new(diffuse_node.outputs[0], output_node.inputs['Surface'])

# Assign material to cube
cube.data.materials.append(mat)

# Add a light
bpy.ops.object.light_add(type='SUN', location=(5, 5, 10))
light = bpy.context.active_object
light.data.energy = 5

# Add a camera
bpy.ops.object.camera_add(location=(7, -7, 5))
camera = bpy.context.active_object
camera.rotation_euler = (1.1, 0, 0.785)

# Set as active camera
bpy.context.scene.camera = camera

# Configure render settings for fast testing
bpy.context.scene.render.engine = 'BLENDER_EEVEE'
bpy.context.scene.render.resolution_x = 512
bpy.context.scene.render.resolution_y = 512
bpy.context.scene.render.resolution_percentage = 50  # Lower res for speed

# Save the file
output_path = os.path.join(os.path.dirname(__file__), "test.blend")
bpy.ops.wm.save_as_mainfile(filepath=output_path)
print(f"Created test.blend at: {output_path}")


path: src/synthclaw/__init__.py
content: from .blender_skill import render_procedural_scene, render_procedural_scene_fast, render_procedural_scene_production, analyze_blend

__all__ = ["render_procedural_scene", "render_procedural_scene_fast", "render_procedural_scene_production", "analyze_blend"]


path: src/synthclaw/analyze_skill.py
content: import subprocess
import json
import os

MODULE_DIR = os.path.dirname(os.path.abspath(__file__))
SCRIPTS_DIR = os.path.abspath(os.path.join(MODULE_DIR, "..", "..", "scripts"))
SCRIPT_PATH = os.path.join(SCRIPTS_DIR, "analyze_blends.py")

def analyze_blender_file(blend_file: str):
    """
    OpenClaw Skill: Executes Blender in background mode to analyze scene settings.
    """
    command = [
        "blender", 
        "-b", blend_file, 
        "-P", SCRIPT_PATH
    ]

    try:
        result = subprocess.run(command, capture_output=True, text=True, check=True)
        # Parse output between markers
        output = result.stdout
        if "---ANALYSIS_START---" in output and "---ANALYSIS_END---" in output:
            start = output.find("---ANALYSIS_START---") + len("---ANALYSIS_START---\n")
            end = output.find("---ANALYSIS_END---")
            json_str = output[start:end].strip()
            return {"status": "success", "data": json.loads(json_str)}
        else:
            return {"status": "error", "message": "Failed to find analysis markers in output.", "log": output[-500:]}
    except subprocess.CalledProcessError as e:
        return {"status": "error", "message": e.stderr}


path: src/synthclaw/blender_skill.py
content: import subprocess
import os
import json

MODULE_DIR = os.path.dirname(os.path.abspath(__file__))
SCRIPTS_DIR = os.path.abspath(os.path.join(MODULE_DIR, "..", "..", "scripts"))
SCRIPT_PATH = os.path.join(SCRIPTS_DIR, "agent_bridge.py")

# Default timeouts
DEFAULT_TIMEOUT_CYCLES = 1800      # 30 minutes for Cycles (production quality)
DEFAULT_TIMEOUT_EEVEE = 60          # 1 minute for EEVEE (fast testing)


def render_procedural_scene(
    blend_file: str, 
    output_path: str, 
    parameters: dict, 
    samples: int = 128,
    engine: str = "CYCLES",
    timeout: int = None,
    reference_image: str = None,
    compute_metrics: bool = False
):
    """
    OpenClaw Skill: Executes Blender in background mode to render a procedural scene.
    
    :param blend_file: Absolute path to the .blend file.
    :param output_path: Where to save the resulting image (e.g., /tmp/render.png).
    :param parameters: A dictionary of node names and float values (e.g., {"Scale": 5.0}).
    :param samples: Cycles samples for rendering (default: 128). Ignored for EEVEE.
    :param engine: Render engine - "CYCLES" (production) or "EEVEE" (fast testing).
    :param timeout: Custom timeout in seconds. Defaults based on engine.
    :return: Dict with status, output path, and log or error message.
    """
    # Validate engine choice
    engine = engine.upper()
    if engine not in ["CYCLES", "EEVEE", "BLENDER_EEVEE_NEXT"]:
        return {"status": "error", "message": f"Invalid engine '{engine}'. Use 'CYCLES' or 'EEVEE'"}
    
    # Set default timeout based on engine
    if timeout is None:
        timeout = DEFAULT_TIMEOUT_CYCLES if engine == "CYCLES" else DEFAULT_TIMEOUT_EEVEE
    
    # Validate blend file exists
    if not os.path.exists(blend_file):
        return {"status": "error", "message": f"Blend file not found: {blend_file}"}
    
    # Validate output directory exists
    output_dir = os.path.dirname(os.path.abspath(output_path))
    if output_dir and not os.path.exists(output_dir):
        try:
            os.makedirs(output_dir, exist_ok=True)
        except OSError as e:
            return {"status": "error", "message": f"Cannot create output directory: {e}"}
    
    # Validate parameters are numeric
    for k, v in parameters.items():
        if not isinstance(v, (int, float)):
            return {"status": "error", "message": f"Parameter '{k}' must be a number, got {type(v).__name__}"}
    
    # Format parameters for the Blender script (key=value)
    param_args = [f"{k}={v}" for k, v in parameters.items()]
    
    # Pass engine and samples to the Blender script via environment variables
    env = os.environ.copy()
    env["BLENDER_ENGINE"] = engine
    env["BLENDER_SAMPLES"] = str(samples)
    
    # Build the CLI command
    # -b: background, -P: run python script, -o: output, -f 1: render frame 1
    command = [
        "blender", 
        "-b", blend_file, 
        "-P", SCRIPT_PATH, 
        "-o", output_path, 
        "-f", "1", 
        "--"
    ] + param_args

    try:
        result = subprocess.run(
            command, 
            capture_output=True, 
            text=True, 
            check=True,
            timeout=timeout,
            env=env
        )
        metrics = {}
        if compute_metrics:
            try:
                import numpy as np
                from PIL import Image
                test_img = Image.open(output_path).convert('RGB')
                img_np = np.array(test_img)
                
                # GranatPy Metrics
                try:
                    import granatpy
                    # Single Image Naturalness
                    _, nfs, _, _, _ = granatpy.naturalize_rgb_image(img_np)
                    metrics["naturalness_channels"] = [float(x) for x in nfs]
                    metrics["naturalness_mean"] = float(np.mean(nfs))
                    
                    # Comparative Metrics
                    if reference_image and os.path.exists(reference_image):
                        ref_img = Image.open(reference_image).convert('RGB')
                        ref_np = np.array(ref_img)
                        comp_metrics = granatpy.compute_all_metrics(ref_np, img_np, verbose=False)
                        for k, v in comp_metrics.items():
                            metrics[f"granatpy_{k}"] = float(v)
                except ImportError:
                    metrics["granatpy_status"] = "granatpy not installed"
                except Exception as e:
                    metrics["granatpy_error"] = str(e)
                
                # LPIPS Metric
                if reference_image and os.path.exists(reference_image):
                    try:
                        import torch
                        import torchvision.transforms.functional as TF
                        import lpips
                        
                        ref_img = Image.open(reference_image).convert('RGB')
                        img_t = TF.to_tensor(test_img) * 2 - 1
                        ref_t = TF.to_tensor(ref_img) * 2 - 1
                        
                        img_t = img_t.unsqueeze(0)
                        ref_t = ref_t.unsqueeze(0)
                        
                        # Using alexnet as default
                        loss_fn_alex = lpips.LPIPS(net='alex', verbose=False)
                        lpips_val = loss_fn_alex(img_t, ref_t)
                        metrics["lpips_alex"] = float(lpips_val.item())
                    except ImportError:
                        metrics["lpips_status"] = "lpips not installed"
                    except Exception as e:
                        metrics["lpips_error"] = str(e)
                        
            except Exception as e:
                metrics["error"] = str(e)

        return {
            "status": "success", 
            "output": output_path, 
            "log": result.stdout[-500:],
            "engine": engine,
            "samples": samples if engine == "CYCLES" else None,
            "metrics": metrics
        }
    except subprocess.TimeoutExpired:
        return {"status": "error", "message": f"Render timed out after {timeout} seconds"}
    except subprocess.CalledProcessError as e:
        return {"status": "error", "message": e.stderr}


def render_procedural_scene_fast(
    blend_file: str, 
    output_path: str, 
    parameters: dict,
    reference_image: str = None,
    compute_metrics: bool = False
):
    """
    Convenience function for fast EEVEE rendering (testing).
    Same as render_procedural_scene with engine='EEVEE'.
    """
    return render_procedural_scene(
        blend_file=blend_file,
        output_path=output_path,
        parameters=parameters,
        engine="EEVEE",
        timeout=DEFAULT_TIMEOUT_EEVEE,
        reference_image=reference_image,
        compute_metrics=compute_metrics
    )


def render_procedural_scene_production(
    blend_file: str, 
    output_path: str, 
    parameters: dict,
    samples: int = 512,
    reference_image: str = None,
    compute_metrics: bool = False
):
    """
    Convenience function for production Cycles rendering.
    Same as render_procedural_scene with engine='CYCLES' and higher samples.
    """
    return render_procedural_scene(
        blend_file=blend_file,
        output_path=output_path,
        parameters=parameters,
        samples=samples,
        engine="CYCLES",
        timeout=DEFAULT_TIMEOUT_CYCLES,
        reference_image=reference_image,
        compute_metrics=compute_metrics
    )


def analyze_blend(blend_file: str):
    """
    Analyzes a Blender file and returns available Value Nodes.
    
    :param blend_file: Absolute path to the .blend file.
    :return: Dict with status and list of available parameters or error message.
    """
    # Validate blend file exists
    if not os.path.exists(blend_file):
        return {"status": "error", "message": f"Blend file not found: {blend_file}"}
    
    # Path to the analyze script
    ANALYZE_SCRIPT_PATH = os.path.join(SCRIPTS_DIR, "analyze_blends.py")
    
    command = [
        "blender",
        "-b", blend_file,
        "-P", ANALYZE_SCRIPT_PATH,
        "--"
    ]
    
    try:
        result = subprocess.run(
            command,
            capture_output=True,
            text=True,
            check=True,
            timeout=60  # 1 minute timeout for analysis
        )
        
        output = result.stdout
        if "---ANALYSIS_START---" in output and "---ANALYSIS_END---" in output:
            start = output.find("---ANALYSIS_START---") + len("---ANALYSIS_START---\n")
            end = output.find("---ANALYSIS_END---")
            json_str = output[start:end].strip()
            return {
                "status": "success",
                "parameters": json.loads(json_str),
                "blend_file": blend_file
            }
        else:
            return {"status": "error", "message": "Failed to find analysis markers in output.", "log": output[-500:]}
            
    except subprocess.TimeoutExpired:
        return {"status": "error", "message": "Analysis timed out after 60 seconds"}
    except subprocess.CalledProcessError as e:
        return {"status": "error", "message": e.stderr}


# Example usage for OpenClaw registration:
# {
#   "name": "render_procedural_scene",
#   "description": "Adjusts procedural nodes and renders a frame in Blender 4.0+",
#   "parameters": { ... }
# }


path: tests/test_skills.py
content: import os
import json
from synthclaw import analyze_blend

def test_blender_skill():
    # Attempt to resolve the path to the assets folder relative to this file
    repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
    low_blend = os.path.join(repo_root, "assets", "low.blend")
    high_blend = os.path.join(repo_root, "assets", "high.blend")

    print(f"Testing against low.blend at: {low_blend}")
    if not os.path.exists(low_blend):
        print("ERROR: Could not find low.blend. Skipping tests.")
        return

    print("\n--- Analyzing Low Realism File ---")
    low_res = analyze_blend(low_blend)
    print(json.dumps(low_res, indent=2))

    print("\n--- Analyzing High Realism File ---")
    high_res = analyze_blend(high_blend)
    print(json.dumps(high_res, indent=2))

if __name__ == "__main__":
    test_blender_skill()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)