Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 37 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ workflows, and call hosted partner image models, all from your terminal.
## Features

- 🚀 One-command ComfyUI install and launch
- 🎨 Direct calls to partner image and video nodes (Flux, Ideogram, DALL·E, Recraft, Stability, Kling, Luma, Runway, Pika, Vidu, Hailuo, …) via `comfy generate`, no workflow JSON required
- 🎨 Direct calls to partner image and video nodes (Flux, Ideogram, DALL·E, Recraft, Stability, Gemini/nano-banana, Kling, Luma, Runway, Pika, Vidu, Hailuo, Seedance, …) via `comfy generate`, no workflow JSON required
- 🔧 Custom node management — install, update, snapshot, bisect
- 📦 Fast dependency resolution with `uv` (`--fast-deps`, `--uv-compile`)
- 🗄️ Model downloads from CivitAI, Hugging Face, and direct URLs
Expand Down Expand Up @@ -291,10 +291,11 @@ the bisect tool can help you pinpoint the custom node that causes the issue.
`comfy generate` calls Comfy's partner nodes directly from the terminal — no
local ComfyUI or workflow JSON required. It hits the same hosted partner nodes
you'd otherwise wire into a ComfyUI workflow, but as one-shot CLI calls. Image
models (Flux, Ideogram, DALL·E, Recraft, Stability, Runway, Reve, xAI Grok, …)
and video models (Kling, Luma, Runway Gen-3, Pika, Vidu, Moonvalley, Hailuo,
Grok video) are all covered; video jobs run async and the CLI polls until the
result is ready.
models (Flux, Ideogram, DALL·E, Recraft, Stability, Runway, Reve, xAI Grok,
Google Gemini Flash Image aka **nano-banana**, …) and video models (Kling,
Luma, Runway Gen-3, Pika, Vidu, Moonvalley, Hailuo, Grok video, ByteDance
**Seedance**) are all covered; video jobs run async and the CLI polls until
the result is ready.

Prerequisites — a Comfy API key and a credit balance:

Expand Down Expand Up @@ -337,6 +338,37 @@ comfy generate luma --prompt "..." --aspect_ratio 16:9 --async
comfy generate resume luma <job_id> --download out.mp4
```

**Gemini Flash Image (nano-banana)** — text-to-image and image edits in one
alias. Pass `--image` (repeatable) for reference images. The response is
inline base64, so `--download` is required to save:

```bash
comfy generate nano-banana --prompt "a watercolor of a sleeping fox" \
--download fox.png

# Image edit — reference accepted as a local path, http(s) URL, or data URI:
comfy generate nano-banana --prompt "add a top hat" \
--image ./cat.png --download out.png

# Switch model variants:
comfy generate nano-banana --prompt "..." --model gemini-3-pro-image-preview \
--download out.png
```

**Seedance** — text-to-video and image-to-video, up to 1080p / 12s clips.
Resolution, ratio, duration, fps, etc. get passed through as flags; the CLI
inlines them into Seedance's prompt syntax for you:

```bash
comfy generate seedance --prompt "a hummingbird hovering over a flower" \
--resolution 1080p --duration 5 --download bird.mp4

