From 467b1987e127982b3b9356b707cfe050ac877b3c Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Fri, 24 Jul 2026 17:57:27 +0200 Subject: [PATCH] Improve LoRA model strength detection across workflow analysis and UI --- __init__.py | 22 +-- core/workflow_analyzer.py | 105 ++++++++++++++ tests/test_downloads_tab_workflow_route.mjs | 92 ++++++++++++ tests/test_workflow_analyzer.py | 145 ++++++++++++++++++- web/resolver/shell/workflow_state_methods.js | 44 +++++- 5 files changed, 387 insertions(+), 21 deletions(-) diff --git a/__init__.py b/__init__.py index bf26d0b..55520cf 100644 --- a/__init__.py +++ b/__init__.py @@ -1676,6 +1676,7 @@ class ModelResolverExtension: from .core.workflow_analyzer import ( URN_TYPE_MAP, analyze_workflow_models, + get_lora_model_strength, ) update_loaded_progress( @@ -1887,25 +1888,16 @@ class ModelResolverExtension: model_name = get_filename_from_path(original_path) strength = None - # For standard LoraLoader nodes, strength is in next widget_value if ( - node_type in ["LoraLoader", "LoraLoaderModelOnly"] - and isinstance(widget_index, int) + ref.get("strength") is None + and category in {"lora", "loras"} ): - # Find the node in workflow to get strength value for node in nodes: if node_matches_ref(node, ref): - widgets_values = node.get("widgets_values", []) - if ( - isinstance(widgets_values, list) - and len(widgets_values) > widget_index + 1 - ): - try: - strength = float( - widgets_values[widget_index + 1] - ) - except (ValueError, TypeError): - strength = 1.0 + strength = get_lora_model_strength( + node, + widget_index, + ) break if ref.get("strength") is not None: diff --git a/core/workflow_analyzer.py b/core/workflow_analyzer.py index 2192f08..b0a0249 100644 --- a/core/workflow_analyzer.py +++ b/core/workflow_analyzer.py @@ -960,6 +960,111 @@ def get_dynamic_serialized_widget_name( return names_by_index.get(widget_index, "") +LORA_MODEL_STRENGTH_WIDGET_NAMES = ( + "strength_model", + "lora_model_strength", + "model_strength", + "lora_strength", + "strength", +) + +LEGACY_LORA_STRENGTH_NEXT_WIDGET_TYPES = { + "LoraLoader", + "LoraLoaderModelOnly", + "LoraLoaderBypass", + "LoraLoaderBypassModelOnly", + "CreateHookLora", + "CreateHookLoraModelOnly", +} + + +def _coerce_lora_strength(value: Any) -> Optional[float]: + if isinstance(value, bool): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def get_lora_model_strength( + node: Dict[str, Any], lora_widget_index: Optional[int] = None +) -> Optional[float]: + """Return the model strength associated with a LoRA widget in a workflow node.""" + widgets_values = node.get("widgets_values", []) + if not isinstance(widgets_values, list): + return None + + hints = get_dynamic_node_widget_category_hints(str(node.get("type", "") or "")) + schema_names = _normalized_index_mapping( + hints.get("serialized_name_by_index", {}) + ) + effective_names, _non_model_indices = _dynamic_serialized_widget_layout( + node, hints + ) + if effective_names: + schema_names = effective_names + + values_by_name: Dict[str, Any] = {} + names_by_index: Dict[int, List[str]] = {} + for widget_index, value in enumerate(widgets_values): + names = [] + schema_name = schema_names.get(widget_index) + if schema_name: + names.append(schema_name) + names.extend(get_widget_name_candidates(node, widget_index)) + normalized_names = unique_ordered_strings( + [normalize_widget_name(name) for name in names] + ) + names_by_index[widget_index] = normalized_names + for normalized_name in normalized_names: + if normalized_name and normalized_name not in values_by_name: + values_by_name[normalized_name] = value + + if isinstance(lora_widget_index, int): + for widget_name in names_by_index.get(lora_widget_index, []): + indexed_match = re.fullmatch(r"lora_(\d+)_name", widget_name) + if not indexed_match: + continue + + lora_number = indexed_match.group(1) + mode = str(values_by_name.get("mode") or "").strip().lower() + if mode == "simple": + indexed_strength_names = [ + f"lora_{lora_number}_strength", + f"lora_{lora_number}_model_strength", + ] + else: + indexed_strength_names = [ + f"lora_{lora_number}_model_strength", + f"lora_{lora_number}_strength", + ] + + for strength_name in indexed_strength_names: + strength = _coerce_lora_strength( + values_by_name.get(strength_name) + ) + if strength is not None: + return strength + + for strength_name in LORA_MODEL_STRENGTH_WIDGET_NAMES: + if strength_name not in values_by_name: + continue + strength = _coerce_lora_strength(values_by_name[strength_name]) + if strength is not None: + return strength + + node_type = str(node.get("type", "") or "") + if ( + node_type in LEGACY_LORA_STRENGTH_NEXT_WIDGET_TYPES + and isinstance(lora_widget_index, int) + and 0 <= lora_widget_index + 1 < len(widgets_values) + ): + return _coerce_lora_strength(widgets_values[lora_widget_index + 1]) + + return None + + def is_dynamic_non_model_widget(node: Dict[str, Any], widget_index: int) -> bool: hints = get_dynamic_node_widget_category_hints(str(node.get("type", "") or "")) _names_by_index, non_model_indices = _dynamic_serialized_widget_layout( diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index 8c0cf75..2720302 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -54,6 +54,10 @@ const resolverDialogSource = fs.readFileSync( path.join(projectRoot, 'web/resolver/resolver_dialog.js'), 'utf8' ); +const workflowStateMethodsSource = fs.readFileSync( + path.join(projectRoot, 'web/resolver/shell/workflow_state_methods.js'), + 'utf8' +); const renderFormatMethodsSource = fs.readFileSync( path.join(projectRoot, 'web/resolver/utils/render_format_methods.js'), 'utf8' @@ -535,6 +539,94 @@ function extractMethod(source, methodName, paramsPattern = '[^)]*') { throw new Error(`Could not parse ${methodName}`); } +test('workflow signature tracks model strength for bypass and custom LoRA loaders', () => { + const getWorkflowSignatureData = eval(`(${extractMethod(workflowStateMethodsSource, 'getWorkflowSignatureData')})`); + const dialog = { capabilities: { node_rules: {} } }; + const makeWorkflow = (type, strengthName, strength) => ({ + nodes: [{ + id: 34, + type, + inputs: [ + { name: 'model', type: 'MODEL', link: 1 }, + { name: 'clip', type: 'CLIP', link: 2 }, + { name: 'lora_name', widget: { name: 'lora_name' } }, + { name: strengthName, widget: { name: strengthName } }, + ], + outputs: [], + widgets_values: ['AnimeEditV2.safetensors', strength], + }], + links: [], + }); + + const bypass = getWorkflowSignatureData.call( + dialog, + makeWorkflow('LoraLoaderBypass', 'strength_model', 0.65) + ); + const custom = getWorkflowSignatureData.call( + dialog, + makeWorkflow('easy fullLoader', 'lora_model_strength', 0.8) + ); + + assert.deepEqual(bypass.nodes[0].widgets_values, [ + { index: 0, value: 'AnimeEditV2.safetensors' }, + { index: 1, value: 0.65 }, + ]); + assert.deepEqual(custom.nodes[0].widgets_values, [ + { index: 0, value: 'AnimeEditV2.safetensors' }, + { index: 1, value: 0.8 }, + ]); +}); + +test('workflow signature tracks EasyLoraStack mode and indexed strengths', () => { + const getWorkflowSignatureData = eval(`(${extractMethod(workflowStateMethodsSource, 'getWorkflowSignatureData')})`); + const dialog = { + capabilities: { + node_rules: { + 'easy loraStack': { 3: 'loras', 7: 'loras' }, + }, + }, + }; + const widgetNames = [ + 'toggle', + 'mode', + 'num_loras', + 'lora_1_name', + 'lora_1_strength', + 'lora_1_model_strength', + 'lora_1_clip_strength', + ]; + const workflow = { + nodes: [{ + id: 160, + type: 'easy loraStack', + inputs: widgetNames.map(name => ({ name, widget: { name } })), + outputs: [], + widgets_values: [ + true, + 'advanced', + 1, + 'nsfw_flux_lora_v1.safetensors', + 1, + 0.45, + 0.25, + ], + }], + links: [], + }; + + const signature = getWorkflowSignatureData.call(dialog, workflow); + + assert.deepEqual(signature.nodes[0].widgets_values, [ + { index: 0, value: true }, + { index: 1, value: 'advanced' }, + { index: 2, value: 1 }, + { index: 3, value: 'nsfw_flux_lora_v1.safetensors' }, + { index: 4, value: 1 }, + { index: 5, value: 0.45 }, + { index: 6, value: 0.25 }, + ]); +}); + test('download percent keeps native Xet progress below one percent visible', () => { const formatDownloadPercent = eval(`(${extractMethod(renderFormatMethodsSource, 'formatDownloadPercent')})`); diff --git a/tests/test_workflow_analyzer.py b/tests/test_workflow_analyzer.py index aeb5750..78583f1 100644 --- a/tests/test_workflow_analyzer.py +++ b/tests/test_workflow_analyzer.py @@ -6,7 +6,11 @@ from unittest.mock import patch from core import workflow_analyzer from core.scanner import scan_directory -from core.workflow_analyzer import analyze_workflow_models, identify_missing_models +from core.workflow_analyzer import ( + analyze_workflow_models, + get_lora_model_strength, + identify_missing_models, +) def _workflow_with_model(model_path): @@ -995,6 +999,145 @@ class WorkflowCategoryHintsTests(unittest.TestCase): self.assertEqual("loras", NODE_TYPE_TO_CATEGORY_HINTS.get("LoraLoaderV2")) +class WorkflowLoraStrengthTests(unittest.TestCase): + @staticmethod + def _easy_lora_stack_hints(): + return { + "serialized_name_by_index": { + 0: "toggle", + 1: "mode", + 2: "num_loras", + 3: "lora_1_name", + 4: "lora_1_strength", + 5: "lora_1_model_strength", + 6: "lora_1_clip_strength", + 7: "lora_2_name", + 8: "lora_2_strength", + 9: "lora_2_model_strength", + 10: "lora_2_clip_strength", + }, + "non_model_by_index": [], + "has_generated_widgets": False, + } + + def test_bypass_loader_uses_strength_model_from_dynamic_schema(self): + node = { + "type": "LoraLoaderBypass", + "widgets_values": [ + r"Anima\anime\AnimeEditV2.safetensors", + 0.65, + 0.4, + ], + } + hints = { + "serialized_name_by_index": { + 0: "lora_name", + 1: "strength_model", + 2: "strength_clip", + }, + "non_model_by_index": [], + "has_generated_widgets": False, + } + + with patch( + "core.workflow_analyzer.get_dynamic_node_widget_category_hints", + return_value=hints, + ): + strength = get_lora_model_strength(node, 0) + + self.assertEqual(0.65, strength) + + def test_custom_loader_supports_lora_model_strength_widget(self): + node = { + "type": "easy fullLoader", + "inputs": [ + {"name": "model", "type": "MODEL"}, + {"name": "lora_name", "widget": {"name": "lora_name"}}, + { + "name": "lora_model_strength", + "widget": {"name": "lora_model_strength"}, + }, + { + "name": "lora_clip_strength", + "widget": {"name": "lora_clip_strength"}, + }, + ], + "widgets_values": ["style.safetensors", 0.8, 0.3], + } + + with patch( + "core.workflow_analyzer.get_dynamic_node_widget_category_hints", + return_value={}, + ): + strength = get_lora_model_strength(node, 0) + + self.assertEqual(0.8, strength) + + def test_bypass_loader_keeps_legacy_next_widget_fallback(self): + node = { + "type": "LoraLoaderBypass", + "widgets_values": ["style.safetensors", "1.25", 0.5], + } + + with patch( + "core.workflow_analyzer.get_dynamic_node_widget_category_hints", + return_value={}, + ): + strength = get_lora_model_strength(node, 0) + + self.assertEqual(1.25, strength) + + def test_easy_lora_stack_simple_mode_uses_matching_indexed_strength(self): + node = { + "type": "easy loraStack", + "widgets_values": [ + True, + "simple", + 2, + "first.safetensors", + 0.7, + 0.2, + 0.3, + "second.safetensors", + 1.1, + 1.2, + 1.3, + ], + } + + with patch( + "core.workflow_analyzer.get_dynamic_node_widget_category_hints", + return_value=self._easy_lora_stack_hints(), + ): + first_strength = get_lora_model_strength(node, 3) + second_strength = get_lora_model_strength(node, 7) + + self.assertEqual(0.7, first_strength) + self.assertEqual(1.1, second_strength) + + def test_easy_lora_stack_advanced_mode_uses_model_strength(self): + node = { + "type": "easy loraStack", + "widgets_values": [ + True, + "advanced", + 1, + "style.safetensors", + 0.7, + 0.45, + 0.25, + ], + } + + with patch( + "core.workflow_analyzer.get_dynamic_node_widget_category_hints", + return_value=self._easy_lora_stack_hints(), + ): + strength = get_lora_model_strength(node, 3) + + self.assertEqual(0.45, strength) + + class WorkflowMissingReferenceGroupingTests(unittest.TestCase): def test_power_lora_duplicate_missing_tracks_all_node_refs(self): workflow = { diff --git a/web/resolver/shell/workflow_state_methods.js b/web/resolver/shell/workflow_state_methods.js index 57306cd..a7a94ce 100644 --- a/web/resolver/shell/workflow_state_methods.js +++ b/web/resolver/shell/workflow_state_methods.js @@ -66,7 +66,23 @@ export const workflowStateMethods = { 'active', 'on' ]); - const loraStrengthNodeTypes = new Set(['LoraLoader', 'LoraLoaderModelOnly']); + const loraStrengthNodeTypes = new Set([ + 'LoraLoader', + 'LoraLoaderModelOnly', + 'LoraLoaderBypass', + 'LoraLoaderBypassModelOnly', + 'CreateHookLora', + 'CreateHookLoraModelOnly' + ]); + const loraStrengthKeys = new Set([ + 'strength_model', + 'lora_model_strength', + 'model_strength', + 'lora_strength', + 'strength' + ]); + const indexedLoraStrengthPattern = /^lora_\d+_(?:strength|model_strength|clip_strength)$/; + const loraStackStateKeys = new Set(['toggle', 'mode', 'num_loras']); const staticFallback = { CheckpointLoaderSimple: [0], CheckpointLoader: [1], @@ -158,7 +174,13 @@ export const workflowStateMethods = { || urlPattern.test(text) || loraTokenPattern.test(text); }; - const isRelevantScalarKey = (key = '') => ['strength', 'active', 'on', 'model_id', 'modelid', 'version_id', 'versionid'].includes(normalizeKey(key)); + const isRelevantScalarKey = (key = '') => { + const normalizedKey = normalizeKey(key); + return loraStrengthKeys.has(normalizedKey) + || indexedLoraStrengthPattern.test(normalizedKey) + || loraStackStateKeys.has(normalizedKey) + || ['active', 'on', 'model_id', 'modelid', 'version_id', 'versionid'].includes(normalizedKey); + }; const normalizeRelevantValue = (value, key = '', isModelWidget = false) => { if (value == null) return null; @@ -192,8 +214,14 @@ export const workflowStateMethods = { const widgetsValues = Array.isArray(node.widgets_values) ? node.widgets_values : []; const nodeType = node.type || ''; const widgetValueKey = (index) => { - for (const key of ['widgets', 'inputs']) { - const items = Array.isArray(node[key]) ? node[key] : []; + const widgetInputs = Array.isArray(node.inputs) + ? node.inputs.filter(item => item?.widget != null) + : []; + const collections = [ + Array.isArray(node.widgets) ? node.widgets : [], + widgetInputs + ]; + for (const items of collections) { const item = items[index]; if (!item || typeof item !== 'object') continue; const widgetName = item.widget && typeof item.widget === 'object' @@ -208,7 +236,13 @@ export const workflowStateMethods = { .map((value, index) => { const key = widgetValueKey(index); const isModelWidget = isModelWidgetIndex(nodeType, index); - let normalized = normalizeRelevantValue(value, key, isModelWidget); + const isLoraStackState = nodeType === 'easy loraStack' + && loraStackStateKeys.has(normalizeKey(key)); + let normalized = normalizeRelevantValue( + value, + key, + isModelWidget || isLoraStackState + ); if ( normalized === null