264 lines
9.9 KiB
Python
264 lines
9.9 KiB
Python
"""Ideogram Magic Prompt -- ComfyUI custom node.
|
|
|
|
Generates the structured JSON caption that Ideogram 4 expects from a
|
|
plain-text prompt, using Ideogram's free hosted magic-prompt API.
|
|
|
|
Inputs: prompt (multiline text), api_key (password), aspect_ratio (combo)
|
|
Output: json_caption (string, displayed via the built-in JsonPreview tool)
|
|
|
|
Drop this folder into your ComfyUI/custom_nodes/ directory and restart
|
|
ComfyUI. The node will appear under the "Ideogram" category.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
try:
|
|
from comfy_api.latest import ComfyExtension, io, ui
|
|
except ImportError: # older ComfyUI / V1 fallback
|
|
from comfy_api.v0_0_2 import ComfyExtension, io, ui # type: ignore[no-redef]
|
|
|
|
from .negative_prompt import (
|
|
NEGATIVE_PLAIN,
|
|
merge_into_json_caption,
|
|
plain_prompt_with_negatives,
|
|
)
|
|
|
|
MAGIC_PROMPT_URL = "https://api.ideogram.ai/v1/ideogram-v4/magic-prompt"
|
|
|
|
# 18 aspect ratios accepted by the Ideogram magic-prompt API
|
|
SUPPORTED_ARS = [
|
|
"AUTO",
|
|
"1x4", "1x3", "1x2",
|
|
"9x16", "10x16",
|
|
"2x3", "3x4", "4x5",
|
|
"1x1",
|
|
"5x4", "4x3", "3x2",
|
|
"16x10", "16x9",
|
|
"2x1", "3x1", "4x1",
|
|
]
|
|
|
|
DEFAULT_HEADERS = {
|
|
"Content-Type": "application/json",
|
|
"Api-Key": "", # filled at call time
|
|
}
|
|
|
|
DEFAULT_TIMEOUT = 120.0
|
|
|
|
|
|
def _post_magic_prompt(prompt: str, aspect_ratio: str, api_key: str, timeout: float = DEFAULT_TIMEOUT) -> dict:
|
|
"""Call Ideogram's hosted magic-prompt API. Returns the parsed JSON
|
|
caption dict (after stripping `aspect_ratio` and bboxes so the
|
|
caption is a clean drop-in for the Ideogram4Pipeline)."""
|
|
headers = {**DEFAULT_HEADERS, "Api-Key": api_key}
|
|
body = json.dumps({"text_prompt": prompt, "aspect_ratio": aspect_ratio}).encode("utf-8")
|
|
req = urllib.request.Request(MAGIC_PROMPT_URL, data=body, headers=headers, method="POST")
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
payload = json.loads(resp.read().decode("utf-8"))
|
|
except urllib.error.HTTPError as e:
|
|
raise RuntimeError(
|
|
f"Ideogram magic-prompt API HTTP {e.code}: {e.read().decode('utf-8', errors='replace')}"
|
|
) from e
|
|
except urllib.error.URLError as e:
|
|
raise RuntimeError(f"Ideogram magic-prompt API unreachable: {e.reason}") from e
|
|
|
|
jp = payload.get("json_prompt")
|
|
if not jp:
|
|
raise RuntimeError(f"Ideogram API returned no json_prompt: {payload}")
|
|
# Clean up the caption: drop the echoed aspect_ratio and any
|
|
# bboxes (the model sometimes includes them in the upstream JSON).
|
|
jp.pop("aspect_ratio", None)
|
|
for el in jp.get("compositional_deconstruction", {}).get("elements", []):
|
|
if isinstance(el, dict):
|
|
el.pop("bbox", None)
|
|
return jp
|
|
|
|
|
|
class IdeogramMagicPrompt(io.ComfyNode):
|
|
"""Expand a plain prompt into Ideogram 4's native structured JSON caption.
|
|
|
|
The output is a stringified JSON object. Feed it directly into an
|
|
Ideogram4 generation node (or paste into the ideogram4 gradio UI).
|
|
"""
|
|
|
|
@classmethod
|
|
def define_schema(cls) -> io.Schema:
|
|
return io.Schema(
|
|
node_id="IdeogramMagicPrompt",
|
|
display_name="Ideogram Magic Prompt",
|
|
category="Ideogram",
|
|
description=(
|
|
"Rewrites a plain prompt into the structured JSON caption "
|
|
"format the Ideogram 4 model was trained on. Uses Ideogram's "
|
|
"free hosted magic-prompt API."
|
|
),
|
|
inputs=[
|
|
io.String.Input(
|
|
"prompt",
|
|
multiline=True,
|
|
tooltip="Plain-text prompt to expand.",
|
|
),
|
|
io.String.Input(
|
|
"api_key",
|
|
default="",
|
|
tooltip="IDEOGRAM_API_KEY from https://ideogram.ai/api/learn/",
|
|
),
|
|
io.Combo.Input(
|
|
"aspect_ratio",
|
|
options=SUPPORTED_ARS,
|
|
default="1x1",
|
|
tooltip="Closest aspect ratio for which to lay out the caption.",
|
|
),
|
|
io.Boolean.Input(
|
|
"suppress_artifacts",
|
|
default=True,
|
|
tooltip=(
|
|
"Append the negative-prompt snippet (no noise, no blur, "
|
|
"no jpeg, no chromatic aberration...) to the caption. "
|
|
"Ideogram 4 has no native negative_prompt field; this "
|
|
"is the closest equivalent."
|
|
),
|
|
),
|
|
],
|
|
outputs=[
|
|
io.String.Output(
|
|
"json_caption",
|
|
tooltip="Stringified JSON caption -- feed to Ideogram4 generate.",
|
|
),
|
|
],
|
|
)
|
|
|
|
@classmethod
|
|
def execute(
|
|
cls, prompt: str, api_key: str, aspect_ratio: str, suppress_artifacts: bool
|
|
) -> io.NodeOutput:
|
|
if not prompt or not prompt.strip():
|
|
raise ValueError("Prompt is empty.")
|
|
if not api_key or not api_key.strip():
|
|
raise ValueError(
|
|
"IDEOGRAM_API_KEY is empty. Get one at "
|
|
"https://ideogram.ai/api/learn/ and paste it into the node."
|
|
)
|
|
|
|
t0 = time.time()
|
|
caption_obj = _post_magic_prompt(prompt, aspect_ratio, api_key)
|
|
elapsed = time.time() - t0
|
|
|
|
if suppress_artifacts:
|
|
caption_obj = merge_into_json_caption(caption_obj)
|
|
log_suffix = " (artifact-suppression snippet merged)"
|
|
else:
|
|
log_suffix = ""
|
|
|
|
caption_str = json.dumps(caption_obj, ensure_ascii=False, separators=(",", ":"))
|
|
log = f"magic-prompt expansion: {elapsed:.1f}s, aspect={aspect_ratio}{log_suffix}"
|
|
return io.NodeOutput(
|
|
caption_str,
|
|
ui=ui.PreviewText(log),
|
|
)
|
|
|
|
|
|
class IdeogramSuppressArtifacts(io.ComfyNode):
|
|
"""Append the artifact-suppression snippet to any text or JSON caption.
|
|
|
|
Useful as a standalone post-processing step if you already have a
|
|
hand-written or LLM-generated caption and just want to bolt on the
|
|
noise/blur/jpeg suppression language without re-running the API.
|
|
|
|
Set `mode` to:
|
|
- "auto" -- parse the input as JSON if it looks like a JSON
|
|
object/array; else treat as plain text.
|
|
- "json" -- force JSON merge (style_description / HLD postfix).
|
|
- "plain text" -- append the plain-prompt snippet.
|
|
"""
|
|
|
|
@classmethod
|
|
def define_schema(cls) -> io.Schema:
|
|
return io.Schema(
|
|
node_id="IdeogramSuppressArtifacts",
|
|
display_name="Ideogram Suppress Artifacts",
|
|
category="Ideogram",
|
|
description=(
|
|
"Appends a positive-constraint snippet ('no noise, no grain, "
|
|
"no blur, no jpeg, no chromatic aberration...') to either a "
|
|
"JSON caption (merged into style_description) or a plain-text "
|
|
"prompt. Closest equivalent to a negative prompt in Ideogram 4."
|
|
),
|
|
inputs=[
|
|
io.String.Input(
|
|
"caption",
|
|
multiline=True,
|
|
tooltip="Either a stringified JSON caption or a plain prompt.",
|
|
),
|
|
io.Combo.Input(
|
|
"mode",
|
|
options=["auto", "json", "plain text"],
|
|
default="auto",
|
|
tooltip=(
|
|
"auto: parse as JSON if input looks like JSON object/array. "
|
|
"json: force JSON merge. "
|
|
"plain text: append the snippet to the input as-is."
|
|
),
|
|
),
|
|
],
|
|
outputs=[
|
|
io.String.Output(
|
|
"caption",
|
|
tooltip="Caption with the artifact-suppression snippet applied.",
|
|
),
|
|
],
|
|
)
|
|
|
|
@classmethod
|
|
def execute(cls, caption: str, mode: str) -> io.NodeOutput:
|
|
if not caption or not caption.strip():
|
|
raise ValueError("Caption is empty.")
|
|
|
|
stripped = caption.strip()
|
|
looks_json = stripped.startswith(("{", "[")) and stripped.endswith(("}", "]"))
|
|
effective_mode = mode
|
|
if mode == "auto":
|
|
effective_mode = "json" if looks_json else "plain text"
|
|
|
|
if effective_mode == "json":
|
|
try:
|
|
obj = json.loads(caption)
|
|
except json.JSONDecodeError as e:
|
|
raise ValueError(
|
|
f"Input looks like JSON but couldn't be parsed: {e}. "
|
|
f"Switch mode to 'plain text' or fix the JSON."
|
|
) from e
|
|
if not isinstance(obj, (dict, list)):
|
|
raise ValueError(
|
|
f"Expected a JSON object or array, got {type(obj).__name__}. "
|
|
f"Switch mode to 'plain text'."
|
|
)
|
|
if isinstance(obj, dict):
|
|
merged = merge_into_json_caption(obj)
|
|
else:
|
|
# list of captions -- merge into each dict element
|
|
merged = [merge_into_json_caption(x) if isinstance(x, dict) else x for x in obj]
|
|
out = json.dumps(merged, ensure_ascii=False, separators=(",", ":"))
|
|
log = f"artifact-suppression merged into JSON ({'array of ' + str(len(merged)) if isinstance(merged, list) else 'object'})"
|
|
else:
|
|
out = plain_prompt_with_negatives(caption)
|
|
log = "artifact-suppression appended to plain text"
|
|
|
|
return io.NodeOutput(out, ui=ui.PreviewText(log))
|
|
|
|
|
|
class IdeogramExtension(ComfyExtension):
|
|
"""Registers the Ideogram magic-prompt nodes with ComfyUI."""
|
|
|
|
async def get_node_list(self) -> list[type[io.ComfyNode]]:
|
|
return [IdeogramMagicPrompt, IdeogramSuppressArtifacts]
|
|
|
|
|
|
async def comfy_entrypoint() -> IdeogramExtension: # noqa: D401
|
|
return IdeogramExtension()
|