Skill: genortg/openclaw-comfyui-api-runner (version 1.0.0)

--- name: comfyui-safe-connector description: Safe ComfyUI image generation. Use saved or ad-hoc server profiles, paste/upload raw workflow JSON, submit, poll, and serve downloaded outputs locally. metadata: {"openclaw":{"emoji":"🖼️","requires":{"anyBins":["python3"]},"primaryEnv":"COMFYUI_API_KEY","skillKey":"comfyui-safe-connector"}} user-invocable: true allowed-tools: exec read --- # ComfyUI Safe Connector Small safe runner for ComfyUI. ## What it does - Connects to a remembered server profile or ad-hoc connection settings - Accepts a workflow as a pasted JSON string, uploaded file, stdin, or built-in template - Submits the workflow to ComfyUI - Polls until done and downloads **all** output images - Saves images under `generated/` - Returns downloaded file paths and browser view URLs for each output ## Workflow keywords Saved workflows can include keywords in their `.meta.json`. Use the keyword, not the prompt, to pick the right template. Examples: - `IL-Ikastrious` → `anime`, `cartoon`, `illustration`, `ikastrious` - use for anime/cartoon image requests ## Main command Run from the skill root: ```bash ./comfy.sh --health ./comfy.sh --list-profiles ./comfy.sh --save-profile home --base-url http://127.0.0.1 --port 8188 ./comfy.sh --gen --workflow-file /path/to/workflow.json ./comfy.sh --gen --workflow-stdin < workflow.json ./comfy.sh --gen --workflow-json '{...json...}' ./comfy.sh --poll <prompt_id> ./comfy.sh --watch <prompt_id> ``` `comfy.sh` is the supported entrypoint (instead of calling the Python runner directly). ## Server profiles Profiles live in: `~/.config/openclaw/comfyui-runner.json` Supported fields: - `servers` (list of server entries) - `base_url` - `port` - `api_key` - `username` - `password` - `default_server` - `default_workflow` Use: - `--profile NAME` to use one for this run - `--save-profile NAME` to remember the current connection - `--set-profile NAME` to make a saved profile active - `--set-default-server NAME` to set the default server - `--set-default-workflow NAME` to set the default workflow - `--delete-profile NAME` to remove one Env vars override saved profiles: - `COMFYUI_PROFILE` - `COMFYUI_BASE_URL` - `COMFYUI_PORT` - `COMFYUI_API_KEY` - `COMFYUI_USERNAME` - `COMFYUI_PASSWORD` ## Workflow input Preferred for this skill: - `--workflow-file` for uploaded JSON files - `--workflow-stdin` for pasted JSON - `--workflow-json` for direct JSON strings Built-in templates still work via the positional template id, but raw JSON is the default safe path. If a keyword matches exactly one saved workflow, you can pass the keyword instead of the template id. ## Output - `poll` / `watch` write downloaded files to `generated/` - return JSON includes `view_url`, `local_path`, and `outputs[]` - image files can be sent directly into Discord or another channel if you want delivery there - server profiles are remembered in `~/.config/openclaw/comfyui-runner.json` ## Safety - No shell interpolation of workflow contents - JSON is parsed, not executed - API key support is optional - Use only trusted ComfyUI servers

path: comfy.sh
content: #!/usr/bin/env bash
set -euo pipefail

# ComfyUI Safe Connector entrypoint.
# Runs the Python runner from the skill root, regardless of the caller's CWD.

SKILL_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
cd "$SKILL_ROOT"

exec python3 scripts/run.py "$@"


path: references/CONFIG.md
content: # ComfyUI Safe Connector - Configuration

Connection settings are stored in a small user config file:

`~/.config/openclaw/comfyui-runner.json`

## Schema

```json
{
  "active_profile": "home",
  "profiles": {
    "home": {
      "base_url": "http://127.0.0.1",
      "port": 8188,
      "api_key": "optional",
      "username": "optional",
      "password": "optional"
    }
  }
}
```

## Env overrides

- `COMFYUI_PROFILE`
- `COMFYUI_BASE_URL`
- `COMFYUI_PORT`
- `COMFYUI_API_KEY`
- `COMFYUI_USERNAME`
- `COMFYUI_PASSWORD`

## CLI

```bash
./comfy.sh --list-profiles
./comfy.sh --save-profile home --base-url http://127.0.0.1 --port 8188
./comfy.sh --profile home --health
```

## Notes

- `base_url` can include the scheme, for example `http://127.0.0.1` or `https://host`.
- If `base_url` already contains a port, `port` is ignored.
- API key is optional.
- Basic auth is optional.
- No local viewer is required. The runner returns downloadable files and browser view URLs instead.


path: references/ENDPOINTS.md
content: # ComfyUI Safe Connector - Endpoints

The runner uses these ComfyUI endpoints:

| Method | Path | Purpose |
|--------|------|---------|
| GET | `/queue` | Health check and queue status |
| POST | `/prompt` | Submit workflow |
| GET | `/history/{prompt_id}` | Fetch execution result |
| GET | `/view` | Download rendered image |
| POST | `/queue` | Clear queue |
| POST | `/interrupt` | Stop current run |

## Workflow input

The runner accepts raw workflow JSON from:
- `--workflow-file`
- `--workflow-stdin`
- `--workflow-json`

It also still supports built-in template JSON files in `workflows/`.

## Output handling

When a run finishes, the script:
- fetches every image in the result history
- saves them in `generated/`
- returns `view_url` plus `local_path` for each output

Use `python3 scripts/serve_generated.py` if you want a temporary local browser server for `generated/`.


path: scripts/comfy_client.py
content: """
Shared HTTP client for ComfyUI.
Loads connection settings from env plus a small user config file.

Supported env vars:
- COMFYUI_PROFILE
- COMFYUI_BASE_URL
- COMFYUI_PORT
- COMFYUI_API_KEY
- COMFYUI_USERNAME
- COMFYUI_PASSWORD
"""

from __future__ import annotations

import base64
import json
import os
import uuid
from pathlib import Path
from typing import Any
from urllib.parse import urljoin

import urllib.error
import urllib.request

DEFAULT_PORT = 8188
DEFAULT_BASE = "http://127.0.0.1"
TIMEOUT = 120
USER_CONFIG_PATH = Path.home() / ".config" / "openclaw" / "comfyui-runner.json"


def _load_json(path: Path) -> dict[str, Any]:
    try:
        if not path.is_file():
            return {}
        with open(path, encoding="utf-8") as f:
            data = json.load(f)
        return data if isinstance(data, dict) else {}
    except (OSError, json.JSONDecodeError):
        return {}


def _save_json(path: Path, data: dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with open(path, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2, ensure_ascii=False)
        f.write("\n")


def _load_user_config() -> dict[str, Any]:
    cfg = _load_json(USER_CONFIG_PATH)
    cfg.setdefault("active_profile", None)
    cfg.setdefault("default_server", None)
    cfg.setdefault("default_workflow", None)
    cfg.setdefault("profiles", {})
    cfg.setdefault("servers", [])
    if not isinstance(cfg["profiles"], dict):
        cfg["profiles"] = {}
    if not isinstance(cfg["servers"], list):
        cfg["servers"] = []
    return cfg


def _sanitize_port(value: Any) -> int:
    try:
        return int(value)
    except (TypeError, ValueError):
        return DEFAULT_PORT


def _pick_profile(cfg: dict[str, Any]) -> dict[str, Any]:
    profiles = cfg.get("profiles") if isinstance(cfg.get("profiles"), dict) else {}
    selected = (
        os.environ.get("COMFYUI_SERVER")
        or os.environ.get("COMFYUI_PROFILE")
        or cfg.get("default_server")
        or cfg.get("active_profile")
    )
    if selected and isinstance(profiles, dict):
        profile = profiles.get(str(selected))
        if isinstance(profile, dict):
            return profile
    servers = cfg.get("servers") if isinstance(cfg.get("servers"), list) else []
    if selected:
        for server in servers:
            if isinstance(server, dict) and str(server.get("name") or "") == str(selected):
                return server
    return {}


def resolve_config(skill_root: Path | None = None) -> dict[str, Any]:
    """Resolve effective connection settings, env overrides profile file."""
    user_cfg = _load_user_config()
    profile_cfg = _pick_profile(user_cfg)

    base_url = (
        os.environ.get("COMFYUI_BASE_URL")
        or profile_cfg.get("base_url")
        or user_cfg.get("base_url")
        or DEFAULT_BASE
    )
    port = _sanitize_port(
        os.environ.get("COMFYUI_PORT")
        or profile_cfg.get("port")
        or user_cfg.get("port")
        or DEFAULT_PORT
    )
    [REDACTED_SECRET]"COMFYUI_API_KEY") or profile_cfg.get("api_key") or user_cfg.get("api_key")
    username = os.environ.get("COMFYUI_USERNAME") or profile_cfg.get("username") or user_cfg.get("username")
    [REDACTED_SECRET]"COMFYUI_PASSWORD") or profile_cfg.get("password") or user_cfg.get("password")
    active_profile = os.environ.get("COMFYUI_PROFILE") or user_cfg.get("active_profile")
    default_server = os.environ.get("COMFYUI_SERVER") or user_cfg.get("default_server") or active_profile
    default_workflow = user_cfg.get("default_workflow")

    return {
        "base_url": base_url,
        "port": port,
        "api_key": api_key,
        "username": username,
        "password": password,
        "active_profile": active_profile,
        "default_server": default_server,
        "default_workflow": default_workflow,
        "user_config_path": str(USER_CONFIG_PATH),
    }


