mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 19:21:27 -03:00
Compare commits
4
Commits
395682509c
...
v1.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94dd08646d | ||
|
|
658f88ca48 | ||
|
|
f53352efb2 | ||
|
|
38809a9d1b |
+327
-295
File diff suppressed because it is too large
Load Diff
@@ -937,6 +937,11 @@
|
||||
"favorites": {
|
||||
"title": "Nur Favoriten anzeigen",
|
||||
"action": "Favoriten"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Rezepte-Layout",
|
||||
"grid": "Raster-Layout",
|
||||
"masonry": "Masonry-Layout (Pinterest-Stil, behält das Seitenverhältnis des Bildes bei)"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
|
||||
@@ -937,6 +937,11 @@
|
||||
"favorites": {
|
||||
"title": "Show Favorites Only",
|
||||
"action": "Favorites"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Recipes Layout",
|
||||
"grid": "Grid layout",
|
||||
"masonry": "Masonry layout (Pinterest-style, preserves image aspect ratio)"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
|
||||
@@ -937,6 +937,11 @@
|
||||
"favorites": {
|
||||
"title": "Mostrar solo favoritos",
|
||||
"action": "Favoritos"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Diseño de recetas",
|
||||
"grid": "Vista de cuadrícula",
|
||||
"masonry": "Vista masonry (estilo Pinterest, conserva la proporción de aspecto de la imagen)"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
|
||||
@@ -937,6 +937,11 @@
|
||||
"favorites": {
|
||||
"title": "Afficher uniquement les favoris",
|
||||
"action": "Favoris"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Disposition des recettes",
|
||||
"grid": "Disposition en grille",
|
||||
"masonry": "Disposition masonry (style Pinterest, préserve le rapport d'aspect de l'image)"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
|
||||
@@ -937,6 +937,11 @@
|
||||
"favorites": {
|
||||
"title": "הצג מועדפים בלבד",
|
||||
"action": "מועדפים"
|
||||
},
|
||||
"layout": {
|
||||
"title": "פריסת מתכונים",
|
||||
"grid": "פריסת רשת",
|
||||
"masonry": "פריסת Masonry (בסגנון Pinterest, שומרת על יחס הגובה-רוחב של התמונה)"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
|
||||
@@ -937,6 +937,11 @@
|
||||
"favorites": {
|
||||
"title": "お気に入りのみ表示",
|
||||
"action": "お気に入り"
|
||||
},
|
||||
"layout": {
|
||||
"title": "レシピのレイアウト",
|
||||
"grid": "グリッドレイアウト",
|
||||
"masonry": "メイソンリーレイアウト(Pinterest スタイル、画像のアスペクト比を保持)"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
|
||||
@@ -937,6 +937,11 @@
|
||||
"favorites": {
|
||||
"title": "즐겨찾기만 표시",
|
||||
"action": "즐겨찾기"
|
||||
},
|
||||
"layout": {
|
||||
"title": "레시피 레이아웃",
|
||||
"grid": "그리드 레이아웃",
|
||||
"masonry": "메이슨리 레이아웃 (Pinterest 스타일, 이미지 종횡비 유지)"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
|
||||
@@ -937,6 +937,11 @@
|
||||
"favorites": {
|
||||
"title": "Только избранные",
|
||||
"action": "Избранное"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Макет рецептов",
|
||||
"grid": "Макет сеткой",
|
||||
"masonry": "Masonry-макет (в стиле Pinterest, сохраняет пропорции изображения)"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
|
||||
@@ -937,6 +937,11 @@
|
||||
"favorites": {
|
||||
"title": "仅显示收藏",
|
||||
"action": "收藏"
|
||||
},
|
||||
"layout": {
|
||||
"title": "配方布局",
|
||||
"grid": "网格布局",
|
||||
"masonry": "瀑布流布局(Pinterest 风格,保留图片原始宽高比)"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
|
||||
@@ -937,6 +937,11 @@
|
||||
"favorites": {
|
||||
"title": "僅顯示收藏",
|
||||
"action": "收藏"
|
||||
},
|
||||
"layout": {
|
||||
"title": "配方版面",
|
||||
"grid": "網格版面",
|
||||
"masonry": "瀑布流版面(Pinterest 風格,保留圖片原始寬高比)"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
|
||||
@@ -214,6 +214,24 @@ class MetadataProcessor:
|
||||
max_denoise = denoise
|
||||
primary_sampler = sampler_info
|
||||
primary_sampler_id = node_id
|
||||
|
||||
# Last resort: any registered sampler. Samplers without a denoise or
|
||||
# add_noise parameter (e.g. multi-stage samplers like KreaTwoStageSampler)
|
||||
# are not caught by the criteria above. Prefer execution order so the
|
||||
# first executed sampler wins, matching the downstream_id branch.
|
||||
if primary_sampler is None:
|
||||
sampler_ids = [
|
||||
node_id
|
||||
for node_id, sampler_info in metadata.get(SAMPLING, {}).items()
|
||||
if sampler_info.get(IS_SAMPLER, False)
|
||||
]
|
||||
if sampler_ids:
|
||||
if downstream_id and "execution_order" in metadata:
|
||||
for node_id in metadata["execution_order"]:
|
||||
if node_id in sampler_ids:
|
||||
return node_id, metadata[SAMPLING][node_id]
|
||||
primary_sampler_id = sampler_ids[0]
|
||||
primary_sampler = metadata[SAMPLING][sampler_ids[0]]
|
||||
|
||||
return primary_sampler_id, primary_sampler
|
||||
|
||||
|
||||
@@ -861,6 +861,65 @@ class TSCKSamplerAdvancedExtractor(KSamplerAdvancedExtractor, TSCSamplerBaseExtr
|
||||
|
||||
# Update method is inherited from TSCSamplerBaseExtractor
|
||||
|
||||
class KreaTwoStageSamplerExtractor(BaseSamplerExtractor):
|
||||
"""Extractor for Krea Two/Three Stage Samplers (Auryg/Krea-2-Two-Stage-Sampler).
|
||||
|
||||
The node samples in two (or three) stages with per-stage settings
|
||||
(stage1_steps/stage2_steps, stage1_cfg/stage2_cfg, ...). The canonical
|
||||
metadata fields consumed by ``extract_generation_params`` (steps, cfg,
|
||||
sampler_name, scheduler) are derived from the base stage (stage 1; the
|
||||
three-stage variant reuses stage 1 settings for stage 3), while the full
|
||||
per-stage breakdown is preserved in the raw parameters.
|
||||
"""
|
||||
|
||||
# All per-stage parameter keys present on both node variants.
|
||||
_STAGE_PARAM_KEYS = (
|
||||
"stage1_steps", "stage1_cfg", "stage1_sampler_name", "stage1_scheduler",
|
||||
"stage2_steps", "stage2_cfg", "stage2_sampler_name", "stage2_scheduler",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
if not inputs:
|
||||
return
|
||||
|
||||
BaseSamplerExtractor.extract_sampling_params(
|
||||
node_id,
|
||||
inputs,
|
||||
metadata,
|
||||
("seed", "handoff_percent", "stage3_handoff_percent")
|
||||
+ KreaTwoStageSamplerExtractor._STAGE_PARAM_KEYS,
|
||||
)
|
||||
|
||||
# Derive the canonical fields expected by extract_generation_params.
|
||||
sampling_params = metadata[SAMPLING][node_id]["parameters"]
|
||||
if "stage1_steps" in sampling_params or "stage2_steps" in sampling_params:
|
||||
sampling_params["steps"] = (
|
||||
(sampling_params.get("stage1_steps") or 0)
|
||||
+ (sampling_params.get("stage2_steps") or 0)
|
||||
)
|
||||
if "stage1_cfg" in sampling_params:
|
||||
sampling_params["cfg"] = sampling_params["stage1_cfg"]
|
||||
if "stage1_sampler_name" in sampling_params:
|
||||
sampling_params["sampler_name"] = sampling_params["stage1_sampler_name"]
|
||||
if "stage1_scheduler" in sampling_params:
|
||||
sampling_params["scheduler"] = sampling_params["stage1_scheduler"]
|
||||
|
||||
BaseSamplerExtractor.extract_conditioning(node_id, inputs, metadata)
|
||||
|
||||
# Prefer the final generation resolution; latent dims are the fallback.
|
||||
BaseSamplerExtractor.extract_latent_dimensions(node_id, inputs, metadata)
|
||||
final_width = inputs.get("final_width")
|
||||
final_height = inputs.get("final_height")
|
||||
if final_width and final_height:
|
||||
if SIZE not in metadata:
|
||||
metadata[SIZE] = {}
|
||||
metadata[SIZE][node_id] = {
|
||||
"width": final_width,
|
||||
"height": final_height,
|
||||
"node_id": node_id,
|
||||
}
|
||||
|
||||
class LoraLoaderExtractor(NodeMetadataExtractor):
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
@@ -901,6 +960,37 @@ class ImageSizeExtractor(NodeMetadataExtractor):
|
||||
"node_id": node_id
|
||||
}
|
||||
|
||||
class KreaDualResolutionSelectorExtractor(NodeMetadataExtractor):
|
||||
"""Extract base resolution from Krea Dual Resolution Selector outputs
|
||||
(Auryg/Krea-2-Two-Stage-Sampler).
|
||||
|
||||
The node computes base/final dimensions at runtime from aspect ratio and
|
||||
megapixel settings, so the values are only available in the update phase
|
||||
(outputs: base_width, base_height, final_width, final_height, seed).
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
# Dimensions are computed at runtime; nothing to do here.
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def update(node_id, outputs, metadata):
|
||||
output_tuple = _first_output_tuple(outputs)
|
||||
if not output_tuple or len(output_tuple) < 2:
|
||||
return
|
||||
width, height = output_tuple[0], output_tuple[1]
|
||||
if not isinstance(width, int) or not isinstance(height, int):
|
||||
return
|
||||
|
||||
if SIZE not in metadata:
|
||||
metadata[SIZE] = {}
|
||||
metadata[SIZE][node_id] = {
|
||||
"width": width,
|
||||
"height": height,
|
||||
"node_id": node_id,
|
||||
}
|
||||
|
||||
class RgthreePowerLoraLoaderExtractor(NodeMetadataExtractor):
|
||||
"""Extract LoRA metadata from rgthree Power Lora Loader.
|
||||
|
||||
@@ -1302,6 +1392,8 @@ NODE_EXTRACTORS = {
|
||||
"ClownsharKSampler_Beta": SamplerExtractor,
|
||||
"TSC_KSampler": TSCKSamplerExtractor, # Efficient Nodes
|
||||
"TSC_KSamplerAdvanced": TSCKSamplerAdvancedExtractor, # Efficient Nodes
|
||||
"KreaTwoStageSampler": KreaTwoStageSamplerExtractor, # Auryg/Krea-2-Two-Stage-Sampler
|
||||
"KreaThreeStageSampler": KreaTwoStageSamplerExtractor, # Auryg/Krea-2-Two-Stage-Sampler
|
||||
"KSamplerBasicPipe": KSamplerBasicPipeExtractor, # comfyui-impact-pack
|
||||
"KSamplerAdvancedBasicPipe": KSamplerAdvancedBasicPipeExtractor, # comfyui-impact-pack
|
||||
"KSampler_inspire_pipe": KSamplerBasicPipeExtractor, # comfyui-inspire-pack
|
||||
@@ -1353,6 +1445,7 @@ NODE_EXTRACTORS = {
|
||||
"GetNode": GetNodeExtractor,
|
||||
# Latent
|
||||
"EmptyLatentImage": ImageSizeExtractor,
|
||||
"KreaDualResolutionSelector": KreaDualResolutionSelectorExtractor, # Auryg/Krea-2-Two-Stage-Sampler
|
||||
# Flux
|
||||
"FluxGuidance": FluxGuidanceExtractor, # Add FluxGuidance
|
||||
"CFGGuider": CFGGuiderExtractor, # Add CFGGuider
|
||||
|
||||
+225
-46
@@ -36,6 +36,11 @@ logger = logging.getLogger(__name__)
|
||||
# explicitly to "diffusion_model" (mirrors Oracle R2-F1).
|
||||
_CHECKPOINT_MODEL_TYPE_ALIASES = {"diffusionmodel": "diffusion_model"}
|
||||
|
||||
# Known weight-file extensions stripped by _normalize_filename_key. Names are
|
||||
# stored extensionless on both sides, so splitext would misread dotted stems
|
||||
# ("my.mix" -> "my") and silently collide distinct models.
|
||||
_WEIGHT_FILE_EXTS = (".safetensors", ".ckpt", ".pt", ".pth", ".gguf", ".bin", ".safebin", ".sft")
|
||||
|
||||
|
||||
class RecipeScanner:
|
||||
"""Service for scanning and managing recipe images"""
|
||||
@@ -116,6 +121,12 @@ class RecipeScanner:
|
||||
self._rematch_autov3_cache: dict[str, dict[str, Any]] | None = None
|
||||
self._rematch_autov3_versions: tuple[int, int] | None = None
|
||||
self._rematch_autov3_lock = asyncio.Lock()
|
||||
# Normalized filename -> [items] map for the L4 rematch fallback,
|
||||
# rebuilt only when either model scanner's cache_version changes.
|
||||
# Mirrors the build_local_hash_cache version pattern.
|
||||
self._local_filename_cache: dict[str, list[dict[str, Any]]] | None = None
|
||||
self._local_filename_cache_versions: tuple[int, int] | None = None
|
||||
self._local_filename_cache_lock = asyncio.Lock()
|
||||
self._initialized = True
|
||||
|
||||
async def build_local_hash_cache(self) -> dict[str, dict[str, Any]]:
|
||||
@@ -162,6 +173,70 @@ class RecipeScanner:
|
||||
self._local_hash_cache_versions = versions
|
||||
return cache
|
||||
|
||||
@staticmethod
|
||||
def _normalize_filename_key(name: str) -> str:
|
||||
"""Normalize a file name to a lookup key (basename, lowercase).
|
||||
|
||||
Only known weight-file extensions are stripped — names are stored
|
||||
extensionless on both sides, so splitext would misread dotted stems
|
||||
("my.mix" -> "my") and collide distinct models.
|
||||
"""
|
||||
if not name:
|
||||
return ""
|
||||
basename = os.path.basename(name.replace("\\", "/"))
|
||||
lower = basename.lower()
|
||||
for ext in _WEIGHT_FILE_EXTS:
|
||||
if lower.endswith(ext):
|
||||
basename = basename[: -len(ext)]
|
||||
break
|
||||
return basename.strip().lower()
|
||||
|
||||
async def _build_local_filename_cache(self) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Build a version-cached map of normalized file names to local items.
|
||||
|
||||
Keys are lowercase basenames without extension. Values are lists of
|
||||
items (lora + checkpoint, type-blind) sharing that name. Only items
|
||||
with a sha256 are indexed — matching a pending or failed download
|
||||
(empty sha256) would leave the entry without a usable hash. The dict
|
||||
is reused while both scanners' cache_version values are unchanged;
|
||||
concurrent callers share a single build via the lock.
|
||||
"""
|
||||
async with self._local_filename_cache_lock:
|
||||
lora_scanner = self._lora_scanner
|
||||
checkpoint_scanner = self._checkpoint_scanner
|
||||
versions = (
|
||||
lora_scanner.cache_version if lora_scanner is not None else 0,
|
||||
checkpoint_scanner.cache_version
|
||||
if checkpoint_scanner is not None
|
||||
else 0,
|
||||
)
|
||||
if (
|
||||
self._local_filename_cache is not None
|
||||
and self._local_filename_cache_versions == versions
|
||||
):
|
||||
return self._local_filename_cache
|
||||
|
||||
cache: dict[str, list[dict[str, Any]]] = {}
|
||||
for scanner in (lora_scanner, checkpoint_scanner):
|
||||
if scanner is None:
|
||||
continue
|
||||
data = await scanner.get_cached_data()
|
||||
for item in data.raw_data:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if not (item.get("sha256") or "").lower():
|
||||
continue
|
||||
file_path = item.get("file_path") or ""
|
||||
file_name = item.get("file_name") or ""
|
||||
key = self._normalize_filename_key(file_name or file_path)
|
||||
if not key:
|
||||
continue
|
||||
cache.setdefault(key, []).append(item)
|
||||
|
||||
self._local_filename_cache = cache
|
||||
self._local_filename_cache_versions = versions
|
||||
return cache
|
||||
|
||||
def _is_rematch_candidate(self, entry: dict[str, Any]) -> bool:
|
||||
"""Return True when a recipe entry is eligible for local re-matching."""
|
||||
if not isinstance(entry, dict):
|
||||
@@ -170,7 +245,10 @@ class RecipeScanner:
|
||||
entry.get("isDeleted") or not entry.get("hash") or not entry.get("file_name")
|
||||
)
|
||||
has_identifier = (
|
||||
entry.get("hash") or entry.get("modelVersionId") or entry.get("id")
|
||||
entry.get("hash")
|
||||
or entry.get("modelVersionId")
|
||||
or entry.get("id")
|
||||
or entry.get("file_name")
|
||||
)
|
||||
return bool(unresolved and has_identifier)
|
||||
|
||||
@@ -221,6 +299,97 @@ class RecipeScanner:
|
||||
self._rematch_autov3_versions = versions
|
||||
return cache
|
||||
|
||||
def _is_type_compatible(self, item: dict[str, Any], *, is_checkpoint: bool) -> bool:
|
||||
"""Return True when a local item's type matches the entry kind.
|
||||
|
||||
The L1 hash cache and the L4 filename cache merge lora and checkpoint
|
||||
items and are type-blind, so a match must be verified against the
|
||||
entry kind before it is accepted.
|
||||
"""
|
||||
sub_type = (item.get("sub_type") or "").lower()
|
||||
if sub_type:
|
||||
valid = (
|
||||
VALID_CHECKPOINT_SUB_TYPES if is_checkpoint else VALID_LORA_TYPES
|
||||
)
|
||||
return sub_type in valid
|
||||
|
||||
civitai_type = (
|
||||
(item.get("civitai") or {}).get("model", {}) or {}
|
||||
).get("type", "")
|
||||
if civitai_type:
|
||||
normalized = civitai_type.lower()
|
||||
if is_checkpoint:
|
||||
normalized = _CHECKPOINT_MODEL_TYPE_ALIASES.get(
|
||||
normalized, normalized
|
||||
)
|
||||
valid = VALID_CHECKPOINT_SUB_TYPES
|
||||
else:
|
||||
valid = VALID_LORA_TYPES
|
||||
return normalized in valid
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _has_positive_type_evidence(item: dict[str, Any]) -> bool:
|
||||
"""Return True when the item carries an explicit type marker.
|
||||
|
||||
Lora raw items rarely carry ``sub_type`` (it is only written when
|
||||
metadata provides it), while checkpoint items always do — so for
|
||||
checkpoint slots a type-less candidate is a red flag, not the norm.
|
||||
"""
|
||||
if (item.get("sub_type") or "").lower():
|
||||
return True
|
||||
civitai_type = (
|
||||
(item.get("civitai") or {}).get("model", {}) or {}
|
||||
).get("type", "")
|
||||
return bool(civitai_type)
|
||||
|
||||
def _match_rematch_entry_filename(
|
||||
self,
|
||||
entry: dict[str, Any],
|
||||
recipe_base_model: Optional[str],
|
||||
filename_cache: dict[str, list[dict[str, Any]]],
|
||||
*,
|
||||
is_checkpoint: bool,
|
||||
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
|
||||
"""Match a recipe entry against local models by file name (L4).
|
||||
|
||||
Conservative fallback used only after the hash (L1), version-index
|
||||
(L2) and computed-autov3 (L3) tiers all failed. Candidates share the
|
||||
entry's normalized file name; a candidate is accepted only when BOTH
|
||||
the recipe base model and the candidate's base model are known and
|
||||
equal (unknown on either side rejects — never guess on missing
|
||||
metadata), the type gate passes, and exactly one candidate survives
|
||||
(ambiguity is a miss). Checkpoint slots additionally require positive
|
||||
type evidence: lora raw items often lack ``sub_type`` while
|
||||
checkpoints always carry it, so a type-less candidate is a red flag
|
||||
there — an unknown-type lora must not be bound into a checkpoint
|
||||
slot.
|
||||
|
||||
Returns:
|
||||
Tuple of (matched item, "L4") — or ``(None, None)``.
|
||||
"""
|
||||
entry_name = self._normalize_filename_key(entry.get("file_name") or "")
|
||||
if not entry_name:
|
||||
return (None, None)
|
||||
|
||||
recipe_base = (recipe_base_model or "").strip().lower()
|
||||
matched: list[dict[str, Any]] = []
|
||||
for candidate in filename_cache.get(entry_name, []):
|
||||
candidate_base = (candidate.get("base_model") or "").strip().lower()
|
||||
if not recipe_base or not candidate_base:
|
||||
continue
|
||||
if recipe_base != candidate_base:
|
||||
continue
|
||||
if is_checkpoint and not self._has_positive_type_evidence(candidate):
|
||||
continue
|
||||
if not self._is_type_compatible(candidate, is_checkpoint=is_checkpoint):
|
||||
continue
|
||||
matched.append(candidate)
|
||||
|
||||
if len(matched) != 1:
|
||||
return (None, None)
|
||||
return (matched[0], "L4")
|
||||
|
||||
async def _match_rematch_entry(
|
||||
self,
|
||||
entry: dict[str, Any],
|
||||
@@ -247,19 +416,23 @@ class RecipeScanner:
|
||||
autov3_cache: dict[str, Any],
|
||||
*,
|
||||
is_checkpoint: bool,
|
||||
filename_cache: Optional[dict[str, list[dict[str, Any]]]] = None,
|
||||
recipe_base_model: Optional[str] = None,
|
||||
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
|
||||
"""Match a recipe entry against local models across three levels.
|
||||
"""Match a recipe entry against local models across four levels.
|
||||
|
||||
L1 looks the stored hash up in the type-blind local hash cache; L2
|
||||
falls back to the version index via ``modelVersionId`` or ``id``; L3
|
||||
resolves 12-char hashes through the computed AutoV3 cache. Matched
|
||||
items are type-verified against the entry kind before being returned.
|
||||
resolves 12-char hashes through the computed AutoV3 cache; L4
|
||||
(conservative) falls back to the file name when a filename cache is
|
||||
provided. Matched items are type-verified against the entry kind
|
||||
before being returned.
|
||||
|
||||
Returns:
|
||||
Tuple of (matched item, match level) where level is "L1", "L2" or
|
||||
"L3" — or ``(None, None)`` when no usable match exists. A missing
|
||||
local match is an expected outcome (the model may simply not be
|
||||
present locally), not an error.
|
||||
Tuple of (matched item, match level) where level is "L1", "L2",
|
||||
"L3" or "L4" — or ``(None, None)`` when no usable match exists. A
|
||||
missing local match is an expected outcome (the model may simply
|
||||
not be present locally), not an error.
|
||||
"""
|
||||
entry_hash = (entry.get("hash") or "").lower()
|
||||
|
||||
@@ -279,33 +452,20 @@ class RecipeScanner:
|
||||
item = autov3_cache.get(entry_hash)
|
||||
level = "L3" if item is not None else None
|
||||
|
||||
if item is None and filename_cache is not None:
|
||||
item, level = self._match_rematch_entry_filename(
|
||||
entry,
|
||||
recipe_base_model,
|
||||
filename_cache,
|
||||
is_checkpoint=is_checkpoint,
|
||||
)
|
||||
level = "L4" if item is not None else None
|
||||
|
||||
if item is None:
|
||||
return (None, None)
|
||||
|
||||
# Type gate: the L1 cache merges lora and checkpoint items and is
|
||||
# type-blind, so a match must be verified against the entry kind.
|
||||
sub_type = (item.get("sub_type") or "").lower()
|
||||
if sub_type:
|
||||
valid = (
|
||||
VALID_CHECKPOINT_SUB_TYPES if is_checkpoint else VALID_LORA_TYPES
|
||||
)
|
||||
if sub_type not in valid:
|
||||
return (None, None)
|
||||
else:
|
||||
civitai_type = (
|
||||
(item.get("civitai") or {}).get("model", {}) or {}
|
||||
).get("type", "")
|
||||
if civitai_type:
|
||||
normalized = civitai_type.lower()
|
||||
if is_checkpoint:
|
||||
normalized = _CHECKPOINT_MODEL_TYPE_ALIASES.get(
|
||||
normalized, normalized
|
||||
)
|
||||
valid = VALID_CHECKPOINT_SUB_TYPES
|
||||
else:
|
||||
valid = VALID_LORA_TYPES
|
||||
if normalized not in valid:
|
||||
return (None, None)
|
||||
if not self._is_type_compatible(item, is_checkpoint=is_checkpoint):
|
||||
return (None, None)
|
||||
|
||||
return (item, level)
|
||||
|
||||
@@ -617,10 +777,11 @@ class RecipeScanner:
|
||||
async def _rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
|
||||
"""Rematch a single recipe's deleted lora/checkpoint entries locally.
|
||||
|
||||
Match snapshots (local hash cache + computed autov3 cache) are built
|
||||
BEFORE acquiring the mutation lock — both are read-only snapshots and
|
||||
the version-cached hash dict would otherwise rebuild mid-run if a scan
|
||||
bumps a scanner's cache_version while we hold the lock.
|
||||
Match snapshots (local hash cache, computed autov3 cache, filename
|
||||
cache) are built BEFORE acquiring the mutation lock — all three are
|
||||
read-only snapshots and the version-cached dicts would otherwise
|
||||
rebuild mid-run if a scan bumps a scanner's cache_version while we
|
||||
hold the lock.
|
||||
|
||||
Args:
|
||||
recipe_id: ID of the recipe to rematch
|
||||
@@ -636,6 +797,7 @@ class RecipeScanner:
|
||||
"""
|
||||
local_cache = await self.build_local_hash_cache()
|
||||
autov3_cache = await self._build_rematch_autov3_cache()
|
||||
filename_cache = await self._build_local_filename_cache()
|
||||
|
||||
async with self._mutation_lock:
|
||||
# Get raw recipe from cache directly to avoid formatted fields
|
||||
@@ -649,7 +811,7 @@ class RecipeScanner:
|
||||
|
||||
try:
|
||||
rematched, _errors, details = await self._rematch_single_recipe(
|
||||
recipe, local_cache, autov3_cache
|
||||
recipe, local_cache, autov3_cache, filename_cache
|
||||
)
|
||||
except RecipePersistenceError as exc:
|
||||
logger.error(
|
||||
@@ -706,6 +868,7 @@ class RecipeScanner:
|
||||
recipe: Dict[str, Any],
|
||||
local_cache: dict[str, dict[str, Any]],
|
||||
autov3_cache: dict[str, dict[str, Any]],
|
||||
filename_cache: Optional[dict[str, list[dict[str, Any]]]] = None,
|
||||
) -> Tuple[int, int, Dict[str, Any]]:
|
||||
"""Rematch a single recipe's lora/checkpoint entries against local models.
|
||||
|
||||
@@ -719,6 +882,8 @@ class RecipeScanner:
|
||||
recipe: The recipe dictionary to rematch (modified in-place)
|
||||
local_cache: L1 hash cache snapshot (build_local_hash_cache)
|
||||
autov3_cache: L3 computed-autov3 cache snapshot
|
||||
filename_cache: L4 filename cache snapshot, or None to disable
|
||||
the filename fallback
|
||||
|
||||
Returns:
|
||||
Tuple of (rematched_entries, errors, details). The errors element
|
||||
@@ -744,7 +909,13 @@ class RecipeScanner:
|
||||
if not self._is_rematch_candidate(entry):
|
||||
continue
|
||||
item, level = await self._match_rematch_entry_with_level(
|
||||
entry, local_cache, autov3_cache, is_checkpoint=False
|
||||
entry,
|
||||
local_cache,
|
||||
autov3_cache,
|
||||
is_checkpoint=False,
|
||||
filename_cache=filename_cache,
|
||||
recipe_base_model=entry.get("baseModel")
|
||||
or recipe.get("base_model"),
|
||||
)
|
||||
if item is None:
|
||||
details["unresolved"].append(
|
||||
@@ -770,7 +941,13 @@ class RecipeScanner:
|
||||
if isinstance(checkpoint, dict):
|
||||
if self._is_rematch_candidate(checkpoint):
|
||||
item, level = await self._match_rematch_entry_with_level(
|
||||
checkpoint, local_cache, autov3_cache, is_checkpoint=True
|
||||
checkpoint,
|
||||
local_cache,
|
||||
autov3_cache,
|
||||
is_checkpoint=True,
|
||||
filename_cache=filename_cache,
|
||||
recipe_base_model=checkpoint.get("baseModel")
|
||||
or recipe.get("base_model"),
|
||||
)
|
||||
if item is None:
|
||||
details["unresolved"].append(
|
||||
@@ -832,12 +1009,13 @@ class RecipeScanner:
|
||||
) -> Dict[str, Any]:
|
||||
"""Rematch every recipe's deleted lora/checkpoint entries locally.
|
||||
|
||||
Match snapshots (local hash cache + computed autov3 cache) are built
|
||||
ONCE before the loop — both are read-only and the version-cached hash
|
||||
dict would otherwise rebuild mid-run if a scan bumps a scanner's
|
||||
cache_version while the mutation lock is held. ``_schedule_resort`` is
|
||||
called exactly once after the loop: it spawns an asyncio task per call,
|
||||
so per-recipe calls would race one resort task per recipe.
|
||||
Match snapshots (local hash cache, computed autov3 cache, filename
|
||||
cache) are built ONCE before the loop — all three are read-only and
|
||||
the version-cached dicts would otherwise rebuild mid-run if a scan
|
||||
bumps a scanner's cache_version while the mutation lock is held.
|
||||
``_schedule_resort`` is called exactly once after the loop: it spawns
|
||||
an asyncio task per call, so per-recipe calls would race one resort
|
||||
task per recipe.
|
||||
|
||||
Args:
|
||||
progress_callback: Optional callback for progress updates
|
||||
@@ -858,6 +1036,7 @@ class RecipeScanner:
|
||||
# Match snapshots built once and shared by every recipe in the loop.
|
||||
local_cache = await self.build_local_hash_cache()
|
||||
autov3_cache = await self._build_rematch_autov3_cache()
|
||||
filename_cache = await self._build_local_filename_cache()
|
||||
|
||||
async with self._mutation_lock:
|
||||
cache = await self.get_cached_data()
|
||||
@@ -925,7 +1104,7 @@ class RecipeScanner:
|
||||
)
|
||||
|
||||
rematched, _errors, details = await self._rematch_single_recipe(
|
||||
recipe, local_cache, autov3_cache
|
||||
recipe, local_cache, autov3_cache, filename_cache
|
||||
)
|
||||
if rematched > 0:
|
||||
matched_recipes += 1
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||
version = "1.2.0"
|
||||
version = "1.2.1"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
|
||||
@@ -911,6 +911,93 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Recipes layout segmented control with visual previews */
|
||||
.layout-options-control {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.layout-options {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.layout-option {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.layout-option:hover,
|
||||
.layout-option:focus-visible {
|
||||
border-color: var(--lora-accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.layout-option.active {
|
||||
border-color: var(--lora-accent);
|
||||
background-color: rgba(from var(--lora-accent) r g b / 0.12);
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.layout-option-label {
|
||||
font-size: 0.85em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.layout-option-preview {
|
||||
width: 72px;
|
||||
height: 44px;
|
||||
padding: 4px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.layout-option-preview span {
|
||||
background: currentColor;
|
||||
opacity: 0.4;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.layout-preview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.layout-preview-masonry {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.layout-preview-masonry span {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.layout-preview-masonry span:nth-child(2) {
|
||||
height: 60%;
|
||||
}
|
||||
|
||||
.layout-preview-masonry span:nth-child(3) {
|
||||
height: 80%;
|
||||
}
|
||||
|
||||
/* Range Slider Control */
|
||||
.range-control {
|
||||
width: 100%;
|
||||
|
||||
@@ -168,6 +168,34 @@
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Recipes layout toggle (grid / masonry) — segmented control in the toolbar */
|
||||
.layout-toggle-group {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.layout-toggle-group .layout-toggle-btn {
|
||||
min-width: 36px;
|
||||
width: 36px;
|
||||
padding: 4px 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.layout-toggle-group .layout-toggle-btn:first-child {
|
||||
border-radius: var(--border-radius-xs) 0 0 var(--border-radius-xs);
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.layout-toggle-group .layout-toggle-btn:last-child {
|
||||
border-radius: 0 var(--border-radius-xs) var(--border-radius-xs) 0;
|
||||
}
|
||||
|
||||
.layout-toggle-group .layout-toggle-btn:hover,
|
||||
.layout-toggle-group .layout-toggle-btn:focus-visible {
|
||||
transform: none;
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
/* Keyboard shortcut indicator styling */
|
||||
.shortcut-key {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -1017,11 +1017,8 @@ export class SettingsManager {
|
||||
displayDensitySelect.value = state.global.settings.display_density || 'default';
|
||||
}
|
||||
|
||||
// Set recipes layout setting
|
||||
const recipesLayoutSelect = document.getElementById('recipesLayout');
|
||||
if (recipesLayoutSelect) {
|
||||
recipesLayoutSelect.value = state.global.settings.recipes_layout || 'grid';
|
||||
}
|
||||
// Set recipes layout setting (segmented control active state)
|
||||
this.updateRecipesLayoutControls(state.global.settings.recipes_layout || 'grid');
|
||||
|
||||
// Set card info display setting
|
||||
const cardInfoDisplaySelect = document.getElementById('cardInfoDisplay');
|
||||
@@ -2294,19 +2291,18 @@ export class SettingsManager {
|
||||
: element.value;
|
||||
|
||||
try {
|
||||
// Recipes layout has its own shared entry point used by both the
|
||||
// settings modal segmented control and the recipes page toolbar toggle
|
||||
if (settingKey === 'recipes_layout') {
|
||||
return this.saveRecipesLayout(element.value);
|
||||
}
|
||||
|
||||
// Update frontend state with mapped keys
|
||||
await this.saveSetting(settingKey, value);
|
||||
|
||||
// Apply frontend settings immediately
|
||||
this.applyFrontendSettings();
|
||||
|
||||
// Dispatch layout change event; the scroller instance is about to be rebuilt,
|
||||
// so calculateLayout() must NOT run on the old instance here
|
||||
if (settingKey === 'recipes_layout') {
|
||||
window.dispatchEvent(new CustomEvent('lm:recipes-layout-changed'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Recalculate layout when display density changes
|
||||
if (settingKey === 'display_density' && state.virtualScroller) {
|
||||
state.virtualScroller.calculateLayout();
|
||||
@@ -2334,6 +2330,47 @@ export class SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the recipes page layout (grid | masonry) and rebuild the scroller.
|
||||
* Shared entry point for the settings modal segmented control and the
|
||||
* recipes page toolbar toggle; both stay in sync via
|
||||
* updateRecipesLayoutControls().
|
||||
*/
|
||||
async saveRecipesLayout(value) {
|
||||
if (value !== 'grid' && value !== 'masonry') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update frontend state with mapped keys
|
||||
await this.saveSetting('recipes_layout', value);
|
||||
|
||||
// Apply frontend settings immediately
|
||||
this.applyFrontendSettings();
|
||||
|
||||
// Dispatch layout change event; the scroller instance is about to be rebuilt,
|
||||
// so calculateLayout() must NOT run on the old instance here
|
||||
window.dispatchEvent(new CustomEvent('lm:recipes-layout-changed'));
|
||||
|
||||
this.updateRecipesLayoutControls(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the active state of every recipes layout control
|
||||
* (settings modal segmented control and recipes page toolbar toggle).
|
||||
*/
|
||||
updateRecipesLayoutControls(value) {
|
||||
document.querySelectorAll('[data-recipes-layout]').forEach((control) => {
|
||||
const active = control.dataset.recipesLayout === value;
|
||||
control.classList.toggle('active', active);
|
||||
if (control.hasAttribute('aria-pressed')) {
|
||||
control.setAttribute('aria-pressed', String(active));
|
||||
}
|
||||
if (control.hasAttribute('aria-checked')) {
|
||||
control.setAttribute('aria-checked', String(active));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async saveRangeSetting(elementId, displayId, settingKey) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) return;
|
||||
|
||||
@@ -282,6 +282,30 @@ class RecipeManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Layout toggle (grid / masonry) — shares the recipes_layout setting with
|
||||
// the settings modal segmented control; active states stay in sync via
|
||||
// settingsManager.updateRecipesLayoutControls() after each save
|
||||
const layoutToggleBtns = document.querySelectorAll('.layout-toggle-btn');
|
||||
if (layoutToggleBtns.length) {
|
||||
const currentLayout = state.global.settings?.recipes_layout || 'grid';
|
||||
layoutToggleBtns.forEach((btn) => {
|
||||
const isActive = btn.dataset.recipesLayout === currentLayout;
|
||||
btn.classList.toggle('active', isActive);
|
||||
btn.setAttribute('aria-pressed', String(isActive));
|
||||
btn.addEventListener('click', async () => {
|
||||
const layout = btn.dataset.recipesLayout;
|
||||
if ((state.global.settings?.recipes_layout || 'grid') === layout) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await window.settingsManager?.saveRecipesLayout(layout);
|
||||
} catch (error) {
|
||||
console.error('Failed to switch recipes layout:', error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Rebuild the scroller on layout switch; in duplicates mode defer until
|
||||
// exitDuplicateMode re-enables the scroller (direct recreation would dispose
|
||||
// the old instance while initializeVirtualScroll skips duplicates mode)
|
||||
|
||||
@@ -134,6 +134,16 @@
|
||||
</div>
|
||||
|
||||
<div class="controls-right">
|
||||
{% if page_id == 'recipes' %}
|
||||
<div class="control-group layout-toggle-group" role="group" aria-label="{{ t('recipes.controls.layout.title') }}" title="{{ t('recipes.controls.layout.title') }}">
|
||||
<button type="button" class="layout-toggle-btn" data-recipes-layout="grid" aria-pressed="false" title="{{ t('recipes.controls.layout.grid') }}" aria-label="{{ t('recipes.controls.layout.grid') }}">
|
||||
<i class="fas fa-th-large" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button type="button" class="layout-toggle-btn" data-recipes-layout="masonry" aria-pressed="false" title="{{ t('recipes.controls.layout.masonry') }}" aria-label="{{ t('recipes.controls.layout.masonry') }}">
|
||||
<i class="fas fa-columns" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="control-group doctor-control-group">
|
||||
<button id="doctorTriggerBtn" class="doctor-trigger" title="{{ t('doctor.buttonTitle', default='Run diagnostics and common fixes') }}">
|
||||
<i class="fas fa-stethoscope"></i>
|
||||
|
||||
@@ -629,16 +629,22 @@
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="recipesLayout">
|
||||
<label id="recipesLayoutLabel">
|
||||
{{ t('settings.layoutSettings.recipesLayout') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.layoutSettings.recipesLayoutHelp') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control select-control">
|
||||
<select id="recipesLayout" onchange="settingsManager.saveSelectSetting('recipesLayout', 'recipes_layout')">
|
||||
<option value="grid">{{ t('settings.layoutSettings.recipesLayoutOptions.grid') }}</option>
|
||||
<option value="masonry">{{ t('settings.layoutSettings.recipesLayoutOptions.masonry') }}</option>
|
||||
</select>
|
||||
<div class="setting-control layout-options-control">
|
||||
<div id="recipesLayoutOptions" class="layout-options" role="radiogroup" aria-label="{{ t('settings.layoutSettings.recipesLayout') }}" aria-labelledby="recipesLayoutLabel">
|
||||
<button type="button" class="layout-option" data-recipes-layout="grid" onclick="settingsManager.saveRecipesLayout('grid')" role="radio" aria-checked="true">
|
||||
<span class="layout-option-preview layout-preview-grid" aria-hidden="true"><span></span><span></span><span></span><span></span></span>
|
||||
<span class="layout-option-label">{{ t('settings.layoutSettings.recipesLayoutOptions.grid') }}</span>
|
||||
</button>
|
||||
<button type="button" class="layout-option" data-recipes-layout="masonry" onclick="settingsManager.saveRecipesLayout('masonry')" role="radio" aria-checked="false">
|
||||
<span class="layout-option-preview layout-preview-masonry" aria-hidden="true"><span></span><span></span><span></span></span>
|
||||
<span class="layout-option-label">{{ t('settings.layoutSettings.recipesLayoutOptions.masonry') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -530,4 +530,49 @@ describe('SettingsManager recipes layout switch', () => {
|
||||
dispatchSpy.mockRestore();
|
||||
delete state.virtualScroller;
|
||||
});
|
||||
|
||||
it('saveRecipesLayout persists, dispatches the layout event, and syncs controls', async () => {
|
||||
const manager = createManager();
|
||||
|
||||
const gridBtn = document.createElement('button');
|
||||
gridBtn.dataset.recipesLayout = 'grid';
|
||||
gridBtn.setAttribute('aria-pressed', 'false');
|
||||
const masonryBtn = document.createElement('button');
|
||||
masonryBtn.dataset.recipesLayout = 'masonry';
|
||||
masonryBtn.setAttribute('aria-pressed', 'false');
|
||||
masonryBtn.setAttribute('role', 'radio');
|
||||
masonryBtn.setAttribute('aria-checked', 'false');
|
||||
document.body.appendChild(gridBtn);
|
||||
document.body.appendChild(masonryBtn);
|
||||
|
||||
const calculateLayout = vi.fn();
|
||||
state.virtualScroller = { calculateLayout };
|
||||
|
||||
const dispatchSpy = vi.spyOn(window, 'dispatchEvent');
|
||||
|
||||
await manager.saveRecipesLayout('masonry');
|
||||
|
||||
expect(state.global.settings.recipes_layout).toBe('masonry');
|
||||
expect(masonryBtn.classList.contains('active')).toBe(true);
|
||||
expect(masonryBtn.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(masonryBtn.getAttribute('aria-checked')).toBe('true');
|
||||
expect(gridBtn.classList.contains('active')).toBe(false);
|
||||
expect(gridBtn.getAttribute('aria-pressed')).toBe('false');
|
||||
|
||||
const layoutEvent = dispatchSpy.mock.calls
|
||||
.map(([event]) => event)
|
||||
.find(event => event.type === 'lm:recipes-layout-changed');
|
||||
expect(layoutEvent).toBeInstanceOf(CustomEvent);
|
||||
expect(calculateLayout).not.toHaveBeenCalled();
|
||||
expect(showToast).not.toHaveBeenCalled();
|
||||
|
||||
dispatchSpy.mockRestore();
|
||||
delete state.virtualScroller;
|
||||
});
|
||||
|
||||
it('ignores invalid recipes layout values', async () => {
|
||||
const manager = createManager();
|
||||
await manager.saveRecipesLayout('bogus');
|
||||
expect(state.global.settings.recipes_layout).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -163,6 +163,7 @@ describe('RecipeManager', () => {
|
||||
afterEach(() => {
|
||||
delete window.recipeManager;
|
||||
delete window.importManager;
|
||||
delete window.settingsManager;
|
||||
});
|
||||
|
||||
it('initializes page controls, restores filters, and wires sort interactions', async () => {
|
||||
@@ -227,6 +228,38 @@ describe('RecipeManager', () => {
|
||||
expect(initializePageFeaturesMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('wires the layout toggle and reflects the saved recipes layout setting', async () => {
|
||||
const gridBtn = document.createElement('button');
|
||||
gridBtn.className = 'layout-toggle-btn';
|
||||
gridBtn.dataset.recipesLayout = 'grid';
|
||||
gridBtn.setAttribute('aria-pressed', 'false');
|
||||
const masonryBtn = document.createElement('button');
|
||||
masonryBtn.className = 'layout-toggle-btn';
|
||||
masonryBtn.dataset.recipesLayout = 'masonry';
|
||||
masonryBtn.setAttribute('aria-pressed', 'false');
|
||||
document.body.appendChild(gridBtn);
|
||||
document.body.appendChild(masonryBtn);
|
||||
|
||||
const saveRecipesLayoutMock = vi.fn().mockResolvedValue();
|
||||
window.settingsManager = { saveRecipesLayout: saveRecipesLayoutMock };
|
||||
|
||||
const manager = new RecipeManager();
|
||||
await manager.initialize();
|
||||
|
||||
// Initial state follows the saved setting (default grid)
|
||||
expect(gridBtn.classList.contains('active')).toBe(true);
|
||||
expect(gridBtn.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(masonryBtn.classList.contains('active')).toBe(false);
|
||||
|
||||
// Clicking the inactive option saves the new layout
|
||||
masonryBtn.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
expect(saveRecipesLayoutMock).toHaveBeenCalledWith('masonry');
|
||||
|
||||
// Clicking the already-active option is a no-op
|
||||
gridBtn.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
expect(saveRecipesLayoutMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips loading when duplicates mode is active and refreshes otherwise', async () => {
|
||||
const manager = new RecipeManager();
|
||||
|
||||
|
||||
@@ -1613,3 +1613,213 @@ def test_fill_missing_metadata_fills_overwrite_for_muted_node(metadata_registry)
|
||||
assert "ow-1" not in metadata.get(OVERWRITE, {})
|
||||
|
||||
metadata_registry.clear_metadata()
|
||||
|
||||
|
||||
def test_krea_two_stage_sampler_prompt_and_params_collected(
|
||||
metadata_registry, monkeypatch
|
||||
):
|
||||
"""KreaTwoStageSampler should be recognized as the primary sampler and
|
||||
contribute the prompt, canonical sampling params, and final resolution."""
|
||||
prompt_graph = {
|
||||
"encode_pos": {
|
||||
"class_type": "PromptLM",
|
||||
"inputs": {"text": "krea masterpiece", "clip": ["clip", 0]},
|
||||
},
|
||||
"encode_neg": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "low quality", "clip": ["clip", 0]},
|
||||
},
|
||||
"sampler": {
|
||||
"class_type": "KreaTwoStageSampler",
|
||||
"inputs": {
|
||||
"seed": 42,
|
||||
"handoff_percent": 16.67,
|
||||
"stage1_steps": 52,
|
||||
"stage1_cfg": 4.0,
|
||||
"stage1_sampler_name": "euler",
|
||||
"stage1_scheduler": "simple",
|
||||
"stage2_steps": 12,
|
||||
"stage2_cfg": 1.0,
|
||||
"stage2_sampler_name": "euler",
|
||||
"stage2_scheduler": "simple",
|
||||
"final_width": 2048,
|
||||
"final_height": 2048,
|
||||
"upscale_method": "bislerp",
|
||||
"positive": ["encode_pos", 0],
|
||||
"negative": ["encode_neg", 0],
|
||||
"latent_image": {
|
||||
"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
prompt = SimpleNamespace(original_prompt=prompt_graph)
|
||||
|
||||
pos_conditioning = object()
|
||||
neg_conditioning = object()
|
||||
|
||||
monkeypatch.setattr(metadata_processor, "standalone_mode", False)
|
||||
|
||||
metadata_registry.start_collection("krea-two-stage")
|
||||
metadata_registry.set_current_prompt(prompt)
|
||||
|
||||
metadata_registry.record_node_execution(
|
||||
"encode_pos", "PromptLM", {"text": "krea masterpiece"}, None
|
||||
)
|
||||
metadata_registry.update_node_execution(
|
||||
"encode_pos", "PromptLM", [(pos_conditioning, "krea masterpiece")]
|
||||
)
|
||||
metadata_registry.record_node_execution(
|
||||
"encode_neg", "CLIPTextEncode", {"text": "low quality"}, None
|
||||
)
|
||||
metadata_registry.update_node_execution(
|
||||
"encode_neg", "CLIPTextEncode", [(neg_conditioning,)]
|
||||
)
|
||||
metadata_registry.record_node_execution(
|
||||
"sampler",
|
||||
"KreaTwoStageSampler",
|
||||
{
|
||||
"seed": 42,
|
||||
"handoff_percent": 16.67,
|
||||
"stage1_steps": 52,
|
||||
"stage1_cfg": 4.0,
|
||||
"stage1_sampler_name": "euler",
|
||||
"stage1_scheduler": "simple",
|
||||
"stage2_steps": 12,
|
||||
"stage2_cfg": 1.0,
|
||||
"stage2_sampler_name": "euler",
|
||||
"stage2_scheduler": "simple",
|
||||
"final_width": 2048,
|
||||
"final_height": 2048,
|
||||
"upscale_method": "bislerp",
|
||||
"positive": pos_conditioning,
|
||||
"negative": neg_conditioning,
|
||||
"latent_image": {
|
||||
"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))
|
||||
},
|
||||
},
|
||||
None,
|
||||
)
|
||||
|
||||
metadata = metadata_registry.get_metadata("krea-two-stage")
|
||||
|
||||
sampler_data = metadata[SAMPLING]["sampler"]
|
||||
assert sampler_data["is_sampler"] is True
|
||||
parameters = sampler_data["parameters"]
|
||||
assert parameters["seed"] == 42
|
||||
assert parameters["steps"] == 64
|
||||
assert parameters["cfg"] == 4.0
|
||||
assert parameters["sampler_name"] == "euler"
|
||||
assert parameters["scheduler"] == "simple"
|
||||
assert parameters["stage1_steps"] == 52
|
||||
assert parameters["stage2_cfg"] == 1.0
|
||||
|
||||
assert metadata[SIZE]["sampler"] == {
|
||||
"width": 2048,
|
||||
"height": 2048,
|
||||
"node_id": "sampler",
|
||||
}
|
||||
|
||||
prompt_results = MetadataProcessor.match_conditioning_to_prompts(
|
||||
metadata, "sampler"
|
||||
)
|
||||
assert prompt_results["prompt"] == "krea masterpiece"
|
||||
assert prompt_results["negative_prompt"] == "low quality"
|
||||
|
||||
params = MetadataProcessor.extract_generation_params(metadata)
|
||||
assert params["prompt"] == "krea masterpiece"
|
||||
assert params["negative_prompt"] == "low quality"
|
||||
assert params["seed"] == 42
|
||||
assert params["steps"] == 64
|
||||
assert params["cfg_scale"] == 4.0
|
||||
assert params["sampler"] == "euler"
|
||||
assert params["scheduler"] == "simple"
|
||||
assert params["size"] == "2048x2048"
|
||||
|
||||
|
||||
def test_krea_three_stage_sampler_uses_stage1_canonical_fields(metadata_registry):
|
||||
"""KreaThreeStageSampler reuses stage 1 settings for stage 3, so canonical
|
||||
fields map from stage 1 and the total counts both sampling stages."""
|
||||
metadata_registry.start_collection("krea-three-stage")
|
||||
metadata_registry.set_current_prompt(SimpleNamespace(original_prompt={}))
|
||||
|
||||
metadata_registry.record_node_execution(
|
||||
"sampler",
|
||||
"KreaThreeStageSampler",
|
||||
{
|
||||
"seed": 7,
|
||||
"handoff_percent": 16.67,
|
||||
"stage3_handoff_percent": 83.33,
|
||||
"stage1_steps": 52,
|
||||
"stage1_cfg": 4.0,
|
||||
"stage1_sampler_name": "euler",
|
||||
"stage1_scheduler": "simple",
|
||||
"stage2_steps": 12,
|
||||
"stage2_cfg": 1.0,
|
||||
"stage2_sampler_name": "euler",
|
||||
"stage2_scheduler": "simple",
|
||||
"final_width": 1024,
|
||||
"final_height": 2048,
|
||||
"upscale_method": "bislerp",
|
||||
"positive": object(),
|
||||
"negative": object(),
|
||||
"latent_image": {"samples": types.SimpleNamespace(shape=(1, 4, 8, 16))},
|
||||
},
|
||||
None,
|
||||
)
|
||||
|
||||
metadata = metadata_registry.get_metadata("krea-three-stage")
|
||||
|
||||
sampler_data = metadata[SAMPLING]["sampler"]
|
||||
assert sampler_data["is_sampler"] is True
|
||||
parameters = sampler_data["parameters"]
|
||||
assert parameters["seed"] == 7
|
||||
assert parameters["stage3_handoff_percent"] == 83.33
|
||||
assert parameters["steps"] == 64
|
||||
assert parameters["cfg"] == 4.0
|
||||
assert parameters["sampler_name"] == "euler"
|
||||
assert parameters["scheduler"] == "simple"
|
||||
|
||||
# Final resolution takes precedence over the latent dimensions (64x128).
|
||||
assert metadata[SIZE]["sampler"] == {
|
||||
"width": 1024,
|
||||
"height": 2048,
|
||||
"node_id": "sampler",
|
||||
}
|
||||
|
||||
|
||||
def test_krea_dual_resolution_selector_extracts_size_from_outputs(
|
||||
metadata_registry,
|
||||
):
|
||||
"""KreaDualResolutionSelector computes dimensions at runtime, so the base
|
||||
resolution is recorded from its outputs in the update phase."""
|
||||
metadata_registry.start_collection("krea-selector")
|
||||
metadata_registry.set_current_prompt(SimpleNamespace(original_prompt={}))
|
||||
|
||||
metadata_registry.record_node_execution(
|
||||
"selector",
|
||||
"KreaDualResolutionSelector",
|
||||
{
|
||||
"aspect_ratio": "1:1",
|
||||
"base_megapixels": 1.0,
|
||||
"final_megapixels": 2.0,
|
||||
"multiple": 16,
|
||||
"random_seed": 123,
|
||||
},
|
||||
None,
|
||||
return_types=("INT", "INT", "INT", "INT", "INT"),
|
||||
)
|
||||
metadata_registry.update_node_execution(
|
||||
"selector",
|
||||
"KreaDualResolutionSelector",
|
||||
[(1024, 1024, 2048, 2048, 123)],
|
||||
return_types=("INT", "INT", "INT", "INT", "INT"),
|
||||
)
|
||||
|
||||
metadata = metadata_registry.get_metadata("krea-selector")
|
||||
|
||||
assert metadata[SIZE]["selector"] == {
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"node_id": "selector",
|
||||
}
|
||||
|
||||
@@ -1883,9 +1883,10 @@ async def test_is_rematch_candidate_rejects_healthy_entry(tmp_path: Path):
|
||||
assert not scanner._is_rematch_candidate({"hash": "abc", "file_name": "m.safetensors"})
|
||||
|
||||
|
||||
async def test_is_rematch_candidate_rejects_no_identifier(tmp_path: Path):
|
||||
async def test_is_rematch_candidate_file_name_only_is_identifier(tmp_path: Path):
|
||||
scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
|
||||
assert not scanner._is_rematch_candidate({"isDeleted": True, "file_name": "m.safetensors"})
|
||||
# file_name alone is now an identifier (enables the L4 filename fallback)
|
||||
assert scanner._is_rematch_candidate({"isDeleted": True, "file_name": "m.safetensors"})
|
||||
assert not scanner._is_rematch_candidate({"isDeleted": True})
|
||||
|
||||
|
||||
@@ -2220,6 +2221,481 @@ async def test_match_rematch_type_gate_lora_accepts_lora_typed_item(tmp_path: Pa
|
||||
assert matched is not None
|
||||
|
||||
|
||||
# _match_rematch_entry — L4 filename fallback (conservative)
|
||||
|
||||
|
||||
async def test_match_rematch_entry_l4_filename_hit(tmp_path: Path):
|
||||
item = _rematch_item(
|
||||
sha256=("T1" * 32).lower(),
|
||||
sub_type="lora",
|
||||
base_model="SD 1.5",
|
||||
file_name="detail.safetensors",
|
||||
)
|
||||
scanner, lora, _ = _make_rematch_scanner([item], [], tmp_path)
|
||||
filename_cache = await scanner._build_local_filename_cache()
|
||||
|
||||
matched, level = await scanner._match_rematch_entry_with_level(
|
||||
{"file_name": "detail.safetensors", "isDeleted": True},
|
||||
{},
|
||||
{},
|
||||
is_checkpoint=False,
|
||||
filename_cache=filename_cache,
|
||||
recipe_base_model="SD 1.5",
|
||||
)
|
||||
|
||||
assert matched is lora._cache.raw_data[0]
|
||||
assert level == "L4"
|
||||
|
||||
|
||||
async def test_match_rematch_entry_l4_filename_normalized_key(tmp_path: Path):
|
||||
# case, path and extension differences are normalized on both sides
|
||||
item = _rematch_item(
|
||||
sha256=("T2" * 32).lower(),
|
||||
sub_type="lora",
|
||||
base_model="SDXL",
|
||||
file_name="My_Mix.safetensors",
|
||||
)
|
||||
scanner, lora, _ = _make_rematch_scanner([item], [], tmp_path)
|
||||
filename_cache = await scanner._build_local_filename_cache()
|
||||
|
||||
matched, level = await scanner._match_rematch_entry_with_level(
|
||||
{"file_name": "subdir/my_mix", "isDeleted": True},
|
||||
{},
|
||||
{},
|
||||
is_checkpoint=False,
|
||||
filename_cache=filename_cache,
|
||||
recipe_base_model="sdxl",
|
||||
)
|
||||
|
||||
assert matched is lora._cache.raw_data[0]
|
||||
assert level == "L4"
|
||||
|
||||
|
||||
async def test_match_rematch_entry_l4_dotted_stem_no_collision(tmp_path: Path):
|
||||
# "my.mix" (dotted stem) and "my" are distinct names — splitext-style
|
||||
# stripping would collapse both to "my" and bind the wrong model as a
|
||||
# unique candidate.
|
||||
item = _rematch_item(
|
||||
sha256=("T2A" * 32).lower(),
|
||||
sub_type="lora",
|
||||
base_model="SD 1.5",
|
||||
file_name="my.mix",
|
||||
)
|
||||
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
|
||||
filename_cache = await scanner._build_local_filename_cache()
|
||||
|
||||
matched, level = await scanner._match_rematch_entry_with_level(
|
||||
{"file_name": "my", "isDeleted": True},
|
||||
{},
|
||||
{},
|
||||
is_checkpoint=False,
|
||||
filename_cache=filename_cache,
|
||||
recipe_base_model="SD 1.5",
|
||||
)
|
||||
|
||||
assert (matched, level) == (None, None)
|
||||
|
||||
|
||||
async def test_match_rematch_entry_l4_extension_bearing_entry_reconciled(tmp_path: Path):
|
||||
# extension-bearing entry names reconcile with extensionless items
|
||||
item = _rematch_item(
|
||||
sha256=("T2B" * 32).lower(),
|
||||
sub_type="lora",
|
||||
base_model="SD 1.5",
|
||||
file_name="my.mix.v1",
|
||||
)
|
||||
scanner, lora, _ = _make_rematch_scanner([item], [], tmp_path)
|
||||
filename_cache = await scanner._build_local_filename_cache()
|
||||
|
||||
matched, level = await scanner._match_rematch_entry_with_level(
|
||||
{"file_name": "my.mix.v1.safetensors", "isDeleted": True},
|
||||
{},
|
||||
{},
|
||||
is_checkpoint=False,
|
||||
filename_cache=filename_cache,
|
||||
recipe_base_model="SD 1.5",
|
||||
)
|
||||
|
||||
assert matched is lora._cache.raw_data[0]
|
||||
assert level == "L4"
|
||||
|
||||
|
||||
async def test_match_rematch_entry_l4_base_model_mismatch_rejects(tmp_path: Path):
|
||||
item = _rematch_item(
|
||||
sha256=("T3" * 32).lower(),
|
||||
sub_type="lora",
|
||||
base_model="SD 1.5",
|
||||
file_name="detail.safetensors",
|
||||
)
|
||||
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
|
||||
filename_cache = await scanner._build_local_filename_cache()
|
||||
|
||||
matched, level = await scanner._match_rematch_entry_with_level(
|
||||
{"file_name": "detail.safetensors", "isDeleted": True},
|
||||
{},
|
||||
{},
|
||||
is_checkpoint=False,
|
||||
filename_cache=filename_cache,
|
||||
recipe_base_model="SDXL",
|
||||
)
|
||||
|
||||
assert (matched, level) == (None, None)
|
||||
|
||||
|
||||
async def test_match_rematch_entry_l4_recipe_base_model_unknown_rejects(tmp_path: Path):
|
||||
item = _rematch_item(
|
||||
sha256=("T4" * 32).lower(),
|
||||
sub_type="lora",
|
||||
base_model="SD 1.5",
|
||||
file_name="detail.safetensors",
|
||||
)
|
||||
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
|
||||
filename_cache = await scanner._build_local_filename_cache()
|
||||
|
||||
matched, level = await scanner._match_rematch_entry_with_level(
|
||||
{"file_name": "detail.safetensors", "isDeleted": True},
|
||||
{},
|
||||
{},
|
||||
is_checkpoint=False,
|
||||
filename_cache=filename_cache,
|
||||
recipe_base_model="",
|
||||
)
|
||||
|
||||
assert (matched, level) == (None, None)
|
||||
|
||||
|
||||
async def test_match_rematch_entry_l4_item_base_model_unknown_rejects(tmp_path: Path):
|
||||
item = _rematch_item(
|
||||
sha256=("T5" * 32).lower(), sub_type="lora", file_name="detail.safetensors"
|
||||
)
|
||||
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
|
||||
filename_cache = await scanner._build_local_filename_cache()
|
||||
|
||||
matched, level = await scanner._match_rematch_entry_with_level(
|
||||
{"file_name": "detail.safetensors", "isDeleted": True},
|
||||
{},
|
||||
{},
|
||||
is_checkpoint=False,
|
||||
filename_cache=filename_cache,
|
||||
recipe_base_model="SD 1.5",
|
||||
)
|
||||
|
||||
assert (matched, level) == (None, None)
|
||||
|
||||
|
||||
async def test_match_rematch_entry_l4_ambiguous_same_base_model_rejects(tmp_path: Path):
|
||||
items = [
|
||||
_rematch_item(
|
||||
sha256=("T6" * 32).lower(),
|
||||
sub_type="lora",
|
||||
base_model="SD 1.5",
|
||||
file_name="detail.safetensors",
|
||||
),
|
||||
_rematch_item(
|
||||
sha256=("T7" * 32).lower(),
|
||||
sub_type="lora",
|
||||
base_model="SD 1.5",
|
||||
file_name="detail.safetensors",
|
||||
),
|
||||
]
|
||||
scanner, _, _ = _make_rematch_scanner(items, [], tmp_path)
|
||||
filename_cache = await scanner._build_local_filename_cache()
|
||||
|
||||
matched, level = await scanner._match_rematch_entry_with_level(
|
||||
{"file_name": "detail.safetensors", "isDeleted": True},
|
||||
{},
|
||||
{},
|
||||
is_checkpoint=False,
|
||||
filename_cache=filename_cache,
|
||||
recipe_base_model="SD 1.5",
|
||||
)
|
||||
|
||||
assert (matched, level) == (None, None)
|
||||
|
||||
|
||||
async def test_match_rematch_entry_l4_ambiguity_resolved_by_base_model(tmp_path: Path):
|
||||
sdxl_item = _rematch_item(
|
||||
sha256=("T8" * 32).lower(),
|
||||
sub_type="lora",
|
||||
base_model="SDXL",
|
||||
file_name="detail.safetensors",
|
||||
)
|
||||
sd15_item = _rematch_item(
|
||||
sha256=("T9" * 32).lower(),
|
||||
sub_type="lora",
|
||||
base_model="SD 1.5",
|
||||
file_name="detail.safetensors",
|
||||
)
|
||||
scanner, lora, _ = _make_rematch_scanner([sdxl_item, sd15_item], [], tmp_path)
|
||||
filename_cache = await scanner._build_local_filename_cache()
|
||||
|
||||
matched, level = await scanner._match_rematch_entry_with_level(
|
||||
{"file_name": "detail.safetensors", "isDeleted": True},
|
||||
{},
|
||||
{},
|
||||
is_checkpoint=False,
|
||||
filename_cache=filename_cache,
|
||||
recipe_base_model="SDXL",
|
||||
)
|
||||
|
||||
assert matched is lora._cache.raw_data[0]
|
||||
assert level == "L4"
|
||||
|
||||
|
||||
async def test_match_rematch_entry_l4_type_gate_rejects(tmp_path: Path):
|
||||
# a checkpoint-typed item with a matching name must not satisfy a lora entry
|
||||
item = _rematch_item(
|
||||
sha256=("TA" * 32).lower(),
|
||||
sub_type="checkpoint",
|
||||
base_model="SD 1.5",
|
||||
file_name="detail.safetensors",
|
||||
)
|
||||
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
|
||||
filename_cache = await scanner._build_local_filename_cache()
|
||||
|
||||
matched, level = await scanner._match_rematch_entry_with_level(
|
||||
{"file_name": "detail.safetensors", "isDeleted": True},
|
||||
{},
|
||||
{},
|
||||
is_checkpoint=False,
|
||||
filename_cache=filename_cache,
|
||||
recipe_base_model="SD 1.5",
|
||||
)
|
||||
|
||||
assert (matched, level) == (None, None)
|
||||
|
||||
|
||||
async def test_match_rematch_entry_l4_checkpoint_slot_rejects_type_less_candidate(
|
||||
tmp_path: Path,
|
||||
):
|
||||
# lora raw items often carry no sub_type; an unknown-type candidate must
|
||||
# not be bound into a checkpoint slot
|
||||
item = _rematch_item(
|
||||
sha256=("TA1" * 32).lower(),
|
||||
base_model="SD 1.5",
|
||||
file_name="realistic.safetensors",
|
||||
)
|
||||
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
|
||||
filename_cache = await scanner._build_local_filename_cache()
|
||||
|
||||
matched, level = await scanner._match_rematch_entry_with_level(
|
||||
{"file_name": "realistic.safetensors", "isDeleted": True},
|
||||
{},
|
||||
{},
|
||||
is_checkpoint=True,
|
||||
filename_cache=filename_cache,
|
||||
recipe_base_model="SD 1.5",
|
||||
)
|
||||
|
||||
assert (matched, level) == (None, None)
|
||||
|
||||
|
||||
async def test_match_rematch_entry_l4_checkpoint_slot_accepts_typed_candidate(
|
||||
tmp_path: Path,
|
||||
):
|
||||
item = _rematch_item(
|
||||
sha256=("TA2" * 32).lower(),
|
||||
sub_type="checkpoint",
|
||||
base_model="SD 1.5",
|
||||
file_name="realistic.safetensors",
|
||||
)
|
||||
scanner, _, checkpoint = _make_rematch_scanner([], [item], tmp_path)
|
||||
filename_cache = await scanner._build_local_filename_cache()
|
||||
|
||||
matched, level = await scanner._match_rematch_entry_with_level(
|
||||
{"file_name": "realistic.safetensors", "isDeleted": True},
|
||||
{},
|
||||
{},
|
||||
is_checkpoint=True,
|
||||
filename_cache=filename_cache,
|
||||
recipe_base_model="SD 1.5",
|
||||
)
|
||||
|
||||
assert matched is checkpoint._cache.raw_data[0]
|
||||
assert level == "L4"
|
||||
|
||||
|
||||
async def test_match_rematch_entry_l4_lora_slot_accepts_type_less_candidate(tmp_path: Path):
|
||||
# asymmetry: lora slots still accept type-less candidates (the norm for
|
||||
# lora raw items); checkpoint items always carry sub_type, so the type
|
||||
# gate alone protects the reverse direction
|
||||
item = _rematch_item(
|
||||
sha256=("TA3" * 32).lower(), base_model="SD 1.5", file_name="detail.safetensors"
|
||||
)
|
||||
scanner, lora, _ = _make_rematch_scanner([item], [], tmp_path)
|
||||
filename_cache = await scanner._build_local_filename_cache()
|
||||
|
||||
matched, level = await scanner._match_rematch_entry_with_level(
|
||||
{"file_name": "detail.safetensors", "isDeleted": True},
|
||||
{},
|
||||
{},
|
||||
is_checkpoint=False,
|
||||
filename_cache=filename_cache,
|
||||
recipe_base_model="SD 1.5",
|
||||
)
|
||||
|
||||
assert matched is lora._cache.raw_data[0]
|
||||
assert level == "L4"
|
||||
|
||||
|
||||
async def test_rematch_l4_entry_base_model_preferred_over_recipe(tmp_path: Path, monkeypatch):
|
||||
# a Pony lora inside an SD 1.5 recipe matches via its own baseModel
|
||||
item = _rematch_item(
|
||||
sha256=("TB1" * 32).lower(),
|
||||
sub_type="lora",
|
||||
base_model="Pony",
|
||||
file_name="pony.safetensors",
|
||||
)
|
||||
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
|
||||
filename_cache = await scanner._build_local_filename_cache()
|
||||
saved, _ = await _spy_rematch_persistence(scanner, monkeypatch)
|
||||
await _spy_fts(scanner, monkeypatch)
|
||||
|
||||
recipe: Dict[str, Any] = {
|
||||
"id": "r1",
|
||||
"base_model": "SD 1.5",
|
||||
"loras": [
|
||||
{"file_name": "pony.safetensors", "isDeleted": True, "baseModel": "Pony"}
|
||||
],
|
||||
}
|
||||
rematched, _errors, details = await scanner._rematch_single_recipe(
|
||||
recipe, {}, {}, filename_cache
|
||||
)
|
||||
|
||||
assert rematched == 1
|
||||
assert details["matched"][0]["match_level"] == "L4"
|
||||
assert recipe["loras"][0]["hash"] == ("TB1" * 32).lower()
|
||||
assert saved == [recipe]
|
||||
|
||||
|
||||
async def test_rematch_l4_entry_base_model_missing_falls_back_to_recipe(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
# without entry-level baseModel the recipe-level gate governs: a Pony
|
||||
# candidate must not match an SD 1.5 recipe
|
||||
item = _rematch_item(
|
||||
sha256=("TB2" * 32).lower(),
|
||||
sub_type="lora",
|
||||
base_model="Pony",
|
||||
file_name="pony.safetensors",
|
||||
)
|
||||
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
|
||||
filename_cache = await scanner._build_local_filename_cache()
|
||||
await _spy_rematch_persistence(scanner, monkeypatch)
|
||||
await _spy_fts(scanner, monkeypatch)
|
||||
|
||||
recipe: Dict[str, Any] = {
|
||||
"id": "r1",
|
||||
"base_model": "SD 1.5",
|
||||
"loras": [{"file_name": "pony.safetensors", "isDeleted": True}],
|
||||
}
|
||||
rematched, _errors, details = await scanner._rematch_single_recipe(
|
||||
recipe, {}, {}, filename_cache
|
||||
)
|
||||
|
||||
assert rematched == 0
|
||||
assert details["unresolved"] == [{"type": "lora", "entry": "pony.safetensors"}]
|
||||
|
||||
|
||||
async def test_match_rematch_entry_l4_no_filename_hit(tmp_path: Path):
|
||||
item = _rematch_item(
|
||||
sha256=("TB" * 32).lower(),
|
||||
sub_type="lora",
|
||||
base_model="SD 1.5",
|
||||
file_name="other.safetensors",
|
||||
)
|
||||
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
|
||||
filename_cache = await scanner._build_local_filename_cache()
|
||||
|
||||
matched, level = await scanner._match_rematch_entry_with_level(
|
||||
{"file_name": "missing.safetensors", "isDeleted": True},
|
||||
{},
|
||||
{},
|
||||
is_checkpoint=False,
|
||||
filename_cache=filename_cache,
|
||||
recipe_base_model="SD 1.5",
|
||||
)
|
||||
|
||||
assert (matched, level) == (None, None)
|
||||
|
||||
|
||||
async def test_match_rematch_entry_l4_entry_without_file_name_skipped(tmp_path: Path):
|
||||
item = _rematch_item(
|
||||
sha256=("TC" * 32).lower(),
|
||||
sub_type="lora",
|
||||
base_model="SD 1.5",
|
||||
file_name="detail.safetensors",
|
||||
)
|
||||
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
|
||||
filename_cache = await scanner._build_local_filename_cache()
|
||||
|
||||
matched, level = await scanner._match_rematch_entry_with_level(
|
||||
{"isDeleted": True, "hash": ""},
|
||||
{},
|
||||
{},
|
||||
is_checkpoint=False,
|
||||
filename_cache=filename_cache,
|
||||
recipe_base_model="SD 1.5",
|
||||
)
|
||||
|
||||
assert (matched, level) == (None, None)
|
||||
|
||||
|
||||
async def test_match_rematch_entry_l1_wins_over_l4_filename(tmp_path: Path):
|
||||
# a valid stored hash resolves via L1 even when the filename would match
|
||||
sha256 = ("TD" * 32).lower()
|
||||
l1_item = _rematch_item(
|
||||
sha256=sha256, sub_type="lora", base_model="SD 1.5", file_name="l1-item.safetensors"
|
||||
)
|
||||
l4_item = _rematch_item(
|
||||
sha256=("TE" * 32).lower(),
|
||||
sub_type="lora",
|
||||
base_model="SD 1.5",
|
||||
file_name="detail.safetensors",
|
||||
)
|
||||
scanner, lora, _ = _make_rematch_scanner([l1_item, l4_item], [], tmp_path)
|
||||
local_cache = await scanner.build_local_hash_cache()
|
||||
filename_cache = await scanner._build_local_filename_cache()
|
||||
|
||||
matched, level = await scanner._match_rematch_entry_with_level(
|
||||
{"hash": sha256, "file_name": "detail.safetensors", "isDeleted": True},
|
||||
local_cache,
|
||||
{},
|
||||
is_checkpoint=False,
|
||||
filename_cache=filename_cache,
|
||||
recipe_base_model="SD 1.5",
|
||||
)
|
||||
|
||||
assert matched is lora._cache.raw_data[0]
|
||||
assert level == "L1"
|
||||
|
||||
|
||||
# _build_local_filename_cache
|
||||
|
||||
|
||||
async def test_build_local_filename_cache_normalized_keys_sha256_only(tmp_path: Path):
|
||||
lora_items = [
|
||||
_rematch_item(sha256=("TF" * 32).lower(), file_name="Case.Mix.safetensors"),
|
||||
_rematch_item(sha256="", file_name="no-hash.safetensors"), # skipped
|
||||
]
|
||||
checkpoint_items = [
|
||||
_rematch_item(
|
||||
sha256=("TG" * 32).lower(), sub_type="checkpoint", file_name="Base.safetensors"
|
||||
)
|
||||
]
|
||||
scanner, lora, checkpoint = _make_rematch_scanner(
|
||||
lora_items, checkpoint_items, tmp_path
|
||||
)
|
||||
|
||||
result = await scanner._build_local_filename_cache()
|
||||
|
||||
assert set(result) == {"case.mix", "base"}
|
||||
assert len(result["case.mix"]) == 1
|
||||
assert result["case.mix"][0] is lora._cache.raw_data[0]
|
||||
# checkpoint items are indexed too (type-blind cache)
|
||||
assert result["base"][0] is checkpoint._cache.raw_data[0]
|
||||
|
||||
|
||||
# _build_rematch_autov3_cache
|
||||
|
||||
|
||||
@@ -3089,6 +3565,7 @@ async def test_rematch_all_recipes_per_recipe_error_continues_loop(
|
||||
recipe: Dict[str, Any],
|
||||
local_cache: dict[str, Any],
|
||||
autov3_cache: dict[str, Any],
|
||||
filename_cache=None,
|
||||
) -> tuple[int, int, dict[str, Any]]:
|
||||
if recipe.get("id") == "boom":
|
||||
raise RuntimeError("kaboom")
|
||||
@@ -3146,12 +3623,13 @@ async def test_rematch_all_recipes_holds_mutation_lock(tmp_path: Path, monkeypat
|
||||
recipe: Dict[str, Any],
|
||||
local_cache: dict[str, Any],
|
||||
autov3_cache: dict[str, Any],
|
||||
) -> tuple[int, int]:
|
||||
filename_cache=None,
|
||||
) -> tuple[int, int, dict[str, Any]]:
|
||||
nonlocal entered
|
||||
if recipe.get("id") == "r0":
|
||||
entered = True
|
||||
await release.wait()
|
||||
return await original(recipe, local_cache, autov3_cache)
|
||||
return await original(recipe, local_cache, autov3_cache, filename_cache)
|
||||
|
||||
monkeypatch.setattr(scanner, "_rematch_single_recipe", blocking_single)
|
||||
|
||||
@@ -3271,6 +3749,8 @@ async def test_rematch_bulk_generic_exception_continues(tmp_path: Path, monkeypa
|
||||
autov3_cache: dict[str, Any],
|
||||
*,
|
||||
is_checkpoint: bool,
|
||||
filename_cache=None,
|
||||
recipe_base_model=None,
|
||||
) -> Any:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
|
||||
Reference in New Issue
Block a user