Files
EricRollei-Local_LLM_Prompt…/ideogram_magic_core.py
T
Eric HissandClaude Fable 5.1 4459af9ea6 V2.0.0: wildcard engine, platform profiles, MiniMax H3 default, API models
Every node id, input name and existing widget position is unchanged, so saved
workflows load; new inputs are appended only.

Wildcards
- core/wildcards: Dynamic Prompts / Impact Pack grammar, tag queries, variables,
  conditionals, concept / verbatim / loose bindings, reproducible seeds.
  Wired into every enhancer node; reads ComfyUI/models/wildcards.
- New nodes: Wildcard Processor, Semantic Variation (direction axes),
  Concept Survival Check. Starter lists in wildcards/.
- Random choices use a private generator; global random state is untouched.

Platform profiles
- core/profiles + profiles/*.yaml: one file per model (format, length,
  negative prompt or none, quality tokens, text and dialogue syntax, vendor
  rewriter prompt fetched once and cached). profiles_user/ overrides by id.
- New node: Platform Prompt Rewriter (prose, tags, edit instructions, JSON,
  MiniMax H3 fields, Hailuo brackets), with validators for each format.
- Text-to-Image and Image-to-Image read profiles through platforms.py;
  corrected facts for FLUX, Kontext, Chroma, Wan, AuraFlow, Kolors, NoobAI,
  Qwen-Image, HunyuanImage. Negative prompt is empty for models without one.
- Hosted / API models: Seedream 5, Nano Banana, GPT Image, Midjourney V8,
  FLUX.2 pro, Ideogram 3, Recraft V4, Seedance 2.0 / 2.5, Kling 3.0, Veo 3.1,
  Gemini Omni Flash, Runway, Wan 3.0, Luma, hosted H3, Grok, Vidu, PixVerse.
  Reports say how to switch the vendor's own prompt rewriting off.
- Illustrious: spaced tags, quality tags last, 25-80 words (OnomaAI paper).

Video nodes
- target_model and duration_seconds appended to the three video expanders.
  Default target is MiniMax H3; wan22_video is the original path, unchanged.
  A workflow saved earlier loads with the default until the input is set.

Also included: the Alice, Ideogram Magic Prompt and prompt-database nodes that
were already registered in __init__.py but had not been committed.

Fixes: v1 {animal} / {label:a|b} wildcards were inert in the video nodes;
Image-to-Video save_to_file raised NameError; T2I padded short-prompt models.

.gitattributes added (text=auto) so CRLF working files stop showing as
whole-file changes. prompt_database.db and the cached Ideogram prompt are
git-ignored.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VpCi6nw5UECMdZ7ayYJvN
2026-09-21 00:28:58 -07:00

257 lines
10 KiB
Python

"""
Ideogram 4 Magic Prompt - core engine.
Expands a plain-language idea into an Ideogram 4 structured caption (JSON string)
using a local LLM via the existing LLMBackend, driven by Ideogram's *official*
open-source magic-prompt system prompt (v1.txt).
Provenance:
The system prompt file (v1.txt) is Ideogram's open-source magic-prompt prompt,
Apache-2.0, from https://github.com/ideogram-oss/ideogram4
(src/ideogram4/magic_prompt_system_prompts/v1.txt). It is downloaded and cached
on first use (or place it manually - see ensure_official_prompt). Integration
code (c) Eric Hiss. See IDEOGRAM_NOTES.md for schema + tailoring rationale.
Mechanics mirror Ideogram's magic_prompt.py: parse [SYSTEM]/[USER] sections, fill
the user template, call the model, strip code fences, reorder keys to the canonical
schema order, drop the top-level aspect_ratio (dims are passed separately), and
optionally strip bboxes.
"""
from __future__ import annotations
import json
import os
import math
import urllib.request
from functools import lru_cache
from .llm_backend import LLMBackend
THIS_DIR = os.path.dirname(__file__)
PROMPT_DIR = os.path.join(THIS_DIR, "ideogram_prompts")
OFFICIAL_PATH = os.path.join(PROMPT_DIR, "v1_official.txt")
TAILORING_PATH = os.path.join(PROMPT_DIR, "v1_tailoring.txt")
OFFICIAL_URL = (
"https://raw.githubusercontent.com/ideogram-oss/ideogram4/main/"
"src/ideogram4/magic_prompt_system_prompts/v1.txt"
)
# Canonical key orders (from the Ideogram caption schema / prompting.md).
TOP_ORDER = ["aspect_ratio", "high_level_description", "style_description",
"compositional_deconstruction"]
CD_ORDER = ["background", "elements"]
STYLE_PHOTO = ["aesthetics", "lighting", "photo", "medium", "color_palette"]
STYLE_ART = ["aesthetics", "lighting", "medium", "art_style", "color_palette"]
OBJ_ORDER = ["type", "bbox", "desc", "color_palette"]
TEXT_ORDER = ["type", "bbox", "text", "desc", "color_palette"]
def ensure_official_prompt() -> str:
"""Return the path to the cached official v1.txt, downloading it on first use."""
os.makedirs(PROMPT_DIR, exist_ok=True)
if os.path.isfile(OFFICIAL_PATH) and os.path.getsize(OFFICIAL_PATH) > 1000:
return OFFICIAL_PATH
try:
req = urllib.request.Request(OFFICIAL_URL, headers={"User-Agent": "comfyui-ideogram-magic"})
with urllib.request.urlopen(req, timeout=30) as r:
data = r.read().decode("utf-8")
if "[SYSTEM]" not in data:
raise ValueError("downloaded file is missing the [SYSTEM] section")
with open(OFFICIAL_PATH, "w", encoding="utf-8") as f:
f.write(data)
print(f"[Ideogram Magic] Cached official system prompt -> {OFFICIAL_PATH}")
return OFFICIAL_PATH
except Exception as e:
raise RuntimeError(
f"Could not download Ideogram's official magic-prompt v1.txt ({e}). "
f"Place it manually at:\n {OFFICIAL_PATH}\nSource: {OFFICIAL_URL}"
)
@lru_cache(maxsize=8)
def _load_sections_cached(path: str, mtime: float) -> dict:
raw = open(path, encoding="utf-8").read()
sections, current, lines = {}, None, []
for line in raw.splitlines():
s = line.strip()
if s.startswith("[") and s.endswith("]") and " " not in s:
if current is not None:
sections[current] = "\n".join(lines).strip()
current = s[1:-1].lower()
lines = []
else:
lines.append(line)
if current is not None:
sections[current] = "\n".join(lines).strip()
return sections
def load_sections(path: str) -> dict:
return _load_sections_cached(path, os.path.getmtime(path))
def _strip_fences(text: str) -> str:
text = text.strip()
if not text.startswith("```"):
return text
lines = text.splitlines()
if lines and lines[0].startswith("```"):
lines = lines[1:]
if lines and lines[-1].strip() == "```":
lines = lines[:-1]
return "\n".join(lines).strip()
def _extract_json(text: str) -> dict:
text = _strip_fences(text)
try:
return json.loads(text)
except Exception:
pass
start = text.find("{")
if start < 0:
raise ValueError("no JSON object found in model output")
depth = 0
for i in range(start, len(text)):
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
if depth == 0:
return json.loads(text[start:i + 1])
raise ValueError("unbalanced JSON in model output")
def _ordered(d: dict, order: list) -> dict:
known = [k for k in order if k in d]
extra = [k for k in d if k not in order]
return {k: d[k] for k in (*known, *extra)}
def reorder(caption: dict) -> dict:
"""Reorder caption keys to canonical schema order (CaptionVerifier-friendly)."""
if not isinstance(caption, dict):
return caption
sd = caption.get("style_description")
if isinstance(sd, dict):
order = STYLE_PHOTO if "photo" in sd else (STYLE_ART if "art_style" in sd else STYLE_PHOTO)
caption["style_description"] = _ordered(sd, order)
cd = caption.get("compositional_deconstruction")
if isinstance(cd, dict):
cd = _ordered(cd, CD_ORDER)
els = cd.get("elements")
if isinstance(els, list):
cd["elements"] = [
_ordered(e, TEXT_ORDER if e.get("type") == "text" else OBJ_ORDER)
if isinstance(e, dict) else e
for e in els
]
caption["compositional_deconstruction"] = cd
return _ordered(caption, TOP_ORDER)
def aspect_to_dims(aspect: str, megapixels: float):
try:
a, b = aspect.split(":")
w, h = float(a), float(b)
if w <= 0 or h <= 0:
raise ValueError
except Exception:
w = h = 1.0
k = math.sqrt((megapixels * 1e6) / (w * h))
r16 = lambda x: max(256, min(2048, int(round(x / 16) * 16)))
return r16(w * k), r16(h * k)
def _quick_verify(cap: dict) -> list:
"""Light guideline check echoing the official prompt's hard caps."""
warns = []
hl = cap.get("high_level_description") or ""
if len(hl.split()) > 50:
warns.append(f"high_level_description is {len(hl.split())} words (>50 cap)")
cd = cap.get("compositional_deconstruction", {})
if "background" not in cd:
warns.append("missing compositional_deconstruction.background")
if "elements" not in cd:
warns.append("missing compositional_deconstruction.elements")
for i, e in enumerate(cd.get("elements", []) or []):
if isinstance(e, dict) and len((e.get("desc") or "").split()) > 60:
warns.append(f"element {i} desc is {len(e['desc'].split())} words (>60 cap)")
return warns
def expand(idea: str, *, backend: str = "lm_studio",
endpoint: str = "http://localhost:1234/v1", model: str = "",
temperature: float = 1.0, aspect_ratio: str = "1:1",
megapixels: float = 1.0, keep_bboxes: bool = True,
use_tailoring: bool = True, max_tokens: int = 16384,
reference_caption: str = "", application: str = "",
extra_system: str = "") -> dict:
"""Expand a plain idea into an Ideogram 4 caption. Returns a result dict.
extra_system: optional text appended to the system prompt after the tailoring file. Used for
the wildcard selected-elements block; it goes in SYSTEM, not in the idea, so Ideogram's
rewriter never mistakes the instruction for text to render in the image.
"""
path = ensure_official_prompt()
sections = load_sections(path)
system = sections.get("system", "")
if not system:
return {"ok": False, "error": "official v1.txt has no [SYSTEM] section",
"prompt_json": "", "high_level": "", "width": 0, "height": 0}
if use_tailoring and os.path.isfile(TAILORING_PATH):
tail = open(TAILORING_PATH, encoding="utf-8").read().strip()
if tail:
system = f"{system}\n\n{tail}"
if extra_system and extra_system.strip():
system = f"{system}\n\n{extra_system.strip()}"
user_t = sections.get(
"user",
"TARGET IMAGE ASPECT RATIO: {{aspect_ratio}} (width:height).\nUser idea: {{original_prompt}}",
)
idea_full = idea.strip()
if reference_caption.strip():
idea_full = f"{idea_full}\n\nReference image (use as visual guidance): {reference_caption.strip()}"
user = user_t.replace("{{aspect_ratio}}", aspect_ratio).replace("{{original_prompt}}", idea_full)
if application and application not in ("", "(custom)"):
user = (f"Intended application: {application}. Follow that format's conventions "
f"for medium, layout, typography hierarchy, and palette.\n\n{user}")
try:
be = LLMBackend(backend, endpoint, model or None, temperature)
res = be.send_prompt(system, user, max_tokens=max_tokens)
except Exception as e:
return {"ok": False, "error": f"backend error: {e}",
"prompt_json": "", "high_level": "", "width": 0, "height": 0}
if not res.get("success"):
return {"ok": False, "error": res.get("error", "LLM call failed"),
"prompt_json": "", "high_level": "", "width": 0, "height": 0}
try:
cap = _extract_json(res["response"])
except Exception as e:
return {"ok": False, "error": f"could not parse model JSON: {e}",
"prompt_json": (res.get("response") or "")[:2000],
"high_level": "", "width": 0, "height": 0}
committed = cap.get("aspect_ratio") if isinstance(cap.get("aspect_ratio"), str) else aspect_ratio
if not committed or committed.lower() == "auto":
committed = aspect_ratio if aspect_ratio.lower() != "auto" else "1:1"
cap = reorder(cap)
cap.pop("aspect_ratio", None) # dims passed separately (open-weight schema)
if not keep_bboxes:
for e in cap.get("compositional_deconstruction", {}).get("elements", []) or []:
if isinstance(e, dict):
e.pop("bbox", None)
high = cap.get("high_level_description", "")
out_json = json.dumps(cap, ensure_ascii=False, separators=(",", ":"))
w, h = aspect_to_dims(committed, megapixels)
return {"ok": True, "error": "", "prompt_json": out_json, "high_level": high,
"width": w, "height": h, "aspect": committed, "warnings": _quick_verify(cap)}