def get_base_url(skill_root: Path | None = None) -> str:
    """Return full base URL (e.g. http://127.0.0.1:8188)."""
    cfg = resolve_config(skill_root)
    base = str(cfg["base_url"] or "").rstrip("/")
    port = int(cfg["port"] or DEFAULT_PORT)
    if ":" in base.split("//", 1)[-1]:
        return base
    return f"{base}:{port}"


def _build_headers(api_key: str | None) -> dict[str, str]:
    headers = {
        "Content-Type": "application/json",
        "Accept": "application/json",
    }
    if [REDACTED_SECRET]"Authorization"] = f"Bearer {api_key}"
    return headers


def _install_basic_auth(req: urllib.request.Request, username: str | None, password: str | None) -> None:
    if username and password:
        creds = base64.b64encode(f"{username}:{password}".encode()).decode()
        req.add_header("Authorization", f"Basic {creds}")


def _request(
    method: str,
    path: str,
    data: dict[str, Any] | None = None,
    skill_root: Path | None = None,
) -> dict[str, Any] | list[Any]:
    cfg = resolve_config(skill_root)
    base = get_base_url(skill_root)
    url = urljoin(base + "/", path.lstrip("/"))
    body = json.dumps(data).encode("utf-8") if data is not None else None
    req = urllib.request.Request(url, data=body, headers=_build_headers(cfg.get("api_key")), method=method)
    _install_basic_auth(req, cfg.get("username"), cfg.get("password"))
    try:
        with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
            raw = resp.read().decode("utf-8")
            if not raw:
                return {}
            parsed = json.loads(raw)
            return parsed if isinstance(parsed, (dict, list)) else {}
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8") if e.fp else ""
        try:
            err_json = json.loads(body) if body else {}
        except json.JSONDecodeError:
            err_json = {"message": body or e.reason}
        raise ComfyAPIError(e.code, err_json) from e
    except urllib.error.URLError as e:
        raise ComfyAPIError(0, {"message": str(e.reason) or "Connection failed"}) from e
    except json.JSONDecodeError as e:
        raise ComfyAPIError(0, {"message": f"Invalid JSON: {e}"}) from e


class ComfyAPIError(Exception):
    """ComfyUI API HTTP or response error."""

    def __init__(self, code: int, body: dict[str, Any]) -> None:
        self.code = code
        self.body = body
        super().__init__(f"ComfyUI API error {code}: {body}")


def list_profiles() -> dict[str, Any]:
    cfg = _load_user_config()
    profiles = cfg.get("profiles") if isinstance(cfg.get("profiles"), dict) else {}
    servers = cfg.get("servers") if isinstance(cfg.get("servers"), list) else []
    return {
        "active_profile": cfg.get("active_profile"),
        "default_server": cfg.get("default_server"),
        "default_workflow": cfg.get("default_workflow"),
        "profiles": {name: profile for name, profile in sorted(profiles.items()) if isinstance(profile, dict)},
        "servers": [server for server in servers if isinstance(server, dict)],
        "config_path": str(USER_CONFIG_PATH),
    }


def save_profile(name: str, profile: dict[str, Any], set_active: bool = True) -> dict[str, Any]:
    if not name or not name.strip():
        raise ValueError("Profile name is required")
    cfg = _load_user_config()
    profiles = cfg.setdefault("profiles", {})
    if not isinstance(profiles, dict):
        profiles = {}
        cfg["profiles"] = profiles
    profiles[name] = {
        "name": name,
        "base_url": profile.get("base_url") or DEFAULT_BASE,
        "port": _sanitize_port(profile.get("port") or DEFAULT_PORT),
        **({"api_key": profile["api_key"]} if profile.get("api_key") else {}),
        **({"username": profile["username"]} if profile.get("username") else {}),
        **({"password": profile["password"]} if profile.get("password") else {}),
    }
    servers = cfg.setdefault("servers", [])
    if not isinstance(servers, list):
        servers = []
        cfg["servers"] = servers
    servers = [s for s in servers if not (isinstance(s, dict) and str(s.get("name") or "") == name)]
    servers.append(profiles[name])
    cfg["servers"] = servers
    if set_active:
        cfg["active_profile"] = name
        cfg["default_server"] = name
    _save_json(USER_CONFIG_PATH, cfg)
    return list_profiles()


def delete_profile(name: str) -> dict[str, Any]:
    cfg = _load_user_config()
    profiles = cfg.get("profiles") if isinstance(cfg.get("profiles"), dict) else {}
    servers = cfg.get("servers") if isinstance(cfg.get("servers"), list) else []
    if name in profiles:
        del profiles[name]
    cfg["servers"] = [s for s in servers if not (isinstance(s, dict) and str(s.get("name") or "") == name)]
    if cfg.get("active_profile") == name:
        cfg["active_profile"] = None
    if cfg.get("default_server") == name:
        cfg["default_server"] = None
    _save_json(USER_CONFIG_PATH, cfg)
    return list_profiles()


def set_active_profile(name: str) -> dict[str, Any]:
    cfg = _load_user_config()
    profiles = cfg.get("profiles") if isinstance(cfg.get("profiles"), dict) else {}
    if name not in profiles:
        raise KeyError(f"Profile not found: {name}")
    cfg["active_profile"] = name
    cfg["default_server"] = name
    _save_json(USER_CONFIG_PATH, cfg)
    return list_profiles()


def set_default_server(name: str) -> dict[str, Any]:
    cfg = _load_user_config()
    profiles = cfg.get("profiles") if isinstance(cfg.get("profiles"), dict) else {}
    servers = cfg.get("servers") if isinstance(cfg.get("servers"), list) else []
    if name not in profiles and not any(isinstance(s, dict) and str(s.get("name") or "") == name for s in servers):
        raise KeyError(f"Server not found: {name}")
    cfg["default_server"] = name
    _save_json(USER_CONFIG_PATH, cfg)
    return list_profiles()


def set_default_workflow(name: str) -> dict[str, Any]:
    cfg = _load_user_config()
    cfg["default_workflow"] = name
    _save_json(USER_CONFIG_PATH, cfg)
    return list_profiles()


def post_prompt(workflow: dict[str, Any], client_id: str | None = None, skill_root: Path | None = None) -> dict[str, Any]:
    """POST /prompt — submit workflow. Returns { prompt_id, number } or error."""
    payload: dict[str, Any] = {"prompt": workflow}
    if client_id is None:
        client_id = str(uuid.uuid4())
    payload["client_id"] = client_id
    out = _request("POST", "/prompt", data=payload, skill_root=skill_root)
    return out if isinstance(out, dict) else {"prompt_id": None, "number": 0}


def get_prompt(skill_root: Path | None = None) -> dict[str, Any]:
    out = _request("GET", "/prompt", skill_root=skill_root)
    return out if isinstance(out, dict) else {}


def get_history(prompt_id: str | None = None, skill_root: Path | None = None) -> dict[str, Any]:
    path = f"/history/{prompt_id}" if prompt_id else "/history"
    out = _request("GET", path, skill_root=skill_root)
    return out if isinstance(out, dict) else {}


def get_queue(skill_root: Path | None = None) -> dict[str, Any]:
    out = _request("GET", "/queue", skill_root=skill_root)
    return out if isinstance(out, dict) else {}


def health_check(skill_root: Path | None = None) -> dict[str, Any]:
    base_url = get_base_url(skill_root)
    try:
        queue_data = get_queue(skill_root=skill_root)
        return {
            "ok": True,
            "base_url": base_url,
            "message": "ComfyUI server is reachable and API responds; generation is possible.",
            "queue_info": queue_data,
        }
    except ComfyAPIError as e:
        return {
            "ok": False,
            "base_url": base_url,
            "message": e.body.get("message", str(e)),
            "code": e.code,
        }


def clear_queue(skill_root: Path | None = None) -> dict[str, Any]:
    out = _request("POST", "/queue", data={"clear": True}, skill_root=skill_root)
    return out if isinstance(out, dict) else {}


