diff --git a/__pycache__/metadata_utils_impl.cpython-310.pyc b/__pycache__/metadata_utils_impl.cpython-310.pyc new file mode 100644 index 0000000..9ff6f7d Binary files /dev/null and b/__pycache__/metadata_utils_impl.cpython-310.pyc differ diff --git a/__pycache__/metahub_save_video_node.cpython-310.pyc b/__pycache__/metahub_save_video_node.cpython-310.pyc index 14ca554..680f198 100644 Binary files a/__pycache__/metahub_save_video_node.cpython-310.pyc and b/__pycache__/metahub_save_video_node.cpython-310.pyc differ diff --git a/__pycache__/video_metadata_utils.cpython-310.pyc b/__pycache__/video_metadata_utils.cpython-310.pyc index c7c2e2b..62733fc 100644 Binary files a/__pycache__/video_metadata_utils.cpython-310.pyc and b/__pycache__/video_metadata_utils.cpython-310.pyc differ diff --git a/metadata_utils.py b/metadata_utils.py index e3d2516..a3c994e 100644 --- a/metadata_utils.py +++ b/metadata_utils.py @@ -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.3. +version to 1.1.4. """ try: @@ -11,7 +11,7 @@ try: except ImportError: import metadata_utils_impl as _impl -METAHUB_SAVE_NODE_VERSION = "1.1.3" +METAHUB_SAVE_NODE_VERSION = "1.1.4" _impl.METAHUB_SAVE_NODE_VERSION = METAHUB_SAVE_NODE_VERSION for _name in dir(_impl): diff --git a/pyproject.toml b/pyproject.toml index 1c946c3..98863eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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.3" +version = "1.1.4" license = {file = "LICENSE"} # classifiers = [ # # For OS-independent nodes (works on all operating systems) diff --git a/tests/test_workflow_extractor.py b/tests/test_workflow_extractor.py index f78d7ec..aaec601 100644 --- a/tests/test_workflow_extractor.py +++ b/tests/test_workflow_extractor.py @@ -135,6 +135,75 @@ def test_workflow_extractor_detects_lora_alternate_keys(): assert data["lora_list"] == [{"name": "style.safetensors", "weight": 0.65}] +def test_workflow_extractor_detects_rgthree_power_lora_objects(): + prompt = { + "67": { + "class_type": "Power Lora Loader (rgthree)", + "inputs": { + "lora_1": { + "on": True, + "lora": "Z-Detail-Slider.safetensors", + "strength": 0.5325, + }, + "lora_2": { + "on": True, + "lora": "zy_CinematicShot_zit.safetensors", + "strength": 0.6875, + }, + "lora_3": { + "on": False, + "lora": "disabled.safetensors", + "strength": 1.0, + }, + }, + } + } + + extractor = WorkflowExtractor(prompt) + data, _missing = extractor.extract() + + assert data["lora_list"] == [ + {"name": "Z-Detail-Slider.safetensors", "weight": 0.5325}, + {"name": "zy_CinematicShot_zit.safetensors", "weight": 0.6875}, + ] + + +def test_workflow_extractor_reads_style_prompt_encoder_through_basic_guider(): + prompt = { + "61": { + "class_type": "SamplerCustomAdvanced", + "inputs": { + "noise": ["63", 0], + "guider": ["101", 0], + "sampler": ["108", 0], + "sigmas": ["62", 0], + "latent_image": ["113", 0], + }, + }, + "101": { + "class_type": "BasicGuider", + "inputs": { + "model": ["100", 0], + "conditioning": ["114", 0], + }, + }, + "114": { + "class_type": "StylePromptEncoder2 //ZImagePowerNodes", + "inputs": { + "style": "\"Production Photo\"", + "text": "A white cat on the roof of a brick house", + "clip": ["67", 1], + }, + }, + } + + extractor = WorkflowExtractor(prompt) + data, missing = extractor.extract() + + assert data["positive"] == "A white cat on the roof of a brick house" + assert "positive" not in missing + + def test_workflow_extractor_resolves_connected_seed_and_prompt_strings(): prompt = { "1": { diff --git a/workflow_extractor.py b/workflow_extractor.py index 4368981..08238be 100644 --- a/workflow_extractor.py +++ b/workflow_extractor.py @@ -274,8 +274,12 @@ class WorkflowExtractor: if stacked_loras: loras.extend(stacked_loras) continue + dynamic_loras = self._extract_dynamic_lora_entries(inputs) + if dynamic_loras: + loras.extend(dynamic_loras) + continue lora_name = self._get_first_literal(inputs, ("lora_name", "lora", "lora_name_1", "lora_1")) - if not lora_name: + if not isinstance(lora_name, str) or not lora_name.strip(): continue strength_model = self._get_first_literal( inputs, @@ -291,6 +295,28 @@ class WorkflowExtractor: return loras, has_lora_nodes + def _extract_dynamic_lora_entries(self, inputs: Dict[str, Any]) -> List[Dict[str, Any]]: + loras: List[Dict[str, Any]] = [] + for key, raw_lora in inputs.items(): + if not key.lower().startswith("lora_") or not isinstance(raw_lora, dict): + continue + if raw_lora.get("on") is False or raw_lora.get("active") is False: + continue + name = raw_lora.get("lora") or raw_lora.get("lora_name") or raw_lora.get("name") + if not isinstance(name, str) or not name.strip(): + continue + weight = self._coerce_float( + raw_lora.get("strength") + if raw_lora.get("strength") is not None + else raw_lora.get("modelStrength") + ) + if weight is None: + weight = self._coerce_float(raw_lora.get("strength_model")) + if weight is None: + weight = 1.0 + loras.append({"name": name, "weight": float(weight)}) + return loras + def _extract_lora_manager_entries(self, inputs: Dict[str, Any]) -> List[Dict[str, Any]]: raw_loras = inputs.get("loras") if isinstance(raw_loras, dict) and isinstance(raw_loras.get("__value__"), list): @@ -453,7 +479,7 @@ class WorkflowExtractor: class_type = self._class_type(node) if class_type == "ConditioningZeroOut": return "" - if class_type in self.CLIP_NODES: + if self._is_prompt_encoder(class_type): text = self._get_clip_text(node) if text is not None: texts.append(text) @@ -506,7 +532,7 @@ class WorkflowExtractor: if class_type == "ConditioningZeroOut": return "" - if class_type in self.CLIP_NODES: + if self._is_prompt_encoder(class_type): return self._get_clip_text(node) if class_lower == "joinstrings" or "join strings" in class_lower: @@ -663,3 +689,9 @@ class WorkflowExtractor: if class_type in cls.LORA_NODES: return True return "lora" in class_type.lower() + + @classmethod + def _is_prompt_encoder(cls, class_type: str) -> bool: + if class_type in cls.CLIP_NODES: + return True + return "promptencoder" in class_type.lower().replace(" ", "")