diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dbc1893..a0bc701b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [2.2.1] - 2026-05-07 + +### Fixed + +Fixed `FunPack Video Refiner V2` prompt phrase categorization so environment and appearance descriptions are not pulled into action learning by generic `-ing` or `-ed` words. + +Updated Refiner V2 category similarity blending so CLIP category comparisons only help uncertain phrases instead of overriding strong local action, camera, appearance, environment, quality, or detail anchors. + ## [2.2.0] - 2026-05-07 ### Added diff --git a/README.md b/README.md index 7a9d9aed..bed2b069 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ A set of ComfyUI nodes for experimenting with video generation workflows based on WAN, HunyuanVideo, LTX, and similar models. +## Updates in 2.2.1 + +Fixed Refiner V2 prompt phrase categorization so background, appearance, quality, camera, and action phrases stay aligned before the node updates Lucky memory or LoRA suggestions. + ## Updates in 2.2.0 Added `FunPack Video Refiner V2`, a simpler prompt-owned refiner that takes `positive_prompt` and a connected `CLIP`, encodes the prompt inside the node, learns from ratings, and returns refined positive conditioning plus diagnostics. diff --git a/__init__.py b/__init__.py index 3763076b..a48eda75 100644 --- a/__init__.py +++ b/__init__.py @@ -1,23 +1,64 @@ # ComfyUI-FunPack/__init__.py -from .conditioning import ( - FunPackLorebookEnhancer, - FunPackPromptCombiner, - FunPackPromptEnhancer, - FunPackStoryWriter, - FunPackVideoRefinerV2, -) -from .image_processing import ( - FunPackClipVisionOutputCombine, - FunPackContinueVideo, - FunPackStoryMemKeyframeExtractor, - FunPackStoryMemLastFrameExtractor, - FunPackVideoStitch, -) -from .model_management import FunPackApplyLoraWeights, FunPackLoraLoader -from .samplers import FunPackHybridEuler2SSampler -from .context_transition import FunPackContextTransitionWindows -from .templates import FunPackTemplateManager +if __package__: + from .conditioning import ( + FunPackLorebookEnhancer, + FunPackPromptCombiner, + FunPackPromptEnhancer, + FunPackStoryWriter, + FunPackVideoRefinerV2, + ) + from .image_processing import ( + FunPackClipVisionOutputCombine, + FunPackContinueVideo, + FunPackStoryMemKeyframeExtractor, + FunPackStoryMemLastFrameExtractor, + FunPackVideoStitch, + ) + from .model_management import FunPackApplyLoraWeights, FunPackLoraLoader + from .samplers import FunPackHybridEuler2SSampler + from .context_transition import FunPackContextTransitionWindows + from .templates import FunPackTemplateManager +else: + # Standalone tests may not have the full ComfyUI/CUDA runtime loaded. + from conditioning import ( + FunPackLorebookEnhancer, + FunPackPromptCombiner, + FunPackPromptEnhancer, + FunPackStoryWriter, + FunPackVideoRefinerV2, + ) + try: + from image_processing import ( + FunPackClipVisionOutputCombine, + FunPackContinueVideo, + FunPackStoryMemKeyframeExtractor, + FunPackStoryMemLastFrameExtractor, + FunPackVideoStitch, + ) + except Exception: + FunPackClipVisionOutputCombine = None + FunPackContinueVideo = None + FunPackStoryMemKeyframeExtractor = None + FunPackStoryMemLastFrameExtractor = None + FunPackVideoStitch = None + try: + from model_management import FunPackApplyLoraWeights, FunPackLoraLoader + except Exception: + FunPackApplyLoraWeights = None + FunPackLoraLoader = None + try: + from samplers import FunPackHybridEuler2SSampler + except Exception: + FunPackHybridEuler2SSampler = None + try: + from context_transition import FunPackContextTransitionWindows + except Exception: + FunPackContextTransitionWindows = None + try: + from templates import FunPackTemplateManager + except Exception: + FunPackTemplateManager = None WEB_DIRECTORY = "./web" @@ -38,6 +79,7 @@ NODE_CLASS_MAPPINGS = { "FunPackLoraLoader": FunPackLoraLoader, "FunPackTemplateManager": FunPackTemplateManager, } +NODE_CLASS_MAPPINGS = {name: cls for name, cls in NODE_CLASS_MAPPINGS.items() if cls is not None} NODE_DISPLAY_NAME_MAPPINGS = { "FunPackPromptCombiner": "FunPack Prompt Combiner", diff --git a/conditioning.py b/conditioning.py index abffb0d4..1716319d 100644 --- a/conditioning.py +++ b/conditioning.py @@ -5546,37 +5546,85 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): "jumping", "fly", "flying", "move", "moving", "motion", "gesture", "gesturing", "hold", "holding", "reach", "reaching", "look", "looking", "blink", "blinking", "smile", "smiling", "sit", "sitting", "stand", "standing", "kneel", "kneeling", - "flow", "flowing", "sway", "swaying", "spin", "spinning", "fall", "falling", + "sway", "swaying", "spin", "spinning", "fall", "falling", "grab", "grabbing", + "throw", "throwing", "catch", "catching", "climb", "climbing", "fight", "fighting", + "talk", "talking", "speak", "speaking", "wave", "waving", "crawl", "crawling", + "drive", "driving", "ride", "riding", "open", "opening", "close", "closing", }, "camera": { "camera", "shot", "closeup", "close-up", "wide", "angle", "zoom", "pan", "panning", - "dolly", "tracking", "handheld", "focus", "framing", "push", "pull", "tilt", + "dolly", "tracking", "handheld", "focus", "framing", "push", "push-in", "pull", + "pull-back", "tilt", "orbit", "orbiting", "crane", "rack", "lens", "viewpoint", + "perspective", "overhead", "low-angle", "high-angle", "close-up", "macro", }, "subject": { "woman", "man", "girl", "boy", "person", "character", "robot", "creature", "dragon", - "animal", "dog", "cat", "car", "vehicle", "object", "sculpture", "glass", + "animal", "dog", "cat", "car", "vehicle", "object", "sculpture", "monster", + "child", "baby", "adult", "crowd", "dancer", "runner", "warrior", "knight", }, "appearance": { "hair", "eyes", "face", "skin", "dress", "jacket", "armor", "outfit", "clothing", - "hands", "pose", "expression", "beard", "makeup", "body", "anatomy", + "hands", "pose", "expression", "beard", "makeup", "body", "anatomy", "wearing", + "wears", "dressed", "clothed", "costume", "shirt", "coat", "robe", "boots", + "hat", "helmet", "gloves", "mask", "tattoo", "freckles", "scar", "silhouette", + "flowing", "curly", "straight", "long", "short", "blonde", "brunette", }, "environment": { "forest", "city", "street", "room", "kitchen", "beach", "mountain", "temple", "sunset", "night", "rain", "snow", "sky", "background", "studio", "tabletop", + "setting", "environment", "landscape", "interior", "exterior", "alley", "road", + "field", "desert", "ocean", "sea", "lake", "river", "waterfall", "clouds", + "weather", "fog", "mist", "storm", "snowfall", "backdrop", "horizon", }, "style": { "anime", "cinematic", "photorealistic", "painterly", "illustration", "stylized", "realistic", "film", "noir", "vintage", "dramatic", "soft", "lighting", + "moody", "surreal", "documentary", "editorial", "watercolor", "sketch", + "render", "rendered", "monochrome", "pastel", "neon", "gothic", }, "quality": { "masterpiece", "best", "quality", "detailed", "sharp", "highres", "high-res", - "ultra", "perfect", "clean", "realism", "realistic", "smooth", + "ultra", "perfect", "clean", "realism", "realistic", "smooth", "crisp", + "high-detail", "highly-detailed", "ultra-detailed", "noise-free", "polished", + "refined", "clear", "high-resolution", "8k", "4k", }, "details": { "reflection", "reflections", "texture", "textures", "shadow", "shadows", "smoke", "dust", "particles", "small", "tiny", "prop", "props", "fabric", "glass", + "sparkles", "embers", "debris", "scratches", "cracks", "drops", "droplets", + "pattern", "patterns", "grain", "details", "ornament", "ornaments", }, } + CATEGORY_PRIORITY = { + "action": 0.018, + "camera": 0.014, + "subject": 0.008, + "appearance": 0.012, + "environment": 0.012, + "style": 0.006, + "quality": 0.004, + "details": 0.002, + } + ACTION_SUFFIX_STEMS = { + "walk", "run", "turn", "danc", "jump", "fly", "mov", "gestur", "hold", "reach", + "look", "blink", "smil", "sit", "stand", "kneel", "sway", "spin", "fall", + "grab", "throw", "catch", "climb", "fight", "talk", "speak", "wav", "crawl", + "driv", "rid", "open", "clos", + } + APPEARANCE_CONTEXT_WORDS = { + "hair", "eyes", "face", "skin", "dress", "jacket", "armor", "outfit", "clothing", + "shirt", "coat", "robe", "boots", "hat", "helmet", "gloves", "mask", "body", + "anatomy", "beard", "makeup", "costume", + } + ENVIRONMENT_CONTEXT_WORDS = { + "background", "setting", "environment", "landscape", "room", "street", "forest", + "city", "beach", "mountain", "temple", "sky", "studio", "interior", "exterior", + "backdrop", "horizon", "weather", + } + QUALITY_CONTEXT_WORDS = { + "quality", "detail", "details", "detailed", "sharp", "clean", "crisp", "realism", + "resolution", "highres", "high-resolution", "smooth", "polished", + } @classmethod def INPUT_TYPES(cls): @@ -5692,28 +5740,67 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): if self._is_valuable_token(word.strip()) ] + def _v2_has_action_suffix(self, words): + for word in words: + if word.endswith("ing"): + stem = word[:-3] + elif word.endswith("ed"): + stem = word[:-2] + else: + continue + if stem in self.ACTION_SUFFIX_STEMS: + return True + if stem.endswith("n") and stem[:-1] in self.ACTION_SUFFIX_STEMS: + return True + if stem.endswith("p") and stem[:-1] in self.ACTION_SUFFIX_STEMS: + return True + return False + + def _v2_rebalance_anchored_scores(self, scores, words): + words = set(words or []) + has_action = bool(words & self.CATEGORY_KEYWORDS["action"]) or self._v2_has_action_suffix(words) + if words & self.APPEARANCE_CONTEXT_WORDS: + scores["appearance"] = max(scores["appearance"], 0.66) + if not has_action: + scores["action"] = min(scores["action"], 0.40) + if words & self.ENVIRONMENT_CONTEXT_WORDS: + scores["environment"] = max(scores["environment"], 0.66) + if not has_action: + scores["action"] = min(scores["action"], 0.36) + if words & self.QUALITY_CONTEXT_WORDS: + scores["quality"] = max(scores["quality"], 0.64) + if not has_action: + scores["action"] = min(scores["action"], 0.34) + if has_action: + scores["action"] = max(scores["action"], 0.66) + return scores + def _v2_heuristic_scores(self, phrase): words = set(self._v2_phrase_words(phrase)) scores = {category: 0.0 for category in self.CATEGORY_DESCRIPTIONS} for category, keywords in self.CATEGORY_KEYWORDS.items(): hits = len(words & keywords) if hits: - scores[category] += min(0.85, 0.38 + hits * 0.17) + scores[category] += min(0.92, 0.48 + hits * 0.18) text = str(phrase or "").lower() - if re.search(r"\b\w+(ing|ed)\b", text): + if self._v2_has_action_suffix(words): scores["action"] = max(scores["action"], 0.52) if "camera" in text and re.search(r"\b(move|moves|moving|push|pull|pan|zoom|track|dolly|tilt)", text): scores["camera"] = max(scores["camera"], 0.78) scores["action"] = max(scores["action"], 0.48) if re.search(r"\b(slowly|quickly|gentle|smooth|fast)\b", text) and scores["action"] > 0.0: scores["action"] = min(0.92, scores["action"] + 0.12) + scores = self._v2_rebalance_anchored_scores(scores, words) return scores def _v2_scores_primary(self, scores): if not scores: return "details", 0.0 - primary, confidence = max(scores.items(), key=lambda item: item[1]) + primary, confidence = max( + scores.items(), + key=lambda item: (item[1], self.CATEGORY_PRIORITY.get(item[0], 0.0)), + ) return primary, float(confidence) def _v2_clip_similarity_scores(self, clip, phrase, category_vectors): @@ -5738,6 +5825,31 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): vectors[category] = vector.cpu() return vectors + def _v2_merge_clip_category_scores(self, heuristic_scores, clip_scores): + if not clip_scores: + return heuristic_scores + merged = dict(heuristic_scores) + heuristic_primary, heuristic_confidence = self._v2_scores_primary(heuristic_scores) + ranked_clip = sorted(clip_scores.items(), key=lambda item: item[1], reverse=True) + if not ranked_clip: + return merged + clip_primary, clip_confidence = ranked_clip[0] + clip_runner_up = ranked_clip[1][1] if len(ranked_clip) > 1 else 0.0 + clip_margin = float(clip_confidence) - float(clip_runner_up) + + if heuristic_confidence >= 0.60: + return merged + if heuristic_confidence >= 0.44 and clip_primary != heuristic_primary and clip_margin < 0.075: + return merged + + mean_clip = sum(float(value) for value in clip_scores.values()) / max(1, len(clip_scores)) + for category, score in clip_scores.items(): + relative = max(0.0, float(score) - mean_clip) + merged[category] = max(float(merged.get(category, 0.0)), min(0.56, relative * 2.40)) + if clip_margin >= 0.045: + merged[clip_primary] = max(float(merged.get(clip_primary, 0.0)), min(0.68, 0.48 + clip_margin * 2.20)) + return merged + def _v2_classify_phrases(self, clip, phrases): phrase_items = [] uncertain = [] @@ -5766,12 +5878,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): clip_scores = self._v2_clip_similarity_scores(clip, item["text"], category_vectors) if not clip_scores: continue - merged = {} - for category in self.CATEGORY_DESCRIPTIONS: - merged[category] = max( - float(item["category_scores"].get(category, 0.0)) * 0.58, - float(clip_scores.get(category, 0.0)) * 0.82, - ) + merged = self._v2_merge_clip_category_scores(item["category_scores"], clip_scores) primary, confidence = self._v2_scores_primary(merged) item["category_scores"] = merged item["primary"] = primary diff --git a/pyproject.toml b/pyproject.toml index e823b977..5b279c3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ [project] name = "ComfyUI-FunPack" # Unique identifier for your node. Immutable after creation. description = "A set of nodes for experiments with video generation models, including prompt refinement, adaptive LoRA weight workflows, story tools, video stitching, lorebook enhancement and more." -version = "2.2.0" # Custom Node version. Must be semantically versioned. +version = "2.2.1" # Custom Node version. Must be semantically versioned. license = { text = "GNU GPL v3" } dependencies = [] # Filled in from requirements.txt diff --git a/tests/test_v2_category_alignment.py b/tests/test_v2_category_alignment.py new file mode 100644 index 00000000..bcd18bb8 --- /dev/null +++ b/tests/test_v2_category_alignment.py @@ -0,0 +1,53 @@ +import sys +import types + +sys.modules.setdefault("folder_paths", types.SimpleNamespace(models_dir="")) + +from conditioning import FunPackVideoRefinerV2 + + +def primary_category(phrase): + refiner = FunPackVideoRefinerV2() + scores = refiner._v2_heuristic_scores(phrase) + primary, confidence = refiner._v2_scores_primary(scores) + return primary, confidence, scores + + +def test_environment_descriptions_do_not_become_actions(): + primary, confidence, scores = primary_category("detailed background") + + assert primary == "environment" + assert confidence >= 0.60 + assert scores["action"] == 0.0 + + +def test_weathered_environment_descriptions_do_not_become_actions(): + primary, confidence, scores = primary_category("weathered stone room") + + assert primary == "environment" + assert confidence >= 0.60 + assert scores["action"] == 0.0 + + +def test_appearance_descriptions_stay_appearance(): + primary, confidence, scores = primary_category("flowing hair and blue eyes") + + assert primary == "appearance" + assert confidence >= 0.60 + assert scores["action"] == 0.0 + + +def test_clothing_descriptions_stay_appearance(): + primary, confidence, scores = primary_category("wearing red dress") + + assert primary == "appearance" + assert confidence >= 0.60 + assert scores["action"] == 0.0 + + +def test_motion_descriptions_stay_action(): + primary, confidence, scores = primary_category("running through the street") + + assert primary == "action" + assert confidence >= 0.60 + assert scores["action"] >= scores["environment"]