Zum Inhalt springen

cat posts/comfyui-api-erstes-bild.md

ComfyUI per API: das erste Bild mit Python erzeugen

Einen ComfyUI-Workflow im API-Format exportieren, Prompt und Seed in Python setzen, den Auftrag per /prompt starten und das fertige Bild über /history und /view herunterladen.

ComfyUI ist mehr als der Node-Editor im Browser. Hinter der Oberfläche läuft ein Server, der Workflows entgegennimmt, validiert, in eine Queue stellt und ausführt. Diesen Server können wir auch direkt aus Python ansprechen.

Der Ablauf ist überschaubar:

Python-Skript
    |
    | POST /prompt
    v
ComfyUI-Queue
    |
    | Workflow ausführen
    v
History
    |
    | GET /history/{prompt_id}
    v
Bildinformationen
    |
    | GET /view
    v
Lokale Bilddatei

Wir verwenden dafür bewusst nur HTTP-Requests. ComfyUI bietet auch WebSockets für Live-Fortschritt, aber zum Einstieg lässt sich Polling über die History leichter nachvollziehen.

Voraussetzungen

Bevor Python ins Spiel kommt, sollte Dein Workflow direkt in ComfyUI funktionieren. Du brauchst:

  • eine laufende lokale ComfyUI-Instanz;
  • einen funktionierenden Text-to-Image-Workflow;
  • ein lokal verfügbares Checkpoint-Modell;
  • einen Save Image-Node am Ende des Workflows;
  • Python und das Paket requests.

Standardmäßig ist ComfyUI unter dieser Adresse erreichbar:

http://127.0.0.1:8188

Wenn sich dort die Oberfläche öffnet und Dein Workflow ein Bild erzeugt, können wir ihn aus Python starten.

Den Workflow für die API exportieren

Das Skript baut den Workflow nicht selbst zusammen. Wir erstellen und testen ihn in ComfyUI und exportieren anschließend die ausführbare Beschreibung als JSON.

Für das Beispiel reicht ein klassischer Text-to-Image-Workflow:

Load Checkpoint
    ├──> CLIP Text Encode (positiver Prompt)
    ├──> CLIP Text Encode (negativer Prompt)
    └──> KSampler <── Empty Latent Image
             |
             v
         VAE Decode
             |
             v
         Save Image

Wichtig ist, dass Du nicht das normale Workflow-JSON verwendest. POST /prompt erwartet das API-Format. Exportiere den funktionierenden Workflow über:

File → Export Workflow (API)

und speichere ihn als:

workflow_api.json

Das normale Speicherformat enthält zusätzliche Daten für den Editor, etwa Positionen, Größen und Gruppen. Das API-Format besteht im Wesentlichen aus den Nodes, ihren Inputs und den Verbindungen untereinander.

Ein Ausschnitt sieht beispielsweise so aus:

{
  "3": {
    "inputs": {
      "seed": 42,
      "steps": 20,
      "cfg": 7.0,
      "sampler_name": "euler",
      "scheduler": "normal",
      "denoise": 1,
      "model": ["4", 0],
      "positive": ["6", 0],
      "negative": ["7", 0],
      "latent_image": ["5", 0]
    },
    "class_type": "KSampler"
  },
  "6": {
    "inputs": {
      "text": "a small robot repairing a computer",
      "clip": ["4", 1]
    },
    "class_type": "CLIPTextEncode"
  },
  "7": {
    "inputs": {
      "text": "blurry, text, watermark",
      "clip": ["4", 1]
    },
    "class_type": "CLIPTextEncode"
  }
}

Je nach ComfyUI-Version kann zusätzlich ein _meta-Block an einem Node hängen. Für unser Skript ist er uninteressant und kann einfach im JSON bleiben.

Die Schlüssel "3", "6" und "7" sind die Node-IDs. Eine Verbindung wie:

"positive": ["6", 0]

heißt: Verwende Ausgang 0 von Node 6 als positiven Input des Samplers.

Damit können wir die drei IDs ermitteln, die das Skript später verändert. Suche in workflow_api.json nach:

"class_type": "KSampler"

In unserem Beispiel ist das Node "3". Dort stehen auch:

"positive": ["6", 0],
"negative": ["7", 0]

Also:

KSampler:          3
Positiver Prompt:  6
Negativer Prompt:  7

Die IDs sind nicht universell. In Deinem Workflow können sie anders lauten.

Python-Projekt vorbereiten

Lege ein kleines Projekt an und installiere requests:

