Unify metadata workflow cache and category handling

This commit is contained in:
Dariusz L
2026-09-13 23:09:22 +02:00
parent 21245569c7
commit 552ef683da
11 changed files with 318 additions and 111 deletions
+3 -18
View File
@@ -14,6 +14,7 @@ from ..request_utils import (
read_text_field,
)
from ..routes.context import RouteContext
from ..type_utils import CATEGORY_MAP, normalize_category_token
from ..workflow.formats import normalize_workflow_payload
MAX_IMAGE_BYTES = 64 * 1024 * 1024
@@ -25,22 +26,6 @@ _CIVITAI_JSON_LABELS = (
"Civitai metadata",
"Civitai resources",
)
_CIVITAI_CATEGORY_ALIASES = {
"checkpoint": "checkpoints",
"checkpoints": "checkpoints",
"lora": "loras",
"loras": "loras",
"vae": "vae",
"controlnet": "controlnet",
"embedding": "embeddings",
"embeddings": "embeddings",
"textualinversion": "embeddings",
"upscale": "upscale_models",
"upscaler": "upscale_models",
"upscalemodel": "upscale_models",
}
def _decode_exif_user_comment(value: bytes) -> str:
"""Decode the standard EXIF UserComment prefix and payload."""
if value.startswith(b"UNICODE"):
@@ -268,8 +253,8 @@ def _normalize_civitai_resource(value: Any, index: int) -> Optional[dict[str, An
resource_type = str(
_first_mapping_value(value, ("type", "model_type", "modelType")) or ""
).strip()
category_key = re.sub(r"[^a-z0-9]+", "", resource_type.lower())
category = _CIVITAI_CATEGORY_ALIASES.get(category_key, category_key or "unknown")
category_key = normalize_category_token(resource_type)
category = CATEGORY_MAP.get(category_key, category_key or "unknown")
model_version_id = _coerce_identifier(
_first_mapping_value(
value,
+3
View File
@@ -242,6 +242,7 @@ URN_TYPE_MAP = {
CATEGORY_MAP = {
"checkpoints": "checkpoints",
"checkpoint": "checkpoints",
"ckpt": "checkpoints",
"loras": "loras",
"lora": "loras",
"embeddings": "embeddings",
@@ -254,7 +255,9 @@ CATEGORY_MAP = {
"control_net": "controlnet",
"vae": "vae",
"upscaler": "upscale_models",
"upscale": "upscale_models",
"upscale_model": "upscale_models",
"upscalemodel": "upscale_models",
"upscale_models": "upscale_models",
"latent_upscale_model": "latent_upscale_models",
"latent_upscale_models": "latent_upscale_models",
+3 -36
View File
@@ -11,33 +11,11 @@ from typing import Any, Dict, List, Optional
from .contracts import ModelReference, Resolution, ResolvedModel
from .custom_nodes import get_custom_node_resolution_metadata
from .path_utils import get_filename_from_path
from .type_utils import CATEGORY_MAP, normalize_category_token
from .workflow import dynamic_widgets
from .workflow.widgets import get_widget_name_candidates, normalize_widget_name
from .workflow_updater import update_model_path
_CATEGORY_ALIASES = {
"checkpoint": "checkpoints",
"checkpoints": "checkpoints",
"ckpt": "checkpoints",
"lora": "loras",
"loras": "loras",
"text_encoder": "text_encoders",
"text_encoders": "text_encoders",
"clip": "text_encoders",
"vae": "vae",
"controlnet": "controlnet",
"control_net": "controlnet",
"upscale": "upscale_models",
"upscaler": "upscale_models",
"upscale_model": "upscale_models",
"upscale_models": "upscale_models",
"diffusion_model": "diffusion_models",
"diffusion_models": "diffusion_models",
"unet": "diffusion_models",
"embedding": "embeddings",
"embeddings": "embeddings",
}
_LORA_MANAGER_NODE_TYPES = {
"LoraLoaderV2",
"Lora Loader (LoraManager)",
@@ -64,10 +42,10 @@ _TRANSFER_ACTIVITY_SCOPES = {"all", "active", "inactive"}
def normalize_transfer_category(value: Any) -> str:
"""Normalize the category names used by workflow and metadata payloads."""
token = str(value or "").strip().lower().replace("-", "_")
token = normalize_category_token(value)
if not token or token in {"unknown", "none", "null"}:
return ""
return _CATEGORY_ALIASES.get(token, token)
return CATEGORY_MAP.get(token, token)
def _normalize_identity(value: Any) -> str:
@@ -289,17 +267,6 @@ def _resolution_payload(resolution: Resolution) -> Dict[str, Any]:
return payload
def _assign_sources(
sources: Sequence[Mapping[str, Any]],
references: Sequence[ModelReference],
) -> List[tuple[ModelReference, Mapping[str, Any]]]:
if not sources or not references:
return []
if len(sources) == 1:
return [(reference, sources[0]) for reference in references]
return list(zip(references, sources, strict=False))
def _select_source_for_reference(
category_sources: Sequence[Mapping[str, Any]],
selector: Mapping[str, Any],
@@ -2096,6 +2096,40 @@ test('background Loaded Models refresh keeps the current view until new data is
assert.equal(contentElement.scrollTop, 48);
});
test('Loaded Models renders the shared cache without starting another scan', async () => {
const loadLoadedModels = eval(`(${extractMethod(tabsLoadedMethodsSource, 'loadLoadedModels')})`);
const getCachedLoadedModelsForSignature = eval(`(${extractMethod(
workflowStateMethodsSource,
'getCachedLoadedModelsForSignature'
)})`);
const cachedData = { loaded_models: [{ strength: 0.5 }], total: 1 };
const contentElement = { innerHTML: '' };
let fetchCount = 0;
let renderCount = 0;
const dialog = {
activeTab: 'loaded',
contentElement,
cachedLoadedModelsSignature: 'workflow-a',
cachedLoadedModelsData: cachedData,
syncWorkflowScopedQueue() {},
getWorkflowSignature: () => 'workflow-a',
getCachedLoadedModelsForSignature,
fetchJson: async () => {
fetchCount += 1;
throw new Error('The shared cache was not used');
},
displayLoadedModels: (_container, data) => {
renderCount += 1;
assert.equal(data, cachedData);
},
};
await loadLoadedModels.call(dialog, { nodes: [] });
assert.equal(fetchCount, 0);
assert.equal(renderCount, 1);
});
test('Loaded Models progress patches the stable progress container', () => {
const pollWorkflowProgress = extractMethod(renderFormatMethodsSource, 'pollWorkflowProgress');
const patchLoadedModelsProgress = extractMethod(
@@ -2730,6 +2764,31 @@ test('workflow analysis and loaded model caches stay independent and cloned', ()
assert.equal(dialog.workflowAnalysisCaches.get(dialog.workflowKey).data.missing_models.length, 1);
});
test('shared loaded model cache requires a matching workflow signature unless forced', () => {
const getCachedLoadedModelsForSignature = eval(`(${extractMethod(
workflowStateMethodsSource,
'getCachedLoadedModelsForSignature'
)})`);
const cachedData = { loaded_models: [{ filename: 'loaded.safetensors' }] };
const dialog = {
cachedLoadedModelsSignature: 'workflow-a',
cachedLoadedModelsData: cachedData,
};
assert.equal(
getCachedLoadedModelsForSignature.call(dialog, 'workflow-a'),
cachedData
);
assert.equal(
getCachedLoadedModelsForSignature.call(dialog, 'workflow-b'),
null
);
assert.equal(
getCachedLoadedModelsForSignature.call(dialog, 'workflow-a', { force: true }),
null
);
});
test('Missing Models filter toggles persist state and rerender once per change', () => {
const bindMissingFilterToggle = eval(
`(${extractMethod(missingBrowserMethodsSource, 'bindMissingFilterToggle')})`
+117
View File
@@ -147,6 +147,123 @@ function createLoraTransferState(sourceModels, activityScope = 'all') {
};
}
test('metadata transfer persists only mode and activity scope preferences', () => {
const context = createTransferContext();
assert.deepEqual(context.getMetadataTransferPreferences(), {
mode: 'replace',
activityScope: 'all',
});
assert.equal(context.setMetadataTransferMode('merge'), 'merge');
assert.equal(context.setMetadataTransferActivityScope('inactive'), 'inactive');
assert.deepEqual(context.getMetadataTransferPreferences(), {
mode: 'merge',
activityScope: 'inactive',
});
});
test('metadata tab reuses transfer state for the unchanged workflow', () => {
const context = createTransferContext();
context.getWorkflowSignature = workflow => workflow.signature;
const state = { transfer: { workflowSignature: 'workflow-a' } };
assert.equal(
context.isMetadataTransferCurrentWorkflow(state, { signature: 'workflow-a' }),
true
);
assert.equal(
context.isMetadataTransferCurrentWorkflow(state, { signature: 'workflow-b' }),
false
);
assert.equal(
context.isMetadataTransferCurrentWorkflow({ transfer: { workflowSignature: '' } }, null),
true
);
assert.equal(context.isMetadataTransferCurrentWorkflow(null, null), false);
});
test('metadata transfer reuses the shared Loaded Models cache for the same workflow', async () => {
const cachedData = {
loaded_models: [{
category: 'text_encoders',
node_id: 1,
node_type: 'CLIPLoader',
widget_index: 0,
original_path: 'current.safetensors',
active: true,
connected: true,
is_top_level: true,
}],
total: 1,
};
let fetchCount = 0;
const context = {
...imageMetadataMethods,
getCurrentWorkflow: () => ({ signature: 'workflow-a' }),
getWorkflowSignature: workflow => workflow.signature,
cachedLoadedModelsSignature: 'workflow-a',
cachedLoadedModelsData: cachedData,
getCachedLoadedModelsForSignature(signature, { force = false } = {}) {
return !force && signature === this.cachedLoadedModelsSignature
? this.cachedLoadedModelsData
: null;
},
fetchJson: async () => {
fetchCount += 1;
throw new Error('The shared cache was not used');
},
renderImageMetadataResult() {},
};
const result = await context.openMetadataTransfer();
assert.equal(fetchCount, 0);
assert.equal(result, cachedData);
assert.equal(context.imageInspectorState.transfer.targetModels, cachedData);
});
test('metadata transfer stores a fresh target scan in the shared Loaded Models cache', async () => {
const freshData = { loaded_models: [], total: 0 };
let saveCount = 0;
const context = {
...imageMetadataMethods,
getCurrentWorkflow: () => ({ signature: 'workflow-b' }),
getWorkflowSignature: workflow => workflow.signature,
cachedLoadedModelsSignature: null,
cachedLoadedModelsData: null,
getCachedLoadedModelsForSignature() {
return null;
},
fetchJson: async () => freshData,
saveLoadedModelsCacheForActiveWorkflow() {
saveCount += 1;
},
renderImageMetadataResult() {},
};
const result = await context.openMetadataTransfer();
assert.equal(result, freshData);
assert.equal(context.cachedLoadedModelsSignature, 'workflow-b');
assert.equal(context.cachedLoadedModelsData, freshData);
assert.equal(saveCount, 1);
});
test('metadata transfer uses the shared canonical category aliases', () => {
const context = createTransferContext();
const state = createTransferState([
{ category: 'clip_gguf', original_path: 'clip.safetensors' },
]);
state.transfer.targetGroups[0].category = 'clips';
state.transfer.targetGroups[0].nodes[0].refs[0].category = 'clips';
const [row] = context.getMetadataTransferPreviewRows(state);
assert.equal(row.sourceUnavailable, false);
assert.equal(row.nextValue, 'clip.safetensors');
});
test('metadata transfer hides imported models with an unsupported file extension', () => {
const context = createTransferContext();
const state = createTransferState([
+7
View File
@@ -4,6 +4,7 @@ from types import SimpleNamespace
import pytest
from core.contracts import ModelReference
from core.type_utils import CATEGORY_MAP
from core.workflow_transfer import (
normalize_transfer_category,
transfer_models_to_workflow,
@@ -48,9 +49,15 @@ def _selector(reference):
def test_normalize_transfer_category_covers_workflow_aliases():
assert normalize_transfer_category("UPSCALE-MODEL") == "upscale_models"
assert normalize_transfer_category("checkpoint") == "checkpoints"
assert normalize_transfer_category("CLIP / GGUF") == "text_encoders"
assert normalize_transfer_category("unknown") == ""
def test_normalize_transfer_category_matches_the_canonical_category_map():
for raw_category, canonical_category in CATEGORY_MAP.items():
assert normalize_transfer_category(raw_category) == canonical_category
def test_replace_fans_one_source_to_selected_slots_and_syncs_named_values():
workflow = {
"nodes": [
@@ -486,6 +486,18 @@ export const workflowStateMethods = {
};
},
getCachedLoadedModelsForSignature(workflowSignature, { force = false } = {}) {
if (
force
|| !workflowSignature
|| this.cachedLoadedModelsSignature !== workflowSignature
|| !this.cachedLoadedModelsData
) {
return null;
}
return this.cachedLoadedModelsData;
},
saveAnalysisCacheForActiveWorkflow() {
this.saveWorkflowModelCache(
this.workflowAnalysisCaches,
@@ -16,6 +16,7 @@ export const CATEGORY_ALIASES = Object.freeze(
"background_removal_model": "background_removal",
"checkpoint": "checkpoints",
"checkpoints": "checkpoints",
"ckpt": "checkpoints",
"clip": "text_encoders",
"clip_gguf": "text_encoders",
"clip_vision": "clip_vision",
@@ -67,8 +68,10 @@ export const CATEGORY_ALIASES = Object.freeze(
"ultralytics_segm": "ultralytics",
"unet": "diffusion_models",
"unet_gguf": "diffusion_models",
"upscale": "upscale_models",
"upscale_model": "upscale_models",
"upscale_models": "upscale_models",
"upscalemodel": "upscale_models",
"upscaler": "upscale_models",
"vae": "vae",
"vae_approx": "vae_approx",
+17
View File
@@ -1,5 +1,22 @@
import { CATEGORY_ALIASES, normalizeCategoryToken } from './category_aliases.generated.js';
export function normalizeCategoryValue(
category = '',
{
aliases = CATEGORY_ALIASES,
defaultValue = '',
emptyValues = ['unknown', 'none', 'null'],
} = {},
) {
const token = normalizeCategoryToken(category);
const categoryAliases = aliases && typeof aliases === 'object'
? aliases
: CATEGORY_ALIASES;
const emptyTokens = new Set(emptyValues);
if (!token || emptyTokens.has(token)) return defaultValue;
return categoryAliases[token] || token;
}
export function normalizeDownloadCategoryValue(
category = '',
aliases = CATEGORY_ALIASES,
@@ -1,28 +1,14 @@
import { safeStorage } from "../utils/html_utils.js";
import {
isExistingResolvedModel,
toResolverContextModel,
} from "../node_context_menu.js";
import { normalizeCategoryValue } from "../utils/category_utils.js";
const MAX_IMAGE_FILE_SIZE = 64 * 1024 * 1024;
const MAX_WORKFLOW_FILE_SIZE = 16 * 1024 * 1024;
const TRANSFER_CATEGORY_ALIASES = Object.freeze({
checkpoint: 'checkpoints',
checkpoints: 'checkpoints',
ckpt: 'checkpoints',
lora: 'loras',
loras: 'loras',
clip: 'text_encoders',
text_encoder: 'text_encoders',
text_encoders: 'text_encoders',
vae: 'vae',
control_net: 'controlnet',
controlnet: 'controlnet',
upscale: 'upscale_models',
upscaler: 'upscale_models',
upscale_model: 'upscale_models',
upscale_models: 'upscale_models',
unet: 'diffusion_models',
diffusion_model: 'diffusion_models',
diffusion_models: 'diffusion_models',
embedding: 'embeddings',
embeddings: 'embeddings',
});
const METADATA_TRANSFER_MODE_STORAGE_KEY = 'model_resolver_metadata_transfer_mode';
const METADATA_TRANSFER_ACTIVITY_SCOPE_STORAGE_KEY = 'model_resolver_metadata_transfer_activity_scope';
const MERGE_LORA_NODE_TYPES = new Set([
'LoraLoaderV2',
@@ -63,8 +49,7 @@ function isAppendableLoraNodeType(nodeType) {
}
function normalizeTransferCategory(value) {
const token = String(value || '').trim().toLowerCase().replaceAll('-', '_');
return TRANSFER_CATEGORY_ALIASES[token] || token;
return normalizeCategoryValue(value);
}
function getTransferNodeKey(model = {}) {
@@ -161,11 +146,23 @@ function buildCivitaiModelData(resources = []) {
}
export const imageMetadataMethods = {
isMetadataTransferCurrentWorkflow(state = null, workflow = null) {
const transfer = state?.transfer;
if (!transfer) return false;
if (!workflow) return !transfer.workflowSignature;
const workflowSignature = this.getWorkflowSignature?.(workflow) || '';
return Boolean(workflowSignature) && transfer.workflowSignature === workflowSignature;
},
loadImageMetadata() {
if (!this.contentElement) return null;
this.contentElement.style.overflowY = 'auto';
const state = getImageInspectorState(this);
const workflow = this.getCurrentWorkflow?.() || null;
const shouldOpenTransfer = !this.isMetadataTransferCurrentWorkflow(state, workflow);
this.renderImageMetadataShell();
void this.openMetadataTransfer();
if (shouldOpenTransfer) void this.openMetadataTransfer();
return null;
},
@@ -244,7 +241,7 @@ export const imageMetadataMethods = {
if (action === 'choose-file') fileInput?.click();
if (action === 'clear') this.clearImageMetadata();
if (action === 'clear-transfer-targets') this.clearMetadataTransferTargets();
if (action === 'retry-transfer-targets') void this.openMetadataTransfer();
if (action === 'retry-transfer-targets') void this.openMetadataTransfer({ force: true });
if (action === 'apply-transfer') void this.applyMetadataTransfer();
if (action === 'analyze-paste') {
const textarea = root.querySelector('.mr-image-inspector-textarea');
@@ -275,14 +272,14 @@ export const imageMetadataMethods = {
}
if (input.matches?.('[data-image-transfer-mode]')) {
const state = getImageInspectorState(this);
if (state.transfer) state.transfer.mode = normalizeTransferMode(input.value);
if (state.transfer) state.transfer.mode = this.setMetadataTransferMode(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);
state.transfer.activityScope = this.setMetadataTransferActivityScope(input.value);
}
this.renderImageMetadataResult();
return;
@@ -681,18 +678,22 @@ export const imageMetadataMethods = {
},
getMetadataTransferModelInteractionAttrs(model = {}, contextScope = 'loaded_model', label = '') {
const modelPath = String(
model?.resolved_path
|| model?.path
|| model?.full_path
|| ''
).trim();
if (model?.exists !== true || !modelPath) return '';
if (!isExistingResolvedModel(model)) return '';
const contextModel = {
...model,
context_scope: contextScope,
};
let contextModel;
if (contextScope === 'local_model') {
contextModel = toResolverContextModel(model);
} else if (
contextScope === 'loaded_model'
&& typeof this.getLoadedModelContext === 'function'
) {
contextModel = this.getLoadedModelContext(model);
} else {
contextModel = {
...model,
context_scope: contextScope,
};
}
const contextMenuAttrs = typeof this.getContextMenuAttrs === 'function'
? this.getContextMenuAttrs(contextModel)
: '';
@@ -702,6 +703,27 @@ export const imageMetadataMethods = {
return `${contextMenuAttrs}${previewTooltipAttrs}`;
},
getMetadataTransferPreferences() {
return {
mode: normalizeTransferMode(safeStorage.getItem(METADATA_TRANSFER_MODE_STORAGE_KEY)),
activityScope: normalizeTransferActivityScope(
safeStorage.getItem(METADATA_TRANSFER_ACTIVITY_SCOPE_STORAGE_KEY)
),
};
},
setMetadataTransferMode(value) {
const mode = normalizeTransferMode(value);
safeStorage.setItem(METADATA_TRANSFER_MODE_STORAGE_KEY, mode);
return mode;
},
setMetadataTransferActivityScope(value) {
const activityScope = normalizeTransferActivityScope(value);
safeStorage.setItem(METADATA_TRANSFER_ACTIVITY_SCOPE_STORAGE_KEY, activityScope);
return activityScope;
},
getMetadataTransferSourceSelectionKey(ref = {}) {
return String(
ref.transferKey
@@ -1378,13 +1400,14 @@ export const imageMetadataMethods = {
`;
},
async openMetadataTransfer() {
async openMetadataTransfer({ force = false } = {}) {
const state = getImageInspectorState(this);
const imageRequestToken = state.requestToken;
const workflow = this.getCurrentWorkflow?.();
const workflowSignature = workflow
? (this.getWorkflowSignature?.(workflow) || '')
: '';
if (workflow) this.syncWorkflowScopedQueue?.(workflow);
const previousTransfer = state.transfer;
const preserveSelection = Boolean(
workflowSignature
@@ -1392,6 +1415,7 @@ export const imageMetadataMethods = {
&& previousTransfer.workflowSignature === workflowSignature
);
const previousSourceSelections = previousTransfer?.sourceSelections;
const savedPreferences = this.getMetadataTransferPreferences();
const sourceSelections = preserveSelection
? previousSourceSelections instanceof Map
? Object.fromEntries(previousSourceSelections)
@@ -1405,8 +1429,10 @@ export const imageMetadataMethods = {
loading: Boolean(workflow),
applying: false,
error: '',
mode: normalizeTransferMode(previousTransfer?.mode),
activityScope: normalizeTransferActivityScope(previousTransfer?.activityScope),
mode: normalizeTransferMode(previousTransfer?.mode ?? savedPreferences.mode),
activityScope: normalizeTransferActivityScope(
previousTransfer?.activityScope ?? savedPreferences.activityScope
),
requestToken: token,
workflowSignature,
targetGroups: [],
@@ -1428,18 +1454,30 @@ export const imageMetadataMethods = {
}
try {
const targetModels = await this.fetchJson(
'/model_resolver/loaded',
{
method: 'POST',
body: JSON.stringify({ workflow }),
},
'Scan current workflow for transfer'
const cachedTargetModels = this.getCachedLoadedModelsForSignature?.(
workflowSignature,
{ force },
);
let targetModels = cachedTargetModels;
if (!targetModels) {
targetModels = await this.fetchJson(
'/model_resolver/loaded',
{
method: 'POST',
body: JSON.stringify({ workflow }),
},
'Scan current workflow for transfer'
);
}
if (
state.requestToken !== imageRequestToken
|| state.transfer?.requestToken !== token
) return null;
if (!cachedTargetModels && workflowSignature) {
this.cachedLoadedModelsSignature = workflowSignature;
this.cachedLoadedModelsData = targetModels;
this.saveLoadedModelsCacheForActiveWorkflow?.();
}
state.transfer.loading = false;
state.transfer.targetModels = targetModels;
state.transfer.targetGroups = this.getMetadataTransferTargetGroups(targetModels);
+6 -7
View File
@@ -120,14 +120,13 @@ export const tabsLoadedMethods = {
this._loadedModelsLoadToken = loadToken;
const workflowSignature = this.getWorkflowSignature(workflow);
if (
!force &&
workflowSignature &&
this.cachedLoadedModelsSignature === workflowSignature &&
this.cachedLoadedModelsData
) {
const cachedLoadedModels = this.getCachedLoadedModelsForSignature?.(
workflowSignature,
{ force },
);
if (cachedLoadedModels) {
if (shouldRenderLoadedModels()) {
this.displayLoadedModels(this.contentElement, this.cachedLoadedModelsData);
this.displayLoadedModels(this.contentElement, cachedLoadedModels);
}
return;
}