comfyui

Generate images, video, and audio via diffusion workflows.

  • comfyui
  • image-generation
  • stable-diffusion
  • flux
  • sd3
  • wan-video
  • hunyuan-video
  • creative
  • generative-ai
  • video-generation

Declared platforms: macos · linux · windows

Install
npx skills add 'https://github.com/NousResearch/hermes-agent/tree/main/optional-skills/creative/comfyui'
Download bundle ↓
main · 24fd22bScanned 2026-09-15

Contributors

GitHub-linked commit authors for this SKILL.md at the saved revision. Co-authors and history before file renames are not included.

File history ↗
View on GitHub
← Back to SKILL.md
#!/usr/bin/env python3"""run_batch.py — Run a workflow many times, varying parameters per run. Two modes:  1. --count N --randomize-seed       Submit N runs, each with a fresh random seed. Use for quick variations.  2. --sweep '{"seed": [1,2,3], "steps": [20,30]}'       Cartesian product of values. With cloud subscription, runs in parallel       up to your tier's concurrent-job limit. Both modes write each run's outputs into output-dir/run_NNN/. Examples:    python3 run_batch.py --workflow flux_dev.json \        --args '{"prompt": "a cat"}' \        --count 8 --randomize-seed \        --output-dir ./outputs/cat-batch     python3 run_batch.py --workflow sdxl.json \        --args '{"prompt": "abstract"}' \        --sweep '{"seed": [1,2,3], "steps": [20, 40]}' \        --output-dir ./outputs/sweep""" from __future__ import annotations import argparseimport itertoolsimport jsonimport sysfrom concurrent.futures import ThreadPoolExecutor, as_completedfrom pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent))from _common import (  # noqa: E402    DEFAULT_LOCAL_HOST, ENV_API_KEY, coerce_seed, emit_json, log,    looks_like_video_workflow, resolve_api_key, unwrap_workflow,)from run_workflow import (  # noqa: E402    ComfyRunner, download_outputs, inject_params,)from extract_schema import extract_schema  # noqa: E402  def expand_sweep(sweep: dict, base_args: dict, count: int, randomize_seed: bool) -> list[dict]:    """Generate a list of args dicts for each run."""    if sweep:        # Cartesian product        keys = list(sweep.keys())        values = [sweep[k] if isinstance(sweep[k], list) else [sweep[k]] for k in keys]        runs = []        for combo in itertools.product(*values):            ar = dict(base_args)            for k, v in zip(keys, combo):                ar[k] = v            runs.append(ar)        return runs    # Count mode    runs = []    for _ in range(count):        ar = dict(base_args)        if randomize_seed:            ar["seed"] = coerce_seed(None)        runs.append(ar)    return runs  def execute_one(    runner: ComfyRunner, workflow: dict, schema: dict, args: dict,    *, output_dir: Path, timeout: int, ws: bool,) -> dict:    wf, warnings = inject_params(workflow, schema, args)    sub = runner.submit(wf)    if "_http_error" in sub:        return {"status": "error", "error": "submission HTTP error",                "details": sub.get("body"), "args": args}    pid = sub.get("prompt_id")    if not pid:        return {"status": "error", "error": "no prompt_id", "response": sub, "args": args}    if sub.get("node_errors"):        return {"status": "error", "error": "validation failed",                "node_errors": sub["node_errors"], "args": args}     if ws:        result = runner.monitor_ws(pid, timeout=timeout)    else:        result = runner.poll_status(pid, timeout=timeout)     if result["status"] != "success":        return {            "status": result["status"],            "prompt_id": pid,            "details": result.get("data"),            "args": args,        }     outputs = result.get("outputs") or runner.get_outputs(pid)    downloaded = download_outputs(runner, outputs, output_dir, preserve_subfolder=False)    return {        "status": "success",        "prompt_id": pid,        "args": args,        "outputs": downloaded,        "warnings": warnings,    }  def main(argv: list[str] | None = None) -> int:    p = argparse.ArgumentParser(        description="Submit a workflow many times with varying parameters.",    )    p.add_argument("--workflow", required=True)    p.add_argument("--args", default="{}", help="Base parameters JSON")    p.add_argument("--count", type=int, default=0,                   help="Number of runs (use with --randomize-seed)")    p.add_argument("--sweep", default="",                   help='JSON dict of param→list of values. Cartesian product. '                        'e.g. \'{"seed":[1,2,3],"cfg":[5,8]}\'')    p.add_argument("--randomize-seed", action="store_true",                   help="In --count mode, vary seed per run")    p.add_argument("--host", default=DEFAULT_LOCAL_HOST)    p.add_argument("--api-key", help=f"or set ${ENV_API_KEY}")    p.add_argument("--partner-key")    p.add_argument("--parallel", type=int, default=1,                   help="Concurrent submissions (cloud: up to your tier limit). "                        "Default 1 (sequential)")    p.add_argument("--output-dir", default="./outputs/batch")    p.add_argument("--timeout", type=int, default=0)    p.add_argument("--ws", action="store_true")    p.add_argument("--continue-on-error", action="store_true",                   help="Don't stop the batch when a run fails")    args = p.parse_args(argv)     if args.count <= 0 and not args.sweep:        emit_json({"error": "Specify --count N or --sweep '{...}'"})        return 1     base_args = json.loads(args.args) if args.args.strip() else {}    sweep = json.loads(args.sweep) if args.sweep.strip() else {}     # Validate sweep shape    if sweep:        if not isinstance(sweep, dict):            emit_json({"error": "--sweep must be a JSON object {param: [values]}"})            return 1        empty = [k for k, v in sweep.items() if isinstance(v, list) and len(v) == 0]        if empty:            emit_json({"error": f"--sweep parameters have empty value lists: {empty}"})            return 1        # If user passed BOTH --sweep and --count/--randomize-seed, --sweep wins        if args.count or args.randomize_seed:            log("--sweep set; ignoring --count / --randomize-seed (sweep defines the runs)")     wf_path = Path(args.workflow).expanduser()    if not wf_path.exists():        emit_json({"error": f"Workflow not found: {args.workflow}"})        return 1    try:        with wf_path.open(encoding="utf-8-sig") as f:            workflow = unwrap_workflow(json.load(f))    except (ValueError, json.JSONDecodeError) as e:        emit_json({"error": str(e)})        return 1     schema = extract_schema(workflow)    runs = expand_sweep(sweep, base_args, args.count, args.randomize_seed)    log(f"Planned {len(runs)} run(s)")     api_key = resolve_api_key(args.api_key)    runner = ComfyRunner(host=args.host, api_key=api_key, partner_key=args.partner_key)     ok, info = runner.check_server()    if not ok:        emit_json({"error": "Cannot reach server", "details": info, "host": args.host})        return 1     timeout = args.timeout    if timeout <= 0:        timeout = 900 if looks_like_video_workflow(workflow) else 300     base_dir = Path(args.output_dir).expanduser()    base_dir.mkdir(parents=True, exist_ok=True)     results: list[dict] = []    failures = 0     if args.parallel > 1:        with ThreadPoolExecutor(max_workers=args.parallel) as ex:            future_to_idx = {}            for i, ar in enumerate(runs):                run_dir = base_dir / f"run_{i:04d}"                fut = ex.submit(                    execute_one, runner, workflow, schema, ar,                    output_dir=run_dir, timeout=timeout, ws=args.ws,                )                future_to_idx[fut] = i            for fut in as_completed(future_to_idx):                i = future_to_idx[fut]                try:                    r = fut.result()                except Exception as e:                    r = {"status": "error", "error": str(e), "args": runs[i]}                r["index"] = i                results.append(r)                if r["status"] != "success":                    failures += 1                    log(f"  run {i} → {r['status']}: {r.get('error','?')}")                    if not args.continue_on_error:                        log("  --continue-on-error not set; aborting batch")                        break                else:                    log(f"  run {i} → success: {len(r.get('outputs', []))} files")    else:        for i, ar in enumerate(runs):            run_dir = base_dir / f"run_{i:04d}"            r = execute_one(runner, workflow, schema, ar,                           output_dir=run_dir, timeout=timeout, ws=args.ws)            r["index"] = i            results.append(r)            if r["status"] != "success":                failures += 1                log(f"  run {i} → {r['status']}: {r.get('error','?')}")                if not args.continue_on_error:                    log("  --continue-on-error not set; aborting batch")                    break            else:                log(f"  run {i} → success: {len(r.get('outputs', []))} files")     results.sort(key=lambda x: x.get("index", 0))    emit_json({        "status": "success" if failures == 0 else "partial",        "total": len(runs),        "completed": sum(1 for r in results if r["status"] == "success"),        "failed": failures,        "output_dir": str(base_dir),        "results": results,    })    return 0 if failures == 0 else 1  if __name__ == "__main__":    sys.exit(main()) 
Referenced from SKILL.md