mkdir comfy-api
cd comfy-api
uv init
uv add requests

Kopiere workflow_api.json in das Verzeichnis und lege daneben generate.py an:

comfy-api/
├── generate.py
└── workflow_api.json

Pillow oder eine andere Bildbibliothek brauchen wir nicht. ComfyUI liefert die fertige Datei als Bytes, die Python direkt speichern kann.

Das vollständige Skript

from __future__ import annotations

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

import requests


SERVER_URL = "http://127.0.0.1:8188"
WORKFLOW_FILE = Path("workflow_api.json")
OUTPUT_DIR = Path("output")

# Diese IDs musst Du an Deinen Workflow anpassen.
SAMPLER_NODE_ID = "3"
POSITIVE_PROMPT_NODE_ID = "6"
NEGATIVE_PROMPT_NODE_ID = "7"

HTTP_TIMEOUT = 30
GENERATION_TIMEOUT = 600
POLL_INTERVAL = 0.5
MAX_SEED = 2**64


def load_workflow() -> dict[str, Any]:
    with WORKFLOW_FILE.open(encoding="utf-8") as file:
        workflow = json.load(file)

    if not isinstance(workflow, dict):
        raise ValueError("Der Workflow muss ein JSON-Objekt sein.")

    return workflow


def check_response(response: requests.Response) -> None:
    if response.ok:
        return

    try:
        details = json.dumps(response.json(), ensure_ascii=False, indent=2)
    except ValueError:
        details = response.text

    raise RuntimeError(
        f"ComfyUI antwortete mit HTTP {response.status_code}:\n{details}"
    )


def queue_prompt(workflow: dict[str, Any]) -> str:
    response = requests.post(
        f"{SERVER_URL}/prompt",
        json={"prompt": workflow},
        timeout=HTTP_TIMEOUT,
    )
    check_response(response)

    data = response.json()
    return str(data["prompt_id"])


def wait_for_result(prompt_id: str) -> dict[str, Any]:
    deadline = time.monotonic() + GENERATION_TIMEOUT

    while time.monotonic() < deadline:
        response = requests.get(
            f"{SERVER_URL}/history/{prompt_id}",
            timeout=HTTP_TIMEOUT,
        )
        check_response(response)
        history = response.json()

        if prompt_id in history:
            return history[prompt_id]

        time.sleep(POLL_INTERVAL)

    raise TimeoutError(
        f"Nach {GENERATION_TIMEOUT} Sekunden liegt noch kein Ergebnis vor."
    )


def collect_images(result: dict[str, Any]) -> list[dict[str, str]]:
    status = result.get("status", {})

    if status.get("status_str") == "error":
        messages = status.get("messages", [])
        raise RuntimeError(
            "ComfyUI konnte den Workflow nicht ausführen:\n"
            f"{json.dumps(messages, ensure_ascii=False, indent=2)}"
        )

    images: list[dict[str, str]] = []

    for node_output in result.get("outputs", {}).values():
        images.extend(node_output.get("images", []))

    if not images:
        raise RuntimeError(
            "Der Workflow wurde beendet, enthält aber keine abrufbaren Bilder."
        )

    return images


def download_image(image: dict[str, str], index: int) -> Path:
    response = requests.get(
        f"{SERVER_URL}/view",
        params={
            "filename": image["filename"],
            "subfolder": image["subfolder"],
            "type": image["type"],
        },
        timeout=HTTP_TIMEOUT,
    )
    check_response(response)

    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)

    suffix = Path(image["filename"]).suffix or ".png"
    target = OUTPUT_DIR / f"bild_{index:02d}{suffix}"
    target.write_bytes(response.content)

    return target


def parse_arguments() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Erzeugt über die lokale ComfyUI-API Bilder."
    )
    parser.add_argument(
        "--prompt",
        default=(
            "a small friendly robot repairing a computer, "
            "detailed illustration"
        ),
    )
    parser.add_argument(
        "--negative",
        default="blurry, text, watermark, low quality",
    )
    parser.add_argument("--seed", type=int)

    args = parser.parse_args()

    if args.seed is not None and not 0 <= args.seed < MAX_SEED:
        parser.error(f"--seed muss zwischen 0 und {MAX_SEED - 1} liegen")

    return args


