Merge pull request #10 from LuqP2/feature-node-cta

Add creator attribution metadata support
This commit is contained in:
Lucas
2026-06-15 19:32:11 -03:00
committed by GitHub
8 changed files with 233 additions and 4 deletions
+3 -1
View File
@@ -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"]
+65
View File
@@ -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);
},
});
+71 -1
View File
@@ -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] = {}
+2
View File
@@ -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"),
+7 -1
View File
@@ -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,
}
+56
View File
@@ -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",
+22
View File
@@ -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(
{
+7 -1
View File
@@ -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,