def interrupt(skill_root: Path | None = None) -> dict[str, Any]:
    out = _request("POST", "/interrupt", skill_root=skill_root)
    return out if isinstance(out, dict) else {}


def fetch_url(url: str, skill_root: Path | None = None) -> bytes:
    cfg = resolve_config(skill_root)
    req = urllib.request.Request(url, method="GET")
    for name, value in _build_headers(cfg.get("api_key")).items():
        req.add_header(name, value)
    _install_basic_auth(req, cfg.get("username"), cfg.get("password"))
    with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
        return resp.read()


path: scripts/parse_workflow.py
content: """
Parse a ComfyUI API-format workflow JSON and optional .meta.json into a variable map.
Output: JSON with required and optional keys, each with node id and input key.
"""

from __future__ import annotations

import json
import sys
from pathlib import Path
from typing import Any

# Types for variable map entries
VarMapEntry = dict[str, str]  # {"node": "6", "input": "text"}
VariableMap = dict[str, Any]  # {"positive_prompt": VarMapEntry, "extra": {...}}


def load_workflow(path: Path) -> dict[str, Any]:
    """Load workflow API JSON."""
    with open(path, encoding="utf-8") as f:
        return json.load(f)


def load_meta(meta_path: Path | None) -> dict[str, Any] | None:
    """Load optional .meta.json."""
    if meta_path is None or not meta_path.is_file():
        return None
    with open(meta_path, encoding="utf-8") as f:
        return json.load(f)


def infer_positive_negative(workflow: dict[str, Any]) -> tuple[str | None, str | None]:
    """Infer first CLIPTextEncode as positive, second as negative (by key order)."""
    clip_nodes: list[str] = []
    for nid, node in workflow.items():
        if not isinstance(node, dict):
            continue
        if node.get("class_type") == "CLIPTextEncode":
            clip_nodes.append(nid)
    clip_nodes.sort(key=lambda x: (len(x), x))
    pos = clip_nodes[0] if clip_nodes else None
    neg = clip_nodes[1] if len(clip_nodes) > 1 else None
    return pos, neg


def infer_sampler(workflow: dict[str, Any]) -> str | None:
    """Infer first KSampler (or KSamplerAdvanced) node."""
    for nid, node in workflow.items():
        if not isinstance(node, dict):
            continue
        if node.get("class_type") in ("KSampler", "KSamplerAdvanced"):
            return nid
    return None


def build_varmap_from_heuristic(workflow: dict[str, Any]) -> dict[str, Any]:
    """Build variable map from workflow using heuristics."""
    pos_node, neg_node = infer_positive_negative(workflow)
    sampler_node = infer_sampler(workflow)
    required: list[str] = []
    optional: list[str] = []
    mapping: dict[str, VarMapEntry] = {}

    if pos_node:
        mapping["positive_prompt"] = {"node": pos_node, "input": "text"}
        required.append("positive_prompt")
    if neg_node:
        mapping["negative_prompt"] = {"node": neg_node, "input": "text"}
        optional.append("negative_prompt")
    if sampler_node:
        mapping["seed"] = {"node": sampler_node, "input": "seed"}
        required.append("seed")
        for key in ("steps", "cfg", "sampler_name", "scheduler", "denoise"):
            if key in (workflow.get(sampler_node) or {}).get("inputs", {}):
                mapping[key] = {"node": sampler_node, "input": key}
                optional.append(key)

    return {
        "required": required,
        "optional": optional,
        "variables": mapping,
    }


def build_varmap_from_meta(meta: dict[str, Any], workflow: dict[str, Any]) -> dict[str, Any]:
    """Build variable map from manifest (.meta.json)."""
    variables: dict[str, VarMapEntry] = {}
    required: list[str] = ["positive_prompt"]
    optional: list[str] = []
    keywords = [str(k).strip().lower() for k in (meta.get("keywords") or []) if str(k).strip()]

    pos_node = meta.get("positive_prompt_node")
    pos_input = meta.get("positive_prompt_input", "text")
    if pos_node:
        variables["positive_prompt"] = {"node": str(pos_node), "input": pos_input}

    seed_node = meta.get("seed_node")
    if seed_node:
        variables["seed"] = {"node": str(seed_node), "input": "seed"}
        optional.append("seed")

    neg_node = meta.get("negative_prompt_node")
    neg_input = meta.get("negative_prompt_input", "text")
    if neg_node:
        variables["negative_prompt"] = {"node": str(neg_node), "input": neg_input}
        optional.append("negative_prompt")

    extra = meta.get("extra") or {}
    for key, val in extra.items():
        if isinstance(val, dict) and "node" in val and "input" in val:
            variables[key] = {"node": str(val["node"]), "input": str(val["input"])}
            optional.append(key)
    
    # Handle model/ckpt_name variable (for fallback mechanism)
    model_info = meta.get("model")
    if isinstance(model_info, dict) and "node" in model_info and "input" in model_info:
        variables["model"] = {"node": str(model_info["node"]), "input": str(model_info["input"])}
        optional.append("model")

    return {
        "required": required,
        "optional": optional,
        "keywords": keywords,
        "variables": variables,
    }


def parse_workflow_meta_only(meta_path: Path) -> dict[str, Any]:
    """
    Build variable map from a .meta.json only (no workflow JSON loaded).
    Returns dict with keys: required (list), optional (list), variables (dict).
    """
    meta = load_meta(meta_path)
    if not meta:
        raise FileNotFoundError(f"Meta file not found: {meta_path}")
    return build_varmap_from_meta(meta, {})


def parse_workflow(
    workflow_path: Path,
    meta_path: Path | None = None,
) -> dict[str, Any]:
    """
    Parse workflow and optional manifest into variable map.
    When meta_path exists, only the meta file is read (workflow JSON is not loaded).
    Returns dict with keys: required (list), optional (list), variables (dict).
    """
    meta = load_meta(meta_path) if meta_path else None
    if meta:
        return build_varmap_from_meta(meta, {})
    workflow = load_workflow(workflow_path)
    return build_varmap_from_heuristic(workflow)


def main() -> None:
    """CLI: parse_workflow.py <workflow.json> [meta.json]"""
    if len(sys.argv) < 2:
        print(json.dumps({"error": "Usage: parse_workflow.py <workflow.json> [meta.json]"}), file=sys.stderr)
        sys.exit(1)
    workflow_path = Path(sys.argv[1])
    meta_path = Path(sys.argv[2]) if len(sys.argv) > 2 else workflow_path.with_suffix(".meta.json")
    if not workflow_path.is_file():
        print(json.dumps({"error": f"Not a file: {workflow_path}"}), file=sys.stderr)
        sys.exit(1)
    result = parse_workflow(workflow_path, meta_path if meta_path != workflow_path else None)
    print(json.dumps(result, indent=2))


if __name__ == "__main__":
    main()


path: scripts/run.py
content: """
Safe ComfyUI runner.

Supports:
- health check
- list built-in template workflows
- save/list/delete remembered server profiles
- submit generation from a built-in template, pasted JSON, uploaded JSON file, or stdin
- poll/watch for results and download every output image

All output is JSON. Run from skill root.
"""

from __future__ import annotations

import argparse
import json
import os
import random
import sys
import time
from pathlib import Path
from typing import Any

SKILL_ROOT = Path(__file__).resolve().parent.parent
WORKFLOWS_DIR = SKILL_ROOT / "workflows"
GENERATED_DIR = SKILL_ROOT / "generated"
SEED_MAX = 2**32 - 1
DEFAULT_TIMEOUT_IMAGE = 120
DEFAULT_INTERVAL = 10

sys.path.insert(0, str(Path(__file__).resolve().parent))
from comfy_client import (  # noqa: E402
    ComfyAPIError,
    delete_profile,
    fetch_url,
    get_base_url,
    get_history,
    health_check,
    list_profiles,
    post_prompt,
    resolve_config,
    save_profile,
    set_active_profile,
    set_default_server,
    set_default_workflow,
)
from parse_workflow import parse_workflow, parse_workflow_meta_only  # noqa: E402


def _apply_cli_env(args: argparse.Namespace) -> None:
    mapping = {
        "profile": "COMFYUI_PROFILE",
        "base_url": "COMFYUI_BASE_URL",
        "port": "COMFYUI_PORT",
        "api_key": "COMFYUI_API_KEY",
        "username": "COMFYUI_USERNAME",
        "password": "COMFYUI_PASSWORD",
    }
    for attr, env_name in mapping.items():
        value = getattr(args, attr, None)
        if value not in (None, ""):
            os.environ[env_name] = str(value)


