185 lines
6.3 KiB
Python
185 lines
6.3 KiB
Python
"""
|
|
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",
|
|
}
|