From 9586370ecef65a0656c277a921e7e33087900ef9 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:23:07 +0300 Subject: [PATCH 01/13] Add Video Refiner latent refinement dev path --- CHANGELOG.md | 10 +- README.md | 16 +- __init__.py | 8 +- conditioning.py | 234 ++++++++++++++++++++++++--- docs/FunPackGemmaEmbeddingRefiner.md | 18 ++- docs/FunPackLoraWorkflow.md | 10 +- docs/FunPackSaveRefinementLatent.md | 21 +++ docs/FunPackVideoRefiner.md | 3 + funpack.py | 12 +- 9 files changed, 297 insertions(+), 35 deletions(-) create mode 100644 docs/FunPackSaveRefinementLatent.md create mode 100644 docs/FunPackVideoRefiner.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 22ef7177..328b918b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,9 @@ ### Added -Added `FunPack Apply LoRA Weights` and `FunPack LoRA Loader`, a prompt-exact LoRA weight workflow designed to work with `FunPack Gemma Embedding Refiner`. +Added `FunPack Apply LoRA Weights` and `FunPack LoRA Loader`, a prompt-exact LoRA weight workflow designed to work with `FunPack Video Refiner`. + +Added `FunPack Save Refinement Latent`, which stores latent tensor bundles by refinement key for optional latent refinement in `FunPack Video Refiner`. The new workflow uses base LoRA weights on the first run for a prompt, then lets the refiner save prompt-specific suggested LoRA weights into its existing JSON state for later runs. @@ -19,7 +21,11 @@ Split the old single `funpack.py` implementation into focused modules: `funpack.py` remains as a compatibility re-export for older imports. -Updated `FunPack Gemma Embedding Refiner` so it can accept a FunPack LoRA stack and save next-run model LoRA weight suggestions based on prompt concepts, LoRA type hints, and user ratings. +Renamed the visible refiner title to `FunPack Video Refiner` while keeping the old node key as a compatibility alias. + +Updated `FunPack Video Refiner` so it can accept a FunPack LoRA stack and save next-run model LoRA weight suggestions based on prompt concepts, LoRA type hints, and user ratings. + +Updated `FunPack Video Refiner` with optional latent input/output refinement. If no matching saved latent exists, the latent path is skipped and existing conditioning behavior continues unchanged. ## [1.3.3] - 2026-04-22 diff --git a/README.md b/README.md index 164454b4..17181df0 100644 --- a/README.md +++ b/README.md @@ -11,17 +11,19 @@ FunPack is now split into focused Python modules for easier editing and maintena - `image_processing.py` - `model_management.py` -Version 2.0.0 also adds a prompt-exact LoRA weight workflow that works together with `FunPack Gemma Embedding Refiner`. +Version 2.0.0 also adds a prompt-exact LoRA weight workflow that works together with `FunPack Video Refiner`. Use this order: -`FunPack Apply LoRA Weights` -> `FunPack LoRA Loader` -> `FunPack Gemma Embedding Refiner` +`FunPack Apply LoRA Weights` -> `FunPack LoRA Loader` -> `FunPack Video Refiner` `FunPack Apply LoRA Weights` defines selected LoRAs, LoRA type, and model base weights. If the refiner has saved suggested weights for the exact prompt, it applies them; otherwise it uses the base weights. `FunPack LoRA Loader` loads the prepared LoRA stack into the model. Its CLIP input is optional and is left untouched when omitted. -`FunPack Gemma Embedding Refiner` does the prompt/concept/rating work and saves next-run LoRA weight suggestions into its existing refinement JSON. It can improve the balance and stability of concepts the model can already express, but it cannot teach a model missing concepts or capabilities from scratch. +`FunPack Video Refiner` does the prompt/concept/rating work and saves next-run LoRA weight suggestions into its existing refinement JSON. It can improve the balance and stability of concepts the model can already express, but it cannot teach a model missing concepts or capabilities from scratch. + +`FunPack Save Refinement Latent` can save a latent tensor bundle under the same refinement key. If a saved latent and an optional latent input are connected to `FunPack Video Refiner`, the refiner can also produce a conservative refined latent output while preserving zero-valued latent positions. ## Installation @@ -30,7 +32,7 @@ FunPack is available on Comfy Registry and can be installed in any of these ways 1. With `comfy-cli`: `comfy node install ComfyUI-FunPack` 2. With git, inside your `ComfyUI/custom_nodes` directory: - `git clone https://github.com/aimfordeb/ComfyUI-FunPack` + `git clone https://github.com/digital-garbage/ComfyUI-FunPack` 3. With ComfyUI-Manager: Open `Custom Nodes Manager`, search for `ComfyUI-FunPack`, and click `Install`. @@ -55,7 +57,7 @@ Higher Torch versions are fine. The important part is avoiding older Torch relea ## Known Limitation -`FunPack Gemma Embedding Refiner` can optionally refine `SIGMAS`, but that currently breaks audio generation in workflows that also produce audio streams. If you need audio to generate and mux correctly, do not connect `sigmas` to the refiner. Leave sigma schedules on their original path and use the refiner only for conditioning. +`FunPack Video Refiner` can optionally refine `SIGMAS`, but that currently breaks audio generation in workflows that also produce audio streams. If you need audio to generate and mux correctly, do not connect `sigmas` to the refiner. Leave sigma schedules on their original path and use the refiner only for conditioning. UPD: This is not caused by Embedding Refiner node. Updating to latest nightly version of ComfyUI solves the problem. @@ -79,6 +81,10 @@ Per-node documentation is available in the [`docs`](docs) folder. The LoRA/refiner helper workflow is documented in [`docs/FunPackLoraWorkflow.md`](docs/FunPackLoraWorkflow.md). +The video refiner is documented in [`docs/FunPackVideoRefiner.md`](docs/FunPackVideoRefiner.md). + +The latent saver helper is documented in [`docs/FunPackSaveRefinementLatent.md`](docs/FunPackSaveRefinementLatent.md). + Version history is available in [CHANGELOG.md](CHANGELOG.md). ## Feedback diff --git a/__init__.py b/__init__.py index cdb33671..f9ec09aa 100644 --- a/__init__.py +++ b/__init__.py @@ -5,7 +5,9 @@ from .conditioning import ( FunPackLorebookEnhancer, FunPackPromptCombiner, FunPackPromptEnhancer, + FunPackSaveRefinementLatent, FunPackStoryWriter, + FunPackVideoRefiner, ) from .image_processing import ( FunPackContinueVideo, @@ -28,6 +30,8 @@ NODE_CLASS_MAPPINGS = { "FunPackContinueVideo": FunPackContinueVideo, "FunPackLorebookEnhancer": FunPackLorebookEnhancer, "FunPackGemmaEmbeddingRefiner": FunPackGemmaEmbeddingRefiner, + "FunPackVideoRefiner": FunPackVideoRefiner, + "FunPackSaveRefinementLatent": FunPackSaveRefinementLatent, "FunPackHybridEuler2SSampler": FunPackHybridEuler2SSampler, "FunPackApplyLoraWeights": FunPackApplyLoraWeights, "FunPackLoraLoader": FunPackLoraLoader, @@ -42,7 +46,9 @@ NODE_DISPLAY_NAME_MAPPINGS = { "FunPackVideoStitch": "FunPack Video Stitch", "FunPackContinueVideo": "FunPack Continue Video", "FunPackLorebookEnhancer": "FunPack Lorebook Enhancer", - "FunPackGemmaEmbeddingRefiner": "FunPack Gemma Embedding Refiner (Self-Refinement)", + "FunPackGemmaEmbeddingRefiner": "FunPack Video Refiner (Compatibility)", + "FunPackVideoRefiner": "FunPack Video Refiner", + "FunPackSaveRefinementLatent": "FunPack Save Refinement Latent", "FunPackHybridEuler2SSampler": "FunPack Hybrid Euler 2S Sampler", "FunPackApplyLoraWeights": "FunPack Apply LoRA Weights", "FunPackLoraLoader": "FunPack LoRA Loader", diff --git a/conditioning.py b/conditioning.py index f5587471..de375825 100644 --- a/conditioning.py +++ b/conditioning.py @@ -151,7 +151,46 @@ def render_refinement_loss_graph(refinement_key, scheduler_mode, mode, total_ite return _to_image_tensor(image.convert("RGB")) -class FunPackGemmaEmbeddingRefiner: + +def refinement_state_path(refinement_key, mode, prefix="refine", extension="json"): + base_dir = os.path.dirname(os.path.abspath(__file__)) + refinements_dir = os.path.join(base_dir, "refinements") + safe_key = md5(f"{(mode or 'ltx2').lower()}::{refinement_key}".encode("utf-8")).hexdigest() + return os.path.join(refinements_dir, f"{prefix}_{safe_key}.{extension}") + + +def empty_latent(): + return {"samples": torch.empty(0)} + + +def clone_latent(latent): + if not isinstance(latent, dict): + return empty_latent() + + cloned = {} + for key, value in latent.items(): + cloned[key] = value.detach().clone() if isinstance(value, torch.Tensor) else value + return cloned + + +def latent_samples(latent): + if not isinstance(latent, dict): + return None + samples = latent.get("samples") + return samples if isinstance(samples, torch.Tensor) else None + + +def cpu_tensor_bundle(latent): + if not isinstance(latent, dict): + return {} + + bundle = {} + for key, value in latent.items(): + if isinstance(value, torch.Tensor): + bundle[key] = value.detach().cpu().clone() + return bundle + +class FunPackVideoRefiner: _tokenizers = {} _tokenizer_sources = { "ltx2": [ @@ -224,7 +263,7 @@ class FunPackGemmaEmbeddingRefiner: cls._tokenizers[mode] = AutoTokenizer.from_pretrained(model_id, **kwargs) return cls._tokenizers[mode] except Exception as e: - print(f"[FunPackGemmaEmbeddingRefiner] Tokenizer load failed for mode '{mode}' from '{model_id}': {e}") + print(f"[FunPackVideoRefiner] Tokenizer load failed for mode '{mode}' from '{model_id}': {e}") return None @@ -403,11 +442,14 @@ class FunPackGemmaEmbeddingRefiner: "lora_stack": ("FUNPACK_LORA_STACK", { "tooltip": "Optional stack from FunPack LoRA Loader. The refiner uses it to save prompt-specific suggested LoRA weights." }), + "latent": ("LATENT", { + "tooltip": "Optional latent to refine. If no saved latent exists for this key, it passes through unchanged." + }), } } - RETURN_TYPES = ("CONDITIONING", "STRING", "STRING", "STRING", "IMAGE", "SIGMAS") - RETURN_NAMES = ("modified_positive", "status", "feedback_question", "training_info", "loss_graph", "refined_sigmas") + RETURN_TYPES = ("CONDITIONING", "STRING", "STRING", "STRING", "IMAGE", "SIGMAS", "LATENT") + RETURN_NAMES = ("modified_positive", "status", "feedback_question", "training_info", "loss_graph", "refined_sigmas", "refined_latent") FUNCTION = "refine" CATEGORY = "FunPack/Refinement" @@ -1233,6 +1275,110 @@ class FunPackGemmaEmbeddingRefiner: ) return tuned_sigmas, sigma_status + # ========================================================================= + # LATENT REFINEMENT + # ========================================================================= + + def _resize_tensor_like(self, tensor: torch.Tensor, reference: torch.Tensor): + if not isinstance(tensor, torch.Tensor) or not isinstance(reference, torch.Tensor): + return None + + out = tensor.to(device=reference.device, dtype=reference.dtype) + if list(out.shape) == list(reference.shape): + return out + if out.dim() != reference.dim() or out.dim() not in {4, 5}: + return None + + batch = reference.shape[0] + if out.shape[0] < batch: + reps = [1] * out.dim() + reps[0] = math.ceil(batch / max(1, out.shape[0])) + out = out.repeat(*reps) + out = out[:batch] + + channels = reference.shape[1] + if out.shape[1] < channels: + pad_shape = list(out.shape) + pad_shape[1] = channels - out.shape[1] + out = torch.cat([out, torch.zeros(pad_shape, device=out.device, dtype=out.dtype)], dim=1) + out = out[:, :channels] + + if out.dim() == 4: + return F.interpolate(out, size=reference.shape[-2:], mode="bilinear", align_corners=False) + return F.interpolate(out, size=reference.shape[-3:], mode="trilinear", align_corners=False) + + def _load_saved_latent(self, refinement_key, mode): + path = refinement_state_path(refinement_key, mode, prefix="latent", extension="pt") + if not os.path.exists(path): + return None, "Latent: no saved latent for this key." + + try: + data = torch.load(path, map_location="cpu", weights_only=False) + except Exception as e: + return None, f"Latent: failed to load saved latent ({e})." + + if not isinstance(data, dict) or not isinstance(data.get("samples"), torch.Tensor): + return None, "Latent: saved latent file is invalid." + + return data, "Latent: saved latent loaded." + + def _refine_latent(self, latent, refinement_key, mode, rating, reward, global_adaptive): + current = latent_samples(latent) + if current is None: + return empty_latent(), "Latent: no latent input connected." + + refined_latent = clone_latent(latent) + saved_bundle, load_status = self._load_saved_latent(refinement_key, mode) + if saved_bundle is None: + return refined_latent, load_status + + saved_samples = self._resize_tensor_like(saved_bundle.get("samples"), current) + if saved_samples is None: + return refined_latent, "Latent: saved latent shape is incompatible, passthrough." + + latent_state = global_adaptive.setdefault("latent_refinement", {}) + latent_state["last_shape"] = list(current.shape) + latent_state["saved_shape"] = list(saved_bundle["samples"].shape) + + momentum_data = latent_state.get("momentum") + if isinstance(momentum_data, dict): + try: + momentum = serializable_to_tensor(momentum_data).to(device=current.device, dtype=current.dtype) + momentum = self._resize_tensor_like(momentum, current) + except Exception: + momentum = None + else: + momentum = None + if momentum is None: + momentum = torch.zeros_like(current) + + nonzero_mask = current.ne(0) & saved_samples.ne(0) + if not bool(nonzero_mask.any()): + refined_latent["samples"] = current + latent_state["status"] = "Latent: only zero-valued positions available, passthrough." + return refined_latent, latent_state["status"] + + lr = 0.035 if rating >= 7 else 0.055 if rating <= 4 else 0.018 + target_delta = (current - saved_samples) * nonzero_mask.to(dtype=current.dtype) + update = target_delta * (reward * lr) + momentum = 0.82 * momentum + 0.18 * update + max_step = max(0.001, min(0.08, float(current.detach().abs().mean().item()) * 0.18)) + latent_delta = torch.clamp(momentum, min=-max_step, max=max_step) + + refined_samples = current + latent_delta + refined_samples = torch.where(nonzero_mask, refined_samples, current) + refined_latent["samples"] = refined_samples + + latent_state["momentum"] = tensor_to_serializable(momentum.detach().cpu()) + latent_state["last_rating"] = int(rating) + latent_state["last_reward"] = round(float(reward), 4) + latent_state["nonzero_ratio"] = round(float(nonzero_mask.float().mean().item()), 6) + latent_state["status"] = ( + f"Latent: adjusted shape {tuple(current.shape)} | nonzero {latent_state['nonzero_ratio']:.1%} | " + f"max step {max_step:.5f}" + ) + return refined_latent, latent_state["status"] + # ========================================================================= # LORA WEIGHT SUGGESTIONS # ========================================================================= @@ -1412,7 +1558,7 @@ class FunPackGemmaEmbeddingRefiner: scheduler_mode: str = "original", positive_prompt: str = "", reset_session: bool = False, unlimited_history: bool = False, seed: int = 0, feedback_enabled: bool = False, feedback_rating: int = 3, - sigmas=None, sigma_strength: str = "subtle", lora_stack=None): + sigmas=None, sigma_strength: str = "subtle", lora_stack=None, latent=None): mode = (mode or "ltx2").lower() if mode not in self._tokenizer_sources: @@ -1422,12 +1568,10 @@ class FunPackGemmaEmbeddingRefiner: torch.manual_seed(seed) random.seed(seed) - base_dir = os.path.dirname(os.path.abspath(__file__)) - refinements_dir = os.path.join(base_dir, "refinements") + refinements_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "refinements") os.makedirs(refinements_dir, exist_ok=True) - safe_key = md5(f"{mode}::{refinement_key}".encode("utf-8")).hexdigest() - json_file = os.path.join(refinements_dir, f"refine_{safe_key}.json") + json_file = refinement_state_path(refinement_key, mode) fallback_loss_graph = render_refinement_loss_graph( refinement_key=refinement_key, scheduler_mode=scheduler_mode, @@ -1437,9 +1581,10 @@ class FunPackGemmaEmbeddingRefiner: points=[], ) fallback_sigmas = sigmas.detach().clone() if isinstance(sigmas, torch.Tensor) else torch.FloatTensor([]) + fallback_latent = clone_latent(latent) if not positive_conditioning or not isinstance(positive_conditioning, list) or len(positive_conditioning) == 0: - return (positive_conditioning, "ERROR: Empty positive CONDITIONING input", "", "ERROR: No positive conditioning", fallback_loss_graph, fallback_sigmas) + return (positive_conditioning, "ERROR: Empty positive CONDITIONING input", "", "ERROR: No positive conditioning", fallback_loss_graph, fallback_sigmas, fallback_latent) item = positive_conditioning[0] if isinstance(item, (list, tuple)) and len(item) >= 2: @@ -1450,7 +1595,7 @@ class FunPackGemmaEmbeddingRefiner: positive_meta = {"pooled_output": None} if not isinstance(raw_positive, torch.Tensor): - return (positive_conditioning, "ERROR: No positive embedding tensor found", "", "ERROR: Invalid embedding", fallback_loss_graph, fallback_sigmas) + return (positive_conditioning, "ERROR: No positive embedding tensor found", "", "ERROR: Invalid embedding", fallback_loss_graph, fallback_sigmas, fallback_latent) analysis_prompt = self._normalize_prompt_for_mode(positive_prompt, mode) prompt_key = analysis_prompt if mode == "wan" else positive_prompt @@ -1506,14 +1651,14 @@ class FunPackGemmaEmbeddingRefiner: data = _fresh_data() with open(json_file, "w", encoding="utf-8") as f: json.dump(data, f, indent=2) - return (positive_conditioning, "✓ New session started – Reference saved", "", "✓ New session started. Reference embedding saved.", fallback_loss_graph, fallback_sigmas) + return (positive_conditioning, "New session started - Reference saved", "", "New session started. Reference embedding saved.", fallback_loss_graph, fallback_sigmas, fallback_latent) # ====================== SAFE JSON LOAD ====================== try: with open(json_file, "r", encoding="utf-8") as f: data = json.load(f) except (json.JSONDecodeError, OSError, ValueError) as e: - print(f"[FunPackGemmaEmbeddingRefiner] Corrupt session file, resetting: {e}") + print(f"[FunPackVideoRefiner] Corrupt session file, resetting: {e}") try: os.remove(json_file) except OSError: @@ -1521,7 +1666,7 @@ class FunPackGemmaEmbeddingRefiner: data = _fresh_data() with open(json_file, "w", encoding="utf-8") as f: json.dump(data, f, indent=2) - return (positive_conditioning, "⚠️ Session file was corrupt – Reset and started fresh", "", "⚠️ Session reset due to corrupt file", fallback_loss_graph, fallback_sigmas) + return (positive_conditioning, "Session file was corrupt - Reset and started fresh", "", "Session reset due to corrupt file", fallback_loss_graph, fallback_sigmas, fallback_latent) global_adaptive = data["global_adaptive"] # Migrate sessions created before the multi-level concept system was added @@ -1584,7 +1729,7 @@ class FunPackGemmaEmbeddingRefiner: try: old_reference = serializable_to_tensor(active["reference_embeds"]) except Exception as e: - print(f"[FunPackGemmaEmbeddingRefiner] Failed to load reference embedding: {e}. Resetting for this prompt.") + print(f"[FunPackVideoRefiner] Failed to load reference embedding: {e}. Resetting for this prompt.") old_reference = raw_positive.clone() active["reference_embeds"] = tensor_to_serializable(old_reference) active["history"] = [] @@ -1595,7 +1740,7 @@ class FunPackGemmaEmbeddingRefiner: # Shape mismatch guard if old_reference.shape != cur_positive.shape: - print(f"[FunPackGemmaEmbeddingRefiner] Reference shape {old_reference.shape} != current {cur_positive.shape}. Resetting reference.") + print(f"[FunPackVideoRefiner] Reference shape {old_reference.shape} != current {cur_positive.shape}. Resetting reference.") old_reference = cur_positive.clone() active["reference_embeds"] = tensor_to_serializable(old_reference) active["history"] = [] @@ -1921,6 +2066,14 @@ class FunPackGemmaEmbeddingRefiner: sigma_strength, seed, ) + refined_latent, latent_status = self._refine_latent( + latent, + refinement_key, + mode, + rating, + reward, + global_adaptive, + ) # ====================== INTELLIGENT CONCEPT FEEDBACK SELECTION ====================== # Chooses one concept/question pair per run using category-aware scoring. @@ -2043,7 +2196,8 @@ class FunPackGemmaEmbeddingRefiner: f"Concept groups ({len(concept_groups)} total): {group_line}\n" f"Global top words: {current_top}\n" f"{lora_suggestion_status}\n" - f"{sigma_status}" + f"{sigma_status}\n" + f"{latent_status}" ) if scheduler_mode == "accurate": @@ -2088,7 +2242,8 @@ class FunPackGemmaEmbeddingRefiner: f"EMA {avg_reward_ema:+.2f} | Good ratio {good_ratio:.0%} | Expl {expl:.3f} | {health}\n" f"Dominant: {dominant_line} | Groups: {group_line}\n" f"{lora_suggestion_status}\n" - f"{sigma_status}" + f"{sigma_status}\n" + f"{latent_status}" ) # ====================== FINAL SAVE ====================== @@ -2098,7 +2253,48 @@ class FunPackGemmaEmbeddingRefiner: # ====================== RETURN ====================== modified_positive = [(new_positive, positive_meta)] - return (modified_positive, status, feedback_question_output, training_info, loss_graph, refined_sigmas) + return (modified_positive, status, feedback_question_output, training_info, loss_graph, refined_sigmas, refined_latent) + + +FunPackGemmaEmbeddingRefiner = FunPackVideoRefiner + + +class FunPackSaveRefinementLatent: + CATEGORY = "FunPack/Refinement" + RETURN_TYPES = ("LATENT", "STRING") + RETURN_NAMES = ("latent", "status") + FUNCTION = "save_latent" + DESCRIPTION = "Saves a latent tensor bundle under a refinement key for FunPack Video Refiner latent refinement." + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "latent": ("LATENT",), + "refinement_key": ("STRING", {"default": "my_style_v1", "multiline": False}), + "mode": (["ltx2", "wan"], {"default": "ltx2"}), + } + } + + @classmethod + def IS_CHANGED(cls, **kwargs): + return float("nan") + + def save_latent(self, latent, refinement_key, mode): + samples = latent_samples(latent) + if samples is None: + return (clone_latent(latent), "No latent samples tensor found.") + + path = refinement_state_path(refinement_key, mode, prefix="latent", extension="pt") + os.makedirs(os.path.dirname(path), exist_ok=True) + bundle = cpu_tensor_bundle(latent) + bundle["_meta"] = { + "refinement_key": refinement_key, + "mode": (mode or "ltx2").lower(), + "samples_shape": list(samples.shape), + } + torch.save(bundle, path) + return (clone_latent(latent), f"Saved latent for key '{refinement_key}' ({mode}) shape={tuple(samples.shape)}") class FunPackPromptCombiner: diff --git a/docs/FunPackGemmaEmbeddingRefiner.md b/docs/FunPackGemmaEmbeddingRefiner.md index c0921faa..5c454d64 100644 --- a/docs/FunPackGemmaEmbeddingRefiner.md +++ b/docs/FunPackGemmaEmbeddingRefiner.md @@ -1,6 +1,6 @@ -# FunPack Gemma Embedding Refiner +# FunPack Video Refiner -This node analyzes the positive conditioning produced by your text encoder, tracks your feedback over time, and nudges the conditioning toward directions that should score higher in future generations. +This node analyzes the positive conditioning produced by your text encoder, tracks your feedback over time, and nudges the conditioning toward directions that should score higher in future generations. It also has optional sigma and latent refinement paths for video workflows. ## Parameters @@ -30,6 +30,10 @@ This node analyzes the positive conditioning produced by your text encoder, trac **feedback_rating**: Answer to the feedback question on its own 1 to 6 scale. This is only useful when concept feedback is enabled. +**lora_stack**: Optional stack from `FunPack LoRA Loader`. The refiner uses it to save next-run model LoRA weight suggestions. + +**latent**: Optional latent input. If a latent was saved with `FunPack Save Refinement Latent` under the same `refinement_key` and `mode`, the node can return a conservatively refined latent. If no saved latent exists, or if no latent is connected, latent refinement is skipped. + ## Outputs **modified_positive**: Refined conditioning. Connect this output to the next conditioning stage in your workflow. @@ -44,10 +48,20 @@ This node analyzes the positive conditioning produced by your text encoder, trac **refined_sigmas**: Refined sigma schedule. If no `sigmas` input is connected, this output is empty. +**refined_latent**: Refined latent output. If latent refinement is inactive, this passes the input latent through unchanged or returns an empty latent when no latent input is connected. + ## Purpose Use this node when you want to iteratively tune prompt conditioning based on your own ratings instead of manually rewriting prompts every time. Over multiple runs, it builds a refinement profile for the selected `refinement_key`. +## Latent Refinement + +Use `FunPack Save Refinement Latent` to store a reference latent tensor bundle under a `refinement_key` and tokenizer `mode`. When `FunPack Video Refiner` later receives a latent input with the same key and mode, it loads that saved latent and applies a small rating-driven adjustment to the latent output. + +Latent refinement is intentionally conservative. It resizes saved tensors to the current latent shape when possible, tracks latent shape changes in the session state, and leaves zero-valued latent positions unchanged because zero values can be intentional. + +If the saved latent is missing, invalid, incompatible, or the latent input is not connected, the refiner skips latent refinement and continues with conditioning/sigma/LoRA behavior as usual. + ## Capability Boundary This node cannot make the underlying model generate concepts it does not already know how to represent. If a required subject, action, style, object, anatomy detail, or relationship is missing from the model's learned capabilities, repeating ratings will not teach it from scratch. diff --git a/docs/FunPackLoraWorkflow.md b/docs/FunPackLoraWorkflow.md index bafb9d53..1fa7213e 100644 --- a/docs/FunPackLoraWorkflow.md +++ b/docs/FunPackLoraWorkflow.md @@ -1,12 +1,12 @@ # FunPack LoRA Weight Workflow -This workflow lets `FunPack Gemma Embedding Refiner` suggest prompt-specific LoRA weights while keeping LoRA loading deterministic. +This workflow lets `FunPack Video Refiner` suggest prompt-specific LoRA weights while keeping LoRA loading deterministic. ## Node Order Use this pattern: -`FunPack Apply LoRA Weights` -> `FunPack LoRA Loader` -> `FunPack Gemma Embedding Refiner` +`FunPack Apply LoRA Weights` -> `FunPack LoRA Loader` -> `FunPack Video Refiner` The loader does not learn. The refiner owns prompt parsing, concept matching, rating interpretation, and saving suggested weights. @@ -17,7 +17,7 @@ This node defines the selected LoRAs and their base weights. Inputs: - **positive_prompt**: Exact prompt text for lookup. -- **refinement_key**: Same key used by `FunPack Gemma Embedding Refiner`. +- **refinement_key**: Same key used by `FunPack Video Refiner`. - **mode**: Same tokenizer mode as the refiner. - **lora_N**: LoRA file. - **lora_N_type**: `general`, `concept`, `style`, `quality`, or `character`. @@ -31,9 +31,9 @@ On the first run for an exact prompt, no saved suggestion exists, so base weight This node only loads the LoRA stack. It applies the model weights prepared by `FunPack Apply LoRA Weights`, then passes the same stack forward so the refiner can learn from it. The `clip` input is optional and uses zero CLIP strength when omitted. -## Gemma Refiner Integration +## Video Refiner Integration -Connect the `lora_stack` output from `FunPack LoRA Loader` into the optional `lora_stack` input on `FunPack Gemma Embedding Refiner`. +Connect the `lora_stack` output from `FunPack LoRA Loader` into the optional `lora_stack` input on `FunPack Video Refiner`. The refiner compares each LoRA's declared type and filename with the concepts it already extracted from the prompt. After processing the rating, it saves `lora_weight_suggestions` into the same prompt entry in the refiner JSON. diff --git a/docs/FunPackSaveRefinementLatent.md b/docs/FunPackSaveRefinementLatent.md new file mode 100644 index 00000000..fbef4cb0 --- /dev/null +++ b/docs/FunPackSaveRefinementLatent.md @@ -0,0 +1,21 @@ +# FunPack Save Refinement Latent + +This node saves a latent tensor bundle under a refinement key so `FunPack Video Refiner` can use it as an optional latent refinement reference on later runs. + +## Parameters + +**latent**: Latent tensor bundle to save. + +**refinement_key**: Name of the refinement session. Use the same key in `FunPack Video Refiner`. + +**mode**: Tokenizer mode namespace. Use the same mode as `FunPack Video Refiner`. + +## Outputs + +**latent**: Passthrough latent. + +**status**: Save status with the stored tensor shape. + +## Notes + +The saved latent lives in FunPack's local `refinements` folder as a PyTorch tensor file. The refiner only uses it when both the key and mode match. Zero-valued latent positions are treated as intentional and are not refined. diff --git a/docs/FunPackVideoRefiner.md b/docs/FunPackVideoRefiner.md new file mode 100644 index 00000000..aa45ded6 --- /dev/null +++ b/docs/FunPackVideoRefiner.md @@ -0,0 +1,3 @@ +# FunPack Video Refiner + +See [`FunPackGemmaEmbeddingRefiner.md`](FunPackGemmaEmbeddingRefiner.md) for the full documentation. The old filename is kept as a compatibility documentation path for users and workflows that still know the node by its former name. diff --git a/funpack.py b/funpack.py index 95157e63..aa211348 100644 --- a/funpack.py +++ b/funpack.py @@ -7,7 +7,9 @@ try: FunPackLorebookEnhancer, FunPackPromptCombiner, FunPackPromptEnhancer, + FunPackSaveRefinementLatent, FunPackStoryWriter, + FunPackVideoRefiner, ) from .image_processing import ( FunPackContinueVideo, @@ -23,7 +25,9 @@ except ImportError: FunPackLorebookEnhancer, FunPackPromptCombiner, FunPackPromptEnhancer, + FunPackSaveRefinementLatent, FunPackStoryWriter, + FunPackVideoRefiner, ) from image_processing import ( FunPackContinueVideo, @@ -44,6 +48,8 @@ except ImportError: "FunPackContinueVideo": FunPackContinueVideo, "FunPackLorebookEnhancer": FunPackLorebookEnhancer, "FunPackGemmaEmbeddingRefiner": FunPackGemmaEmbeddingRefiner, + "FunPackVideoRefiner": FunPackVideoRefiner, + "FunPackSaveRefinementLatent": FunPackSaveRefinementLatent, "FunPackHybridEuler2SSampler": FunPackHybridEuler2SSampler, "FunPackApplyLoraWeights": FunPackApplyLoraWeights, "FunPackLoraLoader": FunPackLoraLoader, @@ -58,7 +64,9 @@ except ImportError: "FunPackVideoStitch": "FunPack Video Stitch", "FunPackContinueVideo": "FunPack Continue Video", "FunPackLorebookEnhancer": "FunPack Lorebook Enhancer", - "FunPackGemmaEmbeddingRefiner": "FunPack Gemma Embedding Refiner (Self-Refinement)", + "FunPackGemmaEmbeddingRefiner": "FunPack Video Refiner (Compatibility)", + "FunPackVideoRefiner": "FunPack Video Refiner", + "FunPackSaveRefinementLatent": "FunPack Save Refinement Latent", "FunPackHybridEuler2SSampler": "FunPack Hybrid Euler 2S Sampler", "FunPackApplyLoraWeights": "FunPack Apply LoRA Weights", "FunPackLoraLoader": "FunPack LoRA Loader", @@ -68,6 +76,8 @@ __all__ = [ "NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "FunPackGemmaEmbeddingRefiner", + "FunPackSaveRefinementLatent", + "FunPackVideoRefiner", "FunPackHybridEuler2SSampler", "FunPackPromptCombiner", "FunPackLorebookEnhancer", From 69614804ca8f6545975bd59e5c57e1af4ce758e6 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:33:55 +0300 Subject: [PATCH 02/13] Make latent refiner establish references safely --- conditioning.py | 64 +++++++++++++++++++--------- docs/FunPackGemmaEmbeddingRefiner.md | 4 +- docs/FunPackSaveRefinementLatent.md | 2 +- 3 files changed, 47 insertions(+), 23 deletions(-) diff --git a/conditioning.py b/conditioning.py index de375825..1584f274 100644 --- a/conditioning.py +++ b/conditioning.py @@ -159,13 +159,9 @@ def refinement_state_path(refinement_key, mode, prefix="refine", extension="json return os.path.join(refinements_dir, f"{prefix}_{safe_key}.{extension}") -def empty_latent(): - return {"samples": torch.empty(0)} - - def clone_latent(latent): if not isinstance(latent, dict): - return empty_latent() + return None cloned = {} for key, value in latent.items(): @@ -1322,23 +1318,57 @@ class FunPackVideoRefiner: return data, "Latent: saved latent loaded." + def _save_latent_reference(self, latent, refinement_key, mode): + samples = latent_samples(latent) + if samples is None: + return False + + path = refinement_state_path(refinement_key, mode, prefix="latent", extension="pt") + os.makedirs(os.path.dirname(path), exist_ok=True) + bundle = cpu_tensor_bundle(latent) + bundle["_meta"] = { + "refinement_key": refinement_key, + "mode": (mode or "ltx2").lower(), + "samples_shape": list(samples.shape), + } + torch.save(bundle, path) + return True + + def _delete_latent_reference(self, refinement_key, mode): + path = refinement_state_path(refinement_key, mode, prefix="latent", extension="pt") + try: + os.remove(path) + return True + except FileNotFoundError: + return False + except OSError: + return False + def _refine_latent(self, latent, refinement_key, mode, rating, reward, global_adaptive): current = latent_samples(latent) if current is None: - return empty_latent(), "Latent: no latent input connected." + return None, "Latent: no latent input connected." refined_latent = clone_latent(latent) saved_bundle, load_status = self._load_saved_latent(refinement_key, mode) if saved_bundle is None: - return refined_latent, load_status + self._save_latent_reference(latent, refinement_key, mode) + global_adaptive.setdefault("latent_refinement", {})["last_shape"] = list(current.shape) + return refined_latent, f"{load_status} Current latent saved as reference." - saved_samples = self._resize_tensor_like(saved_bundle.get("samples"), current) - if saved_samples is None: - return refined_latent, "Latent: saved latent shape is incompatible, passthrough." + raw_saved_samples = saved_bundle.get("samples") + if list(raw_saved_samples.shape) != list(current.shape): + self._save_latent_reference(latent, refinement_key, mode) + latent_state = global_adaptive.setdefault("latent_refinement", {}) + latent_state.pop("momentum", None) + latent_state["last_shape"] = list(current.shape) + latent_state["saved_shape"] = list(raw_saved_samples.shape) + return refined_latent, "Latent: input shape changed. Reference rewritten, passthrough." + saved_samples = raw_saved_samples.to(device=current.device, dtype=current.dtype) latent_state = global_adaptive.setdefault("latent_refinement", {}) latent_state["last_shape"] = list(current.shape) - latent_state["saved_shape"] = list(saved_bundle["samples"].shape) + latent_state["saved_shape"] = list(raw_saved_samples.shape) momentum_data = latent_state.get("momentum") if isinstance(momentum_data, dict): @@ -1648,6 +1678,8 @@ class FunPackVideoRefiner: # ====================== RESET / NEW SESSION ====================== if reset_session or not os.path.exists(json_file): + if reset_session: + self._delete_latent_reference(refinement_key, mode) data = _fresh_data() with open(json_file, "w", encoding="utf-8") as f: json.dump(data, f, indent=2) @@ -2285,15 +2317,7 @@ class FunPackSaveRefinementLatent: if samples is None: return (clone_latent(latent), "No latent samples tensor found.") - path = refinement_state_path(refinement_key, mode, prefix="latent", extension="pt") - os.makedirs(os.path.dirname(path), exist_ok=True) - bundle = cpu_tensor_bundle(latent) - bundle["_meta"] = { - "refinement_key": refinement_key, - "mode": (mode or "ltx2").lower(), - "samples_shape": list(samples.shape), - } - torch.save(bundle, path) + FunPackVideoRefiner()._save_latent_reference(latent, refinement_key, mode) return (clone_latent(latent), f"Saved latent for key '{refinement_key}' ({mode}) shape={tuple(samples.shape)}") diff --git a/docs/FunPackGemmaEmbeddingRefiner.md b/docs/FunPackGemmaEmbeddingRefiner.md index 5c454d64..f7e36427 100644 --- a/docs/FunPackGemmaEmbeddingRefiner.md +++ b/docs/FunPackGemmaEmbeddingRefiner.md @@ -58,9 +58,9 @@ Use this node when you want to iteratively tune prompt conditioning based on you Use `FunPack Save Refinement Latent` to store a reference latent tensor bundle under a `refinement_key` and tokenizer `mode`. When `FunPack Video Refiner` later receives a latent input with the same key and mode, it loads that saved latent and applies a small rating-driven adjustment to the latent output. -Latent refinement is intentionally conservative. It resizes saved tensors to the current latent shape when possible, tracks latent shape changes in the session state, and leaves zero-valued latent positions unchanged because zero values can be intentional. +If the refiner receives a latent input but no saved latent exists yet, it passes the input latent through unchanged and saves it as the current reference. If the input latent shape changes later, it rewrites the saved reference and passes the new latent through unchanged for that run. Resetting the session deletes the saved latent reference too. -If the saved latent is missing, invalid, incompatible, or the latent input is not connected, the refiner skips latent refinement and continues with conditioning/sigma/LoRA behavior as usual. +Latent refinement is intentionally conservative. It tracks latent shape changes in the session state and leaves zero-valued latent positions unchanged because zero values can be intentional. If the latent input is not connected, the refiner skips latent refinement and continues with conditioning/sigma/LoRA behavior as usual. ## Capability Boundary diff --git a/docs/FunPackSaveRefinementLatent.md b/docs/FunPackSaveRefinementLatent.md index fbef4cb0..7791e024 100644 --- a/docs/FunPackSaveRefinementLatent.md +++ b/docs/FunPackSaveRefinementLatent.md @@ -18,4 +18,4 @@ This node saves a latent tensor bundle under a refinement key so `FunPack Video ## Notes -The saved latent lives in FunPack's local `refinements` folder as a PyTorch tensor file. The refiner only uses it when both the key and mode match. Zero-valued latent positions are treated as intentional and are not refined. +The saved latent lives in FunPack's local `refinements` folder as a PyTorch tensor file. The refiner only uses it when both the key and mode match. If the refiner receives a latent and no saved reference exists, it saves the incoming latent automatically. Zero-valued latent positions are treated as intentional and are not refined. From 1f26b45f1b6e29eb63e980b3de653581343b6e44 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:43:42 +0300 Subject: [PATCH 03/13] Align latent refinement connection logic --- conditioning.py | 144 ++++++++++++++++++++++++--- docs/FunPackGemmaEmbeddingRefiner.md | 18 +++- docs/FunPackSaveRefinementLatent.md | 2 +- 3 files changed, 145 insertions(+), 19 deletions(-) diff --git a/conditioning.py b/conditioning.py index 1584f274..20dff205 100644 --- a/conditioning.py +++ b/conditioning.py @@ -186,7 +186,26 @@ def cpu_tensor_bundle(latent): bundle[key] = value.detach().cpu().clone() return bundle + +def latent_from_tensor_bundle(bundle): + if not isinstance(bundle, dict): + return None + + latent = {} + for key, value in bundle.items(): + if key == "_meta": + continue + latent[key] = value.detach().clone() if isinstance(value, torch.Tensor) else value + return latent if latent_samples(latent) is not None else None + + class FunPackVideoRefiner: + LATENT_OUTPUT_INDEX = 6 + NO_LATENT_REFERENCE_ERROR = "No available latent to operate. Please connect reference latent to input of Video Refiner." + SAVED_LATENT_ONLY_STATUS = ( + "Running refinement on saved latent. Changing reference latent shape and size will cause no effect to generation." + ) + _tokenizers = {} _tokenizer_sources = { "ltx2": [ @@ -441,6 +460,10 @@ class FunPackVideoRefiner: "latent": ("LATENT", { "tooltip": "Optional latent to refine. If no saved latent exists for this key, it passes through unchanged." }), + }, + "hidden": { + "prompt": "PROMPT", + "unique_id": "UNIQUE_ID", } } @@ -1318,6 +1341,58 @@ class FunPackVideoRefiner: return data, "Latent: saved latent loaded." + def _value_links_to_output(self, value, source_id, output_index): + if isinstance(value, (list, tuple)): + if len(value) >= 2 and str(value[0]) == source_id: + try: + if int(value[1]) == output_index: + return True + except (TypeError, ValueError): + pass + return any(self._value_links_to_output(item, source_id, output_index) for item in value) + + if isinstance(value, dict): + return any(self._value_links_to_output(item, source_id, output_index) for item in value.values()) + + return False + + def _is_output_connected(self, prompt, unique_id, output_index): + if prompt is None or unique_id is None: + return False + + source_id = str(unique_id) + prompt_nodes = prompt.get("output", prompt) if isinstance(prompt, dict) else {} + if not isinstance(prompt_nodes, dict): + return False + + for node in prompt_nodes.values(): + if not isinstance(node, dict): + continue + inputs = node.get("inputs", {}) + if isinstance(inputs, dict) and self._value_links_to_output(inputs, source_id, output_index): + return True + + workflow = prompt.get("workflow") if isinstance(prompt, dict) else None + links = workflow.get("links", []) if isinstance(workflow, dict) else [] + for link in links: + if isinstance(link, (list, tuple)) and len(link) >= 3: + try: + if str(link[1]) == source_id and int(link[2]) == output_index: + return True + except (TypeError, ValueError): + continue + elif isinstance(link, dict): + try: + if str(link.get("origin_id")) == source_id and int(link.get("origin_slot")) == output_index: + return True + except (TypeError, ValueError): + continue + + return False + + def _latent_refinement_disabled(self, latent): + return clone_latent(latent), "Latent: disabled because latent refinement input/output path is not fully connected." + def _save_latent_reference(self, latent, refinement_key, mode): samples = latent_samples(latent) if samples is None: @@ -1346,11 +1421,24 @@ class FunPackVideoRefiner: def _refine_latent(self, latent, refinement_key, mode, rating, reward, global_adaptive): current = latent_samples(latent) + saved_bundle, load_status = self._load_saved_latent(refinement_key, mode) + if current is None: - return None, "Latent: no latent input connected." + if saved_bundle is None: + raise ValueError(self.NO_LATENT_REFERENCE_ERROR) + + saved_latent = latent_from_tensor_bundle(saved_bundle) + saved_samples = latent_samples(saved_latent) + if saved_samples is None: + raise ValueError(self.NO_LATENT_REFERENCE_ERROR) + + latent_state = global_adaptive.setdefault("latent_refinement", {}) + latent_state["last_shape"] = list(saved_samples.shape) + latent_state["saved_shape"] = list(saved_samples.shape) + latent_state["status"] = self.SAVED_LATENT_ONLY_STATUS + return saved_latent, latent_state["status"] refined_latent = clone_latent(latent) - saved_bundle, load_status = self._load_saved_latent(refinement_key, mode) if saved_bundle is None: self._save_latent_reference(latent, refinement_key, mode) global_adaptive.setdefault("latent_refinement", {})["last_shape"] = list(current.shape) @@ -1389,7 +1477,7 @@ class FunPackVideoRefiner: return refined_latent, latent_state["status"] lr = 0.035 if rating >= 7 else 0.055 if rating <= 4 else 0.018 - target_delta = (current - saved_samples) * nonzero_mask.to(dtype=current.dtype) + target_delta = (saved_samples - current) * nonzero_mask.to(dtype=current.dtype) update = target_delta * (reward * lr) momentum = 0.82 * momentum + 0.18 * update max_step = max(0.001, min(0.08, float(current.detach().abs().mean().item()) * 0.18)) @@ -1588,7 +1676,8 @@ class FunPackVideoRefiner: scheduler_mode: str = "original", positive_prompt: str = "", reset_session: bool = False, unlimited_history: bool = False, seed: int = 0, feedback_enabled: bool = False, feedback_rating: int = 3, - sigmas=None, sigma_strength: str = "subtle", lora_stack=None, latent=None): + sigmas=None, sigma_strength: str = "subtle", lora_stack=None, latent=None, + prompt=None, unique_id=None): mode = (mode or "ltx2").lower() if mode not in self._tokenizer_sources: @@ -1611,7 +1700,9 @@ class FunPackVideoRefiner: points=[], ) fallback_sigmas = sigmas.detach().clone() if isinstance(sigmas, torch.Tensor) else torch.FloatTensor([]) + latent_output_connected = self._is_output_connected(prompt, unique_id, self.LATENT_OUTPUT_INDEX) fallback_latent = clone_latent(latent) + fallback_latent_status = "Latent: not evaluated before conditioning validation." if not positive_conditioning or not isinstance(positive_conditioning, list) or len(positive_conditioning) == 0: return (positive_conditioning, "ERROR: Empty positive CONDITIONING input", "", "ERROR: No positive conditioning", fallback_loss_graph, fallback_sigmas, fallback_latent) @@ -1680,10 +1771,21 @@ class FunPackVideoRefiner: if reset_session or not os.path.exists(json_file): if reset_session: self._delete_latent_reference(refinement_key, mode) + if latent_output_connected: + fallback_latent, fallback_latent_status = self._refine_latent( + latent, + refinement_key, + mode, + rating, + (rating - 5.5) / 4.5, + {}, + ) + else: + fallback_latent, fallback_latent_status = self._latent_refinement_disabled(latent) data = _fresh_data() with open(json_file, "w", encoding="utf-8") as f: json.dump(data, f, indent=2) - return (positive_conditioning, "New session started - Reference saved", "", "New session started. Reference embedding saved.", fallback_loss_graph, fallback_sigmas, fallback_latent) + return (positive_conditioning, "New session started - Reference saved", "", f"New session started. Reference embedding saved.\n{fallback_latent_status}", fallback_loss_graph, fallback_sigmas, fallback_latent) # ====================== SAFE JSON LOAD ====================== try: @@ -1695,10 +1797,21 @@ class FunPackVideoRefiner: os.remove(json_file) except OSError: pass + if latent_output_connected: + fallback_latent, fallback_latent_status = self._refine_latent( + latent, + refinement_key, + mode, + rating, + (rating - 5.5) / 4.5, + {}, + ) + else: + fallback_latent, fallback_latent_status = self._latent_refinement_disabled(latent) data = _fresh_data() with open(json_file, "w", encoding="utf-8") as f: json.dump(data, f, indent=2) - return (positive_conditioning, "Session file was corrupt - Reset and started fresh", "", "Session reset due to corrupt file", fallback_loss_graph, fallback_sigmas, fallback_latent) + return (positive_conditioning, "Session file was corrupt - Reset and started fresh", "", f"Session reset due to corrupt file\n{fallback_latent_status}", fallback_loss_graph, fallback_sigmas, fallback_latent) global_adaptive = data["global_adaptive"] # Migrate sessions created before the multi-level concept system was added @@ -2098,14 +2211,17 @@ class FunPackVideoRefiner: sigma_strength, seed, ) - refined_latent, latent_status = self._refine_latent( - latent, - refinement_key, - mode, - rating, - reward, - global_adaptive, - ) + if latent_output_connected: + refined_latent, latent_status = self._refine_latent( + latent, + refinement_key, + mode, + rating, + reward, + global_adaptive, + ) + else: + refined_latent, latent_status = self._latent_refinement_disabled(latent) # ====================== INTELLIGENT CONCEPT FEEDBACK SELECTION ====================== # Chooses one concept/question pair per run using category-aware scoring. diff --git a/docs/FunPackGemmaEmbeddingRefiner.md b/docs/FunPackGemmaEmbeddingRefiner.md index f7e36427..072b069a 100644 --- a/docs/FunPackGemmaEmbeddingRefiner.md +++ b/docs/FunPackGemmaEmbeddingRefiner.md @@ -32,7 +32,7 @@ This node analyzes the positive conditioning produced by your text encoder, trac **lora_stack**: Optional stack from `FunPack LoRA Loader`. The refiner uses it to save next-run model LoRA weight suggestions. -**latent**: Optional latent input. If a latent was saved with `FunPack Save Refinement Latent` under the same `refinement_key` and `mode`, the node can return a conservatively refined latent. If no saved latent exists, or if no latent is connected, latent refinement is skipped. +**latent**: Optional latent input. It is only used when the `refined_latent` output is also connected. If no saved latent exists yet, the node uses the connected input latent as the current reference. ## Outputs @@ -48,7 +48,7 @@ This node analyzes the positive conditioning produced by your text encoder, trac **refined_sigmas**: Refined sigma schedule. If no `sigmas` input is connected, this output is empty. -**refined_latent**: Refined latent output. If latent refinement is inactive, this passes the input latent through unchanged or returns an empty latent when no latent input is connected. +**refined_latent**: Refined latent output. Connecting this output enables latent refinement logic; leaving it disconnected disables latent refinement even if the latent input is connected. ## Purpose @@ -58,9 +58,19 @@ Use this node when you want to iteratively tune prompt conditioning based on you Use `FunPack Save Refinement Latent` to store a reference latent tensor bundle under a `refinement_key` and tokenizer `mode`. When `FunPack Video Refiner` later receives a latent input with the same key and mode, it loads that saved latent and applies a small rating-driven adjustment to the latent output. -If the refiner receives a latent input but no saved latent exists yet, it passes the input latent through unchanged and saves it as the current reference. If the input latent shape changes later, it rewrites the saved reference and passes the new latent through unchanged for that run. Resetting the session deletes the saved latent reference too. +Latent refinement follows the connection state exactly: -Latent refinement is intentionally conservative. It tracks latent shape changes in the session state and leaves zero-valued latent positions unchanged because zero values can be intentional. If the latent input is not connected, the refiner skips latent refinement and continues with conditioning/sigma/LoRA behavior as usual. +- If the latent input is disconnected and `refined_latent` is disconnected, latent tweaking is disabled. +- If the latent input is disconnected, `refined_latent` is connected, and no saved latent exists, the node errors with: `No available latent to operate. Please connect reference latent to input of Video Refiner.` +- If the latent input is disconnected, `refined_latent` is connected, and a saved latent exists, the node reports: `Running refinement on saved latent. Changing reference latent shape and size will cause no effect to generation.` +- If the latent input is connected and `refined_latent` is disconnected, latent tweaking is disabled. +- If both latent input and `refined_latent` are connected but no saved latent exists, the node passes the input latent through unchanged and saves it as the current reference. +- If both are connected and the saved latent shape does not match the current input, the node rewrites the reference and passes the current input through unchanged. +- If both are connected and the saved latent shape matches the current input, the node tweaks the latent using the connected input as the base and the saved latent as the learned reference. + +Resetting the session deletes the saved latent reference too. + +Latent refinement is intentionally conservative. It tracks latent shape changes in the session state and leaves zero-valued latent positions unchanged because zero values can be intentional. ## Capability Boundary diff --git a/docs/FunPackSaveRefinementLatent.md b/docs/FunPackSaveRefinementLatent.md index 7791e024..5eab88e1 100644 --- a/docs/FunPackSaveRefinementLatent.md +++ b/docs/FunPackSaveRefinementLatent.md @@ -18,4 +18,4 @@ This node saves a latent tensor bundle under a refinement key so `FunPack Video ## Notes -The saved latent lives in FunPack's local `refinements` folder as a PyTorch tensor file. The refiner only uses it when both the key and mode match. If the refiner receives a latent and no saved reference exists, it saves the incoming latent automatically. Zero-valued latent positions are treated as intentional and are not refined. +The saved latent lives in FunPack's local `refinements` folder as a PyTorch tensor file. The refiner only uses it when both the key and mode match and the `refined_latent` output is connected. If the refiner receives a latent and no saved reference exists, it saves the incoming latent automatically. Zero-valued latent positions are treated as intentional and are not refined. From 99bd659db5875198cfdc8b77d3e0f7eae47aa164 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:58:37 +0300 Subject: [PATCH 04/13] Validate video latent refinement inputs --- conditioning.py | 37 +++++++++++++++++++++++++++- docs/FunPackGemmaEmbeddingRefiner.md | 12 ++++++++- docs/FunPackSaveRefinementLatent.md | 4 ++- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/conditioning.py b/conditioning.py index 20dff205..76ae368a 100644 --- a/conditioning.py +++ b/conditioning.py @@ -176,6 +176,20 @@ def latent_samples(latent): return samples if isinstance(samples, torch.Tensor) else None +def latent_sample_type_name(latent): + if not isinstance(latent, dict): + return "missing" + samples = latent.get("samples") + if samples is None: + return "missing samples" + return f"{type(samples).__module__}.{type(samples).__name__}" + + +def latent_is_plain_video_tensor(latent): + samples = latent_samples(latent) + return samples is not None and latent.get("type") != "audio" + + def cpu_tensor_bundle(latent): if not isinstance(latent, dict): return {} @@ -202,6 +216,12 @@ def latent_from_tensor_bundle(bundle): class FunPackVideoRefiner: LATENT_OUTPUT_INDEX = 6 NO_LATENT_REFERENCE_ERROR = "No available latent to operate. Please connect reference latent to input of Video Refiner." + WRONG_LATENT_ERROR = ( + "Video Refiner latent input must be a plain video LATENT with tensor samples. " + "Audio latents and LTX audio/video combined NestedTensor latents are not supported here. " + "Connect only the video latent to Video Refiner, then feed the refined video latent into " + "LTXVConcatAVLatent as video_latent." + ) SAVED_LATENT_ONLY_STATUS = ( "Running refinement on saved latent. Changing reference latent shape and size will cause no effect to generation." ) @@ -1393,6 +1413,11 @@ class FunPackVideoRefiner: def _latent_refinement_disabled(self, latent): return clone_latent(latent), "Latent: disabled because latent refinement input/output path is not fully connected." + def _raise_wrong_latent(self, latent): + sample_type = latent_sample_type_name(latent) + latent_type = latent.get("type", "unspecified") if isinstance(latent, dict) else "missing" + raise ValueError(f"{self.WRONG_LATENT_ERROR} Received samples={sample_type}, type={latent_type}.") + def _save_latent_reference(self, latent, refinement_key, mode): samples = latent_samples(latent) if samples is None: @@ -1420,6 +1445,9 @@ class FunPackVideoRefiner: return False def _refine_latent(self, latent, refinement_key, mode, rating, reward, global_adaptive): + if isinstance(latent, dict) and not latent_is_plain_video_tensor(latent): + self._raise_wrong_latent(latent) + current = latent_samples(latent) saved_bundle, load_status = self._load_saved_latent(refinement_key, mode) @@ -1485,6 +1513,10 @@ class FunPackVideoRefiner: refined_samples = current + latent_delta refined_samples = torch.where(nonzero_mask, refined_samples, current) + refined_samples = torch.nan_to_num(refined_samples, nan=0.0) + range_min = torch.minimum(current, saved_samples) + range_max = torch.maximum(current, saved_samples) + refined_samples = torch.maximum(torch.minimum(refined_samples, range_max), range_min) refined_latent["samples"] = refined_samples latent_state["momentum"] = tensor_to_serializable(momentum.detach().cpu()) @@ -1493,7 +1525,7 @@ class FunPackVideoRefiner: latent_state["nonzero_ratio"] = round(float(nonzero_mask.float().mean().item()), 6) latent_state["status"] = ( f"Latent: adjusted shape {tuple(current.shape)} | nonzero {latent_state['nonzero_ratio']:.1%} | " - f"max step {max_step:.5f}" + f"max step {max_step:.5f} | bounded to input/reference range" ) return refined_latent, latent_state["status"] @@ -2429,6 +2461,9 @@ class FunPackSaveRefinementLatent: return float("nan") def save_latent(self, latent, refinement_key, mode): + if isinstance(latent, dict) and not latent_is_plain_video_tensor(latent): + FunPackVideoRefiner()._raise_wrong_latent(latent) + samples = latent_samples(latent) if samples is None: return (clone_latent(latent), "No latent samples tensor found.") diff --git a/docs/FunPackGemmaEmbeddingRefiner.md b/docs/FunPackGemmaEmbeddingRefiner.md index 072b069a..cdc711dc 100644 --- a/docs/FunPackGemmaEmbeddingRefiner.md +++ b/docs/FunPackGemmaEmbeddingRefiner.md @@ -58,6 +58,16 @@ Use this node when you want to iteratively tune prompt conditioning based on you Use `FunPack Save Refinement Latent` to store a reference latent tensor bundle under a `refinement_key` and tokenizer `mode`. When `FunPack Video Refiner` later receives a latent input with the same key and mode, it loads that saved latent and applies a small rating-driven adjustment to the latent output. +For LTX audio/video workflows, the latent path must use video latents only: + +- Split sampler output with `LTXVSeparateAVLatent`. +- Send the video latent output to `FunPack Save Refinement Latent`. +- Send the plain video latent to `FunPack Video Refiner` latent input. +- Send `FunPack Video Refiner` `refined_latent` to `LTXVConcatAVLatent` `video_latent`. +- Send the original audio latent to `LTXVConcatAVLatent` `audio_latent`. + +Do not connect the combined AV latent from `LTXVConcatAVLatent`, the sampler AV output, or the audio latent to this node. Those use LTX nested/audio latent structures and are intentionally rejected with a clear error. + Latent refinement follows the connection state exactly: - If the latent input is disconnected and `refined_latent` is disconnected, latent tweaking is disabled. @@ -66,7 +76,7 @@ Latent refinement follows the connection state exactly: - If the latent input is connected and `refined_latent` is disconnected, latent tweaking is disabled. - If both latent input and `refined_latent` are connected but no saved latent exists, the node passes the input latent through unchanged and saves it as the current reference. - If both are connected and the saved latent shape does not match the current input, the node rewrites the reference and passes the current input through unchanged. -- If both are connected and the saved latent shape matches the current input, the node tweaks the latent using the connected input as the base and the saved latent as the learned reference. +- If both are connected and the saved latent shape matches the current input, the node tweaks the latent using the connected input as the base and the saved latent as the learned reference. The refined tensor is bounded to the current/reference value range to avoid out-of-range latent values. Resetting the session deletes the saved latent reference too. diff --git a/docs/FunPackSaveRefinementLatent.md b/docs/FunPackSaveRefinementLatent.md index 5eab88e1..c3506309 100644 --- a/docs/FunPackSaveRefinementLatent.md +++ b/docs/FunPackSaveRefinementLatent.md @@ -4,7 +4,7 @@ This node saves a latent tensor bundle under a refinement key so `FunPack Video ## Parameters -**latent**: Latent tensor bundle to save. +**latent**: Plain video latent tensor bundle to save. In LTX audio/video workflows, connect the video output from `LTXVSeparateAVLatent`, not the audio latent or combined AV latent. **refinement_key**: Name of the refinement session. Use the same key in `FunPack Video Refiner`. @@ -19,3 +19,5 @@ This node saves a latent tensor bundle under a refinement key so `FunPack Video ## Notes The saved latent lives in FunPack's local `refinements` folder as a PyTorch tensor file. The refiner only uses it when both the key and mode match and the `refined_latent` output is connected. If the refiner receives a latent and no saved reference exists, it saves the incoming latent automatically. Zero-valued latent positions are treated as intentional and are not refined. + +Audio latents and LTX audio/video combined `NestedTensor` latents are intentionally rejected. Use only the video latent side, then reconnect the refined result to `LTXVConcatAVLatent` as `video_latent`. From eafac4e61ad61723b21c95035b894ce2bc032219 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Thu, 23 Apr 2026 19:04:02 +0300 Subject: [PATCH 05/13] Accept LTX separated video latents --- conditioning.py | 11 +++++++++-- docs/FunPackGemmaEmbeddingRefiner.md | 2 ++ docs/FunPackSaveRefinementLatent.md | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/conditioning.py b/conditioning.py index 76ae368a..6093fdee 100644 --- a/conditioning.py +++ b/conditioning.py @@ -182,12 +182,19 @@ def latent_sample_type_name(latent): samples = latent.get("samples") if samples is None: return "missing samples" - return f"{type(samples).__module__}.{type(samples).__name__}" + shape = tuple(samples.shape) if isinstance(samples, torch.Tensor) else "unknown" + return f"{type(samples).__module__}.{type(samples).__name__}, shape={shape}" def latent_is_plain_video_tensor(latent): samples = latent_samples(latent) - return samples is not None and latent.get("type") != "audio" + if samples is None: + return False + if samples.dim() == 5: + return True + if samples.dim() == 4 and latent.get("type") != "audio": + return True + return False def cpu_tensor_bundle(latent): diff --git a/docs/FunPackGemmaEmbeddingRefiner.md b/docs/FunPackGemmaEmbeddingRefiner.md index cdc711dc..f7bfb043 100644 --- a/docs/FunPackGemmaEmbeddingRefiner.md +++ b/docs/FunPackGemmaEmbeddingRefiner.md @@ -68,6 +68,8 @@ For LTX audio/video workflows, the latent path must use video latents only: Do not connect the combined AV latent from `LTXVConcatAVLatent`, the sampler AV output, or the audio latent to this node. Those use LTX nested/audio latent structures and are intentionally rejected with a clear error. +Some LTX separated video latents keep `type: audio` metadata even after `LTXVSeparateAVLatent`; the node accepts those when their `samples` tensor is the 5D video tensor shape. + Latent refinement follows the connection state exactly: - If the latent input is disconnected and `refined_latent` is disconnected, latent tweaking is disabled. diff --git a/docs/FunPackSaveRefinementLatent.md b/docs/FunPackSaveRefinementLatent.md index c3506309..84c01a49 100644 --- a/docs/FunPackSaveRefinementLatent.md +++ b/docs/FunPackSaveRefinementLatent.md @@ -20,4 +20,4 @@ This node saves a latent tensor bundle under a refinement key so `FunPack Video The saved latent lives in FunPack's local `refinements` folder as a PyTorch tensor file. The refiner only uses it when both the key and mode match and the `refined_latent` output is connected. If the refiner receives a latent and no saved reference exists, it saves the incoming latent automatically. Zero-valued latent positions are treated as intentional and are not refined. -Audio latents and LTX audio/video combined `NestedTensor` latents are intentionally rejected. Use only the video latent side, then reconnect the refined result to `LTXVConcatAVLatent` as `video_latent`. +Audio latents and LTX audio/video combined `NestedTensor` latents are intentionally rejected. Use only the video latent side, then reconnect the refined result to `LTXVConcatAVLatent` as `video_latent`. If a separated LTX video latent still reports `type: audio`, it is accepted when its `samples` tensor has the 5D video latent shape. From 7f37c289d8349d40afb4061d6f94c8c61def35e8 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Thu, 23 Apr 2026 19:36:39 +0300 Subject: [PATCH 06/13] Clarify dev branch support policy --- README.md | 95 ++-------------------------- docs/FunPackGemmaEmbeddingRefiner.md | 4 -- 2 files changed, 4 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index 17181df0..fe53981a 100644 --- a/README.md +++ b/README.md @@ -1,94 +1,7 @@ -# ComfyUI-FunPack +# ComfyUI-FunPack Dev Branch -A set of ComfyUI nodes for experimenting with video generation workflows based on WAN, HunyuanVideo, LTX, and similar models. +This is the development branch of ComfyUI-FunPack. -## What's New in 2.0.0 +Features on this branch may be unfinished, unstable, broken, or changed without notice. If you want a stable version of this node set, do not use the `dev` branch. Use the current version from the `main` branch instead. -FunPack is now split into focused Python modules for easier editing and maintenance: - -- `conditioning.py` -- `samplers.py` -- `image_processing.py` -- `model_management.py` - -Version 2.0.0 also adds a prompt-exact LoRA weight workflow that works together with `FunPack Video Refiner`. - -Use this order: - -`FunPack Apply LoRA Weights` -> `FunPack LoRA Loader` -> `FunPack Video Refiner` - -`FunPack Apply LoRA Weights` defines selected LoRAs, LoRA type, and model base weights. If the refiner has saved suggested weights for the exact prompt, it applies them; otherwise it uses the base weights. - -`FunPack LoRA Loader` loads the prepared LoRA stack into the model. Its CLIP input is optional and is left untouched when omitted. - -`FunPack Video Refiner` does the prompt/concept/rating work and saves next-run LoRA weight suggestions into its existing refinement JSON. It can improve the balance and stability of concepts the model can already express, but it cannot teach a model missing concepts or capabilities from scratch. - -`FunPack Save Refinement Latent` can save a latent tensor bundle under the same refinement key. If a saved latent and an optional latent input are connected to `FunPack Video Refiner`, the refiner can also produce a conservative refined latent output while preserving zero-valued latent positions. - -## Installation - -FunPack is available on Comfy Registry and can be installed in any of these ways: - -1. With `comfy-cli`: - `comfy node install ComfyUI-FunPack` -2. With git, inside your `ComfyUI/custom_nodes` directory: - `git clone https://github.com/digital-garbage/ComfyUI-FunPack` -3. With ComfyUI-Manager: - Open `Custom Nodes Manager`, search for `ComfyUI-FunPack`, and click `Install`. - -## Dependencies - -FunPack now includes a [`requirements.txt`](requirements.txt) file for the Python packages it expects outside the standard library. - -Install them with: - -`pip install -r requirements.txt` - -The requirements file intentionally avoids pip command flags because some ComfyUI/registry installers treat those as invalid requirement entries. - -FunPack expects your existing ComfyUI environment to already have a compatible PyTorch install, and the requirements file only lists the extra Python packages this node set needs. - -The expected baseline is: - -- `torch >= 2.8.0` -- `transformers >= 5.0.0` - -Higher Torch versions are fine. The important part is avoiding older Torch releases that may break FunPack workflows. If your ComfyUI environment has an older Torch build, upgrade Torch in that environment separately instead of through `requirements.txt`. - -## Known Limitation - -`FunPack Video Refiner` can optionally refine `SIGMAS`, but that currently breaks audio generation in workflows that also produce audio streams. If you need audio to generate and mux correctly, do not connect `sigmas` to the refiner. Leave sigma schedules on their original path and use the refiner only for conditioning. - -UPD: This is not caused by Embedding Refiner node. Updating to latest nightly version of ComfyUI solves the problem. - -`hpsv3` is still an optional dependency used only by the `FunPack StoryMem Keyframe Extractor` quality filter, so it is not included in the default requirements. - -Install it manually only if you need that feature: - -`pip3 install hpsv3 --no-build-isolation` - -## Important Note About `hpsv3` - -Installing `hpsv3` can break `Prompt Enhancer` and `Story Writer`, because `hpsv3` depends on a `transformers` version that conflicts with the version those LLM-based nodes require. - -FunPack's LLM nodes require `transformers >= 5.0`. - -If you install `hpsv3`, use `--no-build-isolation`. Otherwise the install may appear to succeed while the node still cannot detect a working `hpsv3` package. - -## Documentation - -Per-node documentation is available in the [`docs`](docs) folder. - -The LoRA/refiner helper workflow is documented in [`docs/FunPackLoraWorkflow.md`](docs/FunPackLoraWorkflow.md). - -The video refiner is documented in [`docs/FunPackVideoRefiner.md`](docs/FunPackVideoRefiner.md). - -The latent saver helper is documented in [`docs/FunPackSaveRefinementLatent.md`](docs/FunPackSaveRefinementLatent.md). - -Version history is available in [CHANGELOG.md](CHANGELOG.md). - -## Feedback - -If you have suggestions, questions, or ideas for new nodes, feel free to open an Issue or submit a pull request. - -Thanks to teams of OpenAI, xAI, DeepSeek, Anthropic and Google and their respective AI large language models for providing all the help with the code and transforming my stupid ideas into something actually running in your UI. Thanks to all testers and users who use FunPack in their workflows daily and provide feedback in any way. Without all of you, this would have been a dream of a wannabe coder nerd. +Issues with nodes from the `dev` branch will not be reviewed, and reports about them will be ignored. Feedback is accepted only for issues with the current version of nodes present on the `main` branch. diff --git a/docs/FunPackGemmaEmbeddingRefiner.md b/docs/FunPackGemmaEmbeddingRefiner.md index f7bfb043..732a44f5 100644 --- a/docs/FunPackGemmaEmbeddingRefiner.md +++ b/docs/FunPackGemmaEmbeddingRefiner.md @@ -89,7 +89,3 @@ Latent refinement is intentionally conservative. It tracks latent shape changes This node cannot make the underlying model generate concepts it does not already know how to represent. If a required subject, action, style, object, anatomy detail, or relationship is missing from the model's learned capabilities, repeating ratings will not teach it from scratch. The refiner can only adjust the strength, balance, stability, and emphasis of concepts that are already present in the model's output space. Use model choice, LoRAs, ControlNet/reference tools, better source images, or prompt changes when a concept is absent rather than merely under-weighted. - -## Warning - -Sigma refinement currently breaks audio generation in workflows that also produce audio streams. If you need audio to generate and save correctly, do not connect `sigmas` to this node. Use the refiner only for conditioning in those workflows. From 6b84aba0ec85346236cb467f4b0d8e870d7c1408 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:12:17 +0300 Subject: [PATCH 07/13] Let refiner mute harmful LoRAs --- conditioning.py | 62 +++++++++++++++++++++++++++++-------- docs/FunPackLoraWorkflow.md | 1 + 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/conditioning.py b/conditioning.py index 6093fdee..d55a78b8 100644 --- a/conditioning.py +++ b/conditioning.py @@ -19,11 +19,11 @@ from transformers import AutoConfig, AutoModelForCausalLM, AutoTokenizer import folder_paths LORA_REFINER_TYPE_PROFILES = { - "general": {"step": 0.025, "max_offset": 0.20, "bad_max_offset": 0.45}, - "concept": {"step": 0.040, "max_offset": 0.35, "bad_max_offset": 0.70}, - "style": {"step": 0.032, "max_offset": 0.28, "bad_max_offset": 0.58}, - "quality": {"step": 0.022, "max_offset": 0.18, "bad_max_offset": 0.38}, - "character": {"step": 0.024, "max_offset": 0.20, "bad_max_offset": 0.42}, + "general": {"step": 0.025, "max_offset": 0.20, "min_offset": -0.35, "bad_max_offset": 0.45, "bad_min_offset": -1.35, "culprit_bias": 0.28}, + "concept": {"step": 0.040, "max_offset": 0.35, "min_offset": -0.40, "bad_max_offset": 0.70, "bad_min_offset": -1.80, "culprit_bias": 0.08}, + "style": {"step": 0.032, "max_offset": 0.28, "min_offset": -0.38, "bad_max_offset": 0.58, "bad_min_offset": -1.55, "culprit_bias": 0.20}, + "quality": {"step": 0.022, "max_offset": 0.18, "min_offset": -0.30, "bad_max_offset": 0.38, "bad_min_offset": -1.20, "culprit_bias": 0.18}, + "character": {"step": 0.024, "max_offset": 0.20, "min_offset": -0.32, "bad_max_offset": 0.42, "bad_min_offset": -1.30, "culprit_bias": 0.10}, } @@ -1561,8 +1561,10 @@ class FunPackVideoRefiner: "offset_ratio": 0.0, "stable_offset_ratio": None, "reward_ema": 0.0, + "culprit_score": 0.0, "good_streak": 0, "bad_streak": 0, + "culprit_hits": 0, "iterations": 0, }, ) @@ -1571,8 +1573,10 @@ class FunPackVideoRefiner: state.setdefault("offset_ratio", 0.0) state.setdefault("stable_offset_ratio", None) state.setdefault("reward_ema", 0.0) + state.setdefault("culprit_score", 0.0) state.setdefault("good_streak", 0) state.setdefault("bad_streak", 0) + state.setdefault("culprit_hits", 0) state.setdefault("iterations", 0) return lora_id, state @@ -1645,47 +1649,78 @@ class FunPackVideoRefiner: offset = float(state.get("offset_ratio", 0.0)) stable_offset = state.get("stable_offset_ratio") + culprit_score = float(state.get("culprit_score", 0.0)) + culprit_hits = int(state.get("culprit_hits", 0)) if stable_offset is not None and rating >= 6: offset = 0.72 * offset + 0.28 * float(stable_offset) step = profile["step"] * max(0.15, relation) max_offset = profile["max_offset"] - if relation <= 0.05: - offset *= 0.94 - elif rating >= 8: + min_offset = profile["min_offset"] + effective_relation = max(relation, profile.get("culprit_bias", 0.0)) + base_model = float(entry.get("base_model_weight", entry.get("model_weight", 1.0))) + base_abs = abs(base_model) + + if rating >= 8: value_mult = 0.75 + min(1.4, max(0.5, concept_importance)) * 0.25 offset += step * (0.45 + max(0.0, reward)) * value_mult + culprit_score *= 0.72 if state["good_streak"] >= 3: if stable_offset is None: stable_offset = offset else: stable_offset = 0.78 * float(stable_offset) + 0.22 * offset - state["stable_offset_ratio"] = _clamp(stable_offset, -max_offset, max_offset) + state["stable_offset_ratio"] = _clamp(stable_offset, min_offset, max_offset) offset = state["stable_offset_ratio"] elif rating <= 4: severity = _clamp((5.0 - float(rating)) / 4.0, 0.0, 1.0) + culprit_signal = max(0.20, effective_relation) * (0.70 + base_abs * 0.30) + culprit_score = _clamp(culprit_score * 0.72 + severity * culprit_signal, 0.0, 2.5) + culprit_hits = culprit_hits + 1 if culprit_score >= 0.45 else max(0, culprit_hits - 1) max_offset = profile["bad_max_offset"] if rating <= 2 else max(max_offset, profile["bad_max_offset"] * 0.72) - offset -= step * (1.0 + severity * 3.0) * max(0.6, relation) + bad_floor_strength = min(1.0, 0.45 + 0.35 * culprit_score + 0.14 * state["bad_streak"]) + min_offset = min(min_offset, profile["bad_min_offset"] * bad_floor_strength) + offset -= step * (1.0 + severity * 3.0) * max(0.4, effective_relation) * max(0.8, 0.85 + culprit_score) + if state["bad_streak"] >= 2: + offset -= step * (0.65 + severity * 1.8) * max(0.25, effective_relation) if state["bad_streak"] >= 2: state["stable_offset_ratio"] = None + if state["bad_streak"] >= 3 and culprit_score >= 0.85 and abs(1.0 + offset) < 0.08: + offset = -1.0 else: - if stable_offset is not None: + culprit_score *= 0.92 + culprit_hits = max(0, culprit_hits - 1) + if relation <= 0.05: + offset *= 0.94 + elif stable_offset is not None: offset = 0.65 * offset + 0.35 * float(stable_offset) else: offset *= 0.90 - offset = _clamp(offset, -max_offset, max_offset) + offset = _clamp(offset, min_offset, max_offset) state["offset_ratio"] = offset + state["culprit_score"] = culprit_score + state["culprit_hits"] = culprit_hits state["iterations"] = int(state.get("iterations", 0)) + 1 - base_model = float(entry.get("base_model_weight", entry.get("model_weight", 1.0))) model_weight = base_model * (1.0 + offset) + suspect = culprit_score >= 0.65 or model_weight <= 0.0 or culprit_hits >= 2 + action = ( + "invert" if model_weight < 0.0 else + "mute" if model_weight == 0.0 else + "reduce" if abs(model_weight) < abs(base_model) else + "boost" + ) suggestions[lora_id] = { "name": entry.get("name", ""), "type": lora_type, "model_weight": model_weight, "base_model_weight": base_model, "offset_ratio": offset, + "culprit_score": culprit_score, + "culprit_hits": culprit_hits, + "suspect": suspect, + "action": action, "relation": relation, "matched_concepts": matched_labels, "rating": int(rating), @@ -1698,6 +1733,7 @@ class FunPackVideoRefiner: status_parts.append( f"{entry.get('name', '?')}[{lora_type}] rel={relation:.2f} " f"offset={offset:+.3f} next={model_weight:+.3f} " + f"sus={culprit_score:.2f}{' !' if suspect else ''} " f"match={match_text}" ) diff --git a/docs/FunPackLoraWorkflow.md b/docs/FunPackLoraWorkflow.md index 1fa7213e..9ae49537 100644 --- a/docs/FunPackLoraWorkflow.md +++ b/docs/FunPackLoraWorkflow.md @@ -41,4 +41,5 @@ Examples: - A `quality` LoRA matching a valuable quality concept can be boosted after good ratings. - A `concept` LoRA matching a bad-rated prompt can be reduced if it may be over-weighting or distorting that concept. +- Repeated bad ratings can mark a LoRA as a likely culprit, push its automatic weight down to `0.0`, and even invert it with a negative weight if it keeps ruining output. - Consistently good ratings can stabilize around a saved offset instead of continually drifting. From 86d84d4e3d729f1299a1d81054ff3c14c8d17649 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:15:19 +0300 Subject: [PATCH 08/13] Tighten concept LoRA blame logic --- conditioning.py | 15 ++++++++++++++- docs/FunPackLoraWorkflow.md | 2 +- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/conditioning.py b/conditioning.py index d55a78b8..4e4faa7d 100644 --- a/conditioning.py +++ b/conditioning.py @@ -20,7 +20,7 @@ import folder_paths LORA_REFINER_TYPE_PROFILES = { "general": {"step": 0.025, "max_offset": 0.20, "min_offset": -0.35, "bad_max_offset": 0.45, "bad_min_offset": -1.35, "culprit_bias": 0.28}, - "concept": {"step": 0.040, "max_offset": 0.35, "min_offset": -0.40, "bad_max_offset": 0.70, "bad_min_offset": -1.80, "culprit_bias": 0.08}, + "concept": {"step": 0.046, "max_offset": 0.35, "min_offset": -0.45, "bad_max_offset": 0.75, "bad_min_offset": -2.10, "culprit_bias": 0.16}, "style": {"step": 0.032, "max_offset": 0.28, "min_offset": -0.38, "bad_max_offset": 0.58, "bad_min_offset": -1.55, "culprit_bias": 0.20}, "quality": {"step": 0.022, "max_offset": 0.18, "min_offset": -0.30, "bad_max_offset": 0.38, "bad_min_offset": -1.20, "culprit_bias": 0.18}, "character": {"step": 0.024, "max_offset": 0.20, "min_offset": -0.32, "bad_max_offset": 0.42, "bad_min_offset": -1.30, "culprit_bias": 0.10}, @@ -1660,6 +1660,11 @@ class FunPackVideoRefiner: effective_relation = max(relation, profile.get("culprit_bias", 0.0)) base_model = float(entry.get("base_model_weight", entry.get("model_weight", 1.0))) base_abs = abs(base_model) + is_concept_lora = lora_type == "concept" + concept_match_strength = 1.0 + if is_concept_lora: + concept_match_strength += 0.30 if matched_labels else 0.0 + concept_match_strength += max(0.0, relation - 0.30) * 1.35 if rating >= 8: value_mult = 0.75 + min(1.4, max(0.5, concept_importance)) * 0.25 @@ -1675,16 +1680,24 @@ class FunPackVideoRefiner: elif rating <= 4: severity = _clamp((5.0 - float(rating)) / 4.0, 0.0, 1.0) culprit_signal = max(0.20, effective_relation) * (0.70 + base_abs * 0.30) + if is_concept_lora: + culprit_signal *= concept_match_strength culprit_score = _clamp(culprit_score * 0.72 + severity * culprit_signal, 0.0, 2.5) culprit_hits = culprit_hits + 1 if culprit_score >= 0.45 else max(0, culprit_hits - 1) max_offset = profile["bad_max_offset"] if rating <= 2 else max(max_offset, profile["bad_max_offset"] * 0.72) bad_floor_strength = min(1.0, 0.45 + 0.35 * culprit_score + 0.14 * state["bad_streak"]) + if is_concept_lora: + bad_floor_strength = min(1.0, bad_floor_strength + 0.18 * concept_match_strength) min_offset = min(min_offset, profile["bad_min_offset"] * bad_floor_strength) offset -= step * (1.0 + severity * 3.0) * max(0.4, effective_relation) * max(0.8, 0.85 + culprit_score) if state["bad_streak"] >= 2: offset -= step * (0.65 + severity * 1.8) * max(0.25, effective_relation) + if is_concept_lora and state["bad_streak"] >= 2: + offset -= step * (0.85 + severity * 2.4) * max(0.50, effective_relation) * concept_match_strength if state["bad_streak"] >= 2: state["stable_offset_ratio"] = None + if is_concept_lora and matched_labels and state["bad_streak"] >= 2 and culprit_score >= 0.70: + offset = min(offset, -1.0) if state["bad_streak"] >= 3 and culprit_score >= 0.85 and abs(1.0 + offset) < 0.08: offset = -1.0 else: diff --git a/docs/FunPackLoraWorkflow.md b/docs/FunPackLoraWorkflow.md index 9ae49537..3cb810b4 100644 --- a/docs/FunPackLoraWorkflow.md +++ b/docs/FunPackLoraWorkflow.md @@ -40,6 +40,6 @@ The refiner compares each LoRA's declared type and filename with the concepts it Examples: - A `quality` LoRA matching a valuable quality concept can be boosted after good ratings. -- A `concept` LoRA matching a bad-rated prompt can be reduced if it may be over-weighting or distorting that concept. +- A `concept` LoRA matching a bad-rated prompt is treated more aggressively than broad LoRAs, so it can be muted or inverted faster if it keeps distorting that concept. - Repeated bad ratings can mark a LoRA as a likely culprit, push its automatic weight down to `0.0`, and even invert it with a negative weight if it keeps ruining output. - Consistently good ratings can stabilize around a saved offset instead of continually drifting. From 2ea1e6cfe0bf9c5178534993512f912e99cdbbea Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:32:33 +0300 Subject: [PATCH 09/13] Protect quoted dialogue in refiner parsing --- conditioning.py | 97 ++++++++++++++++++++++++---- docs/FunPackGemmaEmbeddingRefiner.md | 2 + 2 files changed, 87 insertions(+), 12 deletions(-) diff --git a/conditioning.py b/conditioning.py index 4e4faa7d..beab6562 100644 --- a/conditioning.py +++ b/conditioning.py @@ -31,6 +31,9 @@ def _clamp(value, low, high): return max(low, min(high, value)) +QUOTED_TEXT_RE = re.compile(r'"([^"]+)"|“([^”]+)”') + + def tensor_to_serializable(t: torch.Tensor) -> dict: if not isinstance(t, torch.Tensor): raise TypeError(f"Expected torch.Tensor, got {type(t)}") @@ -389,6 +392,28 @@ class FunPackVideoRefiner: except Exception: return [] + def _iter_prompt_segments(self, prompt: str): + if not prompt: + return + + last_end = 0 + for match in QUOTED_TEXT_RE.finditer(prompt): + start, end = match.span() + if start > last_end: + yield ("text", prompt[last_end:start]) + quoted_text = (match.group(1) or match.group(2) or "").strip() + if quoted_text: + yield ("quote", quoted_text) + last_end = end + + if last_end < len(prompt): + yield ("text", prompt[last_end:]) + + def _mask_quoted_text(self, prompt: str): + if not prompt: + return "" + return QUOTED_TEXT_RE.sub(lambda match: " " * len(match.group(0)), prompt) + def _build_word_groups(self, prompt: str, tokenizer, seq_len: int, token_mask=None): if not tokenizer or not prompt or seq_len <= 0: return [] @@ -407,7 +432,33 @@ class FunPackVideoRefiner: word_groups = [] grouped_seen = set() - raw_words = [w.strip() for w in prompt.split() if w.strip()] + for segment_type, segment_text in self._iter_prompt_segments(prompt): + if segment_type != "quote": + continue + + clean_phrase = segment_text.strip() + lower = clean_phrase.lower() + if lower in grouped_seen or len(clean_phrase) < 3 or not any(c.isalpha() for c in clean_phrase): + continue + + grouped_seen.add(lower) + phrase_token_list = self._tokenize_ids( + tokenizer, + clean_phrase, + add_special_tokens=False + ) + if not phrase_token_list: + continue + + for start in range(max(0, len(full_token_ids) - len(phrase_token_list) + 1)): + end = start + len(phrase_token_list) + if token_mask_list is not None and not all(token_mask_list[start:min(effective_seq_len, end)]): + continue + if full_token_ids[start:end] == phrase_token_list and start < effective_seq_len: + word_groups.append((start, min(effective_seq_len, end), clean_phrase, phrase_token_list)) + break + + raw_words = [w.strip() for w in self._mask_quoted_text(prompt).split() if w.strip()] for word in raw_words: clean_word = word lower = clean_word.lower() @@ -436,7 +487,10 @@ class FunPackVideoRefiner: if not found: continue - return [group for group in word_groups if group[1] > group[0]] + return sorted( + [group for group in word_groups if group[1] > group[0]], + key=lambda group: (group[0], group[1], group[2].lower()), + ) @classmethod def INPUT_TYPES(s): @@ -806,23 +860,42 @@ class FunPackVideoRefiner: """ if not prompt: return [] - phrases = [p.strip() for p in re.split(r'[,;]', prompt) if p.strip()] result = [] - for phrase in phrases: - words = [w.strip().lower() for w in phrase.split() if self._is_valuable_token(w.strip())] - if words: - result.append(words) + for segment_type, segment_text in self._iter_prompt_segments(prompt): + if segment_type == "quote": + quoted_phrase = segment_text.strip().lower() + if len(quoted_phrase) >= 3 and any(c.isalpha() for c in quoted_phrase): + result.append([quoted_phrase]) + continue + + phrases = [p.strip() for p in re.split(r'[,;]', segment_text) if p.strip()] + for phrase in phrases: + words = [w.strip().lower() for w in phrase.split() if self._is_valuable_token(w.strip())] + if words: + result.append(words) return result def _build_prompt_fallback_concept(self, prompt: str, concept_clusters: dict): if not prompt: return None - fallback_words = [ - w.strip().lower() - for w in re.split(r'[\s,;]+', prompt) - if self._is_valuable_token(w.strip()) - ][:8] + fallback_words = [] + for segment_type, segment_text in self._iter_prompt_segments(prompt): + if segment_type == "quote": + quoted_phrase = segment_text.strip().lower() + if len(quoted_phrase) >= 3 and any(c.isalpha() for c in quoted_phrase): + fallback_words.append(quoted_phrase) + continue + + fallback_words.extend( + w.strip().lower() + for w in re.split(r'[\s,;]+', segment_text) + if self._is_valuable_token(w.strip()) + ) + if len(fallback_words) >= 8: + break + + fallback_words = fallback_words[:8] if not fallback_words: return None diff --git a/docs/FunPackGemmaEmbeddingRefiner.md b/docs/FunPackGemmaEmbeddingRefiner.md index 732a44f5..d89a8731 100644 --- a/docs/FunPackGemmaEmbeddingRefiner.md +++ b/docs/FunPackGemmaEmbeddingRefiner.md @@ -16,6 +16,8 @@ This node analyzes the positive conditioning produced by your text encoder, trac **positive_prompt**: Optional prompt text used for token analysis and concept-level feedback. +Quoted text inside `"` or `“ ”` is treated as a protected phrase during prompt analysis, so dialogue like `"You should go for it, young man."` is handled as one unit instead of having stopwords inside it stripped into nonsense fragments. + **sigmas**: Optional input sigma schedule to co-refine alongside the conditioning. When connected, the node preserves the first and last sigma exactly and only adjusts the middle values. **sigma_strength**: Preset strength for sigma refinement. `off` disables sigma changes, while `subtle`, `medium`, `strong`, and `max` allow progressively larger movement of the middle sigma values. From 0435d637f18d9ece647abc3d5917d63638d746fd Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:33:29 +0300 Subject: [PATCH 10/13] Clarify quoted speech requirement --- docs/FunPackGemmaEmbeddingRefiner.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/FunPackGemmaEmbeddingRefiner.md b/docs/FunPackGemmaEmbeddingRefiner.md index d89a8731..5cedad04 100644 --- a/docs/FunPackGemmaEmbeddingRefiner.md +++ b/docs/FunPackGemmaEmbeddingRefiner.md @@ -16,7 +16,7 @@ This node analyzes the positive conditioning produced by your text encoder, trac **positive_prompt**: Optional prompt text used for token analysis and concept-level feedback. -Quoted text inside `"` or `“ ”` is treated as a protected phrase during prompt analysis, so dialogue like `"You should go for it, young man."` is handled as one unit instead of having stopwords inside it stripped into nonsense fragments. +If you want the model to preserve speech or spoken lines, wrap that text in `"` or `“ ”`. Quoted text is treated as one protected phrase during prompt analysis, so dialogue like `"You should go for it, young man."` stays intact. If speech is not quoted, common words inside it can still be treated as low-value prompt words and may be omitted or ignored. **sigmas**: Optional input sigma schedule to co-refine alongside the conditioning. When connected, the node preserves the first and last sigma exactly and only adjusts the middle values. From d5f102c828384366d644e774b44e2c7a8b3703c1 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:37:28 +0300 Subject: [PATCH 11/13] Split refiner status from training info --- conditioning.py | 40 ++++++++++++++++++++++------ docs/FunPackGemmaEmbeddingRefiner.md | 4 +-- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/conditioning.py b/conditioning.py index beab6562..8b8e4710 100644 --- a/conditioning.py +++ b/conditioning.py @@ -2543,16 +2543,40 @@ class FunPackVideoRefiner: "⚠️ Still exploring" if avg_reward_ema > -0.2 else "🔄 Heavy correction" ) + pending_feedback = data.get("pending_feedback") + if not feedback_enabled: + feedback_state = "Feedback off" + elif pending_feedback is not None: + feedback_state = f"Feedback queued for '{pending_feedback.get('concept_label', 'concept')}'" + else: + feedback_state = "Feedback ready" + + suggestions_snapshot = active.get("lora_weight_suggestions", {}) + suspect_count = sum(1 for item in suggestions_snapshot.values() if item.get("suspect")) + if isinstance(lora_stack, dict) and lora_stack.get("loras"): + lora_state = f"LoRA active ({len(lora_stack.get('loras', []))} loaded" + if suspect_count > 0: + lora_state += f", {suspect_count} suspect" + lora_state += ")" + else: + lora_state = "LoRA idle" + + sigma_state = "Sigma active" if isinstance(sigmas, torch.Tensor) and sigma_strength != "off" else ( + "Sigma connected (off)" if isinstance(sigmas, torch.Tensor) else "Sigma idle" + ) + + latent_state = ( + "Latent active" if latent_output_connected and refined_latent is not None else + "Latent armed" if latent_output_connected else + "Latent idle" + ) status = ( - f"Mode {mode.upper()} | Iter {iter_num} | Total iters {global_total_iterations} | " - f"History {len(history)} | Unique prompts {len(prompt_histories)}\n" - f"Rating {rating}/10 {trend} | Sim {similarity:.3f} | Reward {reward:+.2f} | " - f"EMA {avg_reward_ema:+.2f} | Good ratio {good_ratio:.0%} | Expl {expl:.3f} | {health}\n" - f"Dominant: {dominant_line} | Groups: {group_line}\n" - f"{lora_suggestion_status}\n" - f"{sigma_status}\n" - f"{latent_status}" + f"{health} | Mode {mode.upper()} | Rating {rating}/10 {trend} | Iter {iter_num}\n" + f"Session: {len(prompt_histories)} prompt(s), {global_total_iterations} total update(s), " + f"{len(history)} history item(s) on this prompt\n" + f"Focus: {dominant_line} | {feedback_state}\n" + f"Systems: {lora_state} | {sigma_state} | {latent_state}" ) # ====================== FINAL SAVE ====================== diff --git a/docs/FunPackGemmaEmbeddingRefiner.md b/docs/FunPackGemmaEmbeddingRefiner.md index 5cedad04..f8c63ae9 100644 --- a/docs/FunPackGemmaEmbeddingRefiner.md +++ b/docs/FunPackGemmaEmbeddingRefiner.md @@ -40,11 +40,11 @@ If you want the model to preserve speech or spoken lines, wrap that text in `"` **modified_positive**: Refined conditioning. Connect this output to the next conditioning stage in your workflow. -**status**: Human-readable training status, including iteration and refinement diagnostics. +**status**: Short general node state summary. Use this to quickly see whether the refiner is learning well, what it is focused on, and which subsystems are active. **feedback_question**: Optional concept feedback prompt generated by the node when feedback mode is active. -**training_info**: Additional training details for the current refinement step. +**training_info**: Detailed training diagnostics for the current refinement step, including exact metrics, concept breakdown, LoRA suggestion details, and sigma/latent refinement details. **loss_graph**: Graph image showing learning loss over total session iterations for the current refinement key. From c60b372c70f8b1db2160df1715f04751e9f5c45d Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:57:10 +0300 Subject: [PATCH 12/13] Support backslash-protected prompt phrases --- conditioning.py | 30 ++++++++++++++-------------- docs/FunPackGemmaEmbeddingRefiner.md | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/conditioning.py b/conditioning.py index 8b8e4710..5236fb2a 100644 --- a/conditioning.py +++ b/conditioning.py @@ -31,7 +31,7 @@ def _clamp(value, low, high): return max(low, min(high, value)) -QUOTED_TEXT_RE = re.compile(r'"([^"]+)"|“([^”]+)”') +PROTECTED_PHRASE_RE = re.compile(r'"([^"]+)"|“([^”]+)”|\\([^\\]+)\\') def tensor_to_serializable(t: torch.Tensor) -> dict: @@ -397,13 +397,13 @@ class FunPackVideoRefiner: return last_end = 0 - for match in QUOTED_TEXT_RE.finditer(prompt): + for match in PROTECTED_PHRASE_RE.finditer(prompt): start, end = match.span() if start > last_end: yield ("text", prompt[last_end:start]) - quoted_text = (match.group(1) or match.group(2) or "").strip() - if quoted_text: - yield ("quote", quoted_text) + protected_text = (match.group(1) or match.group(2) or match.group(3) or "").strip() + if protected_text: + yield ("protected", protected_text) last_end = end if last_end < len(prompt): @@ -412,7 +412,7 @@ class FunPackVideoRefiner: def _mask_quoted_text(self, prompt: str): if not prompt: return "" - return QUOTED_TEXT_RE.sub(lambda match: " " * len(match.group(0)), prompt) + return PROTECTED_PHRASE_RE.sub(lambda match: " " * len(match.group(0)), prompt) def _build_word_groups(self, prompt: str, tokenizer, seq_len: int, token_mask=None): if not tokenizer or not prompt or seq_len <= 0: @@ -433,7 +433,7 @@ class FunPackVideoRefiner: word_groups = [] grouped_seen = set() for segment_type, segment_text in self._iter_prompt_segments(prompt): - if segment_type != "quote": + if segment_type != "protected": continue clean_phrase = segment_text.strip() @@ -862,10 +862,10 @@ class FunPackVideoRefiner: return [] result = [] for segment_type, segment_text in self._iter_prompt_segments(prompt): - if segment_type == "quote": - quoted_phrase = segment_text.strip().lower() - if len(quoted_phrase) >= 3 and any(c.isalpha() for c in quoted_phrase): - result.append([quoted_phrase]) + if segment_type == "protected": + protected_phrase = segment_text.strip().lower() + if len(protected_phrase) >= 3 and any(c.isalpha() for c in protected_phrase): + result.append([protected_phrase]) continue phrases = [p.strip() for p in re.split(r'[,;]', segment_text) if p.strip()] @@ -881,10 +881,10 @@ class FunPackVideoRefiner: fallback_words = [] for segment_type, segment_text in self._iter_prompt_segments(prompt): - if segment_type == "quote": - quoted_phrase = segment_text.strip().lower() - if len(quoted_phrase) >= 3 and any(c.isalpha() for c in quoted_phrase): - fallback_words.append(quoted_phrase) + if segment_type == "protected": + protected_phrase = segment_text.strip().lower() + if len(protected_phrase) >= 3 and any(c.isalpha() for c in protected_phrase): + fallback_words.append(protected_phrase) continue fallback_words.extend( diff --git a/docs/FunPackGemmaEmbeddingRefiner.md b/docs/FunPackGemmaEmbeddingRefiner.md index f8c63ae9..1823411a 100644 --- a/docs/FunPackGemmaEmbeddingRefiner.md +++ b/docs/FunPackGemmaEmbeddingRefiner.md @@ -16,7 +16,7 @@ This node analyzes the positive conditioning produced by your text encoder, trac **positive_prompt**: Optional prompt text used for token analysis and concept-level feedback. -If you want the model to preserve speech or spoken lines, wrap that text in `"` or `“ ”`. Quoted text is treated as one protected phrase during prompt analysis, so dialogue like `"You should go for it, young man."` stays intact. If speech is not quoted, common words inside it can still be treated as low-value prompt words and may be omitted or ignored. +If you want the model to preserve speech or spoken lines, wrap that text in `"` or `“ ”`. If you want a non-speech phrase to be treated as one whole phrase, wrap it in backslashes like `\a woman holds his drawings in her hands\`. Protected phrases are treated as one unit during prompt analysis, so dialogue like `"You should go for it, young man."` stays intact and a phrase like `\She calls him in.\` is kept whole instead of being reduced to a few "important" words. If speech or phrase-level text is not wrapped this way, common words inside it can still be treated as low-value prompt words and may be omitted or ignored. **sigmas**: Optional input sigma schedule to co-refine alongside the conditioning. When connected, the node preserves the first and last sigma exactly and only adjusts the middle values. From bee4145832db4870179bd0eea382ff52464b928a Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:50:32 +0300 Subject: [PATCH 13/13] Add hidden per-block LoRA support for LTX --- docs/FunPackLoraWorkflow.md | 3 + model_management.py | 182 +++++++++++++++++++++++++++++++++++- 2 files changed, 181 insertions(+), 4 deletions(-) diff --git a/docs/FunPackLoraWorkflow.md b/docs/FunPackLoraWorkflow.md index 3cb810b4..e25ad2eb 100644 --- a/docs/FunPackLoraWorkflow.md +++ b/docs/FunPackLoraWorkflow.md @@ -19,6 +19,7 @@ Inputs: - **positive_prompt**: Exact prompt text for lookup. - **refinement_key**: Same key used by `FunPack Video Refiner`. - **mode**: Same tokenizer mode as the refiner. +- **per_block**: If enabled for `ltx2` stacks, the loader derives hidden transformer block scales from each LoRA's own patch magnitudes. The UI still exposes only the regular base weights. - **lora_N**: LoRA file. - **lora_N_type**: `general`, `concept`, `style`, `quality`, or `character`. - **lora_N_base_weight**: Trainer-recommended model base weight. @@ -31,6 +32,8 @@ On the first run for an exact prompt, no saved suggestion exists, so base weight This node only loads the LoRA stack. It applies the model weights prepared by `FunPack Apply LoRA Weights`, then passes the same stack forward so the refiner can learn from it. The `clip` input is optional and uses zero CLIP strength when omitted. +When `per_block` is enabled on an `ltx2` stack, the loader keeps the user-facing global LoRA weight but redistributes it across detected `transformer_blocks.N` patches automatically. Blocks with stronger LoRA magnitude get a bit more weight, weaker blocks get a bit less, and non-block patches stay at the global weight. If the loader cannot detect a usable LTX transformer block layout, it falls back to the normal global application path. + ## Video Refiner Integration Connect the `lora_stack` output from `FunPack LoRA Loader` into the optional `lora_stack` input on `FunPack Video Refiner`. diff --git a/model_management.py b/model_management.py index d594e280..bb35d76f 100644 --- a/model_management.py +++ b/model_management.py @@ -1,17 +1,24 @@ import json +import logging import os import re +from collections import defaultdict from hashlib import md5 +import comfy.lora +import comfy.lora_convert import comfy.sd import comfy.utils import folder_paths +import torch from aiohttp import web from server import PromptServer LORA_TYPES = ["general", "concept", "style", "quality", "character"] LORA_STACK_TYPE = "FUNPACK_LORA_STACK" +TRANSFORMER_BLOCK_PATTERN = re.compile(r"(?:^|\.)transformer_blocks\.(\d+)\.") +LTX_IMAGE_MODELS = {"ltxv", "ltxav"} @PromptServer.instance.routes.get("/funpack/loras") @@ -82,6 +89,46 @@ def refiner_state_path(refinement_key, mode): return os.path.join(refinements_dir, f"refine_{safe_key}.json") +def coerce_bool(value): + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + +def patch_target_key(patch_key): + if isinstance(patch_key, tuple) and patch_key: + return patch_key[0] + return patch_key + + +def transformer_block_index(patch_key): + target_key = patch_target_key(patch_key) + if not isinstance(target_key, str): + return None + + match = TRANSFORMER_BLOCK_PATTERN.search(target_key) + if not match: + return None + return int(match.group(1)) + + +def patch_energy(value): + if isinstance(value, torch.Tensor): + return float(value.abs().mean().item()) + + weights = getattr(value, "weights", None) + if weights is not None: + return patch_energy(weights) + + if isinstance(value, dict): + return sum(patch_energy(item) for item in value.values()) + + if isinstance(value, (list, tuple)): + return sum(patch_energy(item) for item in value) + + return 0.0 + + class FunPackApplyLoraWeights: """ Builds a LoRA stack from user base weights, then applies prompt-specific @@ -120,6 +167,13 @@ class FunPackApplyLoraWeights: "positive_prompt": ("STRING", {"multiline": True, "default": ""}), "refinement_key": ("STRING", {"default": "my_style_v1", "multiline": False}), "mode": (["ltx2", "wan"], {"default": "ltx2"}), + "per_block": ( + "BOOLEAN", + { + "default": False, + "tooltip": "For LTX-mode stacks, derive hidden transformer block strengths from the LoRA itself and apply them automatically.", + }, + ), }, "optional": optional, } @@ -217,13 +271,15 @@ class FunPackApplyLoraWeights: return abs(float(saved_base) - float(entry["base_model_weight"])) <= 1e-6 - def apply_lora_weights(self, positive_prompt, refinement_key, mode, **kwargs): + def apply_lora_weights(self, positive_prompt, refinement_key, mode, per_block=False, **kwargs): mode = (mode or "ltx2").lower() + per_block = coerce_bool(per_block) prompt_key = prompt_key_for_mode(positive_prompt, mode) suggestions, source_message = self._load_suggestions(refinement_key, mode, prompt_key) loras = [] lines = [f"FunPack Apply LoRA Weights | {source_message}"] + lines.append(f"Per-block application: {'enabled' if per_block else 'disabled'}") for entry in self._iter_slots(kwargs): suggestion = self._get_suggestion(suggestions, entry) model_weight = float(suggestion.get("model_weight", entry["base_model_weight"])) @@ -240,9 +296,10 @@ class FunPackApplyLoraWeights: ) stack = { - "version": 1, + "version": 2, "refinement_key": refinement_key, "mode": mode, + "per_block": per_block, "positive_prompt": positive_prompt, "prompt_key": prompt_key, "loras": loras, @@ -293,9 +350,115 @@ class FunPackLoraLoader: return lora + def _model_image_model(self, model): + model_wrapper = getattr(model, "model", None) + model_config = getattr(model_wrapper, "model_config", None) + unet_config = getattr(model_config, "unet_config", None) + if isinstance(unet_config, dict): + return unet_config.get("image_model") + return None + + def _per_block_requested(self, entry, lora_stack): + return coerce_bool(entry.get("per_block", lora_stack.get("per_block", False))) + + def _per_block_supported(self, model, lora_stack, entry): + if not self._per_block_requested(entry, lora_stack): + return False + + if (lora_stack.get("mode") or "ltx2").lower() != "ltx2": + return False + + return self._model_image_model(model) in LTX_IMAGE_MODELS + + def _load_model_lora_patches(self, model, lora): + key_map = comfy.lora.model_lora_keys_unet(model.model, {}) + converted_lora = comfy.lora_convert.convert_lora(lora) + return comfy.lora.load_lora(converted_lora, key_map) + + def _split_model_patches_by_block(self, loaded): + global_patches = {} + block_patches = defaultdict(dict) + + for patch_key, patch_value in loaded.items(): + block_index = transformer_block_index(patch_key) + if block_index is None: + global_patches[patch_key] = patch_value + continue + block_patches[block_index][patch_key] = patch_value + + return global_patches, dict(block_patches) + + def _block_scales_from_patches(self, block_patches): + block_scores = {} + for block_index, patches in block_patches.items(): + score = 0.0 + for patch_value in patches.values(): + score += patch_energy(patch_value) + if score > 0.0: + block_scores[block_index] = score + + if len(block_scores) < 2: + return {} + + mean_score = sum(block_scores.values()) / len(block_scores) + if mean_score <= 0.0: + return {} + + scales = {} + for block_index, score in block_scores.items(): + ratio = max(0.0, score / mean_score) + scales[block_index] = max(0.25, min(1.75, ratio ** 0.5)) + + return scales + + def _apply_model_patches(self, model, loaded, model_weight, block_scales=None): + new_model = model.clone() + applied = set() + global_patches, block_patches = self._split_model_patches_by_block(loaded) + + if global_patches: + applied.update(new_model.add_patches(global_patches, model_weight)) + + if block_scales: + for block_index in sorted(block_patches): + block_strength = model_weight * block_scales.get(block_index, 1.0) + applied.update(new_model.add_patches(block_patches[block_index], block_strength)) + else: + for block_index in sorted(block_patches): + applied.update(new_model.add_patches(block_patches[block_index], model_weight)) + + for patch_key in loaded: + if patch_key not in applied: + logging.warning("NOT LOADED %s", patch_key) + + return new_model, len(global_patches), len(block_patches) + + def _load_lora_per_block(self, model, lora, model_weight): + loaded = self._load_model_lora_patches(model, lora) + _, block_patches = self._split_model_patches_by_block(loaded) + block_scales = self._block_scales_from_patches(block_patches) + if not block_scales: + return None, "per-block fallback=global" + + new_model, non_block_count, _ = self._apply_model_patches( + model, + loaded, + model_weight, + block_scales=block_scales, + ) + min_scale = min(block_scales.values()) + max_scale = max(block_scales.values()) + status = ( + f"per-block blocks={len(block_scales)} non_block={non_block_count} " + f"range={min_scale:.2f}..{max_scale:.2f}" + ) + return new_model, status + def load_loras(self, model, lora_stack, clip=None): loras = lora_stack.get("loras", []) if isinstance(lora_stack, dict) else [] + per_block = coerce_bool(lora_stack.get("per_block", False)) if isinstance(lora_stack, dict) else False lines = [f"FunPack LoRA Loader | loading {len(loras)} LoRA(s)"] + lines.append(f"Per-block application: {'enabled' if per_block else 'disabled'}") loaded_count = 0 for entry in loras: @@ -305,11 +468,22 @@ class FunPackLoraLoader: continue lora = self._load_lora_file(entry["name"]) - model, clip = comfy.sd.load_lora_for_models(model, clip, lora, model_weight, 0.0) + apply_status = "global" + if self._per_block_supported(model, lora_stack, entry): + per_block_model, apply_status = self._load_lora_per_block(model, lora, model_weight) + if per_block_model is not None: + model = per_block_model + else: + model, clip = comfy.sd.load_lora_for_models(model, clip, lora, model_weight, 0.0) + elif self._per_block_requested(entry, lora_stack): + apply_status = "per-block unsupported -> global" + model, clip = comfy.sd.load_lora_for_models(model, clip, lora, model_weight, 0.0) + else: + model, clip = comfy.sd.load_lora_for_models(model, clip, lora, model_weight, 0.0) loaded_count += 1 lines.append( f"lora_{entry.get('slot', '?')}: {entry['name']} " - f"applied={model_weight:+.3f} source={entry.get('source', 'base')}" + f"applied={model_weight:+.3f} source={entry.get('source', 'base')} mode={apply_status}" ) if loaded_count == 0: