单独制作一个K采样器逻辑

This commit is contained in:
Gin3601
2026-05-22 11:27:17 +08:00
parent 6ff64e8948
commit 6962c005d0
20 changed files with 1849 additions and 97 deletions
+831
View File
@@ -0,0 +1,831 @@
{
"id": "b6958508-1473-44fa-bd93-03897b3bb6cb",
"revision": 0,
"last_node_id": 87,
"last_link_id": 166,
"nodes": [
{
"id": 78,
"type": "ShowText|pysssss",
"pos": [
498,
2633
],
"size": [
492.36199951171875,
169.55548095703125
],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{
"label": "text",
"name": "text",
"type": "STRING",
"link": 164
}
],
"outputs": [
{
"label": "text",
"name": "text",
"shape": 6,
"type": "STRING"
}
],
"properties": {
"Node name for S&R": "ShowText|pysssss",
"widget_ue_connectable": {}
},
"widgets_values": []
},
{
"id": 72,
"type": "KSampler",
"pos": [
1780,
1860
],
"size": [
315,
262
],
"flags": {},
"order": 11,
"mode": 0,
"inputs": [
{
"label": "model",
"name": "model",
"type": "MODEL",
"link": 142
},
{
"label": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 129
},
{
"label": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 130
},
{
"label": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 145
}
],
"outputs": [
{
"label": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
137
]
}
],
"properties": {
"Node name for S&R": "KSampler",
"widget_ue_connectable": {}
},
"widgets_values": [
479945352329433,
"randomize",
30,
1,
"euler",
"simple",
1
]
},
{
"id": 75,
"type": "CLIPTextEncode",
"pos": [
1510,
1930
],
"size": [
210.29940795898438,
88
],
"flags": {
"collapsed": false
},
"order": 6,
"mode": 0,
"inputs": [
{
"label": "clip",
"name": "clip",
"type": "CLIP",
"link": 138
}
],
"outputs": [
{
"label": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
139
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode",
"widget_ue_connectable": {}
},
"widgets_values": [
""
]
},
{
"id": 69,
"type": "CLIPTextEncodeFlux",
"pos": [
1500,
1730
],
"size": [
210,
166
],
"flags": {
"collapsed": false
},
"order": 9,
"mode": 0,
"inputs": [
{
"label": "clip",
"name": "clip",
"type": "CLIP",
"link": 144
},
{
"label": "t5xxl",
"name": "t5xxl",
"type": "STRING",
"widget": {
"name": "t5xxl"
},
"link": 165
}
],
"outputs": [
{
"label": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
120
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncodeFlux",
"widget_ue_connectable": {
"t5xxl": true
}
},
"widgets_values": [
"",
"",
3.5
]
},
{
"id": 76,
"type": "DifferentialDiffusion",
"pos": [
1500,
1610
],
"size": [
210,
58
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"label": "model",
"name": "model",
"type": "MODEL",
"link": 141
}
],
"outputs": [
{
"label": "MODEL",
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [
142
]
}
],
"properties": {
"Node name for S&R": "DifferentialDiffusion",
"widget_ue_connectable": {}
},
"widgets_values": [
1
]
},
{
"id": 70,
"type": "InpaintModelConditioning",
"pos": [
1780,
1600
],
"size": [
315,
138
],
"flags": {},
"order": 10,
"mode": 0,
"inputs": [
{
"label": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 120
},
{
"label": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 139
},
{
"label": "vae",
"name": "vae",
"type": "VAE",
"link": 122
},
{
"label": "pixels",
"name": "pixels",
"type": "IMAGE",
"link": 123
},
{
"label": "mask",
"name": "mask",
"type": "MASK",
"link": 124
}
],
"outputs": [
{
"label": "positive",
"name": "positive",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
129
]
},
{
"label": "negative",
"name": "negative",
"type": "CONDITIONING",
"slot_index": 1,
"links": [
130
]
},
{
"label": "latent",
"name": "latent",
"type": "LATENT",
"slot_index": 2,
"links": [
145
]
}
],
"properties": {
"Node name for S&R": "InpaintModelConditioning",
"widget_ue_connectable": {}
},
"widgets_values": [
false
]
},
{
"id": 60,
"type": "Note",
"pos": [
232,
1464
],
"size": [
210,
140
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"text": "",
"widget_ue_connectable": {}
},
"widgets_values": [
"1.上传图片后右键\n2.在遮罩编辑器中打开\n3.涂抹想要重绘的区域\n4.点击cave或者确定\n5.按住右键涂抹可以去掉涂错的地方"
],
"color": "#432",
"bgcolor": "#653"
},
{
"id": 30,
"type": "LoadImage",
"pos": [
510,
1504
],
"size": [
470.2230224609375,
704.8888549804688
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"label": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [
123
]
},
{
"label": "MASK",
"name": "MASK",
"type": "MASK",
"slot_index": 1,
"links": [
124
]
}
],
"properties": {
"Node name for S&R": "LoadImage",
"widget_ue_connectable": {}
},
"widgets_values": [
"clipspace/5c52c8440e713f6489a038f8c25e4f48e3dee18a3945147a28c7631104ea3f9c.png [input]",
"image"
]
},
{
"id": 46,
"type": "DualCLIPLoader",
"pos": [
1130,
1810
],
"size": [
315,
130
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [
{
"label": "CLIP",
"name": "CLIP",
"type": "CLIP",
"slot_index": 0,
"links": [
138,
144
]
}
],
"properties": {
"Node name for S&R": "DualCLIPLoader",
"widget_ue_connectable": {}
},
"widgets_values": [
"clip_l.safetensors",
"t5xxl_fp8_e4m3fn.safetensors",
"flux",
"default"
]
},
{
"id": 54,
"type": "VAELoader",
"pos": [
1130,
2050
],
"size": [
315,
58
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [],
"outputs": [
{
"label": "VAE",
"name": "VAE",
"type": "VAE",
"slot_index": 0,
"links": [
79,
122
]
}
],
"properties": {
"Node name for S&R": "VAELoader",
"widget_ue_connectable": {}
},
"widgets_values": [
"ae.sft"
]
},
{
"id": 45,
"type": "UNETLoader",
"pos": [
1130,
1620
],
"size": [
315,
82
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [],
"outputs": [
{
"label": "MODEL",
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [
141
]
}
],
"properties": {
"Node name for S&R": "UNETLoader",
"widget_ue_connectable": {}
},
"widgets_values": [
"fluxFillFP8_v10.safetensors",
"fp8_e4m3fn"
]
},
{
"id": 53,
"type": "VAEDecode",
"pos": [
1510,
2070
],
"size": [
210,
46
],
"flags": {},
"order": 12,
"mode": 0,
"inputs": [
{
"label": "samples",
"name": "samples",
"type": "LATENT",
"link": 137
},
{
"label": "vae",
"name": "vae",
"type": "VAE",
"link": 79
}
],
"outputs": [
{
"label": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [
166
]
}
],
"properties": {
"Node name for S&R": "VAEDecode",
"widget_ue_connectable": {}
},
"widgets_values": []
},
{
"id": 87,
"type": "SaveImage",
"pos": [
2134,
1526
],
"size": [
459.5748291015625,
661.1332397460938
],
"flags": {},
"order": 13,
"mode": 0,
"inputs": [
{
"label": "images",
"name": "images",
"type": "IMAGE",
"link": 166
}
],
"outputs": [
{
"label": "image_urls",
"name": "image_urls",
"type": "STRING",
"links": null
}
],
"properties": {
"Node name for S&R": "SaveImage",
"widget_ue_connectable": {}
},
"widgets_values": [
"ComfyUI"
]
},
{
"id": 86,
"type": "ArgosTranslateTextNode",
"pos": [
513,
2359
],
"size": [
469.2802429199219,
199.02728271484375
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [],
"outputs": [
{
"label": "text",
"name": "text",
"type": "STRING",
"slot_index": 0,
"links": [
164,
165
]
}
],
"properties": {
"Node name for S&R": "ArgosTranslateTextNode",
"widget_ue_connectable": {}
},
"widgets_values": [
"chinese",
"english",
"手镯"
]
}
],
"links": [
[
79,
54,
0,
53,
1,
"VAE"
],
[
120,
69,
0,
70,
0,
"CONDITIONING"
],
[
122,
54,
0,
70,
2,
"VAE"
],
[
123,
30,
0,
70,
3,
"IMAGE"
],
[
124,
30,
1,
70,
4,
"MASK"
],
[
129,
70,
0,
72,
1,
"CONDITIONING"
],
[
130,
70,
1,
72,
2,
"CONDITIONING"
],
[
137,
72,
0,
53,
0,
"LATENT"
],
[
138,
46,
0,
75,
0,
"CLIP"
],
[
139,
75,
0,
70,
1,
"CONDITIONING"
],
[
141,
45,
0,
76,
0,
"MODEL"
],
[
142,
76,
0,
72,
0,
"MODEL"
],
[
144,
46,
0,
69,
0,
"CLIP"
],
[
145,
70,
2,
72,
3,
"LATENT"
],
[
164,
86,
0,
78,
0,
"STRING"
],
[
165,
86,
0,
69,
1,
"STRING"
],
[
166,
53,
0,
87,
0,
"IMAGE"
]
],
"groups": [
{
"id": 1,
"title": "FLUX",
"bounding": [
1063,
1433,
1577,
796
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 2,
"title": "上传图片并进入遮罩涂抹需要修饰的地方",
"bounding": [
475,
1426,
534,
795
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 3,
"title": "输入中文提示词例如:口红、花环",
"bounding": [
472,
2267,
535,
574
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ue_links": [],
"0246.VERSION": [
0,
0,
4
],
"ds": {
"scale": 0.6588450000000002,
"offset": [
460.77367154024205,
-1317.5880656092918
]
},
"frontendVersion": "1.23.0",
"VHS_latentpreview": false,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true
},
"version": 0.4
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,63 @@
# FLUX Native Repair Routing Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Rebuild `LLSSimpleRepairPrepare` so FLUX local repair can follow the official Fill workflow shape while base models still fall back to generic repair behavior.
**Architecture:** Add a repair backend registry parallel to `pro_edit`, with a native FLUX backend that prepares `InpaintModelConditioning` payloads and marks the sampler to patch the model through `DifferentialDiffusion`. Keep the existing latent-mask / VAE-inpaint preparation as the generic fallback path for base or unsupported profiles.
**Tech Stack:** Python, unittest, existing ComfyUI node classes, model profile routing, fake tensor/mask helpers
---
### Task 1: Lock in the failing tests
**Files:**
- Modify: `tests/test_repair_registration.py`
- Modify: `tests/test_repair_utils.py`
- Modify: `tests/test_repair_prepare.py`
- Create: `tests/test_repair_registry.py`
- [ ] **Step 1: Add schema coverage for optional `model` input and backend metadata**
- [ ] **Step 2: Add routing tests showing FLUX fill/edit profiles choose the native backend and base profiles stay on fallback**
- [ ] **Step 3: Add prepare tests showing native FLUX returns `InpaintModelConditioning`-style latent/conditioning instead of the old fallback latent payload**
- [ ] **Step 4: Run focused repair tests and confirm the new expectations fail for the current implementation**
### Task 2: Add repair backend routing and runtime wrappers
**Files:**
- Create: `repair/backends/base.py`
- Create: `repair/backends/registry.py`
- Create: `repair/backends/generic.py`
- Create: `repair/backends/flux.py`
- Create: `repair/runtime.py`
- [ ] **Step 1: Mirror the `pro_edit` routing/result structure for repair**
- [ ] **Step 2: Wrap ComfyUI `InpaintModelConditioning` and `DifferentialDiffusion` with runtime-safe helpers**
- [ ] **Step 3: Implement the FLUX native prepare path and generic fallback path**
- [ ] **Step 4: Re-run focused routing tests until green**
### Task 3: Integrate repair prepare and sampler
**Files:**
- Modify: `repair/repair_prepare.py`
- Modify: `repair/repair_utils.py`
- Modify: `repair/__init__.py`
- Modify: `sampling/nodes.py`
- [ ] **Step 1: Add optional `model` input to `LLSSimpleRepairPrepare` and resolve the model profile from `model` + `model_info`**
- [ ] **Step 2: Move backend-specific latent/conditioning preparation behind the new registry**
- [ ] **Step 3: Extend `repair_info` with backend/routing/execution metadata**
- [ ] **Step 4: Patch FLUX native models with `DifferentialDiffusion` inside `LLSSimpleKSampler` when `repair_info` requests it**
- [ ] **Step 5: Re-run focused repair prepare + sampler tests**
### Task 4: Update the workflow JSON and verify regressions
**Files:**
- Modify: `测试工作流.json`
- Modify: other files from earlier tasks only if verification exposes gaps
- [ ] **Step 1: Rewire the FLUX repair example workflow to feed `model` into `LLSSimpleRepairPrepare` and keep the prompt text on the active path**
- [ ] **Step 2: Run `python3 -m unittest tests.test_repair_registration tests.test_repair_registry tests.test_repair_prepare tests.test_repair_sampler tests.test_repair_utils -v`**
- [ ] **Step 3: Run `python3 -m compileall __init__.py repair sampling model_profiles pro_edit qwen model_loader`**
- [ ] **Step 4: Inspect the final JSON diff to confirm the workflow matches the intended FLUX repair path**
+1
View File
@@ -0,0 +1 @@
__all__ = []
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Protocol
from ..repair_utils import make_noise_mask
@dataclass(frozen=True)
class RoutingResult:
backend_name: str
routing_reason: str
capabilities: dict[str, Any]
profile: dict[str, Any]
execution_path: str
class RepairBackend(Protocol):
backend_name: str
def supports(self, profile: dict[str, Any]) -> bool:
raise NotImplementedError
def prepare(self, **kwargs) -> dict[str, Any]:
raise NotImplementedError
def validate_backend(backend_name: str, backend: RepairBackend, profile: dict[str, Any]) -> None:
if backend.supports(profile):
return
raise RuntimeError(
f"[LLS] Repair backend '{backend_name}' is incompatible with profile "
f"'{profile.get('profile_id')}' (family '{profile.get('family')}', role '{profile.get('role')}')."
)
def build_fallback_latent(vae, work_image, work_mask, *, repair_kernel: str, latent_source: str):
latent_samples = vae.encode(work_image)
latent = {
"samples": latent_samples,
"source": latent_source,
}
if str(repair_kernel or "") == "latent_mask":
latent["noise_mask"] = make_noise_mask(work_mask, latent_samples)
return latent
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
from .. import runtime as repair_runtime
from .base import build_fallback_latent
from .registry import register_backend
class FluxRepairBackend:
backend_name = "flux"
def supports(self, profile):
return str(profile.get("family") or "").strip().upper().startswith("FLUX")
def prepare(self, *, vae, work_image, work_mask, positive, negative, workspace, repair_kernel, routing, **_kwargs):
if routing.execution_path != "native_repair":
latent = build_fallback_latent(
vae,
work_image,
work_mask,
repair_kernel=repair_kernel,
latent_source=f"repair_prepare_{workspace['repair_scope']}",
)
return {
"latent": latent,
"positive": positive,
"negative": negative,
"backend_hints": {
"backend_name": self.backend_name,
"routing_reason": routing.routing_reason,
"execution_path": routing.execution_path,
"model_patch": "",
},
}
if positive is None or negative is None:
raise RuntimeError("[LLS] Native FLUX repair requires positive and negative conditioning.")
positive_out, negative_out, latent = repair_runtime.encode_inpaint_conditioning(
positive,
negative,
vae,
work_image,
work_mask,
noise_mask=True,
)
latent["source"] = f"repair_prepare_{workspace['repair_scope']}"
return {
"latent": latent,
"positive": positive_out,
"negative": negative_out,
"backend_hints": {
"backend_name": self.backend_name,
"routing_reason": routing.routing_reason,
"execution_path": routing.execution_path,
"model_patch": "differential_diffusion",
"model_patch_strength": 1.0,
},
}
register_backend(FluxRepairBackend())
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
from .base import build_fallback_latent
from .registry import register_backend
class GenericRepairBackend:
backend_name = "generic"
def supports(self, profile):
del profile
return True
def prepare(self, *, vae, work_image, work_mask, positive, negative, workspace, repair_kernel, routing, **_kwargs):
latent = build_fallback_latent(
vae,
work_image,
work_mask,
repair_kernel=repair_kernel,
latent_source=f"repair_prepare_{workspace['repair_scope']}",
)
return {
"latent": latent,
"positive": positive,
"negative": negative,
"backend_hints": {
"backend_name": self.backend_name,
"routing_reason": routing.routing_reason,
"execution_path": routing.execution_path,
"model_patch": "",
},
}
register_backend(GenericRepairBackend())
+101
View File
@@ -0,0 +1,101 @@
from __future__ import annotations
import importlib
from .base import RoutingResult, validate_backend
from ...utils.model_info import canonicalize_family, parse_jsonish_info, resolve_edit_capabilities
_BACKENDS = {}
_BUILTINS_LOADED = False
def _ensure_builtin_backends():
global _BUILTINS_LOADED
if _BUILTINS_LOADED:
return
importlib.import_module(f"{__package__}.generic")
importlib.import_module(f"{__package__}.sdxl")
importlib.import_module(f"{__package__}.flux")
_BUILTINS_LOADED = True
def register_backend(backend):
_BACKENDS[str(backend.backend_name).strip().lower()] = backend
return backend
def get_backend(name: str):
_ensure_builtin_backends()
backend = _BACKENDS.get(str(name or "").strip().lower())
if backend is None:
raise RuntimeError(f"[LLS] Unknown repair backend '{name}'.")
return backend
def _build_profile(capabilities: dict[str, object]) -> dict[str, object]:
return {
"family": capabilities["model_family"],
"role": capabilities["model_role"],
"profile_id": capabilities["profile_id"],
"backend_type": capabilities["backend_type"],
"sampler_strategy": capabilities["sampler_strategy"],
"loader_strategy": capabilities["loader_strategy"],
"supports_inpaint_native": capabilities["supports_inpaint_native"],
"supports_image_edit_native": capabilities["supports_image_edit_native"],
"preferred_edit_backend": capabilities["preferred_edit_backend"],
}
def _resolve_profile_and_capabilities(model=None, model_info=None, repair_info=None):
raw_model_info = parse_jsonish_info(model_info)
raw_repair_info = parse_jsonish_info(repair_info)
merged_info = dict(raw_model_info)
merged_info.update(raw_repair_info)
capabilities = resolve_edit_capabilities(model=model, model_info=merged_info)
return _build_profile(capabilities), capabilities
def _resolve_fallback_backend_name(family: str) -> str:
normalized = canonicalize_family(family)
if normalized.startswith("SDXL"):
return "sdxl"
if normalized.startswith("FLUX"):
return "flux"
return "generic"
def resolve_backend(repair_kernel: str, *, model=None, model_info=None, repair_info=None):
_ensure_builtin_backends()
profile, capabilities = _resolve_profile_and_capabilities(
model=model,
model_info=model_info,
repair_info=repair_info,
)
if str(repair_kernel or "") == "native_fill":
native_backend_name = {
"sdxl_native": "sdxl",
"flux_edit": "flux",
}.get(str(profile.get("backend_type") or "").strip().lower())
if native_backend_name is not None:
backend = get_backend(native_backend_name)
validate_backend(native_backend_name, backend, profile)
return backend, RoutingResult(
backend.backend_name,
"profile.backend_type",
capabilities,
profile,
"native_repair",
)
fallback_backend_name = _resolve_fallback_backend_name(str(profile.get("family") or ""))
backend = get_backend(fallback_backend_name)
validate_backend(fallback_backend_name, backend, profile)
return backend, RoutingResult(
backend.backend_name,
"profile.family_fallback",
capabilities,
profile,
"fallback_repair",
)
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
from .. import runtime as repair_runtime
from .base import build_fallback_latent
from .registry import register_backend
class SDXLRepairBackend:
backend_name = "sdxl"
def supports(self, profile):
return str(profile.get("family") or "").strip().upper().startswith("SDXL")
def prepare(self, *, vae, work_image, work_mask, positive, negative, workspace, repair_kernel, routing, **_kwargs):
if routing.execution_path != "native_repair":
latent = build_fallback_latent(
vae,
work_image,
work_mask,
repair_kernel=repair_kernel,
latent_source=f"repair_prepare_{workspace['repair_scope']}",
)
return {
"latent": latent,
"positive": positive,
"negative": negative,
"backend_hints": {
"backend_name": self.backend_name,
"routing_reason": routing.routing_reason,
"execution_path": routing.execution_path,
"model_patch": "",
},
}
if positive is None or negative is None:
raise RuntimeError("[LLS] Native SDXL repair requires positive and negative conditioning.")
positive_out, negative_out, latent = repair_runtime.encode_inpaint_conditioning(
positive,
negative,
vae,
work_image,
work_mask,
noise_mask=True,
)
latent["source"] = f"repair_prepare_{workspace['repair_scope']}"
return {
"latent": latent,
"positive": positive_out,
"negative": negative_out,
"backend_hints": {
"backend_name": self.backend_name,
"routing_reason": routing.routing_reason,
"execution_path": routing.execution_path,
"model_patch": "",
},
}
register_backend(SDXLRepairBackend())
+144 -71
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from .backends.registry import resolve_backend
from .repair_utils import (
build_canvas_info,
build_canvas_repair_mask,
@@ -10,9 +11,7 @@ from .repair_utils import (
expand_canvas_mask,
get_image_size,
get_mask_metrics,
make_noise_mask,
merge_masks,
normalize_model_info,
preprocess_mask,
recommend_denoise,
resize_image_to,
@@ -21,6 +20,7 @@ from .repair_utils import (
resolve_repair_scope,
resolve_work_size,
)
from ..utils.model_info import resolve_edit_capabilities
_REPAIR_SCOPE_CHOICES = ["auto", "region", "crop", "canvas"]
@@ -43,6 +43,67 @@ _AUTO_RECOMMEND_CHOICES = ["enabled", "disabled"]
_CANVAS_FILL_CHOICES = ["edge", "blur", "black", "white", "neutral"]
def _build_repair_info(
*,
repair_scope: str,
repair_kernel: str,
task_hint: str,
original_size: tuple[int, int],
work_size: tuple[int, int],
crop_box,
crop_scale,
canvas_expand: list[int],
mask_grow: int,
mask_blur: float,
mask_threshold: float,
invert_mask: bool,
recommended_denoise: float,
routing,
warnings: list[str],
original_box_in_canvas=None,
backend_hints: dict | None = None,
):
profile = routing.profile
info = {
"repair_scope": repair_scope,
"repair_kernel": repair_kernel,
"task_hint": str(task_hint or "auto"),
"original_size": original_size,
"work_size": work_size,
"crop_box": crop_box,
"crop_scale": crop_scale,
"canvas_expand": list(canvas_expand),
"original_box_in_canvas": original_box_in_canvas,
"mask_grow": int(mask_grow),
"mask_blur": float(mask_blur),
"mask_threshold": float(mask_threshold),
"invert_mask": bool(invert_mask),
"recommended_denoise": float(recommended_denoise),
"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"],
"backend_name": routing.backend_name,
"routing_reason": routing.routing_reason,
"execution_path": routing.execution_path,
"model_patch": "",
"model_patch_strength": 1.0,
"repair_payload_version": "1.0",
"has_mask": False,
"mask_area_ratio": 0.0,
"mask_bbox": None,
"warnings": list(warnings),
}
if backend_hints:
info.update(backend_hints)
return info
class LLSSimpleRepairPrepare:
CATEGORY = "LLS/Image Repair"
FUNCTION = "prepare"
@@ -77,6 +138,7 @@ class LLSSimpleRepairPrepare:
"auto_recommend": (_AUTO_RECOMMEND_CHOICES, {"default": "enabled"}),
},
"optional": {
"model": ("MODEL",),
"model_info": ("STRING",),
"positive": ("CONDITIONING",),
"negative": ("CONDITIONING",),
@@ -106,6 +168,7 @@ class LLSSimpleRepairPrepare:
expand_bottom,
canvas_fill,
auto_recommend,
model=None,
model_info=None,
positive=None,
negative=None,
@@ -115,7 +178,7 @@ class LLSSimpleRepairPrepare:
image_width, image_height = get_image_size(image)
original_size = (image_width, image_height)
normalized_model = normalize_model_info(model_info)
capabilities = resolve_edit_capabilities(model=model, model_info=model_info)
processed_mask = preprocess_mask(
mask,
original_size,
@@ -144,51 +207,75 @@ class LLSSimpleRepairPrepare:
scope=effective_scope,
task_hint=task_hint,
mask_area_ratio=mask_area_ratio,
model_info=normalized_model,
model_info=capabilities,
)
backend, routing = resolve_backend(
effective_kernel,
model=model,
model_info=capabilities,
)
warnings = list(warnings)
if effective_kernel == "native_fill":
effective_kernel = "vae_inpaint"
warnings.append(
"native_fill requested but runtime sampler support is not implemented yet; using vae_inpaint"
)
recommended = recommend_denoise(task_hint, effective_scope, effective_kernel, auto_recommend)
repair_info = {
"repair_scope": effective_scope,
"repair_kernel": effective_kernel,
"task_hint": str(task_hint or "auto"),
"original_size": original_size,
"work_size": original_size,
"crop_box": None,
"crop_scale": 1.0,
"canvas_expand": canvas_expand,
"mask_grow": int(mask_grow),
"mask_blur": float(mask_blur),
"mask_threshold": float(mask_threshold),
"invert_mask": bool(invert_mask),
"recommended_denoise": recommended,
"model_family": normalized_model["model_family"],
"model_role": normalized_model["model_role"],
"repair_payload_version": "1.0",
"has_mask": False,
"mask_area_ratio": 0.0,
"mask_bbox": None,
"warnings": warnings,
}
def finalize(work_image, work_mask, *, work_size, crop_box=None, crop_scale=1.0, original_box_in_canvas=None):
workspace = {
"repair_scope": effective_scope,
"original_size": original_size,
"work_size": work_size,
"crop_box": crop_box,
"crop_scale": crop_scale,
"canvas_expand": list(canvas_expand),
"original_box_in_canvas": original_box_in_canvas,
"work_image": work_image,
"work_mask": work_mask,
}
prepared = backend.prepare(
model=model,
vae=vae,
work_image=work_image,
work_mask=work_mask,
positive=positive,
negative=negative,
workspace=workspace,
routing=routing,
repair_kernel=effective_kernel,
)
info = _build_repair_info(
repair_scope=effective_scope,
repair_kernel=effective_kernel,
task_hint=task_hint,
original_size=original_size,
work_size=work_size,
crop_box=None if crop_box is None else list(crop_box),
crop_scale=crop_scale,
canvas_expand=canvas_expand,
mask_grow=mask_grow,
mask_blur=mask_blur,
mask_threshold=mask_threshold,
invert_mask=invert_mask,
recommended_denoise=recommended,
routing=routing,
warnings=list(warnings),
original_box_in_canvas=None if original_box_in_canvas is None else list(original_box_in_canvas),
backend_hints=prepared.get("backend_hints"),
)
final_bbox, final_area_ratio = get_mask_metrics(work_mask, work_size)
info["has_mask"] = final_bbox is not None and final_area_ratio > 0.0
info["mask_bbox"] = list(final_bbox) if final_bbox is not None else None
info["mask_area_ratio"] = final_area_ratio
return (
prepared["latent"],
work_image,
work_mask,
info,
recommended,
prepared["positive"],
prepared["negative"],
)
if effective_scope == "region":
work_image = image
work_mask = resize_mask_to(processed_mask, image_width, image_height)
latent_samples = vae.encode(work_image)
latent = {"samples": latent_samples, "source": "repair_prepare_region"}
if effective_kernel == "latent_mask":
latent["noise_mask"] = make_noise_mask(work_mask, latent_samples)
final_bbox, final_area_ratio = get_mask_metrics(work_mask, original_size)
repair_info["has_mask"] = final_bbox is not None and final_area_ratio > 0.0
repair_info["mask_bbox"] = list(final_bbox) if final_bbox is not None else None
repair_info["mask_area_ratio"] = final_area_ratio
return latent, work_image, work_mask, repair_info, recommended, positive, negative
return finalize(work_image, work_mask, work_size=original_size)
if effective_scope == "crop":
if mask_bbox is None or mask_area_ratio <= 0.0:
@@ -203,23 +290,15 @@ class LLSSimpleRepairPrepare:
int(max_size),
resize_mode,
)
cropped_image = crop_image_to(image, crop_box)
cropped_mask = crop_mask_to(processed_mask, crop_box)
work_image = resize_image_to(cropped_image, work_width, work_height)
work_mask = resize_mask_to(cropped_mask, work_width, work_height)
latent_samples = vae.encode(work_image)
latent = {"samples": latent_samples, "source": "repair_prepare_crop"}
if effective_kernel == "latent_mask":
latent["noise_mask"] = make_noise_mask(work_mask, latent_samples)
repair_info["crop_box"] = list(crop_box)
repair_info["crop_scale"] = crop_scale
repair_info["work_size"] = (work_width, work_height)
final_bbox, final_area_ratio = get_mask_metrics(work_mask, (work_width, work_height))
repair_info["has_mask"] = final_bbox is not None and final_area_ratio > 0.0
repair_info["mask_bbox"] = list(final_bbox) if final_bbox is not None else None
repair_info["mask_area_ratio"] = final_area_ratio
return latent, work_image, work_mask, repair_info, recommended, positive, negative
work_image = resize_image_to(crop_image_to(image, crop_box), work_width, work_height)
work_mask = resize_mask_to(crop_mask_to(processed_mask, crop_box), work_width, work_height)
return finalize(
work_image,
work_mask,
work_size=(work_width, work_height),
crop_box=crop_box,
crop_scale=crop_scale,
)
if effective_scope == "canvas":
canvas_info = build_canvas_info(
@@ -251,18 +330,12 @@ class LLSSimpleRepairPrepare:
original_box=original_box,
)
work_mask = merge_masks(user_mask, canvas_mask, (work_width, work_height))
latent_samples = vae.encode(work_image)
latent = {"samples": latent_samples, "source": "repair_prepare_canvas"}
if effective_kernel == "latent_mask":
latent["noise_mask"] = make_noise_mask(work_mask, latent_samples)
repair_info["work_size"] = (work_width, work_height)
repair_info["original_box_in_canvas"] = list(original_box)
final_bbox, final_area_ratio = get_mask_metrics(work_mask, (work_width, work_height))
repair_info["has_mask"] = final_bbox is not None and final_area_ratio > 0.0
repair_info["mask_bbox"] = list(final_bbox) if final_bbox is not None else None
repair_info["mask_area_ratio"] = final_area_ratio
return latent, work_image, work_mask, repair_info, recommended, positive, negative
return finalize(
work_image,
work_mask,
work_size=(work_width, work_height),
original_box_in_canvas=original_box,
)
raise RuntimeError(f"[LLS] Unsupported repair scope '{effective_scope}'.")
+42 -7
View File
@@ -59,8 +59,14 @@ def normalize_model_info(model_info: dict[str, Any] | str | None) -> dict[str, A
raw_family = raw.get("model_family") or raw.get("family")
return {
"model_family": _normalize_model_family(raw_family),
"model_role": str(raw.get("model_role") or raw.get("role") or "unknown"),
"model_role": str(raw.get("model_role") or raw.get("role") or "unknown").strip().lower(),
"profile_id": str(raw.get("profile_id") or ""),
"backend_type": str(raw.get("backend_type") or "none").strip().lower(),
"sampler_strategy": str(raw.get("sampler_strategy") or "").strip().lower(),
"loader_strategy": str(raw.get("loader_strategy") or "").strip().lower(),
"supports_inpaint_native": _coerce_bool(raw.get("supports_inpaint_native"), False),
"supports_image_edit_native": _coerce_bool(raw.get("supports_image_edit_native"), False),
"preferred_edit_backend": str(raw.get("preferred_edit_backend") or "").strip().lower(),
}
@@ -84,11 +90,27 @@ def normalize_repair_info(repair_info: dict[str, Any] | str | None) -> dict[str,
info["invert_mask"] = _coerce_bool(info.get("invert_mask"), False)
info["recommended_denoise"] = _coerce_float(info.get("recommended_denoise"), 0.55)
info["model_family"] = _normalize_model_family(info.get("model_family") or model_info["model_family"])
info["model_role"] = str(info.get("model_role") or model_info["model_role"])
info["model_role"] = str(info.get("model_role") or model_info["model_role"]).strip().lower()
info["profile_id"] = str(info.get("profile_id") or model_info["profile_id"])
info["backend_type"] = str(info.get("backend_type") or model_info["backend_type"] or "none").strip().lower()
info["sampler_strategy"] = str(info.get("sampler_strategy") or model_info["sampler_strategy"]).strip().lower()
info["loader_strategy"] = str(info.get("loader_strategy") or model_info["loader_strategy"]).strip().lower()
info["supports_inpaint_native"] = _coerce_bool(
info.get("supports_inpaint_native", model_info["supports_inpaint_native"]),
model_info["supports_inpaint_native"],
)
info["supports_image_edit_native"] = _coerce_bool(
info.get("supports_image_edit_native", model_info["supports_image_edit_native"]),
model_info["supports_image_edit_native"],
)
info["preferred_edit_backend"] = str(
info.get("preferred_edit_backend") or model_info["preferred_edit_backend"] or ""
).strip().lower()
info["backend_name"] = str(info.get("backend_name") or "")
info["routing_reason"] = str(info.get("routing_reason") or "")
info["execution_path"] = str(info.get("execution_path") or "")
info["model_patch"] = str(info.get("model_patch") or "")
info["model_patch_strength"] = _coerce_float(info.get("model_patch_strength"), 1.0)
info["repair_payload_version"] = str(info.get("repair_payload_version") or "1.0")
info["warnings"] = _normalize_warning_list(info.get("warnings"))
return info
@@ -140,15 +162,12 @@ def resolve_repair_kernel(
if kernel != "auto":
if kernel not in _REPAIR_KERNEL_VALUES:
raise RuntimeError(f"[LLS] Unsupported repair_kernel '{requested_kernel}'.")
if kernel == "native_fill" and not normalized_model["supports_inpaint_native"]:
if kernel == "native_fill" and not _supports_native_repair(normalized_model):
warnings.append("native_fill requested but unsupported; falling back to vae_inpaint")
return "vae_inpaint", warnings
return kernel, warnings
if (
normalized_model["model_role"] in {"inpaint", "fill", "edit"}
and normalized_model["supports_inpaint_native"]
):
if normalized_model["model_role"] in {"inpaint", "fill", "edit"} and _supports_native_repair(normalized_model):
return "native_fill", warnings
if scope != "canvas" and mask_area_ratio <= 0.20 and task_hint in _TASK_HINT_LATENT_MASK:
@@ -157,6 +176,22 @@ def resolve_repair_kernel(
return "vae_inpaint", warnings
def _supports_native_repair(model_info: dict[str, Any]) -> bool:
if _coerce_bool(model_info.get("supports_inpaint_native"), False):
return True
family = _normalize_model_family(model_info.get("model_family"))
role = str(model_info.get("model_role") or "").strip().lower()
backend_type = str(model_info.get("backend_type") or "").strip().lower()
preferred_backend = str(model_info.get("preferred_edit_backend") or "").strip().lower()
supports_image_edit_native = _coerce_bool(model_info.get("supports_image_edit_native"), False)
if family.startswith("FLUX") and role in {"inpaint", "edit", "fill"}:
return supports_image_edit_native or backend_type == "flux_edit" or preferred_backend == "flux"
return False
def recommend_denoise(task_hint: str, scope: str, kernel: str, auto_recommend: str) -> float:
if auto_recommend == "disabled":
if scope == "canvas":
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
import importlib
from typing import Any
try:
comfy_core_nodes = importlib.import_module("nodes")
except Exception as exc: # pragma: no cover - runtime-only import
comfy_core_nodes = None
_CORE_NODES_ERR = exc
else: # pragma: no cover - runtime-only import
_CORE_NODES_ERR = None
try:
nodes_differential_diffusion = importlib.import_module("comfy_extras.nodes_differential_diffusion")
except Exception as exc: # pragma: no cover - runtime-only import
nodes_differential_diffusion = None
_DIFFUSION_ERR = exc
else: # pragma: no cover - runtime-only import
_DIFFUSION_ERR = None
def _require_class(module: Any, class_name: str, import_error: Exception | None = None):
if module is None:
raise RuntimeError(
f"[LLS] Required ComfyUI runtime component '{class_name}' is unavailable."
) from import_error
cls = getattr(module, class_name, None)
if cls is None:
raise RuntimeError(
f"[LLS] Required ComfyUI runtime component '{class_name}' is unavailable."
) from import_error
return cls
def _unwrap_result(result: Any):
if hasattr(result, "result"):
return result.result
return result
def _unwrap_first(result: Any):
values = _unwrap_result(result)
if isinstance(values, tuple):
return values[0]
if isinstance(values, list):
return values[0]
return values
def _unwrap_three(result: Any):
values = _unwrap_result(result)
if isinstance(values, tuple):
return values[:3]
if isinstance(values, list):
return tuple(values[:3])
raise RuntimeError("[LLS] Expected a three-value runtime result from ComfyUI.")
def encode_inpaint_conditioning(positive, negative, vae, pixels, mask, *, noise_mask: bool = True):
node_cls = _require_class(comfy_core_nodes, "InpaintModelConditioning", _CORE_NODES_ERR)
node = node_cls()
if not hasattr(node, "encode"):
raise RuntimeError(
"[LLS] ComfyUI InpaintModelConditioning is missing the expected encode() method."
)
return _unwrap_three(
node.encode(
positive,
negative,
pixels,
vae,
mask,
bool(noise_mask),
)
)
def apply_differential_diffusion(model, *, strength: float = 1.0):
node_cls = _require_class(
nodes_differential_diffusion,
"DifferentialDiffusion",
_DIFFUSION_ERR,
)
node = node_cls()
if hasattr(node, "execute"):
return _unwrap_first(node.execute(model, float(strength)))
if hasattr(node, "apply"):
return _unwrap_first(node.apply(model, float(strength)))
if hasattr(node, "patch"):
return _unwrap_first(node.patch(model, float(strength)))
if hasattr(node_cls, "execute"):
return _unwrap_first(node_cls.execute(model, float(strength)))
raise RuntimeError(
"[LLS] ComfyUI DifferentialDiffusion is missing a supported execute/apply method."
)
+19 -1
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import random
from ..repair import runtime as repair_runtime
from ..repair.repair_utils import normalize_model_info, normalize_repair_info, resolve_adapter_mode
from ..utils.model_info import (
FAMILY_DEFAULT_PRESET,
@@ -152,6 +153,18 @@ def _normalize_ksampler_compat_values(
return denoise_mode, adapter_mode, flux_guidance, model_family
def _apply_repair_model_patch(model, repair_meta):
if repair_meta is None:
return model
patch_name = str(repair_meta.get("model_patch") or "").strip().lower()
if patch_name == "differential_diffusion":
strength = float(repair_meta.get("model_patch_strength", 1.0))
return repair_runtime.apply_differential_diffusion(model, strength=strength)
return model
def _common_ksampler(
model,
seed: int,
@@ -404,10 +417,12 @@ class LLSSimpleKSampler:
if seed == -1:
actual_seed = random.randint(0, 0xFFFFFFFFFFFFFFFF)
sample_model = _apply_repair_model_patch(model, repair_meta)
# 复用 ComfyUI 原生 common_ksampler
try:
result_latent = _common_ksampler(
model=model,
model=sample_model,
seed=actual_seed,
steps=steps,
cfg=cfg,
@@ -438,6 +453,9 @@ class LLSSimpleKSampler:
"repair_mode": repair_meta is not None,
"repair_scope": repair_meta.get("repair_scope") if repair_meta else None,
"repair_kernel": repair_meta.get("repair_kernel") if repair_meta else None,
"repair_backend": repair_meta.get("backend_name") if repair_meta else None,
"repair_execution_path": repair_meta.get("execution_path") if repair_meta else None,
"repair_model_patch": repair_meta.get("model_patch") if repair_meta else None,
"guidance_used": bool(guidance_stack),
}
)
+53 -1
View File
@@ -1,3 +1,4 @@
import importlib
import importlib.util
import pathlib
import sys
@@ -47,6 +48,16 @@ class FakeTensor:
original_box=original_box,
)
def masked_fill(self, mask, fill_value):
del mask
return FakeTensor(
self.shape,
label=f"{self.label}:masked[{fill_value}]",
crop_box=self.crop_box,
fill_mode=self.fill_mode,
original_box=self.original_box,
)
class FakeMask:
def __init__(self, shape, mask_bbox=None, mask_area_ratio=0.0, label="mask", crop_box=None):
@@ -262,9 +273,46 @@ class FakeLatentTensor:
class FakeVAE:
def __init__(self, latent_channels=4, downscale_ratio=8):
self.latent_channels = int(latent_channels)
self.downscale_ratio = int(downscale_ratio)
def encode(self, image):
batch, height, width, _ = image.shape
return FakeLatentTensor((batch, 4, height // 8, width // 8))
latent_height = max(1, height // self.downscale_ratio)
latent_width = max(1, width // self.downscale_ratio)
return FakeLatentTensor((batch, self.latent_channels, latent_height, latent_width))
class FakeModel:
def __init__(
self,
family="SDXL",
model_role="base",
supports_inpaint_native=False,
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
self._lls_supports_inpaint_native = supports_inpaint_native
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):
return [[label, {"origin": label}]]
def load_plugin_package():
@@ -281,3 +329,7 @@ def load_plugin_package():
sys.modules[MODULE_NAME] = module
spec.loader.exec_module(module)
return module
def import_plugin_submodule(plugin, dotted_name: str):
return importlib.import_module(f"{plugin.__name__}.{dotted_name}")
+91 -2
View File
@@ -1,9 +1,26 @@
import unittest
from unittest import mock
try:
from .test_repair_helpers import FakeMask, FakeVAE, FakeTensor, load_plugin_package
from .test_repair_helpers import (
FakeMask,
FakeModel,
FakeTensor,
FakeVAE,
import_plugin_submodule,
load_plugin_package,
make_conditioning,
)
except ImportError: # pragma: no cover - discovery mode imports from top level
from test_repair_helpers import FakeMask, FakeVAE, FakeTensor, load_plugin_package
from test_repair_helpers import (
FakeMask,
FakeModel,
FakeTensor,
FakeVAE,
import_plugin_submodule,
load_plugin_package,
make_conditioning,
)
class TestRepairPrepare(unittest.TestCase):
@@ -234,6 +251,78 @@ class TestRepairPrepare(unittest.TestCase):
self.assertIs(positive_out, positive)
self.assertIs(negative_out, negative)
def test_prepare_region_routes_flux_fill_profile_to_native_backend(self):
plugin = load_plugin_package()
node = plugin.NODE_CLASS_MAPPINGS["LLSSimpleRepairPrepare"]()
repair_runtime = import_plugin_submodule(plugin, "repair.runtime")
image = FakeTensor((1, 1024, 1024, 3), label="flux-region-image")
mask = FakeMask((1, 1024, 1024), mask_bbox=(192, 192, 512, 512), mask_area_ratio=0.10)
model = FakeModel(
family="FLUX_DEV",
model_role="fill",
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",
)
native_positive = make_conditioning("native-positive")
native_negative = make_conditioning("native-negative")
native_latent = {"samples": "native-latent", "noise_mask": "native-mask"}
with mock.patch.object(
repair_runtime,
"encode_inpaint_conditioning",
return_value=(native_positive, native_negative, native_latent),
) as encode_inpaint:
latent, work_image, work_mask, repair_info, recommended, positive, negative = node.prepare(
image=image,
mask=mask,
vae=FakeVAE(latent_channels=16, downscale_ratio=16),
repair_scope="region",
repair_kernel="auto",
task_hint="fill",
mask_grow=8,
mask_blur=4.0,
mask_threshold=0.5,
invert_mask=False,
crop_context=64,
crop_context_factor=1.5,
min_size=512,
max_size=1024,
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,
positive=make_conditioning("positive"),
negative=make_conditioning("negative"),
)
encode_inpaint.assert_called_once()
self.assertIs(latent, native_latent)
self.assertEqual(latent["source"], "repair_prepare_region")
self.assertEqual(work_image.shape, (1, 1024, 1024, 3))
self.assertEqual(work_mask.shape, (1, 1024, 1024))
self.assertEqual(repair_info["repair_kernel"], "native_fill")
self.assertEqual(repair_info["backend_name"], "flux")
self.assertEqual(repair_info["execution_path"], "native_repair")
self.assertEqual(repair_info["model_patch"], "differential_diffusion")
self.assertEqual(repair_info["profile_id"], "flux_edit")
self.assertEqual(repair_info["backend_type"], "flux_edit")
self.assertEqual(repair_info["sampler_strategy"], "flux_guided")
self.assertTrue(repair_info["supports_image_edit_native"])
self.assertGreater(recommended, 0.0)
self.assertIs(positive, native_positive)
self.assertIs(negative, native_negative)
def test_prepare_requires_vae(self):
plugin = load_plugin_package()
node = plugin.NODE_CLASS_MAPPINGS["LLSSimpleRepairPrepare"]()
+2 -1
View File
@@ -65,8 +65,9 @@ class TestRepairRegistration(unittest.TestCase):
):
self.assertIn(field, required)
for field in ("model_info", "positive", "negative"):
for field in ("model", "model_info", "positive", "negative"):
self.assertIn(field, optional)
self.assertEqual(optional["model"], ("MODEL",))
self.assertEqual(required["image"], ("IMAGE",))
self.assertEqual(required["mask"], ("MASK",))
self.assertEqual(required["vae"], ("VAE",))
+79
View File
@@ -0,0 +1,79 @@
import unittest
try:
from .test_repair_helpers import FakeModel, import_plugin_submodule, load_plugin_package
except ImportError: # pragma: no cover - discovery mode imports from top level
from test_repair_helpers import FakeModel, import_plugin_submodule, load_plugin_package
class TestRepairRegistry(unittest.TestCase):
def setUp(self):
self.plugin = load_plugin_package()
self.registry = import_plugin_submodule(self.plugin, "repair.backends.registry")
def test_native_fill_routes_flux_edit_profile_to_flux_backend(self):
model = FakeModel(
family="FLUX_DEV",
model_role="fill",
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("native_fill", model=model)
self.assertEqual(backend.backend_name, "flux")
self.assertEqual(routing.backend_name, "flux")
self.assertEqual(routing.routing_reason, "profile.backend_type")
self.assertEqual(routing.execution_path, "native_repair")
self.assertEqual(routing.profile["profile_id"], "flux_edit")
self.assertEqual(routing.profile["backend_type"], "flux_edit")
self.assertEqual(routing.capabilities["model_family"], "FLUX_DEV")
def test_fallback_routes_sdxl_base_profile_to_sdxl_backend(self):
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",
)
backend, routing = self.registry.resolve_backend("vae_inpaint", model=model)
self.assertEqual(backend.backend_name, "sdxl")
self.assertEqual(routing.routing_reason, "profile.family_fallback")
self.assertEqual(routing.execution_path, "fallback_repair")
self.assertEqual(routing.profile["profile_id"], "sdxl_base")
def test_fallback_routes_sd15_base_profile_to_generic_backend(self):
model = FakeModel(
family="SD1.5",
model_role="base",
supports_inpaint_native=False,
supports_image_edit_native=False,
preferred_edit_backend=None,
profile_id="sd15_base",
backend_type="none",
sampler_strategy="standard_k",
loader_strategy="sd15_checkpoint",
)
backend, routing = self.registry.resolve_backend("latent_mask", model=model)
self.assertEqual(backend.backend_name, "generic")
self.assertEqual(routing.routing_reason, "profile.family_fallback")
self.assertEqual(routing.execution_path, "fallback_repair")
self.assertEqual(routing.profile["profile_id"], "sd15_base")
if __name__ == "__main__":
unittest.main()
+59
View File
@@ -112,6 +112,65 @@ class TestRepairSampler(unittest.TestCase):
self.assertEqual(payload["repair_scope"], "region")
self.assertEqual(payload["repair_kernel"], "latent_mask")
def test_sampler_patches_native_flux_repair_model_before_sampling(self):
load_plugin_package()
from lls_node_test_repair.sampling import nodes as sampling_nodes
node = sampling_nodes.LLSSimpleKSampler()
latent = {"samples": FakeLatentTensor((1, 16, 64, 64)), "source": "repair_prepare_region"}
model = object()
patched_model = object()
with mock.patch.object(sampling_nodes, "comfy_sample", object()), mock.patch.object(
sampling_nodes,
"comfy_samplers",
types.SimpleNamespace(KSampler=types.SimpleNamespace(SAMPLERS=["euler"], SCHEDULERS=["simple"])),
), mock.patch.object(
sampling_nodes,
"_apply_repair_model_patch",
return_value=patched_model,
) as patch_model, mock.patch.object(
sampling_nodes,
"_common_ksampler",
return_value={"samples": "done"},
) as common:
_result_latent, sample_info = node.sample(
model=model,
positive="positive",
negative="negative",
latent_image=latent,
quality_preset="Manual",
seed=7,
steps=20,
cfg=1.0,
sampler_name="euler",
scheduler="simple",
denoise=0.33,
denoise_mode="auto_from_repair",
adapter_mode="auto",
flux_guidance=3.5,
model_family="Auto",
repair_info={
"repair_scope": "region",
"repair_kernel": "native_fill",
"recommended_denoise": 0.61,
"model_family": "FLUX_DEV",
"model_role": "fill",
"backend_name": "flux",
"execution_path": "native_repair",
"model_patch": "differential_diffusion",
"model_patch_strength": 1.0,
},
guidance_stack=None,
model_info=None,
)
patch_model.assert_called_once()
self.assertIs(common.call_args.kwargs["model"], patched_model)
payload = json.loads(sample_info)
self.assertEqual(payload["repair_kernel"], "native_fill")
self.assertEqual(payload["repair_backend"], "flux")
self.assertEqual(payload["repair_execution_path"], "native_repair")
def test_sampler_recovers_shifted_values_from_broken_widget_order(self):
load_plugin_package()
from lls_node_test_repair.sampling import nodes as sampling_nodes
+39 -2
View File
@@ -52,6 +52,13 @@ class TestRepairUtils(unittest.TestCase):
self.assertTrue(
utils.normalize_model_info({"supports_inpaint_native": 1})["supports_inpaint_native"]
)
self.assertTrue(
utils.normalize_model_info({"supports_image_edit_native": "true"})["supports_image_edit_native"]
)
self.assertEqual(
utils.normalize_model_info({"backend_type": "flux_edit"})["backend_type"],
"flux_edit",
)
def test_canonicalized_family_feeds_sd_classic_adapter_resolution(self):
utils = load_repair_utils()
@@ -100,9 +107,16 @@ class TestRepairUtils(unittest.TestCase):
utils.resolve_repair_kernel(
"auto",
scope="region",
task_hint="repair",
task_hint="fill",
mask_area_ratio=0.05,
model_info={"model_role": "fill", "supports_inpaint_native": True},
model_info={
"model_family": "FLUX_DEV",
"model_role": "fill",
"backend_type": "flux_edit",
"supports_inpaint_native": False,
"supports_image_edit_native": True,
"preferred_edit_backend": "flux",
},
)[0],
"native_fill",
)
@@ -127,6 +141,29 @@ class TestRepairUtils(unittest.TestCase):
"vae_inpaint",
)
def test_resolve_repair_kernel_downgrades_flux_base_profile_from_native_fill(self):
utils = load_repair_utils()
kernel, warnings = utils.resolve_repair_kernel(
"native_fill",
scope="region",
task_hint="repair",
mask_area_ratio=0.05,
model_info={
"model_family": "FLUX_DEV",
"model_role": "base",
"backend_type": "none",
"supports_inpaint_native": False,
"supports_image_edit_native": False,
"preferred_edit_backend": None,
},
)
self.assertEqual(kernel, "vae_inpaint")
self.assertEqual(
warnings,
["native_fill requested but unsupported; falling back to vae_inpaint"],
)
def test_resolve_repair_kernel_downgrades_explicit_native_fill_when_unsupported(self):
utils = load_repair_utils()
+27 -12
View File
@@ -2,7 +2,7 @@
"id": "196d3f49-c541-4fcd-b800-ad54aecdf346",
"revision": 0,
"last_node_id": 78,
"last_link_id": 128,
"last_link_id": 129,
"nodes": [
{
"id": 28,
@@ -1040,15 +1040,15 @@
"Manual",
452874131519053,
"randomize",
20,
3,
30,
1,
"euler",
"simple",
1,
"manual",
"flux",
"auto",
3.5,
"FLUX_SCHNELL",
"Auto",
""
]
},
@@ -1143,6 +1143,12 @@
"type": "VAE",
"link": 74
},
{
"name": "model",
"shape": 7,
"type": "MODEL",
"link": 129
},
{
"name": "positive",
"shape": 7,
@@ -1210,9 +1216,9 @@
"Node name for S&R": "LLSSimpleRepairPrepare"
},
"widgets_values": [
"crop",
"latent_mask",
"repair",
"auto",
"auto",
"replace",
24,
8,
0.5,
@@ -1251,7 +1257,8 @@
"name": "model",
"type": "MODEL",
"links": [
68
68,
129
]
},
{
@@ -1283,7 +1290,7 @@
"Node name for S&R": "LLSSimpleCheckpointLoader"
},
"widgets_values": [
"flux1-schnell-fp8.safetensors",
"diffusion_models/fluxFillFP8_v10.safetensors",
"Auto",
"simple",
"auto",
@@ -2146,7 +2153,7 @@
44,
0,
52,
3,
4,
"CONDITIONING"
],
[
@@ -2154,7 +2161,7 @@
44,
1,
52,
4,
5,
"CONDITIONING"
],
[
@@ -2420,6 +2427,14 @@
69,
3,
"MASK"
],
[
129,
45,
0,
52,
3,
"MODEL"
]
],
"groups": [