Add metadata workflow model transfer

This commit is contained in:
Dariusz L
2026-09-13 21:38:52 +02:00
parent 3a106e8d67
commit d1812fa46f
8 changed files with 1917 additions and 172 deletions
+1
View File
@@ -318,6 +318,7 @@ class LoadedModelsService:
"category": category,
"node_id": node_id,
"widget_index": widget_index,
"widget_name": ref.extra_value("widget_name", ""),
"node_type": node_type,
"node_title": ref.extra_value("node_title", ""),
"subgraph_id": ref.subgraph_id or "",
@@ -49,6 +49,12 @@ class WorkflowTransferService:
{"error": "Transfer mode must be a string"},
status=400,
)
activity_scope = data.get("activity_scope", "all")
if not isinstance(activity_scope, str):
return self.web.json_response(
{"error": "LoRA activity scope must be a string"},
status=400,
)
if detect_workflow_format(workflow) == "api":
try:
@@ -67,6 +73,7 @@ class WorkflowTransferService:
source_models,
target_refs,
mode=mode,
activity_scope=activity_scope,
inventory=inventory,
)
except (TypeError, ValueError) as exc:
+265 -17
View File
@@ -59,6 +59,7 @@ _LORA_STRENGTH_NAMES = {
"lora_model_strength",
"model_strength",
}
_TRANSFER_ACTIVITY_SCOPES = {"all", "active", "inactive"}
def normalize_transfer_category(value: Any) -> str:
@@ -89,6 +90,25 @@ def _coerce_active(value: Any) -> Optional[bool]:
return None
def normalize_transfer_activity_scope(value: Any) -> str:
"""Normalize the active/inactive imported LoRA filter used by transfers."""
token = str(value or "all").strip().lower().replace("_", "-")
if token in {"both", "active-inactive", "active-and-inactive"}:
return "all"
if token in _TRANSFER_ACTIVITY_SCOPES:
return token
raise ValueError("LoRA activity scope must be all, active, or inactive")
def _activity_matches_scope(active: Optional[bool], scope: str) -> bool:
"""Treat an unspecified LoRA state as active for backwards compatibility."""
if scope == "all":
return True
if scope == "inactive":
return active is False
return active is not False
def _set_named_lora_strength(
node: Mapping[str, Any],
reference: ModelReference,
@@ -127,13 +147,17 @@ def _source_model(value: Any) -> Optional[Dict[str, Any]]:
if path.casefold().startswith("urn:") or value.get("is_urn"):
return None
return {
normalized = {
"path": path,
"category": category,
"strength": _coerce_strength(value.get("strength")),
"active": _coerce_active(value.get("active")),
"name": str(value.get("name") or get_filename_from_path(path)).strip(),
}
source_index = value.get("source_index")
if isinstance(source_index, int) and not isinstance(source_index, bool) and source_index >= 0:
normalized["source_index"] = source_index
return normalized
def _scope_key(reference: ModelReference) -> tuple[str, bool, str]:
@@ -276,6 +300,40 @@ def _assign_sources(
return list(zip(references, sources, strict=False))
def _select_source_for_reference(
category_sources: Sequence[Mapping[str, Any]],
selector: Mapping[str, Any],
position: int,
) -> Optional[Mapping[str, Any]]:
"""Resolve an optional explicit source index, then use positional mapping."""
if not category_sources:
return None
selected_source_index = selector.get("source_index")
if selected_source_index is not None:
if (
isinstance(selected_source_index, bool)
or not isinstance(selected_source_index, int)
or selected_source_index < 0
):
raise ValueError("The selected metadata model index is invalid")
source = next(
(
candidate
for candidate in category_sources
if candidate.get("source_index") == selected_source_index
),
None,
)
if source is None:
raise ValueError("The selected metadata model is no longer available")
return source
if len(category_sources) == 1:
return category_sources[0]
return category_sources[position] if position < len(category_sources) else None
def _set_lora_strength(
node: Dict[str, Any],
reference: ModelReference,
@@ -382,6 +440,64 @@ def _set_lora_strength(
return False
def _set_lora_active(
node: Dict[str, Any],
reference: ModelReference,
active: Optional[bool],
*,
fallback_identity: Optional[str] = None,
) -> bool:
"""Copy a source LoRA's enabled state when the target serializes one."""
if active is None or normalize_transfer_category(reference.category) != "loras":
return False
widgets_values = node.get("widgets_values", [])
if not isinstance(widgets_values, list):
return False
widget_index = reference.widget_index
if not isinstance(widget_index, int) or widget_index < 0 or widget_index >= len(widgets_values):
return False
value = widgets_values[widget_index]
nested_key = reference.extra_value("nested_key")
adapter_id = reference.extra_value("custom_node_adapter")
if nested_key == "lora" and isinstance(value, dict):
if "on" not in value and adapter_id != "rgthree-power-lora-loader":
return False
value["on"] = active
named_values = node.get("widgets_values_named")
if isinstance(named_values, dict):
widget_name = reference.extra_value("widget_name")
if not widget_name:
keys = list(named_values)
if 0 <= widget_index < len(keys):
widget_name = keys[widget_index]
named_value = named_values.get(widget_name) if widget_name else None
if isinstance(named_value, dict):
named_value["on"] = active
return True
if adapter_id != "lora-manager" and node.get("type") not in _LORA_MANAGER_NODE_TYPES:
return False
if len(widgets_values) <= 2 or not isinstance(widgets_values[2], list):
return False
target_identities = {
identity
for identity in (
_normalize_identity(reference.original_path),
_normalize_identity(fallback_identity),
)
if identity
}
for item in widgets_values[2]:
if not isinstance(item, dict):
continue
if _normalize_identity(item.get("name")) in target_identities:
item["active"] = active
return True
return False
def _lora_name(source: Mapping[str, Any]) -> str:
filename = get_filename_from_path(str(source["path"]))
stem, _extension = os.path.splitext(filename)
@@ -525,20 +641,26 @@ def transfer_models_to_workflow(
target_refs: Sequence[Any],
*,
mode: str = "replace",
activity_scope: str = "all",
inventory: Any = None,
) -> Dict[str, Any]:
"""Apply selected metadata models to selected workflow model slots.
``replace`` changes only checked target slots. ``merge``/``add`` keeps the
current values and appends to the two supported multi-LoRA list formats.
``replace-add`` replaces selected LoRA slots and appends any remaining
imported LoRAs to supported multi-LoRA list formats.
"""
if not isinstance(workflow, dict):
raise ValueError("Workflow JSON must be an object")
normalized_mode = str(mode or "replace").strip().lower().replace("_", "-")
if normalized_mode in {"merge", "add", "merge-add"}:
normalized_mode = "merge"
elif normalized_mode in {"replace-add", "replaceadd", "replace-plus-add"}:
normalized_mode = "replace-add"
elif normalized_mode != "replace":
raise ValueError("Transfer mode must be replace or merge")
raise ValueError("Transfer mode must be replace, replace-add, or merge")
normalized_activity_scope = normalize_transfer_activity_scope(activity_scope)
if not isinstance(source_models, Sequence) or isinstance(source_models, (str, bytes)):
raise ValueError("source_models must be an array")
if not isinstance(target_refs, Sequence) or isinstance(target_refs, (str, bytes)):
@@ -560,6 +682,7 @@ def transfer_models_to_workflow(
]
selected_refs: List[ModelReference] = []
selected_ref_selectors: List[tuple[ModelReference, Mapping[str, Any]]] = []
used_ref_indexes = set()
skipped: List[Dict[str, Any]] = []
for selector in target_refs:
@@ -582,7 +705,9 @@ def transfer_models_to_workflow(
})
continue
used_ref_indexes.add(match_index)
selected_refs.append(available_refs[match_index])
reference = available_refs[match_index]
selected_refs.append(reference)
selected_ref_selectors.append((reference, selector))
if not selected_refs:
raise ValueError("The selected workflow model slots are no longer available")
@@ -593,27 +718,47 @@ def transfer_models_to_workflow(
sources_by_category: OrderedDict[str, List[Dict[str, Any]]] = OrderedDict()
for source in sources:
sources_by_category.setdefault(source["category"], []).append(source)
transfer_sources_by_category: OrderedDict[str, List[Dict[str, Any]]] = OrderedDict()
for category, category_sources in sources_by_category.items():
transfer_sources_by_category[category] = [
source
for source in category_sources
if category != "loras"
or _activity_matches_scope(source.get("active"), normalized_activity_scope)
]
# Build assignments in selection order across the complete workflow. One
# source intentionally fans out to every compatible target; multiple
# sources map one-to-one until either side is exhausted.
source_by_reference: Dict[int, Mapping[str, Any]] = {}
selected_refs_by_category: OrderedDict[str, List[ModelReference]] = OrderedDict()
for reference in selected_refs:
selected_refs_by_category: OrderedDict[
str, List[tuple[ModelReference, Mapping[str, Any]]]
] = OrderedDict()
for reference, selector in selected_ref_selectors:
category = normalize_transfer_category(reference.category)
if category:
selected_refs_by_category.setdefault(category, []).append(reference)
selected_refs_by_category.setdefault(category, []).append((reference, selector))
for category, category_refs in selected_refs_by_category.items():
for reference, source in _assign_sources(
sources_by_category.get(category, []),
category_refs,
):
source_by_reference[id(reference)] = source
category_sources = transfer_sources_by_category.get(category, [])
for source_position, (reference, selector) in enumerate(category_refs):
source = _select_source_for_reference(
category_sources,
selector,
source_position,
)
if source is not None:
source_by_reference[id(reference)] = source
selector_by_reference = {
id(reference): selector
for reference, selector in selected_ref_selectors
}
resolutions: List[Dict[str, Any]] = []
updates: List[Dict[str, Any]] = []
requires_full_reload = normalized_mode == "merge"
strength_changed = False
active_state_changed = False
for _scope, scope_refs in refs_by_scope.items():
node = _find_workflow_node(workflow, scope_refs[0])
@@ -629,7 +774,7 @@ def transfer_models_to_workflow(
if normalized_mode == "merge":
if node_type in _LORA_MANAGER_NODE_TYPES:
lora_sources = sources_by_category.get("loras", [])
lora_sources = transfer_sources_by_category.get("loras", [])
appended = _append_lora_manager_models(node, lora_sources)
if appended:
_sync_lora_manager_named_values(node)
@@ -648,7 +793,7 @@ def transfer_models_to_workflow(
continue
if node_type == _POWER_LORA_NODE_TYPE:
lora_sources = sources_by_category.get("loras", [])
lora_sources = transfer_sources_by_category.get("loras", [])
appended = _append_power_lora_models(node, lora_sources)
if appended:
updates.append({
@@ -666,7 +811,9 @@ def transfer_models_to_workflow(
continue
matching_source_categories = [
category for category in refs_by_category if sources_by_category.get(category)
category
for category in refs_by_category
if transfer_sources_by_category.get(category)
]
if matching_source_categories:
skipped.append({
@@ -676,8 +823,100 @@ def transfer_models_to_workflow(
})
continue
if normalized_mode == "replace-add" and (
node_type in _LORA_MANAGER_NODE_TYPES
or node_type == _POWER_LORA_NODE_TYPE
):
lora_references = refs_by_category.get("loras", [])
lora_sources = transfer_sources_by_category.get("loras", [])
used_source_ids = set()
for source_position, reference in enumerate(lora_references):
selector = selector_by_reference.get(id(reference), {})
source = _select_source_for_reference(
lora_sources,
selector,
source_position,
)
if source is None:
continue
resolution = _build_resolution(reference, source)
if not update_model_path(workflow, resolution):
skipped.append({
"node_id": reference.node_id,
"widget_index": reference.widget_index,
"reason": "The selected workflow model slot could not be updated",
})
continue
used_source_ids.add(id(source))
if _set_lora_strength(
node,
reference,
source.get("strength"),
fallback_identity=_lora_name(source),
):
strength_changed = True
if _set_lora_active(
node,
reference,
source.get("active"),
fallback_identity=_lora_name(source),
):
active_state_changed = True
if node_type in _LORA_MANAGER_NODE_TYPES:
_sync_lora_manager_named_values(node)
resolutions.append(_resolution_payload(resolution))
updates.append({
"node_id": reference.node_id,
"widget_index": reference.widget_index,
"node_type": reference.node_type,
"category": "loras",
"operation": "replace",
"source": source["path"],
})
if reference.is_top_level is False:
requires_full_reload = True
append_sources = [
source for source in lora_sources if id(source) not in used_source_ids
]
if node_type in _LORA_MANAGER_NODE_TYPES:
appended = _append_lora_manager_models(node, append_sources)
if appended:
_sync_lora_manager_named_values(node)
requires_full_reload = True
updates.append({
"node_id": scope_refs[0].node_id,
"node_type": node_type,
"category": "loras",
"operation": "replace-add",
"count": appended,
})
elif append_sources:
skipped.append({
"node_id": scope_refs[0].node_id,
"reason": "This LoRA Manager node has no appendable LoRA list",
})
else:
appended = _append_power_lora_models(node, append_sources)
if appended:
requires_full_reload = True
updates.append({
"node_id": scope_refs[0].node_id,
"node_type": node_type,
"category": "loras",
"operation": "replace-add",
"count": appended,
})
elif append_sources:
skipped.append({
"node_id": scope_refs[0].node_id,
"reason": "This Power LoRA node has no appendable LoRA slots",
})
continue
for category, references in refs_by_category.items():
if not sources_by_category.get(category):
if not transfer_sources_by_category.get(category):
continue
for reference in references:
source = source_by_reference.get(id(reference))
@@ -698,6 +937,13 @@ def transfer_models_to_workflow(
fallback_identity=_lora_name(source),
):
strength_changed = True
if _set_lora_active(
node,
reference,
source.get("active"),
fallback_identity=_lora_name(source),
):
active_state_changed = True
if node_type in _LORA_MANAGER_NODE_TYPES:
_sync_lora_manager_named_values(node)
resolutions.append(_resolution_payload(resolution))
@@ -712,11 +958,13 @@ def transfer_models_to_workflow(
if reference.is_top_level is False:
requires_full_reload = True
if strength_changed:
if strength_changed or active_state_changed:
requires_full_reload = True
updated_count = sum(
int(item.get("count", 0)) if item.get("operation") == "merge" else 1
int(item.get("count", 0))
if item.get("operation") in {"merge", "replace-add"}
else 1
for item in updates
)
if not updated_count:
+300
View File
@@ -0,0 +1,300 @@
import { readFileSync } from 'node:fs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { imageMetadataMethods } from '../web/resolver/views/tabs_image_metadata_methods.js';
const modelInfoMethodsSource = readFileSync(
new URL('../web/resolver/views/model_info_methods.js', import.meta.url),
'utf8'
);
const imageMetadataMethodsSource = readFileSync(
new URL('../web/resolver/views/tabs_image_metadata_methods.js', import.meta.url),
'utf8'
);
function extractMethod(source, methodName, paramsPattern = '[^)]*') {
const signatureRegex = new RegExp(`\\n\\s+(async\\s+)?${methodName}\\s*\\(${paramsPattern}\\)\\s*\\{`);
const match = signatureRegex.exec(source);
assert.ok(match, `Could not find ${methodName}`);
const isAsync = Boolean(match[1]);
const parenStart = source.indexOf('(', match.index);
const parenEnd = source.indexOf(')', parenStart);
const params = source.slice(parenStart + 1, parenEnd);
const braceStart = source.indexOf('{', parenEnd);
let depth = 0;
for (let index = braceStart; index < source.length; index += 1) {
const char = source[index];
if (char === '{') depth += 1;
if (char === '}') depth -= 1;
if (depth === 0) {
return `${isAsync ? 'async ' : ''}function ${methodName}(${params}) ${source.slice(braceStart, index + 1)}`;
}
}
throw new Error(`Could not parse ${methodName}`);
}
function createTransferContext(catalogValues = ['clip.safetensors']) {
return {
...imageMetadataMethods,
getMissingAcceptedModelFileTypes: ({ node_type: nodeType, widget_name: widgetName }) => (
nodeType === 'CLIPLoader' && widgetName === 'clip_name'
? catalogValues
.map(value => {
const match = String(value || '').match(/\.([a-z0-9]+)$/i);
return match
? { extension: match[1].toLowerCase(), display: `.${match[1].toLowerCase()}` }
: null;
})
.filter(Boolean)
: []
),
getModelFileTypeInfo: value => {
const match = String(value || '').match(/\.([a-z0-9]+)$/i);
return match ? { extension: match[1].toLowerCase() } : null;
},
getComfyWidgetNameByIndex: () => 'clip_name',
getCurrentComfyCatalogValues: (nodeType, widgetName) => (
nodeType === 'CLIPLoader' && widgetName === 'clip_name'
? catalogValues
: null
),
escapeHtml: value => String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;'),
getFilenameFromPath: value => String(value).split(/[\\/]/).pop(),
stripModelExtension: value => String(value).replace(/\.[^.]+$/, ''),
getContextMenuAttrs: model => model?.context_scope
? ` data-context-scope="${model.context_scope}"`
: '',
getModelPreviewTooltipAttrs: model => model?.exists === true && model?.resolved_path
? ` data-tooltip-image="${model.resolved_path}"`
: '',
};
}
function createTransferState(sourceModels) {
const targetGroups = [{
category: 'text_encoders',
nodes: [{
nodeKey: 'top:1',
nodeId: 1,
nodeType: 'CLIPLoader',
refs: [{
category: 'text_encoders',
node_id: 1,
node_type: 'CLIPLoader',
widget_index: 0,
widget_name: 'clip_name',
original_path: 'current.safetensors',
transferKey: 'text_encoders:top:1:0',
}],
}],
}];
return {
loadedModels: { loaded_models: sourceModels },
transfer: {
mode: 'replace',
targetGroups,
selectedNodeKeys: new Set(['top:1']),
sourceSelections: {},
},
};
}
function createLoraTransferState(sourceModels, activityScope = 'all') {
const targetGroups = [{
category: 'loras',
nodes: [{
nodeKey: 'top:2',
nodeId: 2,
nodeType: 'LoraLoaderV2',
refs: [
{
category: 'loras',
node_id: 2,
node_type: 'LoraLoaderV2',
widget_index: 2,
original_path: 'active-current.safetensors',
active: true,
transferKey: 'loras:top:2:0',
},
{
category: 'loras',
node_id: 2,
node_type: 'LoraLoaderV2',
widget_index: 2,
original_path: 'inactive-current.safetensors',
active: false,
transferKey: 'loras:top:2:1',
},
],
}],
}];
return {
loadedModels: { loaded_models: sourceModels },
transfer: {
mode: 'replace-add',
activityScope,
targetGroups,
selectedNodeKeys: new Set(['top:2']),
sourceSelections: {},
},
};
}
test('metadata transfer hides imported models with an unsupported file extension', () => {
const context = createTransferContext();
const state = createTransferState([
{ category: 'text_encoders', original_path: 'clip.gguf' },
{ category: 'text_encoders', original_path: 'clip.safetensors' },
]);
const [row] = context.getMetadataTransferPreviewRows(state);
assert.deepEqual(row.sourceOptions.map(option => option.value), ['clip.safetensors']);
assert.equal(row.nextValue, 'clip.safetensors');
assert.equal(row.sourceUnavailable, false);
});
test('metadata transfer blocks a selected node when no imported model matches its format', () => {
const context = createTransferContext();
const state = createTransferState([
{ category: 'text_encoders', original_path: 'clip.gguf' },
]);
const [row] = context.getMetadataTransferPreviewRows(state);
assert.equal(row.sourceOptions.length, 0);
assert.equal(row.nextLabel, 'No compatible model');
assert.equal(row.sourceUnavailable, true);
});
test('replace/add preview replaces existing LoRAs and shows remaining models as additions', () => {
const context = createTransferContext();
const state = createLoraTransferState([
{ category: 'loras', original_path: 'new-a.safetensors' },
{ category: 'loras', original_path: 'new-b.safetensors' },
{ category: 'loras', original_path: 'new-c.safetensors' },
]);
const rows = context.getMetadataTransferPreviewRows(state);
assert.equal(rows.length, 3);
assert.deepEqual(rows.slice(0, 2).map(row => row.operation), ['replace', 'replace']);
assert.equal(rows[0].nextValue, 'new-a.safetensors');
assert.equal(rows[1].nextValue, 'new-b.safetensors');
assert.equal(rows[2].operation, 'add');
assert.equal(rows[2].nextValue, 'new-c.safetensors');
});
test('metadata transfer preview adds model actions and previews only for local files', () => {
const context = createTransferContext();
const state = createTransferState([
{
category: 'text_encoders',
original_path: 'imported.safetensors',
exists: true,
resolved_path: 'C:\\models\\imported.safetensors',
},
]);
state.transfer.targetGroups[0].nodes[0].refs[0] = {
...state.transfer.targetGroups[0].nodes[0].refs[0],
exists: true,
resolved_path: 'C:\\models\\current.safetensors',
};
const [row] = context.getMetadataTransferPreviewRows(state);
const html = context.renderMetadataTransferPreview([row]);
assert.equal(row.nextModel.resolved_path, 'C:\\models\\imported.safetensors');
assert.match(html, /data-context-scope="loaded_model"/);
assert.match(html, /data-context-scope="local_model"/);
assert.match(html, /data-tooltip-image="C:\\models\\current\.safetensors"/);
assert.match(html, /data-tooltip-image="C:\\models\\imported\.safetensors"/);
state.loadedModels.loaded_models[0].exists = false;
const [missingRow] = context.getMetadataTransferPreviewRows(state);
const missingHtml = context.renderMetadataTransferPreview([missingRow]);
assert.doesNotMatch(missingHtml, /data-context-scope="local_model"/);
assert.doesNotMatch(missingHtml, /data-tooltip-image="C:\\models\\imported\.safetensors"/);
});
test('LoRA activity scope filters imported models but keeps all target slots eligible', () => {
const context = createTransferContext();
const state = createLoraTransferState([
{ category: 'loras', original_path: 'new-active.safetensors', active: true },
{ category: 'loras', original_path: 'new-inactive-a.safetensors', active: false },
{ category: 'loras', original_path: 'new-inactive-b.safetensors', active: false },
{ category: 'loras', original_path: 'new-inactive-c.safetensors', active: false },
], 'inactive');
const rows = context.getMetadataTransferPreviewRows(state);
assert.equal(rows.length, 3);
assert.equal(rows[0].currentValue, 'active-current.safetensors');
assert.equal(rows[0].nextValue, 'new-inactive-a.safetensors');
assert.equal(rows[1].currentValue, 'inactive-current.safetensors');
assert.equal(rows[1].nextValue, 'new-inactive-b.safetensors');
assert.equal(rows[0].operation, 'replace');
assert.equal(rows[1].operation, 'replace');
assert.equal(rows[2].operation, 'add');
assert.equal(rows[2].nextValue, 'new-inactive-c.safetensors');
});
test('workflow node context keeps Locate Node but hides model info', () => {
const contextMenu = {
style: { display: 'none', left: '', top: '' },
querySelector() {
return null;
},
getBoundingClientRect() {
return { right: 0, bottom: 0 };
},
};
const visibility = {};
const showContextMenu = eval(`(${extractMethod(modelInfoMethodsSource, 'showContextMenu')})`);
const dialog = {
contextMenu,
hideTooltip() {},
canShowSourceDetails() {
return false;
},
getContextMenuSourceLink() {
return null;
},
getContextMenuSourceLookupModel(value) {
return value;
},
canSuggestDownloadSubfolderFromContextMenu() {
return false;
},
setContextMenuItemVisible(action, visible) {
visibility[action] = visible;
},
setContextMenuDividerVisible() {},
updateContextMenuSourceItem() {},
refreshContextMenuSourceLink() {},
};
const previousWindow = globalThis.window;
globalThis.window = { innerWidth: 1000, innerHeight: 1000 };
try {
showContextMenu.call(dialog, 10, 20, {
context_scope: 'workflow_node',
node_id: 42,
});
} finally {
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
}
assert.match(imageMetadataMethodsSource, /context_scope: 'workflow_node'/);
assert.equal(visibility.showInfo, false);
assert.equal(visibility.locateNode, true);
});
+199
View File
@@ -153,6 +153,64 @@ def test_replace_zips_multiple_sources_and_copies_lora_strength():
assert workflow["nodes"][1]["widgets_values"] == ["new-b.safetensors", 0.8]
def test_replace_honors_explicit_source_index_per_target():
workflow = {
"nodes": [
{
"id": 30,
"type": "CLIPLoader",
"widgets_values": ["old-clip-a.safetensors"],
},
{
"id": 31,
"type": "CLIPLoader",
"widgets_values": ["old-clip-b.safetensors"],
},
]
}
refs = (
_reference(
30,
"old-clip-a.safetensors",
category="text_encoders",
node_type="CLIPLoader",
),
_reference(
31,
"old-clip-b.safetensors",
category="text_encoders",
node_type="CLIPLoader",
),
)
result = transfer_models_to_workflow(
workflow,
[
{
"original_path": "source-clip-a.safetensors",
"category": "text_encoders",
"source_index": 4,
},
{
"original_path": "source-clip-b.safetensors",
"category": "text_encoders",
"source_index": 9,
},
],
[
{**_selector(refs[0]), "source_index": 9},
{**_selector(refs[1]), "source_index": 4},
],
inventory=_inventory(*refs),
)
assert result["updated"] == 2
assert [node["widgets_values"][0] for node in workflow["nodes"]] == [
"source-clip-b.safetensors",
"source-clip-a.safetensors",
]
def test_replace_updates_inner_subgraph_node_and_requests_full_reload():
workflow = {
"nodes": [],
@@ -284,6 +342,65 @@ def test_merge_appends_lora_manager_entry_without_removing_existing_values():
assert "<lora:added:0.6>" in workflow["nodes"][0]["widgets_values"][1]
def test_replace_add_replaces_lora_manager_entries_and_appends_remaining_values():
workflow = {
"nodes": [
{
"id": 26,
"type": "LoraLoaderV2",
"widgets_values": [
None,
"existing prompt",
[
{"name": "old_a", "strength": 0.7, "active": True},
{"name": "old_b", "strength": 0.8, "active": True},
],
],
}
]
}
refs = (
_reference(
26,
"old_a",
category="loras",
node_type="LoraLoaderV2",
widget_index=2,
custom_node_adapter="lora-manager",
name="old_a",
active=True,
),
_reference(
26,
"old_b",
category="loras",
node_type="LoraLoaderV2",
widget_index=2,
custom_node_adapter="lora-manager",
name="old_b",
active=True,
),
)
result = transfer_models_to_workflow(
workflow,
[
{"original_path": "new_a.safetensors", "category": "loras", "strength": 0.3},
{"original_path": "new_b.safetensors", "category": "loras", "strength": 0.4},
{"original_path": "new_c.safetensors", "category": "loras", "strength": 0.5},
],
[_selector(ref) for ref in refs],
mode="replace-add",
inventory=_inventory(*refs),
)
entries = workflow["nodes"][0]["widgets_values"][2]
assert result["updated"] == 3
assert result["requires_full_reload"] is True
assert [entry["name"] for entry in entries] == ["new_a", "new_b", "new_c"]
assert [entry["strength"] for entry in entries] == [0.3, 0.4, 0.5]
def test_merge_appends_power_lora_slot_without_removing_existing_values():
workflow = {
"nodes": [
@@ -344,6 +461,88 @@ def test_merge_appends_power_lora_slot_without_removing_existing_values():
assert named_values["➕ Add Lora"] == ""
def test_replace_add_activity_scope_filters_imported_loras_but_keeps_all_target_slots():
workflow = {
"nodes": [
{
"id": 27,
"type": "Power Lora Loader (rgthree)",
"widgets_values": [
{},
{"type": "PowerLoraLoaderHeaderWidget"},
{"on": True, "lora": "active-old.safetensors", "strength": 1.0},
{"on": False, "lora": "inactive-old.safetensors", "strength": 0.8},
{},
"",
],
}
]
}
refs = (
_reference(
27,
"active-old.safetensors",
category="loras",
node_type="Power Lora Loader (rgthree)",
widget_index=2,
nested_key="lora",
active=True,
),
_reference(
27,
"inactive-old.safetensors",
category="loras",
node_type="Power Lora Loader (rgthree)",
widget_index=3,
nested_key="lora",
active=False,
),
)
result = transfer_models_to_workflow(
workflow,
[
{
"original_path": "active-new.safetensors",
"category": "loras",
"active": True,
},
{
"original_path": "inactive-new-a.safetensors",
"category": "loras",
"active": False,
},
{
"original_path": "inactive-new-b.safetensors",
"category": "loras",
"active": False,
},
{
"original_path": "inactive-new-c.safetensors",
"category": "loras",
"active": False,
},
],
[_selector(ref) for ref in refs],
mode="replace-add",
activity_scope="inactive",
inventory=_inventory(*refs),
)
lora_values = [
value
for value in workflow["nodes"][0]["widgets_values"]
if isinstance(value, dict) and "lora" in value
]
assert result["updated"] == 3
assert [value["lora"] for value in lora_values] == [
"inactive-new-a.safetensors",
"inactive-new-b.safetensors",
"inactive-new-c.safetensors",
]
assert [value["on"] for value in lora_values] == [False, False, False]
def test_replace_updates_power_lora_named_mirror_and_strength():
workflow = {
"nodes": [
+490 -24
View File
@@ -4807,25 +4807,16 @@ button.mr-info-link:disabled {
.mr-image-inspector-tools {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--mr-spacing-12);
padding: var(--mr-spacing-12);
gap: var(--mr-spacing-8);
padding: var(--mr-spacing-8) var(--mr-spacing-12) var(--mr-spacing-10);
border-bottom: 1px solid var(--mr-border);
}
.mr-image-inspector-paste {
display: flex;
flex-direction: column;
gap: var(--mr-spacing-8);
gap: var(--mr-spacing-6);
min-width: 0;
padding: var(--mr-spacing-16);
border: 1px dashed var(--mr-border-strong, rgba(255, 255, 255, 0.24));
border-radius: var(--mr-radius-xl);
background: rgba(255, 255, 255, 0.018);
}
.mr-image-inspector-paste label {
color: var(--mr-text-muted);
font-size: var(--mr-font-md);
}
.mr-image-inspector-actions {
@@ -4833,16 +4824,11 @@ button.mr-info-link:disabled {
flex-wrap: wrap;
justify-content: flex-start;
gap: var(--mr-spacing-8);
margin-top: var(--mr-spacing-4);
}
.mr-image-inspector-paste label {
font-weight: 700;
}
.mr-image-inspector-textarea {
width: 100%;
min-height: 112px;
min-height: 76px;
box-sizing: border-box;
resize: vertical;
padding: var(--mr-spacing-8) var(--mr-spacing-10);
@@ -4896,8 +4882,7 @@ button.mr-info-link:disabled {
padding-top: var(--mr-spacing-4);
}
.mr-image-inspector-loading,
.mr-image-inspector-empty {
.mr-image-inspector-loading {
display: flex;
align-items: center;
justify-content: center;
@@ -4957,7 +4942,7 @@ button.mr-info-link:disabled {
.mr-image-transfer-mode {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--mr-spacing-8);
}
@@ -5004,6 +4989,44 @@ button.mr-info-link:disabled {
font-size: var(--mr-font-xs);
}
.mr-image-transfer-activity {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--mr-spacing-4) var(--mr-spacing-8);
margin-top: var(--mr-spacing-8);
}
.mr-image-transfer-activity-label {
margin-right: var(--mr-spacing-4);
color: var(--mr-text-muted);
font-size: var(--mr-font-xs);
font-weight: 650;
}
.mr-image-transfer-activity-option {
display: inline-flex;
align-items: center;
gap: var(--mr-spacing-4);
padding: var(--mr-spacing-4) var(--mr-spacing-6);
border: 1px solid transparent;
border-radius: var(--mr-radius-md, 5px);
color: var(--mr-text-muted);
cursor: pointer;
font-size: var(--mr-font-xs);
}
.mr-image-transfer-activity-option:has(input:checked) {
border-color: rgba(114, 211, 138, 0.25);
background: rgba(114, 211, 138, 0.06);
color: var(--mr-accent);
}
.mr-image-transfer-activity-option input {
margin: 0;
accent-color: var(--mr-accent);
}
.mr-image-transfer-note {
margin: var(--mr-spacing-10) 0;
color: var(--mr-text-muted);
@@ -5015,6 +5038,7 @@ button.mr-info-link:disabled {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--mr-spacing-12);
align-items: start;
}
.mr-image-transfer-column {
@@ -5057,12 +5081,16 @@ button.mr-info-link:disabled {
.mr-image-transfer-node-list {
padding: 0;
max-height: 520px;
overflow: auto;
}
.mr-image-transfer-preview {
margin-top: var(--mr-spacing-12);
padding-top: var(--mr-spacing-12);
border-top: 1px solid var(--mr-border);
margin: 0;
padding: var(--mr-spacing-10);
border: 1px solid var(--mr-border);
border-radius: var(--mr-radius-lg);
background: rgba(255, 255, 255, 0.018);
}
.mr-image-transfer-preview-header {
@@ -5119,6 +5147,7 @@ button.mr-info-link:disabled {
.mr-image-transfer-preview-side-next {
text-align: right;
align-items: flex-end;
}
.mr-image-transfer-preview-label {
@@ -5135,6 +5164,25 @@ button.mr-info-link:disabled {
font-weight: 650;
}
.mr-image-transfer-source-select {
width: min(100%, 260px);
min-width: 0;
padding: var(--mr-spacing-6) var(--mr-spacing-8);
border: 1px solid var(--mr-border-strong, rgba(255, 255, 255, 0.24));
border-radius: var(--mr-radius-md, var(--mr-radius-lg));
background: rgba(0, 0, 0, 0.2);
color: var(--mr-text);
font: inherit;
font-size: var(--mr-font-sm);
text-align: left;
}
.mr-image-transfer-source-select:focus {
border-color: var(--mr-accent);
outline: none;
box-shadow: 0 0 0 2px var(--mr-missing-control-focus-ring, rgba(114, 211, 138, 0.08));
}
.mr-image-transfer-preview-arrow {
color: var(--mr-accent);
font-size: var(--mr-font-lg);
@@ -5254,6 +5302,339 @@ button.mr-info-link:disabled {
font-size: var(--mr-font-xs);
}
.mr-image-transfer-category-list {
display: flex;
flex-direction: column;
gap: var(--mr-spacing-6);
}
.mr-image-transfer-category {
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: var(--mr-radius-lg, 7px);
background: rgba(255, 255, 255, 0.018);
box-shadow: none;
}
.mr-image-transfer-category-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--mr-spacing-10);
min-width: 0;
min-height: 36px;
border-bottom: 1px solid rgba(255, 255, 255, 0.075);
}
.mr-image-transfer-category.is-collapsed .mr-image-transfer-category-header {
border-bottom-color: transparent;
}
.mr-image-transfer-category-toggle {
display: flex;
align-items: center;
gap: var(--mr-spacing-8);
min-width: 0;
flex: 1 1 auto;
padding: var(--mr-spacing-8) var(--mr-spacing-10);
border: 0;
background: transparent;
color: var(--mr-text);
cursor: pointer;
font: inherit;
text-align: left;
}
.mr-image-transfer-category-toggle:hover {
background: rgba(255, 255, 255, 0.025);
}
.mr-image-transfer-category-toggle:focus-visible {
outline: none;
box-shadow: inset 0 0 0 1px var(--mr-missing-control-focus-ring, rgba(114, 211, 138, 0.12));
}
.mr-image-transfer-category-chevron {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
flex: 0 0 14px;
color: var(--mr-text);
line-height: 1;
transition: transform 120ms ease;
}
.mr-image-transfer-category-chevron::before {
width: 5px;
height: 5px;
border-right: 1px solid currentColor;
border-bottom: 1px solid currentColor;
content: "";
transform: rotate(45deg) translate(-1px, -1px);
}
.mr-image-transfer-category.is-collapsed .mr-image-transfer-category-chevron {
transform: rotate(-90deg);
}
.mr-image-transfer-category-title {
overflow: hidden;
color: var(--mr-text);
font-size: var(--mr-font-sm);
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.mr-image-transfer-category-selected {
color: var(--mr-accent);
font-size: var(--mr-font-xs);
font-weight: 600;
white-space: nowrap;
}
.mr-image-transfer-category-meta {
display: flex;
align-items: center;
gap: var(--mr-spacing-6);
padding: var(--mr-spacing-6) var(--mr-spacing-8);
color: var(--mr-text-muted);
font-size: var(--mr-font-xs);
white-space: nowrap;
}
.mr-image-transfer-category-node-count {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 var(--mr-spacing-7);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 999px;
background: rgba(255, 255, 255, 0.035);
color: var(--mr-text-muted);
font-size: var(--mr-font-xs);
}
.mr-image-transfer-category-nodes {
display: flex;
flex-direction: column;
}
.mr-image-transfer-node-row {
position: relative;
display: grid;
grid-template-columns: minmax(260px, 0.92fr) minmax(320px, 1.08fr);
align-items: center;
gap: var(--mr-spacing-12);
min-width: 0;
margin: 0;
padding: var(--mr-spacing-10) var(--mr-spacing-10);
border: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
border-radius: 0;
background: transparent;
cursor: default;
}
.mr-image-transfer-node-row:last-child {
border-bottom: 0;
}
.mr-image-transfer-node-row:hover {
background: rgba(255, 255, 255, 0.02);
border-color: rgba(255, 255, 255, 0.055);
}
.mr-image-transfer-node-row.is-selected {
background: linear-gradient(90deg, rgba(114, 211, 138, 0.12), rgba(114, 211, 138, 0.035) 55%, transparent);
}
.mr-image-transfer-node-row.is-selected::before {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 3px;
border-radius: 0 2px 2px 0;
background: var(--mr-accent);
content: "";
}
.mr-image-transfer-node-row.is-unavailable {
opacity: 0.5;
}
.mr-image-transfer-node-select {
appearance: none;
display: flex;
align-items: center;
gap: var(--mr-spacing-10);
min-width: 0;
width: 100%;
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
color: var(--mr-text);
cursor: pointer;
font: inherit;
text-align: left;
}
.mr-image-transfer-node-select:disabled {
cursor: not-allowed;
}
.mr-image-transfer-node-select:focus-visible {
outline: none;
}
.mr-image-transfer-node-check {
display: inline-flex;
align-items: center;
justify-content: center;
width: 19px;
height: 19px;
flex: 0 0 19px;
margin-top: 0;
border: 1px solid rgba(255, 255, 255, 0.24);
border-radius: var(--mr-radius-md, 5px);
background: rgba(0, 0, 0, 0.2);
color: #102015;
font-size: var(--mr-font-sm);
font-weight: 800;
line-height: 1;
}
.mr-image-transfer-node-row.is-selected .mr-image-transfer-node-check {
border-color: var(--mr-accent);
background: var(--mr-accent);
}
.mr-image-transfer-node-select:hover:not(:disabled) .mr-image-transfer-node-check {
border-color: rgba(114, 211, 138, 0.62);
}
.mr-image-transfer-node-details {
display: flex;
flex-direction: column;
gap: var(--mr-spacing-4);
min-width: 0;
}
.mr-image-transfer-node-name {
overflow-wrap: anywhere;
color: var(--mr-text);
font-size: var(--mr-font-sm);
font-weight: 650;
}
.mr-image-transfer-node-meta {
overflow-wrap: anywhere;
color: var(--mr-text-muted);
font-family: var(--mr-font-family-sans, sans-serif);
font-size: var(--mr-font-xs);
font-weight: 500;
}
.mr-image-transfer-node-preview {
display: flex;
flex-direction: column;
gap: 0;
min-width: 0;
}
.mr-image-transfer-node-preview > .mr-image-transfer-change + .mr-image-transfer-change {
margin-top: var(--mr-spacing-6);
padding-top: var(--mr-spacing-6);
border-top: 1px solid rgba(255, 255, 255, 0.055);
}
.mr-image-transfer-change {
display: grid;
grid-template-columns: minmax(110px, 0.9fr) 18px minmax(135px, 1.1fr);
align-items: center;
gap: var(--mr-spacing-10);
min-width: 0;
}
.mr-image-transfer-change-side {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--mr-spacing-4);
min-width: 0;
}
.mr-image-transfer-change-side-imported {
align-items: flex-start;
}
.mr-image-transfer-change-label {
color: var(--mr-text-muted);
font-size: var(--mr-font-xs);
}
.mr-image-transfer-change-arrow {
color: var(--mr-text-muted);
font-size: var(--mr-font-lg);
font-weight: 700;
}
.mr-image-transfer-model-value {
display: inline-flex;
max-width: 100%;
box-sizing: border-box;
overflow-wrap: anywhere;
padding: var(--mr-spacing-4) var(--mr-spacing-8);
border: 1px solid transparent;
border-radius: var(--mr-radius-lg);
background: rgba(255, 255, 255, 0.07);
color: var(--mr-text);
font-family: var(--mr-font-family-mono);
font-size: var(--mr-font-xs);
font-weight: 600;
}
.mr-image-transfer-model-value.is-current {
background: rgba(255, 255, 255, 0.08);
color: var(--mr-text);
}
.mr-image-transfer-model-value.is-imported,
.mr-image-transfer-model-value.is-added {
border-color: rgba(114, 211, 138, 0.26);
background: rgba(114, 211, 138, 0.16);
color: var(--mr-accent);
}
.mr-image-transfer-model-value.is-unchanged,
.mr-image-transfer-model-value.is-placeholder {
color: var(--mr-text-muted);
}
.mr-image-transfer-change.is-unselected {
opacity: 0.7;
}
.mr-image-transfer-source-select {
width: 100%;
max-width: 260px;
min-width: 0;
box-sizing: border-box;
min-height: 24px;
padding: var(--mr-spacing-4) var(--mr-spacing-8);
border-color: rgba(114, 211, 138, 0.26);
border-radius: var(--mr-radius-lg, 6px);
background: rgba(114, 211, 138, 0.12);
color: var(--mr-accent);
font-family: var(--mr-font-family-mono);
font-size: var(--mr-font-xs);
font-weight: 600;
}
@keyframes mr-image-inspector-spin {
to {
transform: rotate(360deg);
@@ -5270,6 +5651,41 @@ button.mr-info-link:disabled {
grid-template-columns: 1fr;
}
.mr-image-transfer-category-header {
align-items: flex-start;
flex-direction: column;
}
.mr-image-transfer-category-meta {
box-sizing: border-box;
justify-content: space-between;
padding-top: 0;
width: 100%;
}
.mr-image-transfer-node-row {
align-items: start;
grid-template-columns: minmax(0, 1fr) auto;
}
.mr-image-transfer-node-select {
grid-column: 1;
}
.mr-image-transfer-node-preview {
grid-column: 1 / -1;
}
.mr-image-transfer-change {
gap: var(--mr-spacing-6);
grid-template-columns: minmax(0, 1fr);
}
.mr-image-transfer-change-arrow {
justify-self: start;
transform: rotate(90deg);
}
.mr-image-transfer-preview-row {
grid-template-columns: minmax(0, 1fr);
}
@@ -5281,6 +5697,7 @@ button.mr-info-link:disabled {
.mr-image-transfer-preview-side-next {
text-align: left;
align-items: flex-start;
}
.mr-image-transfer-header,
@@ -10599,3 +11016,52 @@ a.mr-image-preview-resource-name:hover {
.litegraph.litecontextmenu .litemenu-entry.mr-model-resolver-node-menu::before {
margin-right: var(--mr-spacing-6);
}
/* Keep the transfer rows flat when ComfyUI's global button theme is present. */
#model-resolver-modal .mr-image-transfer-category-toggle,
#model-resolver-modal .mr-image-transfer-node-select {
appearance: none;
background: transparent !important;
border: 0 !important;
border-radius: 0;
box-shadow: none !important;
outline: none !important;
}
#model-resolver-modal .mr-image-transfer-node-select {
padding: 0 !important;
}
#model-resolver-modal .mr-image-transfer-model-value,
#model-resolver-modal .mr-image-transfer-source-select {
box-sizing: border-box;
height: 24px;
min-height: 24px;
margin: 0;
padding: 0 var(--mr-spacing-8) !important;
line-height: 1;
}
#model-resolver-modal .mr-image-transfer-model-value {
align-items: center;
display: inline-flex;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#model-resolver-modal .mr-image-transfer-source-select {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#model-resolver-modal .mr-image-transfer-category-toggle:focus,
#model-resolver-modal .mr-image-transfer-category-toggle:focus-visible,
#model-resolver-modal .mr-image-transfer-node-select:focus,
#model-resolver-modal .mr-image-transfer-node-select:focus-visible {
border: 0 !important;
box-shadow: none !important;
outline: none !important;
}
+13 -3
View File
@@ -45,9 +45,14 @@ export const modelInfoMethods = {
const isDownloadQueueContext = model?.context_scope === 'download_queue';
const isDownloadHistoryContext = model?.context_scope === 'download_history';
const isLoadedModelContext = model?.context_scope === 'loaded_model';
const isWorkflowNodeContext = model?.context_scope === 'workflow_node';
const isLocalModelContext = model?.context_scope === 'local_model' || model?.context_scope === 'local_match';
const isFolderOnlyContext = isDownloadFolderContext || isDownloadRootContext;
const isSourceModelContext = !isDownloadTableContext && !isFolderOnlyContext && !isDownloadQueueContext && !isDownloadHistoryContext;
const isSourceModelContext = !isDownloadTableContext
&& !isFolderOnlyContext
&& !isDownloadQueueContext
&& !isDownloadHistoryContext
&& !isWorkflowNodeContext;
const showSuggestSubfolder = this.canSuggestDownloadSubfolderFromContextMenu?.(model) || false;
const hasLocalPath = Boolean(model?.open_path || model?.folder_path || model?.download_directory || model?.directory || model?.path || model?.resolved_path);
const showOpenFolder = !isDownloadTableContext && hasLocalPath;
@@ -55,7 +60,7 @@ export const modelInfoMethods = {
&& hasLocalPath
&& Boolean(model?.missing_key || model?.missing_search_key);
const showSwitchWorkflow = (isDownloadQueueContext || isDownloadHistoryContext) && Boolean(this.canSwitchToDownloadWorkflow?.(model));
const showLocateNode = isLoadedModelContext
const showLocateNode = (isLoadedModelContext || isWorkflowNodeContext)
&& model?.node_id !== undefined
&& model?.node_id !== null
&& model?.node_id !== '';
@@ -451,8 +456,13 @@ export const modelInfoMethods = {
const isDownloadRootContext = model?.context_scope === 'download_root';
const isDownloadQueueContext = model?.context_scope === 'download_queue';
const isDownloadHistoryContext = model?.context_scope === 'download_history';
const isWorkflowNodeContext = model?.context_scope === 'workflow_node';
const isFolderOnlyContext = isDownloadFolderContext || isDownloadRootContext;
const isSourceModelContext = !isDownloadTableContext && !isFolderOnlyContext && !isDownloadQueueContext && !isDownloadHistoryContext;
const isSourceModelContext = !isDownloadTableContext
&& !isFolderOnlyContext
&& !isDownloadQueueContext
&& !isDownloadHistoryContext
&& !isWorkflowNodeContext;
const showSuggestSubfolder = this.canSuggestDownloadSubfolderFromContextMenu?.(model) || false;
const hasLocalPath = Boolean(model?.open_path || model?.folder_path || model?.download_directory || model?.directory || model?.path || model?.resolved_path);
const showOpenFolder = !isDownloadTableContext && hasLocalPath;
File diff suppressed because it is too large Load Diff