重新修改局部重绘的功能
This commit is contained in:
@@ -208,10 +208,34 @@ node/
|
||||
### Backend Selection
|
||||
|
||||
- `backend_mode = auto | sdxl | flux`
|
||||
- `auto` 会根据 capability metadata 自动路由,找不到专业后端时直接报错,不会静默降级到旧的 Simple repair 逻辑
|
||||
- `auto` 现在使用 profile-driven routing:先解析模型 profile,再按 `backend_type` 自动路由
|
||||
- 找不到专业后端时直接报错,不会静默降级到旧的 Simple repair 逻辑
|
||||
- `sdxl` 和 `flux` 会强制指定后端,但仍然会验证当前模型是否兼容
|
||||
|
||||
### Capability Requirements
|
||||
### Profile-Driven Routing
|
||||
|
||||
LLS Simple Checkpoint Loader writes the resolved model profile into runtime metadata.
|
||||
|
||||
- `LLS Pro Image Edit Prepare` routes by `backend_type`
|
||||
- `LLS Pro KSampler Bridge` routes by `sampler_strategy`
|
||||
|
||||
重要 profile 字段:
|
||||
|
||||
- `profile_id`
|
||||
- `backend_type`
|
||||
- `sampler_strategy`
|
||||
|
||||
示例:
|
||||
|
||||
- `sdxl_inpaint` -> `backend_type = sdxl_native`
|
||||
- `flux_edit` -> `backend_type = flux_edit`
|
||||
|
||||
Base profile 不会再模糊地进入 Pro 链:
|
||||
|
||||
- `SDXL` base 仍然保持 base profile,除非显式 override
|
||||
- `FLUX base models should remain on the Simple workflow unless they resolve to flux_edit`
|
||||
|
||||
### Compatibility Metadata
|
||||
|
||||
- `model_role`
|
||||
- `supports_inpaint_native`
|
||||
@@ -223,13 +247,14 @@ node/
|
||||
- `model_info`
|
||||
- `LLS Simple Checkpoint Loader` 写入到对象上的 `_lls_*` 元信息
|
||||
- `utils/model_info.py` 里的 family / name 推断逻辑
|
||||
- profile resolver 对旧 capability tags 的兼容升级
|
||||
|
||||
### Adding new professional edit models
|
||||
|
||||
1. 在 `utils/model_info.py` 里补充或修正角色与 capability 推断规则
|
||||
2. 确认 `LLS Simple Checkpoint Loader` 会把正确的 `_lls_*` capability tags 写到 `model` / `clip` / `vae`
|
||||
3. 优先用 `backend_mode=auto` 验证 capability metadata 是否足以自动识别
|
||||
4. 调试阶段可临时改用 `backend_mode=sdxl` 或 `backend_mode=flux`,快速确认显式路由是否匹配预期
|
||||
1. 在 `model_profiles/` 里补充或修正 profile rule
|
||||
2. 确认 `LLS Simple Checkpoint Loader` 会把正确的 `_lls_*` profile tags 写到 `model` / `clip` / `vae`
|
||||
3. 优先用 `backend_mode=auto` 验证 resolved profile 是否足以自动识别
|
||||
4. 调试阶段可通过 `model_info` 显式覆盖 `profile_id` / `backend_type` / `sampler_strategy`
|
||||
|
||||
### Notes
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,402 @@
|
||||
# LLS Pro Model Profile Routing Design
|
||||
|
||||
## Context
|
||||
|
||||
`LLS-node` now has a working `pro_edit/` feature family with three nodes:
|
||||
|
||||
- `LLS Pro Image Edit Prepare`
|
||||
- `LLS Pro KSampler Bridge`
|
||||
- `LLS Pro Image Edit Finish`
|
||||
|
||||
The current implementation already separates `SDXL` and `FLUX` professional edit backends, but the routing contract is still too inference-heavy:
|
||||
|
||||
- the loader infers a family and lightweight capability tags
|
||||
- `pro_edit` resolves a backend from `family + role + capability flags`
|
||||
- `role` often comes from checkpoint-name keyword guessing
|
||||
|
||||
This works for a narrow set of obvious names such as `inpaint`, `edit`, `fill`, and now `kontext`, but it does not scale well to real-world model collections:
|
||||
|
||||
- many `SD1.5` or `SDXL` derivatives share the same sampling core but differ in edit support
|
||||
- many `FLUX`-family edit models use different naming conventions
|
||||
- capability inference is duplicated across loader, routing, and bridge layers
|
||||
- each node still partially reinterprets the model instead of reading a single authoritative profile
|
||||
|
||||
The result is exactly the current user pain:
|
||||
|
||||
- a model may clearly belong to `FLUX` or `SDXL`, but still fail the Pro workflow because `model_role` was inferred as `base`
|
||||
- onboarding a new model often means adding more filename aliases
|
||||
- routing logic becomes harder to trust as more families and edit variants are added
|
||||
|
||||
## Goals
|
||||
|
||||
- Keep the existing node names and primary ports:
|
||||
- `LLS Pro Image Edit Prepare`
|
||||
- `LLS Pro KSampler Bridge`
|
||||
- `LLS Pro Image Edit Finish`
|
||||
- Preserve existing workflow wiring as much as possible.
|
||||
- Introduce a single explicit internal `ModelProfile` contract that becomes the authoritative source for:
|
||||
- loader behavior
|
||||
- backend routing
|
||||
- sampler routing
|
||||
- Stop treating checkpoint-name guessing as the main routing truth.
|
||||
- Support large model families by adapting once per model type, not once per individual checkpoint.
|
||||
- Keep the current `LLSSimple*` repair chain untouched and separate from the Pro chain.
|
||||
- Keep auto-routing, but make it profile-driven instead of keyword-driven.
|
||||
- Preserve manual override capability for debugging and exceptional models.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
This refactor will not:
|
||||
|
||||
- rename the existing Pro nodes
|
||||
- force users to rebuild current Pro workflows from scratch
|
||||
- replace the current `LLSSimple*` chain with the Pro chain
|
||||
- add every future model backend in this iteration
|
||||
- implement a full external YAML or JSON profile-management UI
|
||||
- promise that arbitrary unknown edit models can work with zero metadata
|
||||
|
||||
## Current Problem Statement
|
||||
|
||||
The current Pro path has three architectural weaknesses:
|
||||
|
||||
### 1. Inference Is Not Centralized
|
||||
|
||||
`model_loader`, `utils/model_info.py`, `pro_edit/backends/registry.py`, and `pro_edit/pro_edit_bridge.py` all participate in deciding what the model is. This creates overlapping heuristics and weak ownership.
|
||||
|
||||
### 2. Capability Flags Are Too Low-Level To Be The Main Contract
|
||||
|
||||
Flags such as:
|
||||
|
||||
- `supports_inpaint_native`
|
||||
- `supports_image_edit_native`
|
||||
- `preferred_edit_backend`
|
||||
|
||||
are useful, but they are not enough to fully describe execution behavior. They do not explicitly tell the pipeline:
|
||||
|
||||
- which backend implementation to use
|
||||
- which sampler strategy to apply
|
||||
- which loader assumptions are valid
|
||||
|
||||
### 3. Model Family Is Not The Same As Execution Strategy
|
||||
|
||||
A family such as `SDXL` or `FLUX_DEV` only tells part of the story.
|
||||
|
||||
Examples:
|
||||
|
||||
- `SDXL base` and `SDXL inpaint` can share core diffusion family defaults but should not share the same professional edit backend eligibility.
|
||||
- `FLUX base` and `FLUX Kontext/edit` can share the same broad family while requiring different Pro-routing behavior.
|
||||
- `SD1.5` belongs in the same unified profile system, but most `SD1.5` models should still route away from the current Pro backend set and remain on Simple workflows.
|
||||
|
||||
## Chosen Architecture
|
||||
|
||||
Introduce an explicit `ModelProfile` layer and make it the single authoritative routing contract.
|
||||
|
||||
The new execution model is:
|
||||
|
||||
1. The loader resolves a `ModelProfile`.
|
||||
2. The loader writes the resolved profile into `_lls_*` metadata on `model`, `clip`, and `vae`.
|
||||
3. `LLS Pro Image Edit Prepare` reads the resolved profile and dispatches by `backend_type`.
|
||||
4. `LLS Pro KSampler Bridge` reads the same resolved profile and dispatches by `sampler_strategy`.
|
||||
5. `LLS Pro Image Edit Finish` continues to composite primarily by `edit_scope`, but can also read `backend_type` for backend-specific finish behavior later.
|
||||
|
||||
Checkpoint-name inference remains as a fallback source of evidence, but not as the primary truth source once a profile has been resolved.
|
||||
|
||||
## Model Profile Contract
|
||||
|
||||
Create a unified internal profile structure with these fields:
|
||||
|
||||
- `profile_id`
|
||||
- `family`
|
||||
- `role`
|
||||
- `backend_type`
|
||||
- `sampler_strategy`
|
||||
- `loader_strategy`
|
||||
- `supports_inpaint_native`
|
||||
- `supports_image_edit_native`
|
||||
- `preferred_edit_backend`
|
||||
|
||||
### Field Meanings
|
||||
|
||||
- `profile_id`
|
||||
- stable identifier for the resolved profile, such as `sdxl_base`, `sdxl_inpaint`, `flux_edit`
|
||||
- `family`
|
||||
- broad model family, for example `SD1.5`, `SDXL`, `FLUX_DEV`
|
||||
- `role`
|
||||
- semantic model role, such as `base`, `inpaint`, `edit`, `fill`
|
||||
- `backend_type`
|
||||
- Pro backend routing target, for example `none`, `sdxl_native`, `flux_edit`
|
||||
- `sampler_strategy`
|
||||
- bridge-time sampling strategy, for example `standard_k`, `flux_guided`
|
||||
- `loader_strategy`
|
||||
- loader-time resource strategy, for example `sd15_checkpoint`, `sdxl_checkpoint`, `flux_split_or_bundle`
|
||||
- `supports_inpaint_native`
|
||||
- whether the model can consume native inpaint-style conditioning
|
||||
- `supports_image_edit_native`
|
||||
- whether the model can consume native image-edit-style conditioning
|
||||
- `preferred_edit_backend`
|
||||
- compatibility hint for UI, migration, and legacy metadata interop
|
||||
|
||||
## Built-In Profiles In This Iteration
|
||||
|
||||
The first refactor will ship with built-in profiles for the currently supported scope:
|
||||
|
||||
- `sd15_base`
|
||||
- `sdxl_base`
|
||||
- `sdxl_inpaint`
|
||||
- `sdxl_edit`
|
||||
- `flux_base`
|
||||
- `flux_edit`
|
||||
|
||||
### Intended Behavior
|
||||
|
||||
- `sd15_base`
|
||||
- stays outside the current Pro backend set
|
||||
- keeps using normal/simple workflows unless future SD1.5 native edit support is added
|
||||
- `sdxl_base`
|
||||
- does not enter the current Pro backend set by default
|
||||
- `sdxl_inpaint`
|
||||
- maps to `backend_type = sdxl_native`
|
||||
- maps to `sampler_strategy = standard_k`
|
||||
- `sdxl_edit`
|
||||
- maps to `backend_type = sdxl_native`
|
||||
- maps to `sampler_strategy = standard_k`
|
||||
- `flux_base`
|
||||
- does not enter the current Pro backend set by default
|
||||
- `flux_edit`
|
||||
- maps to `backend_type = flux_edit`
|
||||
- maps to `sampler_strategy = flux_guided`
|
||||
|
||||
This structure ensures that many model variants can share one profile instead of requiring one-off code changes per checkpoint.
|
||||
|
||||
## Profile Resolution Order
|
||||
|
||||
Profile resolution must be deterministic and centralized.
|
||||
|
||||
Resolution order:
|
||||
|
||||
1. explicit `model_info` overrides
|
||||
2. existing `_lls_*` profile metadata already attached to the runtime objects
|
||||
3. built-in registry rules using:
|
||||
- family
|
||||
- known naming patterns
|
||||
- capability hints
|
||||
4. fallback base profile for the detected family
|
||||
|
||||
### Manual Override Behavior
|
||||
|
||||
Manual override remains supported through `model_info`, not through new required ports.
|
||||
|
||||
The following keys should be accepted when present in `model_info`:
|
||||
|
||||
- `profile_id`
|
||||
- `family`
|
||||
- `role`
|
||||
- `backend_type`
|
||||
- `sampler_strategy`
|
||||
- `loader_strategy`
|
||||
- `supports_inpaint_native`
|
||||
- `supports_image_edit_native`
|
||||
- `preferred_edit_backend`
|
||||
|
||||
This keeps the current node surface stable while still allowing explicit correction when auto-resolution is wrong.
|
||||
|
||||
## File Structure Changes
|
||||
|
||||
Add a new package:
|
||||
|
||||
- `model_profiles/__init__.py`
|
||||
- `model_profiles/base.py`
|
||||
- `model_profiles/registry.py`
|
||||
- `model_profiles/rules.py`
|
||||
|
||||
### Responsibilities
|
||||
|
||||
- `model_profiles/base.py`
|
||||
- define the profile record contract and normalization helpers
|
||||
- `model_profiles/rules.py`
|
||||
- define built-in family and pattern matching rules
|
||||
- `model_profiles/registry.py`
|
||||
- resolve the final `ModelProfile` from runtime inputs and overrides
|
||||
- `model_profiles/__init__.py`
|
||||
- export the public profile-resolution helpers
|
||||
|
||||
## Existing File Changes
|
||||
|
||||
### `utils/model_info.py`
|
||||
|
||||
Keep this file as the low-level family and metadata helper layer, but reduce its authority:
|
||||
|
||||
- keep family inference helpers
|
||||
- keep JSON-like parsing helpers
|
||||
- keep backwards-compatible accessors
|
||||
- stop treating capability inference as the final routing truth
|
||||
- add profile-aware wrappers where needed
|
||||
|
||||
This file becomes a supporting utility for profile resolution instead of the main owner of routing semantics.
|
||||
|
||||
### `model_loader/nodes.py`
|
||||
|
||||
`LLSSimpleCheckpointLoader` becomes the authoritative profile stamping point.
|
||||
|
||||
It must:
|
||||
|
||||
- resolve family as it does today
|
||||
- ask the new profile registry for the final `ModelProfile`
|
||||
- tag `model`, `clip`, and `vae` with:
|
||||
- `_lls_profile_id`
|
||||
- `_lls_backend_type`
|
||||
- `_lls_sampler_strategy`
|
||||
- `_lls_loader_strategy`
|
||||
- `_lls_model_role`
|
||||
- `_lls_supports_inpaint_native`
|
||||
- `_lls_supports_image_edit_native`
|
||||
- `_lls_preferred_edit_backend`
|
||||
|
||||
The loader remains family-aware for text encoder and VAE resolution, but those behaviors should now align with explicit `loader_strategy` values instead of only raw family checks.
|
||||
|
||||
### `pro_edit/backends/registry.py`
|
||||
|
||||
This file must stop doing broad capability inference.
|
||||
|
||||
Its new responsibility is:
|
||||
|
||||
- read resolved profile data
|
||||
- validate `backend_mode` overrides against `backend_type`
|
||||
- route to the correct backend implementation
|
||||
|
||||
New routing logic should be driven by `backend_type`, not by repeated filename or role guessing.
|
||||
|
||||
### `pro_edit/pro_edit_prepare.py`
|
||||
|
||||
This node should:
|
||||
|
||||
- resolve the effective profile
|
||||
- reject profiles whose `backend_type` is `none`
|
||||
- dispatch strictly by `backend_type`
|
||||
- keep current ports and return values
|
||||
|
||||
### `pro_edit/pro_edit_bridge.py`
|
||||
|
||||
This node should:
|
||||
|
||||
- resolve the same effective profile
|
||||
- dispatch strictly by `sampler_strategy`
|
||||
- keep current ports and return values
|
||||
- continue using the shared low-level `_common_ksampler` where valid
|
||||
- keep FLUX-specific guidance handling behind the `flux_guided` strategy instead of ad-hoc family checks
|
||||
|
||||
### `pro_edit/pro_edit_finish.py`
|
||||
|
||||
This node can remain primarily `edit_scope`-based in this iteration, but it should:
|
||||
|
||||
- normalize and preserve `backend_type` in `edit_info`
|
||||
- leave explicit extension points for future backend-specific finish behavior
|
||||
|
||||
## Runtime Data Flow
|
||||
|
||||
### Happy Path
|
||||
|
||||
1. User loads a model through `LLS Simple Checkpoint Loader`.
|
||||
2. Loader resolves family and final profile.
|
||||
3. Loader writes profile metadata to `model`, `clip`, and `vae`.
|
||||
4. `LLS Pro Image Edit Prepare` reads profile metadata and chooses the backend via `backend_type`.
|
||||
5. Backend-specific prepare logic builds native edit conditioning.
|
||||
6. `LLS Pro KSampler Bridge` reads the same profile and applies the right `sampler_strategy`.
|
||||
7. `LLS Pro Image Edit Finish` composites the generated output according to `edit_scope`.
|
||||
|
||||
### Override Path
|
||||
|
||||
1. Auto profile resolution produces an incorrect result or a generic base profile.
|
||||
2. User supplies `model_info` override fields.
|
||||
3. Profile registry merges those overrides into the final profile.
|
||||
4. Pro nodes use the overridden explicit profile instead of fallback inference.
|
||||
|
||||
## Simple vs Pro Separation
|
||||
|
||||
The separation must become stricter, not weaker.
|
||||
|
||||
- `Simple` remains the compatibility-first path for broad masked resampling.
|
||||
- `Pro` remains the native local edit path for true edit/inpaint-capable profiles.
|
||||
|
||||
The Pro chain should not silently downgrade to the Simple chain when a profile is `base` or unsupported. It should fail with a clear error that explains:
|
||||
|
||||
- the detected family
|
||||
- the detected profile
|
||||
- the missing backend eligibility
|
||||
|
||||
This keeps workflow behavior predictable.
|
||||
|
||||
## Error Handling
|
||||
|
||||
The new profile-driven path should improve the error messages.
|
||||
|
||||
### Unsupported Profile For Pro Chain
|
||||
|
||||
If `backend_type = none`, raise an error such as:
|
||||
|
||||
> `[LLS] Pro image edit is not available for profile '<profile_id>' (family '<family>', role '<role>'). Use a supported native edit/inpaint profile or provide an explicit override.`
|
||||
|
||||
### Invalid Manual Override
|
||||
|
||||
If the user forces `backend_mode = flux` but the resolved profile maps to `sdxl_native`, raise a clear mismatch error instead of trying to proceed.
|
||||
|
||||
### Incomplete Override
|
||||
|
||||
If the user provides a partial override that produces an invalid combination, the profile registry should normalize or reject it immediately instead of letting the mismatch leak deeper into the pipeline.
|
||||
|
||||
## Compatibility Strategy
|
||||
|
||||
The refactor must preserve current workflow usability:
|
||||
|
||||
- existing node names stay unchanged
|
||||
- existing ports remain valid
|
||||
- `model_info` remains the optional override surface
|
||||
- old `_lls_*` capability tags continue to be read during migration
|
||||
|
||||
During migration, the profile resolver may derive `backend_type` and `sampler_strategy` from old capability fields when a full explicit profile is not yet present.
|
||||
|
||||
This prevents a hard break between the current Pro implementation and the new profile-driven contract.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Add focused tests for the new architecture in these categories:
|
||||
|
||||
### Profile Resolution Tests
|
||||
|
||||
- family base profiles resolve correctly
|
||||
- explicit `model_info` overrides take precedence
|
||||
- legacy capability tags still map to the correct profile during migration
|
||||
- common alias patterns such as `kontext` resolve to the intended edit profile
|
||||
|
||||
### Loader Tagging Tests
|
||||
|
||||
- loader writes the new `_lls_profile_*` metadata
|
||||
- loader keeps writing the existing compatibility fields
|
||||
|
||||
### Backend Routing Tests
|
||||
|
||||
- `backend_type = sdxl_native` routes to the SDXL backend
|
||||
- `backend_type = flux_edit` routes to the FLUX backend
|
||||
- `backend_type = none` fails clearly in Pro prepare
|
||||
- forced backend mismatches are rejected clearly
|
||||
|
||||
### Sampler Strategy Tests
|
||||
|
||||
- `sampler_strategy = standard_k` uses the shared standard bridge path
|
||||
- `sampler_strategy = flux_guided` injects FLUX guidance behavior
|
||||
|
||||
### Regression Tests
|
||||
|
||||
- current Pro SDXL tests still pass
|
||||
- current Pro FLUX tests still pass
|
||||
- current `LLSSimple*` repair tests still pass
|
||||
|
||||
## Recommended Migration Outcome
|
||||
|
||||
After this refactor:
|
||||
|
||||
- adding a new model should usually mean adding or adjusting a profile rule
|
||||
- adding a truly new model type should mean adding a new profile plus backend or sampler strategy
|
||||
- neither path should require scattering new filename heuristics across loader, registry, and bridge code
|
||||
|
||||
This is the core success criterion for the refactor.
|
||||
+16
-7
@@ -34,9 +34,9 @@ except Exception as exc:
|
||||
else:
|
||||
_CORE_NODES_ERR = None
|
||||
|
||||
from ..model_profiles.registry import resolve_model_profile
|
||||
from ..utils.model_info import (
|
||||
MODEL_FAMILY_CHOICES,
|
||||
build_model_info,
|
||||
canonicalize_family,
|
||||
get_family_defaults,
|
||||
infer_family_from_name,
|
||||
@@ -62,16 +62,25 @@ _FLUX_VAE_PATTERNS = ("ae.safetensors", "ae", "vae")
|
||||
|
||||
|
||||
def _build_capability_tags(model_name: str, family: str) -> dict[str, object]:
|
||||
info = build_model_info(
|
||||
profile = resolve_model_profile(
|
||||
model=None,
|
||||
model_info={
|
||||
"checkpoint_name": model_name,
|
||||
"model_name": model_name,
|
||||
"family": family,
|
||||
},
|
||||
checkpoint_name=model_name,
|
||||
model_name=model_name,
|
||||
family=family,
|
||||
)
|
||||
return {
|
||||
"model_role": info["model_role"],
|
||||
"supports_inpaint_native": info["supports_inpaint_native"],
|
||||
"supports_image_edit_native": info["supports_image_edit_native"],
|
||||
"preferred_edit_backend": info["preferred_edit_backend"],
|
||||
"profile_id": profile["profile_id"],
|
||||
"backend_type": profile["backend_type"],
|
||||
"sampler_strategy": profile["sampler_strategy"],
|
||||
"loader_strategy": profile["loader_strategy"],
|
||||
"model_role": profile["role"],
|
||||
"supports_inpaint_native": profile["supports_inpaint_native"],
|
||||
"supports_image_edit_native": profile["supports_image_edit_native"],
|
||||
"preferred_edit_backend": profile["preferred_edit_backend"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from .base import ModelProfile, build_model_profile, normalize_profile_dict
|
||||
from .registry import resolve_model_profile
|
||||
|
||||
__all__ = [
|
||||
"ModelProfile",
|
||||
"build_model_profile",
|
||||
"normalize_profile_dict",
|
||||
"resolve_model_profile",
|
||||
]
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _normalize_text(value: Any, default: str = "", *, lower: bool = False) -> str:
|
||||
if value is None:
|
||||
return default
|
||||
normalized = str(value).strip()
|
||||
if not normalized:
|
||||
return default
|
||||
return normalized.lower() if lower else normalized
|
||||
|
||||
|
||||
def _coerce_bool(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return False
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in {"", "0", "false", "no", "off"}:
|
||||
return False
|
||||
if lowered in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
return bool(value)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelProfile:
|
||||
profile_id: str
|
||||
family: str
|
||||
role: str
|
||||
backend_type: str
|
||||
sampler_strategy: str
|
||||
loader_strategy: str
|
||||
supports_inpaint_native: bool
|
||||
supports_image_edit_native: bool
|
||||
preferred_edit_backend: str | None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def normalize_profile_dict(value: dict[str, Any]) -> dict[str, Any]:
|
||||
preferred_backend = value.get("preferred_edit_backend")
|
||||
return {
|
||||
"profile_id": _normalize_text(value.get("profile_id"), lower=True),
|
||||
"family": _normalize_text(value.get("family") or value.get("model_family"), "SD1.5"),
|
||||
"role": _normalize_text(value.get("role") or value.get("model_role"), "base", lower=True),
|
||||
"backend_type": _normalize_text(value.get("backend_type"), "none", lower=True),
|
||||
"sampler_strategy": _normalize_text(value.get("sampler_strategy"), "standard_k", lower=True),
|
||||
"loader_strategy": _normalize_text(value.get("loader_strategy"), lower=True),
|
||||
"supports_inpaint_native": _coerce_bool(value.get("supports_inpaint_native", False)),
|
||||
"supports_image_edit_native": _coerce_bool(value.get("supports_image_edit_native", False)),
|
||||
"preferred_edit_backend": (
|
||||
None if preferred_backend in (None, "") else _normalize_text(preferred_backend, lower=True)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def build_model_profile(**kwargs: Any) -> ModelProfile:
|
||||
normalized = normalize_profile_dict(kwargs)
|
||||
return ModelProfile(**normalized)
|
||||
@@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .base import build_model_profile
|
||||
from .rules import match_builtin_profile
|
||||
|
||||
try:
|
||||
from ..utils.model_info import canonicalize_family, get_lls_attr, parse_jsonish_info
|
||||
except ImportError:
|
||||
from utils.model_info import canonicalize_family, get_lls_attr, parse_jsonish_info
|
||||
|
||||
|
||||
def _pick(*values: Any) -> Any:
|
||||
for value in values:
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _derive_role_from_legacy(
|
||||
family: str | None,
|
||||
role: Any,
|
||||
*,
|
||||
supports_inpaint_native: Any,
|
||||
supports_image_edit_native: Any,
|
||||
preferred_edit_backend: Any,
|
||||
) -> str | None:
|
||||
normalized_role = str(role or "").strip().lower()
|
||||
if normalized_role and normalized_role != "base":
|
||||
return normalized_role
|
||||
|
||||
resolved_family = canonicalize_family(family)
|
||||
preferred = str(preferred_edit_backend or "").strip().lower()
|
||||
inpaint_native = supports_inpaint_native not in (None, "", False, 0, "0", "false", "False")
|
||||
image_edit_native = supports_image_edit_native not in (None, "", False, 0, "0", "false", "False")
|
||||
|
||||
if resolved_family.startswith("SDXL"):
|
||||
if image_edit_native:
|
||||
return "edit"
|
||||
if inpaint_native or preferred == "sdxl":
|
||||
return "inpaint"
|
||||
if resolved_family.startswith("FLUX"):
|
||||
if inpaint_native:
|
||||
return "inpaint"
|
||||
if image_edit_native or preferred == "flux":
|
||||
return "edit"
|
||||
return normalized_role or None
|
||||
|
||||
|
||||
def _extract_profile_overrides(model=None, model_info=None, extra_info=None) -> dict[str, object]:
|
||||
raw = parse_jsonish_info(model_info)
|
||||
extra = parse_jsonish_info(extra_info)
|
||||
|
||||
return {
|
||||
"profile_id": _pick(extra.get("profile_id"), raw.get("profile_id"), get_lls_attr(model, "profile_id", None)),
|
||||
"family": _pick(
|
||||
extra.get("family"),
|
||||
extra.get("model_family"),
|
||||
raw.get("family"),
|
||||
raw.get("model_family"),
|
||||
get_lls_attr(model, "family", None),
|
||||
),
|
||||
"role": _pick(
|
||||
extra.get("role"),
|
||||
extra.get("model_role"),
|
||||
raw.get("role"),
|
||||
raw.get("model_role"),
|
||||
get_lls_attr(model, "model_role", None),
|
||||
),
|
||||
"backend_type": _pick(extra.get("backend_type"), raw.get("backend_type"), get_lls_attr(model, "backend_type", None)),
|
||||
"sampler_strategy": _pick(
|
||||
extra.get("sampler_strategy"),
|
||||
raw.get("sampler_strategy"),
|
||||
get_lls_attr(model, "sampler_strategy", None),
|
||||
),
|
||||
"loader_strategy": _pick(extra.get("loader_strategy"), raw.get("loader_strategy"), get_lls_attr(model, "loader_strategy", None)),
|
||||
"supports_inpaint_native": (
|
||||
extra["supports_inpaint_native"] if "supports_inpaint_native" in extra
|
||||
else raw["supports_inpaint_native"] if "supports_inpaint_native" in raw
|
||||
else get_lls_attr(model, "supports_inpaint_native", None)
|
||||
),
|
||||
"supports_image_edit_native": (
|
||||
extra["supports_image_edit_native"] if "supports_image_edit_native" in extra
|
||||
else raw["supports_image_edit_native"] if "supports_image_edit_native" in raw
|
||||
else get_lls_attr(model, "supports_image_edit_native", None)
|
||||
),
|
||||
"preferred_edit_backend": _pick(
|
||||
extra.get("preferred_edit_backend"),
|
||||
raw.get("preferred_edit_backend"),
|
||||
get_lls_attr(model, "preferred_edit_backend", None),
|
||||
),
|
||||
"model_name": _pick(
|
||||
extra.get("checkpoint_name"),
|
||||
extra.get("model_name"),
|
||||
raw.get("checkpoint_name"),
|
||||
raw.get("model_name"),
|
||||
raw.get("ckpt_name"),
|
||||
raw.get("ckpt"),
|
||||
get_lls_attr(model, "checkpoint_name", None),
|
||||
get_lls_attr(model, "model_name", None),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def resolve_model_profile(
|
||||
model=None,
|
||||
model_info=None,
|
||||
extra_info=None,
|
||||
checkpoint_name: str | None = None,
|
||||
family: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
overrides = _extract_profile_overrides(model=model, model_info=model_info, extra_info=extra_info)
|
||||
resolved_family = canonicalize_family(family or overrides.get("family") or "SD1.5")
|
||||
resolved_role = _derive_role_from_legacy(
|
||||
resolved_family,
|
||||
overrides.get("role"),
|
||||
supports_inpaint_native=overrides.get("supports_inpaint_native"),
|
||||
supports_image_edit_native=overrides.get("supports_image_edit_native"),
|
||||
preferred_edit_backend=overrides.get("preferred_edit_backend"),
|
||||
)
|
||||
model_name = str(checkpoint_name or overrides.get("model_name") or "").strip()
|
||||
|
||||
merged = dict(match_builtin_profile(model_name, resolved_family, resolved_role))
|
||||
if overrides.get("profile_id") not in (None, ""):
|
||||
merged["profile_id"] = overrides["profile_id"]
|
||||
if overrides.get("family") not in (None, ""):
|
||||
merged["family"] = canonicalize_family(overrides["family"])
|
||||
if overrides.get("role") not in (None, ""):
|
||||
merged["role"] = str(overrides["role"]).strip().lower()
|
||||
elif resolved_role:
|
||||
merged["role"] = str(resolved_role).strip().lower()
|
||||
if overrides.get("backend_type") not in (None, ""):
|
||||
merged["backend_type"] = overrides["backend_type"]
|
||||
if overrides.get("sampler_strategy") not in (None, ""):
|
||||
merged["sampler_strategy"] = overrides["sampler_strategy"]
|
||||
if overrides.get("loader_strategy") not in (None, ""):
|
||||
merged["loader_strategy"] = overrides["loader_strategy"]
|
||||
if overrides.get("preferred_edit_backend") not in (None, ""):
|
||||
merged["preferred_edit_backend"] = overrides["preferred_edit_backend"]
|
||||
for key in ("supports_inpaint_native", "supports_image_edit_native"):
|
||||
if overrides.get(key) is not None:
|
||||
merged[key] = overrides[key]
|
||||
|
||||
return build_model_profile(**merged).to_dict()
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
try:
|
||||
from ..utils.model_info import (
|
||||
canonicalize_family,
|
||||
infer_family_from_name,
|
||||
infer_model_role_from_name,
|
||||
is_flux_family,
|
||||
is_sdxl_family,
|
||||
)
|
||||
except ImportError:
|
||||
from utils.model_info import (
|
||||
canonicalize_family,
|
||||
infer_family_from_name,
|
||||
infer_model_role_from_name,
|
||||
is_flux_family,
|
||||
is_sdxl_family,
|
||||
)
|
||||
|
||||
|
||||
_ROLE_TO_PROFILE = {
|
||||
("SDXL", "inpaint"): ("sdxl_inpaint", "sdxl_native", "standard_k", "sdxl_checkpoint", True, False, "sdxl"),
|
||||
("SDXL", "edit"): ("sdxl_edit", "sdxl_native", "standard_k", "sdxl_checkpoint", True, True, "sdxl"),
|
||||
("SDXL", "fill"): ("sdxl_edit", "sdxl_native", "standard_k", "sdxl_checkpoint", True, True, "sdxl"),
|
||||
("FLUX_DEV", "inpaint"): ("flux_edit", "flux_edit", "flux_guided", "flux_split_or_bundle", True, True, "flux"),
|
||||
("FLUX_DEV", "edit"): ("flux_edit", "flux_edit", "flux_guided", "flux_split_or_bundle", False, True, "flux"),
|
||||
("FLUX_DEV", "fill"): ("flux_edit", "flux_edit", "flux_guided", "flux_split_or_bundle", False, True, "flux"),
|
||||
("FLUX_SCHNELL", "inpaint"): ("flux_edit", "flux_edit", "flux_guided", "flux_split_or_bundle", True, True, "flux"),
|
||||
("FLUX_SCHNELL", "edit"): ("flux_edit", "flux_edit", "flux_guided", "flux_split_or_bundle", False, True, "flux"),
|
||||
("FLUX_SCHNELL", "fill"): ("flux_edit", "flux_edit", "flux_guided", "flux_split_or_bundle", False, True, "flux"),
|
||||
}
|
||||
|
||||
|
||||
def _base_profile_for_family(family: str) -> dict[str, object]:
|
||||
resolved_family = canonicalize_family(family)
|
||||
if is_sdxl_family(resolved_family):
|
||||
return {
|
||||
"profile_id": "sdxl_base",
|
||||
"family": resolved_family,
|
||||
"role": "base",
|
||||
"backend_type": "none",
|
||||
"sampler_strategy": "standard_k",
|
||||
"loader_strategy": "sdxl_checkpoint",
|
||||
"supports_inpaint_native": False,
|
||||
"supports_image_edit_native": False,
|
||||
"preferred_edit_backend": None,
|
||||
}
|
||||
if is_flux_family(resolved_family):
|
||||
return {
|
||||
"profile_id": "flux_base",
|
||||
"family": resolved_family,
|
||||
"role": "base",
|
||||
"backend_type": "none",
|
||||
"sampler_strategy": "flux_guided",
|
||||
"loader_strategy": "flux_split_or_bundle",
|
||||
"supports_inpaint_native": False,
|
||||
"supports_image_edit_native": False,
|
||||
"preferred_edit_backend": None,
|
||||
}
|
||||
return {
|
||||
"profile_id": "sd15_base",
|
||||
"family": "SD1.5",
|
||||
"role": "base",
|
||||
"backend_type": "none",
|
||||
"sampler_strategy": "standard_k",
|
||||
"loader_strategy": "sd15_checkpoint",
|
||||
"supports_inpaint_native": False,
|
||||
"supports_image_edit_native": False,
|
||||
"preferred_edit_backend": None,
|
||||
}
|
||||
|
||||
|
||||
def match_builtin_profile(model_name: str | None, family: str | None, role: str | None = None) -> dict[str, object]:
|
||||
resolved_family = canonicalize_family(family or infer_family_from_name(model_name, "SD1.5"))
|
||||
resolved_role = str(role or infer_model_role_from_name(model_name, resolved_family) or "base").strip().lower()
|
||||
matched = _ROLE_TO_PROFILE.get((resolved_family, resolved_role))
|
||||
if matched is None:
|
||||
return _base_profile_for_family(resolved_family)
|
||||
profile_id, backend_type, sampler_strategy, loader_strategy, supports_inpaint_native, supports_image_edit_native, preferred_backend = matched
|
||||
return {
|
||||
"profile_id": profile_id,
|
||||
"family": resolved_family,
|
||||
"role": resolved_role,
|
||||
"backend_type": backend_type,
|
||||
"sampler_strategy": sampler_strategy,
|
||||
"loader_strategy": loader_strategy,
|
||||
"supports_inpaint_native": supports_inpaint_native,
|
||||
"supports_image_edit_native": supports_image_edit_native,
|
||||
"preferred_edit_backend": preferred_backend,
|
||||
}
|
||||
@@ -9,12 +9,13 @@ class RoutingResult:
|
||||
backend_name: str
|
||||
routing_reason: str
|
||||
capabilities: dict[str, Any]
|
||||
profile: dict[str, Any]
|
||||
|
||||
|
||||
class ProEditBackend(Protocol):
|
||||
backend_name: str
|
||||
|
||||
def supports(self, capabilities: dict[str, Any]) -> bool:
|
||||
def supports(self, profile: dict[str, Any]) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
def prepare(self, **kwargs) -> dict[str, Any]:
|
||||
@@ -24,10 +25,10 @@ class ProEditBackend(Protocol):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def validate_manual_backend(backend_name: str, backend: ProEditBackend, capabilities: dict[str, Any]) -> None:
|
||||
if backend.supports(capabilities):
|
||||
def validate_manual_backend(backend_name: str, backend: ProEditBackend, profile: dict[str, Any]) -> None:
|
||||
if backend.supports(profile):
|
||||
return
|
||||
family = capabilities.get("model_family") or "UNKNOWN"
|
||||
raise RuntimeError(
|
||||
f"[LLS] Pro edit backend '{backend_name}' is incompatible with model family '{family}'."
|
||||
f"[LLS] Pro edit backend '{backend_name}' is incompatible with profile "
|
||||
f"'{profile.get('profile_id')}' (family '{profile.get('family')}', role '{profile.get('role')}')."
|
||||
)
|
||||
|
||||
@@ -5,15 +5,8 @@ from ..pro_edit_utils import build_native_conditioning_payload, set_conditioning
|
||||
class FluxProEditBackend:
|
||||
backend_name = "flux"
|
||||
|
||||
def supports(self, capabilities):
|
||||
family = str(capabilities.get("model_family") or "")
|
||||
role = str(capabilities.get("model_role") or "")
|
||||
preferred = str(capabilities.get("preferred_edit_backend") or "").strip().lower()
|
||||
return family.startswith("FLUX") and (
|
||||
role in {"inpaint", "edit", "fill"}
|
||||
or bool(capabilities.get("supports_image_edit_native"))
|
||||
or preferred == self.backend_name
|
||||
)
|
||||
def supports(self, profile):
|
||||
return str(profile.get("backend_type") or "").strip().lower() == "flux_edit"
|
||||
|
||||
def prepare(self, *, vae, work_image, work_mask, positive, negative, workspace, routing, **_kwargs):
|
||||
latent, concat_latent_image, concat_mask = build_native_conditioning_payload(
|
||||
|
||||
@@ -3,7 +3,8 @@ from __future__ import annotations
|
||||
import importlib
|
||||
|
||||
from .base import RoutingResult, validate_manual_backend
|
||||
from ...utils.model_info import parse_jsonish_info, resolve_edit_capabilities
|
||||
from ...model_profiles.registry import resolve_model_profile
|
||||
from ...utils.model_info import get_lls_attr, parse_jsonish_info
|
||||
|
||||
|
||||
_BACKENDS = {}
|
||||
@@ -32,58 +33,79 @@ def get_backend(name: str):
|
||||
return backend
|
||||
|
||||
|
||||
def _normalize_capabilities(model=None, model_info=None, edit_info=None):
|
||||
resolved = resolve_edit_capabilities(model=model, model_info=model_info)
|
||||
def _resolve_profile_and_capabilities(model=None, model_info=None, edit_info=None):
|
||||
raw_model_info = parse_jsonish_info(model_info)
|
||||
raw_edit_info = parse_jsonish_info(edit_info)
|
||||
|
||||
if raw_edit_info.get("model_family"):
|
||||
resolved["model_family"] = str(raw_edit_info["model_family"])
|
||||
if raw_edit_info.get("model_name"):
|
||||
resolved["model_name"] = str(raw_edit_info["model_name"])
|
||||
if raw_edit_info.get("model_role"):
|
||||
resolved["model_role"] = str(raw_edit_info["model_role"])
|
||||
if "supports_inpaint_native" in raw_edit_info:
|
||||
resolved["supports_inpaint_native"] = bool(raw_edit_info["supports_inpaint_native"])
|
||||
if "supports_image_edit_native" in raw_edit_info:
|
||||
resolved["supports_image_edit_native"] = bool(raw_edit_info["supports_image_edit_native"])
|
||||
if raw_edit_info.get("preferred_edit_backend"):
|
||||
resolved["preferred_edit_backend"] = str(raw_edit_info["preferred_edit_backend"]).strip().lower()
|
||||
|
||||
return resolved, raw_edit_info
|
||||
merged_extra_info = dict(raw_model_info)
|
||||
merged_extra_info.update(raw_edit_info)
|
||||
profile = resolve_model_profile(
|
||||
model=model,
|
||||
model_info=model_info,
|
||||
extra_info=merged_extra_info,
|
||||
checkpoint_name=str(
|
||||
merged_extra_info.get("checkpoint_name")
|
||||
or merged_extra_info.get("model_name")
|
||||
or get_lls_attr(model, "checkpoint_name", None)
|
||||
or get_lls_attr(model, "model_name", "")
|
||||
or ""
|
||||
),
|
||||
family=(
|
||||
merged_extra_info.get("family")
|
||||
or merged_extra_info.get("model_family")
|
||||
or get_lls_attr(model, "family", None)
|
||||
or None
|
||||
),
|
||||
)
|
||||
capabilities = {
|
||||
"model_family": profile["family"],
|
||||
"model_name": str(
|
||||
merged_extra_info.get("checkpoint_name")
|
||||
or merged_extra_info.get("model_name")
|
||||
or get_lls_attr(model, "checkpoint_name", None)
|
||||
or get_lls_attr(model, "model_name", "")
|
||||
or ""
|
||||
),
|
||||
"model_role": profile["role"],
|
||||
"profile_id": profile["profile_id"],
|
||||
"backend_type": profile["backend_type"],
|
||||
"sampler_strategy": profile["sampler_strategy"],
|
||||
"loader_strategy": profile["loader_strategy"],
|
||||
"supports_inpaint_native": profile["supports_inpaint_native"],
|
||||
"supports_image_edit_native": profile["supports_image_edit_native"],
|
||||
"preferred_edit_backend": profile["preferred_edit_backend"],
|
||||
}
|
||||
return profile, capabilities
|
||||
|
||||
|
||||
def resolve_backend(backend_mode: str, *, model=None, model_info=None, edit_info=None):
|
||||
_ensure_builtin_backends()
|
||||
mode = str(backend_mode or "auto").strip().lower() or "auto"
|
||||
capabilities, raw_edit_info = _normalize_capabilities(
|
||||
profile, capabilities = _resolve_profile_and_capabilities(
|
||||
model=model,
|
||||
model_info=model_info,
|
||||
edit_info=edit_info,
|
||||
)
|
||||
backend_type = str(profile.get("backend_type") or "none").strip().lower()
|
||||
|
||||
if backend_type == "none":
|
||||
raise RuntimeError(
|
||||
f"[LLS] Pro image edit is not available for profile '{profile['profile_id']}' "
|
||||
f"(family '{profile['family']}', role '{profile['role']}'). "
|
||||
"Use a supported native edit/inpaint profile or provide an explicit override."
|
||||
)
|
||||
|
||||
backend_name = {
|
||||
"sdxl_native": "sdxl",
|
||||
"flux_edit": "flux",
|
||||
}.get(backend_type)
|
||||
if backend_name is None:
|
||||
raise RuntimeError(f"[LLS] Unsupported pro edit backend_type '{backend_type}'.")
|
||||
|
||||
if mode != "auto":
|
||||
backend = get_backend(mode)
|
||||
validate_manual_backend(mode, backend, capabilities)
|
||||
return backend, RoutingResult(backend.backend_name, "manual.override", capabilities)
|
||||
validate_manual_backend(mode, backend, profile)
|
||||
return backend, RoutingResult(backend.backend_name, "manual.override", capabilities, profile)
|
||||
|
||||
backend_name = str(raw_edit_info.get("backend_name") or "").strip().lower()
|
||||
if backend_name:
|
||||
backend = get_backend(backend_name)
|
||||
validate_manual_backend(backend_name, backend, capabilities)
|
||||
return backend, RoutingResult(backend.backend_name, "edit_info.backend_name", capabilities)
|
||||
|
||||
preferred = str(capabilities.get("preferred_edit_backend") or "").strip().lower()
|
||||
if preferred:
|
||||
backend = get_backend(preferred)
|
||||
validate_manual_backend(preferred, backend, capabilities)
|
||||
return backend, RoutingResult(backend.backend_name, "model.preferred_edit_backend", capabilities)
|
||||
|
||||
for candidate_name in sorted(_BACKENDS):
|
||||
backend = get_backend(candidate_name)
|
||||
if backend.supports(capabilities):
|
||||
return backend, RoutingResult(backend.backend_name, "model.capability_match", capabilities)
|
||||
|
||||
raise RuntimeError(
|
||||
"[LLS] No professional edit backend matched the current model capability. "
|
||||
"Use an edit-capable SDXL or FLUX model, or provide explicit capability metadata."
|
||||
)
|
||||
backend = get_backend(backend_name)
|
||||
validate_manual_backend(backend_name, backend, profile)
|
||||
return backend, RoutingResult(backend.backend_name, "profile.backend_type", capabilities, profile)
|
||||
|
||||
@@ -5,15 +5,8 @@ from ..pro_edit_utils import build_native_conditioning_payload, set_conditioning
|
||||
class SDXLProEditBackend:
|
||||
backend_name = "sdxl"
|
||||
|
||||
def supports(self, capabilities):
|
||||
family = str(capabilities.get("model_family") or "")
|
||||
role = str(capabilities.get("model_role") or "")
|
||||
preferred = str(capabilities.get("preferred_edit_backend") or "").strip().lower()
|
||||
return family.startswith("SDXL") and (
|
||||
role in {"inpaint", "edit", "fill"}
|
||||
or bool(capabilities.get("supports_inpaint_native"))
|
||||
or preferred == self.backend_name
|
||||
)
|
||||
def supports(self, profile):
|
||||
return str(profile.get("backend_type") or "").strip().lower() == "sdxl_native"
|
||||
|
||||
def prepare(self, *, vae, work_image, work_mask, positive, negative, workspace, routing, **_kwargs):
|
||||
latent, concat_latent_image, concat_mask = build_native_conditioning_payload(
|
||||
|
||||
+28
-10
@@ -20,6 +20,18 @@ from ..utils.model_info import (
|
||||
)
|
||||
|
||||
|
||||
def _apply_sampler_strategy(strategy: str, *, positive, negative, flux_guidance, defaults):
|
||||
normalized = str(strategy or "").strip().lower() or "standard_k"
|
||||
if normalized == "standard_k":
|
||||
return positive, negative, None
|
||||
if normalized == "flux_guided":
|
||||
guidance_value = _normalize_flux_guidance(flux_guidance, defaults.get("default_guidance"))
|
||||
positive = set_conditioning_values(positive, {"guidance": guidance_value})
|
||||
negative = set_conditioning_values(negative, {"guidance": guidance_value})
|
||||
return positive, negative, guidance_value
|
||||
raise RuntimeError(f"[LLS] Unsupported sampler_strategy '{strategy}'.")
|
||||
|
||||
|
||||
class LLSProKSamplerBridge:
|
||||
CATEGORY = "LLS/Image Edit"
|
||||
FUNCTION = "sample"
|
||||
@@ -82,13 +94,14 @@ class LLSProKSamplerBridge:
|
||||
elif model_info is None and normalized_model_family not in {"", "Auto", "auto"}:
|
||||
effective_model_info = {"family": normalized_model_family}
|
||||
|
||||
backend, routing = resolve_backend(
|
||||
_, routing = resolve_backend(
|
||||
backend_mode,
|
||||
model=model,
|
||||
model_info=effective_model_info,
|
||||
edit_info=edit_info,
|
||||
)
|
||||
defaults = get_family_defaults(routing.capabilities["model_family"])
|
||||
profile = routing.profile
|
||||
defaults = get_family_defaults(profile["family"])
|
||||
|
||||
if quality_preset == FAMILY_DEFAULT_PRESET:
|
||||
steps = int(defaults["default_steps"])
|
||||
@@ -97,7 +110,7 @@ class LLSProKSamplerBridge:
|
||||
scheduler = str(defaults["default_scheduler"])
|
||||
denoise = float(defaults["default_denoise"])
|
||||
else:
|
||||
preset = get_sampling_preset({"family": routing.capabilities["model_family"]}, quality_preset)
|
||||
preset = get_sampling_preset({"family": profile["family"]}, quality_preset)
|
||||
if preset is not None:
|
||||
steps = int(preset["steps"])
|
||||
cfg = float(preset["cfg"])
|
||||
@@ -110,11 +123,13 @@ class LLSProKSamplerBridge:
|
||||
if denoise_mode == "auto_from_edit":
|
||||
actual_denoise = float(normalized_edit_info.get("recommended_denoise", actual_denoise))
|
||||
|
||||
guidance_value = None
|
||||
if routing.backend_name == "flux":
|
||||
guidance_value = _normalize_flux_guidance(flux_guidance, defaults.get("default_guidance"))
|
||||
positive = set_conditioning_values(positive, {"guidance": guidance_value})
|
||||
negative = set_conditioning_values(negative, {"guidance": guidance_value})
|
||||
positive, negative, guidance_value = _apply_sampler_strategy(
|
||||
profile["sampler_strategy"],
|
||||
positive=positive,
|
||||
negative=negative,
|
||||
flux_guidance=flux_guidance,
|
||||
defaults=defaults,
|
||||
)
|
||||
|
||||
actual_seed = random.randint(0, 0xFFFFFFFFFFFFFFFF) if int(seed) == -1 else int(seed)
|
||||
result_latent = _common_ksampler(
|
||||
@@ -133,8 +148,11 @@ class LLSProKSamplerBridge:
|
||||
{
|
||||
"backend_name": routing.backend_name,
|
||||
"routing_reason": routing.routing_reason,
|
||||
"family": routing.capabilities["model_family"],
|
||||
"model_role": routing.capabilities["model_role"],
|
||||
"family": profile["family"],
|
||||
"model_role": profile["role"],
|
||||
"profile_id": profile["profile_id"],
|
||||
"backend_type": profile["backend_type"],
|
||||
"sampler_strategy": profile["sampler_strategy"],
|
||||
"seed": actual_seed,
|
||||
"steps": int(steps),
|
||||
"cfg": float(cfg),
|
||||
|
||||
@@ -49,6 +49,12 @@ def normalize_edit_info(edit_info):
|
||||
info = parse_jsonish_info(edit_info)
|
||||
info.setdefault("backend_name", "")
|
||||
info.setdefault("routing_reason", "")
|
||||
info.setdefault("model_family", "SD1.5")
|
||||
info.setdefault("model_role", "base")
|
||||
info.setdefault("profile_id", "")
|
||||
info.setdefault("backend_type", "none")
|
||||
info.setdefault("sampler_strategy", "standard_k")
|
||||
info.setdefault("loader_strategy", "")
|
||||
info.setdefault("edit_scope", "region")
|
||||
info.setdefault("edit_payload_version", "1.0")
|
||||
return info
|
||||
@@ -224,14 +230,19 @@ def build_workspace(
|
||||
|
||||
|
||||
def build_edit_info(workspace: dict[str, Any], routing, *, backend_hints: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
profile = routing.profile
|
||||
info = {
|
||||
"backend_name": routing.backend_name,
|
||||
"routing_reason": routing.routing_reason,
|
||||
"model_family": routing.capabilities["model_family"],
|
||||
"model_role": routing.capabilities["model_role"],
|
||||
"supports_inpaint_native": routing.capabilities["supports_inpaint_native"],
|
||||
"supports_image_edit_native": routing.capabilities["supports_image_edit_native"],
|
||||
"preferred_edit_backend": routing.capabilities["preferred_edit_backend"],
|
||||
"model_family": profile["family"],
|
||||
"model_role": profile["role"],
|
||||
"profile_id": profile["profile_id"],
|
||||
"backend_type": profile["backend_type"],
|
||||
"sampler_strategy": profile["sampler_strategy"],
|
||||
"loader_strategy": profile["loader_strategy"],
|
||||
"supports_inpaint_native": profile["supports_inpaint_native"],
|
||||
"supports_image_edit_native": profile["supports_image_edit_native"],
|
||||
"preferred_edit_backend": profile["preferred_edit_backend"],
|
||||
"edit_scope": workspace["edit_scope"],
|
||||
"original_size": list(workspace["original_size"]),
|
||||
"work_size": list(workspace["work_size"]),
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
try:
|
||||
from .test_pro_edit_helpers import import_plugin_submodule, load_plugin_package
|
||||
except ImportError:
|
||||
from test_pro_edit_helpers import import_plugin_submodule, load_plugin_package
|
||||
|
||||
|
||||
class TestModelProfileLoader(unittest.TestCase):
|
||||
def test_loader_writes_profile_fields_to_outputs(self):
|
||||
plugin = load_plugin_package()
|
||||
loader_module = import_plugin_submodule(plugin, "model_loader.nodes")
|
||||
loader = loader_module.LLSSimpleCheckpointLoader()
|
||||
model = SimpleNamespace()
|
||||
clip = SimpleNamespace()
|
||||
vae = SimpleNamespace()
|
||||
|
||||
with mock.patch.object(loader_module, "folder_paths", object()), \
|
||||
mock.patch.object(loader_module, "comfy_sd", object()), \
|
||||
mock.patch.object(loader_module, "_resolve_model_path", return_value=("diffusion_models", "/fake/demo-flux-kontext-dev.safetensors")), \
|
||||
mock.patch.object(loader_module, "_load_model", return_value=(model, clip, vae)), \
|
||||
mock.patch.object(loader_module, "_resolve_text_encoder", return_value=(clip, "embedded", None, None)), \
|
||||
mock.patch.object(loader_module, "_resolve_vae", return_value=(vae, "embedded", None)):
|
||||
loaded_model, loaded_clip, loaded_vae, text_encoder = loader.load_checkpoint(
|
||||
ckpt_name="demo-flux-kontext-dev.safetensors",
|
||||
model_family="FLUX_DEV",
|
||||
load_mode="simple",
|
||||
vae_source="auto",
|
||||
text_encoder_source="auto",
|
||||
external_vae_name=loader_module.AUTO_PLACEHOLDER,
|
||||
external_text_encoder_1=loader_module.AUTO_PLACEHOLDER,
|
||||
external_text_encoder_2=loader_module.AUTO_PLACEHOLDER,
|
||||
)
|
||||
|
||||
for obj in (loaded_model, loaded_clip, loaded_vae, text_encoder):
|
||||
self.assertEqual(obj._lls_profile_id, "flux_edit")
|
||||
self.assertEqual(obj._lls_backend_type, "flux_edit")
|
||||
self.assertEqual(obj._lls_sampler_strategy, "flux_guided")
|
||||
self.assertEqual(obj._lls_loader_strategy, "flux_split_or_bundle")
|
||||
self.assertEqual(obj._lls_model_role, "edit")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,80 @@
|
||||
import unittest
|
||||
|
||||
try:
|
||||
from .test_pro_edit_helpers import FakeModel, import_plugin_submodule, load_plugin_package
|
||||
except ImportError:
|
||||
from test_pro_edit_helpers import FakeModel, import_plugin_submodule, load_plugin_package
|
||||
|
||||
|
||||
class TestModelProfiles(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.plugin = load_plugin_package()
|
||||
self.registry = import_plugin_submodule(self.plugin, "model_profiles.registry")
|
||||
|
||||
def test_resolves_sdxl_base_profile_from_family(self):
|
||||
profile = self.registry.resolve_model_profile(
|
||||
model=None,
|
||||
model_info={"checkpoint_name": "plain-sdxl-base.safetensors", "family": "SDXL"},
|
||||
)
|
||||
|
||||
self.assertEqual(profile["profile_id"], "sdxl_base")
|
||||
self.assertEqual(profile["backend_type"], "none")
|
||||
self.assertEqual(profile["sampler_strategy"], "standard_k")
|
||||
self.assertEqual(profile["loader_strategy"], "sdxl_checkpoint")
|
||||
|
||||
def test_resolves_flux_kontext_profile_from_name(self):
|
||||
profile = self.registry.resolve_model_profile(
|
||||
model=None,
|
||||
model_info={"checkpoint_name": "demo-flux-kontext-dev.safetensors", "family": "FLUX_DEV"},
|
||||
)
|
||||
|
||||
self.assertEqual(profile["profile_id"], "flux_edit")
|
||||
self.assertEqual(profile["role"], "edit")
|
||||
self.assertEqual(profile["backend_type"], "flux_edit")
|
||||
self.assertEqual(profile["sampler_strategy"], "flux_guided")
|
||||
self.assertEqual(profile["loader_strategy"], "flux_split_or_bundle")
|
||||
|
||||
def test_model_info_override_has_highest_priority(self):
|
||||
profile = self.registry.resolve_model_profile(
|
||||
model=None,
|
||||
model_info={
|
||||
"checkpoint_name": "plain-sdxl-base.safetensors",
|
||||
"family": "SDXL",
|
||||
"profile_id": "sdxl_edit",
|
||||
"role": "edit",
|
||||
"backend_type": "sdxl_native",
|
||||
"sampler_strategy": "standard_k",
|
||||
"supports_inpaint_native": True,
|
||||
"supports_image_edit_native": True,
|
||||
"preferred_edit_backend": "sdxl",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(profile["profile_id"], "sdxl_edit")
|
||||
self.assertEqual(profile["role"], "edit")
|
||||
self.assertEqual(profile["backend_type"], "sdxl_native")
|
||||
self.assertTrue(profile["supports_image_edit_native"])
|
||||
|
||||
def test_legacy_tags_upgrade_to_matching_profile(self):
|
||||
model = FakeModel(
|
||||
family="FLUX_DEV",
|
||||
model_role="edit",
|
||||
supports_inpaint_native=False,
|
||||
supports_image_edit_native=True,
|
||||
preferred_edit_backend="flux",
|
||||
profile_id="",
|
||||
backend_type="",
|
||||
sampler_strategy="",
|
||||
loader_strategy="",
|
||||
model_name="legacy-flux-edit.safetensors",
|
||||
)
|
||||
|
||||
profile = self.registry.resolve_model_profile(model=model, model_info=None)
|
||||
|
||||
self.assertEqual(profile["profile_id"], "flux_edit")
|
||||
self.assertEqual(profile["backend_type"], "flux_edit")
|
||||
self.assertEqual(profile["sampler_strategy"], "flux_guided")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -21,6 +21,10 @@ class TestProEditBridge(unittest.TestCase):
|
||||
supports_inpaint_native=True,
|
||||
supports_image_edit_native=False,
|
||||
preferred_edit_backend="sdxl",
|
||||
profile_id="sdxl_inpaint",
|
||||
backend_type="sdxl_native",
|
||||
sampler_strategy="standard_k",
|
||||
loader_strategy="sdxl_checkpoint",
|
||||
)
|
||||
latent = {"samples": FakeLatentTensor((1, 4, 64, 64)), "source": "pro_edit_prepare_region"}
|
||||
|
||||
@@ -48,6 +52,9 @@ class TestProEditBridge(unittest.TestCase):
|
||||
"supports_inpaint_native": True,
|
||||
"supports_image_edit_native": False,
|
||||
"preferred_edit_backend": "sdxl",
|
||||
"profile_id": "sdxl_inpaint",
|
||||
"backend_type": "sdxl_native",
|
||||
"sampler_strategy": "standard_k",
|
||||
"recommended_denoise": 0.63,
|
||||
},
|
||||
model_info=None,
|
||||
@@ -56,6 +63,8 @@ class TestProEditBridge(unittest.TestCase):
|
||||
sample_info = json.loads(sample_info_json)
|
||||
self.assertEqual(result_latent["source"], "pro_edit_prepare_region")
|
||||
self.assertEqual(sample_info["backend_name"], "sdxl")
|
||||
self.assertEqual(sample_info["profile_id"], "sdxl_inpaint")
|
||||
self.assertEqual(sample_info["sampler_strategy"], "standard_k")
|
||||
self.assertEqual(sample_info["denoise"], 0.63)
|
||||
self.assertEqual(sample_info["denoise_mode"], "auto_from_edit")
|
||||
|
||||
@@ -66,6 +75,10 @@ class TestProEditBridge(unittest.TestCase):
|
||||
supports_inpaint_native=False,
|
||||
supports_image_edit_native=True,
|
||||
preferred_edit_backend="flux",
|
||||
profile_id="flux_edit",
|
||||
backend_type="flux_edit",
|
||||
sampler_strategy="flux_guided",
|
||||
loader_strategy="flux_split_or_bundle",
|
||||
)
|
||||
latent = {"samples": FakeLatentTensor((1, 16, 64, 64)), "source": "pro_edit_prepare_region"}
|
||||
|
||||
@@ -93,6 +106,9 @@ class TestProEditBridge(unittest.TestCase):
|
||||
"supports_inpaint_native": False,
|
||||
"supports_image_edit_native": True,
|
||||
"preferred_edit_backend": "flux",
|
||||
"profile_id": "flux_edit",
|
||||
"backend_type": "flux_edit",
|
||||
"sampler_strategy": "flux_guided",
|
||||
"recommended_denoise": 0.8,
|
||||
},
|
||||
model_info=None,
|
||||
@@ -100,6 +116,8 @@ class TestProEditBridge(unittest.TestCase):
|
||||
|
||||
sample_info = json.loads(sample_info_json)
|
||||
self.assertEqual(sample_info["backend_name"], "flux")
|
||||
self.assertEqual(sample_info["profile_id"], "flux_edit")
|
||||
self.assertEqual(sample_info["sampler_strategy"], "flux_guided")
|
||||
self.assertEqual(sample_info["guidance"], 4.2)
|
||||
|
||||
def test_bridge_manual_backend_mismatch_raises(self):
|
||||
@@ -109,6 +127,10 @@ class TestProEditBridge(unittest.TestCase):
|
||||
supports_inpaint_native=True,
|
||||
supports_image_edit_native=False,
|
||||
preferred_edit_backend="sdxl",
|
||||
profile_id="sdxl_inpaint",
|
||||
backend_type="sdxl_native",
|
||||
sampler_strategy="standard_k",
|
||||
loader_strategy="sdxl_checkpoint",
|
||||
)
|
||||
latent = {"samples": FakeLatentTensor((1, 4, 64, 64)), "source": "pro_edit_prepare_region"}
|
||||
|
||||
@@ -133,6 +155,41 @@ class TestProEditBridge(unittest.TestCase):
|
||||
model_info=None,
|
||||
)
|
||||
|
||||
def test_bridge_rejects_unknown_sampler_strategy(self):
|
||||
model = FakeModel(
|
||||
family="FLUX_DEV",
|
||||
model_role="edit",
|
||||
supports_inpaint_native=False,
|
||||
supports_image_edit_native=True,
|
||||
preferred_edit_backend="flux",
|
||||
profile_id="flux_edit",
|
||||
backend_type="flux_edit",
|
||||
sampler_strategy="mystery_strategy",
|
||||
loader_strategy="flux_split_or_bundle",
|
||||
)
|
||||
latent = {"samples": FakeLatentTensor((1, 16, 64, 64)), "source": "pro_edit_prepare_region"}
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "Unsupported sampler_strategy 'mystery_strategy'"):
|
||||
self.node.sample(
|
||||
model=model,
|
||||
positive=make_conditioning("positive"),
|
||||
negative=make_conditioning("negative"),
|
||||
latent_image=latent,
|
||||
backend_mode="auto",
|
||||
quality_preset="Manual",
|
||||
seed=9,
|
||||
steps=12,
|
||||
cfg=1.0,
|
||||
sampler_name="euler",
|
||||
scheduler="simple",
|
||||
denoise=0.8,
|
||||
denoise_mode="manual",
|
||||
flux_guidance=4.2,
|
||||
model_family="Auto",
|
||||
edit_info=None,
|
||||
model_info=None,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -41,6 +41,38 @@ class TestProEditCapabilities(unittest.TestCase):
|
||||
self.assertTrue(info["supports_image_edit_native"])
|
||||
self.assertEqual(info["preferred_edit_backend"], "flux")
|
||||
|
||||
def test_parse_model_info_infers_flux_kontext_as_edit_capable(self):
|
||||
plugin = load_plugin_package()
|
||||
model_info = import_plugin_submodule(plugin, "utils.model_info")
|
||||
|
||||
info = model_info.parse_model_info(
|
||||
{
|
||||
"checkpoint_name": "demo-flux-kontext-dev.safetensors",
|
||||
"family": "FLUX_DEV",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(info["model_role"], "edit")
|
||||
self.assertFalse(info["supports_inpaint_native"])
|
||||
self.assertTrue(info["supports_image_edit_native"])
|
||||
self.assertEqual(info["preferred_edit_backend"], "flux")
|
||||
|
||||
def test_parse_model_info_exposes_profile_fields(self):
|
||||
plugin = load_plugin_package()
|
||||
model_info = import_plugin_submodule(plugin, "utils.model_info")
|
||||
|
||||
info = model_info.parse_model_info(
|
||||
{
|
||||
"checkpoint_name": "demo-flux-kontext-dev.safetensors",
|
||||
"family": "FLUX_DEV",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(info["profile_id"], "flux_edit")
|
||||
self.assertEqual(info["backend_type"], "flux_edit")
|
||||
self.assertEqual(info["sampler_strategy"], "flux_guided")
|
||||
self.assertEqual(info["loader_strategy"], "flux_split_or_bundle")
|
||||
|
||||
def test_parse_model_info_infers_capabilities_from_ckpt_name_alias(self):
|
||||
plugin = load_plugin_package()
|
||||
model_info = import_plugin_submodule(plugin, "utils.model_info")
|
||||
@@ -144,6 +176,29 @@ class TestProEditCapabilities(unittest.TestCase):
|
||||
self.assertTrue(capabilities["supports_image_edit_native"])
|
||||
self.assertEqual(capabilities["preferred_edit_backend"], "flux")
|
||||
|
||||
def test_resolve_edit_capabilities_reads_profile_tags(self):
|
||||
plugin = load_plugin_package()
|
||||
model_info = import_plugin_submodule(plugin, "utils.model_info")
|
||||
model = FakeModel(
|
||||
family="SDXL",
|
||||
model_role="edit",
|
||||
supports_inpaint_native=True,
|
||||
supports_image_edit_native=True,
|
||||
preferred_edit_backend="sdxl",
|
||||
profile_id="sdxl_edit",
|
||||
backend_type="sdxl_native",
|
||||
sampler_strategy="standard_k",
|
||||
loader_strategy="sdxl_checkpoint",
|
||||
model_name="demo-sdxl-edit.safetensors",
|
||||
)
|
||||
|
||||
capabilities = model_info.resolve_edit_capabilities(model=model, model_info=None)
|
||||
|
||||
self.assertEqual(capabilities["profile_id"], "sdxl_edit")
|
||||
self.assertEqual(capabilities["backend_type"], "sdxl_native")
|
||||
self.assertEqual(capabilities["sampler_strategy"], "standard_k")
|
||||
self.assertEqual(capabilities["loader_strategy"], "sdxl_checkpoint")
|
||||
|
||||
def test_resolve_edit_capabilities_coerces_string_overrides_to_false(self):
|
||||
plugin = load_plugin_package()
|
||||
model_info = import_plugin_submodule(plugin, "utils.model_info")
|
||||
|
||||
@@ -16,6 +16,12 @@ class TestProEditDocs(unittest.TestCase):
|
||||
"Simple = lightweight masked latent resampling",
|
||||
"Pro = true image edit / inpaint pipeline",
|
||||
"backend_mode = auto | sdxl | flux",
|
||||
"profile-driven routing",
|
||||
"profile_id",
|
||||
"backend_type",
|
||||
"sampler_strategy",
|
||||
"LLS Simple Checkpoint Loader writes the resolved model profile",
|
||||
"FLUX base models should remain on the Simple workflow unless they resolve to flux_edit",
|
||||
"Load Image -> Load Mask or LLS Simple Mask Draw -> LLS Simple Checkpoint Loader -> LLS Simple Prompt Encode -> LLS Pro Image Edit Prepare -> LLS Pro KSampler Bridge -> VAE Decode -> LLS Pro Image Edit Finish -> Preview Image",
|
||||
"Adding new professional edit models",
|
||||
):
|
||||
|
||||
@@ -316,6 +316,10 @@ class FakeModel:
|
||||
supports_image_edit_native=False,
|
||||
preferred_edit_backend=None,
|
||||
model_name="demo-model.safetensors",
|
||||
profile_id="",
|
||||
backend_type="",
|
||||
sampler_strategy="",
|
||||
loader_strategy="",
|
||||
):
|
||||
self._lls_family = family
|
||||
self._lls_model_role = model_role
|
||||
@@ -323,6 +327,11 @@ class FakeModel:
|
||||
self._lls_supports_image_edit_native = supports_image_edit_native
|
||||
self._lls_preferred_edit_backend = preferred_edit_backend
|
||||
self._lls_model_name = model_name
|
||||
self._lls_checkpoint_name = model_name
|
||||
self._lls_profile_id = profile_id
|
||||
self._lls_backend_type = backend_type
|
||||
self._lls_sampler_strategy = sampler_strategy
|
||||
self._lls_loader_strategy = loader_strategy
|
||||
|
||||
|
||||
def make_conditioning(label):
|
||||
|
||||
@@ -36,6 +36,10 @@ class TestProEditPrepareFlux(unittest.TestCase):
|
||||
supports_inpaint_native=False,
|
||||
supports_image_edit_native=True,
|
||||
preferred_edit_backend="flux",
|
||||
profile_id="flux_edit",
|
||||
backend_type="flux_edit",
|
||||
sampler_strategy="flux_guided",
|
||||
loader_strategy="flux_split_or_bundle",
|
||||
)
|
||||
|
||||
latent, work_image, work_mask, edit_info, recommended, positive, negative = self.node.prepare(
|
||||
@@ -66,7 +70,11 @@ class TestProEditPrepareFlux(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(edit_info["backend_name"], "flux")
|
||||
self.assertEqual(edit_info["routing_reason"], "profile.backend_type")
|
||||
self.assertEqual(edit_info["model_family"], "FLUX_DEV")
|
||||
self.assertEqual(edit_info["profile_id"], "flux_edit")
|
||||
self.assertEqual(edit_info["backend_type"], "flux_edit")
|
||||
self.assertEqual(edit_info["sampler_strategy"], "flux_guided")
|
||||
self.assertTrue(edit_info["supports_image_edit_native"])
|
||||
self.assertIn("concat_latent_image", positive[0][1])
|
||||
self.assertIn("concat_mask", positive[0][1])
|
||||
@@ -84,6 +92,10 @@ class TestProEditPrepareFlux(unittest.TestCase):
|
||||
supports_inpaint_native=False,
|
||||
supports_image_edit_native=True,
|
||||
preferred_edit_backend="flux",
|
||||
profile_id="flux_edit",
|
||||
backend_type="flux_edit",
|
||||
sampler_strategy="flux_guided",
|
||||
loader_strategy="flux_split_or_bundle",
|
||||
)
|
||||
|
||||
latent, work_image, work_mask, edit_info, recommended, positive, negative = self.node.prepare(
|
||||
@@ -116,6 +128,9 @@ class TestProEditPrepareFlux(unittest.TestCase):
|
||||
self.assertEqual(edit_info["edit_scope"], "canvas")
|
||||
self.assertEqual(edit_info["original_box_in_canvas"], [64, 32, 832, 800])
|
||||
self.assertEqual(edit_info["work_size"], [896, 832])
|
||||
self.assertEqual(edit_info["profile_id"], "flux_edit")
|
||||
self.assertEqual(edit_info["backend_type"], "flux_edit")
|
||||
self.assertEqual(edit_info["sampler_strategy"], "flux_guided")
|
||||
self.assertEqual(positive[0][1]["edit_backend"], "flux")
|
||||
self.assertGreater(recommended, 0.0)
|
||||
|
||||
|
||||
@@ -36,6 +36,10 @@ class TestProEditPrepareSDXL(unittest.TestCase):
|
||||
supports_inpaint_native=True,
|
||||
supports_image_edit_native=False,
|
||||
preferred_edit_backend="sdxl",
|
||||
profile_id="sdxl_inpaint",
|
||||
backend_type="sdxl_native",
|
||||
sampler_strategy="standard_k",
|
||||
loader_strategy="sdxl_checkpoint",
|
||||
)
|
||||
|
||||
latent, work_image, work_mask, edit_info, recommended, positive, negative = self.node.prepare(
|
||||
@@ -66,8 +70,11 @@ class TestProEditPrepareSDXL(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(edit_info["backend_name"], "sdxl")
|
||||
self.assertEqual(edit_info["routing_reason"], "model.preferred_edit_backend")
|
||||
self.assertEqual(edit_info["routing_reason"], "profile.backend_type")
|
||||
self.assertEqual(edit_info["edit_scope"], "region")
|
||||
self.assertEqual(edit_info["profile_id"], "sdxl_inpaint")
|
||||
self.assertEqual(edit_info["backend_type"], "sdxl_native")
|
||||
self.assertEqual(edit_info["sampler_strategy"], "standard_k")
|
||||
self.assertEqual(latent["source"], "pro_edit_prepare_region")
|
||||
self.assertIn("concat_latent_image", positive[0][1])
|
||||
self.assertIn("concat_mask", positive[0][1])
|
||||
@@ -87,6 +94,10 @@ class TestProEditPrepareSDXL(unittest.TestCase):
|
||||
supports_inpaint_native=True,
|
||||
supports_image_edit_native=True,
|
||||
preferred_edit_backend="sdxl",
|
||||
profile_id="sdxl_edit",
|
||||
backend_type="sdxl_native",
|
||||
sampler_strategy="standard_k",
|
||||
loader_strategy="sdxl_checkpoint",
|
||||
)
|
||||
|
||||
latent, work_image, work_mask, edit_info, recommended, positive, negative = self.node.prepare(
|
||||
@@ -120,6 +131,9 @@ class TestProEditPrepareSDXL(unittest.TestCase):
|
||||
self.assertIsInstance(edit_info["crop_box"], list)
|
||||
self.assertEqual(edit_info["work_size"], [work_image.shape[2], work_image.shape[1]])
|
||||
self.assertEqual(edit_info["backend_name"], "sdxl")
|
||||
self.assertEqual(edit_info["profile_id"], "sdxl_edit")
|
||||
self.assertEqual(edit_info["backend_type"], "sdxl_native")
|
||||
self.assertEqual(edit_info["sampler_strategy"], "standard_k")
|
||||
self.assertEqual(positive[0][1]["edit_backend"], "sdxl")
|
||||
self.assertEqual(negative[0][1]["edit_backend"], "sdxl")
|
||||
self.assertGreater(recommended, 0.0)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import unittest
|
||||
|
||||
try:
|
||||
from .test_pro_edit_helpers import FakeMask, FakeModel, FakeTensor, FakeVAE, load_plugin_package, make_conditioning
|
||||
except ImportError:
|
||||
from test_pro_edit_helpers import FakeMask, FakeModel, FakeTensor, FakeVAE, load_plugin_package, make_conditioning
|
||||
|
||||
|
||||
class TestProEditProfilePrepare(unittest.TestCase):
|
||||
def test_prepare_rejects_base_profile(self):
|
||||
plugin = load_plugin_package()
|
||||
node = plugin.NODE_CLASS_MAPPINGS["LLSProImageEditPrepare"]()
|
||||
image = FakeTensor((1, 64, 64, 3), label="image")
|
||||
mask = FakeMask((1, 64, 64), mask_bbox=(8, 8, 32, 32), mask_area_ratio=0.2)
|
||||
vae = FakeVAE(latent_channels=4, downscale_ratio=8)
|
||||
model = FakeModel(
|
||||
family="SDXL",
|
||||
model_role="base",
|
||||
supports_inpaint_native=False,
|
||||
supports_image_edit_native=False,
|
||||
preferred_edit_backend=None,
|
||||
profile_id="sdxl_base",
|
||||
backend_type="none",
|
||||
sampler_strategy="standard_k",
|
||||
loader_strategy="sdxl_checkpoint",
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "Pro image edit is not available for profile 'sdxl_base'"):
|
||||
node.prepare(
|
||||
image=image,
|
||||
mask=mask,
|
||||
vae=vae,
|
||||
positive=make_conditioning("positive"),
|
||||
negative=make_conditioning("negative"),
|
||||
backend_mode="auto",
|
||||
edit_scope="region",
|
||||
mask_grow=0,
|
||||
mask_blur=0.0,
|
||||
mask_threshold=0.5,
|
||||
invert_mask=False,
|
||||
crop_context=32,
|
||||
crop_context_factor=1.0,
|
||||
min_size=128,
|
||||
max_size=512,
|
||||
resize_mode="fit",
|
||||
expand_left=0,
|
||||
expand_right=0,
|
||||
expand_top=0,
|
||||
expand_bottom=0,
|
||||
canvas_fill="edge",
|
||||
auto_recommend="enabled",
|
||||
model=model,
|
||||
model_info=None,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -11,37 +11,48 @@ class TestProEditRegistry(unittest.TestCase):
|
||||
self.plugin = load_plugin_package()
|
||||
self.registry = import_plugin_submodule(self.plugin, "pro_edit.backends.registry")
|
||||
|
||||
def test_auto_routes_sdxl_inpaint_model(self):
|
||||
def test_auto_routes_sdxl_profile_by_backend_type(self):
|
||||
model = FakeModel(
|
||||
family="SDXL",
|
||||
model_role="inpaint",
|
||||
model_role="edit",
|
||||
supports_inpaint_native=True,
|
||||
supports_image_edit_native=False,
|
||||
supports_image_edit_native=True,
|
||||
preferred_edit_backend="sdxl",
|
||||
profile_id="sdxl_edit",
|
||||
backend_type="sdxl_native",
|
||||
sampler_strategy="standard_k",
|
||||
loader_strategy="sdxl_checkpoint",
|
||||
)
|
||||
|
||||
backend, routing = self.registry.resolve_backend("auto", model=model)
|
||||
|
||||
self.assertEqual(backend.backend_name, "sdxl")
|
||||
self.assertEqual(routing.backend_name, "sdxl")
|
||||
self.assertEqual(routing.routing_reason, "model.preferred_edit_backend")
|
||||
self.assertEqual(routing.routing_reason, "profile.backend_type")
|
||||
self.assertEqual(routing.profile["profile_id"], "sdxl_edit")
|
||||
self.assertEqual(routing.profile["backend_type"], "sdxl_native")
|
||||
|
||||
def test_auto_routes_flux_edit_model(self):
|
||||
def test_auto_routes_flux_profile_by_backend_type(self):
|
||||
model = FakeModel(
|
||||
family="FLUX_DEV",
|
||||
model_role="edit",
|
||||
supports_inpaint_native=False,
|
||||
supports_image_edit_native=True,
|
||||
preferred_edit_backend="flux",
|
||||
profile_id="flux_edit",
|
||||
backend_type="flux_edit",
|
||||
sampler_strategy="flux_guided",
|
||||
loader_strategy="flux_split_or_bundle",
|
||||
)
|
||||
|
||||
backend, routing = self.registry.resolve_backend("auto", model=model)
|
||||
|
||||
self.assertEqual(backend.backend_name, "flux")
|
||||
self.assertEqual(routing.routing_reason, "model.preferred_edit_backend")
|
||||
self.assertEqual(routing.routing_reason, "profile.backend_type")
|
||||
self.assertEqual(routing.capabilities["model_family"], "FLUX_DEV")
|
||||
self.assertEqual(routing.profile["profile_id"], "flux_edit")
|
||||
|
||||
def test_auto_reuses_backend_name_from_edit_info(self):
|
||||
def test_auto_upgrades_legacy_edit_info_to_profile_route(self):
|
||||
backend, routing = self.registry.resolve_backend(
|
||||
"auto",
|
||||
model=None,
|
||||
@@ -56,7 +67,8 @@ class TestProEditRegistry(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(backend.backend_name, "sdxl")
|
||||
self.assertEqual(routing.routing_reason, "edit_info.backend_name")
|
||||
self.assertEqual(routing.routing_reason, "profile.backend_type")
|
||||
self.assertEqual(routing.profile["profile_id"], "sdxl_inpaint")
|
||||
|
||||
def test_manual_flux_override_rejects_sdxl_only_model(self):
|
||||
model = FakeModel(
|
||||
@@ -65,21 +77,29 @@ class TestProEditRegistry(unittest.TestCase):
|
||||
supports_inpaint_native=True,
|
||||
supports_image_edit_native=False,
|
||||
preferred_edit_backend="sdxl",
|
||||
profile_id="sdxl_inpaint",
|
||||
backend_type="sdxl_native",
|
||||
sampler_strategy="standard_k",
|
||||
loader_strategy="sdxl_checkpoint",
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "backend 'flux' is incompatible"):
|
||||
with self.assertRaisesRegex(RuntimeError, "backend 'flux' is incompatible with profile"):
|
||||
self.registry.resolve_backend("flux", model=model)
|
||||
|
||||
def test_auto_without_matching_backend_raises_clear_error(self):
|
||||
def test_auto_rejects_base_profile_for_pro_chain(self):
|
||||
model = FakeModel(
|
||||
family="SD1.5",
|
||||
family="SDXL",
|
||||
model_role="base",
|
||||
supports_inpaint_native=False,
|
||||
supports_image_edit_native=False,
|
||||
preferred_edit_backend=None,
|
||||
profile_id="sdxl_base",
|
||||
backend_type="none",
|
||||
sampler_strategy="standard_k",
|
||||
loader_strategy="sdxl_checkpoint",
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "No professional edit backend matched"):
|
||||
with self.assertRaisesRegex(RuntimeError, "Pro image edit is not available for profile 'sdxl_base'"):
|
||||
self.registry.resolve_backend("auto", model=model)
|
||||
|
||||
|
||||
|
||||
+44
-52
@@ -195,6 +195,7 @@ SAMPLING_PRESETS: dict[str, dict[str, dict[str, float | int | None]]] = {
|
||||
_LEGACY_MODEL_INFO_PATTERN = re.compile(r"(\w+)=([^|]+)")
|
||||
_ROLE_KEYWORDS = (
|
||||
("inpaint", "inpaint"),
|
||||
("kontext", "edit"),
|
||||
("img2img", "edit"),
|
||||
("imageedit", "edit"),
|
||||
("image-edit", "edit"),
|
||||
@@ -336,6 +337,14 @@ def infer_edit_capabilities(model_name: str | None, family: str | None = None) -
|
||||
}
|
||||
|
||||
|
||||
def _load_profile_resolver():
|
||||
try:
|
||||
from ..model_profiles.registry import resolve_model_profile
|
||||
except ImportError:
|
||||
from model_profiles.registry import resolve_model_profile
|
||||
return resolve_model_profile
|
||||
|
||||
|
||||
def parse_model_info(model_info: dict[str, Any] | str | None) -> dict[str, Any]:
|
||||
raw = parse_jsonish_info(model_info)
|
||||
raw_model_name = _get_model_name_alias_value(raw)
|
||||
@@ -348,25 +357,26 @@ def parse_model_info(model_info: dict[str, Any] | str | None) -> dict[str, Any]:
|
||||
)
|
||||
)
|
||||
defaults = get_family_defaults(family)
|
||||
capability_defaults = infer_edit_capabilities(
|
||||
raw_model_name,
|
||||
family,
|
||||
resolve_model_profile = _load_profile_resolver()
|
||||
profile = resolve_model_profile(
|
||||
model=None,
|
||||
model_info=raw,
|
||||
checkpoint_name=raw_model_name,
|
||||
family=family,
|
||||
)
|
||||
|
||||
info: dict[str, Any] = {**defaults}
|
||||
info.update(raw)
|
||||
info["family"] = family
|
||||
info["model_role"] = str(raw.get("model_role", capability_defaults["model_role"]))
|
||||
info["supports_inpaint_native"] = _coerce_bool(
|
||||
raw.get("supports_inpaint_native", capability_defaults["supports_inpaint_native"])
|
||||
)
|
||||
info["supports_image_edit_native"] = _coerce_bool(
|
||||
raw.get("supports_image_edit_native", capability_defaults["supports_image_edit_native"])
|
||||
)
|
||||
info["preferred_edit_backend"] = raw.get(
|
||||
"preferred_edit_backend",
|
||||
capability_defaults["preferred_edit_backend"],
|
||||
)
|
||||
info["family"] = profile["family"]
|
||||
info["model_family"] = profile["family"]
|
||||
info["model_role"] = profile["role"]
|
||||
info["profile_id"] = profile["profile_id"]
|
||||
info["backend_type"] = profile["backend_type"]
|
||||
info["sampler_strategy"] = profile["sampler_strategy"]
|
||||
info["loader_strategy"] = profile["loader_strategy"]
|
||||
info["supports_inpaint_native"] = _coerce_bool(profile["supports_inpaint_native"])
|
||||
info["supports_image_edit_native"] = _coerce_bool(profile["supports_image_edit_native"])
|
||||
info["preferred_edit_backend"] = profile["preferred_edit_backend"]
|
||||
info["text_encoder_type"] = raw.get("text_encoder_type", defaults["text_encoder_type"])
|
||||
required_text_encoders = raw.get("required_text_encoders", defaults["required_text_encoders"])
|
||||
if isinstance(required_text_encoders, str):
|
||||
@@ -402,7 +412,6 @@ def parse_model_info(model_info: dict[str, Any] | str | None) -> dict[str, Any]:
|
||||
|
||||
info.setdefault("checkpoint_name", raw_model_name or "")
|
||||
info.setdefault("model_name", info["checkpoint_name"])
|
||||
info["model_family"] = family
|
||||
info.setdefault("vae_source", raw.get("vae_source", "auto"))
|
||||
info.setdefault("text_encoder_source", raw.get("text_encoder_source", "auto"))
|
||||
info.setdefault("load_mode", raw.get("load_mode", "simple"))
|
||||
@@ -516,12 +525,12 @@ def get_lls_attr(obj, name: str, default: Any = None) -> Any:
|
||||
def resolve_edit_capabilities(model=None, model_info: dict[str, Any] | str | None = None) -> dict[str, Any]:
|
||||
raw = parse_jsonish_info(model_info)
|
||||
info = parse_model_info(model_info)
|
||||
raw_model_name = _get_model_name_alias_value(raw)
|
||||
model_name = str(
|
||||
raw_model_name
|
||||
_get_model_name_alias_value(raw)
|
||||
or get_lls_attr(model, "checkpoint_name", None)
|
||||
or get_lls_attr(model, "model_name", "")
|
||||
or info.get("checkpoint_name")
|
||||
or info.get("model_name")
|
||||
or get_lls_attr(model, "model_name", "")
|
||||
or ""
|
||||
)
|
||||
family = canonicalize_family(
|
||||
@@ -532,41 +541,24 @@ def resolve_edit_capabilities(model=None, model_info: dict[str, Any] | str | Non
|
||||
or info.get("model_family")
|
||||
or infer_family_from_model(model)
|
||||
)
|
||||
inferred = infer_edit_capabilities(model_name, family)
|
||||
resolve_model_profile = _load_profile_resolver()
|
||||
profile = resolve_model_profile(
|
||||
model=model,
|
||||
model_info=model_info,
|
||||
checkpoint_name=model_name,
|
||||
family=family,
|
||||
)
|
||||
return {
|
||||
"model_family": family,
|
||||
"model_family": profile["family"],
|
||||
"model_name": model_name,
|
||||
"model_role": str(
|
||||
raw.get("model_role")
|
||||
or get_lls_attr(model, "model_role", None)
|
||||
or info.get("model_role")
|
||||
or inferred["model_role"]
|
||||
),
|
||||
"supports_inpaint_native": _coerce_bool(
|
||||
raw.get(
|
||||
"supports_inpaint_native",
|
||||
get_lls_attr(
|
||||
model,
|
||||
"supports_inpaint_native",
|
||||
info.get("supports_inpaint_native", inferred["supports_inpaint_native"]),
|
||||
),
|
||||
)
|
||||
),
|
||||
"supports_image_edit_native": _coerce_bool(
|
||||
raw.get(
|
||||
"supports_image_edit_native",
|
||||
get_lls_attr(
|
||||
model,
|
||||
"supports_image_edit_native",
|
||||
info.get("supports_image_edit_native", inferred["supports_image_edit_native"]),
|
||||
),
|
||||
)
|
||||
),
|
||||
"preferred_edit_backend": (
|
||||
raw.get("preferred_edit_backend")
|
||||
or get_lls_attr(model, "preferred_edit_backend", inferred["preferred_edit_backend"])
|
||||
or info.get("preferred_edit_backend")
|
||||
),
|
||||
"model_role": profile["role"],
|
||||
"profile_id": profile["profile_id"],
|
||||
"backend_type": profile["backend_type"],
|
||||
"sampler_strategy": profile["sampler_strategy"],
|
||||
"loader_strategy": profile["loader_strategy"],
|
||||
"supports_inpaint_native": _coerce_bool(profile["supports_inpaint_native"]),
|
||||
"supports_image_edit_native": _coerce_bool(profile["supports_image_edit_native"]),
|
||||
"preferred_edit_backend": profile["preferred_edit_backend"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user