def _list_workflows(workflows_dir: Path) -> list[dict[str, Any]]:
    if not workflows_dir.is_dir():
        return []
    result: list[dict[str, Any]] = []
    for path in sorted(workflows_dir.glob("*.json")):
        if path.name.endswith(".meta.json"):
            continue
        meta_path = path.with_name(path.stem + ".meta.json")
        try:
            if meta_path.is_file():
                varmap = parse_workflow_meta_only(meta_path)
            else:
                varmap = parse_workflow(path, None)
            result.append({
                "id": path.stem,
                "required": varmap.get("required", []),
                "optional": varmap.get("optional", []),
                "keywords": varmap.get("keywords", []),
            })
        except (OSError, json.JSONDecodeError, KeyError, FileNotFoundError):
            continue
    return result


def _apply_overrides(workflow: dict[str, Any], variables: dict[str, dict[str, str]], overrides: dict[str, Any]) -> None:
    for key, value in overrides.items():
        entry = variables.get(key)
        if not entry:
            continue
        node_id = entry.get("node")
        input_key = entry.get("input")
        if not node_id or node_id not in workflow:
            continue
        workflow.setdefault(node_id, {}).setdefault("inputs", {})[input_key] = value


def _randomize_literal_seeds(workflow: dict[str, Any]) -> None:
    """Randomize any literal seed inputs so repeat runs do not reuse the same seed."""
    for node in workflow.values():
        if not isinstance(node, dict):
            continue
        inputs = node.get("inputs")
        if not isinstance(inputs, dict):
            continue
        seed_value = inputs.get("seed")
        if isinstance(seed_value, list):
            continue
        if isinstance(seed_value, bool) or seed_value is None:
            continue
        if isinstance(seed_value, (int, float, str)):
            inputs["seed"] = random.randint(0, SEED_MAX)


def _load_template_workflow(template_id: str, skill_root: Path) -> tuple[dict[str, Any], dict[str, Any]]:
    workflow_path = skill_root / "workflows" / f"{template_id}.json"
    if not workflow_path.is_file():
        raise FileNotFoundError(f"Workflow not found: {workflow_path}")
    meta_path = workflow_path.with_suffix(".meta.json")
    varmap = parse_workflow(workflow_path, meta_path)
    with open(workflow_path, encoding="utf-8") as f:
        workflow = json.load(f)
    return workflow, varmap


def _find_workflow_by_keyword(query: str, skill_root: Path) -> str | None:
    needle = query.strip().lower()
    if not needle:
        return None
    matches: list[str] = []
    for path in sorted((skill_root / "workflows").glob("*.json")):
        if path.name.endswith(".meta.json"):
            continue
        meta_path = path.with_name(path.stem + ".meta.json")
        if not meta_path.is_file():
            continue
        try:
            meta = json.loads(meta_path.read_text(encoding="utf-8"))
        except Exception:
            continue
        keywords = [str(k).strip().lower() for k in (meta.get("keywords") or []) if str(k).strip()]
        if needle in keywords:
            matches.append(path.stem)
    if len(matches) == 1:
        return matches[0]
    return None


def _load_raw_workflow(args: argparse.Namespace) -> tuple[dict[str, Any], str]:
    if args.workflow_file:
        with open(Path(args.workflow_file), encoding="utf-8") as f:
            data = json.load(f)
        return data, f"file:{args.workflow_file}"
    if args.workflow_json:
        return json.loads(args.workflow_json), "json"
    if args.workflow_stdin:
        return json.loads(sys.stdin.read()), "stdin"
    raise FileNotFoundError("Missing workflow source. Use --workflow-file, --workflow-json, --workflow-stdin, or a template id.")


def _resolve_workflow(args: argparse.Namespace, skill_root: Path) -> tuple[dict[str, Any], str, dict[str, Any] | None]:
    """Return (workflow, source_label, varmap). varmap is only present for templates."""
    template_mode = bool((args.id or args.default_workflow) and not (args.workflow_file or args.workflow_json or args.workflow_stdin))
    if template_mode:
        template_id = args.id or args.default_workflow
        if not (skill_root / "workflows" / f"{template_id}.json").is_file():
            found = _find_workflow_by_keyword(template_id, skill_root)
            if found:
                template_id = found
        workflow, varmap = _load_template_workflow(template_id, skill_root)
        return workflow, f"template:{template_id}", varmap
    workflow, label = _load_raw_workflow(args)
    if not isinstance(workflow, dict):
        raise ValueError("Workflow JSON must be an object keyed by node id.")
    return workflow, label, None


def _extract_images(entry: dict[str, Any]) -> list[dict[str, Any]]:
    outputs = entry.get("outputs") or {}
    images: list[dict[str, Any]] = []
    for node_id, node_out in outputs.items():
        if not isinstance(node_out, dict):
            continue
        for idx, img in enumerate(node_out.get("images") or []):
            if not isinstance(img, dict) or not img.get("filename"):
                continue
            item = dict(img)
            item["node_id"] = str(node_id)
            item["image_index"] = idx
            images.append(item)
    return images


def _enrich_with_view_urls(history: dict[str, Any], base_url: str) -> dict[str, Any]:
    base = base_url.rstrip("/")
    for prompt_id, data in history.items():
        if not isinstance(data, dict):
            continue
        outputs = data.get("outputs") or {}
        for node_id, node_out in outputs.items():
            if not isinstance(node_out, dict):
                continue
            for img in node_out.get("images") or []:
                if not isinstance(img, dict) or "filename" not in img:
                    continue
                if img.get("view_url"):
                    continue
                fn = img["filename"]
                subfolder = img.get("subfolder", "")
                img_type = img.get("type", "output")
                q = f"filename={fn}&type={img_type}&subfolder={subfolder}"
                if prompt_id:
                    q += f"&prompt_id={prompt_id}"
                img["view_url"] = f"{base}/view?{q}"
    return history


def _safe_output_name(prompt_id: str, img: dict[str, Any]) -> str:
    original = Path(str(img.get("filename") or "image.png")).name
    stem = Path(original).stem or "image"
    suffix = Path(original).suffix or ".png"
    node_id = str(img.get("node_id") or "node")
    index = int(img.get("image_index") or 0)
    safe = f"{prompt_id}__{node_id}__{index}__{stem}{suffix}"
    return "".join(ch if ch.isalnum() or ch in {".", "-", "_"} else "_" for ch in safe)


def _download_outputs(prompt_id: str, entry: dict[str, Any], skill_root: Path) -> list[dict[str, Any]]:
    base_url = get_base_url(skill_root)
    wrapped = {prompt_id: entry}
    _enrich_with_view_urls(wrapped, base_url)
    enriched = wrapped[prompt_id]
    images = _extract_images(enriched)
    if not images:
        return []

    GENERATED_DIR.mkdir(parents=True, exist_ok=True)
    results: list[dict[str, Any]] = []
    for img in images:
        view_url = img.get("view_url")
        if not view_url:
            continue
        local_name = _safe_output_name(prompt_id, img)
        local_path = GENERATED_DIR / local_name
        try:
            body = fetch_url(view_url, skill_root=skill_root)
            local_path.write_bytes(body)
        except Exception:
            continue
        results.append({
            "filename": img.get("filename"),
            "subfolder": img.get("subfolder", ""),
            "type": img.get("type", "output"),
            "view_url": view_url,
            "local_path": str(local_path.relative_to(skill_root)),
        })
    return results


def cmd_health(skill_root: Path) -> int:
    result = health_check(skill_root=skill_root)
    result["status"] = "health"
    print(json.dumps(result))
    return 0 if result.get("ok") else 1


def cmd_list(skill_root: Path) -> int:
    out = {
        "status": "workflows",
        "workflows": _list_workflows(skill_root / "workflows"),
    }
    print(json.dumps(out))
    return 0


def cmd_list_profiles() -> int:
    print(json.dumps({"status": "profiles", **list_profiles()}))
    return 0


def cmd_save_profile(args: argparse.Namespace) -> int:
    cfg = resolve_config()
    profile = {
        "base_url": cfg["base_url"],
        "port": cfg["port"],
    }
    if cfg.get("api_key"):
        profile["api_key"] = cfg["api_key"]
    if cfg.get("username"):
        profile["username"] = cfg["username"]
    if cfg.get("password"):
        profile["password"] = cfg["password"]
    out = save_profile(args.save_profile, profile, set_active=not args.no_activate)
    print(json.dumps({"status": "profiles", **out}))
    return 0


def cmd_delete_profile(args: argparse.Namespace) -> int:
    out = delete_profile(args.delete_profile)
    print(json.dumps({"status": "profiles", **out}))
    return 0


def cmd_set_active_profile(args: argparse.Namespace) -> int:
    out = set_active_profile(args.set_profile)
    print(json.dumps({"status": "profiles", **out}))
    return 0


