Improve LoRA model strength detection across workflow analysis and UI

This commit is contained in:
Dariusz L
2026-07-24 17:57:27 +02:00
parent dcbb5dc8e1
commit 467b1987e1
5 changed files with 387 additions and 21 deletions
+7 -15
View File
@@ -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:
+105
View File
@@ -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(
@@ -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')})`);
+144 -1
View File
@@ -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 = {
+39 -5
View File
@@ -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