diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6227841..1812109 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,5 +68,5 @@ jobs: - name: Lint JavaScript run: npm run lint - - name: Test caption layout helpers - run: node --test tests/caption_layout.test.js + - name: Test JavaScript helpers + run: node --test tests/*.test.js diff --git a/README.md b/README.md index 164259e..25e8e4c 100644 --- a/README.md +++ b/README.md @@ -58,11 +58,13 @@ The builder outputs a structured JSON prompt compatible with Ideogram 4's API fo This is usually harmless. The store routes register on startup, and if they fail, character/object/scene save features may not work, but the builder itself will function normally. Check file permissions on your ComfyUI user directory. +Load shows the request error (for example `HTTP 500`) when the store cannot be read. It says there are no saved scenes or characters only when the server returns an empty library. A failed request does not mean the presets were deleted. + **Store location:** Character, object, and scene presets are stored in `/byae-ideogram-tools/` as JSON files (`characters.json`, `objects.json`, `scenes.json`). **Unicode support:** Preset names can include Unicode characters (e.g. 角色, Персонаж, Émilie). Names must start with a letter, number, or underscore, and can contain letters, numbers, spaces, underscores, and hyphens (up to 64 characters). -**Corrupt file recovery:** If a store file becomes corrupted (invalid JSON), it will be automatically moved to a backup file named `.json.corrupt-` and the bucket will be treated as empty. Other buckets continue to work normally, and saves to the recovered bucket will work immediately. +**Corrupt file recovery:** If a store file cannot be used as a bucket (invalid JSON, or JSON that is not an object), it is moved to a backup named `.json.corrupt-` and the bucket is treated as empty. When that backup name already exists, the new file gets an extra suffix so the earlier backup is kept. Other buckets continue to work normally, and saves to the recovered bucket work immediately. ### Failed to save character/object/scene @@ -97,8 +99,8 @@ pip install pytest pytest-asyncio aiohttp # Run all tests python -m pytest tests/ -v -# Region layout helpers (no Python dependencies) -node --test tests/caption_layout.test.js +# Region layout and store-client helpers (no Python dependencies) +node --test tests/*.test.js # Run specific test file python -m pytest tests/test_store.py -v diff --git a/nodes/ideogram4_nodes.py b/nodes/ideogram4_nodes.py index 3d700a0..bfe560c 100644 --- a/nodes/ideogram4_nodes.py +++ b/nodes/ideogram4_nodes.py @@ -230,14 +230,10 @@ def _render_preview(boxes, width, height, bg_guid="", background_opacity=0.5): if not isinstance(box, dict) or box.get("nobbox"): continue r, g, b = _hex_rgb(PREVIEW_PALETTE[i % len(PREVIEW_PALETTE)]) - x1 = max(0, min(rw, round(box.get("x", 0) * rw))) - y1 = max(0, min(rh, round(box.get("y", 0) * rh))) - x2 = max(0, min(rw, round((box.get("x", 0) + box.get("w", 0)) * rw))) - y2 = max(0, min(rh, round((box.get("y", 0) + box.get("h", 0)) * rh))) - if x2 < x1: - x1, x2 = x2, x1 - if y2 < y1: - y1, y2 = y2, y1 + edges = _preview_edges(box, rw, rh) + if edges is None: + continue + x1, y1, x2, y2 = edges draw.rectangle([x1, y1, x2, y2], outline=(r, g, b, 255), width=2) @@ -271,12 +267,45 @@ def _render_preview(boxes, width, height, bg_guid="", background_opacity=0.5): return torch.from_numpy(arr).unsqueeze(0) -def _norm_bbox(box): - def c(v): - return max(0, min(1000, round(v * 1000))) +def _scale_axis(value): + """Map one finite axis onto Ideogram's integer 0-1000 range. - x, y, w, h = box.get("x", 0.0), box.get("y", 0.0), box.get("w", 0.0), box.get("h", 0.0) - ymin, xmin, ymax, xmax = c(y), c(x), c(y + h), c(x + w) + Clamp before rounding. round() raises OverflowError once value * 1000 + exceeds the float range, which a hand-edited region can do. + """ + if value >= 1: + return 1000 + if value <= 0: + return 0 + return int(round(value * 1000)) + + +def _box_coords(box): + """Return x, y, w, h as finite floats, or None when any value is unusable.""" + if not isinstance(box, dict): + return None + coords = [_finite_coord(box.get(key, 0.0)) for key in ("x", "y", "w", "h")] + if any(value is None for value in coords): + return None + return coords + + +def _norm_bbox(box): + """Return [ymin, xmin, ymax, xmax], or None when a coordinate is unusable. + + Numeric strings are accepted. Booleans are not: True is an int, and + treating it as 1 used to pin an edge to the far side of the canvas. + """ + coords = _box_coords(box) + if coords is None: + return None + x, y, w, h = coords + ymin, xmin, ymax, xmax = ( + _scale_axis(y), + _scale_axis(x), + _scale_axis(y + h), + _scale_axis(x + w), + ) if ymin > ymax: ymin, ymax = ymax, ymin if xmin > xmax: @@ -284,6 +313,28 @@ def _norm_bbox(box): return [ymin, xmin, ymax, xmax] +def _preview_edges(box, rw, rh): + """Pixel edges for one region, or None when its coordinates are unusable.""" + coords = _box_coords(box) + if coords is None: + return None + x, y, w, h = coords + + def edge(value, scale): + if value >= 1: + return scale + if value <= 0: + return 0 + return max(0, min(scale, int(round(value * scale)))) + + x1, y1, x2, y2 = edge(x, rw), edge(y, rh), edge(x + w, rw), edge(y + h, rh) + if x2 < x1: + x1, x2 = x2, x1 + if y2 < y1: + y1, y2 = y2, y1 + return x1, y1, x2, y2 + + # Ideogram 4 captions accept uppercase #RRGGBB only: 16 style colors, 5 per element. # https://github.com/ideogram-oss/ideogram4/blob/main/docs/prompting.md _HEX6 = re.compile(r"^#[0-9A-F]{6}$") @@ -338,6 +389,27 @@ def _dumps(v, lvl=0): return json.dumps(v, ensure_ascii=False) +def _elements_from_boxes(boxes): + elements = [] + for box in boxes or []: + if not isinstance(box, dict): + continue + etype = "text" if box.get("type") == "text" else "obj" + elem = {"type": etype} + if not box.get("nobbox"): + bbox = _norm_bbox(box) + if bbox is not None: + elem["bbox"] = bbox + if etype == "text": + elem["text"] = box.get("text", "") + elem["desc"] = box.get("desc", "") + palette = _palette(box.get("palette", []), ELEMENT_PALETTE_MAX) + if palette: + elem["color_palette"] = palette + elements.append(elem) + return elements + + def _parse_json_list(s): if s: try: @@ -537,21 +609,7 @@ folder and picked up automatically by the editor.""", 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", "") - elem["desc"] = box.get("desc", "") - palette = _palette(box.get("palette", []), ELEMENT_PALETTE_MAX) - if palette: - elem["color_palette"] = palette - elements.append(elem) + elements = _elements_from_boxes(boxes) caption["compositional_deconstruction"] = { "background": background, diff --git a/store.py b/store.py index 35682e1..defd4e5 100644 --- a/store.py +++ b/store.py @@ -60,26 +60,37 @@ def _validate_name(name: str) -> str: return name +def _backup_unreadable(path: str, reason) -> None: + """Move a store file aside. A name that already exists is not replaced.""" + timestamp = int(time.time()) + backup_path = f"{path}.corrupt-{timestamp}" + if os.path.exists(backup_path): + backup_path = f"{path}.corrupt-{timestamp}-{time.time_ns()}" + logger.warning( + "ByAE Ideogram: corrupt store file %s (%s), moving to %s", + path, reason, backup_path, + ) + try: + os.rename(path, backup_path) + except OSError as rename_err: + logger.error("ByAE Ideogram: failed to backup corrupt file: %s", rename_err) + + def _read_json(path: str) -> dict: if not os.path.isfile(path): return {} try: with open(path, encoding="utf-8") as f: data = json.load(f) - return data if isinstance(data, dict) else {} except (json.JSONDecodeError, UnicodeDecodeError) as e: - # Move corrupt file aside and treat bucket as empty - timestamp = int(time.time()) - backup_path = f"{path}.corrupt-{timestamp}" - logger.warning( - "ByAE Ideogram: corrupt store file %s (%s), moving to %s", - path, e, backup_path - ) - try: - os.rename(path, backup_path) - except OSError as rename_err: - logger.error("ByAE Ideogram: failed to backup corrupt file: %s", rename_err) + _backup_unreadable(path, e) return {} + if isinstance(data, dict): + return data + # A JSON array or other value is valid JSON but not a bucket. Leaving it + # in place made the next save overwrite it with no backup. + _backup_unreadable(path, f"expected a JSON object, got {type(data).__name__}") + return {} def _write_json(path: str, data: dict) -> None: diff --git a/tests/caption_layout.test.js b/tests/caption_layout.test.js index ecc5054..55968a6 100644 --- a/tests/caption_layout.test.js +++ b/tests/caption_layout.test.js @@ -7,7 +7,10 @@ import { fallbackRegion, ideogramBboxToRect, offsetAddedRegions, + pasteRegions, + rectToIdeogramBbox, regionCoordsFinite, + staggerLoadedRegion, } from "../web/js/caption_layout.js"; describe("ideogram bbox import", () => { @@ -72,6 +75,17 @@ describe("ideogram bbox import", () => { assert.equal(regionCoordsFinite({ x: NaN, y: 0.2, w: 0.3, h: 0.4 }), false); assert.equal(regionCoordsFinite({ x: 0.1, y: 0.2, w: 0.3 }), false); }); + + it("exports a usable bbox and drops coordinates that are not finite", () => { + assert.deepEqual(rectToIdeogramBbox({ x: 0.1, y: 0.2, w: 0.3, h: 0.4 }), [200, 100, 600, 400]); + assert.deepEqual(rectToIdeogramBbox({ x: "0.1", y: "0.2", w: "0.3", h: "0.4" }), [200, 100, 600, 400]); + assert.deepEqual(rectToIdeogramBbox({ x: 0.5, y: 0.6, w: -0.2, h: -0.1 }), [500, 300, 600, 500]); + assert.equal(rectToIdeogramBbox({ x: Number.NaN, y: 0.2, w: 0.3, h: 0.4 }), null); + assert.equal(rectToIdeogramBbox({ x: "nope", y: 0, w: 0.2, h: 0.2 }), null); + assert.equal(rectToIdeogramBbox({ x: true, y: 0, w: 0.2, h: 0.2 }), null); + assert.deepEqual(rectToIdeogramBbox({}), [0, 0, 0, 0]); + assert.deepEqual(rectToIdeogramBbox({ x: 1e308, y: 0, w: 1e308, h: 0.2 }), [0, 1000, 200, 1000]); + }); }); describe("color palettes", () => { @@ -121,3 +135,64 @@ describe("added scenes", () => { near(placed[0].w, 0.05); }); }); + +describe("pasted regions", () => { + it("nudges by the paste offset and stays inside the canvas", () => { + const placed = pasteRegions([ + { x: 0.9, y: 0.9, w: 0.2, h: 0.2, desc: "edge", palette: ["#FFFFFF"] }, + ]); + near(placed[0].x, 0.93); + near(placed[0].y, 0.93); + near(placed[0].w, 0.07); + near(placed[0].h, 0.07); + assert.ok(placed[0].x + placed[0].w <= 1 + 1e-9); + assert.ok(placed[0].y + placed[0].h <= 1 + 1e-9); + assert.equal(placed[0].desc, "edge"); + }); + + it("does not keep the source store link or share its palette array", () => { + const src = [{ + x: 0.1, y: 0.2, w: 0.2, h: 0.2, + palette: ["#FFFFFF"], + storeRef: { type: "character", name: "Ada" }, + }]; + const placed = pasteRegions(src); + near(placed[0].x, 0.13); + near(placed[0].y, 0.23); + near(placed[0].w, 0.2); + assert.equal(placed[0].storeRef, undefined); + placed[0].palette.push("#000000"); + assert.deepEqual(src[0].palette, ["#FFFFFF"]); + assert.deepEqual(src[0].storeRef, { type: "character", name: "Ada" }); + }); +}); + +describe("loaded characters and objects", () => { + it("keeps the original diagonal stagger while the region still fits", () => { + for (let i = 0; i <= 16; i++) { + const box = staggerLoadedRegion(i); + near(box.x, 0.1 + i * 0.04); + near(box.y, 0.1 + i * 0.04); + near(box.w, 0.25); + near(box.h, 0.2); + assert.ok(box.x + box.w <= 1 + 1e-9, `index ${i} leaves the canvas`); + } + }); + + it("tiles later regions inside the canvas instead of sliding off it", () => { + const box = staggerLoadedRegion(17); + near(box.x, 0); + near(box.y, 0); + near(box.w, 0.25); + near(box.h, 0.2); + const next = staggerLoadedRegion(18); + near(next.x, 0.25); + near(next.y, 0); + for (let i = 0; i < 80; i++) { + const placed = staggerLoadedRegion(i); + assert.ok(placed.x >= -1e-9 && placed.y >= -1e-9, `index ${i} origin`); + assert.ok(placed.x + placed.w <= 1 + 1e-9, `index ${i} x`); + assert.ok(placed.y + placed.h <= 1 + 1e-9, `index ${i} y`); + } + }); +}); diff --git a/tests/store_client.test.js b/tests/store_client.test.js new file mode 100644 index 0000000..336a9db --- /dev/null +++ b/tests/store_client.test.js @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; + +globalThis.window = { + comfyAPI: { + app: {}, + api: { addEventListener() {} }, + }, +}; + +const { loadStores } = await import("../web/js/ideogram4_shared.js"); + +describe("loadStores", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("returns the three buckets from a successful response", async () => { + globalThis.fetch = async () => ({ + ok: true, + json: async () => ({ + characters: { Ada: { desc: "a" } }, + objects: {}, + scenes: { Scene: { boxes: [] } }, + }), + }); + const stores = await loadStores(); + assert.deepEqual(stores.characters.Ada, { desc: "a" }); + assert.deepEqual(stores.objects, {}); + assert.deepEqual(stores.scenes.Scene, { boxes: [] }); + }); + + it("returns empty buckets when the server has no presets", async () => { + globalThis.fetch = async () => ({ + ok: true, + json: async () => ({}), + }); + const stores = await loadStores(); + assert.deepEqual(stores, { characters: {}, objects: {}, scenes: {} }); + }); + + it("throws when the store request fails instead of looking empty", async () => { + globalThis.fetch = async () => ({ ok: false, status: 500, json: async () => ({}) }); + await assert.rejects(loadStores(), /HTTP 500/); + }); + + it("throws when the network request fails", async () => { + globalThis.fetch = async () => { + throw new Error("network down"); + }; + await assert.rejects(loadStores(), /network down/); + }); +}); diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 8243e86..75680c5 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -7,6 +7,7 @@ import unittest from nodes.ideogram4_nodes import ( sanitize_bg_guid, _norm_bbox, _palette, _dumps, _parse_json_list, _caption_to_boxes, _coerce_layout_int, _coerce_opacity, + _elements_from_boxes, _preview_edges, save_bg_image_to_temp, ) @@ -58,6 +59,45 @@ class TestPromptHelpers(unittest.TestCase): result = _norm_bbox(box) self.assertEqual(result, [0, 0, 0, 0]) + def test_norm_bbox_accepts_numeric_strings(self): + box = {"x": "0.1", "y": "0.2", "w": "0.3", "h": "0.4"} + self.assertEqual(_norm_bbox(box), [200, 100, 600, 400]) + + def test_norm_bbox_rejects_non_finite_coordinates(self): + self.assertIsNone(_norm_bbox({"x": float("nan"), "y": 0.2, "w": 0.3, "h": 0.4})) + self.assertIsNone(_norm_bbox({"x": "nope", "y": 0.2, "w": 0.3, "h": 0.4})) + self.assertIsNone(_norm_bbox({"x": True, "y": 0.0, "w": 0.2, "h": 0.2})) + self.assertIsNone(_norm_bbox({"x": float("inf"), "y": 0.0, "w": 0.2, "h": 0.2})) + + def test_norm_bbox_huge_finite_values_do_not_raise(self): + result = _norm_bbox({"x": 1e308, "y": 0.0, "w": 1e308, "h": 0.2}) + self.assertEqual(result, [0, 1000, 200, 1000]) + + def test_elements_from_boxes_omits_unusable_bbox(self): + elements = _elements_from_boxes([ + {"type": "obj", "desc": "bad", "x": "nope", "y": 0, "w": 0.2, "h": 0.2}, + { + "type": "text", "text": "Hi", "desc": "ok", + "x": 0.1, "y": 0.2, "w": 0.3, "h": 0.4, + }, + "skip", + {"type": "obj", "desc": "no box", "nobbox": True, "x": 0.1, "y": 0.1, "w": 0.2, "h": 0.2}, + ]) + self.assertEqual(len(elements), 3) + self.assertNotIn("bbox", elements[0]) + self.assertEqual(elements[0]["desc"], "bad") + self.assertEqual(elements[1]["bbox"], [200, 100, 600, 400]) + self.assertEqual(elements[1]["text"], "Hi") + self.assertEqual(list(elements[1].keys()), ["type", "bbox", "text", "desc"]) + self.assertNotIn("bbox", elements[2]) + + def test_preview_edges_skip_unusable_box(self): + self.assertIsNone(_preview_edges({"x": "nope", "y": 0, "w": 0.2, "h": 0.2}, 100, 50)) + self.assertEqual( + _preview_edges({"x": 0.1, "y": 0.2, "w": 0.3, "h": 0.4}, 100, 50), + (10, 10, 40, 30), + ) + def test_palette_filters_empty_strings(self): colors = {"c1": "#FF0000", "c2": "", "c3": "#00FF00", "c4": None} result = _palette(colors) diff --git a/tests/test_store.py b/tests/test_store.py index c8dae56..176ec31 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -144,6 +144,54 @@ class TestStore(unittest.TestCase): self.assertIn("ValidObject", stores["objects"]) self.assertEqual(stores["objects"]["ValidObject"]["data"], "test") + def test_non_object_json_is_backed_up_before_overwrite(self): + bucket_path = os.path.join(self.temp_dir, "byae-ideogram-tools", "characters.json") + os.makedirs(os.path.dirname(bucket_path), exist_ok=True) + original = '[{"desc": "keep me"}]' + with open(bucket_path, "w", encoding="utf-8") as f: + f.write(original) + + self.assertEqual(load_bucket("characters"), {}) + self.assertFalse(os.path.exists(bucket_path)) + backup_files = [ + name for name in os.listdir(os.path.dirname(bucket_path)) + if name.startswith("characters.json.corrupt-") + ] + self.assertEqual(len(backup_files), 1) + with open(os.path.join(os.path.dirname(bucket_path), backup_files[0]), encoding="utf-8") as f: + self.assertEqual(f.read(), original) + + save_entry("characters", "New", {"desc": "n"}) + bucket = load_bucket("characters") + self.assertEqual(bucket["New"]["desc"], "n") + with open(os.path.join(os.path.dirname(bucket_path), backup_files[0]), encoding="utf-8") as f: + self.assertEqual(f.read(), original) + + def test_second_corrupt_backup_in_the_same_second_keeps_the_first(self): + store_dir = os.path.join(self.temp_dir, "byae-ideogram-tools") + os.makedirs(store_dir, exist_ok=True) + bucket_path = os.path.join(store_dir, "objects.json") + stamp = 1_700_000_000 + existing = os.path.join(store_dir, f"objects.json.corrupt-{stamp}") + with open(existing, "w", encoding="utf-8") as f: + f.write("first") + with open(bucket_path, "w", encoding="utf-8") as f: + f.write("{not json") + + with patch("store.time.time", return_value=stamp): + self.assertEqual(load_bucket("objects"), {}) + + self.assertFalse(os.path.exists(bucket_path)) + with open(existing, encoding="utf-8") as f: + self.assertEqual(f.read(), "first") + extras = [ + name for name in os.listdir(store_dir) + if name.startswith(f"objects.json.corrupt-{stamp}-") + ] + self.assertEqual(len(extras), 1) + with open(os.path.join(store_dir, extras[0]), encoding="utf-8") as f: + self.assertEqual(f.read(), "{not json") + def test_save_works_after_corrupt_file_recovery(self): # Create corrupt file bucket_path = os.path.join(self.temp_dir, "byae-ideogram-tools", "scenes.json") diff --git a/web/js/caption_layout.js b/web/js/caption_layout.js index 14b569f..86370e0 100644 --- a/web/js/caption_layout.js +++ b/web/js/caption_layout.js @@ -35,6 +35,29 @@ export function ideogramBboxToRect(bbox) { }; } +function scaleAxis(value) { + if (value >= 1) return 1000; + if (value <= 0) return 0; + return Math.max(0, Math.min(1000, Math.round(value * 1000))); +} + +/** Editor x/y/w/h (0-1) to Ideogram [ymin, xmin, ymax, xmax], or null if unusable. */ +export function rectToIdeogramBbox(box) { + if (!box || typeof box !== "object") return null; + const x = finiteCoord(box.x ?? 0); + const y = finiteCoord(box.y ?? 0); + const w = finiteCoord(box.w ?? 0); + const h = finiteCoord(box.h ?? 0); + if (x == null || y == null || w == null || h == null) return null; + let ymin = scaleAxis(y); + let xmin = scaleAxis(x); + let ymax = scaleAxis(y + h); + let xmax = scaleAxis(x + w); + if (ymin > ymax) [ymin, ymax] = [ymax, ymin]; + if (xmin > xmax) [xmin, xmax] = [xmax, xmin]; + return [ymin, xmin, ymax, xmax]; +} + export function fallbackRegion(index) { const i = Number.isInteger(index) && index >= 0 ? index : 0; const row = Math.floor(i / 4); @@ -119,3 +142,44 @@ export function offsetAddedRegions(boxes, nudge = 0.05) { y: (typeof b.y === "number" && Number.isFinite(b.y) ? b.y : 0) + nudge, })); } + +/** Ctrl+V: nudge a copy so it is visible, and do not keep the source store link. */ +export function pasteRegions(boxes, nudge = 0.03) { + return offsetAddedRegions(boxes, nudge).map((b) => { + const copy = { + ...b, + palette: Array.isArray(b.palette) ? b.palette.slice() : [], + }; + delete copy.storeRef; + return copy; + }); +} + +/** + * Place a character/object loaded into the scene. + * Indexes 0-16 keep the original 0.04 diagonal from (0.1, 0.1). Later indexes + * tile a 0.25x0.20 grid that stays inside the canvas. + */ +export function staggerLoadedRegion(index) { + const origin = 0.1; + const step = 0.04; + const w = 0.25; + const h = 0.2; + const i = Number.isInteger(index) && index >= 0 ? index : 0; + const diagonalCount = Math.floor((1 - w - origin) / step) + 1; + if (i < diagonalCount) { + return normalizeBoxCoords({ + x: origin + i * step, + y: origin + i * step, + w, + h, + }); + } + const extra = i - diagonalCount; + const cols = Math.max(1, Math.floor((1 - w) / w) + 1); + const rows = Math.max(1, Math.floor((1 - h) / h) + 1); + const slot = extra % (cols * rows); + const col = slot % cols; + const row = Math.floor(slot / cols); + return normalizeBoxCoords({ x: col * w, y: row * h, w, h }); +} diff --git a/web/js/ideogram4_prompt_builder_enhanced.js b/web/js/ideogram4_prompt_builder_enhanced.js index 9000807..4836b68 100644 --- a/web/js/ideogram4_prompt_builder_enhanced.js +++ b/web/js/ideogram4_prompt_builder_enhanced.js @@ -7,7 +7,8 @@ import { loadStores, saveStoreEntry, installBuilderQueueHooks, installBgUpdateListener, } from "./ideogram4_shared.js"; import { - cleanPalette, elementToRegion, offsetAddedRegions, regionCoordsFinite, + cleanPalette, elementToRegion, offsetAddedRegions, pasteRegions, rectToIdeogramBbox, + regionCoordsFinite, staggerLoadedRegion, } from "./caption_layout.js"; const { app } = window.comfyAPI.app; @@ -1527,11 +1528,7 @@ app.registerExtension({ e.stopPropagation(); if (!node._clipBoxes?.length) return; const newIdxs = []; - for (const cb of node._clipBoxes) { - const nb = cloneBox(cb); - nb.x = clamp01(nb.x + 0.03); - nb.y = clamp01(nb.y + 0.03); - delete nb.storeRef; + for (const nb of pasteRegions(node._clipBoxes.map((b) => cloneBox(b)))) { node._boxes.push(nb); newIdxs.push(node._boxes.length - 1); } @@ -1660,14 +1657,6 @@ app.registerExtension({ return widgetString(findW(name)?.value); } - function normBboxJS(b) { - const c = (v) => Math.max(0, Math.min(1000, Math.round(v * 1000))); - let ymin = c(b.y), xmin = c(b.x), ymax = c(b.y + b.h), xmax = c(b.x + b.w); - if (ymin > ymax) [ymin, ymax] = [ymax, ymin]; - if (xmin > xmax) [xmin, xmax] = [xmax, xmin]; - return [ymin, xmin, ymax, xmax]; - } - function buildCaption() { const cap = {}; const hld = getW("high_level_description").trim(); @@ -1684,7 +1673,10 @@ app.registerExtension({ const elements = node._boxes.map((b) => { const etype = b.type === "text" ? "text" : "obj"; const el = { type: etype }; - if (!b.nobbox) el.bbox = normBboxJS(b); + if (!b.nobbox) { + const bbox = rectToIdeogramBbox(b); + if (bbox) el.bbox = bbox; + } if (etype === "text") el.text = b.text || ""; el.desc = b.desc || ""; const pal = cleanPalette(b.palette, MAX_ELEM_COLORS); @@ -2011,10 +2003,9 @@ app.registerExtension({ const bucket = type === "object" ? stores.objects : stores.characters; const data = bucket[name]; if (!data) return; - const offset = node._boxes.length * 0.04; + const placed = staggerLoadedRegion(node._boxes.length); const nb = { - x: clamp01(0.1 + offset), y: clamp01(0.1 + offset), - w: 0.25, h: 0.2, + x: placed.x, y: placed.y, w: placed.w, h: placed.h, type: data.type || "obj", text: data.text || "", desc: data.desc || "", diff --git a/web/js/ideogram4_shared.js b/web/js/ideogram4_shared.js index 5c686a0..aa73e21 100644 --- a/web/js/ideogram4_shared.js +++ b/web/js/ideogram4_shared.js @@ -8,8 +8,6 @@ export const BUILDER_CLASS = "Ideogram4PromptBuilderKJEnhancedByAE"; export const PREVIEW_UPDATE_CLASS = "Ideogram4PromptBuilderKJPreviewUpdateByAE"; const STORE_API = "/byae/ideogram/stores"; -const EMPTY_STORES = () => ({ characters: {}, objects: {}, scenes: {} }); - export async function loadStores() { try { const resp = await fetch(STORE_API, { @@ -23,8 +21,9 @@ export async function loadStores() { scenes: data.scenes || {}, }; } catch (e) { + // Callers distinguish a failed request from a library that is actually empty. console.warn("ByAE Ideogram: failed to load stores", e); - return EMPTY_STORES(); + throw e; } }