314 lines
12 KiB
Python
314 lines
12 KiB
Python
import hashlib
|
|
import json
|
|
import math
|
|
import numpy as np
|
|
import torch
|
|
from PIL import Image
|
|
import folder_paths
|
|
import os
|
|
from server import PromptServer
|
|
from aiohttp import web
|
|
|
|
# The official MOSS compiler, vendored unchanged (moss_compile.py sits beside this file).
|
|
try:
|
|
from .moss_compile import compile_prompt as moss_compile_prompt
|
|
except ImportError: # loaded outside a package
|
|
import importlib.util
|
|
_spec = importlib.util.spec_from_file_location(
|
|
"boyo_moss_compile", os.path.join(os.path.dirname(os.path.abspath(__file__)), "moss_compile.py"))
|
|
_mod = importlib.util.module_from_spec(_spec)
|
|
_spec.loader.exec_module(_mod)
|
|
moss_compile_prompt = _mod.compile_prompt
|
|
|
|
|
|
def _clean_list(value):
|
|
"""Comma-separated string or list -> list of non-empty stripped strings."""
|
|
if isinstance(value, str):
|
|
value = value.split(",")
|
|
if not isinstance(value, (list, tuple)):
|
|
return []
|
|
return [str(v).strip() for v in value if str(v).strip()]
|
|
|
|
|
|
def _floor16(v):
|
|
"""Round down to a multiple of 16 (never below 16). Same rule as the editor."""
|
|
return max(16, (int(v) // 16) * 16)
|
|
|
|
|
|
def _announce_size(unique_id, width, height, raw=None):
|
|
"""
|
|
Tell the browser editor the size actually used (a linked Resolution Selector is invisible
|
|
to JS). raw = the size that was linked, when it differs from the size used.
|
|
"""
|
|
if unique_id is None:
|
|
return
|
|
payload = {"node": str(unique_id), "width": int(width), "height": int(height)}
|
|
if raw:
|
|
payload["raw_width"], payload["raw_height"] = int(raw[0]), int(raw[1])
|
|
try:
|
|
PromptServer.instance.send_sync("boyo_bbox_size", payload)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _half_up(v):
|
|
# same rounding as the editor's Math.round, so JS and Python agree on .5 cases
|
|
return int(math.floor(v + 0.5))
|
|
|
|
|
|
def _fit_boxes(elements, old_size, new_size, scale):
|
|
"""
|
|
Make every box safe for the canvas it will be compiled against, instead of letting
|
|
one stray coordinate kill the run:
|
|
1. scale: boxes are proportional to the canvas they were drawn on, so when the
|
|
canvas is a different size (a linked Resolution Selector changed, say) they are
|
|
scaled by the width and height ratios, i.e. the layout follows the aspect;
|
|
2. clip: anything still overhanging is clamped to the canvas (logged).
|
|
Only a box with nothing left on the canvas is an error. Malformed boxes are passed
|
|
through untouched so the compiler can report them in its own words.
|
|
"""
|
|
ow, oh = old_size
|
|
nw, nh = new_size
|
|
do_scale = bool(scale) and ow > 0 and oh > 0 and (ow, oh) != (nw, nh)
|
|
out = []
|
|
for i, el in enumerate(elements):
|
|
b = el.get("bbox") if isinstance(el, dict) else None
|
|
if not (isinstance(b, (list, tuple)) and len(b) == 4
|
|
and all(type(v) in (int, float) and math.isfinite(v) for v in b)):
|
|
out.append(el)
|
|
continue
|
|
x1, y1, x2, y2 = b
|
|
if not (x1 < x2 and y1 < y2):
|
|
out.append(el) # zero/negative area: the compiler's message is clearer
|
|
continue
|
|
if do_scale:
|
|
rx, ry = nw / ow, nh / oh
|
|
x1, x2 = _half_up(x1 * rx), _half_up(x2 * rx)
|
|
y1, y2 = _half_up(y1 * ry), _half_up(y2 * ry)
|
|
if x2 <= x1:
|
|
x2 = x1 + 1 # a very thin box must not vanish when scaled down
|
|
if y2 <= y1:
|
|
y2 = y1 + 1
|
|
c = [min(max(x1, 0), nw), min(max(y1, 0), nh), min(max(x2, 0), nw), min(max(y2, 0), nh)]
|
|
if not (c[0] < c[2] and c[1] < c[3]):
|
|
raise ValueError(
|
|
f"MOSS layout: items[{i}].bbox lies entirely outside the {nw}x{nh} canvas, "
|
|
f"so there is nothing left to place (box was {list(b)})")
|
|
if c != [x1, y1, x2, y2]:
|
|
print(f"[BoyoBBOX] items[{i}].bbox {[x1, y1, x2, y2]} clipped to the {nw}x{nh} canvas -> {c}")
|
|
out.append({**el, "bbox": c})
|
|
return out
|
|
|
|
|
|
def build_moss_draft(moss, width, height):
|
|
"""
|
|
Translate the editor's MOSS state into the public Layout draft schema.
|
|
Editor-only fields (id, colour) never leave this function, empty optional
|
|
fields are omitted (the compiler rejects empty strings/arrays), and key
|
|
order matches the spec: label, caption, text, bbox, tags, palette.
|
|
"""
|
|
sm = moss.get("summary") or {}
|
|
summary = {"caption": str(sm.get("caption", ""))}
|
|
meta = _clean_list(sm.get("meta"))
|
|
if meta:
|
|
summary["meta"] = meta
|
|
if sm.get("canvas"):
|
|
summary["canvas"] = sm["canvas"]
|
|
palette = _clean_list(sm.get("palette"))
|
|
if palette:
|
|
summary["palette"] = palette
|
|
|
|
items = []
|
|
for el in moss.get("elements") or []:
|
|
label = el.get("label", "object")
|
|
item = {"label": label, "caption": str(el.get("desc", ""))}
|
|
# Only text items carry literal text; stale text on a relabelled item is dropped.
|
|
if label == "text" and str(el.get("text", "")).strip():
|
|
item["text"] = str(el["text"])
|
|
item["bbox"] = el.get("bbox", [])
|
|
tags = _clean_list(el.get("tags"))
|
|
if tags:
|
|
item["tags"] = tags
|
|
pal = _clean_list(el.get("palette"))
|
|
if pal:
|
|
item["palette"] = pal
|
|
items.append(item)
|
|
|
|
return {
|
|
"width": width,
|
|
"height": height,
|
|
"bbox_coord_space": "abs",
|
|
"bbox_format": "xyxy",
|
|
"summary": summary,
|
|
"items": items,
|
|
}
|
|
|
|
|
|
class BoyoBBOX:
|
|
"""
|
|
Boyo Bounding Box Spatial Prompt Editor — Flux 3 / MOSS.
|
|
|
|
Flux 3 format: dual mode Generate (blank canvas, manual resolution) or Edit
|
|
(image input, auto dims). Outputs a BFL prompt string + bbox JSON.
|
|
|
|
MOSS format (generation only): flat pixel Layout draft compiled by the official
|
|
MOSS compiler. Outputs the compiled one-line prompt, the editable draft JSON,
|
|
and the canvas width/height. The image input is ignored.
|
|
|
|
width_in / height_in (optional INT sockets, e.g. from a Resolution Selector) override
|
|
the node's own size in either format when connected, so the size is chosen once.
|
|
In Flux edit mode a connected image still wins, as before.
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"bbox_data": ("STRING", {"default": "{}"}),
|
|
},
|
|
"optional": {
|
|
"image": ("IMAGE",),
|
|
"width_in": ("INT", {"forceInput": True}),
|
|
"height_in": ("INT", {"forceInput": True}),
|
|
},
|
|
"hidden": {"unique_id": "UNIQUE_ID"},
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING", "INT", "INT")
|
|
RETURN_NAMES = ("prompt", "bbox_json", "width", "height")
|
|
FUNCTION = "process"
|
|
CATEGORY = "BoyoNodes/Flux3"
|
|
OUTPUT_NODE = False
|
|
|
|
@classmethod
|
|
def IS_CHANGED(cls, bbox_data="{}", width_in=None, height_in=None, **kwargs):
|
|
try:
|
|
data = json.loads(bbox_data)
|
|
except Exception:
|
|
data = {}
|
|
if isinstance(data, dict) and data.get("format") == "moss":
|
|
# Deterministic compile: only re-run (and invalidate downstream) when the draft changes.
|
|
return hashlib.sha256(f"{bbox_data}|{width_in}|{height_in}".encode("utf-8")).hexdigest()
|
|
return float("nan")
|
|
|
|
def _process_moss(self, data, width_in=None, height_in=None, unique_id=None):
|
|
moss = data.get("moss") or {}
|
|
try:
|
|
width = int(moss.get("width", 1024))
|
|
height = int(moss.get("height", 1024))
|
|
except (TypeError, ValueError):
|
|
raise ValueError("MOSS layout: width and height must be whole numbers") from None
|
|
|
|
editor_size = (width, height) # the canvas the editor's boxes were drawn on
|
|
|
|
# Connected size sockets win over the node's own selection
|
|
if width_in is not None or height_in is not None:
|
|
if width_in is not None:
|
|
width = int(width_in)
|
|
if height_in is not None:
|
|
height = int(height_in)
|
|
# People round their sizes to 8, 16, 32, 64...; MOSS needs multiples of 16, so round DOWN
|
|
# (never more pixels than were asked for). The width/height outputs carry the rounded
|
|
# size, so a latent fed from this node always matches the compiled canvas.
|
|
raw = (width, height)
|
|
width, height = _floor16(width), _floor16(height)
|
|
if (width, height) != raw:
|
|
print(f"[BoyoBBOX] linked size {raw[0]}x{raw[1]} rounded down to {width}x{height} "
|
|
f"(MOSS needs multiples of 16)")
|
|
_announce_size(unique_id, width, height, raw) # before compiling, so the editor syncs even on failure
|
|
|
|
# Boxes follow the canvas (and are clipped to it) rather than killing the run
|
|
scale = moss.get("scale_boxes", True) is not False
|
|
moss = {**moss, "elements": _fit_boxes(moss.get("elements") or [], editor_size, (width, height), scale)}
|
|
|
|
draft = build_moss_draft(moss, width, height)
|
|
try:
|
|
prompt = moss_compile_prompt(draft)
|
|
except ValueError as e:
|
|
raise ValueError(f"MOSS layout: {e}") from None
|
|
|
|
draft_json = json.dumps(draft, ensure_ascii=False, indent=2)
|
|
return (prompt, draft_json, width, height)
|
|
|
|
def process(self, bbox_data, image=None, width_in=None, height_in=None, unique_id=None):
|
|
try:
|
|
data = json.loads(bbox_data)
|
|
except Exception:
|
|
data = {}
|
|
if not isinstance(data, dict):
|
|
data = {}
|
|
|
|
# MOSS is generation-only: no image handling, no preview.
|
|
if data.get("format") == "moss":
|
|
return self._process_moss(data, width_in, height_in, unique_id)
|
|
|
|
mode = data.get("mode", "generate")
|
|
caption = data.get("caption", "")
|
|
elements = data.get("elements", [])
|
|
width = data.get("width", 1024)
|
|
height = data.get("height", 1024)
|
|
|
|
# Connected size sockets override the node's own size (an image in edit mode still wins, below)
|
|
if width_in is not None:
|
|
width = int(width_in)
|
|
if height_in is not None:
|
|
height = int(height_in)
|
|
if width_in is not None or height_in is not None:
|
|
_announce_size(unique_id, width, height) # the linked size, before any image override
|
|
|
|
# If image connected, save preview and use its dims
|
|
if image is not None:
|
|
height = image.shape[1]
|
|
width = image.shape[2]
|
|
# Save temp preview for the load button to fetch
|
|
img_np = (image[0].cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
|
|
pil_img = Image.fromarray(img_np)
|
|
temp_dir = folder_paths.get_temp_directory()
|
|
fpath = os.path.join(temp_dir, "boyo_bbox_preview.png")
|
|
pil_img.save(fpath)
|
|
|
|
# Build bbox rows
|
|
rows = []
|
|
for el in elements:
|
|
rows.append({
|
|
"id": el.get("id", "element_1"),
|
|
"bbox": el.get("bbox", [0, 0, 1000, 1000]),
|
|
"desc": el.get("desc", ""),
|
|
})
|
|
|
|
bbox_json_str = json.dumps(rows)
|
|
prompt = f"{caption} {bbox_json_str}".strip()
|
|
|
|
return (prompt, bbox_json_str, width, height)
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"BoyoBBOX": BoyoBBOX,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"BoyoBBOX": "Boyo BBOX Spatial Editor (Flux 3 / MOSS)",
|
|
}
|
|
|
|
|
|
# ── Custom API route — serves the cached preview image to JS ──────────────────
|
|
@PromptServer.instance.routes.get("/boyo_bbox/preview")
|
|
async def boyo_bbox_preview(request):
|
|
temp_dir = folder_paths.get_temp_directory()
|
|
fpath = os.path.join(temp_dir, "boyo_bbox_preview.png")
|
|
if not os.path.exists(fpath):
|
|
return web.Response(status=404, text="No preview available — run the node once with an image connected.")
|
|
return web.FileResponse(fpath)
|
|
|
|
|
|
@PromptServer.instance.routes.get("/boyo_bbox/preview_info")
|
|
async def boyo_bbox_preview_info(request):
|
|
"""Returns image dimensions so JS can set canvas aspect before loading."""
|
|
temp_dir = folder_paths.get_temp_directory()
|
|
fpath = os.path.join(temp_dir, "boyo_bbox_preview.png")
|
|
if not os.path.exists(fpath):
|
|
return web.json_response({"exists": False})
|
|
with Image.open(fpath) as img:
|
|
w, h = img.size
|
|
return web.json_response({"exists": True, "width": w, "height": h})
|