Merge pull request #13 from LuqP2/feat/metahub-input-bridge-pr
Add MetaHub Input bridge node
This commit is contained in:
@@ -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,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
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user