def cmd_set_default_server(args: argparse.Namespace) -> int:
    out = set_default_server(args.set_default_server)
    print(json.dumps({"status": "profiles", **out}))
    return 0


def cmd_set_default_workflow(args: argparse.Namespace) -> int:
    out = set_default_workflow(args.set_default_workflow)
    print(json.dumps({"status": "profiles", **out}))
    return 0


def cmd_submit(args: argparse.Namespace, skill_root: Path) -> int:
    try:
        workflow, source, varmap = _resolve_workflow(args, skill_root)
    except (FileNotFoundError, json.JSONDecodeError, ValueError) as e:
        print(json.dumps({"status": "generation_submitted", "success": False, "error": str(e)}))
        return 1

    overrides: dict[str, Any] = {}
    if varmap is not None:
        if args.positive_prompt is not None:
            overrides["positive_prompt"] = args.positive_prompt
        if args.negative_prompt is not None:
            overrides["negative_prompt"] = args.negative_prompt
        if args.steps is not None:
            overrides["steps"] = args.steps
        if args.cfg is not None:
            overrides["cfg"] = args.cfg
        if args.sampler_name is not None:
            overrides["sampler_name"] = args.sampler_name
        if args.scheduler is not None:
            overrides["scheduler"] = args.scheduler
        if args.denoise is not None:
            overrides["denoise"] = args.denoise
        if getattr(args, "overrides", None):
            try:
                extra = json.loads(args.overrides)
                if isinstance(extra, dict):
                    overrides.update(extra)
            except json.JSONDecodeError:
                pass
        if "seed" in (varmap.get("variables") or {}):
            overrides["seed"] = random.randint(0, SEED_MAX)

        required = set(varmap.get("required") or [])
        missing = required - set(overrides.keys())
        if missing:
            print(json.dumps({"status": "generation_submitted", "success": False, "error": f"Missing required overrides: {sorted(missing)}"}))
            return 1
        _apply_overrides(workflow, varmap.get("variables") or {}, overrides)

    _randomize_literal_seeds(workflow)

    try:
        result = post_prompt(workflow, client_id=getattr(args, "client_id", None) or None, skill_root=skill_root)
    except ComfyAPIError as e:
        print(json.dumps({"status": "generation_submitted", "success": False, "error": e.body.get("message", str(e))}))
        return 1

    prompt_id = str(result.get("prompt_id") or "")
    out = {
        "status": "generation_submitted",
        "success": True,
        "prompt_id": prompt_id,
        "source": source,
        "watch_command": f"python3 scripts/run.py --watch {prompt_id}",
        "poll_command": f"python3 scripts/run.py --poll {prompt_id}",
        "message": "Run poll_command in a loop until done:true, or watch_command once and wait for exit.",
    }
    if args.serve_url:
        out["serve_hint"] = args.serve_url
    print(json.dumps(out))
    return 0


def _check_once_and_maybe_download(prompt_id: str, skill_root: Path) -> tuple[dict[str, Any], int]:
    try:
        result = get_history(prompt_id=prompt_id, skill_root=skill_root)
    except ComfyAPIError as e:
        return {
            "status": "generation_done",
            "done": False,
            "prompt_id": prompt_id,
            "error": e.body.get("message", str(e)),
        }, 1

    if not isinstance(result, dict):
        result = {}

    entry = result.get(prompt_id)
    if not isinstance(entry, dict) or not _extract_images(entry):
        return {
            "status": "generation_done",
            "done": False,
            "prompt_id": prompt_id,
            "message": "Still running. Run the same --poll command again in 10s.",
        }, 0

    outputs = _download_outputs(prompt_id, entry, skill_root)
    if not outputs:
        return {
            "status": "generation_done",
            "done": False,
            "prompt_id": prompt_id,
            "error": "No downloadable outputs found.",
        }, 1

    out = {
        "status": "generation_done",
        "done": True,
        "prompt_id": prompt_id,
        "outputs": outputs,
        "message": "Generation finished.",
    }
    out["view_url"] = outputs[0]["view_url"]
    out["local_path"] = outputs[0]["local_path"]
    return out, 0


def cmd_poll(args: argparse.Namespace, skill_root: Path) -> int:
    out, code = _check_once_and_maybe_download(args.prompt_id, skill_root)
    print(json.dumps(out))
    return code


def cmd_watch(args: argparse.Namespace, skill_root: Path) -> int:
    prompt_id = args.prompt_id
    timeout = args.timeout if args.timeout is not None else DEFAULT_TIMEOUT_IMAGE
    interval = max(1, args.interval)

    start = time.monotonic()
    while True:
        if time.monotonic() - start >= timeout:
            print(json.dumps({"status": "generation_done", "done": False, "prompt_id": prompt_id, "error": "Generation is taking longer than expected"}))
            return 1
        out, code = _check_once_and_maybe_download(prompt_id, skill_root)
        if out.get("done"):
            print(json.dumps(out))
            return 0
        if code != 0:
            print(json.dumps(out))
            return 1
        time.sleep(interval)


def main() -> int:
    parser = argparse.ArgumentParser(description="Safe ComfyUI runner: profiles, raw workflows, health, submit, poll, watch.")
    parser.add_argument("--skill_root", type=Path, default=SKILL_ROOT, help="Skill root (default: auto)")
    parser.add_argument("--health", action="store_true", help="Check ComfyUI server health")
    parser.add_argument("--list", action="store_true", dest="list_workflows", help="List built-in template workflows")
    parser.add_argument("--list-profiles", action="store_true", help="List remembered server profiles")
    parser.add_argument("--save-profile", type=str, help="Save the current connection as a named profile")
    parser.add_argument("--delete-profile", type=str, help="Delete a saved profile")
    parser.add_argument("--set-profile", type=str, help="Mark a saved profile as active")
    parser.add_argument("--set-default-server", type=str, help="Set the default server to use")
    parser.add_argument("--set-default-workflow", type=str, help="Set the default workflow to use")
    parser.add_argument("--no-activate", action="store_true", help="Save profile without making it active")
    parser.add_argument("--gen", action="store_true", help="Submit a generation")
    parser.add_argument("--submit", action="store_true", help="Alias for --gen")
    parser.add_argument("--watch", action="store_true", help="Block until done or timeout (requires prompt_id); run in foreground.")
    parser.add_argument("--poll", action="store_true", help="Check once and return; run in a loop until done:true.")
    parser.add_argument("id", nargs="?", help="Template id for --gen, or prompt_id for --watch/--poll")
    parser.add_argument("--workflow-file", type=str, help="Path to a raw ComfyUI workflow JSON file")
    parser.add_argument("--workflow-json", type=str, help="Raw ComfyUI workflow JSON string")
    parser.add_argument("--workflow-stdin", action="store_true", help="Read raw workflow JSON from stdin")
    parser.add_argument("--positive_prompt", "--prompt", "-p", dest="positive_prompt", help="Positive prompt for template workflows")
    parser.add_argument("--negative_prompt", "-n", help="Negative prompt for template workflows")
    parser.add_argument("--steps", type=int, help="Steps override for template workflows")
    parser.add_argument("--cfg", type=float, help="CFG scale override for template workflows")
    parser.add_argument("--sampler_name", help="Sampler name override for template workflows")
    parser.add_argument("--scheduler", help="Scheduler override for template workflows")
    parser.add_argument("--denoise", type=float, help="Denoise override for template workflows")
    parser.add_argument("--client_id", help="Optional client_id UUID")
    parser.add_argument("--overrides", type=str, help="JSON object of extra overrides for template workflows")
    parser.add_argument("--timeout", type=int, default=None, help="Max wait seconds for --watch")
    parser.add_argument("--interval", type=int, default=DEFAULT_INTERVAL, help="Poll interval for --watch (seconds)")
    parser.add_argument("--base-url", dest="base_url", help="ComfyUI base URL, e.g. http://127.0.0.1")
    parser.add_argument("--port", type=int, help="ComfyUI port, e.g. 8188")
    parser.add_argument("--api-key", dest="api_key", help="Optional ComfyUI API key")
    parser.add_argument("--username", help="Optional Basic-auth username")
    parser.add_argument("--password", help="Optional Basic-auth password")
    parser.add_argument("--profile", help="Use a remembered server profile for this run")
    parser.add_argument("--default-workflow", dest="default_workflow", help="Workflow to use when none is provided")
    parser.add_argument("--serve-url", help="Optional local viewer URL hint to echo back")

    args = parser.parse_args()
    _apply_cli_env(args)
    skill_root = args.skill_root
    if args.default_workflow is None:
        args.default_workflow = resolve_config(skill_root=skill_root).get("default_workflow")

    if args.health:
        return cmd_health(skill_root)
    if args.list_workflows:
        return cmd_list(skill_root)
    if args.list_profiles:
        return cmd_list_profiles()
    if args.save_profile:
        return cmd_save_profile(args)
    if args.delete_profile:
        return cmd_delete_profile(args)
    if args.set_profile:
        return cmd_set_active_profile(args)
    if args.set_default_server:
        return cmd_set_default_server(args)
    if args.set_default_workflow:
        return cmd_set_default_workflow(args)
    if args.gen or args.submit:
        return cmd_submit(args, skill_root)
    if args.watch:
        if not args.id:
            print(json.dumps({"status": "generation_done", "done": False, "error": "Missing prompt_id"}))
            return 1
        args.prompt_id = args.id
        return cmd_watch(args, skill_root)
    if args.poll:
        if not args.id:
            print(json.dumps({"status": "generation_done", "done": False, "error": "Missing prompt_id"}))
            return 1
        args.prompt_id = args.id
        return cmd_poll(args, skill_root)

    print(json.dumps({"status": "error", "error": "Use one of: --health, --list, --list-profiles, --save-profile, --delete-profile, --set-profile, --gen, --watch, --poll"}), file=sys.stderr)
    return 1


