456 lines
17 KiB
Python
456 lines
17 KiB
Python
"""IAMCCS Ideogram Sheet Builder.
|
|
|
|
Backend intentionally mirrors Ideogram structured prompt semantics:
|
|
- Ideogram bbox order: [ymin, xmin, ymax, xmax]
|
|
- prompt output mirrors Ideogram4PromptBuilderKJ JSON semantics
|
|
- same import_json/import_mode behavior
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
|
|
import numpy as np
|
|
import torch
|
|
from PIL import Image, ImageDraw, ImageEnhance, ImageFont
|
|
|
|
from comfy_api.latest import io
|
|
|
|
|
|
_FONT_PATH = os.path.join(os.path.dirname(__file__), "fonts", "FreeMono.ttf")
|
|
|
|
|
|
def _hex_rgb(h):
|
|
h = str(h or "").lstrip("#")
|
|
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)) if len(h) == 6 else (255, 255, 255)
|
|
|
|
|
|
def _readable(rgb):
|
|
r, g, b = rgb
|
|
lum = 0.299 * r + 0.587 * g + 0.114 * b
|
|
if lum < 130:
|
|
t = (130 - lum) / max(1, (255 - lum))
|
|
r, g, b = round(r + (255 - r) * t), round(g + (255 - g) * t), round(b + (255 - b) * t)
|
|
return (r, g, b)
|
|
|
|
|
|
def _font(size):
|
|
try:
|
|
return ImageFont.truetype(_FONT_PATH, size)
|
|
except Exception:
|
|
try:
|
|
return ImageFont.load_default(size)
|
|
except Exception:
|
|
return ImageFont.load_default()
|
|
|
|
|
|
def _wrap(draw, text, font, max_w):
|
|
lines = []
|
|
for para in str(text or "").split("\n"):
|
|
line = ""
|
|
for word in para.split():
|
|
test = word if not line else line + " " + word
|
|
if line and draw.textlength(test, font=font) > max_w:
|
|
lines.append(line)
|
|
line = word
|
|
else:
|
|
line = test
|
|
lines.append(line)
|
|
return lines
|
|
|
|
|
|
def _render_preview(boxes, width, height, bg=None, brightness=50):
|
|
if bg is not None:
|
|
iw, ih = bg.size
|
|
long_edge = max(iw, ih)
|
|
scale = min(1.0, 1024 / long_edge) if long_edge > 0 else 1.0
|
|
rw, rh = max(1, round(iw * scale)), max(1, round(ih * scale))
|
|
base = bg.convert("RGB").resize((rw, rh), Image.LANCZOS)
|
|
if brightness < 100:
|
|
base = ImageEnhance.Brightness(base).enhance(max(0.0, brightness / 100.0))
|
|
img = base.convert("RGBA")
|
|
else:
|
|
long_edge = max(width, height)
|
|
scale = min(1.0, 1024 / long_edge) if long_edge > 0 else 1.0
|
|
rw = max(1, round(width * scale))
|
|
rh = max(1, round(height * scale))
|
|
img = Image.new("RGBA", (rw, rh), (0, 0, 0, 255))
|
|
overlay = Image.new("RGBA", (rw, rh), (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(overlay)
|
|
fs = max(10, round(rh / 64))
|
|
font = _font(fs)
|
|
tag_font = _font(max(9, fs - 2))
|
|
lh = fs + 2
|
|
|
|
for i, box in enumerate(boxes):
|
|
if not isinstance(box, dict) or box.get("nobbox"):
|
|
continue
|
|
palette = [c for c in (box.get("palette") or []) if c]
|
|
r, g, b = _hex_rgb(palette[0]) if palette else (140, 140, 140)
|
|
x1 = max(0, min(rw, round(float(box.get("x", 0)) * rw)))
|
|
y1 = max(0, min(rh, round(float(box.get("y", 0)) * rh)))
|
|
x2 = max(0, min(rw, round((float(box.get("x", 0)) + float(box.get("w", 0))) * rw)))
|
|
y2 = max(0, min(rh, round((float(box.get("y", 0)) + float(box.get("h", 0))) * rh)))
|
|
if x2 < x1:
|
|
x1, x2 = x2, x1
|
|
if y2 < y1:
|
|
y1, y2 = y2, y1
|
|
draw.rectangle([x1, y1, x2, y2], outline=(r, g, b, 255), width=2)
|
|
if palette and (x2 - x1) > 2:
|
|
sh = max(5, fs // 2)
|
|
seg = (x2 - x1) / min(5, len(palette))
|
|
for p, hexc in enumerate(palette[:5]):
|
|
sx = x1 + round(p * seg)
|
|
draw.rectangle([sx, y1, x1 + round((p + 1) * seg), y1 + sh], fill=_hex_rgb(hexc))
|
|
tag = str(i + 1).zfill(2)
|
|
tw = draw.textlength(tag, font=tag_font)
|
|
draw.rectangle([x1, y1, x1 + tw + 6, y1 + fs + 2], fill=(r, g, b, 255))
|
|
tagfill = (0, 0, 0, 255) if (0.299 * r + 0.587 * g + 0.114 * b) > 140 else (255, 255, 255, 255)
|
|
draw.text((x1 + 3, y1 + 1), tag, fill=tagfill, font=tag_font)
|
|
body = str(box.get("desc", "") or "")
|
|
if box.get("type") == "text" and box.get("text"):
|
|
body = '"%s"%s' % (box["text"], " - " + body if body else "")
|
|
if body and (x2 - x1) > 8:
|
|
ty = y1 + fs + 5
|
|
for line in _wrap(draw, body, font, x2 - x1 - 8):
|
|
if ty > y2:
|
|
break
|
|
draw.text((x1 + 4, ty), line, fill=_readable((r, g, b)) + (255,), font=font)
|
|
ty += lh
|
|
|
|
img = Image.alpha_composite(img, overlay).convert("RGB")
|
|
arr = np.asarray(img, dtype=np.float32) / 255.0
|
|
return torch.from_numpy(arr).unsqueeze(0)
|
|
|
|
|
|
def _norm_bbox(box):
|
|
def c(v):
|
|
try:
|
|
return max(0, min(1000, round(float(v) * 1000)))
|
|
except Exception:
|
|
return 0
|
|
|
|
x = float(box.get("x", 0.0) or 0.0)
|
|
y = float(box.get("y", 0.0) or 0.0)
|
|
w = float(box.get("w", 0.0) or 0.0)
|
|
h = float(box.get("h", 0.0) or 0.0)
|
|
ymin, xmin, ymax, xmax = c(y), c(x), c(y + h), c(x + w)
|
|
if ymin > ymax:
|
|
ymin, ymax = ymax, ymin
|
|
if xmin > xmax:
|
|
xmin, xmax = xmax, xmin
|
|
return [ymin, xmin, ymax, xmax]
|
|
|
|
|
|
def _palette(colors):
|
|
if isinstance(colors, dict):
|
|
colors = colors.values()
|
|
out = []
|
|
for c in colors or []:
|
|
text = str(c or "").strip().upper()
|
|
if not text:
|
|
continue
|
|
if not text.startswith("#"):
|
|
text = "#" + text
|
|
if len(text) == 7:
|
|
out.append(text)
|
|
return out
|
|
|
|
|
|
def _dumps(v, lvl=0):
|
|
pad, end = " " * (lvl + 1), " " * lvl
|
|
if isinstance(v, str):
|
|
return json.dumps(v, ensure_ascii=False)
|
|
if isinstance(v, list):
|
|
if not v:
|
|
return "[]"
|
|
if all(not isinstance(x, (dict, list)) for x in v):
|
|
return "[" + ", ".join(_dumps(x, lvl) for x in v) + "]"
|
|
return "[\n" + ",\n".join(pad + _dumps(x, lvl + 1) for x in v) + "\n" + end + "]"
|
|
if isinstance(v, dict):
|
|
if not v:
|
|
return "{}"
|
|
items = [pad + json.dumps(k, ensure_ascii=False) + ": " + _dumps(val, lvl + 1) for k, val in v.items()]
|
|
return "{\n" + ",\n".join(items) + "\n" + end + "}"
|
|
return json.dumps(v, ensure_ascii=False)
|
|
|
|
|
|
def _parse_json_list(s):
|
|
if s:
|
|
try:
|
|
v = json.loads(str(s))
|
|
if isinstance(v, list):
|
|
return v
|
|
except Exception:
|
|
pass
|
|
return []
|
|
|
|
|
|
def _caption_to_boxes(cap):
|
|
cd = cap.get("compositional_deconstruction") or {}
|
|
boxes = []
|
|
for el in (cd.get("elements") or []):
|
|
if not isinstance(el, dict):
|
|
continue
|
|
box = {
|
|
"type": "text" if el.get("type") == "text" else "obj",
|
|
"text": el.get("text", "") or "",
|
|
"desc": el.get("desc", "") or "",
|
|
"tips": "",
|
|
"useTips": False,
|
|
"palette": list(el.get("color_palette") or []),
|
|
}
|
|
bb = el.get("bbox")
|
|
if isinstance(bb, (list, tuple)) and len(bb) == 4:
|
|
ymin, xmin, ymax, xmax = bb
|
|
box.update(x=xmin / 1000.0, y=ymin / 1000.0, w=(xmax - xmin) / 1000.0, h=(ymax - ymin) / 1000.0)
|
|
else:
|
|
box.update(x=0.03, y=0.03, w=0.22, h=0.14, nobbox=True)
|
|
boxes.append(box)
|
|
return boxes
|
|
|
|
|
|
def _caption_to_ideoboard_design(caption, boxes, width, height, grid_columns=1, grid_rows=1):
|
|
style = caption.get("style_description") or {}
|
|
comp = caption.get("compositional_deconstruction") or {}
|
|
items = []
|
|
for i, box in enumerate(boxes or []):
|
|
if not isinstance(box, dict):
|
|
continue
|
|
x = max(0, min(1000, round(float(box.get("x", 0) or 0) * 1000)))
|
|
y = max(0, min(1000, round(float(box.get("y", 0) or 0) * 1000)))
|
|
w = max(20, min(1000 - x, round(float(box.get("w", 0) or 0) * 1000)))
|
|
h = max(20, min(1000 - y, round(float(box.get("h", 0) or 0) * 1000)))
|
|
kind = "text" if box.get("type") == "text" else "obj"
|
|
items.append({
|
|
"id": f"sheet_panel_{i + 1:02d}",
|
|
"kind": kind,
|
|
"label": str(box.get("title") or f"Panel {i + 1}"),
|
|
"text": str(box.get("text") or "") if kind == "text" else "",
|
|
"x": x,
|
|
"y": y,
|
|
"w": w,
|
|
"h": h,
|
|
"desc": str(box.get("desc") or ""),
|
|
"color_palette": _palette(box.get("palette", []))[:5] or _palette(style.get("color_palette", []))[:5],
|
|
})
|
|
return {
|
|
"schema": "iamccs.ideogram_storyboard_frame_designer",
|
|
"schema_version": 1,
|
|
"preset_key": "storyboard",
|
|
"canvas": {"width": int(width), "height": int(height), "aspect_label": f"{int(width)}x{int(height)}"},
|
|
"scene": {
|
|
"high_level_description": str(caption.get("high_level_description") or ""),
|
|
"aesthetics": str(style.get("aesthetics") or ""),
|
|
"lighting": str(style.get("lighting") or ""),
|
|
"photo": str(style.get("photo") or style.get("art_style") or ""),
|
|
"medium": str(style.get("medium") or "photograph"),
|
|
"color_palette": _palette(style.get("color_palette", [])),
|
|
"background": str(comp.get("background") or ""),
|
|
},
|
|
"i2i": {
|
|
"enabled": False,
|
|
"denoise": 0.28,
|
|
"low_sigma_start_step": 12,
|
|
"scheduler_hint": "Use this sheet-derived layout as a positive i2i planning canvas.",
|
|
"source_mode": "canvas_composite",
|
|
},
|
|
"items": items,
|
|
"grid": {"columns": int(grid_columns or 1), "rows": int(grid_rows or 1), "order": "row_major"},
|
|
}
|
|
|
|
|
|
class IAMCCS_IdeogramSheetBuilder(io.ComfyNode):
|
|
@classmethod
|
|
def define_schema(cls):
|
|
return io.Schema(
|
|
node_id="IAMCCS_IdeogramSheetBuilder",
|
|
display_name="IAMCCS Ideogram Sheet Builder",
|
|
category="IAMCCS/Cine/Ideogram",
|
|
search_aliases=["iamccs", "ideogram", "caption", "bbox", "prompt builder", "sheet builder"],
|
|
is_experimental=True,
|
|
description="Ideogram 4 sheet builder with IAMCCS UI.",
|
|
inputs=[
|
|
io.Int.Input("width", default=1024, min=64, max=16384, step=16),
|
|
io.Int.Input("height", default=1024, min=64, max=16384, step=16),
|
|
io.String.Input("high_level_description", multiline=True, default=""),
|
|
io.String.Input("background", multiline=True, default=""),
|
|
io.DynamicCombo.Input(
|
|
"style",
|
|
options=[
|
|
io.DynamicCombo.Option("none", []),
|
|
io.DynamicCombo.Option("photo", [io.String.Input("photo", default="")]),
|
|
io.DynamicCombo.Option("art_style", [io.String.Input("art_style", default="")]),
|
|
],
|
|
),
|
|
io.String.Input("aesthetics", default=""),
|
|
io.String.Input("lighting", default=""),
|
|
io.String.Input("medium", default=""),
|
|
io.Image.Input("image", optional=True),
|
|
io.String.Input("import_json", default="", optional=True, force_input=True),
|
|
io.String.Input("style_palette_data", default="", socketless=True, advanced=True),
|
|
io.String.Input("elements_data", default="", socketless=True, advanced=True),
|
|
io.Int.Input("grid_columns", default=1, min=1, max=12, advanced=True),
|
|
io.Int.Input("grid_rows", default=1, min=1, max=12, advanced=True),
|
|
io.Int.Input("bg_brightness", default=25, min=0, max=100, socketless=True, advanced=True),
|
|
io.Combo.Input("import_mode", options=["when empty", "always"], default="when empty"),
|
|
io.BoundingBox.Input("bboxes", optional=True, force_input=True),
|
|
],
|
|
outputs=[
|
|
io.String.Output(display_name="prompt"),
|
|
io.Image.Output(display_name="preview"),
|
|
io.BoundingBox.Output(display_name="bboxes"),
|
|
io.Int.Output(display_name="width"),
|
|
io.Int.Output(display_name="height"),
|
|
io.String.Output(display_name="current_sheet_json"),
|
|
io.Int.Output(display_name="grid_columns"),
|
|
io.Int.Output(display_name="grid_rows"),
|
|
io.String.Output(display_name="ideoboard_for_frame_v2"),
|
|
],
|
|
)
|
|
|
|
@classmethod
|
|
def execute(
|
|
cls,
|
|
width,
|
|
height,
|
|
background,
|
|
style,
|
|
high_level_description="",
|
|
aesthetics="",
|
|
lighting="",
|
|
medium="",
|
|
style_palette_data="",
|
|
elements_data="",
|
|
import_json="",
|
|
import_mode="when empty",
|
|
grid_columns=1,
|
|
grid_rows=1,
|
|
bboxes=None,
|
|
image=None,
|
|
bg_brightness=25,
|
|
) -> io.NodeOutput:
|
|
width = int(width)
|
|
height = int(height)
|
|
boxes = _parse_json_list(elements_data)
|
|
boxes_seeded = False
|
|
if not boxes and bboxes:
|
|
if isinstance(bboxes, dict):
|
|
frame = [bboxes]
|
|
elif bboxes and isinstance(bboxes[0], (list, tuple)):
|
|
frame = bboxes[0]
|
|
else:
|
|
frame = bboxes
|
|
for bb in frame:
|
|
if not isinstance(bb, dict):
|
|
continue
|
|
boxes.append({
|
|
"x": bb.get("x", 0) / width,
|
|
"y": bb.get("y", 0) / height,
|
|
"w": bb.get("width", 0) / width,
|
|
"h": bb.get("height", 0) / height,
|
|
"type": "obj",
|
|
"text": "",
|
|
"desc": "",
|
|
"tips": "",
|
|
"useTips": False,
|
|
"palette": [],
|
|
})
|
|
boxes_seeded = bool(boxes)
|
|
|
|
imported = None
|
|
if import_json and str(import_json).strip():
|
|
try:
|
|
c = json.loads(str(import_json))
|
|
if isinstance(c, dict):
|
|
imported = c
|
|
except Exception:
|
|
pass
|
|
|
|
kind = style["style"] if isinstance(style, dict) and "style" in style else str(style or "none")
|
|
used_import = imported is not None and (import_mode == "always" or not boxes)
|
|
|
|
if used_import:
|
|
caption = imported
|
|
boxes = _caption_to_boxes(imported)
|
|
else:
|
|
caption = {}
|
|
if str(high_level_description or "").strip():
|
|
caption["high_level_description"] = high_level_description
|
|
if kind != "none":
|
|
sd = {"aesthetics": aesthetics, "lighting": lighting}
|
|
if kind == "photo":
|
|
sd["photo"] = style.get("photo", "") if isinstance(style, dict) else ""
|
|
sd["medium"] = medium
|
|
else:
|
|
sd["medium"] = medium
|
|
sd["art_style"] = style.get("art_style", "") if isinstance(style, dict) else ""
|
|
palette = _palette(_parse_json_list(style_palette_data))
|
|
if palette:
|
|
sd["color_palette"] = palette
|
|
caption["style_description"] = sd
|
|
|
|
elements = []
|
|
for box in boxes:
|
|
if not isinstance(box, dict):
|
|
continue
|
|
etype = "text" if box.get("type") == "text" else "obj"
|
|
elem = {"type": etype}
|
|
if not box.get("nobbox"):
|
|
elem["bbox"] = _norm_bbox(box)
|
|
if etype == "text":
|
|
elem["text"] = box.get("text", "")
|
|
# Keep prompt output identical to the reference builder semantics:
|
|
# tips are UI guidance only and never silently rewrite desc.
|
|
elem["desc"] = str(box.get("desc", "") or "")
|
|
palette = _palette(box.get("palette", []))
|
|
if palette:
|
|
elem["color_palette"] = palette[:5]
|
|
elements.append(elem)
|
|
|
|
caption["compositional_deconstruction"] = {
|
|
"background": background,
|
|
"elements": elements,
|
|
}
|
|
|
|
bg = None
|
|
if image is not None:
|
|
try:
|
|
bg = Image.fromarray((image[0].detach().cpu().numpy() * 255).clip(0, 255).astype(np.uint8))
|
|
except Exception:
|
|
bg = None
|
|
preview = _render_preview(boxes, width, height, bg, bg_brightness)
|
|
|
|
bbox_dicts = []
|
|
for box in boxes:
|
|
if not isinstance(box, dict) or box.get("nobbox"):
|
|
continue
|
|
x, y = float(box.get("x", 0.0) or 0.0), float(box.get("y", 0.0) or 0.0)
|
|
bw, bh = float(box.get("w", 0.0) or 0.0), float(box.get("h", 0.0) or 0.0)
|
|
if bw < 0:
|
|
x += bw
|
|
bw = -bw
|
|
if bh < 0:
|
|
y += bh
|
|
bh = -bh
|
|
bbox_dicts.append({"x": round(x * width), "y": round(y * height), "width": round(bw * width), "height": round(bh * height)})
|
|
bboxes_out = [bbox_dicts] if bbox_dicts else []
|
|
|
|
sheet_json = _dumps(caption)
|
|
|
|
ui = {"dims": [width, height]}
|
|
if boxes_seeded:
|
|
ui["boxes"] = [json.dumps(boxes)]
|
|
if used_import:
|
|
ui["caption"] = [_dumps(imported)]
|
|
ideoboard_json = json.dumps(_caption_to_ideoboard_design(caption, boxes, width, height, grid_columns, grid_rows), ensure_ascii=False, indent=2)
|
|
return io.NodeOutput(sheet_json, preview, bboxes_out, width, height, sheet_json, int(grid_columns), int(grid_rows), ideoboard_json, ui=ui)
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"IAMCCS_IdeogramSheetBuilder": IAMCCS_IdeogramSheetBuilder,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"IAMCCS_IdeogramSheetBuilder": "IAMCCS Ideogram Sheet Builder",
|
|
}
|