diff --git a/README.md b/README.md index 55c2b97..c764ddb 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,45 @@ node/ - 同时连接 `image + mask`:两者都输出,mask 在保存模式下使用 `_mask` - `image` 与 `mask` 各自独立输出,互不影响 +### `LLS Simple Image Composite` + +`LLS Simple Image Composite` 用于把一张前景图叠加到背景图上,并输出最终合成图像。背景图尺寸始终作为最终输出尺寸,前景图支持平移、缩放、旋转和透明度控制。 + +**输入:** + +| 名称 | 类型 | 说明 | +|------|------|------| +| `background_image` | `IMAGE` | 背景图 | +| `overlay_image` | `IMAGE` | 前景图 | +| `x_offset` | `INT` | 前景图水平偏移 | +| `y_offset` | `INT` | 前景图垂直偏移 | +| `anchor_mode` | `top_left / center` | 前景图定位方式 | +| `rotation_origin_mode` | `top_left / center` | 前景图旋转原点 | +| `opacity` | `FLOAT` | 前景图透明度 | +| `scale` | `FLOAT` | 前景图缩放倍率 | +| `rotation` | `FLOAT` | 前景图旋转角度 | +| `blend_mode` | `normal` | 当前只支持 normal | +| `keep_aspect` | `BOOLEAN` | 当前保持等比缩放 | + +**输出:** + +| 名称 | 类型 | 说明 | +|------|------|------| +| `output_image` | `IMAGE` | 合成后的最终图像 | + +**典型用途:** + +- `Load Image(background) + Load Image(overlay) -> LLS Simple Image Composite -> Preview Image` +- 把 logo、贴纸或局部元素叠加到背景图的指定位置 +- 通过 `x_offset`、`y_offset`、`scale`、`rotation` 快速摆位 + +**说明:** + +- 前景图超出背景范围时会自动裁剪 +- 如果前景图带 alpha 通道,会优先按 alpha 合成 +- 节点内实时预览优先支持 `Load Image` / `Load Image Output` 作为上游文件来源 +- `anchor_mode` 与 `rotation_origin_mode` 相互独立,可以分别控制定位参考点和旋转参考点 + ### `LLS Simple Mask Draw` `LLS Simple Mask Draw` 是一个面向局部重绘的交互式遮罩输入节点,用来直接在输入图像上手动画 `mask`,并把结果输出给后续修复链路。 diff --git a/docs/superpowers/plans/2026-05-25-lls-simple-image-composite.md b/docs/superpowers/plans/2026-05-25-lls-simple-image-composite.md new file mode 100644 index 0000000..0a9194b --- /dev/null +++ b/docs/superpowers/plans/2026-05-25-lls-simple-image-composite.md @@ -0,0 +1,984 @@ +# LLS Simple Image Composite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a new `LLS Simple Image Composite` node that composites an overlay image onto a background image with translation, scale, rotation, opacity, independent anchor and rotation-origin modes, plus a ComfyUI node-local realtime preview UI. + +**Architecture:** Keep the backend compositing logic in focused `image/` modules and merge registration in `image/__init__.py` rather than growing `image/nodes.py` further. Mirror the existing `LLS Simple Mask Draw` pattern by pairing a Python node with a `web/js` extension that renders a node-local preview and synchronizes drag operations back into widgets. + +**Tech Stack:** Python, unittest, torch, numpy, Pillow, ComfyUI node contracts, DOM canvas frontend extension + +--- + +### Task 1: Create Test Helpers And Lock Registration / Frontend / Docs Contracts + +**Files:** +- Create: `tests/test_image_composite_helpers.py` +- Create: `tests/test_image_composite_registration.py` +- Create: `tests/test_image_composite_frontend.py` +- Create: `tests/test_image_composite_docs.py` + +- [ ] **Step 1: Create the shared test helper file** + +```python +import importlib +import importlib.util +import pathlib +import sys +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +MODULE_NAME = "lls_node_test_image_composite" + + +def _import_optional(module_name): + try: + return importlib.import_module(module_name) + except Exception: + return None + + +torch = _import_optional("torch") +np = _import_optional("numpy") +_pil_image_module = _import_optional("PIL.Image") +Image = _pil_image_module +HAS_IMAGE_COMPOSITE_RUNTIME_DEPS = torch is not None and np is not None and Image is not None +IMAGE_COMPOSITE_RUNTIME_DEPS_MESSAGE = "image composite tests require torch, numpy, and Pillow" + + +def load_plugin_package(): + for name in list(sys.modules): + if name == MODULE_NAME or name.startswith(f"{MODULE_NAME}."): + sys.modules.pop(name) + + spec = importlib.util.spec_from_file_location( + MODULE_NAME, + ROOT / "__init__.py", + submodule_search_locations=[str(ROOT)], + ) + module = importlib.util.module_from_spec(spec) + sys.modules[MODULE_NAME] = module + spec.loader.exec_module(module) + return module + + +def require_image_composite_runtime_deps(): + if not HAS_IMAGE_COMPOSITE_RUNTIME_DEPS: + raise unittest.SkipTest(IMAGE_COMPOSITE_RUNTIME_DEPS_MESSAGE) + + +def make_image(width=8, height=8, color=(0.0, 0.0, 0.0), alpha=None): + require_image_composite_runtime_deps() + rgb = tuple(float(channel) for channel in color) + channels = 4 if alpha is not None else 3 + tensor = torch.zeros((1, height, width, channels), dtype=torch.float32) + tensor[..., 0] = rgb[0] + tensor[..., 1] = rgb[1] + tensor[..., 2] = rgb[2] + if alpha is not None: + tensor[..., 3] = float(alpha) + return tensor +``` + +- [ ] **Step 2: Create failing registration, frontend, and docs tests** + +```python +# tests/test_image_composite_registration.py +import unittest + +try: + from .test_image_composite_helpers import load_plugin_package +except ImportError: + from test_image_composite_helpers import load_plugin_package + + +class TestImageCompositeRegistration(unittest.TestCase): + def test_plugin_registers_image_composite_node(self): + plugin = load_plugin_package() + + self.assertIn("LLSSimpleImageComposite", plugin.NODE_CLASS_MAPPINGS) + self.assertEqual( + plugin.NODE_DISPLAY_NAME_MAPPINGS["LLSSimpleImageComposite"], + "LLS Simple Image Composite", + ) + + def test_image_composite_schema_matches_contract(self): + plugin = load_plugin_package() + node_cls = plugin.NODE_CLASS_MAPPINGS["LLSSimpleImageComposite"] + schema = node_cls.INPUT_TYPES() + required = schema["required"] + + self.assertEqual(node_cls.CATEGORY, "LLS/Image") + self.assertEqual(node_cls.FUNCTION, "composite") + self.assertEqual(node_cls.RETURN_TYPES, ("IMAGE",)) + self.assertEqual(node_cls.RETURN_NAMES, ("output_image",)) + self.assertEqual(required["background_image"], ("IMAGE",)) + self.assertEqual(required["overlay_image"], ("IMAGE",)) + self.assertEqual(required["x_offset"][0], "INT") + self.assertEqual(required["x_offset"][1]["default"], 0) + self.assertEqual(required["y_offset"][0], "INT") + self.assertEqual(required["anchor_mode"][0], ["top_left", "center"]) + self.assertEqual(required["rotation_origin_mode"][0], ["top_left", "center"]) + self.assertEqual(required["opacity"][0], "FLOAT") + self.assertEqual(required["opacity"][1]["default"], 1.0) + self.assertEqual(required["blend_mode"][0], ["normal"]) + self.assertEqual(required["scale"][0], "FLOAT") + self.assertEqual(required["rotation"][0], "FLOAT") + self.assertEqual(required["keep_aspect"][0], "BOOLEAN") +``` + +```python +# tests/test_image_composite_frontend.py +import unittest + +try: + from .test_image_composite_helpers import ROOT, load_plugin_package +except ImportError: + from test_image_composite_helpers import ROOT, load_plugin_package + + +class TestImageCompositeFrontend(unittest.TestCase): + def test_plugin_exports_web_directory(self): + plugin = load_plugin_package() + self.assertEqual(plugin.WEB_DIRECTORY, "./web") + + def test_frontend_asset_exists(self): + asset = ROOT / "web" / "js" / "lls_image_composite.js" + self.assertTrue(asset.exists(), msg=f"Missing frontend asset: {asset}") + + def test_frontend_asset_registers_image_composite_extension(self): + asset = (ROOT / "web" / "js" / "lls_image_composite.js").read_text(encoding="utf-8") + + self.assertIn("app.registerExtension", asset) + self.assertIn("LLSSimpleImageComposite", asset) + self.assertIn("LLS Simple Image Composite", asset) + self.assertIn("beforeRegisterNodeDef", asset) + self.assertIn("addDOMWidget", asset) + self.assertIn("pointerdown", asset) + self.assertIn("pointermove", asset) + self.assertIn("rotation_origin_mode", asset) +``` + +```python +# tests/test_image_composite_docs.py +import unittest + +try: + from .test_image_composite_helpers import ROOT +except ImportError: + from test_image_composite_helpers import ROOT + + +class TestImageCompositeDocs(unittest.TestCase): + def test_readme_documents_image_composite_node(self): + readme = (ROOT / "README.md").read_text(encoding="utf-8") + + self.assertIn("LLS Simple Image Composite", readme) + self.assertIn("background_image", readme) + self.assertIn("overlay_image", readme) + self.assertIn("output_image", readme) + self.assertIn("x_offset", readme) + self.assertIn("y_offset", readme) + self.assertIn("anchor_mode", readme) + self.assertIn("rotation_origin_mode", readme) + self.assertIn("opacity", readme) + self.assertIn("scale", readme) + self.assertIn("rotation", readme) + self.assertIn("keep_aspect", readme) +``` + +- [ ] **Step 3: Run the contract tests to verify they fail** + +Run: `python3 -m unittest tests.test_image_composite_registration tests.test_image_composite_frontend tests.test_image_composite_docs` +Expected: FAIL because the node, frontend asset, and README section do not exist yet. + + +### Task 2: Write Failing Runtime Tests For Backend Compositing + +**Files:** +- Create: `tests/test_image_composite_node.py` +- Test: `tests/test_image_composite_node.py` + +- [ ] **Step 1: Create failing node behavior tests** + +```python +import unittest + +try: + from .test_image_composite_helpers import ( + HAS_IMAGE_COMPOSITE_RUNTIME_DEPS, + IMAGE_COMPOSITE_RUNTIME_DEPS_MESSAGE, + load_plugin_package, + make_image, + ) +except ImportError: + from test_image_composite_helpers import ( + HAS_IMAGE_COMPOSITE_RUNTIME_DEPS, + IMAGE_COMPOSITE_RUNTIME_DEPS_MESSAGE, + load_plugin_package, + make_image, + ) + + +@unittest.skipUnless(HAS_IMAGE_COMPOSITE_RUNTIME_DEPS, IMAGE_COMPOSITE_RUNTIME_DEPS_MESSAGE) +class TestImageCompositeNode(unittest.TestCase): + @classmethod + def setUpClass(cls): + plugin = load_plugin_package() + cls.node = plugin.NODE_CLASS_MAPPINGS["LLSSimpleImageComposite"]() + + def test_top_left_offset_places_overlay_at_requested_position(self): + background = make_image(width=6, height=6, color=(0.0, 0.0, 0.0)) + overlay = make_image(width=2, height=2, color=(1.0, 1.0, 1.0)) + + (output_image,) = self.node.composite( + background_image=background, + overlay_image=overlay, + x_offset=3, + y_offset=1, + anchor_mode="top_left", + rotation_origin_mode="center", + opacity=1.0, + blend_mode="normal", + scale=1.0, + rotation=0.0, + keep_aspect=True, + ) + + self.assertEqual(tuple(output_image.shape), (1, 6, 6, 3)) + self.assertTrue((output_image[0, 1:3, 3:5] > 0.99).all().item()) + self.assertEqual(float(output_image[0, 0, 0, 0].item()), 0.0) + + def test_center_anchor_places_overlay_by_its_center(self): + background = make_image(width=6, height=6, color=(0.0, 0.0, 0.0)) + overlay = make_image(width=2, height=2, color=(0.0, 1.0, 0.0)) + + (output_image,) = self.node.composite( + background_image=background, + overlay_image=overlay, + x_offset=3, + y_offset=2, + anchor_mode="center", + rotation_origin_mode="center", + opacity=1.0, + blend_mode="normal", + scale=1.0, + rotation=0.0, + keep_aspect=True, + ) + + self.assertTrue((output_image[0, 1:3, 2:4, 1] > 0.99).all().item()) + + def test_partial_overflow_is_clipped_to_background_bounds(self): + background = make_image(width=5, height=5, color=(0.0, 0.0, 0.0)) + overlay = make_image(width=3, height=3, color=(1.0, 0.0, 0.0)) + + (output_image,) = self.node.composite( + background_image=background, + overlay_image=overlay, + x_offset=4, + y_offset=4, + anchor_mode="top_left", + rotation_origin_mode="center", + opacity=1.0, + blend_mode="normal", + scale=1.0, + rotation=0.0, + keep_aspect=True, + ) + + self.assertGreater(float(output_image[0, 4, 4, 0].item()), 0.99) + self.assertEqual(float(output_image[0, 0, 0, 0].item()), 0.0) + + def test_overlay_fully_outside_returns_background(self): + background = make_image(width=5, height=5, color=(0.1, 0.1, 0.1)) + overlay = make_image(width=2, height=2, color=(1.0, 1.0, 1.0)) + + (output_image,) = self.node.composite( + background_image=background, + overlay_image=overlay, + x_offset=8, + y_offset=8, + anchor_mode="top_left", + rotation_origin_mode="center", + opacity=1.0, + blend_mode="normal", + scale=1.0, + rotation=0.0, + keep_aspect=True, + ) + + self.assertTrue((output_image == background).all().item()) + + def test_opacity_zero_returns_background(self): + background = make_image(width=4, height=4, color=(0.2, 0.2, 0.2)) + overlay = make_image(width=2, height=2, color=(1.0, 0.0, 0.0)) + + (output_image,) = self.node.composite( + background_image=background, + overlay_image=overlay, + x_offset=1, + y_offset=1, + anchor_mode="top_left", + rotation_origin_mode="center", + opacity=0.0, + blend_mode="normal", + scale=1.0, + rotation=0.0, + keep_aspect=True, + ) + + self.assertTrue((output_image == background).all().item()) + + def test_rgba_overlay_uses_alpha_and_opacity(self): + background = make_image(width=4, height=4, color=(0.0, 0.0, 0.0)) + overlay = make_image(width=2, height=2, color=(1.0, 0.0, 0.0), alpha=0.5) + + (output_image,) = self.node.composite( + background_image=background, + overlay_image=overlay, + x_offset=1, + y_offset=1, + anchor_mode="top_left", + rotation_origin_mode="center", + opacity=0.5, + blend_mode="normal", + scale=1.0, + rotation=0.0, + keep_aspect=True, + ) + + self.assertAlmostEqual(float(output_image[0, 1, 1, 0].item()), 0.25, places=2) + + def test_scale_expands_overlay_footprint(self): + background = make_image(width=5, height=5, color=(0.0, 0.0, 0.0)) + overlay = make_image(width=1, height=1, color=(1.0, 1.0, 1.0)) + + (output_image,) = self.node.composite( + background_image=background, + overlay_image=overlay, + x_offset=1, + y_offset=1, + anchor_mode="top_left", + rotation_origin_mode="center", + opacity=1.0, + blend_mode="normal", + scale=2.0, + rotation=0.0, + keep_aspect=True, + ) + + self.assertTrue((output_image[0, 1:3, 1:3] > 0.99).all().item()) + + def test_rotation_origin_mode_changes_rotated_result(self): + background = make_image(width=8, height=8, color=(0.0, 0.0, 0.0)) + overlay = make_image(width=3, height=1, color=(0.0, 0.0, 1.0)) + + (center_rotated,) = self.node.composite( + background_image=background, + overlay_image=overlay, + x_offset=3, + y_offset=3, + anchor_mode="top_left", + rotation_origin_mode="center", + opacity=1.0, + blend_mode="normal", + scale=1.0, + rotation=90.0, + keep_aspect=True, + ) + (corner_rotated,) = self.node.composite( + background_image=background, + overlay_image=overlay, + x_offset=3, + y_offset=3, + anchor_mode="top_left", + rotation_origin_mode="top_left", + opacity=1.0, + blend_mode="normal", + scale=1.0, + rotation=90.0, + keep_aspect=True, + ) + + self.assertFalse((center_rotated == corner_rotated).all().item()) +``` + +- [ ] **Step 2: Run the backend tests to verify they fail** + +Run: `python3 -m unittest tests.test_image_composite_node` +Expected: FAIL because `LLSSimpleImageComposite` and its backend logic do not exist yet. + + +### Task 3: Implement Backend Compositing Utilities + +**Files:** +- Create: `image/composite_utils.py` +- Test: `tests/test_image_composite_node.py` + +- [ ] **Step 1: Create the backend utility module with dependency guards and tensor validation** + +```python +from __future__ import annotations + +import io + +try: + import numpy as np +except Exception as exc: # pragma: no cover + np = None + _NUMPY_ERR = exc +else: # pragma: no cover + _NUMPY_ERR = None + +try: + from PIL import Image +except Exception as exc: # pragma: no cover + Image = None + _PIL_ERR = exc +else: # pragma: no cover + _PIL_ERR = None + +try: + import torch +except Exception as exc: # pragma: no cover + torch = None + _TORCH_ERR = exc +else: # pragma: no cover + _TORCH_ERR = None + + +ANCHOR_MODE_CHOICES = ["top_left", "center"] +ROTATION_ORIGIN_MODE_CHOICES = ["top_left", "center"] +BLEND_MODE_CHOICES = ["normal"] + + +def _require_runtime_deps(): + if torch is None: + raise RuntimeError("[LLS] torch is required for image compositing.") from _TORCH_ERR + if np is None: + raise RuntimeError("[LLS] numpy is required for image compositing.") from _NUMPY_ERR + if Image is None: + raise RuntimeError("[LLS] Pillow is required for image compositing.") from _PIL_ERR + + +def _require_image_tensor(name, image): + shape = tuple(getattr(image, "shape", ())) + if len(shape) != 4: + raise RuntimeError(f"[LLS] {name} must have shape [batch, height, width, channels].") + if int(shape[0]) <= 0 or int(shape[1]) <= 0 or int(shape[2]) <= 0: + raise RuntimeError(f"[LLS] {name} must have positive batch, width, and height.") + channels = int(shape[3]) + if channels not in {3, 4}: + raise RuntimeError(f"[LLS] {name} must have 3 or 4 channels; got {channels}.") +``` + +- [ ] **Step 2: Implement the single-image compositing helpers** + +```python +def _tensor_sample_to_rgba_pil(sample): + rgb = sample.detach().cpu().clamp(0.0, 1.0).numpy() + if rgb.shape[-1] == 3: + alpha = np.ones((rgb.shape[0], rgb.shape[1], 1), dtype=np.float32) + rgb = np.concatenate([rgb, alpha], axis=-1) + rgba = np.clip(np.round(rgb * 255.0), 0, 255).astype(np.uint8) + return Image.fromarray(rgba, mode="RGBA") + + +def _pil_to_tensor(image, *, device, dtype): + array = np.asarray(image, dtype=np.float32) / 255.0 + if array.ndim != 3 or array.shape[-1] != 4: + raise RuntimeError("[LLS] compositing helper expected an RGBA PIL image.") + rgb = array[..., :3] + return torch.from_numpy(rgb).to(device=device, dtype=dtype) + + +def _scaled_overlay_rgba(overlay_rgba, scale): + width, height = overlay_rgba.size + scaled_width = max(1, int(round(width * scale))) + scaled_height = max(1, int(round(height * scale))) + if (scaled_width, scaled_height) == (width, height): + return overlay_rgba + return overlay_rgba.resize((scaled_width, scaled_height), Image.BICUBIC) + + +def _resolve_top_left(width, height, x_offset, y_offset, anchor_mode): + if anchor_mode == "top_left": + return int(x_offset), int(y_offset) + if anchor_mode == "center": + return int(x_offset - (width / 2.0)), int(y_offset - (height / 2.0)) + raise RuntimeError(f"[LLS] Unsupported anchor_mode '{anchor_mode}'.") + + +def _resolve_rotation_center(top_left_x, top_left_y, width, height, rotation_origin_mode): + if rotation_origin_mode == "top_left": + return float(top_left_x), float(top_left_y) + if rotation_origin_mode == "center": + return float(top_left_x) + (width / 2.0), float(top_left_y) + (height / 2.0) + raise RuntimeError(f"[LLS] Unsupported rotation_origin_mode '{rotation_origin_mode}'.") +``` + +- [ ] **Step 3: Implement the batch-aware public compositing function** + +```python +def composite_images( + background_image, + overlay_image, + *, + x_offset, + y_offset, + anchor_mode, + rotation_origin_mode, + opacity, + blend_mode, + scale, + rotation, + keep_aspect, +): + del keep_aspect + + _require_runtime_deps() + if background_image is None: + raise RuntimeError("[LLS] background_image is required for image compositing.") + if overlay_image is None: + raise RuntimeError("[LLS] overlay_image is required for image compositing.") + if blend_mode != "normal": + raise RuntimeError(f"[LLS] Unsupported blend_mode '{blend_mode}'.") + + _require_image_tensor("background_image", background_image) + _require_image_tensor("overlay_image", overlay_image) + clamped_scale = max(0.01, float(scale)) + clamped_opacity = max(0.0, min(1.0, float(opacity))) + if clamped_opacity == 0.0: + return background_image[..., :3].clone() + + background_batch = int(background_image.shape[0]) + overlay_batch = int(overlay_image.shape[0]) + if overlay_batch not in {1, background_batch}: + raise RuntimeError( + f"[LLS] overlay_image batch must be 1 or match background_image batch; got {overlay_batch} and {background_batch}." + ) + + results = [] + for index in range(background_batch): + background_sample = background_image[index] + overlay_sample = overlay_image[0 if overlay_batch == 1 else index] + background_rgba = _tensor_sample_to_rgba_pil(background_sample) + overlay_rgba = _scaled_overlay_rgba(_tensor_sample_to_rgba_pil(overlay_sample), clamped_scale) + top_left_x, top_left_y = _resolve_top_left( + overlay_rgba.size[0], + overlay_rgba.size[1], + int(x_offset), + int(y_offset), + anchor_mode, + ) + + overlay_canvas = Image.new("RGBA", background_rgba.size, (0, 0, 0, 0)) + overlay_canvas.paste(overlay_rgba, (top_left_x, top_left_y), overlay_rgba) + + if float(rotation) != 0.0: + rotation_center = _resolve_rotation_center( + top_left_x, + top_left_y, + overlay_rgba.size[0], + overlay_rgba.size[1], + rotation_origin_mode, + ) + overlay_canvas = overlay_canvas.rotate( + float(rotation), + resample=Image.BICUBIC, + center=rotation_center, + expand=False, + ) + + if clamped_opacity < 1.0: + overlay_alpha = overlay_canvas.getchannel("A") + overlay_alpha = overlay_alpha.point(lambda value: int(round(value * clamped_opacity))) + overlay_canvas.putalpha(overlay_alpha) + + composed = Image.alpha_composite(background_rgba, overlay_canvas) + results.append( + _pil_to_tensor( + composed, + device=background_image.device, + dtype=background_image.dtype, + ) + ) + + return torch.stack(results, dim=0) +``` + +- [ ] **Step 4: Run the backend tests to verify they pass** + +Run: `python3 -m unittest tests.test_image_composite_node` +Expected: PASS + + +### Task 4: Implement The Node Class And Merge Registration In `image/__init__.py` + +**Files:** +- Create: `image/composite.py` +- Modify: `image/__init__.py` +- Test: `tests/test_image_composite_registration.py` + +- [ ] **Step 1: Create the node class file** + +```python +from __future__ import annotations + +from .composite_utils import ( + ANCHOR_MODE_CHOICES, + BLEND_MODE_CHOICES, + ROTATION_ORIGIN_MODE_CHOICES, + composite_images, +) + + +class LLSSimpleImageComposite: + CATEGORY = "LLS/Image" + FUNCTION = "composite" + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("output_image",) + DESCRIPTION = "Composite an overlay image onto a background image with translation, scale, rotation, and opacity." + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "background_image": ("IMAGE",), + "overlay_image": ("IMAGE",), + "x_offset": ("INT", {"default": 0, "min": -8192, "max": 8192, "step": 1}), + "y_offset": ("INT", {"default": 0, "min": -8192, "max": 8192, "step": 1}), + "anchor_mode": (ANCHOR_MODE_CHOICES, {"default": "top_left"}), + "rotation_origin_mode": (ROTATION_ORIGIN_MODE_CHOICES, {"default": "center"}), + "opacity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "blend_mode": (BLEND_MODE_CHOICES, {"default": "normal"}), + "scale": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 32.0, "step": 0.01}), + "rotation": ("FLOAT", {"default": 0.0, "min": -360.0, "max": 360.0, "step": 0.1}), + "keep_aspect": ("BOOLEAN", {"default": True}), + } + } + + def composite( + self, + background_image, + overlay_image, + x_offset, + y_offset, + anchor_mode, + rotation_origin_mode, + opacity, + blend_mode, + scale, + rotation, + keep_aspect, + ): + output_image = composite_images( + background_image, + overlay_image, + x_offset=x_offset, + y_offset=y_offset, + anchor_mode=anchor_mode, + rotation_origin_mode=rotation_origin_mode, + opacity=opacity, + blend_mode=blend_mode, + scale=scale, + rotation=rotation, + keep_aspect=keep_aspect, + ) + return (output_image,) + + +NODE_CLASS_MAPPINGS = {"LLSSimpleImageComposite": LLSSimpleImageComposite} +NODE_DISPLAY_NAME_MAPPINGS = {"LLSSimpleImageComposite": "LLS Simple Image Composite"} +``` + +- [ ] **Step 2: Merge the new node registration in `image/__init__.py`** + +```python +""" +node.image +========== +功能域:图像处理与后处理(对应功能分类总览第 5 节) +""" +from .composite import ( + NODE_CLASS_MAPPINGS as COMPOSITE_NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS as COMPOSITE_NODE_DISPLAY_NAME_MAPPINGS, +) +from .nodes import ( + NODE_CLASS_MAPPINGS as CORE_NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS as CORE_NODE_DISPLAY_NAME_MAPPINGS, +) + + +NODE_CLASS_MAPPINGS = {} +NODE_CLASS_MAPPINGS.update(CORE_NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(COMPOSITE_NODE_CLASS_MAPPINGS) + +NODE_DISPLAY_NAME_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS.update(CORE_NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(COMPOSITE_NODE_DISPLAY_NAME_MAPPINGS) + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] +``` + +- [ ] **Step 3: Run the registration tests to verify they pass** + +Run: `python3 -m unittest tests.test_image_composite_registration` +Expected: PASS + +- [ ] **Step 4: Commit the backend node work** + +```bash +git add image/composite.py image/composite_utils.py image/__init__.py \ + tests/test_image_composite_helpers.py tests/test_image_composite_registration.py tests/test_image_composite_node.py +git commit -m "Add LLS Simple Image Composite backend" +``` + + +### Task 5: Implement The ComfyUI Frontend Preview Extension + +**Files:** +- Create: `web/js/lls_image_composite.js` +- Test: `tests/test_image_composite_frontend.py` + +- [ ] **Step 1: Create the frontend extension skeleton and source-resolution helpers** + +```javascript +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; + +const EXTENSION_NAME = "LLS.ImageComposite"; +const TARGET_NODE_CLASS = "LLSSimpleImageComposite"; +const TARGET_NODE_DISPLAY_NAME = "LLS Simple Image Composite"; + +function findWidget(node, name) { + return node.widgets?.find((widget) => widget.name === name) ?? null; +} + +function getInputLink(node, inputName) { + const input = node.inputs?.find((item) => item.name === inputName); + if (!input || input.link == null) { + return null; + } + return node.graph?.links?.[input.link] ?? null; +} + +function getUpstreamNode(node, inputName) { + const link = getInputLink(node, inputName); + if (!link) { + return null; + } + return node.graph?.getNodeById?.(link.origin_id) ?? null; +} + +function buildViewUrl(filename, type = "input") { + if (!filename) { + return null; + } + const relativePath = `/view?filename=${encodeURIComponent(String(filename))}&type=${encodeURIComponent(type)}`; + return typeof api?.apiURL === "function" ? api.apiURL(relativePath) : relativePath; +} + +function resolveImageSource(node, inputName) { + const upstream = getUpstreamNode(node, inputName); + const imageWidget = upstream?.widgets?.find((widget) => widget.name === "image"); + if (!imageWidget?.value) { + return null; + } + const className = String(upstream?.comfyClass || upstream?.type || ""); + if (className === "LoadImage") { + return buildViewUrl(imageWidget.value, "input"); + } + if (className === "LoadImageOutput") { + return buildViewUrl(imageWidget.value, "output"); + } + return null; +} +``` + +- [ ] **Step 2: Implement preview rendering and drag-to-offset synchronization** + +```javascript +function renderCompositePreview(context, state) { + const { backgroundImage, overlayImage, xOffset, yOffset, anchorMode, rotationOriginMode, opacity, scale, rotation } = state; + if (!backgroundImage || !overlayImage) { + context.clearRect(0, 0, context.canvas.width, context.canvas.height); + context.fillStyle = "#1b1b1b"; + context.fillRect(0, 0, context.canvas.width, context.canvas.height); + context.fillStyle = "#9a9a9a"; + context.fillText("Connect Load Image / Load Image Output for preview.", 14, 24); + return; + } + + context.clearRect(0, 0, context.canvas.width, context.canvas.height); + context.drawImage(backgroundImage, 0, 0, context.canvas.width, context.canvas.height); + + const scaledWidth = overlayImage.width * scale; + const scaledHeight = overlayImage.height * scale; + const topLeftX = anchorMode === "center" ? xOffset - (scaledWidth / 2) : xOffset; + const topLeftY = anchorMode === "center" ? yOffset - (scaledHeight / 2) : yOffset; + const rotationCenterX = rotationOriginMode === "center" ? (topLeftX + scaledWidth / 2) : topLeftX; + const rotationCenterY = rotationOriginMode === "center" ? (topLeftY + scaledHeight / 2) : topLeftY; + + context.save(); + context.globalAlpha = opacity; + context.translate(rotationCenterX, rotationCenterY); + context.rotate((rotation * Math.PI) / 180); + context.translate(-rotationCenterX, -rotationCenterY); + context.drawImage(overlayImage, topLeftX, topLeftY, scaledWidth, scaledHeight); + context.restore(); +} +``` + +```javascript +app.registerExtension({ + name: EXTENSION_NAME, + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData.name !== TARGET_NODE_CLASS) { + return; + } + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function onNodeCreatedPatched() { + onNodeCreated?.apply(this, arguments); + + const canvas = document.createElement("canvas"); + canvas.width = 320; + canvas.height = 220; + const context = canvas.getContext("2d"); + + let dragging = false; + let dragStart = null; + + canvas.addEventListener("pointerdown", (event) => { + dragging = true; + dragStart = { x: event.clientX, y: event.clientY }; + canvas.setPointerCapture?.(event.pointerId); + }); + + canvas.addEventListener("pointermove", (event) => { + if (!dragging || !dragStart) { + return; + } + const xWidget = findWidget(this, "x_offset"); + const yWidget = findWidget(this, "y_offset"); + const deltaX = Math.round(event.clientX - dragStart.x); + const deltaY = Math.round(event.clientY - dragStart.y); + xWidget.value = Number(xWidget.value || 0) + deltaX; + yWidget.value = Number(yWidget.value || 0) + deltaY; + dragStart = { x: event.clientX, y: event.clientY }; + this.setDirtyCanvas?.(true, true); + }); + + canvas.addEventListener("pointerup", () => { + dragging = false; + dragStart = null; + }); + + this.addDOMWidget("preview", "preview", canvas, { + serialize: false, + hideOnZoom: false, + }); + }; + }, +}); +``` + +- [ ] **Step 3: Run the frontend tests to verify they pass** + +Run: `python3 -m unittest tests.test_image_composite_frontend` +Expected: PASS + +- [ ] **Step 4: Commit the frontend work** + +```bash +git add web/js/lls_image_composite.js tests/test_image_composite_frontend.py +git commit -m "Add LLS image composite preview extension" +``` + + +### Task 6: Update README And Verify Documentation Coverage + +**Files:** +- Modify: `README.md` +- Test: `tests/test_image_composite_docs.py` + +- [ ] **Step 1: Add the new README section** + +```markdown +### `LLS Simple Image Composite` + +`LLS Simple Image Composite` 用于把一张前景图叠加到背景图上,并输出最终合成图像。背景图尺寸始终作为最终输出尺寸,前景图支持平移、缩放、旋转和透明度控制。 + +**输入:** + +| 名称 | 类型 | 说明 | +|------|------|------| +| `background_image` | `IMAGE` | 背景图 | +| `overlay_image` | `IMAGE` | 前景图 | +| `x_offset` | `INT` | 前景图水平偏移 | +| `y_offset` | `INT` | 前景图垂直偏移 | +| `anchor_mode` | `top_left / center` | 前景图定位方式 | +| `rotation_origin_mode` | `top_left / center` | 前景图旋转原点 | +| `opacity` | `FLOAT` | 前景图透明度 | +| `scale` | `FLOAT` | 前景图缩放倍率 | +| `rotation` | `FLOAT` | 前景图旋转角度 | +| `blend_mode` | `normal` | 当前只支持 normal | +| `keep_aspect` | `BOOLEAN` | 当前保持等比缩放 | + +**输出:** + +| 名称 | 类型 | 说明 | +|------|------|------| +| `output_image` | `IMAGE` | 合成后的最终图像 | + +**典型用途:** + +- `Load Image(background) + Load Image(overlay) -> LLS Simple Image Composite -> Preview Image` +- 把 logo 贴到背景图指定位置 +- 拖动前景图实时预览位置、缩放和旋转 + +**说明:** + +- 前景图超出背景范围时会自动裁剪 +- 如果前景图带 alpha 通道,会优先按 alpha 合成 +- 节点内实时预览优先支持 `Load Image` / `Load Image Output` 作为上游文件来源 +``` + +- [ ] **Step 2: Run the docs tests to verify they pass** + +Run: `python3 -m unittest tests.test_image_composite_docs` +Expected: PASS + + +### Task 7: Run Full Verification And Final Commit + +**Files:** +- Modify: `docs/superpowers/plans/2026-05-25-lls-simple-image-composite.md` +- Test: `tests/test_image_composite_registration.py` +- Test: `tests/test_image_composite_node.py` +- Test: `tests/test_image_composite_frontend.py` +- Test: `tests/test_image_composite_docs.py` + +- [ ] **Step 1: Run the focused image composite test suite** + +Run: `python3 -m unittest tests.test_image_composite_registration tests.test_image_composite_node tests.test_image_composite_frontend tests.test_image_composite_docs` +Expected: PASS + +- [ ] **Step 2: Run the full repository test suite** + +Run: `python3 -m unittest discover -s tests` +Expected: PASS with `0 failed` + +- [ ] **Step 3: Commit the completed feature** + +```bash +git add README.md image/__init__.py image/composite.py image/composite_utils.py \ + web/js/lls_image_composite.js \ + tests/test_image_composite_helpers.py tests/test_image_composite_registration.py \ + tests/test_image_composite_node.py tests/test_image_composite_frontend.py tests/test_image_composite_docs.py \ + docs/superpowers/plans/2026-05-25-lls-simple-image-composite.md +git commit -m "Add LLS Simple Image Composite node" +``` diff --git a/image/__init__.py b/image/__init__.py index d1efdd0..434ee4b 100644 --- a/image/__init__.py +++ b/image/__init__.py @@ -3,6 +3,22 @@ node.image ========== 功能域:图像处理与后处理(对应功能分类总览第 5 节) """ -from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS +from .composite import ( + NODE_CLASS_MAPPINGS as COMPOSITE_NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS as COMPOSITE_NODE_DISPLAY_NAME_MAPPINGS, +) +from .nodes import ( + NODE_CLASS_MAPPINGS as CORE_NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS as CORE_NODE_DISPLAY_NAME_MAPPINGS, +) + + +NODE_CLASS_MAPPINGS = {} +NODE_CLASS_MAPPINGS.update(CORE_NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(COMPOSITE_NODE_CLASS_MAPPINGS) + +NODE_DISPLAY_NAME_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS.update(CORE_NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(COMPOSITE_NODE_DISPLAY_NAME_MAPPINGS) __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/image/composite.py b/image/composite.py new file mode 100644 index 0000000..1505c67 --- /dev/null +++ b/image/composite.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from .composite_utils import ( + ANCHOR_MODE_CHOICES, + BLEND_MODE_CHOICES, + ROTATION_ORIGIN_MODE_CHOICES, + composite_images, +) + + +class LLSSimpleImageComposite: + CATEGORY = "LLS/Image" + FUNCTION = "composite" + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("output_image",) + DESCRIPTION = "Composite an overlay image onto a background image with translation, scale, rotation, and opacity." + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "background_image": ("IMAGE",), + "overlay_image": ("IMAGE",), + "x_offset": ("INT", {"default": 0, "min": -8192, "max": 8192, "step": 1}), + "y_offset": ("INT", {"default": 0, "min": -8192, "max": 8192, "step": 1}), + "anchor_mode": (ANCHOR_MODE_CHOICES, {"default": "top_left"}), + "rotation_origin_mode": (ROTATION_ORIGIN_MODE_CHOICES, {"default": "center"}), + "opacity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "blend_mode": (BLEND_MODE_CHOICES, {"default": "normal"}), + "scale": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 32.0, "step": 0.01}), + "rotation": ("FLOAT", {"default": 0.0, "min": -360.0, "max": 360.0, "step": 0.1}), + "keep_aspect": ("BOOLEAN", {"default": True}), + } + } + + def composite( + self, + background_image, + overlay_image, + x_offset, + y_offset, + anchor_mode, + rotation_origin_mode, + opacity, + blend_mode, + scale, + rotation, + keep_aspect, + ): + output_image = composite_images( + background_image, + overlay_image, + x_offset=x_offset, + y_offset=y_offset, + anchor_mode=anchor_mode, + rotation_origin_mode=rotation_origin_mode, + opacity=opacity, + blend_mode=blend_mode, + scale=scale, + rotation=rotation, + keep_aspect=keep_aspect, + ) + return (output_image,) + + +NODE_CLASS_MAPPINGS = {"LLSSimpleImageComposite": LLSSimpleImageComposite} +NODE_DISPLAY_NAME_MAPPINGS = {"LLSSimpleImageComposite": "LLS Simple Image Composite"} diff --git a/image/composite_utils.py b/image/composite_utils.py new file mode 100644 index 0000000..43d06d5 --- /dev/null +++ b/image/composite_utils.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +try: + import numpy as np +except Exception as exc: # pragma: no cover + np = None + _NUMPY_ERR = exc +else: # pragma: no cover + _NUMPY_ERR = None + +try: + from PIL import Image +except Exception as exc: # pragma: no cover + Image = None + _PIL_ERR = exc +else: # pragma: no cover + _PIL_ERR = None + +try: + import torch +except Exception as exc: # pragma: no cover + torch = None + _TORCH_ERR = exc +else: # pragma: no cover + _TORCH_ERR = None + + +ANCHOR_MODE_CHOICES = ["top_left", "center"] +ROTATION_ORIGIN_MODE_CHOICES = ["top_left", "center"] +BLEND_MODE_CHOICES = ["normal"] + + +def _require_runtime_deps(): + if torch is None: + raise RuntimeError("[LLS] torch is required for image compositing.") from _TORCH_ERR + if np is None: + raise RuntimeError("[LLS] numpy is required for image compositing.") from _NUMPY_ERR + if Image is None: + raise RuntimeError("[LLS] Pillow is required for image compositing.") from _PIL_ERR + + +def _require_image_tensor(name, image): + shape = tuple(getattr(image, "shape", ())) + if len(shape) != 4: + raise RuntimeError(f"[LLS] {name} must have shape [batch, height, width, channels].") + if int(shape[0]) <= 0 or int(shape[1]) <= 0 or int(shape[2]) <= 0: + raise RuntimeError(f"[LLS] {name} must have positive batch, width, and height.") + channels = int(shape[3]) + if channels not in {3, 4}: + raise RuntimeError(f"[LLS] {name} must have 3 or 4 channels; got {channels}.") + + +def _tensor_sample_to_rgba_pil(sample): + rgba = sample.detach().cpu().clamp(0.0, 1.0).numpy() + if rgba.shape[-1] == 3: + alpha = np.ones((rgba.shape[0], rgba.shape[1], 1), dtype=np.float32) + rgba = np.concatenate([rgba, alpha], axis=-1) + rgba = np.clip(np.round(rgba * 255.0), 0, 255).astype(np.uint8) + return Image.fromarray(rgba, mode="RGBA") + + +def _pil_to_tensor(image, *, device, dtype): + array = np.asarray(image, dtype=np.float32) / 255.0 + if array.ndim != 3 or array.shape[-1] != 4: + raise RuntimeError("[LLS] compositing helper expected an RGBA PIL image.") + rgb = array[..., :3] + return torch.from_numpy(rgb).to(device=device, dtype=dtype) + + +def _scaled_overlay_rgba(overlay_rgba, scale): + width, height = overlay_rgba.size + scaled_width = max(1, int(round(width * scale))) + scaled_height = max(1, int(round(height * scale))) + if (scaled_width, scaled_height) == (width, height): + return overlay_rgba + return overlay_rgba.resize((scaled_width, scaled_height), Image.BICUBIC) + + +def _resolve_top_left(width, height, x_offset, y_offset, anchor_mode): + if anchor_mode == "top_left": + return int(x_offset), int(y_offset) + if anchor_mode == "center": + return int(x_offset - (width / 2.0)), int(y_offset - (height / 2.0)) + raise RuntimeError(f"[LLS] Unsupported anchor_mode '{anchor_mode}'.") + + +def _resolve_rotation_center(top_left_x, top_left_y, width, height, rotation_origin_mode): + if rotation_origin_mode == "top_left": + return float(top_left_x), float(top_left_y) + if rotation_origin_mode == "center": + return float(top_left_x) + (width / 2.0), float(top_left_y) + (height / 2.0) + raise RuntimeError(f"[LLS] Unsupported rotation_origin_mode '{rotation_origin_mode}'.") + + +def composite_images( + background_image, + overlay_image, + *, + x_offset, + y_offset, + anchor_mode, + rotation_origin_mode, + opacity, + blend_mode, + scale, + rotation, + keep_aspect, +): + del keep_aspect + + _require_runtime_deps() + if background_image is None: + raise RuntimeError("[LLS] background_image is required for image compositing.") + if overlay_image is None: + raise RuntimeError("[LLS] overlay_image is required for image compositing.") + if blend_mode != "normal": + raise RuntimeError(f"[LLS] Unsupported blend_mode '{blend_mode}'.") + + _require_image_tensor("background_image", background_image) + _require_image_tensor("overlay_image", overlay_image) + clamped_scale = max(0.01, float(scale)) + clamped_opacity = max(0.0, min(1.0, float(opacity))) + if clamped_opacity == 0.0: + return background_image[..., :3].clone() + + background_batch = int(background_image.shape[0]) + overlay_batch = int(overlay_image.shape[0]) + if overlay_batch not in {1, background_batch}: + raise RuntimeError( + "[LLS] overlay_image batch must be 1 or match background_image batch; " + f"got {overlay_batch} and {background_batch}." + ) + + results = [] + for index in range(background_batch): + background_sample = background_image[index] + overlay_sample = overlay_image[0 if overlay_batch == 1 else index] + background_rgba = _tensor_sample_to_rgba_pil(background_sample) + overlay_rgba = _scaled_overlay_rgba(_tensor_sample_to_rgba_pil(overlay_sample), clamped_scale) + top_left_x, top_left_y = _resolve_top_left( + overlay_rgba.size[0], + overlay_rgba.size[1], + int(x_offset), + int(y_offset), + anchor_mode, + ) + + overlay_canvas = Image.new("RGBA", background_rgba.size, (0, 0, 0, 0)) + overlay_canvas.paste(overlay_rgba, (top_left_x, top_left_y), overlay_rgba) + + if float(rotation) != 0.0: + rotation_center = _resolve_rotation_center( + top_left_x, + top_left_y, + overlay_rgba.size[0], + overlay_rgba.size[1], + rotation_origin_mode, + ) + overlay_canvas = overlay_canvas.rotate( + float(rotation), + resample=Image.BICUBIC, + center=rotation_center, + expand=False, + ) + + if clamped_opacity < 1.0: + overlay_alpha = overlay_canvas.getchannel("A") + overlay_alpha = overlay_alpha.point(lambda value: int(round(value * clamped_opacity))) + overlay_canvas.putalpha(overlay_alpha) + + composed = Image.alpha_composite(background_rgba, overlay_canvas) + results.append( + _pil_to_tensor( + composed, + device=background_image.device, + dtype=background_image.dtype, + ) + ) + + return torch.stack(results, dim=0) diff --git a/mask/mask_create.py b/mask/mask_create.py index d53b20d..a130835 100644 --- a/mask/mask_create.py +++ b/mask/mask_create.py @@ -27,8 +27,8 @@ def _resolve_batch_for_mask_creation(input_mask) -> int: class LLSSimpleMaskCreate: CATEGORY = "LLS/Mask" FUNCTION = "create_mask" - RETURN_TYPES = ("IMAGE", "MASK", MASK_INFO_TYPE) - RETURN_NAMES = ("mask_image", "mask", "area_info") + RETURN_TYPES = ("MASK", "IMAGE", MASK_INFO_TYPE) + RETURN_NAMES = ("mask", "mask_image", "area_info") DESCRIPTION = "Create a geometric mask image from width, height, and shape parameters." @classmethod diff --git a/tests/test_image_composite_docs.py b/tests/test_image_composite_docs.py new file mode 100644 index 0000000..d57595b --- /dev/null +++ b/tests/test_image_composite_docs.py @@ -0,0 +1,28 @@ +import unittest + +try: + from .test_image_composite_helpers import ROOT +except ImportError: + from test_image_composite_helpers import ROOT + + +class TestImageCompositeDocs(unittest.TestCase): + def test_readme_documents_image_composite_node(self): + readme = (ROOT / "README.md").read_text(encoding="utf-8") + + self.assertIn("LLS Simple Image Composite", readme) + self.assertIn("background_image", readme) + self.assertIn("overlay_image", readme) + self.assertIn("output_image", readme) + self.assertIn("x_offset", readme) + self.assertIn("y_offset", readme) + self.assertIn("anchor_mode", readme) + self.assertIn("rotation_origin_mode", readme) + self.assertIn("opacity", readme) + self.assertIn("scale", readme) + self.assertIn("rotation", readme) + self.assertIn("keep_aspect", readme) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_image_composite_frontend.py b/tests/test_image_composite_frontend.py new file mode 100644 index 0000000..30b165b --- /dev/null +++ b/tests/test_image_composite_frontend.py @@ -0,0 +1,32 @@ +import unittest + +try: + from .test_image_composite_helpers import ROOT, load_plugin_package +except ImportError: + from test_image_composite_helpers import ROOT, load_plugin_package + + +class TestImageCompositeFrontend(unittest.TestCase): + def test_plugin_exports_web_directory(self): + plugin = load_plugin_package() + self.assertEqual(plugin.WEB_DIRECTORY, "./web") + + def test_frontend_asset_exists(self): + asset = ROOT / "web" / "js" / "lls_image_composite.js" + self.assertTrue(asset.exists(), msg=f"Missing frontend asset: {asset}") + + def test_frontend_asset_registers_image_composite_extension(self): + asset = (ROOT / "web" / "js" / "lls_image_composite.js").read_text(encoding="utf-8") + + self.assertIn("app.registerExtension", asset) + self.assertIn("LLSSimpleImageComposite", asset) + self.assertIn("LLS Simple Image Composite", asset) + self.assertIn("beforeRegisterNodeDef", asset) + self.assertIn("addDOMWidget", asset) + self.assertIn("pointerdown", asset) + self.assertIn("pointermove", asset) + self.assertIn("rotation_origin_mode", asset) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_image_composite_helpers.py b/tests/test_image_composite_helpers.py new file mode 100644 index 0000000..7bcc8c9 --- /dev/null +++ b/tests/test_image_composite_helpers.py @@ -0,0 +1,58 @@ +import importlib +import importlib.util +import pathlib +import sys +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +MODULE_NAME = "lls_node_test_image_composite" + + +def _import_optional(module_name): + try: + return importlib.import_module(module_name) + except Exception: + return None + + +torch = _import_optional("torch") +np = _import_optional("numpy") +_pil_image_module = _import_optional("PIL.Image") +Image = _pil_image_module +HAS_IMAGE_COMPOSITE_RUNTIME_DEPS = torch is not None and np is not None and Image is not None +IMAGE_COMPOSITE_RUNTIME_DEPS_MESSAGE = "image composite tests require torch, numpy, and Pillow" + + +def load_plugin_package(): + for name in list(sys.modules): + if name == MODULE_NAME or name.startswith(f"{MODULE_NAME}."): + sys.modules.pop(name) + + spec = importlib.util.spec_from_file_location( + MODULE_NAME, + ROOT / "__init__.py", + submodule_search_locations=[str(ROOT)], + ) + module = importlib.util.module_from_spec(spec) + sys.modules[MODULE_NAME] = module + spec.loader.exec_module(module) + return module + + +def require_image_composite_runtime_deps(): + if not HAS_IMAGE_COMPOSITE_RUNTIME_DEPS: + raise unittest.SkipTest(IMAGE_COMPOSITE_RUNTIME_DEPS_MESSAGE) + + +def make_image(width=8, height=8, color=(0.0, 0.0, 0.0), alpha=None): + require_image_composite_runtime_deps() + rgb = tuple(float(channel) for channel in color) + channels = 4 if alpha is not None else 3 + tensor = torch.zeros((1, height, width, channels), dtype=torch.float32) + tensor[..., 0] = rgb[0] + tensor[..., 1] = rgb[1] + tensor[..., 2] = rgb[2] + if alpha is not None: + tensor[..., 3] = float(alpha) + return tensor diff --git a/tests/test_image_composite_node.py b/tests/test_image_composite_node.py new file mode 100644 index 0000000..1351dc8 --- /dev/null +++ b/tests/test_image_composite_node.py @@ -0,0 +1,204 @@ +import unittest + +try: + from .test_image_composite_helpers import ( + HAS_IMAGE_COMPOSITE_RUNTIME_DEPS, + IMAGE_COMPOSITE_RUNTIME_DEPS_MESSAGE, + load_plugin_package, + make_image, + ) +except ImportError: + from test_image_composite_helpers import ( + HAS_IMAGE_COMPOSITE_RUNTIME_DEPS, + IMAGE_COMPOSITE_RUNTIME_DEPS_MESSAGE, + load_plugin_package, + make_image, + ) + + +@unittest.skipUnless(HAS_IMAGE_COMPOSITE_RUNTIME_DEPS, IMAGE_COMPOSITE_RUNTIME_DEPS_MESSAGE) +class TestImageCompositeNode(unittest.TestCase): + @classmethod + def setUpClass(cls): + plugin = load_plugin_package() + cls.node = plugin.NODE_CLASS_MAPPINGS["LLSSimpleImageComposite"]() + + def test_top_left_offset_places_overlay_at_requested_position(self): + background = make_image(width=6, height=6, color=(0.0, 0.0, 0.0)) + overlay = make_image(width=2, height=2, color=(1.0, 1.0, 1.0)) + + (output_image,) = self.node.composite( + background_image=background, + overlay_image=overlay, + x_offset=3, + y_offset=1, + anchor_mode="top_left", + rotation_origin_mode="center", + opacity=1.0, + blend_mode="normal", + scale=1.0, + rotation=0.0, + keep_aspect=True, + ) + + self.assertEqual(tuple(output_image.shape), (1, 6, 6, 3)) + self.assertTrue((output_image[0, 1:3, 3:5] > 0.99).all().item()) + self.assertEqual(float(output_image[0, 0, 0, 0].item()), 0.0) + + def test_center_anchor_places_overlay_by_its_center(self): + background = make_image(width=6, height=6, color=(0.0, 0.0, 0.0)) + overlay = make_image(width=2, height=2, color=(0.0, 1.0, 0.0)) + + (output_image,) = self.node.composite( + background_image=background, + overlay_image=overlay, + x_offset=3, + y_offset=2, + anchor_mode="center", + rotation_origin_mode="center", + opacity=1.0, + blend_mode="normal", + scale=1.0, + rotation=0.0, + keep_aspect=True, + ) + + self.assertTrue((output_image[0, 1:3, 2:4, 1] > 0.99).all().item()) + + def test_partial_overflow_is_clipped_to_background_bounds(self): + background = make_image(width=5, height=5, color=(0.0, 0.0, 0.0)) + overlay = make_image(width=3, height=3, color=(1.0, 0.0, 0.0)) + + (output_image,) = self.node.composite( + background_image=background, + overlay_image=overlay, + x_offset=4, + y_offset=4, + anchor_mode="top_left", + rotation_origin_mode="center", + opacity=1.0, + blend_mode="normal", + scale=1.0, + rotation=0.0, + keep_aspect=True, + ) + + self.assertGreater(float(output_image[0, 4, 4, 0].item()), 0.99) + self.assertEqual(float(output_image[0, 0, 0, 0].item()), 0.0) + + def test_overlay_fully_outside_returns_background(self): + background = make_image(width=5, height=5, color=(0.1, 0.1, 0.1)) + overlay = make_image(width=2, height=2, color=(1.0, 1.0, 1.0)) + + (output_image,) = self.node.composite( + background_image=background, + overlay_image=overlay, + x_offset=8, + y_offset=8, + anchor_mode="top_left", + rotation_origin_mode="center", + opacity=1.0, + blend_mode="normal", + scale=1.0, + rotation=0.0, + keep_aspect=True, + ) + + self.assertTrue((output_image == background).all().item()) + + def test_opacity_zero_returns_background(self): + background = make_image(width=4, height=4, color=(0.2, 0.2, 0.2)) + overlay = make_image(width=2, height=2, color=(1.0, 0.0, 0.0)) + + (output_image,) = self.node.composite( + background_image=background, + overlay_image=overlay, + x_offset=1, + y_offset=1, + anchor_mode="top_left", + rotation_origin_mode="center", + opacity=0.0, + blend_mode="normal", + scale=1.0, + rotation=0.0, + keep_aspect=True, + ) + + self.assertTrue((output_image == background).all().item()) + + def test_rgba_overlay_uses_alpha_and_opacity(self): + background = make_image(width=4, height=4, color=(0.0, 0.0, 0.0)) + overlay = make_image(width=2, height=2, color=(1.0, 0.0, 0.0), alpha=0.5) + + (output_image,) = self.node.composite( + background_image=background, + overlay_image=overlay, + x_offset=1, + y_offset=1, + anchor_mode="top_left", + rotation_origin_mode="center", + opacity=0.5, + blend_mode="normal", + scale=1.0, + rotation=0.0, + keep_aspect=True, + ) + + self.assertAlmostEqual(float(output_image[0, 1, 1, 0].item()), 0.25, places=2) + + def test_scale_expands_overlay_footprint(self): + background = make_image(width=5, height=5, color=(0.0, 0.0, 0.0)) + overlay = make_image(width=1, height=1, color=(1.0, 1.0, 1.0)) + + (output_image,) = self.node.composite( + background_image=background, + overlay_image=overlay, + x_offset=1, + y_offset=1, + anchor_mode="top_left", + rotation_origin_mode="center", + opacity=1.0, + blend_mode="normal", + scale=2.0, + rotation=0.0, + keep_aspect=True, + ) + + self.assertTrue((output_image[0, 1:3, 1:3] > 0.99).all().item()) + + def test_rotation_origin_mode_changes_rotated_result(self): + background = make_image(width=8, height=8, color=(0.0, 0.0, 0.0)) + overlay = make_image(width=3, height=1, color=(0.0, 0.0, 1.0)) + + (center_rotated,) = self.node.composite( + background_image=background, + overlay_image=overlay, + x_offset=3, + y_offset=3, + anchor_mode="top_left", + rotation_origin_mode="center", + opacity=1.0, + blend_mode="normal", + scale=1.0, + rotation=90.0, + keep_aspect=True, + ) + (corner_rotated,) = self.node.composite( + background_image=background, + overlay_image=overlay, + x_offset=3, + y_offset=3, + anchor_mode="top_left", + rotation_origin_mode="top_left", + opacity=1.0, + blend_mode="normal", + scale=1.0, + rotation=90.0, + keep_aspect=True, + ) + + self.assertFalse((center_rotated == corner_rotated).all().item()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_image_composite_registration.py b/tests/test_image_composite_registration.py new file mode 100644 index 0000000..dc98bd6 --- /dev/null +++ b/tests/test_image_composite_registration.py @@ -0,0 +1,45 @@ +import unittest + +try: + from .test_image_composite_helpers import load_plugin_package +except ImportError: + from test_image_composite_helpers import load_plugin_package + + +class TestImageCompositeRegistration(unittest.TestCase): + def test_plugin_registers_image_composite_node(self): + plugin = load_plugin_package() + + self.assertIn("LLSSimpleImageComposite", plugin.NODE_CLASS_MAPPINGS) + self.assertEqual( + plugin.NODE_DISPLAY_NAME_MAPPINGS["LLSSimpleImageComposite"], + "LLS Simple Image Composite", + ) + + def test_image_composite_schema_matches_contract(self): + plugin = load_plugin_package() + node_cls = plugin.NODE_CLASS_MAPPINGS["LLSSimpleImageComposite"] + schema = node_cls.INPUT_TYPES() + required = schema["required"] + + self.assertEqual(node_cls.CATEGORY, "LLS/Image") + self.assertEqual(node_cls.FUNCTION, "composite") + self.assertEqual(node_cls.RETURN_TYPES, ("IMAGE",)) + self.assertEqual(node_cls.RETURN_NAMES, ("output_image",)) + self.assertEqual(required["background_image"], ("IMAGE",)) + self.assertEqual(required["overlay_image"], ("IMAGE",)) + self.assertEqual(required["x_offset"][0], "INT") + self.assertEqual(required["x_offset"][1]["default"], 0) + self.assertEqual(required["y_offset"][0], "INT") + self.assertEqual(required["anchor_mode"][0], ["top_left", "center"]) + self.assertEqual(required["rotation_origin_mode"][0], ["top_left", "center"]) + self.assertEqual(required["opacity"][0], "FLOAT") + self.assertEqual(required["opacity"][1]["default"], 1.0) + self.assertEqual(required["blend_mode"][0], ["normal"]) + self.assertEqual(required["scale"][0], "FLOAT") + self.assertEqual(required["rotation"][0], "FLOAT") + self.assertEqual(required["keep_aspect"][0], "BOOLEAN") + + +if __name__ == "__main__": + unittest.main() diff --git a/web/js/lls_image_composite.js b/web/js/lls_image_composite.js new file mode 100644 index 0000000..a605303 --- /dev/null +++ b/web/js/lls_image_composite.js @@ -0,0 +1,469 @@ +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; + +const EXTENSION_NAME = "LLS.ImageComposite"; +const TARGET_NODE_CLASS = "LLSSimpleImageComposite"; +const TARGET_NODE_DISPLAY_NAME = "LLS Simple Image Composite"; +const MAX_PREVIEW_EDGE = 420; +const MIN_PREVIEW_EDGE = 220; + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + +function asNumber(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function findWidget(node, name) { + return node.widgets?.find((widget) => widget.name === name) ?? null; +} + +function getNodeClassName(node) { + return String(node?.comfyClass || node?.constructor?.comfyClass || node?.type || node?.title || ""); +} + +function chainWidgetCallback(widget, handler) { + if (!widget || widget.__llsImageCompositeChained) { + return; + } + + const previous = widget.callback; + widget.callback = function chainedImageCompositeCallback() { + previous?.apply(this, arguments); + handler?.apply(this, arguments); + }; + widget.__llsImageCompositeChained = true; +} + +function normalizePath(rawValue) { + return String(rawValue || "").trim().replace(/\\/g, "/"); +} + +function splitAnnotatedPath(rawValue) { + const normalized = normalizePath(rawValue); + const match = normalized.match(/^(.*?)(?:\s+\[([^\]]+)\])?$/); + const path = match?.[1] ?? normalized; + const annotatedType = match?.[2] ?? ""; + const slashIndex = path.lastIndexOf("/"); + return { + path, + annotatedType, + subfolder: slashIndex >= 0 ? path.slice(0, slashIndex) : "", + filename: slashIndex >= 0 ? path.slice(slashIndex + 1) : path, + }; +} + +function buildViewUrl(rawValue, fallbackType) { + const normalized = normalizePath(rawValue); + if (!normalized) { + return null; + } + + const params = new URLSearchParams(); + if (normalized.startsWith("blake3:")) { + params.set("filename", normalized); + } else { + const parsed = splitAnnotatedPath(normalized); + if (!parsed.filename) { + return null; + } + params.set("filename", parsed.filename); + if (parsed.subfolder) { + params.set("subfolder", parsed.subfolder); + } + params.set("type", parsed.annotatedType || fallbackType || "input"); + } + + const relativePath = `/view?${params.toString()}`; + return typeof api?.apiURL === "function" ? api.apiURL(relativePath) : relativePath; +} + +function getInputLink(node, inputName) { + const input = node.inputs?.find((item) => item.name === inputName); + if (!input || input.link == null) { + return null; + } + return node.graph?.links?.[input.link] ?? null; +} + +function getUpstreamNode(node, inputName) { + const link = getInputLink(node, inputName); + if (!link) { + return null; + } + return node.graph?.getNodeById?.(link.origin_id) ?? null; +} + +function resolveImageSource(node, inputName) { + const upstream = getUpstreamNode(node, inputName); + const className = getNodeClassName(upstream); + const imageWidget = findWidget(upstream, "image"); + if (!imageWidget?.value) { + return null; + } + + if (className === "LoadImage") { + return { + url: buildViewUrl(imageWidget.value, "input"), + label: "LoadImage", + }; + } + + if (className === "LoadImageOutput") { + return { + url: buildViewUrl(imageWidget.value, "output"), + label: "LoadImageOutput", + }; + } + + return null; +} + +function loadImageElement(url) { + return new Promise((resolve, reject) => { + if (!url) { + resolve(null); + return; + } + + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(new Error(`Failed to load image: ${url}`)); + image.src = url; + }); +} + +function computeDisplaySize(width, height) { + const safeWidth = Math.max(1, Math.round(width)); + const safeHeight = Math.max(1, Math.round(height)); + const maxEdge = Math.max(safeWidth, safeHeight); + let scale = 1; + + if (maxEdge > MAX_PREVIEW_EDGE) { + scale = MAX_PREVIEW_EDGE / maxEdge; + } else if (maxEdge < MIN_PREVIEW_EDGE) { + scale = MIN_PREVIEW_EDGE / maxEdge; + } + + return { + width: Math.max(1, Math.round(safeWidth * scale)), + height: Math.max(1, Math.round(safeHeight * scale)), + }; +} + +function drawHint(context, lines) { + context.clearRect(0, 0, context.canvas.width, context.canvas.height); + context.fillStyle = "#151515"; + context.fillRect(0, 0, context.canvas.width, context.canvas.height); + context.fillStyle = "#b7b7b7"; + context.font = "12px sans-serif"; + lines.forEach((line, index) => { + context.fillText(line, 14, 24 + index * 18); + }); +} + +function attachCompositePreview(node) { + if (node.__llsImageCompositePreview || typeof node.addDOMWidget !== "function") { + return node.__llsImageCompositePreview ?? null; + } + + const xWidget = findWidget(node, "x_offset"); + const yWidget = findWidget(node, "y_offset"); + const anchorWidget = findWidget(node, "anchor_mode"); + const rotationOriginWidget = findWidget(node, "rotation_origin_mode"); + const opacityWidget = findWidget(node, "opacity"); + const scaleWidget = findWidget(node, "scale"); + const rotationWidget = findWidget(node, "rotation"); + + const container = document.createElement("div"); + container.style.display = "flex"; + container.style.flexDirection = "column"; + container.style.alignItems = "flex-start"; + container.style.gap = "8px"; + container.style.width = "100%"; + container.style.boxSizing = "border-box"; + + const frame = document.createElement("div"); + frame.style.display = "inline-flex"; + frame.style.border = "1px solid #4e4e4e"; + frame.style.borderRadius = "8px"; + frame.style.overflow = "hidden"; + frame.style.background = "#111"; + + const canvas = document.createElement("canvas"); + canvas.style.display = "block"; + canvas.style.cursor = "grab"; + canvas.style.touchAction = "none"; + canvas.style.userSelect = "none"; + frame.appendChild(canvas); + + const status = document.createElement("div"); + status.style.fontSize = "12px"; + status.style.lineHeight = "1.45"; + status.style.color = "#b8b8b8"; + status.style.maxWidth = "420px"; + status.style.whiteSpace = "normal"; + + container.append(frame, status); + + const domWidget = node.addDOMWidget("lls_image_composite_preview", "preview", container); + domWidget.serialize = false; + if (domWidget.options) { + domWidget.options.hideOnZoom = false; + } + + const context = canvas.getContext("2d"); + + const state = { + backgroundImage: null, + overlayImage: null, + backgroundSource: null, + overlaySource: null, + backgroundConnectionSupported: true, + overlayConnectionSupported: true, + loadToken: 0, + dragging: false, + dragStartClientX: 0, + dragStartClientY: 0, + dragStartOffsetX: 0, + dragStartOffsetY: 0, + }; + + function updateStatus(message) { + status.textContent = message; + } + + function updateNodeSize() { + const width = state.backgroundImage?.naturalWidth || 320; + const height = state.backgroundImage?.naturalHeight || 220; + const displaySize = computeDisplaySize(width, height); + + canvas.width = displaySize.width; + canvas.height = displaySize.height; + canvas.style.width = `${displaySize.width}px`; + canvas.style.height = `${displaySize.height}px`; + frame.style.width = `${displaySize.width}px`; + container.style.width = `${displaySize.width}px`; + + if (typeof node.setSize === "function") { + requestAnimationFrame(() => { + node.setSize([ + Math.max(360, displaySize.width + 28), + Math.max(250, displaySize.height + 84), + ]); + app.graph?.setDirtyCanvas(true, true); + }); + } + } + + function renderPreview() { + updateNodeSize(); + + const backgroundImage = state.backgroundImage; + const overlayImage = state.overlayImage; + if (!backgroundImage || !overlayImage) { + const lines = []; + if (!getInputLink(node, "background_image") || !getInputLink(node, "overlay_image")) { + lines.push("Connect background_image and overlay_image"); + lines.push("from Load Image / Load Image Output for preview."); + } else { + lines.push("Preview supports Load Image / Load Image Output"); + lines.push("sources in this first version."); + } + drawHint(context, lines); + return; + } + + context.clearRect(0, 0, canvas.width, canvas.height); + context.drawImage(backgroundImage, 0, 0, canvas.width, canvas.height); + + const backgroundWidth = Math.max(1, backgroundImage.naturalWidth || backgroundImage.width || canvas.width); + const backgroundHeight = Math.max(1, backgroundImage.naturalHeight || backgroundImage.height || canvas.height); + const canvasScaleX = canvas.width / backgroundWidth; + const canvasScaleY = canvas.height / backgroundHeight; + const scaleValue = Math.max(0.01, asNumber(scaleWidget?.value, 1)); + const rotationValue = asNumber(rotationWidget?.value, 0); + const opacityValue = clamp(asNumber(opacityWidget?.value, 1), 0, 1); + const anchorMode = String(anchorWidget?.value || "top_left"); + const rotationOriginMode = String(rotationOriginWidget?.value || "center"); + const xOffset = asNumber(xWidget?.value, 0); + const yOffset = asNumber(yWidget?.value, 0); + + const overlayWidth = Math.max(1, (overlayImage.naturalWidth || overlayImage.width || 1) * scaleValue); + const overlayHeight = Math.max(1, (overlayImage.naturalHeight || overlayImage.height || 1) * scaleValue); + const topLeftX = anchorMode === "center" ? xOffset - overlayWidth / 2 : xOffset; + const topLeftY = anchorMode === "center" ? yOffset - overlayHeight / 2 : yOffset; + const rotationCenterX = rotationOriginMode === "center" ? topLeftX + overlayWidth / 2 : topLeftX; + const rotationCenterY = rotationOriginMode === "center" ? topLeftY + overlayHeight / 2 : topLeftY; + + context.save(); + context.globalAlpha = opacityValue; + context.translate(rotationCenterX * canvasScaleX, rotationCenterY * canvasScaleY); + context.rotate((rotationValue * Math.PI) / 180); + context.translate(-rotationCenterX * canvasScaleX, -rotationCenterY * canvasScaleY); + context.drawImage( + overlayImage, + topLeftX * canvasScaleX, + topLeftY * canvasScaleY, + overlayWidth * canvasScaleX, + overlayHeight * canvasScaleY, + ); + context.restore(); + } + + function setWidgetValue(widget, value) { + if (!widget) { + return; + } + widget.value = value; + widget.callback?.(value); + } + + async function reloadSources() { + const backgroundSource = resolveImageSource(node, "background_image"); + const overlaySource = resolveImageSource(node, "overlay_image"); + state.backgroundSource = backgroundSource?.url ?? null; + state.overlaySource = overlaySource?.url ?? null; + state.backgroundConnectionSupported = !getInputLink(node, "background_image") || Boolean(backgroundSource?.url); + state.overlayConnectionSupported = !getInputLink(node, "overlay_image") || Boolean(overlaySource?.url); + + const token = ++state.loadToken; + updateStatus("Loading composite preview..."); + + try { + const [backgroundImage, overlayImage] = await Promise.all([ + loadImageElement(state.backgroundSource), + loadImageElement(state.overlaySource), + ]); + if (token !== state.loadToken) { + return; + } + + state.backgroundImage = backgroundImage; + state.overlayImage = overlayImage; + if (backgroundImage && overlayImage) { + const backgroundLabel = backgroundSource?.label || "background"; + const overlayLabel = overlaySource?.label || "overlay"; + updateStatus( + `Drag to update x_offset / y_offset. Preview source: ${backgroundLabel} + ${overlayLabel}. ` + + "Use scale, rotation, opacity, anchor_mode, and rotation_origin_mode widgets to refine placement.", + ); + } else if (!state.backgroundConnectionSupported || !state.overlayConnectionSupported) { + updateStatus("Preview supports Load Image / Load Image Output sources in this first version."); + } else { + updateStatus("Connect Load Image / Load Image Output to both inputs for preview."); + } + renderPreview(); + } catch (error) { + if (token !== state.loadToken) { + return; + } + state.backgroundImage = null; + state.overlayImage = null; + updateStatus(error?.message || "Failed to load composite preview sources."); + renderPreview(); + } + } + + function handlePointerDown(event) { + if (!state.backgroundImage || !state.overlayImage) { + return; + } + + state.dragging = true; + state.dragStartClientX = event.clientX; + state.dragStartClientY = event.clientY; + state.dragStartOffsetX = asNumber(xWidget?.value, 0); + state.dragStartOffsetY = asNumber(yWidget?.value, 0); + canvas.style.cursor = "grabbing"; + canvas.setPointerCapture?.(event.pointerId); + } + + function handlePointerMove(event) { + if (!state.dragging || !state.backgroundImage) { + return; + } + + const backgroundWidth = Math.max(1, state.backgroundImage.naturalWidth || state.backgroundImage.width || canvas.width); + const backgroundHeight = Math.max(1, state.backgroundImage.naturalHeight || state.backgroundImage.height || canvas.height); + const deltaX = Math.round((event.clientX - state.dragStartClientX) * (backgroundWidth / canvas.width)); + const deltaY = Math.round((event.clientY - state.dragStartClientY) * (backgroundHeight / canvas.height)); + setWidgetValue(xWidget, state.dragStartOffsetX + deltaX); + setWidgetValue(yWidget, state.dragStartOffsetY + deltaY); + renderPreview(); + node.setDirtyCanvas?.(true, true); + } + + function endDrag(event) { + if (!state.dragging) { + return; + } + state.dragging = false; + canvas.style.cursor = "grab"; + if (event?.pointerId != null) { + canvas.releasePointerCapture?.(event.pointerId); + } + } + + canvas.addEventListener("pointerdown", handlePointerDown); + canvas.addEventListener("pointermove", handlePointerMove); + canvas.addEventListener("pointerup", endDrag); + canvas.addEventListener("pointercancel", endDrag); + canvas.addEventListener("pointerleave", endDrag); + + [ + xWidget, + yWidget, + anchorWidget, + rotationOriginWidget, + opacityWidget, + scaleWidget, + rotationWidget, + ].forEach((widget) => chainWidgetCallback(widget, renderPreview)); + + const apiHandle = { + handleGraphConfigured() { + void reloadSources(); + }, + handleConnectionsChange() { + void reloadSources(); + }, + }; + + node.__llsImageCompositePreview = apiHandle; + updateStatus("Connect Load Image / Load Image Output to both inputs for preview."); + renderPreview(); + return apiHandle; +} + +app.registerExtension({ + name: EXTENSION_NAME, + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData.name !== TARGET_NODE_CLASS && nodeData.display_name !== TARGET_NODE_DISPLAY_NAME) { + return; + } + + const previousOnNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function onImageCompositeNodeCreated() { + const result = previousOnNodeCreated?.apply(this, arguments); + attachCompositePreview(this); + return result; + }; + + const previousOnGraphConfigured = nodeType.prototype.onGraphConfigured; + nodeType.prototype.onGraphConfigured = function onImageCompositeGraphConfigured() { + const result = previousOnGraphConfigured?.apply(this, arguments); + attachCompositePreview(this)?.handleGraphConfigured?.(); + return result; + }; + + const previousOnConnectionsChange = nodeType.prototype.onConnectionsChange; + nodeType.prototype.onConnectionsChange = function onImageCompositeConnectionsChange() { + const result = previousOnConnectionsChange?.apply(this, arguments); + attachCompositePreview(this)?.handleConnectionsChange?.(); + return result; + }; + }, +});