Merge pull request #10 from LuqP2/feature-node-cta
Add creator attribution metadata support
This commit is contained in:
+3
-1
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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] = {}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user