if __name__ == "__main__":
    sys.exit(main())


path: scripts/serve_generated.py
content: """
Serve the skill's generated/ folder over HTTP with a browse/delete UI.
Run from skill root. Auto-exits after 5 minutes (COMFYUI_SERVE_TIMEOUT_SEC); GET /reset-timer resets the timer.
Port: COMFYUI_SERVE_PORT (default 8765).
When run normally, spawns the server in a subprocess and prints JSON success then exits so the agent gets immediate feedback.
"""

from __future__ import annotations

import json
import os
import sys
import threading
import time
from pathlib import Path
from subprocess import DEVNULL, Popen
from urllib.parse import parse_qs, urlparse

from http.server import BaseHTTPRequestHandler, HTTPServer

SKILL_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_PORT = 8765
DEFAULT_TIMEOUT_SEC = 300
MEDIA_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".mp4", ".webm", ".mov", ".avi", ".mkv"}
MIME_TYPES = {
    ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
    ".webp": "image/webp", ".gif": "image/gif",
    ".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime",
    ".avi": "video/x-msvideo", ".mkv": "video/x-matroska",
}


def _safe_path(generated_dir: Path, filename: str) -> Path | None:
    """Resolve filename under generated_dir; return None if invalid (path traversal)."""
    if not filename or "/" in filename or "\\" in filename or ".." in filename:
        return None
    resolved = (generated_dir / filename).resolve()
    try:
        resolved.relative_to(generated_dir.resolve())
    except ValueError:
        return None
    return resolved if resolved.is_file() else None


class GeneratedHandler(BaseHTTPRequestHandler):
    def log_message(self, format: str, *args: object) -> None:
        pass  # Suppress default request logging

    def do_GET(self) -> None:
        parsed = urlparse(self.path)
        path = parsed.path.rstrip("/") or "/"
        query = parse_qs(parsed.query)
        generated_dir: Path = self.server.generated_dir  # type: ignore[attr-defined]

        # Reset timer (also used by serve_status.py)
        if path == "/reset-timer":
            self._reset_timer()
            self.send_response(200)
            self.send_header("Content-Type", "text/plain")
            self.end_headers()
            self.wfile.write(b"ok")
            return

        # Delete file then redirect to /
        if path == "/delete":
            file_param = query.get("file", [None])[0]
            safe = _safe_path(generated_dir, file_param) if file_param else None
            if safe:
                try:
                    safe.unlink()
                except OSError:
                    pass
            self.send_response(302)
            self.send_header("Location", "/")
            self.end_headers()
            return

        # Serve a file under /files/<filename>
        if path.startswith("/files/"):
            filename = path[7:].lstrip("/").split("/")[0]
            safe = _safe_path(generated_dir, filename)
            if safe:
                self.send_response(200)
                mime = MIME_TYPES.get(safe.suffix.lower(), "application/octet-stream")
                self.send_header("Content-Type", mime)
                self.send_header("Content-Disposition", f'inline; filename="{filename}"')
                self.end_headers()
                self.wfile.write(safe.read_bytes())
                return
            self.send_response(404)
            self.end_headers()
            return

        # Index: carousel of media (sorted by date, newest first)
        if path == "/":
            try:
                files_with_mtime = [
                    (f.name, f.stat().st_mtime)
                    for f in generated_dir.iterdir()
                    if f.is_file() and f.suffix.lower() in MEDIA_EXTENSIONS
                ]
                files = [n for n, _ in sorted(files_with_mtime, key=lambda x: -x[1])]
            except OSError:
                files = []
            html = self._carousel_html(files)
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.end_headers()
            self.wfile.write(html.encode("utf-8"))
            return

        self.send_response(404)
        self.end_headers()

    def _carousel_html(self, files: list[str]) -> str:
        if not files:
            return (
                "<!DOCTYPE html><html><head><meta charset='utf-8'><title>Generated</title></head>"
                "<body style='font-family:sans-serif;padding:2rem;'><h1>Generated</h1><p>No media yet.</p></body></html>"
            )
        ext = Path(files[0]).suffix.lower()
        is_video = ext in {".mp4", ".webm", ".mov", ".avi", ".mkv"}
        files_js = ",".join(repr(f) for f in files)
        initial_media = (
            f'<video src="/files/{files[0]}" controls style="max-width:100%;max-height:70vh;"></video>'
            if is_video
            else f'<img src="/files/{files[0]}" alt="" style="max-width:100%;max-height:70vh;display:block;">'
        )
        return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Generated</title>