# Image-to-video: pick a lite/i2v variant and pass a first frame.
comfy generate seedance --model seedance-1-0-lite-i2v-250428 \
--prompt "the wave crests and crashes" \
--image ./still.jpg --download wave.mp4
```

### Managing ComfyUI-Manager

- Disable ComfyUI-Manager completely (no manager flags passed to ComfyUI):
Expand Down
267 changes: 267 additions & 0 deletions comfy_cli/command/generate/adapters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
"""Per-endpoint adapters for partners whose request/response shapes don't fit
the generic schema-driven flag→JSON mold.

Two endpoints today:

- **Gemini Flash Image (nano-banana)** — Vertex AI's ``contents``/``parts``
body, inline base64 image input, and inline base64 image output. The model
variant lives in the URL path, not the body.
- **Seedance** (ByteDance) — assembles a ``content`` array of typed parts
(``text`` + optional ``image_url``) and inlines its own knobs (resolution,
duration, …) into the prompt string.

An adapter contributes three optional pieces:

- ``flags`` — replaces the schema-derived flag list for the model
- ``build_body`` — produces the JSON body from parsed flag values
- ``decode_sync`` — handles a sync response that ships inline blobs (Gemini)
- ``path_param`` — name of a flag whose value gets substituted into the URL
path's ``{placeholder}`` (e.g. ``model`` for Gemini's templated path)
"""

from __future__ import annotations

import base64
import mimetypes
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any

import httpx

from comfy_cli.command.generate.client import ApiError
from comfy_cli.command.generate.schema import FlagDef


@dataclass(frozen=True)
class Adapter:
flags: list[FlagDef]
build_body: Callable[[dict, str], dict]
decode_sync: Callable[[dict, str, str], list[Path]] | None = None
path_param: str | None = None


# ── Gemini / nano-banana ──────────────────────────────────────────────────

GEMINI_IMAGE_MODELS = (
"gemini-2.5-flash-image",
"gemini-2.5-flash-image-preview",
"gemini-3-pro-image-preview",
)


def _inline_image(value: str) -> tuple[str, str]:
"""Return ``(mime_type, base64_str)`` for a local path, http(s) URL, or
``data:`` URI. Gemini accepts inline-only — there's no signed-URL path
here, so we pull bytes locally rather than going through ``upload.py``."""
if value.startswith("data:"):
head, _, b64 = value.partition(",")
mime = head.split(";", 1)[0].removeprefix("data:") or "image/png"
return mime, b64
if value.startswith(("http://", "https://")):
with httpx.Client(timeout=60.0, follow_redirects=True) as c:
r = c.get(value)
r.raise_for_status()
mime = (r.headers.get("content-type") or "image/png").split(";", 1)[0].strip()
return mime, base64.b64encode(r.content).decode("ascii")
path = Path(value).expanduser()
if not path.is_file():
raise ApiError(0, "", f"Image not found: {path}")
mime, _ = mimetypes.guess_type(path.name)
return mime or "image/png", base64.b64encode(path.read_bytes()).decode("ascii")


def _gemini_build_body(values: dict, api_key: str) -> dict[str, Any]:
parts: list[dict[str, Any]] = [{"text": str(values["prompt"])}]
images = values.get("image") or []
if isinstance(images, str):
images = [images]
for img in images:
mime, b64 = _inline_image(str(img))
parts.append({"inlineData": {"mimeType": mime, "data": b64}})
return {
"contents": [{"role": "user", "parts": parts}],
"generationConfig": {"responseModalities": ["IMAGE"]},
}


def _gemini_decode_sync(body: dict, download: str, request_id: str) -> list[Path]:
"""Walk candidates[*].content.parts[*].inlineData; save each blob."""
from comfy_cli.command.generate import output

blobs: list[tuple[str, bytes]] = []
for cand in body.get("candidates") or []:
content = cand.get("content") or {}
for part in content.get("parts") or []:
inline = part.get("inlineData") or part.get("inline_data")
if not inline:
continue
data_b64 = inline.get("data") or ""
mime = inline.get("mimeType") or inline.get("mime_type") or "image/png"
try:
raw = base64.b64decode(data_b64, validate=False)
except (ValueError, TypeError):
continue
blobs.append((mime, raw))
if not blobs:
return []
return output.save_inline_blobs(blobs, download, request_id)


_gemini_adapter = Adapter(
flags=[
FlagDef(
name="prompt",
kind="string",
required=True,
description="Text instruction. For edits, describe the change.",
),
FlagDef(
name="image",
kind="array",
item_kind="string",
required=False,
description="Optional reference image(s): local path, http(s) URL, or data URI.",
),
FlagDef(
name="model",
kind="enum",
required=False,
default="gemini-2.5-flash-image",
description="Gemini image-model variant.",
enum=list(GEMINI_IMAGE_MODELS),
),
],
build_body=_gemini_build_body,
decode_sync=_gemini_decode_sync,
path_param="model",
)


# ── Seedance ──────────────────────────────────────────────────────────────

SEEDANCE_MODELS = (
"seedance-1-0-pro-250528",
"seedance-1-0-pro-fast-251015",
"seedance-1-5-pro-251215",
"seedance-1-0-lite-t2v-250428",
"seedance-1-0-lite-i2v-250428",
)

_SEEDANCE_INLINE_KEYS = ("resolution", "ratio", "duration", "fps", "seed", "camerafixed", "watermark")


def _seedance_text(values: dict) -> str:
"""Compose the ``text`` field, appending Seedance's inline ``--rs/--rt/…``
style overrides for any flags the user set."""
prompt = str(values["prompt"])
extras: list[str] = []
for key in _SEEDANCE_INLINE_KEYS:
v = values.get(key)
if v is None or v == "":
continue
if isinstance(v, bool):
v = "true" if v else "false"
extras.append(f"--{key} {v}")
return f"{prompt} {' '.join(extras)}".strip()


def _seedance_image_url(value: str, api_key: str) -> str:
"""Local paths get uploaded; data: and http(s) pass through verbatim."""
if value.startswith(("http://", "https://", "data:")):
return value
from comfy_cli.command.generate import upload

return upload.upload_path(Path(value).expanduser(), api_key).url


def _seedance_build_body(values: dict, api_key: str) -> dict[str, Any]:
content: list[dict[str, Any]] = [{"type": "text", "text": _seedance_text(values)}]
image = values.get("image")
if image:
content.append({"type": "image_url", "image_url": {"url": _seedance_image_url(str(image), api_key)}})
body: dict[str, Any] = {
"model": values.get("model") or SEEDANCE_MODELS[0],
"content": content,
}
if "generate_audio" in values:
body["generate_audio"] = bool(values["generate_audio"])
if "return_last_frame" in values:
body["return_last_frame"] = bool(values["return_last_frame"])
return body


_seedance_adapter = Adapter(
flags=[
FlagDef(name="prompt", kind="string", required=True, description="Text prompt for the video."),
FlagDef(
name="image",
kind="string",
required=False,
description="Optional first-frame image (URL, local path, or data URI). "
"Local paths are auto-uploaded via /customers/storage.",
),
FlagDef(
name="model",
kind="enum",
required=False,
default="seedance-1-0-pro-250528",
description="Seedance model variant.",
enum=list(SEEDANCE_MODELS),
),
FlagDef(name="resolution", kind="enum", required=False, enum=["480p", "720p", "1080p"]),
FlagDef(
name="ratio",
kind="enum",
required=False,
enum=["21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "9:21", "adaptive"],
),
FlagDef(name="duration", kind="integer", required=False, description="Length in seconds (3–12)."),
FlagDef(name="fps", kind="integer", required=False, description="Frames per second (default 24)."),
FlagDef(name="seed", kind="integer", required=False, description="RNG seed (-1 to 2^32-1)."),
FlagDef(name="camerafixed", kind="boolean", required=False, description="Lock camera position."),
FlagDef(name="watermark", kind="boolean", required=False, description="Include a watermark."),
FlagDef(
name="generate_audio",
kind="boolean",
required=False,
description="Synthesize matching audio (Seedance 1.5 pro only).",
),
FlagDef(
name="return_last_frame",
kind="boolean",
required=False,
description="Return the last-frame image alongside the video.",
),
],
build_body=_seedance_build_body,
decode_sync=None,
path_param=None,
)


_ADAPTERS: dict[str, Adapter] = {
"vertexai/gemini/{model}": _gemini_adapter,
"byteplus/api/v3/contents/generations/tasks": _seedance_adapter,
}


def get(endpoint_id: str) -> Adapter | None:
return _ADAPTERS.get(endpoint_id)


def resolve_path(template: str, values: dict, adapter: Adapter) -> str:
"""Substitute ``adapter.path_param`` into the URL template, falling back to
the flag's ``default`` when the user didn't pass it."""
if not adapter.path_param:
return template
val = values.get(adapter.path_param)
if not val:
for f in adapter.flags:
if f.name == adapter.path_param:
val = f.default
break
if not val:
raise ApiError(0, "", f"Missing --{adapter.path_param}: required to fill in the URL path.")
return template.replace("{" + adapter.path_param + "}", str(val))
24 changes: 22 additions & 2 deletions comfy_cli/command/generate/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn

from comfy_cli import tracking, ui
from comfy_cli.command.generate import client, output, poll, schema, spec, upload
from comfy_cli.command.generate import adapters, client, output, poll, schema, spec, upload

_HELP = "Generate images via ComfyUI partner nodes (Flux, Ideogram, DALL·E, Recraft, Stability, …)."

Expand Down Expand Up @@ -251,6 +251,23 @@ def _on_progress(p: float) -> None:
_emit_result(result, request_id=job_id, download=download, as_json=as_json)
return

adapter = adapters.get(ep.id)
if adapter is not None and adapter.decode_sync is not None:
body = resp.json()
if as_json:
output.print_json(body)
return
if not download:
rprint("[yellow]Image data returned inline. Pass --download <path> to save.[/yellow]")
return
saved = adapter.decode_sync(body, download, request_id)
if saved:
output.print_saved(saved)
else:
rprint("[yellow]No image data found in response.[/yellow]")
output.print_json(body)
return

result = poll.sync_result_from_response(resp)
_emit_result(result, request_id=request_id, download=download, as_json=as_json)

Expand Down Expand Up @@ -360,8 +377,11 @@ def _apply_upload_transforms(values: dict, flags: list[schema.FlagDef], endpoint
base64 blob or a URL, transform it transparently.

This only applies to JSON endpoints — multipart endpoints already stream
file paths natively via httpx and don't need pre-uploading.
file paths natively via httpx and don't need pre-uploading. Endpoints with
a custom adapter handle their own asset shaping inside ``build_body``.
"""
if adapters.get(endpoint.id) is not None:
return
if endpoint.request_content_type != "application/json":
return
flag_by_name = {f.name: f for f in flags}
Expand Down
11 changes: 9 additions & 2 deletions comfy_cli/command/generate/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,15 @@ def send_request(
timeout: float = 120.0,
) -> httpx.Response:
"""Send the initial request for `endpoint` with the given typed values."""
url = spec.base_url() + endpoint.path
json_body, files, data = _split_payload(values, flags, endpoint.request_content_type)
from comfy_cli.command.generate import adapters as _adapters

adapter = _adapters.get(endpoint.id)
url_path = _adapters.resolve_path(endpoint.path, values, adapter) if adapter else endpoint.path
url = spec.base_url() + url_path
if adapter is not None:
json_body, files, data = adapter.build_body(values, api_key), None, None
else:
json_body, files, data = _split_payload(values, flags, endpoint.request_content_type)
headers = _auth_headers(api_key)
try:
if endpoint.method.lower() == "get":
Expand Down
Loading
Loading