"""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", }