Merge pull request #13 from LuqP2/feat/metahub-input-bridge-pr

Add MetaHub Input bridge node
This commit is contained in:
Lucas
2026-06-27 14:52:40 -03:00
committed by GitHub
8 changed files with 316 additions and 6 deletions
+30 -1
View File
@@ -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
+4
View File
@@ -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",
+2 -2
View File
@@ -3,7 +3,7 @@ Compatibility shim for MetaHub Save Node metadata helpers.
The implementation is kept in metadata_utils_impl.py; this module exposes the
same public API expected by the save nodes while pinning the published node
version to 1.1.8.
version to 1.1.9.
"""
try:
@@ -11,7 +11,7 @@ try:
except ImportError:
import metadata_utils_impl as _impl
METAHUB_SAVE_NODE_VERSION = "1.1.8"
METAHUB_SAVE_NODE_VERSION = "1.1.9"
_impl.METAHUB_SAVE_NODE_VERSION = METAHUB_SAVE_NODE_VERSION
for _name in dir(_impl):
+1 -1
View File
@@ -14,7 +14,7 @@ from xml.sax.saxutils import escape as xml_escape
import numpy as np
from PIL import Image, PngImagePlugin
METAHUB_SAVE_NODE_VERSION = "1.1.8"
METAHUB_SAVE_NODE_VERSION = "1.1.9"
try:
import piexif
+184
View File
@@ -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",
}
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "imagemetahub-comfyui-save"
description = "Official companion node for Image MetaHub. Saves A1111/Civitai-compatible parameters plus extended Image MetaHub metadata (workflow JSON, SHA256 model hashes, VRAM peak/total, GPU device, generation time, and steps/sec) for reproducibility and benchmarking."
version = "1.1.8"
version = "1.1.9"
license = {file = "LICENSE"}
# classifiers = [
# # For OS-independent nodes (works on all operating systems)
+1 -1
View File
@@ -334,7 +334,7 @@ def test_extract_workflow_attribution_from_node_properties():
assert attribution["token"] == "imhcrt_br_creator_workflow_v1_random"
assert attribution["source"] == "metahub_save_node"
assert attribution["node_version"] == "1.1.8"
assert attribution["node_version"] == "1.1.9"
def test_build_imh_metadata_includes_attribution_without_a1111_parameters():
+93
View File
@@ -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