diff --git a/README.md b/README.md index a5beaf3..06fca2d 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Advanced image saving node for ComfyUI with dual metadata support. - **Filename Patterns** - Placeholder-based filenames with sanitization - **A1111/Civitai Compatible** - Saves metadata in tEXt chunk ("parameters") recognized by Automatic1111, Civitai, and most SD tools - **Image MetaHub Compatible** - Saves extended metadata in iTXt chunk ("imagemetahub_data") with full workflow JSON +- **MetaHub Input Bridge** - Loads prepared image/mask/outpaint assets from Image MetaHub into your own ComfyUI workflows - **Model Hashes** - Calculates SHA256 hashes (AutoV2 format) for models and LoRAs - **IMH Pro Fields** - Support for user tags, notes, and project names - **Performance** - Hash caching and graceful degradation ensure fast generation @@ -96,6 +97,34 @@ image is still saved and the metadata is marked as `partial`. In that case, connect only the missing override sockets you care about, such as `positive`, `seed`, or `model_name`. +### MetaHub Input Bridge + +Use **MetaHub Input** when you prepare an existing image in Image MetaHub and +want to continue it inside your own ComfyUI workflow. + +1. In Image MetaHub, open an image and choose **Prepare for Generation**. +2. Paint a mask or expand the canvas if needed. +3. Click **Send to ComfyUI Bridge**. +4. In ComfyUI, add **MetaHub Input** to your workflow. +5. Leave `bridge_dir` blank to use the shared default folder, or paste the + bridge folder shown in Image MetaHub settings. +6. Connect `image` and `mask` wherever your workflow expects prepared inputs. + +MetaHub Input reads from: + +```text +~/ImageMetaHub/comfyui_bridge/latest/ + image.png + mask.png + metadata.json +``` + +Set `session_id` to `latest` for the newest bridge payload, or to a folder name +under `sessions/` for a specific prepared payload. If Image MetaHub did not send +a mask, MetaHub Input returns an all-black mask matching the image dimensions. +The node also outputs `metadata_json`, `denoise`, `intent`, `source_path`, +`session_id`, `width`, and `height`. + ### Override (Optional) The full node is still available as **MetaHub Save Image Advanced**. Use it only @@ -298,7 +327,7 @@ The Performance section in Image MetaHub App displays: - Steps per second benchmark - Software versions (ComfyUI, PyTorch, Python) -**Generating Variations**: Image MetaHub App includes a "Generate with ComfyUI" feature that creates simple txt2img workflows from saved metadata and sends them to your ComfyUI instance via API. See the [Image MetaHub documentation](https://github.com/LuqP2/Image-MetaHub) for details on workflow generation and ComfyUI integration features. +**Continuing Images in ComfyUI**: Image MetaHub's primary custom-workflow path is the local ComfyUI Bridge. Prepare the image/mask/outpaint canvas in Image MetaHub, send it to the bridge, then load it with **MetaHub Input** inside your own workflow. Image MetaHub may also offer a quick generated workflow for simple fallback runs, but it does not rewrite arbitrary custom ComfyUI graphs. ## Troubleshooting diff --git a/__init__.py b/__init__.py index 75c780d..3466256 100644 --- a/__init__.py +++ b/__init__.py @@ -4,15 +4,18 @@ Entry point for node registration """ try: + from .metahub_input_node import MetaHubInputNode from .metahub_save_node import MetaHubSaveImage, MetaHubSaveNode from .metahub_save_video_node import MetaHubSaveVideoNode from .timer_node import MetaHubTimerNode except ImportError: + from metahub_input_node import MetaHubInputNode from metahub_save_node import MetaHubSaveImage, MetaHubSaveNode from metahub_save_video_node import MetaHubSaveVideoNode from timer_node import MetaHubTimerNode NODE_CLASS_MAPPINGS = { + "MetaHubInputNode": MetaHubInputNode, "MetaHubSaveImage": MetaHubSaveImage, "MetaHubSaveNode": MetaHubSaveNode, "MetaHubSaveVideoNode": MetaHubSaveVideoNode, @@ -20,6 +23,7 @@ NODE_CLASS_MAPPINGS = { } NODE_DISPLAY_NAME_MAPPINGS = { + "MetaHubInputNode": "MetaHub Input", "MetaHubSaveImage": "MetaHub Save Image", "MetaHubSaveNode": "MetaHub Save Image Advanced", "MetaHubSaveVideoNode": "MetaHub Save Video", diff --git a/metahub_input_node.py b/metahub_input_node.py new file mode 100644 index 0000000..8b3e97b --- /dev/null +++ b/metahub_input_node.py @@ -0,0 +1,184 @@ +""" +MetaHub Input Node for ComfyUI +Reads prepared Image MetaHub bridge assets from local disk. +""" + +import hashlib +import json +from pathlib import Path + +import numpy as np +import torch +from PIL import Image, ImageOps + + +BRIDGE_IMAGE_NAME = "image.png" +BRIDGE_MASK_NAME = "mask.png" +BRIDGE_METADATA_NAME = "metadata.json" + + +def resolve_default_bridge_dir() -> Path: + return Path.home() / "ImageMetaHub" / "comfyui_bridge" + + +def resolve_bridge_dir(bridge_dir: str = "") -> Path: + raw_dir = (bridge_dir or "").strip() + if not raw_dir: + return resolve_default_bridge_dir() + return Path(raw_dir).expanduser().resolve() + + +def sanitize_session_id(session_id: str = "latest") -> str: + normalized = (session_id or "latest").strip() or "latest" + if "/" in normalized or "\\" in normalized or normalized in {".", ".."}: + raise ValueError("MetaHub Input session_id cannot contain path separators.") + return normalized + + +def resolve_payload_dir(bridge_dir: str = "", session_id: str = "latest") -> Path: + root = resolve_bridge_dir(bridge_dir) + normalized_session = sanitize_session_id(session_id) + if normalized_session == "latest": + return root / "latest" + return root / "sessions" / normalized_session + + +def read_metadata(payload_dir: Path) -> tuple[dict, str]: + metadata_path = payload_dir / BRIDGE_METADATA_NAME + if not metadata_path.exists(): + raise FileNotFoundError( + f"MetaHub Input could not find {metadata_path}. " + "Use Send to ComfyUI Bridge in Image MetaHub first, or set bridge_dir." + ) + metadata_json = metadata_path.read_text(encoding="utf-8") + try: + metadata = json.loads(metadata_json) + except json.JSONDecodeError as error: + raise ValueError(f"MetaHub Input metadata.json is invalid JSON: {error}") from error + return metadata, metadata_json + + +def image_to_tensor(image_path: Path) -> torch.Tensor: + if not image_path.exists(): + raise FileNotFoundError(f"MetaHub Input could not find prepared image: {image_path}") + image = ImageOps.exif_transpose(Image.open(image_path)).convert("RGB") + array = np.asarray(image).astype(np.float32) / 255.0 + return torch.from_numpy(array)[None,] + + +def mask_to_tensor(mask_path: Path | None, width: int, height: int) -> torch.Tensor: + if mask_path is None or not mask_path.exists(): + return torch.zeros((1, height, width), dtype=torch.float32) + mask = ImageOps.exif_transpose(Image.open(mask_path)).convert("L") + if mask.size != (width, height): + mask = mask.resize((width, height), Image.Resampling.NEAREST) + array = np.asarray(mask).astype(np.float32) / 255.0 + return torch.from_numpy(array)[None,] + + +def file_digest(path: Path) -> str: + if not path.exists(): + return f"missing:{path}" + digest = hashlib.sha256() + digest.update(str(path).encode("utf-8", errors="replace")) + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def payload_digest(bridge_dir: str = "", session_id: str = "latest") -> str: + try: + payload_dir = resolve_payload_dir(bridge_dir, session_id) + metadata_path = payload_dir / BRIDGE_METADATA_NAME + image_path = payload_dir / BRIDGE_IMAGE_NAME + digest_parts = [ + file_digest(metadata_path), + file_digest(image_path), + ] + try: + metadata, _metadata_json = read_metadata(payload_dir) + mask_available = bool(metadata.get("files", {}).get("mask", {}).get("available")) + except Exception: + mask_available = False + if mask_available: + digest_parts.append(file_digest(payload_dir / BRIDGE_MASK_NAME)) + return "|".join(digest_parts) + except Exception as error: + return f"error:{type(error).__name__}:{error}" + + +class MetaHubInputNode: + """ + Loads the latest prepared Image MetaHub bridge image, optional mask, and metadata. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "bridge_dir": ("STRING", { + "default": "", + "tooltip": "Image MetaHub bridge directory. Blank uses ~/ImageMetaHub/comfyui_bridge.", + }), + "session_id": ("STRING", { + "default": "latest", + "tooltip": "Use latest, or a session id from bridge/sessions.", + }), + }, + } + + RETURN_TYPES = ("IMAGE", "MASK", "STRING", "FLOAT", "STRING", "STRING", "STRING", "INT", "INT") + RETURN_NAMES = ("image", "mask", "metadata_json", "denoise", "intent", "source_path", "session_id", "width", "height") + FUNCTION = "load_bridge" + CATEGORY = "image/load" + DESCRIPTION = "Load prepared Image MetaHub image, mask, and metadata from the local ComfyUI Bridge." + OUTPUT_NODE = False + + @classmethod + def IS_CHANGED(cls, bridge_dir="", session_id="latest", **kwargs): + return payload_digest(bridge_dir, session_id) + + def load_bridge(self, bridge_dir="", session_id="latest"): + payload_dir = resolve_payload_dir(bridge_dir, session_id) + metadata, metadata_json = read_metadata(payload_dir) + image_path = payload_dir / BRIDGE_IMAGE_NAME + image = image_to_tensor(image_path) + height = int(image.shape[1]) + width = int(image.shape[2]) + + mask_available = bool(metadata.get("files", {}).get("mask", {}).get("available")) + mask_path = payload_dir / BRIDGE_MASK_NAME if mask_available else None + mask = mask_to_tensor(mask_path, width, height) + + denoise = metadata.get("denoise", 0.65) + try: + denoise = float(denoise) + except (TypeError, ValueError): + denoise = 0.65 + denoise = min(1.0, max(0.0, denoise)) + + intent = metadata.get("intent") or "img2img" + source_path = metadata.get("source", {}).get("path") or "" + resolved_session_id = metadata.get("session_id") or sanitize_session_id(session_id) + + return ( + image, + mask, + metadata_json, + denoise, + str(intent), + str(source_path), + str(resolved_session_id), + width, + height, + ) + + +NODE_CLASS_MAPPINGS = { + "MetaHubInputNode": MetaHubInputNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "MetaHubInputNode": "MetaHub Input", +} diff --git a/tests/test_metahub_input_node.py b/tests/test_metahub_input_node.py new file mode 100644 index 0000000..812a00d --- /dev/null +++ b/tests/test_metahub_input_node.py @@ -0,0 +1,93 @@ +import json +from pathlib import Path + +import pytest +import torch +from PIL import Image + +from metahub_input_node import MetaHubInputNode, payload_digest, resolve_payload_dir + + +def write_payload(root: Path, session_id="prep_test", mask=True, color=(10, 20, 30)): + payload_dir = root / "sessions" / session_id + latest_dir = root / "latest" + payload_dir.mkdir(parents=True, exist_ok=True) + latest_dir.mkdir(parents=True, exist_ok=True) + + metadata = { + "schema_version": 1, + "session_id": session_id, + "prepared_at": "2026-06-24T18:30:12.000Z", + "intent": "outpaint" if mask else "img2img", + "denoise": 0.72, + "files": { + "image": {"name": "image.png", "width": 3, "height": 2}, + "mask": {"name": "mask.png", "available": mask, "width": 3, "height": 2}, + }, + "source": {"path": "D:/images/source.png", "name": "source.png"}, + } + + image = Image.new("RGB", (3, 2), color=color) + mask_image = Image.new("L", (3, 2), color=255) + for directory in (payload_dir, latest_dir): + image.save(directory / "image.png") + if mask: + mask_image.save(directory / "mask.png") + (directory / "metadata.json").write_text(json.dumps(metadata), encoding="utf-8") + return metadata + + +def test_loads_latest_payload_with_mask(tmp_path): + metadata = write_payload(tmp_path, mask=True) + + result = MetaHubInputNode().load_bridge(str(tmp_path), "latest") + + image, mask, metadata_json, denoise, intent, source_path, session_id, width, height = result + assert tuple(image.shape) == (1, 2, 3, 3) + assert tuple(mask.shape) == (1, 2, 3) + assert torch.all(mask == 1) + assert json.loads(metadata_json)["session_id"] == metadata["session_id"] + assert denoise == pytest.approx(0.72) + assert intent == "outpaint" + assert source_path == "D:/images/source.png" + assert session_id == "prep_test" + assert (width, height) == (3, 2) + + +def test_missing_mask_returns_black_mask(tmp_path): + write_payload(tmp_path, session_id="prep_nomask", mask=False) + + image, mask, _metadata_json, _denoise, intent, _source_path, session_id, width, height = ( + MetaHubInputNode().load_bridge(str(tmp_path), "prep_nomask") + ) + + assert tuple(image.shape) == (1, 2, 3, 3) + assert tuple(mask.shape) == (1, 2, 3) + assert torch.all(mask == 0) + assert intent == "img2img" + assert session_id == "prep_nomask" + assert (width, height) == (3, 2) + + +def test_rejects_path_traversal_session_id(tmp_path): + with pytest.raises(ValueError): + resolve_payload_dir(str(tmp_path), "../bad") + + +def test_is_changed_digest_updates_when_payload_changes(tmp_path): + write_payload(tmp_path, mask=False, color=(10, 20, 30)) + first_digest = payload_digest(str(tmp_path), "latest") + + write_payload(tmp_path, session_id="prep_test_2", mask=False, color=(40, 50, 60)) + second_digest = payload_digest(str(tmp_path), "latest") + + assert first_digest != second_digest + + +def test_node_registration_includes_input_node(): + import __init__ as package + + assert package.NODE_CLASS_MAPPINGS["MetaHubInputNode"] is MetaHubInputNode + assert package.NODE_DISPLAY_NAME_MAPPINGS["MetaHubInputNode"] == "MetaHub Input" + assert "MetaHubSaveImage" in package.NODE_CLASS_MAPPINGS + assert "MetaHubTimerNode" in package.NODE_CLASS_MAPPINGS