From d1812fa46f554b64f6833ae1e3d15f38b600541c Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Sun, 13 Sep 2026 21:38:52 +0200 Subject: [PATCH] Add metadata workflow model transfer --- core/services/loaded_models_service.py | 1 + core/services/workflow_transfer_service.py | 7 + core/workflow_transfer.py | 282 ++++++- tests/test_image_metadata_transfer.mjs | 300 +++++++ tests/test_workflow_transfer.py | 199 +++++ web/css/resolver-main.css | 514 +++++++++++- web/resolver/views/model_info_methods.js | 16 +- .../views/tabs_image_metadata_methods.js | 770 +++++++++++++++--- 8 files changed, 1917 insertions(+), 172 deletions(-) create mode 100644 tests/test_image_metadata_transfer.mjs diff --git a/core/services/loaded_models_service.py b/core/services/loaded_models_service.py index 54de56e..ae991ed 100644 --- a/core/services/loaded_models_service.py +++ b/core/services/loaded_models_service.py @@ -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 "", diff --git a/core/services/workflow_transfer_service.py b/core/services/workflow_transfer_service.py index d696e38..f2d1617 100644 --- a/core/services/workflow_transfer_service.py +++ b/core/services/workflow_transfer_service.py @@ -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: diff --git a/core/workflow_transfer.py b/core/workflow_transfer.py index 497c620..261b105 100644 --- a/core/workflow_transfer.py +++ b/core/workflow_transfer.py @@ -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: diff --git a/tests/test_image_metadata_transfer.mjs b/tests/test_image_metadata_transfer.mjs new file mode 100644 index 0000000..9aec976 --- /dev/null +++ b/tests/test_image_metadata_transfer.mjs @@ -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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''), + 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); +}); diff --git a/tests/test_workflow_transfer.py b/tests/test_workflow_transfer.py index e04775c..ecfcb3f 100644 --- a/tests/test_workflow_transfer.py +++ b/tests/test_workflow_transfer.py @@ -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 "" 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": [ diff --git a/web/css/resolver-main.css b/web/css/resolver-main.css index 278219a..cfe4416 100644 --- a/web/css/resolver-main.css +++ b/web/css/resolver-main.css @@ -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; +} diff --git a/web/resolver/views/model_info_methods.js b/web/resolver/views/model_info_methods.js index 21ce36c..ea16062 100644 --- a/web/resolver/views/model_info_methods.js +++ b/web/resolver/views/model_info_methods.js @@ -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; diff --git a/web/resolver/views/tabs_image_metadata_methods.js b/web/resolver/views/tabs_image_metadata_methods.js index 8a2dce8..6f912ef 100644 --- a/web/resolver/views/tabs_image_metadata_methods.js +++ b/web/resolver/views/tabs_image_metadata_methods.js @@ -30,6 +30,37 @@ const MERGE_LORA_NODE_TYPES = new Set([ 'Lora Stacker (LoraManager)', ]); const MERGE_POWER_LORA_NODE_TYPE = 'Power Lora Loader (rgthree)'; +const TRANSFER_ACTIVITY_SCOPES = new Set(['all', 'active', 'inactive']); + +function normalizeTransferMode(value) { + const token = String(value || 'replace').trim().toLowerCase().replaceAll('_', '-'); + if (token === 'merge' || token === 'add' || token === 'merge-add') return 'merge'; + if (token === 'replace-add' || token === 'replaceadd' || token === 'replace-plus-add') { + return 'replace-add'; + } + return 'replace'; +} + +function normalizeTransferActivityScope(value) { + const token = String(value || 'all').trim().toLowerCase().replaceAll('_', '-'); + if (token === 'both' || token === 'active-inactive' || token === 'active-and-inactive') { + return 'all'; + } + return TRANSFER_ACTIVITY_SCOPES.has(token) ? token : 'all'; +} + +function isTransferActivityAllowed(value, scope) { + const normalizedScope = normalizeTransferActivityScope(scope); + if (normalizedScope === 'all') return true; + if (normalizedScope === 'inactive') return value === false; + return value !== false; +} + +function isAppendableLoraNodeType(nodeType) { + const normalizedType = String(nodeType || ''); + return MERGE_LORA_NODE_TYPES.has(normalizedType) + || normalizedType === MERGE_POWER_LORA_NODE_TYPE; +} function normalizeTransferCategory(value) { const token = String(value || '').trim().toLowerCase().replaceAll('-', '_'); @@ -148,7 +179,7 @@ export const imageMetadataMethods = {

