Add metadata workflow model transfer
This commit is contained in:
@@ -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
@@ -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:
|
||||
|
||||
@@ -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, '"')
|
||||
.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);
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user