<style>
  body {{ font-family: system-ui, sans-serif; margin: 0; padding: 1rem; background: #1a1a1a; color: #eee; }}
  .toolbar {{ display: flex; align-items: center; gap: 1rem; margin: 1rem 0; flex-wrap: wrap; }}
  .toolbar a, .toolbar button {{ padding: 0.5rem 1rem; text-decoration: none; color: #eee; background: #333; border: 1px solid #555; border-radius: 4px; cursor: pointer; font-size: 1rem; }}
  .toolbar a:hover, .toolbar button:hover {{ background: #444; }}
  .counter {{ margin-left: auto; }}
  .viewer > * {{ border-radius: 6px; }}
</style></head>
<body>
  <h1>Generated</h1>
  <div class="viewer" id="mediaWrap">{initial_media}</div>
  <div class="toolbar">
    <button id="prev">← Prev</button>
    <button id="next">Next →</button>
    <a id="download" href="/files/{files[0]}" download="{files[0]}">Download</a>
    <button id="del">Delete</button>
    <span class="counter" id="counter">1 / {len(files)}</span>
  </div>
  <script>
    const files = [{files_js}];
    let i = 0;
    const wrap = document.getElementById("mediaWrap");
    const download = document.getElementById("download");
    const counter = document.getElementById("counter");
    const isVideo = (f) => /\\.(mp4|webm|mov|avi|mkv)$/i.test(f);
    function show() {{
      const f = files[i];
      if (isVideo(f))
        wrap.innerHTML = '<video src="/files/' + f.replace(/"/g, '&quot;') + '" controls style="max-width:100%;max-height:70vh;"></video>';
      else
        wrap.innerHTML = '<img src="/files/' + f.replace(/"/g, '&quot;') + '" alt="" style="max-width:100%;max-height:70vh;display:block;">';
      download.href = "/files/" + encodeURIComponent(f);
      download.download = f;
      counter.textContent = (i + 1) + " / " + files.length;
    }}
    document.getElementById("prev").onclick = () => {{ if (files.length) {{ i = (i - 1 + files.length) % files.length; show(); }} }};
    document.getElementById("next").onclick = () => {{ if (files.length) {{ i = (i + 1) % files.length; show(); }} }};
    document.getElementById("del").onclick = () => {{
      if (!files.length || !confirm("Delete " + files[i] + "?")) return;
      window.location.href = "/delete?file=" + encodeURIComponent(files[i]);
    }};
  </script>
</body></html>"""

    def _reset_timer(self) -> None:
        server = self.server
        lock: threading.Lock = server.shutdown_lock  # type: ignore[attr-defined]
        timeout_sec: int = server.shutdown_timeout_sec  # type: ignore[attr-defined]
        with lock:
            old = getattr(server, "shutdown_timer", None)
            if old is not None:
                old.cancel()
            t = threading.Timer(timeout_sec, server.shutdown)
            t.daemon = True
            server.shutdown_timer = t  # type: ignore[attr-defined]
            t.start()


def run_server(port: int, timeout_sec: int) -> None:
    """Run the HTTP server (blocking). Used by the daemon subprocess."""
    generated = SKILL_ROOT / "generated"
    generated.mkdir(parents=True, exist_ok=True)
    server = HTTPServer(("0.0.0.0", port), GeneratedHandler)
    server.generated_dir = generated  # type: ignore[attr-defined]
    server.shutdown_lock = threading.Lock()  # type: ignore[attr-defined]
    server.shutdown_timeout_sec = timeout_sec  # type: ignore[attr-defined]
    server.shutdown_timer = None  # type: ignore[attr-defined]
    t = threading.Timer(timeout_sec, server.shutdown)
    t.daemon = True
    server.shutdown_timer = t  # type: ignore[attr-defined]
    t.start()
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass


def main() -> int:
    port = int(os.environ.get("COMFYUI_SERVE_PORT", str(DEFAULT_PORT)))
    timeout_sec = int(os.environ.get("COMFYUI_SERVE_TIMEOUT_SEC", str(DEFAULT_TIMEOUT_SEC)))
    url = f"http://127.0.0.1:{port}/"

    # If we are the daemon subprocess, run the server (blocking) and exit when server stops.
    if os.environ.get("COMFYUI_SERVE_DAEMON") == "1":
        run_server(port, timeout_sec)
        return 0

    # Otherwise: spawn daemon subprocess, wait for it to bind, print JSON success, exit so the agent gets immediate response.
    env = {**os.environ, "COMFYUI_SERVE_DAEMON": "1"}
    child = Popen(
        [sys.executable, __file__],
        cwd=str(SKILL_ROOT),
        env=env,
        stdout=DEVNULL,
        stderr=DEVNULL,
    )
    time.sleep(0.3)
    if child.poll() is not None:
        out = {
            "status": "serve_started",
            "success": False,
            "error": "Server process exited immediately.",
        }
        print(json.dumps(out), flush=True)
        return 1
    out = {
        "status": "serve_started",
        "success": True,
        "url": url,
        "port": port,
        "message": f"Server started. Available at {url} for the next {timeout_sec // 60} minutes.",
    }
    print(json.dumps(out), flush=True)
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: scripts/serve_status.py
content: """
Check if the serve_generated server is running. If so, GET /reset-timer (resets 5-min auto-exit).
Run before starting the server to avoid duplicate instances.
Port: COMFYUI_SERVE_PORT (default 8765). Output: JSON with status, running, url (if running), message.
"""

from __future__ import annotations

import json
import os
import sys
from urllib.request import Request, urlopen
from urllib.error import URLError

DEFAULT_PORT = 8765
TIMEOUT = 2


def main() -> int:
    port = int(os.environ.get("COMFYUI_SERVE_PORT", str(DEFAULT_PORT)))
    url = f"http://127.0.0.1:{port}/"
    reset_url = f"http://127.0.0.1:{port}/reset-timer"
    try:
        req = Request(reset_url)
        with urlopen(req, timeout=TIMEOUT) as _:
            out = {
                "status": "serve_status",
                "running": True,
                "url": url,
                "message": f"Viewer already running at {url} (5-minute timer reset).",
            }
            print(json.dumps(out), flush=True)
            return 0
    except (URLError, OSError, ValueError):
        out = {
            "status": "serve_status",
            "running": False,
            "message": "Viewer not running. Start it with: python3 scripts/serve_generated.py",
        }
        print(json.dumps(out), flush=True)
        return 1


if __name__ == "__main__":
    sys.exit(main())


path: workflows/IL-Ikastrious.json
content: {
  "14": {
    "inputs": {
      "ckpt_name": "Illustrious/anime/ikastrious_v200.safetensors",
      "weight_dtype": "default",
      "compute_dtype": "default",
      "patch_cublaslinear": true,
      "sage_attention": "auto",
      "enable_fp16_accumulation": true
    },
    "class_type": "CheckpointLoaderKJ",
    "_meta": {
      "title": "CheckpointLoaderKJ"
    }
  },
  "15": {
    "inputs": {
      "seed": ["28", 3],
      "steps": ["33", 0],
      "cfg": ["32", 0],
      "sampler_name": "res_multistep",
      "scheduler": "karras",
      "denoise": 1,
      "model": ["14", 0],
      "positive": ["17", 0],
      "negative": ["18", 0],
      "latent_image": ["16", 0]
    },
    "class_type": "KSampler",
    "_meta": {
      "title": "KSampler"
    }
  },
  "16": {
    "inputs": {
      "ratio_selected": "3:4 [896x1152 portrait]",
      "batch_size": 1
    },
    "class_type": "Empty Latent Ratio Select SDXL",
    "_meta": {
      "title": "Empty Latent Ratio Select SDXL (Mikey)"
    }
  },
  "17": {
    "inputs": {
      "text": "",
      "clip": ["14", 1]
    },
    "class_type": "CLIPTextEncode",
    "_meta": {
      "title": "Positive Prompt"
    }
  },
  "18": {
    "inputs": {
      "text": "bad quality, worst quality, bad anatomy, low quality,",
      "clip": ["14", 1]
    },
    "class_type": "CLIPTextEncode",
    "_meta": {
      "title": "Negative Prompt"
    }
  },
  "19": {
    "inputs": {
      "samples": ["15", 0],
      "vae": ["14", 2]
    },
    "class_type": "VAEDecode",
    "_meta": {
      "title": "VAEDecode"
    }
  },
  "20": {
    "inputs": {
      "filename_prefix": "ComfyUI",
      "with_workflow": false,
      "metadata_extra": ["21", 0],
      "image": ["30", 0]
    },
    "class_type": "Save image with extra metadata [Crystools]",
    "_meta": {
      "title": "🪛 Save image with extra metadata"
    }
  },
  "21": {
    "inputs": {
      "text": "\n \"Title\": \"\",\n \"Description\": \"\",\n \"Author\": \"CmfRMT\",\n \"Software\": \"ComfyUI\",\n \"Category\": \"StableDiffusion\",\n \"Keywords\": [\n \"\"\n ],\n \"Copyrights\": \"\"\n"
    },
    "class_type": "Text Multiline",
    "_meta": {
      "title": "Text Multiline"
    }
  },
  "22": {
    "inputs": {
      "upscale_by": ["27", 0],
      "seed": ["28", 3],
      "steps": ["33", 0],
      "cfg": ["32", 0],
      "sampler_name": "res_multistep",
      "scheduler": "karras",
      "denoise": 0.5,
      "mode_type": "Linear",
      "tile_width": ["25", 0],
      "tile_height": ["26", 0],
      "mask_blur": 8,
      "tile_padding": 32,
      "seam_fix_mode": "None",
      "seam_fix_denoise": 1,
      "seam_fix_width": 64,
      "seam_fix_mask_blur": 8,
      "seam_fix_padding": 16,
      "force_uniform_tiles": false,
      "tiled_decode": false,
      "batch_size": 1,
      "image": ["19", 0],
      "model": ["14", 0],
      "positive": ["17", 0],
      "negative": ["18", 0],
      "vae": ["14", 2],
      "upscale_model": ["23", 0]
    },
    "class_type": "UltimateSDUpscale",
    "_meta": {
      "title": "Ultimate SD Upscale"
    }
  },
  "23": {
    "inputs": {
      "model_name": "4x-AnimeSharp.pth"
    },
    "class_type": "UpscaleModelLoader",
    "_meta": {
      "title": "Load Upscale Model"
    }
  },
  "24": {
    "inputs": {
      "image": ["19", 0]
    },
    "class_type": "GetImageSize",
    "_meta": {
      "title": "Get Image Size"
    }
  },
  "25": {
    "inputs": {
      "expression": "a*b",
      "a": ["24", 0],
      "b": ["27", 0]
    },
    "class_type": "MathExpression|pysssss",
    "_meta": {
      "title": "Math Expression 🐍"
    }
  },
  "26": {
    "inputs": {
      "expression": "a*b",
      "a": ["24", 1],
      "b": ["27", 0]
    },
    "class_type": "MathExpression|pysssss",
    "_meta": {
      "title": "Math Expression 🐍"
    }
  },
  "27": {
    "inputs": {
      "value": 1.5
    },
    "class_type": "FloatConstant",
    "_meta": {
      "title": "UPSCALE By"
    }
  },
  "28": {
    "inputs": {
      "seed": 984559063235368
    },
    "class_type": "Seed",
    "_meta": {
      "title": "Seed"
    }
  },
  "29": {
    "inputs": {
      "rgthree_comparer": {
        "images": [
          {
            "name": "A",
            "selected": true,
            "url": "/api/view?filename=rgthree.compare._temp_vvkek_00031_.png&type=temp&subfolder=&rand=0.9181522617139417"
          },
          {
            "name": "B",
            "selected": true,
            "url": "/api/view?filename=rgthree.compare._temp_vvkek_00032_.png&type=temp&subfolder=&rand=0.9513444099792554"
          }
        ]
      },
      "image_a": ["30", 0],
      "image_b": ["19", 0]
    },
    "class_type": "Image Comparer (rgthree)",
    "_meta": {
      "title": "Image Comparer (rgthree)"
    }
  },
  "30": {
    "inputs": {
      "guide_size": 2048,
      "guide_size_for": true,
      "max_size": 2048,
      "seed": ["28", 3],
      "steps": ["33", 0],
      "cfg": ["32", 0],
      "sampler_name": "res_multistep",
      "scheduler": "karras",
      "denoise": 0.5,
      "feather": 5,
      "noise_mask": true,
      "force_inpaint": true,
      "bbox_threshold": 0.5,
      "bbox_dilation": 10,
      "bbox_crop_factor": 3,
      "sam_detection_hint": "center-1",
      "sam_dilation": 0,
      "sam_threshold": 0.93,
      "sam_bbox_expansion": 0,
      "sam_mask_hint_threshold": 0.7,
      "sam_mask_hint_use_negative": "False",
      "drop_size": 10,
      "wildcard": "",
      "cycle": 1,
      "inpaint_model": false,
      "noise_mask_feather": 20,
      "tiled_encode": false,
      "tiled_decode": false,
      "image": ["22", 0],
      "model": ["14", 0],
      "clip": ["14", 1],
      "vae": ["14", 2],
      "positive": ["17", 0],
      "negative": ["18", 0],
      "bbox_detector": ["31", 0],
      "segm_detector_opt": ["31", 1]
    },
    "class_type": "FaceDetailer",
    "_meta": {
      "title": "FaceDetailer"
    }
  },
  "31": {
    "inputs": {
      "model_name": "segm/Anzhc Face seg 768MS v2 y8n.pt"
    },
    "class_type": "UltralyticsDetectorProvider",
    "_meta": {
      "title": "UltralyticsDetectorProvider"
    }
  },
  "32": {
    "inputs": {
      "value": 5
    },
    "class_type": "FloatConstant",
    "_meta": {
      "title": "CFG"
    }
  },
  "33": {
    "inputs": {
      "value": 20
    },
    "class_type": "INTConstant",
    "_meta": {
      "title": "STEPS"
    }
  }
}


path: workflows/IL-Ikastrious.meta.json
content: {
  "positive_prompt_node": "17",
  "positive_prompt_input": "text",
  "negative_prompt_node": "18",
  "negative_prompt_input": "text",
  "seed_node": "28",
  "keywords": [
    "anime",
    "cartoon",
    "illustration",
    "ikastrious",
    "il-ikastrious"
  ],
  "model": {
    "node": "14",
    "input": "ckpt_name"
  },
  "extra": {
    "ratio_selected": {
      "node": "16",
      "input": "ratio_selected"
    },
    "upscale_by": {
      "node": "27",
      "input": "value"
    },
    "cfg": {
      "node": "32",
      "input": "value"
    },
    "steps": {
      "node": "33",
      "input": "value"
    },
    "sampler_name": {
      "node": "15",
      "input": "sampler_name"
    },
    "scheduler": {
      "node": "15",
      "input": "scheduler"
    },
    "denoise": {
      "node": "15",
      "input": "denoise"
    },
    "upscale_model": {
      "node": "23",
      "input": "model_name"
    }
  }
}


path: workflows/api-zturbo.json
content: {
  "1": {
    "inputs": {
      "model_name": "ZImageTurbo/realistic/intorealism_zitV10.safetensors",
      "weight_dtype": "default",
      "compute_dtype": "default",
      "patch_cublaslinear": true,
      "sage_attention": "auto",
      "enable_fp16_accumulation": true
    },
    "class_type": "DiffusionModelLoaderKJ",
    "_meta": {"title": "Diffusion Model Loader KJ"}
  },
  "2": {
    "inputs": {
      "clip_name": "mlabonne_Qwen3-4B-abliterated-Q4_K_M.gguf",
      "type": "lumina2",
      "device": "default"
    },
    "class_type": "ClipLoaderGGUF",
    "_meta": {"title": "GGUF CLIP Loader"}
  },
  "3": {
    "inputs": {
      "vae_name": "ae.sft"
    },
    "class_type": "VAELoader",
    "_meta": {"title": "Load VAE"}
  },
  "4": {
    "inputs": {
      "width": 720,
      "height": 1248,
      "batch_size": 1
    },
    "class_type": "EmptySD3LatentImage",
    "_meta": {"title": "EmptySD3LatentImage"}
  },
  "6": {
    "inputs": {
      "text": "test prompt",
      "clip": ["2", 0]
    },
    "class_type": "CLIPTextEncode",
    "_meta": {"title": "CLIP Text Encode (Prompt)"}
  },
  "7": {
    "inputs": {
      "seed": -1,
      "steps": 9,
      "cfg": 1,
      "sampler_name": "res_multistep",
      "scheduler": "simple",
      "denoise": 1,
      "model": ["10", 0],
      "positive": ["6", 0],
      "negative": ["11", 0],
      "latent_image": ["4", 0]
    },
    "class_type": "KSampler",
    "_meta": {"title": "KSampler"}
  },
  "8": {
    "inputs": {
      "samples": ["7", 0],
      "vae": ["3", 0]
    },
    "class_type": "VAEDecode",
    "_meta": {"title": "VAE Decode"}
  },
  "9": {
    "inputs": {
      "images": ["8", 0]
    },
    "class_type": "PreviewImage",
    "_meta": {"title": "Preview Image"}
  },
  "10": {
    "inputs": {
      "shift": 3,
      "model": ["1", 0]
    },
    "class_type": "ModelSamplingAuraFlow",
    "_meta": {"title": "ModelSamplingAuraFlow"}
  },
  "11": {
    "inputs": {
      "conditioning": ["6", 0]
    },
    "class_type": "ConditioningZeroOut",
    "_meta": {"title": "ConditioningZeroOut"}
  }
}

path: workflows/api-zturbo.meta.json
content: {
  "positive_prompt_node": "6",
  "positive_prompt_input": "text",
  "seed_node": "7",
  "negative_prompt_node": null,
  "model": { "node": "1", "input": "model_name" },
  "extra": {
    "steps": { "node": "7", "input": "steps" },
    "cfg": { "node": "7", "input": "cfg" },
    "sampler_name": { "node": "7", "input": "sampler_name" },
    "scheduler": { "node": "7", "input": "scheduler" },
    "denoise": { "node": "7", "input": "denoise" }
  }
}


path: workflows/sdxl-simple.json
content: {
  "1": {
    "inputs": {
      "ckpt_name": "SDXL 1.0/realistic/intorealismXL_sdxlV4.safetensors"
    },
    "class_type": "CheckpointLoaderSimple",
    "_meta": {"title": "Load Checkpoint"}
  },
  "2": {
    "inputs": {
      "text": "test prompt",
      "clip": ["1", 1]
    },
    "class_type": "CLIPTextEncode",
    "_meta": {"title": "CLIP Text Encode (Prompt)"}
  },
  "3": {
    "inputs": {
      "text": "bad quality",
      "clip": ["1", 1]
    },
    "class_type": "CLIPTextEncode",
    "_meta": {"title": "CLIP Text Encode (Negative)"}
  },
  "4": {
    "inputs": {
      "width": 1024,
      "height": 1024,
      "batch_size": 1
    },
    "class_type": "EmptyLatentImage",
    "_meta": {"title": "Empty Latent Image"}
  },
  "5": {
    "inputs": {
      "seed": 12345,
      "steps": 20,
      "cfg": 7,
      "sampler_name": "euler",
      "scheduler": "normal",
      "denoise": 1.0,
      "model": ["1", 0],
      "positive": ["2", 0],
      "negative": ["3", 0],
      "latent_image": ["4", 0]
    },
    "class_type": "KSampler",
    "_meta": {"title": "KSampler"}
  },
  "6": {
    "inputs": {
      "samples": ["5", 0],
      "vae": ["1", 2]
    },
    "class_type": "VAEDecode",
    "_meta": {"title": "VAE Decode"}
  },
  "7": {
    "inputs": {
      "images": ["6", 0]
    },
    "class_type": "PreviewImage",
    "_meta": {"title": "Preview Image"}
  }
}

path: workflows/sdxl-simple.meta.json
content: {
  "positive_prompt_node": "2",
  "positive_prompt_input": "text",
  "seed_node": "5",
  "negative_prompt_node": "3",
  "negative_prompt_input": "text",
  "extra": {
    "steps": { "node": "5", "input": "steps" },
    "cfg": { "node": "5", "input": "cfg" },
    "sampler_name": { "node": "5", "input": "sampler_name" },
    "scheduler": { "node": "5", "input": "scheduler" },
    "denoise": { "node": "5", "input": "denoise" }
  }
}

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