Image Metadata

-

Paste workflow JSON or drop an image or JSON file anywhere in this panel.

+

Paste JSON or drop a supported file anywhere in this panel.

@@ -157,10 +188,9 @@ export const imageMetadataMethods = {
- - +
- +
@@ -180,6 +210,13 @@ export const imageMetadataMethods = { root.dataset.imageInspectorBound = '1'; const fileInput = root.querySelector('[data-image-inspector-input="file"]'); + const pasteInput = root.querySelector('.mr-image-inspector-textarea'); + const analyzePasteButton = root.querySelector('[data-image-inspector-action="analyze-paste"]'); + const updateAnalyzePasteButton = () => { + if (analyzePasteButton) { + analyzePasteButton.disabled = !(pasteInput?.value || '').trim(); + } + }; const setDragOver = (active) => { root.classList.toggle('is-dragover', active); }; @@ -190,6 +227,12 @@ export const imageMetadataMethods = { let dragDepth = 0; root.addEventListener('click', (event) => { + const categoryToggle = event.target.closest?.('[data-image-transfer-category-toggle]'); + if (categoryToggle) { + this.toggleMetadataTransferCategory(categoryToggle.dataset.imageTransferCategoryToggle); + return; + } + const nodeChip = event.target.closest?.('[data-image-transfer-node]'); if (nodeChip) { this.toggleMetadataTransferNode(nodeChip.dataset.imageTransferNode); @@ -211,14 +254,44 @@ export const imageMetadataMethods = { root.addEventListener('change', (event) => { const input = event.target; + if (input.matches?.('[data-image-transfer-source]')) { + const state = getImageInspectorState(this); + if (state.transfer) { + const existingSourceSelections = state.transfer.sourceSelections; + const sourceSelections = existingSourceSelections instanceof Map + ? Object.fromEntries(existingSourceSelections) + : existingSourceSelections && typeof existingSourceSelections === 'object' + ? { ...existingSourceSelections } + : {}; + const selectionKey = input.dataset.imageTransferSource; + if (selectionKey) { + if (input.value) sourceSelections[selectionKey] = input.value; + else delete sourceSelections[selectionKey]; + } + state.transfer.sourceSelections = sourceSelections; + this.renderImageMetadataResult(); + } + return; + } if (input.matches?.('[data-image-transfer-mode]')) { const state = getImageInspectorState(this); - if (state.transfer) state.transfer.mode = input.value === 'merge' ? 'merge' : 'replace'; + if (state.transfer) state.transfer.mode = normalizeTransferMode(input.value); + this.renderImageMetadataResult(); + return; + } + if (input.matches?.('[data-image-transfer-activity-scope]')) { + const state = getImageInspectorState(this); + if (state.transfer) { + state.transfer.activityScope = normalizeTransferActivityScope(input.value); + } this.renderImageMetadataResult(); return; } }); + pasteInput?.addEventListener('input', updateAnalyzePasteButton); + updateAnalyzePasteButton(); + fileInput?.addEventListener('change', (event) => { const file = event.target.files?.[0]; event.target.value = ''; @@ -340,6 +413,7 @@ export const imageMetadataMethods = { state.transfer.loading = false; state.transfer.applying = false; state.transfer.error = ''; + state.transfer.sourceSelections = {}; } state.error = ''; state.loading = 'metadata'; @@ -424,10 +498,7 @@ export const imageMetadataMethods = { return; } if (!state.metadata) { - result.innerHTML = ` -

Drop an image or workflow JSON to inspect it.

- ${this.renderMetadataTransferPanel(state)} - `; + result.innerHTML = this.renderMetadataTransferPanel(state); return; } @@ -609,10 +680,103 @@ export const imageMetadataMethods = { return String(label || fallback).trim() || fallback; }, + getMetadataTransferSourceSelectionKey(ref = {}) { + return String( + ref.transferKey + || `${normalizeTransferCategory(ref.category)}:${String(ref.node_id ?? '')}:${String(ref.widget_index ?? '')}` + ); + }, + + getMetadataTransferSourceSelection(transfer, ref = {}) { + const key = this.getMetadataTransferSourceSelectionKey(ref); + const selections = transfer?.sourceSelections; + if (selections instanceof Map) return selections.get(key); + if (selections && typeof selections === 'object') return selections[key]; + return null; + }, + + getMetadataTransferAcceptedFileTypes(target = {}) { + const ref = target?.ref || target || {}; + const node = target?.node || {}; + const widgetIndex = Number(ref.widget_index ?? ref.widgetIndex); + const resolution = { + node_id: ref.node_id ?? node.nodeId, + is_top_level: ref.is_top_level ?? node.isTopLevel, + subgraph_id: ref.subgraph_id || node.subgraphId || '', + }; + const workflow = this.getCurrentWorkflow?.() || {}; + const workflowNode = this.findWorkflowNodeForResolution?.(workflow, resolution); + const widgetName = String( + ref.widget_name + || ref.widgetName + || this.getWorkflowNodeWidgetName?.(workflowNode || {}, widgetIndex) + || this.getGraphNodeWidgetName?.(workflowNode || {}, widgetIndex, resolution) + || '' + ).trim(); + const explicitExtensions = ( + ref.accepted_extensions + || ref.allowed_extensions + || ref.accepted_model_extensions + ); + return this.getMissingAcceptedModelFileTypes?.({ + node_type: ref.node_type || ref.nodeType || node.nodeType || '', + widget_index: Number.isInteger(widgetIndex) ? widgetIndex : -1, + widget_name: widgetName, + accepted_extensions: explicitExtensions, + }) || []; + }, + + getMetadataTransferCompatibleSourcesForTarget(target, sourcesByCategory) { + const category = normalizeTransferCategory(target?.category || target?.ref?.category); + const allSources = sourcesByCategory.get(category) || []; + if (!allSources.length) return []; + + const activityScope = normalizeTransferActivityScope(target?.activityScope); + const activitySources = category === 'loras' + ? allSources.filter(({ model }) => isTransferActivityAllowed(model?.active, activityScope)) + : allSources; + if (!activitySources.length) return []; + + const acceptedTypes = Array.isArray(target?.acceptedFileTypes) + ? target.acceptedFileTypes + : this.getMetadataTransferAcceptedFileTypes(target); + const acceptedExtensions = new Set( + acceptedTypes + .map(type => String(type?.extension || '').trim().toLowerCase()) + .filter(Boolean) + ); + if (!acceptedExtensions.size) return activitySources; + + return activitySources.filter(({ model }) => { + const modelValue = this.getMetadataTransferModelValue(model); + const fileType = this.getModelFileTypeInfo?.(modelValue); + return !fileType || acceptedExtensions.has(fileType.extension); + }); + }, + + getMetadataTransferSourceForTarget(target, sourcesByCategory, transfer, fallbackIndex = 0) { + const category = normalizeTransferCategory(target?.category || target?.ref?.category); + const sources = Array.isArray(target?.compatibleSources) + ? target.compatibleSources + : sourcesByCategory.get(category) || []; + if (!sources.length) return null; + + const selectedSourceIndex = this.getMetadataTransferSourceSelection(transfer, target.ref); + if (selectedSourceIndex !== null && selectedSourceIndex !== undefined && selectedSourceIndex !== '') { + const selectedSource = sources.find(({ sourceIndex }) => ( + String(sourceIndex) === String(selectedSourceIndex) + )); + if (selectedSource) return selectedSource; + } + + return sources.length === 1 ? sources[0] : sources[fallbackIndex] || null; + }, + getMetadataTransferPreviewRows(state) { const sourceEntries = this.getMetadataTransferSourceModels(state?.loadedModels); const transfer = state?.transfer; if (!transfer) return []; + const activityScope = normalizeTransferActivityScope(transfer.activityScope); const targetGroups = Array.isArray(transfer.targetGroups) ? transfer.targetGroups : []; const selectedNodeKeys = transfer.selectedNodeKeys instanceof Set @@ -621,11 +785,12 @@ export const imageMetadataMethods = { const selectedTargets = []; const sourcesByCategory = new Map(); - for (const { model } of sourceEntries) { + for (const sourceEntry of sourceEntries) { + const { model } = sourceEntry; const category = normalizeTransferCategory(model.category); if (!category) continue; if (!sourcesByCategory.has(category)) sourcesByCategory.set(category, []); - sourcesByCategory.get(category).push(model); + sourcesByCategory.get(category).push(sourceEntry); } for (const group of targetGroups) { @@ -633,16 +798,58 @@ export const imageMetadataMethods = { for (const node of group.nodes || []) { if (!selectedNodeKeys.has(node.nodeKey)) continue; for (const ref of node.refs || []) { - selectedTargets.push({ category, node, ref }); + const target = { category, node, ref, activityScope }; + target.acceptedFileTypes = this.getMetadataTransferAcceptedFileTypes(target); + target.compatibleSources = this.getMetadataTransferCompatibleSourcesForTarget( + target, + sourcesByCategory, + ); + selectedTargets.push(target); } } } const makeRow = ( target, - nextModel = null, - { operation = 'replace', currentLabelOverride = '', forceChange = false } = {}, + nextSource = null, + { + operation = 'replace', + currentLabelOverride = '', + forceChange = false, + sourceSelectable = null, + unchangedWhenNoSource = false, + } = {}, ) => { + const allSourceEntries = sourcesByCategory.get(target.category) || []; + const compatibleSources = Array.isArray(target.compatibleSources) + ? target.compatibleSources + : this.getMetadataTransferCompatibleSourcesForTarget(target, sourcesByCategory); + const activityScope = normalizeTransferActivityScope(target.activityScope); + const acceptedFileTypes = Array.isArray(target.acceptedFileTypes) + ? target.acceptedFileTypes + : this.getMetadataTransferAcceptedFileTypes(target); + const hasActivityMismatch = Boolean( + target.category === 'loras' + && activityScope !== 'all' + && allSourceEntries.length + && !allSourceEntries.some(({ model }) => ( + isTransferActivityAllowed(model?.active, activityScope) + )) + ); + const hasIncompatibleSources = Boolean( + allSourceEntries.length + && acceptedFileTypes.length + && !compatibleSources.length + ) || hasActivityMismatch; + const sourceOptions = compatibleSources.map(({ model, sourceIndex }) => ({ + sourceIndex, + label: this.getMetadataTransferModelLabel(model), + value: this.getMetadataTransferModelValue(model), + })); + const canSelectSource = sourceSelectable === null + ? sourceOptions.length > 1 + : Boolean(sourceSelectable) && sourceOptions.length > 1; + const nextModel = nextSource?.model || null; const currentValue = this.getMetadataTransferModelValue(target.ref); const currentLabel = currentLabelOverride || this.getMetadataTransferModelLabel(target.ref); @@ -651,53 +858,83 @@ export const imageMetadataMethods = { : ''; const nextLabel = nextModel ? this.getMetadataTransferModelLabel(nextModel) - : 'Unchanged'; + : hasActivityMismatch + ? 'No matching activity' + : hasIncompatibleSources + ? 'No compatible model' + : 'Unchanged'; const currentIdentity = normalizeTransferModelIdentity(currentValue); const nextIdentity = normalizeTransferModelIdentity(nextValue); - const unchanged = !forceChange && ( + const needsSourceSelection = canSelectSource && !nextModel; + const unchanged = !forceChange && !needsSourceSelection && ( !nextModel - || ( - currentIdentity && nextIdentity - ? currentIdentity === nextIdentity - : currentLabel === nextLabel - ) + ? ( + unchangedWhenNoSource + ? !hasIncompatibleSources + : !hasIncompatibleSources && sourceOptions.length === 0 + ) + : ( + currentIdentity && nextIdentity + ? currentIdentity === nextIdentity + : currentLabel === nextLabel + ) ); const nodeName = target.node.nodeTitle || target.node.nodeType || `Node ${target.node.nodeId}`; return { category: target.category, + nodeKey: target.node.nodeKey, + refKey: this.getMetadataTransferSourceSelectionKey(target.ref), nodeName, nodeId: target.node.nodeId, slot: target.ref?.transferSlot || 1, + ref: target.ref, currentLabel, currentValue, - nextLabel: unchanged ? 'Unchanged' : nextLabel, + nextLabel: unchanged + ? 'Unchanged' + : needsSourceSelection + ? 'Select model' + : nextLabel, nextValue, + nextModel, operation: unchanged ? 'unchanged' : operation, unchanged, + sourceIndex: nextSource?.sourceIndex ?? null, + sourceOptions, + sourceSelectable: canSelectSource, + sourceSelectionKey: this.getMetadataTransferSourceSelectionKey(target.ref), + sourceUnavailable: hasIncompatibleSources, + sourceUnavailableReason: hasActivityMismatch + ? 'No imported LoRA matches the selected activity scope.' + : '', + acceptedFileTypes, + activityScope, }; }; - if (transfer.mode === 'merge') { - const selectedNodes = new Map(); - for (const target of selectedTargets) { - let entry = selectedNodes.get(target.node.nodeKey); - if (!entry) { - entry = { node: target.node, targets: [] }; - selectedNodes.set(target.node.nodeKey, entry); - } - entry.targets.push(target); + const selectedNodes = new Map(); + for (const target of selectedTargets) { + let entry = selectedNodes.get(target.node.nodeKey); + if (!entry) { + entry = { node: target.node, targets: [] }; + selectedNodes.set(target.node.nodeKey, entry); } + entry.targets.push(target); + } + + const mode = normalizeTransferMode(transfer.mode); + if (mode === 'merge') { const previewRows = []; for (const { node, targets } of selectedNodes.values()) { const nodeType = String(node.nodeType || ''); - const canAppendLoras = ( - MERGE_LORA_NODE_TYPES.has(nodeType) - || nodeType === MERGE_POWER_LORA_NODE_TYPE + const canAppendLoras = isAppendableLoraNodeType(nodeType); + const loraSources = this.getMetadataTransferCompatibleSourcesForTarget( + targets[0], + sourcesByCategory, ); - const loraSources = sourcesByCategory.get('loras') || []; if (canAppendLoras && loraSources.length) { for (const source of loraSources) { previewRows.push(makeRow( @@ -707,13 +944,84 @@ export const imageMetadataMethods = { operation: 'add', currentLabelOverride: 'Existing LoRAs', forceChange: true, + sourceSelectable: false, }, )); } continue; } for (const target of targets) { - previewRows.push(makeRow(target, null, { operation: 'merge' })); + previewRows.push(makeRow(target, null, { + operation: 'merge', + sourceSelectable: false, + })); + } + } + return previewRows; + } + + if (mode === 'replace-add') { + const previewRows = []; + const sourceIndexes = new Map(); + for (const { node, targets } of selectedNodes.values()) { + const nodeType = String(node.nodeType || ''); + const loraTargets = targets.filter(target => target.category === 'loras'); + if (isAppendableLoraNodeType(nodeType) && loraTargets.length) { + const loraSources = this.getMetadataTransferCompatibleSourcesForTarget( + loraTargets[0], + sourcesByCategory, + ); + const usedSourceIndexes = new Set(); + for (const [sourcePosition, target] of loraTargets.entries()) { + const source = this.getMetadataTransferSourceForTarget( + target, + sourcesByCategory, + transfer, + sourcePosition, + ); + if (source) usedSourceIndexes.add(String(source.sourceIndex)); + previewRows.push(makeRow(target, source, { + sourceSelectable: source ? null : false, + unchangedWhenNoSource: true, + })); + } + for (const source of loraSources) { + if (usedSourceIndexes.has(String(source.sourceIndex))) continue; + previewRows.push(makeRow( + loraTargets[0], + source, + { + operation: 'add', + currentLabelOverride: 'Remaining LoRAs', + forceChange: true, + sourceSelectable: false, + }, + )); + } + for (const target of targets) { + if (target.category === 'loras') continue; + const sourceIndex = sourceIndexes.get(target.category) || 0; + sourceIndexes.set(target.category, sourceIndex + 1); + const source = this.getMetadataTransferSourceForTarget( + target, + sourcesByCategory, + transfer, + sourceIndex, + ); + previewRows.push(makeRow(target, source)); + } + continue; + } + for (const target of targets) { + const sourceIndex = sourceIndexes.get(target.category) || 0; + sourceIndexes.set(target.category, sourceIndex + 1); + const source = this.getMetadataTransferSourceForTarget( + target, + sourcesByCategory, + transfer, + sourceIndex, + ); + previewRows.push(makeRow(target, source)); } } return previewRows; @@ -721,53 +1029,90 @@ export const imageMetadataMethods = { const sourceIndexes = new Map(); return selectedTargets.map(target => { - const sources = sourcesByCategory.get(target.category) || []; const sourceIndex = sourceIndexes.get(target.category) || 0; sourceIndexes.set(target.category, sourceIndex + 1); - const source = sources.length === 1 - ? sources[0] - : sources[sourceIndex]; - return makeRow(target, source || null); + const source = this.getMetadataTransferSourceForTarget( + target, + sourcesByCategory, + transfer, + sourceIndex, + ); + return makeRow(target, source); }); }, renderMetadataTransferPreview(rows = []) { const previewRows = Array.isArray(rows) ? rows : []; - const content = previewRows.length - ? previewRows.map(row => { - const categoryLabel = this.getCategoryDisplayName(row.category).toUpperCase(); - const slotLabel = row.slot > 1 ? ` · Slot ${row.slot}` : ''; - const targetLabel = `${categoryLabel} · ${row.nodeName} · Node ${row.nodeId}${slotLabel}`; - const operationLabel = row.unchanged - ? 'Unchanged' + return previewRows.map(row => { + const slotLabel = row.slot > 1 ? ` · Slot ${row.slot}` : ''; + const targetLabel = `${row.nodeName} · Node ${row.nodeId}${slotLabel}`; + const currentTitle = row.currentValue || row.currentLabel; + const acceptedFormats = Array.isArray(row.acceptedFileTypes) + ? row.acceptedFileTypes + .map(type => String(type?.display || '').trim()) + .filter(Boolean) + .join(', ') + : ''; + const nextTitle = row.sourceUnavailable + ? row.sourceUnavailableReason + || `No imported model matches this node's accepted file format${acceptedFormats ? `: ${acceptedFormats}` : ''}.` + : row.nextValue || row.nextLabel; + const currentLabel = row.currentValue ? row.currentLabel : '(empty)'; + const importedClass = row.sourceUnavailable + ? 'is-placeholder' + : row.unchanged + ? 'is-unchanged' : row.operation === 'add' - ? 'Added' - : 'Replace'; - const currentTitle = row.currentValue || row.currentLabel; - const nextTitle = row.nextValue || row.nextLabel; - return ` -
-
- ${this.escapeHtml(targetLabel)} · Current - ${this.escapeHtml(row.currentLabel)} -
- -
- ${this.escapeHtml(operationLabel)} - ${this.escapeHtml(row.nextLabel)} -
+ ? 'is-added' + : 'is-imported'; + const getModelInteractionAttrs = (model, contextScope, label) => { + const modelPath = String( + model?.resolved_path + || model?.path + || model?.full_path + || '' + ).trim(); + if (model?.exists !== true || !modelPath) return ''; + + const contextModel = { + ...model, + context_scope: contextScope, + }; + const contextMenuAttrs = typeof this.getContextMenuAttrs === 'function' + ? this.getContextMenuAttrs(contextModel) + : ''; + const previewTooltipAttrs = typeof this.getModelPreviewTooltipAttrs === 'function' + ? this.getModelPreviewTooltipAttrs(model, label) + : ''; + return `${contextMenuAttrs}${previewTooltipAttrs}`; + }; + const currentModelAttrs = getModelInteractionAttrs(row.ref, 'loaded_model', currentTitle); + const importedModelAttrs = getModelInteractionAttrs(row.nextModel, 'local_model', nextTitle); + const sourceOptions = row.sourceSelectable + ? [ + ``, + ...row.sourceOptions.map(option => ` + + `), + ].join('') + : ''; + const importedValueHtml = row.sourceSelectable + ? `` + : `${this.escapeHtml(row.nextLabel)}`; + return ` +
+
+ Current + ${this.escapeHtml(currentLabel)} +
+ +
+ Imported + ${importedValueHtml}
- `; - }).join('') - : '

Select a target node above to preview model changes.

'; - return ` -
-
-

Transfer preview ${previewRows.length} ${previewRows.length === 1 ? 'slot' : 'slots'}

-
${content}
-
- `; + `; + }).join(''); }, renderMetadataTransferPanel(state) { @@ -777,6 +1122,7 @@ export const imageMetadataMethods = { applying: false, error: '', mode: 'replace', + activityScope: 'all', targetGroups: [], selectedNodeKeys: new Set(), }; @@ -826,6 +1172,57 @@ export const imageMetadataMethods = { `; } + const previewRows = this.getMetadataTransferPreviewRows(state); + const previewRowsByNodeKey = new Map(); + for (const row of previewRows) { + if (!previewRowsByNodeKey.has(row.nodeKey)) previewRowsByNodeKey.set(row.nodeKey, []); + previewRowsByNodeKey.get(row.nodeKey).push(row); + } + const collapsedCategories = transfer.collapsedCategories instanceof Set + ? transfer.collapsedCategories + : new Set(transfer.collapsedCategories || []); + const categoryLabels = { + checkpoints: 'Checkpoints', + diffusion_models: 'Diffusion Models', + text_encoders: 'Text Encoders', + loras: 'LoRAs', + vae: 'VAE', + controlnet: 'ControlNet', + upscale_models: 'Upscale Models', + embeddings: 'Embeddings', + }; + const getCategoryLabel = category => { + const normalized = normalizeTransferCategory(category); + if (categoryLabels[normalized]) return categoryLabels[normalized]; + return String(this.getCategoryDisplayName(normalized) || normalized || 'Unknown') + .split('_') + .filter(Boolean) + .map(part => `${part.charAt(0).toUpperCase()}${part.slice(1).toLowerCase()}`) + .join(' '); + }; + const renderUnselectedPreview = node => { + const refs = Array.isArray(node.refs) && node.refs.length ? node.refs : [{}]; + return refs.map((ref, index) => { + const currentValue = this.getMetadataTransferModelValue(ref); + const currentLabel = currentValue + ? this.getMetadataTransferModelLabel(ref) + : '(empty)'; + const slotLabel = refs.length > 1 ? ` · Slot ${index + 1}` : ''; + return ` +
+
+ Current${this.escapeHtml(slotLabel)} + ${this.escapeHtml(currentLabel)} +
+ +
+ Imported + Select node +
+
+ `; + }).join(''); + }; const renderNode = (node, isAvailable) => { const selected = selectedNodeKeys.has(node.nodeKey); const scopeLabel = node.isTopLevel @@ -837,37 +1234,34 @@ export const imageMetadataMethods = { : 'Workflow node'; const unavailable = sourceEntries.length > 0 && !isAvailable; const contextMenuAttrs = this.getContextMenuAttrs?.({ - context_scope: 'loaded_model', + context_scope: 'workflow_node', node_id: node.nodeId, node_type: node.nodeType, node_title: node.nodeTitle, subgraph_id: node.subgraphId, subgraph_name: node.subgraphName, is_top_level: node.isTopLevel, - }, 'Right-click to open node options') || ''; + }, 'Right-click to locate this node in the workflow') || ''; + const nodePreviewRows = previewRowsByNodeKey.get(node.nodeKey) || []; + const previewHtml = selected + ? nodePreviewRows.length + ? this.renderMetadataTransferPreview(nodePreviewRows) + : '

No imported LoRAs match the selected activity scope.

' + : renderUnselectedPreview(node); return ` - - `; - }; - const renderNodeGroup = (nodes, label, groupClass, isAvailable) => { - if (!nodes.length) return ''; - const chips = nodes - .map(node => renderNode(node, isAvailable)) - .join(''); - return ` -
-
- ${label} ${nodes.length} -
-
${chips}
+
+ +
${previewHtml}
`; }; @@ -875,43 +1269,57 @@ export const imageMetadataMethods = { const targetRows = targetGroups.length ? targetGroups.map(group => { const nodes = Array.isArray(group.nodes) ? group.nodes : []; - const activeNodes = nodes.filter(node => node.active); - const inactiveNodes = nodes.filter(node => !node.active); const isAvailable = sourceEntries.length === 0 || sourceCategories.has(group.category); const isUnavailable = sourceEntries.length > 0 && !isAvailable; const selectedCount = nodes.filter(node => selectedNodeKeys.has(node.nodeKey)).length; + const categoryLabel = getCategoryLabel(group.category); + const collapsed = collapsedCategories.has(group.category); return ` -
-
-
- ${this.escapeHtml(this.getCategoryDisplayName(group.category).toUpperCase())} - ${countText(nodes.length, 'node')} -
-
- ${activeNodes.length ? `${countText(activeNodes.length, 'active')}` : ''} - ${inactiveNodes.length ? `${countText(inactiveNodes.length, 'inactive')}` : ''} - ${selectedCount ? `${countText(selectedCount, 'selected')}` : ''} - ${isUnavailable ? 'No source model' : ''} -
+
+
+ + + ${countText(nodes.length, 'node')} + ${isUnavailable ? 'No source model' : ''} +
- ${renderNodeGroup(activeNodes, 'Active', 'active', isAvailable)} - ${renderNodeGroup(inactiveNodes, 'Inactive', 'inactive', isAvailable)} -
+ ${collapsed ? '' : `
${nodes.map(node => renderNode(node, isAvailable)).join('')}
`} + `; }).join('') : '

No model nodes were found in the current workflow.

'; - const mode = transfer.mode === 'merge' ? 'merge' : 'replace'; + const mode = normalizeTransferMode(transfer.mode); + const activityScope = normalizeTransferActivityScope(transfer.activityScope); const allTargetNodes = targetGroups.flatMap(group => group.nodes || []); const allTargetNodeKeys = new Set(allTargetNodes.map(node => node.nodeKey)); const selectedTargetCount = Array.from(selectedNodeKeys) .filter(nodeKey => allTargetNodeKeys.has(nodeKey)) .length; - const applyDisabled = transfer.applying || !sourceEntries.length || !selectedTargetCount; const selectionSourceText = sourceEntries.length ? countText(sourceEntries.length, 'metadata model') : 'No source workflow imported'; - const previewRows = this.getMetadataTransferPreviewRows(state); + const hasUnresolvedSource = previewRows.some(row => ( + row.sourceUnavailable + || (row.sourceSelectable && (row.sourceIndex === null || row.sourceIndex === undefined)) + )); + const hasTransferableTarget = previewRows.length > 0; + const applyDisabled = ( + transfer.applying + || !sourceEntries.length + || !selectedTargetCount + || !hasTransferableTarget + || hasUnresolvedSource + ); + const activityLabels = { + all: 'Active + inactive', + active: 'Active only', + inactive: 'Inactive only', + }; return `
@@ -926,14 +1334,26 @@ export const imageMetadataMethods = { ReplaceOverwrite slots in selected nodes. +
-

Click a node name to select it. A green border marks nodes that will be updated. ${this.escapeHtml(sourceEntries.length ? 'Categories without a matching metadata model are shown as unavailable.' : 'Target nodes come from the current ComfyUI workflow. Import a source workflow with model metadata to enable Apply transfer.')}

-
${targetRows}
- ${this.renderMetadataTransferPreview(previewRows)} +
+ Imported LoRAs + ${Object.entries(activityLabels).map(([value, label]) => ` + + `).join('')} +
+

Select a node row to include it in the transfer. ${this.escapeHtml(sourceEntries.length ? 'The current and imported models are shown in the same row. Choose a different imported model when a category contains multiple models.' : 'Target nodes come from the current ComfyUI workflow. Import a source workflow with model metadata to enable Apply transfer.')}${activityScope !== 'all' ? ` ${this.escapeHtml(`Imported LoRAs: ${activityLabels[activityScope]}; current target slots remain eligible.`)}` : ''}

+
${targetRows}
${this.escapeHtml(selectionSourceText)} · ${selectedTargetCount} selected node${selectedTargetCount === 1 ? '' : 's'}