From ccfbc248fd1b48824fea03750d1b31176b05a9a1 Mon Sep 17 00:00:00 2001 From: Robin Huang Date: Mon, 18 May 2026 11:58:47 -0700 Subject: [PATCH] feat(generate): add nano-banana (Gemini Flash Image) and Seedance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a per-endpoint adapter layer for partners whose request/response shape doesn't fit the schema-driven flagβ†’JSON pattern. Adds two new aliases: - `nano-banana` (Vertex AI Gemini Flash Image) β€” text-to-image + image edits via content/parts; inline base64 input and output. Adapter substitutes the model variant into the URL path. - `seedance` (ByteDance) β€” text/image-to-video with custom poller against /proxy/byteplus/api/v3/contents/generations/tasks. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 42 ++- comfy_cli/command/generate/adapters.py | 267 +++++++++++++++ comfy_cli/command/generate/app.py | 24 +- comfy_cli/command/generate/client.py | 11 +- comfy_cli/command/generate/output.py | 19 ++ comfy_cli/command/generate/poll.py | 8 + comfy_cli/command/generate/schema.py | 6 + comfy_cli/command/generate/spec.py | 25 +- .../command/generate/test_adapters.py | 320 ++++++++++++++++++ 9 files changed, 712 insertions(+), 10 deletions(-) create mode 100644 comfy_cli/command/generate/adapters.py create mode 100644 tests/comfy_cli/command/generate/test_adapters.py diff --git a/README.md b/README.md index a27c72f1..38c05daf 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: @@ -337,6 +338,37 @@ comfy generate luma --prompt "..." --aspect_ratio 16:9 --async comfy generate resume luma --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): diff --git a/comfy_cli/command/generate/adapters.py b/comfy_cli/command/generate/adapters.py new file mode 100644 index 00000000..086bf257 --- /dev/null +++ b/comfy_cli/command/generate/adapters.py @@ -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)) diff --git a/comfy_cli/command/generate/app.py b/comfy_cli/command/generate/app.py index 03b99243..a417e480 100644 --- a/comfy_cli/command/generate/app.py +++ b/comfy_cli/command/generate/app.py @@ -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, …)." @@ -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 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) @@ -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} diff --git a/comfy_cli/command/generate/client.py b/comfy_cli/command/generate/client.py index d312f134..44e6739c 100644 --- a/comfy_cli/command/generate/client.py +++ b/comfy_cli/command/generate/client.py @@ -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": diff --git a/comfy_cli/command/generate/output.py b/comfy_cli/command/generate/output.py index da0ecd8b..7210a66a 100644 --- a/comfy_cli/command/generate/output.py +++ b/comfy_cli/command/generate/output.py @@ -70,6 +70,25 @@ def save_urls(urls: list[str], template: str, request_id: str) -> list[Path]: return saved +def save_inline_blobs(blobs: list[tuple[str, bytes]], template: str, request_id: str) -> list[Path]: + """Save ``(mime, bytes)`` pairs returned inline (e.g. Gemini's + ``inlineData``) under the resolved template path. Same auto-indexing rule + as ``save_urls``: if the template has no ``{index}`` placeholder and isn't + a directory shorthand, multi-blob responses get ``_`` inserted before + the suffix so the first blob doesn't get silently overwritten.""" + saved: list[Path] = [] + auto_index = len(blobs) > 1 and "{index}" not in template and not template.endswith(("/", "\\")) + for i, (mime, data) in enumerate(blobs): + ext = _EXT_FROM_MIME.get(mime) or (mimetypes.guess_extension(mime) or ".png").lstrip(".") or "png" + dest = _resolve_template(template, request_id, i, ext) + if auto_index: + dest = dest.with_name(f"{dest.stem}_{i}.{ext}") + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(data) + saved.append(dest) + return saved + + def save_binary_response(resp: httpx.Response, template: str, request_id: str) -> Path: """Save a single binary response body (e.g. Stability returns image/* bytes).""" ext = _ext_from_response(resp) diff --git a/comfy_cli/command/generate/poll.py b/comfy_cli/command/generate/poll.py index 8f4edbee..2e576edb 100644 --- a/comfy_cli/command/generate/poll.py +++ b/comfy_cli/command/generate/poll.py @@ -235,6 +235,14 @@ class PollSpec: success_values=("done",), failure_values=(), ), + "seedance": PollSpec( + name="seedance", + id_paths=("id",), + poll_url="/proxy/byteplus/api/v3/contents/generations/tasks/{id}", + status_paths=("status",), + success_values=("succeeded",), + failure_values=("failed", "cancelled"), + ), } diff --git a/comfy_cli/command/generate/schema.py b/comfy_cli/command/generate/schema.py index b37a3e61..fb6b3ed8 100644 --- a/comfy_cli/command/generate/schema.py +++ b/comfy_cli/command/generate/schema.py @@ -87,6 +87,12 @@ def _detect_upload_mode(name: str, prop: dict[str, Any]) -> str | None: def flags_for(endpoint: Endpoint) -> list[FlagDef]: + # Lazy import: adapters depends on FlagDef, so the top-level import would cycle. + from comfy_cli.command.generate import adapters as _adapters + + adapter = _adapters.get(endpoint.id) + if adapter is not None: + return list(adapter.flags) schema = endpoint.request_schema or {} props = schema.get("properties") or {} required = set(schema.get("required") or []) diff --git a/comfy_cli/command/generate/spec.py b/comfy_cli/command/generate/spec.py index f9e0e3f1..a0bd53c8 100644 --- a/comfy_cli/command/generate/spec.py +++ b/comfy_cli/command/generate/spec.py @@ -136,6 +136,24 @@ class Endpoint: "vidu-extend": "vidu/extend", # Video β€” xAI Grok "grok-video": "xai/v1/videos/generations", + # Google Gemini Flash Image (nano-banana). The model variant lives in the + # URL path; the adapter substitutes ``--model`` at send time. + "nano-banana": "vertexai/gemini/{model}", + # ByteDance Seedance (video). + "seedance": "byteplus/api/v3/contents/generations/tasks", +} + + +# Used in the `list` table for endpoints whose openapi summary is empty or too +# generic to convey what the model is. +_SUMMARY_OVERRIDES: dict[str, str] = { + "vertexai/gemini/{model}": ( + "Google Gemini Flash Image (nano-banana) β€” text-to-image and image edits " + "from a prompt plus optional reference images." + ), + "byteplus/api/v3/contents/generations/tasks": ( + "ByteDance Seedance β€” text-to-video and image-to-video (3–12s clips, up to 1080p)." + ), } _PREFERRED_ALIAS: dict[str, str] = {v: k for k, v in _ALIASES.items()} @@ -231,6 +249,10 @@ def resolve_alias(target: str) -> str: ("vidu/extend", "video-extend", "vidu"), # Video β€” xAI Grok ("xai/v1/videos/generations", "text-to-video", "xai_video"), + # Google Gemini Flash Image (nano-banana). Sync; adapter decodes inline data. + ("vertexai/gemini/{model}", "image-edit", None), + # ByteDance Seedance (video) β€” async, custom poller. + ("byteplus/api/v3/contents/generations/tasks", "text-to-video", "seedance"), ] @@ -341,7 +363,8 @@ def _registry() -> dict[str, Endpoint]: path=path, method=method, partner=partner, - summary=str(op.get("summary") or op.get("description") or "").strip(), + summary=_SUMMARY_OVERRIDES.get(endpoint_id) + or str(op.get("summary") or op.get("description") or "").strip(), category=category, request_schema=req_schema if isinstance(req_schema, dict) else {}, request_content_type=ctype, diff --git a/tests/comfy_cli/command/generate/test_adapters.py b/tests/comfy_cli/command/generate/test_adapters.py new file mode 100644 index 00000000..9736c1a2 --- /dev/null +++ b/tests/comfy_cli/command/generate/test_adapters.py @@ -0,0 +1,320 @@ +"""Tests for the per-endpoint adapters: Gemini (nano-banana) and Seedance.""" + +from __future__ import annotations + +import base64 + +import httpx +import pytest + +from comfy_cli.command.generate import adapters, client, output, schema, spec + +# ── Gemini / nano-banana ───────────────────────────────────────────────── + + +def test_nano_banana_alias_resolves(): + ep = spec.get_endpoint("nano-banana") + assert ep.id == "vertexai/gemini/{model}" + assert ep.polling is None + assert ep.partner == "vertexai" + + +def test_gemini_adapter_overrides_schema_flags(): + """Schema-derived flags would be `contents`/`tools`/…; the adapter + swaps in a friendlier `prompt`/`image`/`model` triple.""" + ep = spec.get_endpoint("nano-banana") + names = [f.name for f in schema.flags_for(ep)] + assert names == ["prompt", "image", "model"] + + +def test_gemini_build_body_text_only(): + body = adapters._gemini_build_body({"prompt": "a fox"}, api_key="k") + assert body["contents"][0]["role"] == "user" + parts = body["contents"][0]["parts"] + assert parts == [{"text": "a fox"}] + assert body["generationConfig"]["responseModalities"] == ["IMAGE"] + + +def test_gemini_build_body_inlines_local_image(tmp_path): + img = tmp_path / "ref.png" + img.write_bytes(b"\x89PNG\r\n\x1a\n-bytes-") + body = adapters._gemini_build_body( + {"prompt": "add hat", "image": [str(img)]}, + api_key="k", + ) + parts = body["contents"][0]["parts"] + assert parts[0] == {"text": "add hat"} + inline = parts[1]["inlineData"] + assert inline["mimeType"] == "image/png" + assert base64.b64decode(inline["data"]) == b"\x89PNG\r\n\x1a\n-bytes-" + + +def test_gemini_build_body_inlines_remote_url(monkeypatch): + class FakeClient: + def __init__(self, *a, **kw): + pass + + def __enter__(self): + return self + + def __exit__(self, *a): + pass + + def get(self, url): + return httpx.Response( + 200, + content=b"jpeg-bytes", + headers={"content-type": "image/jpeg"}, + request=httpx.Request("GET", url), + ) + + monkeypatch.setattr(adapters.httpx, "Client", FakeClient) + body = adapters._gemini_build_body( + {"prompt": "x", "image": ["https://example.com/a.jpg"]}, + api_key="k", + ) + inline = body["contents"][0]["parts"][1]["inlineData"] + assert inline["mimeType"] == "image/jpeg" + assert base64.b64decode(inline["data"]) == b"jpeg-bytes" + + +def test_gemini_build_body_inlines_data_uri(): + blob = base64.b64encode(b"png-bytes").decode("ascii") + body = adapters._gemini_build_body( + {"prompt": "x", "image": f"data:image/png;base64,{blob}"}, + api_key="k", + ) + inline = body["contents"][0]["parts"][1]["inlineData"] + assert inline["mimeType"] == "image/png" + assert inline["data"] == blob + + +def test_gemini_inline_image_missing_path_raises(tmp_path): + with pytest.raises(client.ApiError, match="not found"): + adapters._inline_image(str(tmp_path / "nope.png")) + + +def test_gemini_decode_sync_saves_inline_blobs(tmp_path): + blob = base64.b64encode(b"png-payload").decode("ascii") + body = {"candidates": [{"content": {"parts": [{"inlineData": {"mimeType": "image/png", "data": blob}}]}}]} + out = tmp_path / "out.png" + saved = adapters._gemini_decode_sync(body, str(out), "req-1") + assert saved == [out] + assert out.read_bytes() == b"png-payload" + + +def test_gemini_decode_sync_handles_snake_case_keys(tmp_path): + """Gemini responses are sometimes serialized as inline_data/mime_type. + With a directory-shorthand template, the mime drives the extension.""" + blob = base64.b64encode(b"webp-payload").decode("ascii") + body = {"candidates": [{"content": {"parts": [{"inline_data": {"mime_type": "image/webp", "data": blob}}]}}]} + saved = adapters._gemini_decode_sync(body, str(tmp_path) + "/", "r") + assert len(saved) == 1 + assert saved[0].read_bytes() == b"webp-payload" + assert saved[0].suffix == ".webp" + + +def test_gemini_decode_sync_returns_empty_when_blocked(tmp_path): + body = {"candidates": [{"finishReason": "SAFETY", "content": {"parts": []}}]} + saved = adapters._gemini_decode_sync(body, str(tmp_path / "x.png"), "r") + assert saved == [] + + +def test_gemini_resolve_path_substitutes_model(): + ep = spec.get_endpoint("nano-banana") + adapter = adapters.get(ep.id) + url = adapters.resolve_path(ep.path, {"model": "gemini-2.5-flash-image"}, adapter) + assert url == "/proxy/vertexai/gemini/gemini-2.5-flash-image" + + +def test_gemini_send_request_hits_substituted_path(monkeypatch): + captured = {} + + def fake_post(url, *, json=None, headers=None, timeout=None, **_kw): + captured["url"] = url + captured["json"] = json + return httpx.Response(200, json={"candidates": []}) + + monkeypatch.setattr(client.httpx, "post", fake_post) + ep = spec.get_endpoint("nano-banana") + flags = schema.flags_for(ep) + client.send_request( + ep, + {"prompt": "hi", "model": "gemini-2.5-flash-image"}, + flags, + api_key="comfyui-test", + ) + assert captured["url"].endswith("/proxy/vertexai/gemini/gemini-2.5-flash-image") + assert captured["json"]["contents"][0]["parts"][0]["text"] == "hi" + + +# ── Seedance ────────────────────────────────────────────────────────────── + + +def test_seedance_alias_resolves(): + ep = spec.get_endpoint("seedance") + assert ep.id == "byteplus/api/v3/contents/generations/tasks" + assert ep.polling == "seedance" + assert ep.partner == "byteplus" + + +def test_seedance_adapter_overrides_flags(): + ep = spec.get_endpoint("seedance") + names = [f.name for f in schema.flags_for(ep)] + assert "prompt" in names + assert "model" in names + assert "resolution" in names + assert "ratio" in names + assert "duration" in names + + +def test_seedance_build_body_text_only(): + body = adapters._seedance_build_body({"prompt": "a wave"}, api_key="k") + assert body["model"] == "seedance-1-0-pro-250528" + assert body["content"] == [{"type": "text", "text": "a wave"}] + + +def test_seedance_build_body_inlines_knobs_into_text(): + body = adapters._seedance_build_body( + { + "prompt": "a boat", + "resolution": "720p", + "ratio": "16:9", + "duration": 5, + "fps": 24, + "camerafixed": True, + }, + api_key="k", + ) + text = body["content"][0]["text"] + assert text.startswith("a boat ") + assert "--resolution 720p" in text + assert "--ratio 16:9" in text + assert "--duration 5" in text + assert "--fps 24" in text + assert "--camerafixed true" in text + + +def test_seedance_build_body_uploads_local_image(monkeypatch, tmp_path): + """Local paths get pushed through /customers/storage and replaced with the + returned signed URL β€” we shouldn't see the path appear in the body.""" + img = tmp_path / "ref.png" + img.write_bytes(b"ref") + + from comfy_cli.command.generate import upload + + def fake_upload_path(path, api_key): + return upload.UploadResult(url="https://cdn/signed-ref.png", expires_at=None, existing_file=False) + + monkeypatch.setattr(upload, "upload_path", fake_upload_path) + body = adapters._seedance_build_body( + {"prompt": "wave", "image": str(img)}, + api_key="comfyui-test", + ) + image_part = body["content"][1] + assert image_part == {"type": "image_url", "image_url": {"url": "https://cdn/signed-ref.png"}} + + +def test_seedance_build_body_keeps_remote_url_verbatim(monkeypatch): + """Remote URLs and data: URIs are pass-through β€” no re-upload.""" + from comfy_cli.command.generate import upload + + def boom(*a, **kw): + raise AssertionError("upload should not be called for remote URLs") + + monkeypatch.setattr(upload, "upload_path", boom) + body = adapters._seedance_build_body( + {"prompt": "x", "image": "https://example.com/a.jpg"}, + api_key="k", + ) + assert body["content"][1]["image_url"]["url"] == "https://example.com/a.jpg" + + +def test_seedance_build_body_includes_audio_flag(): + body = adapters._seedance_build_body( + {"prompt": "x", "model": "seedance-1-5-pro-251215", "generate_audio": True}, + api_key="k", + ) + assert body["generate_audio"] is True + assert body["model"] == "seedance-1-5-pro-251215" + + +def test_seedance_send_request_passes_through_body(monkeypatch): + captured = {} + + def fake_post(url, *, json=None, headers=None, timeout=None, **_kw): + captured["url"] = url + captured["json"] = json + return httpx.Response(200, json={"id": "task-1"}) + + monkeypatch.setattr(client.httpx, "post", fake_post) + ep = spec.get_endpoint("seedance") + flags = schema.flags_for(ep) + client.send_request(ep, {"prompt": "x"}, flags, api_key="comfyui-test") + assert captured["url"].endswith("/proxy/byteplus/api/v3/contents/generations/tasks") + assert captured["json"]["model"] + assert captured["json"]["content"][0]["type"] == "text" + + +# ── Seedance polling ────────────────────────────────────────────────────── + + +def test_seedance_poll_url_and_success_extraction(monkeypatch): + """Driver should hit the task-status endpoint and pluck the video_url.""" + from comfy_cli.command.generate import poll + + monkeypatch.setattr(poll, "_sleep", lambda *_: None) + captured = {} + + def fake_get(url, **_kw): + captured["url"] = url + return httpx.Response( + 200, + json={"id": "t1", "status": "succeeded", "content": {"video_url": "https://cdn/v.mp4"}}, + ) + + monkeypatch.setattr("comfy_cli.command.generate.client.get", fake_get) + result = poll.get_poller("seedance")({"id": "t1"}, api_key="k") + assert captured["url"] == "/proxy/byteplus/api/v3/contents/generations/tasks/t1" + assert result.status == "succeeded" + assert "https://cdn/v.mp4" in result.image_urls + + +def test_seedance_poll_failure(monkeypatch): + from comfy_cli.command.generate import poll + + monkeypatch.setattr(poll, "_sleep", lambda *_: None) + monkeypatch.setattr( + "comfy_cli.command.generate.client.get", + lambda *a, **kw: httpx.Response(200, json={"id": "t1", "status": "failed", "error": {"code": "x"}}), + ) + result = poll.get_poller("seedance")({"id": "t1"}, api_key="k") + assert result.status == "failed" + assert "failed" in (result.error or "") + + +def test_seedance_resume_helper_round_trip(): + """`comfy generate resume` reverses the create-response into something the + poller can read β€” make sure that helper knows about seedance.""" + from comfy_cli.command.generate import poll + + body = poll.build_synthetic_initial("seedance", "t-42") + assert poll.extract_job_id("seedance", body) == "t-42" + + +# ── Inline blob saving ──────────────────────────────────────────────────── + + +def test_save_inline_blobs_auto_indexes_multi(tmp_path): + blobs = [("image/png", b"a"), ("image/png", b"b")] + saved = output.save_inline_blobs(blobs, str(tmp_path / "out.png"), "req") + assert len(saved) == 2 + assert saved[0].name == "out_0.png" + assert saved[1].name == "out_1.png" + assert saved[0].read_bytes() == b"a" + assert saved[1].read_bytes() == b"b" + + +def test_save_inline_blobs_picks_extension_from_mime(tmp_path): + saved = output.save_inline_blobs([("image/webp", b"x")], str(tmp_path) + "/", "req") + assert saved[0].suffix == ".webp"