def main() -> None:
    args = parse_arguments()
    seed = args.seed if args.seed is not None else random.randrange(MAX_SEED)

    workflow = load_workflow()
    workflow[POSITIVE_PROMPT_NODE_ID]["inputs"]["text"] = args.prompt
    workflow[NEGATIVE_PROMPT_NODE_ID]["inputs"]["text"] = args.negative
    workflow[SAMPLER_NODE_ID]["inputs"]["seed"] = seed

    print(f"Seed: {seed}")

    prompt_id = queue_prompt(workflow)
    print(f"Auftrag gestartet: {prompt_id}")

    result = wait_for_result(prompt_id)
    images = collect_images(result)

    for index, image in enumerate(images, start=1):
        target = download_image(image, index)
        print(f"Gespeichert: {target}")


if __name__ == "__main__":
    main()

Prüfe vor dem ersten Start diese drei Werte:

SAMPLER_NODE_ID = "3"
POSITIVE_PROMPT_NODE_ID = "6"
NEGATIVE_PROMPT_NODE_ID = "7"

Sie müssen zu den IDs in Deiner workflow_api.json passen.

Das erste Bild erzeugen

Starte das Skript ohne Argumente:

uv run python generate.py

Eine erfolgreiche Ausgabe sieht ungefähr so aus:

Seed: 15727431580152408306
Auftrag gestartet: 9d9c6bd9-2caf-48ad-b988-f48b928ca88a
Gespeichert: output/bild_01.png

Danach liegt die heruntergeladene Datei unter output/bild_01.png. Der Save Image-Node hat das Bild außerdem bereits im Output-Verzeichnis Deiner ComfyUI-Instanz gespeichert. Unser Skript holt diese Datei über die API ab und legt eine eigene Kopie im Projekt an.

Einen anderen Prompt übergibst Du so:

uv run python generate.py \
  --prompt "a corgi astronaut on the moon, cinematic lighting"

Der negative Prompt lässt sich ebenfalls ersetzen:

uv run python generate.py \
  --prompt "an old library inside a giant tree, fantasy illustration" \
  --negative "blurry, text, watermark, distorted architecture"

Für einen festen Seed kommt --seed dazu:

uv run python generate.py \
  --prompt "a corgi astronaut on the moon, cinematic lighting" \
  --seed 42

Der KSampler akzeptiert Seeds von 0 bis 2^64 - 1. Ohne --seed wählt das Skript bei jedem Aufruf einen zufälligen Wert aus diesem Bereich.

Bleiben Workflow, Modell, Prompt und Einstellungen gleich, ist das Ergebnis unter gleichen Bedingungen normalerweise reproduzierbar. Bitgenau identische Ergebnisse über andere ComfyUI-, PyTorch-, Treiber- oder Hardware-Versionen hinweg sind damit aber nicht garantiert.

Was die API-Aufrufe machen

Im Skript stecken nur drei ComfyUI-Endpunkte.

POST /prompt

Mit:

requests.post(
    f"{SERVER_URL}/prompt",
    json={"prompt": workflow},
)

schicken wir den Workflow an ComfyUI. Etwas verwirrend ist der Name prompt: Hier ist damit nicht der Text-Prompt gemeint, sondern der komplette Workflow im API-Format.

ComfyUI validiert ihn und stellt ihn in die Queue. Bei Erfolg kommt unter anderem eine prompt_id zurück:

{
  "prompt_id": "9d9c6bd9-2caf-48ad-b988-f48b928ca88a",
  "number": 4,
  "node_errors": {}
}

Ist der Workflow ungültig, liefert der Server einen HTTP-Fehler. Unser Skript gibt die Antwort mit aus, damit Du zum Beispiel node_errors sehen kannst.

GET /history/{prompt_id}

POST /prompt wartet nicht, bis das Bild fertig ist. Die Ausführung läuft asynchron weiter. Deshalb fragt das Skript alle 0,5 Sekunden:

GET /history/9d9c6bd9-2caf-48ad-b988-f48b928ca88a

Sobald der Auftrag in der History steht, finden wir unter outputs die Ausgaben der Output-Nodes. Ein Save Image-Node liefert beispielsweise:

{
  "outputs": {
    "9": {
      "images": [
        {
          "filename": "ComfyUI_00001_.png",
          "subfolder": "",
          "type": "output"
        }
      ]
    }
  }
}

Das Bild selbst steckt noch nicht in dieser Antwort. Wir bekommen nur die Daten, mit denen es sich abrufen lässt.

GET /view

Aus filename, subfolder und type entsteht der letzte Request:

GET /view?filename=ComfyUI_00001_.png&subfolder=&type=output

