From d24c39499bef929a30fff6550a9c97ed40576368 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:10:44 +0300 Subject: [PATCH 1/6] Share refiner history across prompt variants --- conditioning.py | 187 +++++++++++++++++++++++++++ docs/FunPackGemmaEmbeddingRefiner.md | 2 + 2 files changed, 189 insertions(+) diff --git a/conditioning.py b/conditioning.py index 69acbd7b..b7480c3f 100644 --- a/conditioning.py +++ b/conditioning.py @@ -1018,6 +1018,157 @@ class FunPackVideoRefiner: result.append(words) return result + def _concept_overlap_words(self, prompt: str): + words = [] + seen = set() + for phrase_words in self._parse_concepts(prompt): + for word in phrase_words: + clean = str(word).strip().lower() + if not clean or clean in seen: + continue + seen.add(clean) + words.append(clean) + return words + + def _history_prompt_texts(self, prompt_key: str, history_entry: dict): + texts = [] + for key in ("canonical_prompt", "last_positive_prompt"): + value = history_entry.get(key) + if isinstance(value, str) and value.strip(): + texts.append(value) + if isinstance(prompt_key, str) and prompt_key.strip(): + texts.append(prompt_key) + for item in reversed(history_entry.get("history", [])[-4:]): + value = item.get("prompt") if isinstance(item, dict) else None + if isinstance(value, str) and value.strip(): + texts.append(value) + + unique = [] + seen = set() + for text in texts: + normalized = text.strip() + if normalized and normalized not in seen: + seen.add(normalized) + unique.append(normalized) + return unique + + def _prompt_history_concept_words(self, prompt_key: str, history_entry: dict): + stored = history_entry.get("prompt_concept_words") + if isinstance(stored, list) and stored: + return [str(word).lower() for word in stored if str(word).strip()] + + words = [] + seen = set() + for text in self._history_prompt_texts(prompt_key, history_entry): + for word in self._concept_overlap_words(text): + if word not in seen: + seen.add(word) + words.append(word) + return words + + def _conditioning_history_similarity(self, history_entry: dict, raw_positive: torch.Tensor): + try: + old_reference = serializable_to_tensor(history_entry["reference_embeds"]) + except Exception: + return 0.0 + if not isinstance(old_reference, torch.Tensor) or list(old_reference.shape) != list(raw_positive.shape): + return 0.0 + + current = raw_positive.to(old_reference.device) if raw_positive.device != old_reference.device else raw_positive + try: + if old_reference.dim() >= 2 and current.dim() >= 2: + ref_mean = self._masked_sequence_mean(old_reference, None) + cur_mean = self._masked_sequence_mean(current, None) + return float(F.cosine_similarity(ref_mean, cur_mean, dim=-1).mean().item()) + return float(F.cosine_similarity( + old_reference.flatten().unsqueeze(0), + current.flatten().unsqueeze(0), + dim=-1 + ).mean().item()) + except Exception: + return 0.0 + + def _find_prompt_variant_history(self, exact_prompt_key: str, current_words: list, + raw_positive: torch.Tensor, prompt_histories: dict): + if not exact_prompt_key or not current_words or not prompt_histories: + return None, None + + current_set = set(current_words) + if len(current_set) < 2: + return None, None + + best_key = None + best_info = None + best_score = 0.0 + for candidate_key, history_entry in prompt_histories.items(): + if candidate_key == exact_prompt_key or not isinstance(history_entry, dict): + continue + + candidate_words = self._prompt_history_concept_words(candidate_key, history_entry) + candidate_set = set(candidate_words) + if len(candidate_set) < 2: + continue + + shared = current_set & candidate_set + shared_count = len(shared) + union = current_set | candidate_set + overlap = shared_count / max(1, len(union)) + coverage = shared_count / max(1, min(len(current_set), len(candidate_set))) + semantic_score = max(overlap, coverage * 0.72) + conditioning_similarity = self._conditioning_history_similarity(history_entry, raw_positive) + + if conditioning_similarity < 0.68: + continue + if shared_count < 3 and not (coverage >= 0.85 and conditioning_similarity >= 0.82): + continue + if not ( + (overlap >= 0.46 and conditioning_similarity >= 0.78) or + (coverage >= 0.68 and conditioning_similarity >= 0.72) or + (overlap >= 0.60 and conditioning_similarity >= 0.68) + ): + continue + + score = semantic_score * 0.58 + conditioning_similarity * 0.42 + if score > best_score: + best_score = score + best_key = candidate_key + best_info = { + "score": round(float(score), 4), + "overlap": round(float(overlap), 4), + "coverage": round(float(coverage), 4), + "conditioning_similarity": round(float(conditioning_similarity), 4), + "shared_words": sorted(shared)[:12], + "matched_prompt_key": candidate_key, + } + + return best_key, best_info + + def _remember_prompt_variant(self, active: dict, exact_prompt_key: str, positive_prompt: str, + current_words: list, match_info: dict = None): + active.setdefault("canonical_prompt", positive_prompt or exact_prompt_key) + existing_words = set( + str(word).lower() + for word in active.get("prompt_concept_words", []) + if str(word).strip() + ) + for word in current_words: + existing_words.add(str(word).lower()) + active["prompt_concept_words"] = sorted(existing_words)[:256] + + variants = list(active.get("prompt_variants", []))[-11:] + variant = { + "prompt_key": exact_prompt_key, + "prompt": (positive_prompt or exact_prompt_key)[:240], + } + if match_info: + variant.update({ + "overlap": match_info.get("overlap"), + "coverage": match_info.get("coverage"), + "conditioning_similarity": match_info.get("conditioning_similarity"), + }) + variants.append(variant) + active["prompt_variants"] = variants + def _build_prompt_fallback_concept(self, prompt: str, concept_clusters: dict): if not prompt: return None @@ -2186,6 +2337,9 @@ class FunPackVideoRefiner: analysis_prompt = self._normalize_prompt_for_mode(positive_prompt, mode) prompt_key = analysis_prompt if mode == "wan" else positive_prompt + exact_prompt_key = prompt_key + current_prompt_words = self._concept_overlap_words(analysis_prompt) + prompt_variant_match = None # ====================== STATE TEMPLATES ====================== # Single source of truth for both reset and corrupt-recovery paths. @@ -2224,6 +2378,8 @@ class FunPackVideoRefiner: "global_adaptive": _fresh_global(), "prompt_histories": { prompt_key: { + "canonical_prompt": positive_prompt, + "prompt_concept_words": current_prompt_words, "reference_embeds": tensor_to_serializable(raw_positive), "history": [], "last_rating": rating, @@ -2301,6 +2457,16 @@ class FunPackVideoRefiner: prompt_histories = data.get("prompt_histories", {}) tokenizer = self._get_tokenizer(mode) + if prompt_key not in prompt_histories: + matched_prompt_key, prompt_variant_match = self._find_prompt_variant_history( + exact_prompt_key, + current_prompt_words, + raw_positive, + prompt_histories, + ) + if matched_prompt_key: + prompt_key = matched_prompt_key + # ====================== FEEDBACK STATE MACHINE (CONCEPT-LEVEL) ====================== # Operates on full concept phrases rather than individual words. # Rating a concept updates all its tracked words proportionally and sends a @@ -2333,12 +2499,21 @@ class FunPackVideoRefiner: else: is_new_prompt = True active = { + "canonical_prompt": positive_prompt, + "prompt_concept_words": current_prompt_words, "reference_embeds": tensor_to_serializable(raw_positive), "history": [], "last_rating": rating, "last_rating_label": rating_label } prompt_histories[prompt_key] = active + self._remember_prompt_variant( + active, + exact_prompt_key, + positive_prompt, + current_prompt_words, + prompt_variant_match, + ) # Safe reference loading try: @@ -2815,6 +2990,17 @@ class FunPackVideoRefiner: reward, rating_profile, ) + if prompt_variant_match: + prompt_history_status = ( + "Prompt history: reused similar enhanced prompt " + f"(overlap {prompt_variant_match.get('overlap', 0.0):.0%}, " + f"coverage {prompt_variant_match.get('coverage', 0.0):.0%}, " + f"conditioning {prompt_variant_match.get('conditioning_similarity', 0.0):.2f})." + ) + elif exact_prompt_key != prompt_key: + prompt_history_status = "Prompt history: reused compatible prompt variant." + else: + prompt_history_status = "Prompt history: exact prompt." normalized_reward = max(0.0, min(1.0, (avg_reward_ema + 1.0) / 2.0)) stability_factor = max(0.0, 1.0 - similarity) @@ -2831,6 +3017,7 @@ class FunPackVideoRefiner: f"Concept phrases ({len(concept_clusters)} total): {concept_line}\n" f"Concept groups ({len(concept_groups)} total): {group_line}\n" f"Global top words: {current_top}\n" + f"{prompt_history_status}\n" f"{lora_suggestion_status}\n" f"{sigma_status}\n" f"{latent_status}" diff --git a/docs/FunPackGemmaEmbeddingRefiner.md b/docs/FunPackGemmaEmbeddingRefiner.md index 66489c80..16ed1349 100644 --- a/docs/FunPackGemmaEmbeddingRefiner.md +++ b/docs/FunPackGemmaEmbeddingRefiner.md @@ -27,6 +27,8 @@ Older numeric workflows are still understood internally: `9-10` maps to `I like Wrap dialogue in quotes. Wrap any other phrase that should stay together in backslashes, for example `\a woman holds his drawings in her hands\`. This keeps the phrase together during prompt analysis instead of letting it be split into loose words. +If an upstream prompt enhancer rewrites the prompt on every run, pass the final enhanced text here. The refiner keeps exact prompt histories when possible, but it can reuse a similar prompt history when the new prompt has enough concept overlap and the conditioning is close enough. This helps Gemma-style prompt variants share learning without forcing unrelated prompts into the same history. + **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 556c8799e70fa0455d19e9d20c47823e5ff92802 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:21:41 +0300 Subject: [PATCH 2/6] Refresh LoRA dropdowns on UI refresh --- model_management.py | 5 +- web/funpack_lora_weights.js | 99 +++++++++++++++++++++++++++++++++++-- 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/model_management.py b/model_management.py index 3d99d07f..6e35e2e3 100644 --- a/model_management.py +++ b/model_management.py @@ -24,7 +24,10 @@ LTX_IMAGE_MODELS = {"ltxv", "ltxav"} @PromptServer.instance.routes.get("/funpack/loras") async def funpack_loras(_): - return web.json_response(["None"] + folder_paths.get_filename_list("loras")) + return web.json_response( + ["None"] + folder_paths.get_filename_list("loras"), + headers={"Cache-Control": "no-store, max-age=0"}, + ) class AnyType(str): diff --git a/web/funpack_lora_weights.js b/web/funpack_lora_weights.js index 78c75e8a..f62c55d7 100644 --- a/web/funpack_lora_weights.js +++ b/web/funpack_lora_weights.js @@ -6,26 +6,39 @@ const LORA_TYPES = ["general", "concept", "style", "quality", "character"]; const ADD_BUTTON_NAME = "+ Add LoRA"; let cachedLoraValues = null; +let latestNodeData = null; +const trackedNodes = new Set(); +let pendingRefresh = null; function getLoraValues(nodeData) { return cachedLoraValues || nodeData?.input?.optional?.lora_0?.[0] || ["None"]; } +function rememberLoraValues(nodeData, values) { + cachedLoraValues = values; + const loraInput = nodeData?.input?.optional?.lora_0; + if (Array.isArray(loraInput)) { + loraInput[0] = values; + } +} + async function fetchLoraValues(nodeData) { try { - const response = await api.fetchApi("/funpack/loras"); + const response = await api.fetchApi(`/funpack/loras?cache_bust=${Date.now()}`, { + cache: "no-store", + }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const values = await response.json(); if (Array.isArray(values) && values.length) { - cachedLoraValues = values; + rememberLoraValues(nodeData, values); return values; } } catch (error) { console.warn("FunPack: failed to refresh LoRA list", error); } - cachedLoraValues = getLoraValues(nodeData); + rememberLoraValues(nodeData, getLoraValues(nodeData)); return cachedLoraValues; } @@ -55,6 +68,37 @@ async function refreshLoraWidgets(node, nodeData) { updateLoraWidgets(node, await fetchLoraValues(nodeData)); } +function trackNode(node) { + trackedNodes.add(node); +} + +async function refreshTrackedLoraWidgets(nodeData = latestNodeData) { + if (!nodeData || pendingRefresh) { + return pendingRefresh; + } + + pendingRefresh = (async () => { + const values = await fetchLoraValues(nodeData); + for (const node of [...trackedNodes]) { + if (!node?.graph) { + trackedNodes.delete(node); + continue; + } + updateLoraWidgets(node, values); + } + })().finally(() => { + pendingRefresh = null; + }); + + return pendingRefresh; +} + +function scheduleTrackedRefresh(nodeData = latestNodeData) { + window.setTimeout(() => { + void refreshTrackedLoraWidgets(nodeData); + }, 100); +} + function getNextLoraIndex(node) { let next = 0; for (const widget of node.widgets || []) { @@ -174,16 +218,64 @@ function restoreExtraRows(node, nodeData, info) { } } +function wrapRefreshFunction(functionName) { + const original = app[functionName]; + if (typeof original !== "function" || original.__funpackLoraWrapped) { + return; + } + + const wrapped = function () { + const result = original.apply(this, arguments); + Promise.resolve(result).finally(() => scheduleTrackedRefresh()); + return result; + }; + wrapped.__funpackLoraWrapped = true; + app[functionName] = wrapped; +} + +function hookComfyRefreshControls() { + for (const functionName of ["refreshComboInNodes", "refreshComboInNode", "refreshNodeDefs"]) { + wrapRefreshFunction(functionName); + } +} + +function isRefreshControl(element) { + for (let current = element; current && current !== document.body; current = current.parentElement) { + const text = [ + current.title, + current.ariaLabel, + current.textContent, + ].filter(Boolean).join(" ").toLowerCase(); + if (text.includes("refresh")) { + return true; + } + } + return false; +} + app.registerExtension({ name: "funpack.loraWeights", + setup() { + hookComfyRefreshControls(); + window.setTimeout(hookComfyRefreshControls, 0); + window.setTimeout(hookComfyRefreshControls, 1000); + document.addEventListener("click", (event) => { + if (isRefreshControl(event.target)) { + scheduleTrackedRefresh(); + } + }, true); + }, beforeRegisterNodeDef(nodeType, nodeData) { if (nodeData.name !== NODE_NAME) { return; } + latestNodeData = nodeData; + hookComfyRefreshControls(); const originalOnNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { originalOnNodeCreated?.apply(this, arguments); + trackNode(this); ensureAddButton(this, nodeData); void refreshLoraWidgets(this, nodeData); }; @@ -191,6 +283,7 @@ app.registerExtension({ const originalConfigure = nodeType.prototype.configure; nodeType.prototype.configure = function (info) { originalConfigure?.apply(this, arguments); + trackNode(this); ensureAddButton(this, nodeData); restoreExtraRows(this, nodeData, info); void refreshLoraWidgets(this, nodeData); From 97d61953386aae5b1efde99d8d0328482830a9d5 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:47:07 +0300 Subject: [PATCH 3/6] Compact Apply LoRA Weights rows --- web/funpack_lora_weights.js | 750 +++++++++++++++++++++++++++++------- 1 file changed, 618 insertions(+), 132 deletions(-) diff --git a/web/funpack_lora_weights.js b/web/funpack_lora_weights.js index f62c55d7..30d9158b 100644 --- a/web/funpack_lora_weights.js +++ b/web/funpack_lora_weights.js @@ -4,12 +4,109 @@ import { api } from "../../scripts/api.js"; const NODE_NAME = "FunPackApplyLoraWeights"; const LORA_TYPES = ["general", "concept", "style", "quality", "character"]; const ADD_BUTTON_NAME = "+ Add LoRA"; +const HEADER_WIDGET_NAME = "funpack_lora_header"; +const BASE_WIDGET_NAMES = new Set(["positive_prompt", "refinement_key", "mode", "per_block"]); let cachedLoraValues = null; let latestNodeData = null; const trackedNodes = new Set(); let pendingRefresh = null; +function fitString(ctx, text, maxWidth) { + text = String(text ?? ""); + if (ctx.measureText(text).width <= maxWidth) { + return text; + } + + const ellipsis = "..."; + let low = 0; + let high = text.length; + while (low < high) { + const mid = Math.ceil((low + high) / 2); + if (ctx.measureText(text.slice(0, mid) + ellipsis).width <= maxWidth) { + low = mid; + } else { + high = mid - 1; + } + } + return text.slice(0, low) + ellipsis; +} + +function roundRect(ctx, x, y, width, height, radius = height / 2) { + ctx.beginPath(); + ctx.roundRect(x, y, width, height, [radius]); +} + +function drawRowBackground(ctx, x, y, width, height, active = true) { + ctx.save(); + roundRect(ctx, x, y, width, height, height / 2); + ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR; + ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR; + if (!active) { + ctx.globalAlpha = app.canvas.editor_alpha * 0.65; + } + ctx.fill(); + ctx.stroke(); + ctx.restore(); +} + +function drawToggle(ctx, x, y, height, value) { + const width = height * 1.5; + const radius = height * 0.34; + ctx.save(); + ctx.globalAlpha = app.canvas.editor_alpha * 0.25; + roundRect(ctx, x + 4, y + 4, width - 8, height - 8, height / 2); + ctx.fillStyle = "rgba(255,255,255,0.45)"; + ctx.fill(); + ctx.globalAlpha = app.canvas.editor_alpha; + ctx.fillStyle = value === true ? "#9ab" : "#888"; + const knobX = value === true ? x + height : value === false ? x + height * 0.5 : x + height * 0.75; + ctx.beginPath(); + ctx.arc(knobX, y + height * 0.5, radius, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + return [x, width]; +} + +function drawNumberControl(ctx, xRight, y, height, value) { + const arrowWidth = 9; + const inner = 3; + const numberWidth = 36; + const total = arrowWidth + inner + numberWidth + inner + arrowWidth; + const x = xRight - total; + const midY = y + height / 2; + const arrowHeight = 10; + + ctx.save(); + ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR; + ctx.fill(new Path2D(`M ${x} ${midY} l ${arrowWidth} ${arrowHeight / 2} l 0 -${arrowHeight} L ${x} ${midY} z`)); + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(Number(value ?? 1).toFixed(2), x + arrowWidth + inner + numberWidth / 2, midY); + const rx = x + arrowWidth + inner + numberWidth + inner; + ctx.fill(new Path2D(`M ${rx} ${midY - arrowHeight / 2} l ${arrowWidth} ${arrowHeight / 2} l -${arrowWidth} ${arrowHeight / 2} v -${arrowHeight} z`)); + ctx.restore(); + + return { + dec: [x, arrowWidth], + value: [x + arrowWidth + inner, numberWidth], + inc: [rx, arrowWidth], + any: [x, total], + x, + total, + }; +} + +function inside(pos, bounds) { + const x = bounds[0]; + const width = bounds.length > 2 ? bounds[2] : bounds[1]; + const hitX = pos[0] >= x && pos[0] <= x + width; + if (bounds.length <= 2) { + return hitX; + } + return hitX && pos[1] >= bounds[1] && pos[1] <= bounds[1] + bounds[3]; +} + function getLoraValues(nodeData) { return cachedLoraValues || nodeData?.input?.optional?.lora_0?.[0] || ["None"]; } @@ -49,17 +146,528 @@ function valuesWithCurrent(values, current) { return [...values, current]; } -function isLoraCombo(widget) { - return /^lora_\d+$/.test(widget.name || ""); +function normalizeRowValue(value = {}) { + if (typeof value !== "object" || value === null) { + return { + on: true, + lora: "None", + type: "general", + strength: 1.0, + }; + } + + const strength = Number(value.strength ?? value.base_weight ?? value.base_model_weight ?? 1.0); + return { + on: value.on !== false, + lora: value.lora || value.name || "None", + type: LORA_TYPES.includes(value.type || value.lora_type) ? (value.type || value.lora_type) : "general", + strength: Number.isFinite(strength) ? strength : 1.0, + }; +} + +class FunPackBaseWidget { + constructor(name) { + this.name = name; + this.type = "custom"; + this.options = { serialize: true }; + this.hitAreas = {}; + this.downHitAreas = []; + this.clickHitAreas = []; + this.mouseDown = false; + this.last_y = 0; + } + + serializeValue() { + return this.value; + } + + mouse(event, pos, node) { + if (event.type === "pointerdown") { + this.mouseDown = true; + this.downHitAreas = []; + this.clickHitAreas = []; + let handled = false; + for (const hitArea of Object.values(this.hitAreas)) { + if (!inside(pos, hitArea.bounds)) { + continue; + } + if (hitArea.onMove) { + this.downHitAreas.push(hitArea); + handled = true; + } + if (hitArea.onClick) { + this.clickHitAreas.push(hitArea); + handled = true; + } + if (hitArea.onDown) { + handled = hitArea.onDown.call(this, event, pos, node, hitArea) === true || handled; + } + } + return handled; + } + + if (event.type === "pointermove") { + if (!this.mouseDown) { + return false; + } + for (const hitArea of this.downHitAreas) { + hitArea.onMove.call(this, event, pos, node, hitArea); + } + return true; + } + + if (event.type === "pointerup") { + if (!this.mouseDown) { + return false; + } + this.mouseDown = false; + let handled = false; + for (const hitArea of this.clickHitAreas) { + if (inside(pos, hitArea.bounds)) { + handled = hitArea.onClick.call(this, event, pos, node, hitArea) === true || handled; + } + } + this.downHitAreas = []; + this.clickHitAreas = []; + if (!handled) { + handled = this.onMouseClick?.(event, pos, node) === true; + } + return handled; + } + + return false; + } + + onMouseClick(_event, _pos, _node) { + return false; + } +} + +class FunPackLoraHeaderWidget extends FunPackBaseWidget { + constructor() { + super(HEADER_WIDGET_NAME); + this.options.serialize = false; + this.value = {}; + this.hitAreas = { + toggle: { bounds: [0, 0], onDown: this.onToggleAll }, + }; + } + + computeSize(width) { + return [width, LiteGraph.NODE_WIDGET_HEIGHT]; + } + + draw(ctx, node, width, y, height) { + if (!getLoraRows(node).length) { + return; + } + const margin = 10; + const midY = y + height / 2; + let x = margin; + this.hitAreas.toggle.bounds = drawToggle(ctx, x, y, height, allRowsState(node)); + x += this.hitAreas.toggle.bounds[1] + 4; + + ctx.save(); + ctx.globalAlpha = app.canvas.editor_alpha * 0.55; + ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR; + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + ctx.fillText("Toggle All", x, midY); + ctx.textAlign = "center"; + ctx.fillText("Type", width - 106, midY); + ctx.fillText("Strength", width - 38, midY); + ctx.restore(); + } + + onToggleAll(event, pos, node) { + toggleAllRows(node); + node.setDirtyCanvas(true, true); + return true; + } +} + +class FunPackLoraRowWidget extends FunPackBaseWidget { + constructor(name, value, nodeData) { + super(name); + this.value = normalizeRowValue(value); + this.nodeData = nodeData; + this.draggedWeight = false; + this.hitAreas = { + toggle: { bounds: [0, 0], onDown: this.onToggle }, + lora: { bounds: [0, 0], onClick: this.onLoraClick }, + type: { bounds: [0, 0], onClick: this.onTypeClick }, + weightDec: { bounds: [0, 0], onClick: this.onWeightDec }, + weightValue: { bounds: [0, 0], onClick: this.onWeightValue }, + weightInc: { bounds: [0, 0], onClick: this.onWeightInc }, + weightAny: { bounds: [0, 0], onMove: this.onWeightMove }, + }; + } + + computeSize(width) { + return [width, LiteGraph.NODE_WIDGET_HEIGHT]; + } + + serializeValue() { + return { + on: this.value.on !== false, + lora: this.value.lora || "None", + type: LORA_TYPES.includes(this.value.type) ? this.value.type : "general", + strength: Number(this.value.strength ?? 1.0), + }; + } + + updateLoraValues(values) { + if (!values.includes(this.value.lora)) { + this.value.lora = this.value.lora || "None"; + } + } + + draw(ctx, node, width, y, height) { + const margin = 10; + const inner = 4; + const rowX = margin; + const rowWidth = width - margin * 2; + const midY = y + height / 2; + drawRowBackground(ctx, rowX, y, rowWidth, height, this.value.on); + + let x = rowX; + this.hitAreas.toggle.bounds = drawToggle(ctx, x, y, height, this.value.on); + x += this.hitAreas.toggle.bounds[1] + inner; + + ctx.save(); + if (!this.value.on) { + ctx.globalAlpha = app.canvas.editor_alpha * 0.42; + } + ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR; + ctx.textBaseline = "middle"; + + const number = drawNumberControl(ctx, width - margin - inner, y, height, this.value.strength); + this.hitAreas.weightDec.bounds = number.dec; + this.hitAreas.weightValue.bounds = number.value; + this.hitAreas.weightInc.bounds = number.inc; + this.hitAreas.weightAny.bounds = number.any; + + const typeWidth = 70; + const typeX = number.x - inner - typeWidth; + roundRect(ctx, typeX, y + 3, typeWidth, height - 6, 5); + ctx.fillStyle = "rgba(255,255,255,0.08)"; + ctx.fill(); + ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR; + ctx.textAlign = "center"; + ctx.fillText(fitString(ctx, this.value.type || "general", typeWidth - 12), typeX + typeWidth / 2, midY); + ctx.fillText("▾", typeX + typeWidth - 8, midY); + this.hitAreas.type.bounds = [typeX, typeWidth]; + + const loraWidth = Math.max(40, typeX - inner - x); + ctx.textAlign = "left"; + ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR; + ctx.fillText(fitString(ctx, this.value.lora || "None", loraWidth), x, midY); + this.hitAreas.lora.bounds = [x, loraWidth]; + ctx.restore(); + } + + onToggle(event, pos, node) { + this.value.on = !this.value.on; + node.setDirtyCanvas(true, true); + return true; + } + + onLoraClick(event, pos, node) { + void fetchLoraValues(this.nodeData).then((freshValues) => { + const values = valuesWithCurrent(freshValues, this.value.lora); + new LiteGraph.ContextMenu(values, { + event, + title: "Choose LoRA", + callback: (value) => { + if (typeof value === "string") { + this.value.lora = value; + node.setDirtyCanvas(true, true); + } + }, + }); + }); + return true; + } + + onTypeClick(event, pos, node) { + new LiteGraph.ContextMenu(LORA_TYPES, { + event, + title: "LoRA Type", + callback: (value) => { + if (typeof value === "string") { + this.value.type = value; + node.setDirtyCanvas(true, true); + } + }, + }); + return true; + } + + stepWeight(direction, node) { + const current = Number(this.value.strength ?? 1.0); + this.value.strength = Math.round(((Number.isFinite(current) ? current : 1.0) + direction * 0.05) * 100) / 100; + node.setDirtyCanvas(true, true); + } + + onWeightDec(event, pos, node) { + this.stepWeight(-1, node); + return true; + } + + onWeightInc(event, pos, node) { + this.stepWeight(1, node); + return true; + } + + onWeightMove(event, pos, node) { + if (event.deltaX) { + this.draggedWeight = true; + const current = Number(this.value.strength ?? 1.0); + this.value.strength = Math.round(((Number.isFinite(current) ? current : 1.0) + event.deltaX * 0.02) * 100) / 100; + node.setDirtyCanvas(true, true); + } + return true; + } + + onWeightValue(event, pos, node) { + if (this.draggedWeight) { + this.draggedWeight = false; + return true; + } + app.canvas.prompt("Base weight", this.value.strength, (value) => { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + this.value.strength = parsed; + node.setDirtyCanvas(true, true); + } + }, event); + return true; + } +} + +class FunPackButtonWidget extends FunPackBaseWidget { + constructor(name, label, callback) { + super(name); + this.options.serialize = false; + this.value = ""; + this.label = label; + this.callback = callback; + this.hitAreas = { + button: { bounds: [0, 0], onClick: this.onButtonClick }, + }; + } + + computeSize(width) { + return [width, LiteGraph.NODE_WIDGET_HEIGHT + 2]; + } + + draw(ctx, node, width, y, height) { + const x = 20; + const w = width - 40; + this.hitAreas.button.bounds = [x, y + 1, w, height - 2]; + ctx.save(); + roundRect(ctx, x, y + 1, w, height - 2, 3); + ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR; + ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR; + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(this.label, width / 2, y + height / 2); + ctx.restore(); + } + + onMouseClick(event, pos, node) { + return this.callback(event, pos, node); + } + + onButtonClick(event, pos, node) { + return this.callback(event, pos, node); + } +} + +function isDefaultLoraWidget(widget) { + return /^lora_\d+(?:_type|_base_weight)?$/.test(widget.name || ""); +} + +function isManagedWidget(widget) { + return ( + isDefaultLoraWidget(widget) || + widget.name === HEADER_WIDGET_NAME || + widget.name === ADD_BUTTON_NAME || + widget.__funpackLoraRow === true + ); +} + +function getLoraRows(node) { + return (node.widgets || []).filter((widget) => widget.__funpackLoraRow === true); +} + +function allRowsState(node) { + const rows = getLoraRows(node); + if (!rows.length) { + return false; + } + const allOn = rows.every((row) => row.value.on !== false); + const allOff = rows.every((row) => row.value.on === false); + if (allOn) { + return true; + } + if (allOff) { + return false; + } + return null; +} + +function toggleAllRows(node) { + const rows = getLoraRows(node); + const target = allRowsState(node) !== true; + for (const row of rows) { + row.value.on = target; + } +} + +function nextLoraIndex(node) { + let next = 0; + for (const widget of node.widgets || []) { + const match = /^lora_(\d+)$/.exec(widget.name || ""); + if (match) { + next = Math.max(next, Number(match[1]) + 1); + } + } + return next; +} + +function resizeNode(node) { + const computed = node.computeSize(); + node.size[0] = Math.max(node.size[0], computed[0]); + node.size[1] = computed[1]; +} + +function addLoraRow(node, nodeData, value = {}, markDirty = true) { + const row = new FunPackLoraRowWidget(`lora_${nextLoraIndex(node)}`, value, nodeData); + row.__funpackLoraRow = true; + node.addCustomWidget(row); + moveButtonToEnd(node); + resizeNode(node); + if (markDirty) { + node.setDirtyCanvas(true, true); + } + return row; +} + +function moveButtonToEnd(node) { + const buttonIndex = (node.widgets || []).findIndex((widget) => widget.name === ADD_BUTTON_NAME); + if (buttonIndex < 0 || buttonIndex === node.widgets.length - 1) { + return; + } + const [button] = node.widgets.splice(buttonIndex, 1); + node.widgets.push(button); +} + +function removeManagedWidgets(node) { + node.widgets = (node.widgets || []).filter((widget) => !isManagedWidget(widget)); +} + +function collectVisibleDefaultRows(node) { + const rows = new Map(); + for (const widget of node.widgets || []) { + const match = /^lora_(\d+)(?:_(type|base_weight))?$/.exec(widget.name || ""); + if (!match) { + continue; + } + const index = Number(match[1]); + const field = match[2] || "lora"; + const row = rows.get(index) || { on: true, lora: "None", type: "general", strength: 1.0 }; + if (field === "lora") { + row.lora = widget.value || "None"; + } else if (field === "type") { + row.type = LORA_TYPES.includes(widget.value) ? widget.value : "general"; + } else { + row.strength = Number(widget.value ?? 1.0); + } + rows.set(index, row); + } + return [...rows.keys()].sort((a, b) => a - b).map((index) => rows.get(index)); +} + +function serializedBaseWidgetCount(node) { + let count = 0; + for (const widget of node.widgets || []) { + if (widget.serialize === false || widget.options?.serialize === false || isDefaultLoraWidget(widget)) { + continue; + } + if (BASE_WIDGET_NAMES.has(widget.name)) { + count += 1; + } + } + return count; +} + +function parseSerializedRows(values, baseCount) { + if (!Array.isArray(values)) { + return []; + } + + const rowValues = values.slice(baseCount); + const objectRows = rowValues + .filter((value) => value && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, "lora")) + .map(normalizeRowValue); + if (objectRows.length) { + return objectRows; + } + + const rows = []; + for (let offset = 0; offset + 2 < rowValues.length; offset += 3) { + rows.push(normalizeRowValue({ + lora: rowValues[offset], + type: rowValues[offset + 1], + strength: rowValues[offset + 2], + })); + } + return rows; +} + +function ensureCompactLoraUi(node, nodeData, info = null) { + trackNode(node); + const defaultRows = collectVisibleDefaultRows(node); + const baseCount = serializedBaseWidgetCount(node); + const serializedRows = parseSerializedRows(info?.widgets_values, baseCount); + const rows = serializedRows.length ? serializedRows : defaultRows; + + removeManagedWidgets(node); + node.addCustomWidget(new FunPackLoraHeaderWidget()); + if (rows.length) { + for (const row of rows) { + addLoraRow(node, nodeData, row, false); + } + } else { + addLoraRow(node, nodeData, {}, false); + } + node.addCustomWidget(new FunPackButtonWidget(ADD_BUTTON_NAME, "+ Add LoRA", (event, pos, currentNode) => { + void fetchLoraValues(nodeData).then((values) => { + new LiteGraph.ContextMenu(values, { + event, + title: "Choose LoRA", + callback: (value) => { + if (typeof value === "string" && value !== "None") { + addLoraRow(currentNode, nodeData, { lora: value }); + } else { + addLoraRow(currentNode, nodeData, {}); + } + }, + }); + }); + return true; + })); + moveButtonToEnd(node); + resizeNode(node); } function updateLoraWidgets(node, values) { - for (const widget of node.widgets || []) { - if (!isLoraCombo(widget)) { - continue; - } - widget.options = widget.options || {}; - widget.options.values = valuesWithCurrent(values, widget.value); + for (const widget of getLoraRows(node)) { + widget.updateLoraValues(values); } node.setDirtyCanvas(true, true); } @@ -99,125 +707,6 @@ function scheduleTrackedRefresh(nodeData = latestNodeData) { }, 100); } -function getNextLoraIndex(node) { - let next = 0; - for (const widget of node.widgets || []) { - const match = /^lora_(\d+)$/.exec(widget.name || ""); - if (match) { - next = Math.max(next, Number(match[1]) + 1); - } - } - return next; -} - -function moveAddButtonToEnd(node) { - const button = (node.widgets || []).find((widget) => widget.name === ADD_BUTTON_NAME); - if (!button) { - return; - } - const index = node.widgets.indexOf(button); - if (index >= 0 && index !== node.widgets.length - 1) { - node.widgets.splice(index, 1); - node.widgets.push(button); - } -} - -function resizeNode(node) { - const computed = node.computeSize(); - node.size[0] = Math.max(node.size[0], computed[0]); - node.size[1] = computed[1]; -} - -function addLoraRow(node, nodeData, values, value = {}, markDirty = true) { - const index = getNextLoraIndex(node); - const loraWidget = node.addWidget( - "combo", - `lora_${index}`, - value.lora || "None", - undefined, - { values: valuesWithCurrent(values || getLoraValues(nodeData), value.lora) }, - ); - const typeWidget = node.addWidget( - "combo", - `lora_${index}_type`, - value.type || "general", - undefined, - { values: LORA_TYPES }, - ); - const weightWidget = node.addWidget( - "number", - `lora_${index}_base_weight`, - value.base_weight ?? 1.0, - undefined, - { min: -10.0, max: 10.0, step: 0.01, precision: 3 }, - ); - - loraWidget.serialize = true; - typeWidget.serialize = true; - weightWidget.serialize = true; - moveAddButtonToEnd(node); - resizeNode(node); - if (markDirty) { - node.setDirtyCanvas(true, true); - } -} - -function serializedBaseWidgetCount(node) { - let count = 0; - for (const widget of node.widgets || []) { - if (widget.serialize === false || widget.name === ADD_BUTTON_NAME) { - continue; - } - const match = /^lora_(\d+)(?:_type|_base_weight)?$/.exec(widget.name || ""); - if (match && Number(match[1]) > 0) { - continue; - } - count += 1; - } - return count; -} - -function ensureAddButton(node, nodeData) { - if ((node.widgets || []).some((widget) => widget.name === ADD_BUTTON_NAME)) { - moveAddButtonToEnd(node); - return; - } - - const button = node.addWidget("button", ADD_BUTTON_NAME, null, async () => { - addLoraRow(node, nodeData, await fetchLoraValues(nodeData)); - }); - button.serialize = false; - moveAddButtonToEnd(node); - resizeNode(node); -} - -function restoreExtraRows(node, nodeData, info) { - const values = info?.widgets_values; - if (!Array.isArray(values)) { - return; - } - - const knownWidgetCount = serializedBaseWidgetCount(node); - node.widgets = (node.widgets || []).filter((widget) => { - const match = /^lora_(\d+)(?:_type|_base_weight)?$/.exec(widget.name || ""); - return !match || Number(match[1]) === 0; - }); - - for (let offset = knownWidgetCount; offset + 2 < values.length; offset += 3) { - addLoraRow( - node, - nodeData, - getLoraValues(nodeData), - { - lora: values[offset], - type: values[offset + 1], - base_weight: values[offset + 2], - }, - false, - ); - } -} - function wrapRefreshFunction(functionName) { const original = app[functionName]; if (typeof original !== "function" || original.__funpackLoraWrapped) { @@ -275,17 +764,14 @@ app.registerExtension({ const originalOnNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { originalOnNodeCreated?.apply(this, arguments); - trackNode(this); - ensureAddButton(this, nodeData); + ensureCompactLoraUi(this, nodeData); void refreshLoraWidgets(this, nodeData); }; const originalConfigure = nodeType.prototype.configure; nodeType.prototype.configure = function (info) { originalConfigure?.apply(this, arguments); - trackNode(this); - ensureAddButton(this, nodeData); - restoreExtraRows(this, nodeData, info); + ensureCompactLoraUi(this, nodeData, info); void refreshLoraWidgets(this, nodeData); }; }, From 0eaeabbbc46db1c2d39f626271d64db4a7ab9ea1 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:28:28 +0300 Subject: [PATCH 4/6] Serialize compact LoRA rows as a list --- model_management.py | 68 +++++++++++++++++++++++++++++-------- web/funpack_lora_weights.js | 45 +++++++++++++++++++++++- 2 files changed, 97 insertions(+), 16 deletions(-) diff --git a/model_management.py b/model_management.py index 6e35e2e3..1665b237 100644 --- a/model_management.py +++ b/model_management.py @@ -159,6 +159,7 @@ class FunPackApplyLoraWeights: optional = FlexibleOptionalInputType( any_type, { + "lora_list": ("STRING", {"default": "[]", "multiline": False}), "lora_0": (loras, {"default": "None"}), "lora_0_type": (LORA_TYPES, {"default": "general"}), "lora_0_base_weight": ( @@ -194,6 +195,10 @@ class FunPackApplyLoraWeights: def IS_CHANGED(cls, **kwargs): return float("nan") + @classmethod + def VALIDATE_INPUTS(cls, lora_list=None, lora_0=None): + return True + def _load_suggestions(self, refinement_key, mode, prompt_key): path = refiner_state_path(refinement_key, mode) if not os.path.exists(path): @@ -215,7 +220,52 @@ class FunPackApplyLoraWeights: return suggestions, "refiner suggestions applied" + def _entry_from_row(self, index, row): + if not isinstance(row, dict): + return None + if not row.get("on", True): + return None + + name = row.get("lora", row.get("name", "None")) + if not name or name == "None": + return None + + lora_type = row.get("type", row.get("lora_type", "general")) + if lora_type not in LORA_TYPES: + lora_type = "general" + + return { + "slot": index, + "name": name, + "type": lora_type, + "id": lora_state_id(name, lora_type), + "base_model_weight": safe_float(row.get("strength", row.get("base_weight", 1.0))), + } + + def _iter_lora_list(self, value): + if value in (None, "", "[]"): + return None + if isinstance(value, str): + try: + value = json.loads(value) + except (json.JSONDecodeError, TypeError, ValueError): + return None + if not isinstance(value, list): + return None + + entries = [] + for index, row in enumerate(value): + entry = self._entry_from_row(index, row) + if entry: + entries.append(entry) + return entries + def _iter_slots(self, kwargs): + listed_entries = self._iter_lora_list(kwargs.get("lora_list")) + if listed_entries is not None: + yield from listed_entries + return + indexed_slots = set() for key in kwargs: match = re.fullmatch(r"lora_(\d+)", key) @@ -225,21 +275,9 @@ class FunPackApplyLoraWeights: for index in sorted(indexed_slots): lora_name = kwargs.get(f"lora_{index}", "None") if isinstance(lora_name, dict): - if not lora_name.get("on", True): - continue - name = lora_name.get("lora", "None") - if not name or name == "None": - continue - lora_type = lora_name.get("type", lora_name.get("lora_type", "general")) - if lora_type not in LORA_TYPES: - lora_type = "general" - yield { - "slot": index, - "name": name, - "type": lora_type, - "id": lora_state_id(name, lora_type), - "base_model_weight": safe_float(lora_name.get("strength", lora_name.get("base_weight", 1.0))), - } + entry = self._entry_from_row(index, lora_name) + if entry: + yield entry continue shifted_base_weight = None diff --git a/web/funpack_lora_weights.js b/web/funpack_lora_weights.js index 30d9158b..196e8bab 100644 --- a/web/funpack_lora_weights.js +++ b/web/funpack_lora_weights.js @@ -5,6 +5,7 @@ const NODE_NAME = "FunPackApplyLoraWeights"; const LORA_TYPES = ["general", "concept", "style", "quality", "character"]; const ADD_BUTTON_NAME = "+ Add LoRA"; const HEADER_WIDGET_NAME = "funpack_lora_header"; +const LORA_LIST_WIDGET_NAME = "lora_list"; const BASE_WIDGET_NAMES = new Set(["positive_prompt", "refinement_key", "mode", "per_block"]); let cachedLoraValues = null; @@ -108,7 +109,11 @@ function inside(pos, bounds) { } function getLoraValues(nodeData) { - return cachedLoraValues || nodeData?.input?.optional?.lora_0?.[0] || ["None"]; + const inputType = nodeData?.input?.optional?.lora_0?.[0]; + const embeddedValues = Array.isArray(inputType) + ? inputType + : nodeData?.input?.optional?.lora_0?.[1]?.available_loras; + return cachedLoraValues || (Array.isArray(embeddedValues) ? embeddedValues : ["None"]); } function rememberLoraValues(nodeData, values) { @@ -286,9 +291,28 @@ class FunPackLoraHeaderWidget extends FunPackBaseWidget { } } +class FunPackLoraListWidget extends FunPackBaseWidget { + constructor(node) { + super(LORA_LIST_WIDGET_NAME); + this.node = node; + this.value = "[]"; + } + + computeSize(width) { + return [width, 0]; + } + + draw() {} + + serializeValue() { + return JSON.stringify(getLoraRows(this.node).map((row) => row.serializeValue())); + } +} + class FunPackLoraRowWidget extends FunPackBaseWidget { constructor(name, value, nodeData) { super(name); + this.options.serialize = false; this.value = normalizeRowValue(value); this.nodeData = nodeData; this.draggedWeight = false; @@ -494,6 +518,7 @@ function isDefaultLoraWidget(widget) { function isManagedWidget(widget) { return ( isDefaultLoraWidget(widget) || + widget.name === LORA_LIST_WIDGET_NAME || widget.name === HEADER_WIDGET_NAME || widget.name === ADD_BUTTON_NAME || widget.__funpackLoraRow === true @@ -611,6 +636,23 @@ function parseSerializedRows(values, baseCount) { } const rowValues = values.slice(baseCount); + for (const value of rowValues) { + if (typeof value !== "string") { + continue; + } + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed)) { + const rows = parsed.map(normalizeRowValue); + if (rows.length) { + return rows; + } + } + } catch (_error) { + // Older workflows stored rows as individual widget values. + } + } + const objectRows = rowValues .filter((value) => value && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, "lora")) .map(normalizeRowValue); @@ -637,6 +679,7 @@ function ensureCompactLoraUi(node, nodeData, info = null) { const rows = serializedRows.length ? serializedRows : defaultRows; removeManagedWidgets(node); + node.addCustomWidget(new FunPackLoraListWidget(node)); node.addCustomWidget(new FunPackLoraHeaderWidget()); if (rows.length) { for (const row of rows) { From 6b7ca0f8265c8917527292f4ac90107f340b2758 Mon Sep 17 00:00:00 2001 From: DigitalGarbage <133010426+digital-garbage@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:24:44 +0300 Subject: [PATCH 5/6] Update README.md --- README.md | 50 ++++++++++++++++++-------------------------------- 1 file changed, 18 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index a4260ffc..75853763 100644 --- a/README.md +++ b/README.md @@ -2,37 +2,26 @@ A set of ComfyUI nodes for experimenting with video generation workflows based on WAN, HunyuanVideo, LTX, and similar models. -## Updates in 2.1.1 +## Updates in 2.1.3 -New rating logic for `FunPack Video Refiner`, plus a new CLIP Vision Combine node. +Updated UI for `FunPack Apply LoRA Weights`. Now it's a one-line rgthree-like styled loader which takes up significantly less space in the workflow and looks more pleasent. -`FunPack Gemma Embedding Refiner` now appears in ComfyUI as `FunPack Video Refiner`. Old workflows still load because the original node key is kept as an alias. +Updated rating for `FunPack Video Refiner`. Now it uses a rating system more understandable to end user: -Highlights: +- Rating `"I like it"` - resulting video is an exact match or really close to what was requested; +- Rating `Missing details` - really close to what was requested, main concept is present but some details/actions were omitted; +- Rating `Missing concept` - visually pleasent in terms of quality/anatomy/style/scene, but neither main concept nor details/actions are present; +- Rating `Missing quality` - visually pleasent, but lacking proper anatomy/style/scene and neither main concept nor details/actions are present; +- Rating `I don't like it` - visual garbage, totally not matching the requirements. +- Rating `-Just forget it-` - in case of failing sampling or any other result when you are unable to see the video to rate it, select this rating so the previous generation won't count in learning process. -- clearer rating choices, including `-Just forget it-` for bad runs you do not want saved -- concept feedback questions that can ask what kind of thing a prompt phrase is: concept, style, quality, character, details, or general text -- fewer repeated category questions once the refiner has enough information -- optional sigma and video-latent refinement -- prompt-specific LoRA weight suggestions through `FunPack Apply LoRA Weights` and `FunPack LoRA Loader` -- hidden per-block LoRA weighting for supported LTX model stacks -- a CLIP Vision output combiner node for workflows that need to join multiple CLIP Vision outputs - -For the LoRA/refiner workflow, connect the nodes in this order: - -`FunPack Apply LoRA Weights` -> `FunPack LoRA Loader` -> `FunPack Video Refiner` - -For LTX audio/video latent refinement, split the AV latent before using FunPack's latent path: - -`LTXVSeparateAVLatent` video output -> `FunPack Save Refinement Latent` / `FunPack Video Refiner` -> `LTXVConcatAVLatent` video input - -Do not send the combined AV latent or the audio latent into the FunPack latent nodes. Use the separated video latent, then put it back with `LTXVConcatAVLatent`. +Updated logic for `FunPack Video Refiner`. Now it has a boost in stability in cases when user provides different conditioning and positive prompt with each new generation (e.g. from prompt enhancement nodes). ## Dev Branch -The `dev` branch is for testing unfinished changes. It can be broken, renamed, or changed without warning. +The `dev` branch is intended for testing unfinished changes, implementing new logic and basically, flipping everything just because I can. It can be broken, renamed, or changed without warning. -Use `main` if you want the stable version. Bug reports from `dev` are usually ignored unless I specifically asked you to test that branch. +Use only `main` if you want the most stable version of this node pack. Bug reports based on `dev` version will be ignored. ## Installation @@ -53,12 +42,7 @@ Install them with: `pip install -r requirements.txt` -FunPack uses your existing ComfyUI/PyTorch install. The expected baseline is: - -- `torch >= 2.8.0` -- `transformers >= 5.0.0` - -Higher Torch versions are fine. If your ComfyUI environment has an older Torch build, update it there. +FunPack uses your existing ComfyUI/PyTorch install. The expected baseline is `transformers >= 5.0.0` `hpsv3` is optional and only used by the `FunPack StoryMem Keyframe Extractor` quality filter, so it is not installed by default. @@ -70,9 +54,9 @@ Install it manually only if you need that feature: 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`. +FunPack's LLM nodes require `transformers >= 5.0`. The version required for `hpsv3` is strictly `transformers==4.45.2`. Installing any version different from it will result in broken quality detector. -If you install `hpsv3`, use `--no-build-isolation`. +If you install `hpsv3`, use `--no-build-isolation`. Optionally, specify the exact version - `pip install transformers==4.45.2 --no-build-isolation`. ## Documentation @@ -90,4 +74,6 @@ Version history is available in [CHANGELOG.md](CHANGELOG.md). If you have suggestions, questions, or ideas for new nodes, feel free to open an issue or submit a pull request. -Thanks to the teams behind OpenAI, xAI, DeepSeek, Anthropic, and Google for the AI tools that helped with the code. Thanks also to everyone testing FunPack in real workflows and sending feedback. This project would still be a folder of half-working experiments without you. +## Thank you + +I want to say thanks to teams behind OpenAI (ChatGPT/Codex), xAI (Grok), DeepSeek, Anthropic (Claude) and Google (Gemini) for all the help with coding and transforming my ideas into something working in real UI. Thanks to all the testers and users who regularly use FunPack in their workflows, request features and report bugs. Without all of you, this project would've been just wet dreams of a wannabe coder begging on Discord for someone to add nodes he wants. Seriously, you are cool. I love you all. <3 From dcfd82389aa36e6a19e3b786c45e01608d486d10 Mon Sep 17 00:00:00 2001 From: DigitalGarbage <133010426+digital-garbage@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:27:49 +0300 Subject: [PATCH 6/6] Update CHANGELOG.md --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7617dcc7..99ba8ec3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [2.1.3] - 2026-04-24 + +### Changed + +`FunPack Apply LoRA Weights` now has more user-friendly, compact UI. + +`FunPack Video Refiner` now has updated logic to work more stable when provided different prompts and conditioning with each new generation. + ## [2.1.1] - 2026-04-24 ### Added