diff --git a/__init__.py b/__init__.py index 82e099b..75c780d 100644 --- a/__init__.py +++ b/__init__.py @@ -26,4 +26,6 @@ NODE_DISPLAY_NAME_MAPPINGS = { "MetaHubTimerNode": "MetaHub Timer" } -__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] +WEB_DIRECTORY = "./js" + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] diff --git a/js/metahub_save_cta.js b/js/metahub_save_cta.js new file mode 100644 index 0000000..5a4a886 --- /dev/null +++ b/js/metahub_save_cta.js @@ -0,0 +1,65 @@ +import { app } from "../../scripts/app.js"; + +const METAHUB_SAVE_NODE_CLASSES = new Set([ + "MetaHubSaveImage", + "MetaHubSaveNode", + "MetaHubSaveVideoNode", +]); + +const IMAGE_METAHUB_URL = "https://www.imagemetahub.com/"; +const IMAGE_METAHUB_DEEPLINK = "imagemetahub://open"; + +function getWidgetValue(node, widgetName) { + const widget = node.widgets?.find((candidate) => candidate.name === widgetName); + const value = widget?.value; + return typeof value === "string" ? value.trim() : ""; +} + +function buildDeepLink(node) { + const outputPath = getWidgetValue(node, "output_path"); + const params = new URLSearchParams({ source: "comfyui-save-node" }); + + if (outputPath) { + params.set("path", outputPath); + } + + return `${IMAGE_METAHUB_DEEPLINK}?${params.toString()}`; +} + +function openUrl(url) { + window.open(url, "_blank", "noopener,noreferrer"); +} + +function addMetaHubCTA(node) { + if (!METAHUB_SAVE_NODE_CLASSES.has(node.comfyClass)) { + return; + } + + if (node.__imageMetaHubCTAAdded) { + return; + } + + node.__imageMetaHubCTAAdded = true; + + node.addWidget( + "button", + "Open in Image MetaHub", + null, + () => openUrl(buildDeepLink(node)) + ); + + node.addWidget( + "button", + "Download Image MetaHub", + null, + () => openUrl(IMAGE_METAHUB_URL) + ); +} + +app.registerExtension({ + name: "ImageMetaHub.SaveNodeCTA", + + async nodeCreated(node) { + addMetaHubCTA(node); + }, +}); diff --git a/metadata_utils.py b/metadata_utils.py index e3c4a1d..daa5fbe 100644 --- a/metadata_utils.py +++ b/metadata_utils.py @@ -14,6 +14,8 @@ from xml.sax.saxutils import escape as xml_escape import numpy as np from PIL import Image, PngImagePlugin +METAHUB_SAVE_NODE_VERSION = "1.0.9" + try: import piexif from piexif import helper as piexif_helper @@ -391,8 +393,9 @@ def build_imh_metadata(params: dict, workflow_json: dict) -> dict: safe_workflow = _sanitize(workflow_json.get('workflow', {})) safe_prompt = _sanitize(workflow_json.get('prompt', {})) + attribution = params.get("imh_attribution") - return { + metadata = { # CRITICAL: Required field for IMH detection "generator": "ComfyUI", "metadata_status": params.get("metadata_status", "partial"), @@ -456,6 +459,73 @@ def build_imh_metadata(params: dict, workflow_json: dict) -> dict: "prompt_api": safe_prompt, } + if isinstance(attribution, dict): + metadata["imh_attribution"] = _sanitize(attribution) + + return metadata + + +def normalize_imh_attribution(value: Any) -> Optional[Dict[str, Any]]: + if not isinstance(value, dict): + return None + + token = value.get("token") + if not isinstance(token, str) or not token.strip(): + return None + + attribution = { + key: val + for key, val in value.items() + if isinstance(key, str) and key != "node_version" + } + attribution["schema_version"] = value.get("schema_version") or 1 + attribution["token"] = token.strip() + attribution["source"] = value.get("source") if isinstance(value.get("source"), str) and value.get("source").strip() else "metahub_save_node" + attribution["node_version"] = METAHUB_SAVE_NODE_VERSION + return attribution + + +def extract_workflow_attribution(workflow_json: dict, save_node_id: Optional[str]) -> Optional[Dict[str, Any]]: + if not isinstance(workflow_json, dict): + return None + + workflow = workflow_json.get("workflow") + if isinstance(workflow, str): + try: + workflow = json.loads(workflow) + except Exception: + return None + + if not isinstance(workflow, dict): + return None + + nodes = workflow.get("nodes") + if not isinstance(nodes, list): + return None + + target_id = str(save_node_id) if save_node_id is not None else None + candidates = [] + for node in nodes: + if not isinstance(node, dict): + continue + if target_id is not None: + node_id = node.get("id") + if node_id is None or str(node_id) != target_id: + continue + candidates = [node] + break + if node.get("type") in ("MetaHubSaveImage", "MetaHubSaveNode", "MetaHubSaveVideoNode") or node.get("class_type") in ("MetaHubSaveImage", "MetaHubSaveNode", "MetaHubSaveVideoNode"): + candidates.append(node) + + if len(candidates) != 1: + return None + + props = candidates[0].get("properties") + if not isinstance(props, dict): + return None + + return normalize_imh_attribution(props.get("imh_attribution")) + def build_metadata_sources(manual_inputs: dict, extracted: dict, fields: List[str]) -> Dict[str, str]: sources: Dict[str, str] = {} diff --git a/metahub_save_node.py b/metahub_save_node.py index 5934bab..fef9d2d 100644 --- a/metahub_save_node.py +++ b/metahub_save_node.py @@ -293,6 +293,7 @@ class MetaHubSaveNode: class_type=save_node_class_type, display_name=save_node_display_name, ) + imh_attribution = utils.extract_workflow_attribution(workflow_json, save_node_id) extractor = WorkflowExtractor(prompt_data) extracted, missing_fields = extractor.extract( @@ -429,6 +430,7 @@ class MetaHubSaveNode: "parent_image": parent_image, "generation_type": extracted.get("generation_type"), "source_image": extracted.get("source_image"), + "imh_attribution": imh_attribution, # Performance metrics (Tier 1, 2, 3) "vram_peak_mb": vram_peak_mb if vram_peak_mb is not None else gpu_metrics.get("vram_peak_mb"), "gpu_device": gpu_device_override if gpu_device_override else gpu_metrics.get("gpu_device"), diff --git a/metahub_save_video_node.py b/metahub_save_video_node.py index 47cea63..228ff51 100644 --- a/metahub_save_video_node.py +++ b/metahub_save_video_node.py @@ -198,10 +198,12 @@ class MetaHubSaveVideoNode: prompt_data = {} workflow_json = utils.ensure_prompt_in_workflow(workflow_json, prompt_data) + save_node_id = str(unique_id) if unique_id is not None else None + imh_attribution = utils.extract_workflow_attribution(workflow_json, save_node_id) # Use WorkflowExtractor to get generation params extractor = WorkflowExtractor(prompt_data) - extracted, missing_fields = extractor.extract(save_node_id=None) + extracted, missing_fields = extractor.extract(save_node_id=save_node_id) lora_list = extracted.get("lora_list") or utils.extract_loras_from_workflow(workflow_json) except Exception as e: @@ -209,6 +211,7 @@ class MetaHubSaveVideoNode: extracted = {} lora_list = [] workflow_json = {} + imh_attribution = None # Resolve values (manual override > extracted > default) def resolve_value(manual_value, extracted_value, default_value): @@ -318,6 +321,7 @@ class MetaHubSaveVideoNode: gpu_metrics=gpu_metrics, version_info=version_info, workflow_json=workflow_json, + imh_attribution=imh_attribution, metadata_sources=metadata_sources, metadata_status=metadata_status, ) @@ -353,6 +357,7 @@ class MetaHubSaveVideoNode: gpu_metrics: Dict[str, Any], version_info: Dict[str, str], workflow_json: Dict, + imh_attribution: Optional[Dict[str, Any]], metadata_sources: Dict[str, str], metadata_status: str, ) -> None: @@ -413,6 +418,7 @@ class MetaHubSaveVideoNode: "comfyui_version": version_info.get("comfyui_version"), "torch_version": version_info.get("torch_version"), "python_version": version_info.get("python_version"), + "imh_attribution": imh_attribution, "metadata_sources": metadata_sources, "metadata_status": metadata_status, } diff --git a/tests/test_metadata_utils.py b/tests/test_metadata_utils.py index 3c5e4f4..ce78af2 100644 --- a/tests/test_metadata_utils.py +++ b/tests/test_metadata_utils.py @@ -11,6 +11,7 @@ from metadata_utils import ( build_metadata_status, ensure_metahub_save_node, ensure_prompt_in_workflow, + extract_workflow_attribution, find_model_file, get_next_filename, get_output_directory, @@ -292,6 +293,61 @@ def test_build_imh_metadata_includes_workflow_and_prompt(): assert imh["metadata_sources"] == {"model_name": "default"} +def test_extract_workflow_attribution_from_node_properties(): + workflow_json = { + "workflow": { + "nodes": [ + { + "id": 7, + "type": "MetaHubSaveNode", + "properties": { + "imh_attribution": { + "schema_version": 1, + "token": " imhcrt_br_creator_workflow_v1_random ", + "source": "metahub_save_node", + } + }, + } + ] + } + } + + attribution = extract_workflow_attribution(workflow_json, "7") + + assert attribution["token"] == "imhcrt_br_creator_workflow_v1_random" + assert attribution["source"] == "metahub_save_node" + assert attribution["node_version"] == "1.0.9" + + +def test_build_imh_metadata_includes_attribution_without_a1111_parameters(): + params = { + "positive": "pos", + "negative": "neg", + "steps": 20, + "sampler": "euler", + "scheduler": "normal", + "cfg": 7.5, + "seed": 123, + "width": 512, + "height": 768, + "model_name": "model.safetensors", + "model_hash": "abcdef1234", + "lora_list": [], + "imh_attribution": { + "schema_version": 1, + "token": "imhcrt_br_creator_workflow_v1_random", + "source": "metahub_save_node", + "node_version": "1.0.9", + }, + } + + imh = build_imh_metadata(params, {"workflow": {"nodes": []}, "prompt": {}}) + a1111 = build_a1111_metadata(params) + + assert imh["imh_attribution"]["token"] == "imhcrt_br_creator_workflow_v1_random" + assert "imhcrt_br_creator_workflow_v1_random" not in a1111 + + def test_build_imh_metadata_sanitizes_nan(): params = { "positive": "pos", diff --git a/tests/test_video_metadata_utils.py b/tests/test_video_metadata_utils.py index ec193cb..96fb11e 100644 --- a/tests/test_video_metadata_utils.py +++ b/tests/test_video_metadata_utils.py @@ -24,6 +24,28 @@ def test_video_metadata_omits_full_workflow_payloads(): assert "prompt_api" not in payload +def test_video_metadata_includes_attribution_without_workflow_payloads(): + payload = build_video_metahub_metadata( + { + "positive": "prompt", + "negative": "", + "width": 640, + "height": 480, + "imh_attribution": { + "schema_version": 1, + "token": "imhcrt_br_creator_workflow_v1_random", + "source": "metahub_save_node", + "node_version": "1.0.9", + }, + }, + {"workflow": {"nodes": [{"id": 1}]}, "prompt": {"1": {"class_type": "KSampler"}}}, + ) + + assert payload["imh_attribution"]["token"] == "imhcrt_br_creator_workflow_v1_random" + assert "workflow" not in payload + assert "prompt_api" not in payload + + def test_video_metadata_nulls_defaulted_canonical_fields(): payload = build_video_metahub_metadata( { diff --git a/video_metadata_utils.py b/video_metadata_utils.py index b557d9e..656be1a 100644 --- a/video_metadata_utils.py +++ b/video_metadata_utils.py @@ -207,13 +207,14 @@ def build_video_metahub_metadata(params: dict, workflow_json: dict) -> dict: duration_seconds = round(frame_count / frame_rate, 2) sources = params.get("metadata_sources") if isinstance(params.get("metadata_sources"), dict) else {} + attribution = params.get("imh_attribution") def canonical(field: str, param_key: str, fallback: Any = None) -> Any: if sources.get(field) in {"default", "unknown"}: return None return params.get(param_key, fallback) - return { + metadata = { # CRITICAL: Required field for detection "generator": "ComfyUI", "media_type": "video", @@ -279,6 +280,11 @@ def build_video_metahub_metadata(params: dict, workflow_json: dict) -> dict: # Full workflow JSON intentionally omitted for video containers. } + if isinstance(attribution, dict): + metadata["imh_attribution"] = _sanitize(attribution) + + return metadata + def inject_video_metadata( video_path: Path,