Die Antwort enthält die Binärdaten des Bildes. write_bytes() schreibt sie direkt in unser output-Verzeichnis.

Warum keine client_id?

In vielen ComfyUI-Beispielen taucht zusätzlich eine client_id auf. Für unser Polling über HTTP ist sie nicht nötig: Die von /prompt zurückgegebene prompt_id reicht aus, um das fertige Ergebnis aus der History zu holen.

Eine client_id wird interessant, sobald Du WebSockets verwendest:

ws://127.0.0.1:8188/ws?clientId=<client_id>

Darüber meldet ComfyUI unter anderem gestartete und ausgeführte Nodes, Sampler-Fortschritt, Cache-Treffer und Fehler. Für interaktive Anwendungen ist die Kombination aus WebSocket und History deshalb besser geeignet. Die offizielle Dokumentation empfiehlt diesen Weg für die meisten Anwendungen, die auf die Fertigstellung eines Jobs warten müssen.

Für unser erstes Skript ist Polling absichtlich simpler.

Mehrere Bilder

Das Skript sammelt alle images-Einträge aus den Outputs. Dadurch funktioniert es auch bei einem batch_size größer als 1 oder bei mehreren Nodes, die Bilder zurückgeben.

Die lokalen Dateien werden einfach durchnummeriert:

bild_01.png
bild_02.png
bild_03.png

Häufige Fehler

Keine Verbindung zu ComfyUI

Eine Meldung wie:

Failed to establish a new connection

bedeutet meistens, dass ComfyUI nicht läuft oder unter einer anderen Adresse lauscht. Prüfe zuerst:

http://127.0.0.1:8188

Bei einem anderen Port passt Du SERVER_URL an:

SERVER_URL = "http://127.0.0.1:8288"

Falsche Node-ID

Ein Fehler wie:

KeyError: '6'

bedeutet meist, dass die IDs im Skript nicht zu Deinem exportierten Workflow passen. Ermittle KSampler, positive und negative erneut in workflow_api.json.

HTTP 400 und node_errors

Dann konnte ComfyUI den Workflow nicht validieren. Häufige Ursachen sind ein fehlendes Modell, ein nicht installierter Custom Node, ein ungültiger Input oder ein Workflow, der nicht im API-Format exportiert wurde.

Keine Bilder in der History

Wenn der Auftrag fertig ist, aber keine images-Einträge liefert, prüfe den Output des Workflows. Für dieses Beispiel sollte VAE Decode mit einem Save Image-Node verbunden sein.

Nach Änderungen am Workflow musst Du die API-Datei erneut exportieren.

Timeout

Das Skript wartet standardmäßig höchstens zehn Minuten:

GENERATION_TIMEOUT = 600

Bei großen Modellen, hohen Auflösungen oder einer vollen Queue kannst Du den Wert erhöhen. HTTP_TIMEOUT = 30 gilt dagegen nur für einen einzelnen HTTP-Request.

ComfyUI auf einem anderen Rechner

Standardmäßig bindet ComfyUI den Server an 127.0.0.1. Andere Rechner können ihn so nicht erreichen.

Für Zugriff aus dem lokalen Netz kannst Du ComfyUI beispielsweise so starten:

python main.py --listen 0.0.0.0

oder einfach:

python main.py --listen

Ohne Wert lauscht --listen auf allen IPv4- und IPv6-Interfaces. Im Skript trägst Du anschließend die Adresse des ComfyUI-Rechners ein:

SERVER_URL = "http://192.168.1.50:8188"

Damit ist die API allerdings auch für andere Geräte erreichbar, die Zugriff auf diesen Port haben. Einen direkt aus dem Internet erreichbaren ComfyUI-Server würde ich nicht einfach mit --listen freigeben. Für Remote-Zugriff gehören Zugriffskontrolle und Verschlüsselung davor, etwa über einen Reverse Proxy oder ein VPN.

Von hier aus weiter

Das Grundprinzip bleibt auch für größere Anwendungen dasselbe: Workflow laden, Inputs verändern, mit /prompt starten, auf das Ergebnis warten und die Dateien über /view abholen.

Darauf lassen sich Batch-Generierungen aus Prompt-Listen, Telegram- oder Discord-Bots und eigene Web-UIs aufbauen. Sobald Live-Fortschritt, Abbruch oder viele parallele Jobs ins Spiel kommen, ist der WebSocket-Endpunkt der nächste sinnvolle Schritt.

Weiterlesen

Quellen

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!