feat: Add include mode to layer filters
This commit is contained in:
@@ -11,6 +11,18 @@ A collection of ComfyUI custom nodes for inspecting, modifying, merging, and cre
|
||||
- **LoRA Extraction** – Extract LoRA adapters from model pairs using various SVD rank selection methods (Fixed, Ratio, Quantile, Knee-detection, Frobenius-norm)
|
||||
- **Diffusion Model Dtype Conversion** – Stream models to fp32, fp16, or bf16 while preserving excluded tensor dtypes
|
||||
|
||||
## Layer filters
|
||||
|
||||
Nodes with `exclude_patterns` or `skip_patterns` have an appended `include_mode` toggle
|
||||
(off by default). Turn it on to process only layers matching that same field,
|
||||
using the node's existing regex or glob syntax. An empty include filter selects
|
||||
nothing. Nonmatches follow the node's usual exclusion behavior: preserve the
|
||||
source/anchor layer for pattern-exclusion mergers, resize, or conversion; omit
|
||||
it from analysis or extraction. LoRA Merge to Model retains its existing skip
|
||||
semantics: nonmatching base tensors are omitted from the saved model, while
|
||||
guarded low-bit base tensors are always preserved. Its filter uses regex only.
|
||||
`discard_patterns`, where available, still takes precedence.
|
||||
|
||||
## Example Workflows
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -4,6 +4,12 @@ These nodes compare two safetensors models without merging them or writing an
|
||||
output file. Separate nodes are provided for checkpoints, diffusion models,
|
||||
text encoders, LoRAs, and embeddings.
|
||||
|
||||
`exclude_patterns` accepts one regex per line, or glob patterns when
|
||||
`glob_patterns` is enabled. By default, matches are excluded from metrics and
|
||||
topology counts. Enable `include_mode` to analyze only matches instead; an empty
|
||||
include filter selects no tensors. The same field and matching syntax are used
|
||||
in both modes.
|
||||
|
||||
## Outputs
|
||||
|
||||
`comparison_report` contains conventional numerical differences and topology
|
||||
|
||||
@@ -883,6 +883,7 @@ def _cwb_merge_metadata(
|
||||
"force_clear_cache",
|
||||
"override_dtype",
|
||||
"include_1d_diffs",
|
||||
"include_mode",
|
||||
"counterfactual_weight_sweep",
|
||||
"embedding_coalesce",
|
||||
"target_vector_count",
|
||||
@@ -897,9 +898,10 @@ def _cwb_merge_metadata(
|
||||
metadata["merge_options"][key] = params[key]
|
||||
exclude_patterns = params["exclude_patterns"].split()
|
||||
discard_patterns = params["discard_patterns"].split()
|
||||
if exclude_patterns or discard_patterns:
|
||||
if exclude_patterns or discard_patterns or params.get("include_mode", False):
|
||||
metadata["filters"] = {
|
||||
"exclude_patterns": exclude_patterns,
|
||||
"include_mode": params.get("include_mode", False),
|
||||
"discard_patterns": discard_patterns,
|
||||
"glob_patterns": params["glob_patterns"],
|
||||
}
|
||||
@@ -1794,6 +1796,7 @@ class ConsensusMergerLogic:
|
||||
requested_dtype = _requested_dtype(params["save_dtype"])
|
||||
mismatch_mode = params["mismatch_mode"]
|
||||
glob_mode = params["glob_patterns"]
|
||||
include_mode = params.get("include_mode", False)
|
||||
exclude = _compile_patterns(params["exclude_patterns"], glob_mode=glob_mode)
|
||||
discard = _compile_patterns(params["discard_patterns"], glob_mode=glob_mode)
|
||||
pbar = comfy.utils.ProgressBar(len(keys))
|
||||
@@ -1834,7 +1837,8 @@ class ConsensusMergerLogic:
|
||||
secondary_only = not primary_owned
|
||||
preserve_index = 0 if primary_owned else source_indices[0]
|
||||
guarded = any(key in low_bit_sets[i] for i in source_indices)
|
||||
excluded = _matches_any_pattern(key, exclude, glob_mode=glob_mode)
|
||||
matched = _matches_any_pattern(key, exclude, glob_mode=glob_mode)
|
||||
excluded = not matched if include_mode else matched
|
||||
if guarded or excluded:
|
||||
write_preserved_tensor(
|
||||
writer,
|
||||
@@ -2058,6 +2062,7 @@ class ConsensusMergerLogic:
|
||||
mismatch_mode = params["mismatch_mode"]
|
||||
include_1d = params.get("include_1d_diffs", False)
|
||||
glob_mode = params["glob_patterns"]
|
||||
include_mode = params.get("include_mode", False)
|
||||
exclude = _compile_patterns(params["exclude_patterns"], glob_mode=glob_mode)
|
||||
discard = _compile_patterns(params["discard_patterns"], glob_mode=glob_mode)
|
||||
written = set()
|
||||
@@ -2266,10 +2271,11 @@ class ConsensusMergerLogic:
|
||||
keys is not None and has_non_alpha_low_bit(keys, low_bit_sets[index])
|
||||
for index, _, keys in matches
|
||||
)
|
||||
excluded = any(
|
||||
matched = any(
|
||||
_matches_any_pattern(key, exclude, glob_mode=glob_mode)
|
||||
for key in layer_keys
|
||||
)
|
||||
excluded = not matched if include_mode else matched
|
||||
companion_bearing = any(
|
||||
keys is not None and layer_has_companions(keys)
|
||||
for _, _, keys in matches
|
||||
@@ -2647,6 +2653,11 @@ def _common_inputs(
|
||||
default=False,
|
||||
tooltip="Force generated tensors to save_dtype; guarded tensors and enabled 1D direct diffs are exempt.",
|
||||
),
|
||||
io.Boolean.Input(
|
||||
"include_mode",
|
||||
default=False,
|
||||
tooltip="Use Exclude Patterns as a whitelist instead. Only matching layers are merged; nonmatching layers are preserved from the anchor.",
|
||||
),
|
||||
])
|
||||
return inputs
|
||||
|
||||
@@ -2705,11 +2716,13 @@ class _CWBMergerNode(io.ComfyNode):
|
||||
cls.DEFAULT_PRESET,
|
||||
)
|
||||
if cls.LORA_MODE:
|
||||
include_mode = inputs.pop()
|
||||
inputs.append(io.Boolean.Input(
|
||||
"include_1d_diffs",
|
||||
default=False,
|
||||
tooltip="CWB-merge 1D direct diffs as FP32. Disabled preserves Model A.",
|
||||
))
|
||||
inputs.append(include_mode)
|
||||
return io.Schema(
|
||||
node_id=cls.NODE_ID,
|
||||
display_name=cls.DISPLAY_NAME,
|
||||
@@ -2838,6 +2851,7 @@ class CWBLoRAMultiMerger(io.ComfyNode):
|
||||
io.Boolean.Input("override_dtype", default=False, tooltip="Force generated floating factors to the requested save dtype."),
|
||||
io.Boolean.Input("include_1d_diffs", default=False, tooltip="CWB-merge 1D direct differences as FP32 instead of preserving the anchor."),
|
||||
io.Boolean.Input("counterfactual_weight_sweep", default=False, tooltip="Evaluate alpha, beta, similarity-threshold, DSC, and comfort-bandpass weight combinations from each already-computed consensus similarity vector and append them to the report without additional model loads or saved outputs."),
|
||||
io.Boolean.Input("include_mode", default=False, tooltip="Use Exclude Patterns as a whitelist instead. Only matching layers are merged; nonmatching layers are preserved from the anchor."),
|
||||
],
|
||||
outputs=[
|
||||
io.AnyType.Output(display_name="output_filename"),
|
||||
|
||||
@@ -202,6 +202,7 @@ def _build_layer_units(
|
||||
exclude_patterns,
|
||||
discard_patterns,
|
||||
glob_patterns,
|
||||
include_mode=False,
|
||||
):
|
||||
units = []
|
||||
stream_keys = [[] for _ in handlers]
|
||||
@@ -213,7 +214,8 @@ def _build_layer_units(
|
||||
if _matches_pattern(output_key, discard_patterns, glob_patterns):
|
||||
rejected += 1
|
||||
continue
|
||||
preserve = _matches_pattern(output_key, exclude_patterns, glob_patterns)
|
||||
matched = _matches_pattern(output_key, exclude_patterns, glob_patterns)
|
||||
preserve = not matched if include_mode else matched
|
||||
candidates = []
|
||||
shapes = {}
|
||||
for index, block in sorted(mapped):
|
||||
@@ -414,6 +416,7 @@ class DeltaCWBLoRAMergerLogic:
|
||||
discard_patterns,
|
||||
glob_patterns,
|
||||
force_clear_cache,
|
||||
include_mode=False,
|
||||
):
|
||||
prepare_for_large_operation(
|
||||
sum(estimate_model_size(path) for path in lora_paths) * 2.5,
|
||||
@@ -439,6 +442,7 @@ class DeltaCWBLoRAMergerLogic:
|
||||
compiled_excludes,
|
||||
compiled_discards,
|
||||
glob_patterns,
|
||||
include_mode,
|
||||
)
|
||||
report.rejected_unsupported_layers = rejected
|
||||
cursors = [
|
||||
@@ -547,6 +551,7 @@ class DeltaCWBLoRAMergerLogic:
|
||||
kwargs["discard_patterns"],
|
||||
kwargs["glob_patterns"],
|
||||
kwargs["force_clear_cache"],
|
||||
kwargs.get("include_mode", False),
|
||||
)
|
||||
return io.NodeOutput(filename, report)
|
||||
|
||||
@@ -638,6 +643,11 @@ def _fixed_inputs(count, default_filename):
|
||||
default=True,
|
||||
tooltip="Clear Python and CUDA caches before each layer to reduce retained memory.",
|
||||
),
|
||||
io.Boolean.Input(
|
||||
"include_mode",
|
||||
default=False,
|
||||
tooltip="Use Exclude Patterns as a whitelist instead. Only matching layers are merged; nonmatching layers are preserved from LoRA 1.",
|
||||
),
|
||||
])
|
||||
return inputs
|
||||
|
||||
|
||||
+14
-12
@@ -497,6 +497,7 @@ def extract_dora_from_files(
|
||||
force_clear_cache: bool = True,
|
||||
glob_skip_patterns: bool = False,
|
||||
knee_probe_offset: int = 32,
|
||||
include_mode: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Extract LoRA from difference between two models, writing incrementally to disk.
|
||||
@@ -543,7 +544,7 @@ def extract_dora_from_files(
|
||||
work_units = [
|
||||
(key, key, key if key in keys_b else None)
|
||||
for key in weight_keys
|
||||
if not _matches_any_pattern(key, skip_patterns, glob_mode=glob_skip_patterns)
|
||||
if (not _matches_any_pattern(key, skip_patterns, glob_mode=glob_skip_patterns) if not include_mode else _matches_any_pattern(key, skip_patterns, glob_mode=glob_skip_patterns))
|
||||
and not (key not in keys_b and mismatch_mode == "skip")
|
||||
]
|
||||
pbar = comfy.utils.ProgressBar(len(work_units))
|
||||
@@ -554,7 +555,7 @@ def extract_dora_from_files(
|
||||
weight_diff = None
|
||||
layer_device = device
|
||||
|
||||
if _matches_any_pattern(key, skip_patterns, glob_mode=glob_skip_patterns):
|
||||
if (_matches_any_pattern(key, skip_patterns, glob_mode=glob_skip_patterns) if not include_mode else not _matches_any_pattern(key, skip_patterns, glob_mode=glob_skip_patterns)):
|
||||
return "skipped", None
|
||||
|
||||
# Load tensors with pinned memory for CUDA
|
||||
@@ -773,6 +774,7 @@ def _get_common_inputs():
|
||||
io.Boolean.Input("glob_skip_patterns", default=False,
|
||||
tooltip="When True, skip_patterns use glob syntax (* = any sequence, ? = any char, dots are literal). "
|
||||
"When False (default), patterns are Python regex matched as substrings."),
|
||||
io.Boolean.Input("include_mode", default=False, tooltip="Use Skip Patterns as a whitelist instead. Only matching layers are extracted; an empty whitelist extracts nothing."),
|
||||
]
|
||||
|
||||
|
||||
@@ -803,7 +805,7 @@ class DoRAExtractFixed(io.ComfyNode):
|
||||
@classmethod
|
||||
def execute(cls, model_a, model_b, linear_dim, conv_dim, svd_niter, chunk_large_layers,
|
||||
clamp_quantile, min_diff, mismatch_mode, output_filename,
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache) -> io.NodeOutput:
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache, include_mode=False) -> io.NodeOutput:
|
||||
|
||||
model_a_path = folder_paths.get_full_path_or_raise("diffusion_models", model_a)
|
||||
model_b_path = folder_paths.get_full_path_or_raise("diffusion_models", model_b)
|
||||
@@ -813,7 +815,7 @@ class DoRAExtractFixed(io.ComfyNode):
|
||||
model_a_path, model_b_path, "fixed", linear_dim, conv_dim,
|
||||
device, save_dtype, output_path, linear_dim, conv_dim,
|
||||
clamp_quantile, min_diff, skip_patterns, mismatch_mode, chunk_large_layers, svd_niter,
|
||||
lazy_load, force_clear_cache, glob_skip_patterns
|
||||
lazy_load, force_clear_cache, glob_skip_patterns, include_mode=include_mode
|
||||
)
|
||||
|
||||
return io.NodeOutput(output_name)
|
||||
@@ -850,7 +852,7 @@ class DoRAExtractRatio(io.ComfyNode):
|
||||
@classmethod
|
||||
def execute(cls, model_a, model_b, linear_ratio, conv_ratio, probe_offset, linear_max_rank, conv_max_rank,
|
||||
chunk_large_layers, clamp_quantile, min_diff, mismatch_mode, output_filename,
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache) -> io.NodeOutput:
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache, include_mode=False) -> io.NodeOutput:
|
||||
|
||||
model_a_path = folder_paths.get_full_path_or_raise("diffusion_models", model_a)
|
||||
model_b_path = folder_paths.get_full_path_or_raise("diffusion_models", model_b)
|
||||
@@ -861,7 +863,7 @@ class DoRAExtractRatio(io.ComfyNode):
|
||||
device, save_dtype, output_path, linear_max_rank, conv_max_rank,
|
||||
clamp_quantile, min_diff, skip_patterns, mismatch_mode, chunk_large_layers,
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache,
|
||||
glob_skip_patterns=glob_skip_patterns, knee_probe_offset=probe_offset
|
||||
glob_skip_patterns=glob_skip_patterns, knee_probe_offset=probe_offset, include_mode=include_mode
|
||||
)
|
||||
|
||||
return io.NodeOutput(output_name)
|
||||
@@ -898,7 +900,7 @@ class DoRAExtractQuantile(io.ComfyNode):
|
||||
@classmethod
|
||||
def execute(cls, model_a, model_b, linear_quantile, conv_quantile, probe_offset, linear_max_rank, conv_max_rank,
|
||||
chunk_large_layers, clamp_quantile, min_diff, mismatch_mode, output_filename,
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache) -> io.NodeOutput:
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache, include_mode=False) -> io.NodeOutput:
|
||||
|
||||
model_a_path = folder_paths.get_full_path_or_raise("diffusion_models", model_a)
|
||||
model_b_path = folder_paths.get_full_path_or_raise("diffusion_models", model_b)
|
||||
@@ -909,7 +911,7 @@ class DoRAExtractQuantile(io.ComfyNode):
|
||||
device, save_dtype, output_path, linear_max_rank, conv_max_rank,
|
||||
clamp_quantile, min_diff, skip_patterns, mismatch_mode, chunk_large_layers,
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache,
|
||||
glob_skip_patterns=glob_skip_patterns, knee_probe_offset=probe_offset
|
||||
glob_skip_patterns=glob_skip_patterns, knee_probe_offset=probe_offset, include_mode=include_mode
|
||||
)
|
||||
|
||||
return io.NodeOutput(output_name)
|
||||
@@ -945,7 +947,7 @@ class DoRAExtractKnee(io.ComfyNode):
|
||||
def execute(cls, model_a, model_b, knee_method, knee_probe_offset,
|
||||
linear_max_rank, conv_max_rank,
|
||||
chunk_large_layers, clamp_quantile, min_diff, mismatch_mode, output_filename,
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache) -> io.NodeOutput:
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache, include_mode=False) -> io.NodeOutput:
|
||||
|
||||
model_a_path = folder_paths.get_full_path_or_raise("diffusion_models", model_a)
|
||||
model_b_path = folder_paths.get_full_path_or_raise("diffusion_models", model_b)
|
||||
@@ -956,7 +958,7 @@ class DoRAExtractKnee(io.ComfyNode):
|
||||
device, save_dtype, output_path, linear_max_rank, conv_max_rank,
|
||||
clamp_quantile, min_diff, skip_patterns, mismatch_mode, chunk_large_layers,
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache,
|
||||
glob_skip_patterns=glob_skip_patterns,
|
||||
glob_skip_patterns=glob_skip_patterns, include_mode=include_mode,
|
||||
knee_probe_offset=knee_probe_offset,
|
||||
)
|
||||
|
||||
@@ -994,7 +996,7 @@ class DoRAExtractFrobenius(io.ComfyNode):
|
||||
@classmethod
|
||||
def execute(cls, model_a, model_b, linear_target, conv_target, probe_offset, linear_max_rank, conv_max_rank,
|
||||
chunk_large_layers, clamp_quantile, min_diff, mismatch_mode, output_filename,
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache) -> io.NodeOutput:
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache, include_mode=False) -> io.NodeOutput:
|
||||
|
||||
model_a_path = folder_paths.get_full_path_or_raise("diffusion_models", model_a)
|
||||
model_b_path = folder_paths.get_full_path_or_raise("diffusion_models", model_b)
|
||||
@@ -1005,7 +1007,7 @@ class DoRAExtractFrobenius(io.ComfyNode):
|
||||
device, save_dtype, output_path, linear_max_rank, conv_max_rank,
|
||||
clamp_quantile, min_diff, skip_patterns, mismatch_mode, chunk_large_layers,
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache,
|
||||
glob_skip_patterns=glob_skip_patterns, knee_probe_offset=probe_offset
|
||||
glob_skip_patterns=glob_skip_patterns, knee_probe_offset=probe_offset, include_mode=include_mode
|
||||
)
|
||||
|
||||
return io.NodeOutput(output_name)
|
||||
|
||||
+13
-12
@@ -354,6 +354,7 @@ def extract_dora_learned_from_files(
|
||||
force_clear_cache: bool = True,
|
||||
glob_skip_patterns: bool = False,
|
||||
knee_probe_offset: int = 32,
|
||||
include_mode: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Extract Learned DoRA from difference between two models.
|
||||
@@ -380,7 +381,7 @@ def extract_dora_learned_from_files(
|
||||
work_units = [
|
||||
(key, key, key if key in keys_b else None)
|
||||
for key in weight_keys
|
||||
if not _matches_any_pattern(key, skip_patterns, glob_mode=glob_skip_patterns)
|
||||
if (not _matches_any_pattern(key, skip_patterns, glob_mode=glob_skip_patterns) if not include_mode else _matches_any_pattern(key, skip_patterns, glob_mode=glob_skip_patterns))
|
||||
and not (key not in keys_b and mismatch_mode == "skip")
|
||||
]
|
||||
pbar = comfy.utils.ProgressBar(len(work_units))
|
||||
@@ -393,7 +394,7 @@ def extract_dora_learned_from_files(
|
||||
tensor_ft = None
|
||||
layer_device = device
|
||||
|
||||
if _matches_any_pattern(key, skip_patterns, glob_mode=glob_skip_patterns):
|
||||
if (_matches_any_pattern(key, skip_patterns, glob_mode=glob_skip_patterns) if not include_mode else not _matches_any_pattern(key, skip_patterns, glob_mode=glob_skip_patterns)):
|
||||
return "skipped", None
|
||||
|
||||
if key not in keys_b:
|
||||
@@ -597,7 +598,7 @@ class DoRALearnedExtractFixed(io.ComfyNode):
|
||||
@classmethod
|
||||
def execute(cls, model_a, model_b, linear_dim, conv_dim, optimize_iters, learning_rate, optimizer, lr_schedule, lr_patience, lr_factor, lr_cooldown, early_stop_loss, early_stop_stall, early_stop_lr, svd_niter, chunk_large_layers,
|
||||
clamp_quantile, min_diff, mismatch_mode, output_filename,
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache) -> io.NodeOutput:
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache, include_mode=False) -> io.NodeOutput:
|
||||
|
||||
model_a_path = folder_paths.get_full_path_or_raise("diffusion_models", model_a)
|
||||
model_b_path = folder_paths.get_full_path_or_raise("diffusion_models", model_b)
|
||||
@@ -607,7 +608,7 @@ class DoRALearnedExtractFixed(io.ComfyNode):
|
||||
model_a_path, model_b_path, "fixed", linear_dim, conv_dim,
|
||||
device, save_dtype, output_path, optimize_iters, learning_rate, optimizer, lr_schedule, lr_patience, lr_factor, lr_cooldown, early_stop_loss, early_stop_stall, early_stop_lr, linear_dim, conv_dim,
|
||||
clamp_quantile, min_diff, skip_patterns, mismatch_mode, chunk_large_layers, svd_niter,
|
||||
lazy_load, force_clear_cache, glob_skip_patterns
|
||||
lazy_load, force_clear_cache, glob_skip_patterns, include_mode=include_mode
|
||||
)
|
||||
|
||||
return io.NodeOutput(output_name)
|
||||
@@ -641,7 +642,7 @@ class DoRALearnedExtractRatio(io.ComfyNode):
|
||||
@classmethod
|
||||
def execute(cls, model_a, model_b, linear_ratio, conv_ratio, probe_offset, linear_max_rank, conv_max_rank,
|
||||
optimize_iters, learning_rate, optimizer, lr_schedule, lr_patience, lr_factor, lr_cooldown, early_stop_loss, early_stop_stall, early_stop_lr, chunk_large_layers, clamp_quantile, min_diff, mismatch_mode,
|
||||
output_filename, save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache) -> io.NodeOutput:
|
||||
output_filename, save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache, include_mode=False) -> io.NodeOutput:
|
||||
|
||||
model_a_path = folder_paths.get_full_path_or_raise("diffusion_models", model_a)
|
||||
model_b_path = folder_paths.get_full_path_or_raise("diffusion_models", model_b)
|
||||
@@ -651,7 +652,7 @@ class DoRALearnedExtractRatio(io.ComfyNode):
|
||||
model_a_path, model_b_path, "ratio", linear_ratio, conv_ratio,
|
||||
device, save_dtype, output_path, optimize_iters, learning_rate, optimizer, lr_schedule, lr_patience, lr_factor, lr_cooldown, early_stop_loss, early_stop_stall, early_stop_lr, linear_max_rank, conv_max_rank,
|
||||
clamp_quantile, min_diff, skip_patterns, mismatch_mode, chunk_large_layers,
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache, glob_skip_patterns=glob_skip_patterns,
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache, glob_skip_patterns=glob_skip_patterns, include_mode=include_mode,
|
||||
knee_probe_offset=probe_offset
|
||||
)
|
||||
|
||||
@@ -686,7 +687,7 @@ class DoRALearnedExtractQuantile(io.ComfyNode):
|
||||
@classmethod
|
||||
def execute(cls, model_a, model_b, linear_quantile, conv_quantile, probe_offset, linear_max_rank, conv_max_rank,
|
||||
optimize_iters, learning_rate, optimizer, lr_schedule, lr_patience, lr_factor, lr_cooldown, early_stop_loss, early_stop_stall, early_stop_lr, chunk_large_layers, clamp_quantile, min_diff, mismatch_mode,
|
||||
output_filename, save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache) -> io.NodeOutput:
|
||||
output_filename, save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache, include_mode=False) -> io.NodeOutput:
|
||||
|
||||
model_a_path = folder_paths.get_full_path_or_raise("diffusion_models", model_a)
|
||||
model_b_path = folder_paths.get_full_path_or_raise("diffusion_models", model_b)
|
||||
@@ -696,7 +697,7 @@ class DoRALearnedExtractQuantile(io.ComfyNode):
|
||||
model_a_path, model_b_path, "quantile", linear_quantile, conv_quantile,
|
||||
device, save_dtype, output_path, optimize_iters, learning_rate, optimizer, lr_schedule, lr_patience, lr_factor, lr_cooldown, early_stop_loss, early_stop_stall, early_stop_lr, linear_max_rank, conv_max_rank,
|
||||
clamp_quantile, min_diff, skip_patterns, mismatch_mode, chunk_large_layers,
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache, glob_skip_patterns=glob_skip_patterns,
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache, glob_skip_patterns=glob_skip_patterns, include_mode=include_mode,
|
||||
knee_probe_offset=probe_offset
|
||||
)
|
||||
|
||||
@@ -731,7 +732,7 @@ class DoRALearnedExtractKnee(io.ComfyNode):
|
||||
def execute(cls, model_a, model_b, knee_method, knee_probe_offset,
|
||||
linear_max_rank, conv_max_rank,
|
||||
optimize_iters, learning_rate, optimizer, lr_schedule, lr_patience, lr_factor, lr_cooldown, early_stop_loss, early_stop_stall, early_stop_lr, chunk_large_layers, clamp_quantile, min_diff, mismatch_mode,
|
||||
output_filename, save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache) -> io.NodeOutput:
|
||||
output_filename, save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache, include_mode=False) -> io.NodeOutput:
|
||||
|
||||
model_a_path = folder_paths.get_full_path_or_raise("diffusion_models", model_a)
|
||||
model_b_path = folder_paths.get_full_path_or_raise("diffusion_models", model_b)
|
||||
@@ -742,7 +743,7 @@ class DoRALearnedExtractKnee(io.ComfyNode):
|
||||
device, save_dtype, output_path, optimize_iters, learning_rate, optimizer, lr_schedule, lr_patience, lr_factor, lr_cooldown, early_stop_loss, early_stop_stall, early_stop_lr, linear_max_rank, conv_max_rank,
|
||||
clamp_quantile, min_diff, skip_patterns, mismatch_mode, chunk_large_layers,
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache,
|
||||
glob_skip_patterns=glob_skip_patterns, knee_probe_offset=knee_probe_offset
|
||||
glob_skip_patterns=glob_skip_patterns, knee_probe_offset=knee_probe_offset, include_mode=include_mode
|
||||
)
|
||||
|
||||
return io.NodeOutput(output_name)
|
||||
@@ -776,7 +777,7 @@ class DoRALearnedExtractFrobenius(io.ComfyNode):
|
||||
@classmethod
|
||||
def execute(cls, model_a, model_b, linear_target, conv_target, probe_offset, linear_max_rank, conv_max_rank,
|
||||
optimize_iters, learning_rate, optimizer, lr_schedule, lr_patience, lr_factor, lr_cooldown, early_stop_loss, early_stop_stall, early_stop_lr, chunk_large_layers, clamp_quantile, min_diff, mismatch_mode,
|
||||
output_filename, save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache) -> io.NodeOutput:
|
||||
output_filename, save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache, include_mode=False) -> io.NodeOutput:
|
||||
|
||||
model_a_path = folder_paths.get_full_path_or_raise("diffusion_models", model_a)
|
||||
model_b_path = folder_paths.get_full_path_or_raise("diffusion_models", model_b)
|
||||
@@ -786,7 +787,7 @@ class DoRALearnedExtractFrobenius(io.ComfyNode):
|
||||
model_a_path, model_b_path, "sv_fro", linear_target, conv_target,
|
||||
device, save_dtype, output_path, optimize_iters, learning_rate, optimizer, lr_schedule, lr_patience, lr_factor, lr_cooldown, early_stop_loss, early_stop_stall, early_stop_lr, linear_max_rank, conv_max_rank,
|
||||
clamp_quantile, min_diff, skip_patterns, mismatch_mode, chunk_large_layers,
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache, glob_skip_patterns=glob_skip_patterns,
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache, glob_skip_patterns=glob_skip_patterns, include_mode=include_mode,
|
||||
knee_probe_offset=probe_offset
|
||||
)
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ def convert_diffusion_model_dtype(
|
||||
exclude_patterns: str,
|
||||
output_filename: str,
|
||||
reference_model: str | None = None,
|
||||
include_mode: bool = False,
|
||||
) -> tuple[str, str]:
|
||||
try:
|
||||
destination_dtype = TARGET_DTYPES[target_dtype]
|
||||
@@ -104,7 +105,7 @@ def convert_diffusion_model_dtype(
|
||||
key, source = batch[0]
|
||||
original_dtypes[str(source.dtype).removeprefix("torch.")] += 1
|
||||
output = source
|
||||
if _is_excluded(key, exclusions):
|
||||
if _is_excluded(key, exclusions) != include_mode:
|
||||
counts["excluded"] += 1
|
||||
elif not source.is_floating_point():
|
||||
counts["non_floating"] += 1
|
||||
@@ -175,7 +176,8 @@ class DiffusionModelDtypeConversion(io.ComfyNode):
|
||||
description=(
|
||||
"Streams a diffusion-model safetensors file into fp32, fp16, "
|
||||
"or bf16. Floating tensors matching an exclusion regex retain "
|
||||
"their original dtype; non-floating tensors are always preserved."
|
||||
"their original dtype, or enable Include Mode to convert only matches; "
|
||||
"non-floating tensors are always preserved."
|
||||
),
|
||||
inputs=[
|
||||
io.Combo.Input(
|
||||
@@ -185,7 +187,7 @@ class DiffusionModelDtypeConversion(io.ComfyNode):
|
||||
),
|
||||
io.Combo.Input(
|
||||
"target_dtype", options=["fp32", "fp16", "bf16"],
|
||||
tooltip="Target dtype for floating tensors not matched by an exclusion pattern.",
|
||||
tooltip="Target dtype for floating tensors selected by the layer filter.",
|
||||
),
|
||||
io.Combo.Input(
|
||||
"reference_model",
|
||||
@@ -199,13 +201,18 @@ class DiffusionModelDtypeConversion(io.ComfyNode):
|
||||
multiline=True,
|
||||
tooltip=(
|
||||
"Optional Python regex patterns, one per line. Matching "
|
||||
"tensor keys retain their original dtype."
|
||||
"tensor keys retain their original dtype, or are the only keys "
|
||||
"converted when Include Mode is enabled."
|
||||
),
|
||||
),
|
||||
io.String.Input(
|
||||
"output_filename", default="converted_model",
|
||||
tooltip="Output filename without extension, written under ComfyUI's diffusion-model directory.",
|
||||
),
|
||||
io.Boolean.Input(
|
||||
"include_mode", default=False,
|
||||
tooltip="Use Exclude Patterns as an include-only filter. Only matching tensors are converted; an empty filter converts nothing. Nonmatches retain their original dtype.",
|
||||
),
|
||||
],
|
||||
outputs=[
|
||||
io.AnyType.Output(display_name="output_path"),
|
||||
@@ -222,6 +229,7 @@ class DiffusionModelDtypeConversion(io.ComfyNode):
|
||||
reference_model: str,
|
||||
exclude_patterns: str,
|
||||
output_filename: str,
|
||||
include_mode: bool = False,
|
||||
) -> io.NodeOutput:
|
||||
output_path, report = convert_diffusion_model_dtype(
|
||||
model_name,
|
||||
@@ -229,5 +237,6 @@ class DiffusionModelDtypeConversion(io.ComfyNode):
|
||||
exclude_patterns,
|
||||
output_filename,
|
||||
reference_model,
|
||||
include_mode,
|
||||
)
|
||||
return io.NodeOutput(output_path, report)
|
||||
|
||||
@@ -92,7 +92,7 @@ def _is_cuda_oom(error):
|
||||
)
|
||||
|
||||
|
||||
def _build_units(layer_map, infos, handlers, mismatch_mode, exclude_patterns, discard_patterns, glob_patterns):
|
||||
def _build_units(layer_map, infos, handlers, mismatch_mode, exclude_patterns, discard_patterns, glob_patterns, include_mode=False):
|
||||
units = []
|
||||
stream_keys = [[] for _ in handlers]
|
||||
for core, mapped in layer_map.items():
|
||||
@@ -101,7 +101,8 @@ def _build_units(layer_map, infos, handlers, mismatch_mode, exclude_patterns, di
|
||||
output_key = canonical_lora_key(core, "diff")
|
||||
if _matches(output_key, discard_patterns, glob_patterns):
|
||||
continue
|
||||
preserve = _matches(output_key, exclude_patterns, glob_patterns)
|
||||
matched = _matches(output_key, exclude_patterns, glob_patterns)
|
||||
preserve = not matched if include_mode else matched
|
||||
sources = []
|
||||
for index, block in sorted(mapped):
|
||||
roles = infos[index]["pairs"][block]
|
||||
@@ -178,7 +179,7 @@ def _process(unit, loaded, method, save_dtype, requested_device):
|
||||
|
||||
def merge_lodestone_loras(lora_paths, method, device, save_dtype, output_filename,
|
||||
mismatch_mode="skip", exclude_patterns="", discard_patterns="",
|
||||
glob_patterns=False, verbose=True):
|
||||
glob_patterns=False, verbose=True, include_mode=False):
|
||||
operation = "Lodestone LoRA Merge"
|
||||
prepare_for_large_operation(sum(estimate_model_size(path) for path in lora_paths) * 2.5, torch.device(device))
|
||||
handlers = [MemoryEfficientSafeOpen(path, low_memory=True) for path in lora_paths]
|
||||
@@ -192,7 +193,7 @@ def merge_lodestone_loras(lora_paths, method, device, save_dtype, output_filenam
|
||||
layer_map = build_lora_layer_map(infos, None)
|
||||
validate_canonical_blocks(layer_map, operation)
|
||||
units, stream_keys = _build_units(
|
||||
layer_map, infos, handlers, mismatch_mode, exclude_patterns, discard_patterns, glob_patterns
|
||||
layer_map, infos, handlers, mismatch_mode, exclude_patterns, discard_patterns, glob_patterns, include_mode
|
||||
)
|
||||
cursors = [
|
||||
AsyncTensorCursor(handler, keys, pin_memory=torch.device(device).type == "cuda")
|
||||
@@ -244,6 +245,7 @@ def _controls(default_filename):
|
||||
io.String.Input("exclude_patterns", default="", multiline=True, tooltip="Optional layer-name patterns to copy from LoRA 1 instead of merging. Enter one pattern per line; useful for protecting specific blocks."),
|
||||
io.String.Input("discard_patterns", default="", multiline=True, tooltip="Optional layer-name patterns to leave out of the saved LoRA completely. Enter one pattern per line; discarded layers cannot affect the model."),
|
||||
io.Boolean.Input("glob_patterns", default=False, tooltip="Choose filter syntax. Off uses regular expressions; on uses shell-style globs where * matches any text and ? matches one character."),
|
||||
io.Boolean.Input("include_mode", default=False, tooltip="Use Exclude Patterns as a whitelist instead. Only matching layers are merged; nonmatching layers are preserved from LoRA 1."),
|
||||
]
|
||||
|
||||
|
||||
@@ -252,7 +254,7 @@ def _execute(kwargs, count):
|
||||
dtype = {"fp32": torch.float32, "fp16": torch.float16, "bf16": torch.bfloat16}[kwargs["save_dtype"]]
|
||||
merge_lodestone_loras(paths, kwargs["calc_mode"], kwargs["process_device"], dtype,
|
||||
kwargs["output_filename"], kwargs["mismatch_mode"],
|
||||
kwargs["exclude_patterns"], kwargs["discard_patterns"], kwargs["glob_patterns"])
|
||||
kwargs["exclude_patterns"], kwargs["discard_patterns"], kwargs["glob_patterns"], include_mode=kwargs.get("include_mode", False))
|
||||
return canonical_model_artifact_path("loras", kwargs["output_filename"])[1]
|
||||
|
||||
|
||||
|
||||
+14
-12
@@ -499,6 +499,7 @@ def extract_lora_from_files(
|
||||
glob_skip_patterns: bool = False,
|
||||
include_1d_diffs: bool = False,
|
||||
knee_probe_offset: int = 32,
|
||||
include_mode: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Extract LoRA from difference between two models, writing incrementally to disk.
|
||||
@@ -560,7 +561,7 @@ def extract_lora_from_files(
|
||||
work_units = [
|
||||
(key, key, key if key in keys_b else None)
|
||||
for key in extraction_keys
|
||||
if not _matches_any_pattern(key, skip_patterns, glob_mode=glob_skip_patterns)
|
||||
if (not _matches_any_pattern(key, skip_patterns, glob_mode=glob_skip_patterns) if not include_mode else _matches_any_pattern(key, skip_patterns, glob_mode=glob_skip_patterns))
|
||||
and not (key not in keys_b and mismatch_mode == "skip")
|
||||
]
|
||||
pbar = comfy.utils.ProgressBar(len(work_units))
|
||||
@@ -571,7 +572,7 @@ def extract_lora_from_files(
|
||||
weight_diff = None
|
||||
layer_device = device
|
||||
|
||||
if _matches_any_pattern(key, skip_patterns, glob_mode=glob_skip_patterns):
|
||||
if (_matches_any_pattern(key, skip_patterns, glob_mode=glob_skip_patterns) if not include_mode else not _matches_any_pattern(key, skip_patterns, glob_mode=glob_skip_patterns)):
|
||||
return "skipped", None
|
||||
|
||||
source_dtypes = [handler_a.get_dtype(key)]
|
||||
@@ -808,6 +809,7 @@ def _get_common_inputs():
|
||||
"When False (default), patterns are Python regex matched as substrings."),
|
||||
io.Boolean.Input("include_1d_diffs", default=False,
|
||||
tooltip="Include 1D tensors as FP32 and keys ending in .scale or .lin as full .diff patches"),
|
||||
io.Boolean.Input("include_mode", default=False, tooltip="Use Skip Patterns as a whitelist instead. Only matching layers are extracted; an empty whitelist extracts nothing."),
|
||||
]
|
||||
|
||||
|
||||
@@ -839,7 +841,7 @@ class LoRAExtractFixed(io.ComfyNode):
|
||||
def execute(cls, model_a, model_b, linear_dim, conv_dim, svd_niter, chunk_large_layers,
|
||||
clamp_quantile, min_diff, mismatch_mode, output_filename,
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache,
|
||||
include_1d_diffs=False) -> io.NodeOutput:
|
||||
include_1d_diffs=False, include_mode=False) -> io.NodeOutput:
|
||||
|
||||
model_a_path = folder_paths.get_full_path_or_raise("diffusion_models", model_a)
|
||||
model_b_path = folder_paths.get_full_path_or_raise("diffusion_models", model_b)
|
||||
@@ -849,7 +851,7 @@ class LoRAExtractFixed(io.ComfyNode):
|
||||
model_a_path, model_b_path, "fixed", linear_dim, conv_dim,
|
||||
device, save_dtype, output_path, linear_dim, conv_dim,
|
||||
clamp_quantile, min_diff, skip_patterns, mismatch_mode, chunk_large_layers, svd_niter,
|
||||
lazy_load, force_clear_cache, glob_skip_patterns, include_1d_diffs
|
||||
lazy_load, force_clear_cache, glob_skip_patterns, include_1d_diffs, include_mode=include_mode
|
||||
)
|
||||
|
||||
return io.NodeOutput(output_name)
|
||||
@@ -887,7 +889,7 @@ class LoRAExtractRatio(io.ComfyNode):
|
||||
def execute(cls, model_a, model_b, linear_ratio, conv_ratio, probe_offset, linear_max_rank, conv_max_rank,
|
||||
chunk_large_layers, clamp_quantile, min_diff, mismatch_mode, output_filename,
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache,
|
||||
include_1d_diffs=False) -> io.NodeOutput:
|
||||
include_1d_diffs=False, include_mode=False) -> io.NodeOutput:
|
||||
|
||||
model_a_path = folder_paths.get_full_path_or_raise("diffusion_models", model_a)
|
||||
model_b_path = folder_paths.get_full_path_or_raise("diffusion_models", model_b)
|
||||
@@ -898,7 +900,7 @@ class LoRAExtractRatio(io.ComfyNode):
|
||||
device, save_dtype, output_path, linear_max_rank, conv_max_rank,
|
||||
clamp_quantile, min_diff, skip_patterns, mismatch_mode, chunk_large_layers,
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache,
|
||||
glob_skip_patterns=glob_skip_patterns, include_1d_diffs=include_1d_diffs,
|
||||
glob_skip_patterns=glob_skip_patterns, include_1d_diffs=include_1d_diffs, include_mode=include_mode,
|
||||
knee_probe_offset=probe_offset,
|
||||
)
|
||||
|
||||
@@ -937,7 +939,7 @@ class LoRAExtractQuantile(io.ComfyNode):
|
||||
def execute(cls, model_a, model_b, linear_quantile, conv_quantile, probe_offset, linear_max_rank, conv_max_rank,
|
||||
chunk_large_layers, clamp_quantile, min_diff, mismatch_mode, output_filename,
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache,
|
||||
include_1d_diffs=False) -> io.NodeOutput:
|
||||
include_1d_diffs=False, include_mode=False) -> io.NodeOutput:
|
||||
|
||||
model_a_path = folder_paths.get_full_path_or_raise("diffusion_models", model_a)
|
||||
model_b_path = folder_paths.get_full_path_or_raise("diffusion_models", model_b)
|
||||
@@ -948,7 +950,7 @@ class LoRAExtractQuantile(io.ComfyNode):
|
||||
device, save_dtype, output_path, linear_max_rank, conv_max_rank,
|
||||
clamp_quantile, min_diff, skip_patterns, mismatch_mode, chunk_large_layers,
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache,
|
||||
glob_skip_patterns=glob_skip_patterns, include_1d_diffs=include_1d_diffs,
|
||||
glob_skip_patterns=glob_skip_patterns, include_1d_diffs=include_1d_diffs, include_mode=include_mode,
|
||||
knee_probe_offset=probe_offset,
|
||||
)
|
||||
|
||||
@@ -986,7 +988,7 @@ class LoRAExtractKnee(io.ComfyNode):
|
||||
linear_max_rank, conv_max_rank,
|
||||
chunk_large_layers, clamp_quantile, min_diff, mismatch_mode, output_filename,
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache,
|
||||
include_1d_diffs=False) -> io.NodeOutput:
|
||||
include_1d_diffs=False, include_mode=False) -> io.NodeOutput:
|
||||
|
||||
model_a_path = folder_paths.get_full_path_or_raise("diffusion_models", model_a)
|
||||
model_b_path = folder_paths.get_full_path_or_raise("diffusion_models", model_b)
|
||||
@@ -997,7 +999,7 @@ class LoRAExtractKnee(io.ComfyNode):
|
||||
device, save_dtype, output_path, linear_max_rank, conv_max_rank,
|
||||
clamp_quantile, min_diff, skip_patterns, mismatch_mode, chunk_large_layers,
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache,
|
||||
glob_skip_patterns=glob_skip_patterns, include_1d_diffs=include_1d_diffs,
|
||||
glob_skip_patterns=glob_skip_patterns, include_1d_diffs=include_1d_diffs, include_mode=include_mode,
|
||||
knee_probe_offset=knee_probe_offset,
|
||||
)
|
||||
|
||||
@@ -1036,7 +1038,7 @@ class LoRAExtractFrobenius(io.ComfyNode):
|
||||
def execute(cls, model_a, model_b, linear_target, conv_target, probe_offset, linear_max_rank, conv_max_rank,
|
||||
chunk_large_layers, clamp_quantile, min_diff, mismatch_mode, output_filename,
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache,
|
||||
include_1d_diffs=False) -> io.NodeOutput:
|
||||
include_1d_diffs=False, include_mode=False) -> io.NodeOutput:
|
||||
|
||||
model_a_path = folder_paths.get_full_path_or_raise("diffusion_models", model_a)
|
||||
model_b_path = folder_paths.get_full_path_or_raise("diffusion_models", model_b)
|
||||
@@ -1047,7 +1049,7 @@ class LoRAExtractFrobenius(io.ComfyNode):
|
||||
device, save_dtype, output_path, linear_max_rank, conv_max_rank,
|
||||
clamp_quantile, min_diff, skip_patterns, mismatch_mode, chunk_large_layers,
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache,
|
||||
glob_skip_patterns=glob_skip_patterns, include_1d_diffs=include_1d_diffs,
|
||||
glob_skip_patterns=glob_skip_patterns, include_1d_diffs=include_1d_diffs, include_mode=include_mode,
|
||||
knee_probe_offset=probe_offset,
|
||||
)
|
||||
|
||||
|
||||
+33
-15
@@ -648,12 +648,14 @@ def _resize_filter_inputs():
|
||||
io.String.Input("exclude_patterns", default="", multiline=True, tooltip="One pattern per line. Matching layer names or source/canonical tensor keys keep the whole layer at its original rank and precision, including alpha."),
|
||||
io.String.Input("discard_patterns", default="", multiline=True, tooltip="One pattern per line. Remove the whole matching layer, including factors and alpha. Discard takes precedence over exclude."),
|
||||
io.Boolean.Input("glob_patterns", default=False, tooltip="Use shell-style glob patterns instead of regular expressions for exclude and discard filters."),
|
||||
io.Boolean.Input("include_mode", default=False, tooltip="Treat exclude patterns as an include list. Matching layers are resized; nonmatching layers are preserved unchanged. Discard patterns still remove matching layers."),
|
||||
]
|
||||
|
||||
|
||||
def _build_resize_work_units(
|
||||
handler, pairs, passthrough_keys, low_bit_keys,
|
||||
exclude_patterns="", discard_patterns="", glob_patterns=False,
|
||||
include_mode=False,
|
||||
):
|
||||
"""Plan raw-copy and streamed work without loading tensor payloads."""
|
||||
raw_units = []
|
||||
@@ -691,7 +693,8 @@ def _build_resize_work_units(
|
||||
if matches(discards, names):
|
||||
claimed.update(key for _, key, _ in group_entries)
|
||||
continue
|
||||
excluded = matches(excludes, names)
|
||||
matched = matches(excludes, names)
|
||||
preserve = matched != include_mode
|
||||
|
||||
if layer_has_low_bit(block_keys, low_bit_keys):
|
||||
raw_entries = entries + related_passthrough
|
||||
@@ -699,7 +702,7 @@ def _build_resize_work_units(
|
||||
claimed.update(key for _, key, _ in raw_entries)
|
||||
continue
|
||||
|
||||
if excluded:
|
||||
if preserve:
|
||||
stream_units.append({"kind": "preserve", "entries": group_entries})
|
||||
claimed.update(key for _, key, _ in group_entries)
|
||||
elif layer_has_companions(block_keys):
|
||||
@@ -725,11 +728,12 @@ def _build_resize_work_units(
|
||||
if matches(discards, [key]):
|
||||
continue
|
||||
entry = (None, key, key)
|
||||
matched = matches(excludes, [key])
|
||||
if key in low_bit_keys:
|
||||
raw_units.append({"entries": [entry]})
|
||||
else:
|
||||
stream_units.append({
|
||||
"kind": "preserve" if matches(excludes, [key]) else "copy",
|
||||
"kind": "preserve" if matched != include_mode else "copy",
|
||||
"entries": [entry],
|
||||
})
|
||||
claimed.add(key)
|
||||
@@ -902,6 +906,7 @@ def resize_lora_file(
|
||||
exclude_patterns: str = "",
|
||||
discard_patterns: str = "",
|
||||
glob_patterns: bool = False,
|
||||
include_mode: bool = False,
|
||||
) -> str:
|
||||
"""Resize a LoRA through one bounded UEL stream and factor-space SVD."""
|
||||
if min_rank < 1:
|
||||
@@ -925,7 +930,7 @@ def resize_lora_file(
|
||||
raw_units, stream_units, rank_counts, preserved_companion_groups = (
|
||||
_build_resize_work_units(
|
||||
handler, pairs, passthrough_keys, low_bit_keys,
|
||||
exclude_patterns, discard_patterns, glob_patterns,
|
||||
exclude_patterns, discard_patterns, glob_patterns, include_mode,
|
||||
)
|
||||
)
|
||||
rank_text = _rank_summary(rank_counts)
|
||||
@@ -948,6 +953,9 @@ def resize_lora_file(
|
||||
)
|
||||
metadata["ss_network_dim"] = "Dynamic"
|
||||
metadata["ss_network_alpha"] = "Dynamic"
|
||||
metadata["modelutils_resize_filter_mode"] = (
|
||||
"include" if include_mode else "exclude"
|
||||
)
|
||||
|
||||
output_path, _ = canonical_model_artifact_path("loras", output_filename)
|
||||
|
||||
@@ -1230,7 +1238,7 @@ class LoRAResizeFixed(io.ComfyNode):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, lora_name, new_rank, output_filename, save_dtype, device, force_clear_cache, exclude_patterns="", discard_patterns="", glob_patterns=False) -> io.NodeOutput:
|
||||
def execute(cls, lora_name, new_rank, output_filename, save_dtype, device, force_clear_cache, exclude_patterns="", discard_patterns="", glob_patterns=False, include_mode=False) -> io.NodeOutput:
|
||||
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
|
||||
dtype = {"fp16": torch.float16, "bf16": torch.bfloat16, "fp32": torch.float32}[save_dtype]
|
||||
|
||||
@@ -1240,6 +1248,7 @@ class LoRAResizeFixed(io.ComfyNode):
|
||||
exclude_patterns=exclude_patterns,
|
||||
discard_patterns=discard_patterns,
|
||||
glob_patterns=glob_patterns,
|
||||
include_mode=include_mode,
|
||||
)
|
||||
_, output_name = canonical_model_artifact_path("loras", output_filename)
|
||||
return io.NodeOutput(output_name)
|
||||
@@ -1273,7 +1282,7 @@ class LoRAResizeRatio(io.ComfyNode):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, lora_name, max_rank, ratio, output_filename, save_dtype, device, force_clear_cache, exclude_patterns="", discard_patterns="", glob_patterns=False) -> io.NodeOutput:
|
||||
def execute(cls, lora_name, max_rank, ratio, output_filename, save_dtype, device, force_clear_cache, exclude_patterns="", discard_patterns="", glob_patterns=False, include_mode=False) -> io.NodeOutput:
|
||||
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
|
||||
dtype = {"fp16": torch.float16, "bf16": torch.bfloat16, "fp32": torch.float32}[save_dtype]
|
||||
|
||||
@@ -1283,6 +1292,7 @@ class LoRAResizeRatio(io.ComfyNode):
|
||||
exclude_patterns=exclude_patterns,
|
||||
discard_patterns=discard_patterns,
|
||||
glob_patterns=glob_patterns,
|
||||
include_mode=include_mode,
|
||||
)
|
||||
_, output_name = canonical_model_artifact_path("loras", output_filename)
|
||||
return io.NodeOutput(output_name)
|
||||
@@ -1318,7 +1328,7 @@ class LoRAResizeFrobenius(io.ComfyNode):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, lora_name, max_rank, min_rank, target, output_filename, save_dtype, device, force_clear_cache, exclude_patterns="", discard_patterns="", glob_patterns=False) -> io.NodeOutput:
|
||||
def execute(cls, lora_name, max_rank, min_rank, target, output_filename, save_dtype, device, force_clear_cache, exclude_patterns="", discard_patterns="", glob_patterns=False, include_mode=False) -> io.NodeOutput:
|
||||
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
|
||||
dtype = {"fp16": torch.float16, "bf16": torch.bfloat16, "fp32": torch.float32}[save_dtype]
|
||||
|
||||
@@ -1328,6 +1338,7 @@ class LoRAResizeFrobenius(io.ComfyNode):
|
||||
exclude_patterns=exclude_patterns,
|
||||
discard_patterns=discard_patterns,
|
||||
glob_patterns=glob_patterns,
|
||||
include_mode=include_mode,
|
||||
min_rank=min_rank,
|
||||
)
|
||||
_, output_name = canonical_model_artifact_path("loras", output_filename)
|
||||
@@ -1362,7 +1373,7 @@ class LoRAResizeCumulative(io.ComfyNode):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, lora_name, max_rank, target, output_filename, save_dtype, device, force_clear_cache, exclude_patterns="", discard_patterns="", glob_patterns=False) -> io.NodeOutput:
|
||||
def execute(cls, lora_name, max_rank, target, output_filename, save_dtype, device, force_clear_cache, exclude_patterns="", discard_patterns="", glob_patterns=False, include_mode=False) -> io.NodeOutput:
|
||||
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
|
||||
dtype = {"fp16": torch.float16, "bf16": torch.bfloat16, "fp32": torch.float32}[save_dtype]
|
||||
|
||||
@@ -1372,6 +1383,7 @@ class LoRAResizeCumulative(io.ComfyNode):
|
||||
exclude_patterns=exclude_patterns,
|
||||
discard_patterns=discard_patterns,
|
||||
glob_patterns=glob_patterns,
|
||||
include_mode=include_mode,
|
||||
)
|
||||
_, output_name = canonical_model_artifact_path("loras", output_filename)
|
||||
return io.NodeOutput(output_name)
|
||||
@@ -1396,6 +1408,7 @@ def merge_loras_to_model(
|
||||
force_clear_cache: bool = False,
|
||||
include_1d_diffs: bool = False,
|
||||
return_report: bool = False,
|
||||
include_mode: bool = False,
|
||||
) -> str | tuple[str, str]:
|
||||
"""
|
||||
Merge multiple LoRAs into a base model and save the result directly.
|
||||
@@ -1416,6 +1429,7 @@ def merge_loras_to_model(
|
||||
lazy_load: Low memory mode: load tensors from disk on demand
|
||||
force_clear_cache: Clear CUDA cache after each layer
|
||||
return_report: Return the path and merge report instead of only the path
|
||||
include_mode: Use skip_patterns_str to select matching base keys instead
|
||||
|
||||
Returns:
|
||||
Path to saved merged model, optionally paired with the merge report
|
||||
@@ -1664,7 +1678,7 @@ def merge_loras_to_model(
|
||||
if planned_base_key.endswith(".weight"):
|
||||
core = extract_core_layer_base(planned_base_key)
|
||||
planned_low_rank = lora_lookup.get(core.replace(".", "_"), [])
|
||||
skipped = any(pattern.search(planned_base_key) for pattern in skip_patterns)
|
||||
skipped = any(pattern.search(planned_base_key) for pattern in skip_patterns) != include_mode
|
||||
entries = {}
|
||||
if planned_base_key not in base_low_bit_keys and not skipped:
|
||||
entries[-1] = [planned_base_key]
|
||||
@@ -1745,20 +1759,21 @@ def merge_loras_to_model(
|
||||
(pattern.pattern for pattern in skip_patterns if pattern.search(base_key)),
|
||||
None,
|
||||
)
|
||||
if skip_pattern is not None:
|
||||
if (skip_pattern is not None) != include_mode:
|
||||
skip_reason = "no include pattern matched" if include_mode else f"pattern={skip_pattern}"
|
||||
stats["skipped"] += 1
|
||||
base_outcomes["OMITTED BY SKIP PATTERN"].append(
|
||||
f"{base_key}: pattern={skip_pattern}"
|
||||
f"{base_key}: {skip_reason}"
|
||||
)
|
||||
for info, block_name, _, direct_key, _ in direct_contributions:
|
||||
record_group_event(
|
||||
info, block_name, "skipped", base_key,
|
||||
f"target omitted by pattern {skip_pattern}; source={direct_key}",
|
||||
f"target omitted: {skip_reason}; source={direct_key}",
|
||||
)
|
||||
for info, block_name, block_keys in low_rank_contributions:
|
||||
record_group_event(
|
||||
info, block_name, "skipped", base_key,
|
||||
f"target omitted by pattern {skip_pattern}; sources="
|
||||
f"target omitted: {skip_reason}; sources="
|
||||
+ ", ".join(sorted(layer_tensor_keys(block_keys).values())),
|
||||
)
|
||||
pbar.update(1)
|
||||
@@ -2220,7 +2235,7 @@ class LoRAMergeToModel(io.ComfyNode):
|
||||
tooltip="Weight strength for LoRA 8"),
|
||||
# Settings
|
||||
io.String.Input("skip_patterns", default="", multiline=True,
|
||||
tooltip="Regex patterns for layers to skip"),
|
||||
tooltip="Regex patterns for base tensor keys to omit, or to include when Include Mode is enabled. Guarded low-bit base tensors are always preserved."),
|
||||
io.String.Input("output_filename", default="merged_model", tooltip="Output filename without extension, written under ComfyUI's diffusion-model directory."),
|
||||
io.Combo.Input("save_dtype", options=["fp16", "bf16", "fp32"], default="fp16", tooltip="Data type used to save the model after applying the LoRA."),
|
||||
io.Combo.Input("device", options=["cuda", "cpu"], default="cuda", tooltip="Device used for per-layer LoRA application; CUDA out-of-memory retries the affected layer on CPU."),
|
||||
@@ -2228,6 +2243,8 @@ class LoRAMergeToModel(io.ComfyNode):
|
||||
io.Boolean.Input("force_clear_cache", default=False, tooltip="Clear CUDA cache after each layer (slower but saves VRAM)"),
|
||||
io.Boolean.Input("include_1d_diffs", default=False,
|
||||
tooltip="Apply 1D direct-diff tensors as FP32. Disabled preserves prior behavior."),
|
||||
io.Boolean.Input("include_mode", default=False,
|
||||
tooltip="Use Skip Patterns as an include-only filter. Only matching base tensors are processed and saved; an empty filter selects nothing. Guarded low-bit base tensors remain preserved."),
|
||||
],
|
||||
outputs=[
|
||||
io.AnyType.Output(display_name="output_path"),
|
||||
@@ -2241,7 +2258,7 @@ class LoRAMergeToModel(io.ComfyNode):
|
||||
lora_1, weight_1, lora_2, weight_2, lora_3, weight_3, lora_4, weight_4,
|
||||
lora_5, weight_5, lora_6, weight_6, lora_7, weight_7, lora_8, weight_8,
|
||||
skip_patterns, output_filename, save_dtype, device, lazy_load, force_clear_cache,
|
||||
include_1d_diffs) -> io.NodeOutput:
|
||||
include_1d_diffs, include_mode=False) -> io.NodeOutput:
|
||||
|
||||
# Build LoRA list based on count
|
||||
count = int(lora_count)
|
||||
@@ -2270,6 +2287,7 @@ class LoRAMergeToModel(io.ComfyNode):
|
||||
force_clear_cache=force_clear_cache,
|
||||
include_1d_diffs=include_1d_diffs,
|
||||
return_report=True,
|
||||
include_mode=include_mode,
|
||||
)
|
||||
_, output_name = canonical_model_artifact_path("diffusion_models", output_filename)
|
||||
return io.NodeOutput(output_name, report)
|
||||
|
||||
+41
-12
@@ -128,6 +128,7 @@ class MergerLogic:
|
||||
|
||||
# Compile filter patterns
|
||||
glob_mode = recipe_params.get('glob_patterns', False)
|
||||
include_mode = recipe_params.get('include_mode', False)
|
||||
exclude_patterns = _compile_patterns(recipe_params.get('exclude_patterns', ''), glob_mode=glob_mode)
|
||||
discard_patterns = _compile_patterns(recipe_params.get('discard_patterns', ''), glob_mode=glob_mode)
|
||||
|
||||
@@ -216,7 +217,11 @@ class MergerLogic:
|
||||
model_type == "loras"
|
||||
and len(primary_handler.get_shape(key)) == 1
|
||||
and not include_1d_diffs
|
||||
) or _matches_any_pattern(key, exclude_patterns, glob_mode=glob_mode)
|
||||
) or (
|
||||
not _matches_any_pattern(key, exclude_patterns, glob_mode=glob_mode)
|
||||
if include_mode else
|
||||
_matches_any_pattern(key, exclude_patterns, glob_mode=glob_mode)
|
||||
)
|
||||
requested_names = [primary_model_name]
|
||||
if not preserve_a:
|
||||
requested_names.extend(
|
||||
@@ -297,7 +302,11 @@ class MergerLogic:
|
||||
del cpu_tensor
|
||||
|
||||
# Check exclude patterns - use Model A only, no merge
|
||||
if preserve_model_a_1d or _matches_any_pattern(key, exclude_patterns, glob_mode=glob_mode):
|
||||
if preserve_model_a_1d or (
|
||||
not _matches_any_pattern(key, exclude_patterns, glob_mode=glob_mode)
|
||||
if include_mode else
|
||||
_matches_any_pattern(key, exclude_patterns, glob_mode=glob_mode)
|
||||
):
|
||||
t = tensor_a.detach().to(target_dtype).cpu()
|
||||
|
||||
writer.write_batch([(key, t)])
|
||||
@@ -418,6 +427,7 @@ class CheckpointTwoMerger(io.ComfyNode):
|
||||
io.Boolean.Input("lazy_load", default=True, tooltip="Low memory mode: load tensors from disk on demand"),
|
||||
io.Boolean.Input("force_clear_cache", default=True, tooltip="Clear CUDA cache after each layer"),
|
||||
io.Boolean.Input("override_dtype", default=False, tooltip="Force the entire model to be saved as the selected save_dtype. If False (default), higher precision dtypes are preserved."),
|
||||
io.Boolean.Input("include_mode", default=False, tooltip="Use Exclude Patterns as a whitelist instead. Only matching tensors are merged; nonmatching tensors are preserved from Model A."),
|
||||
],
|
||||
outputs=[
|
||||
io.AnyType.Output(display_name="output_filename"),
|
||||
@@ -430,7 +440,7 @@ class CheckpointTwoMerger(io.ComfyNode):
|
||||
calc_mode: str, mismatch_mode: str, alignment_mode: str, alpha: float, beta: float,
|
||||
gamma: float, delta: float, epsilon: float, zeta: float, seed: int, output_filename: str, save_dtype: str,
|
||||
process_device: str, exclude_patterns: str, discard_patterns: str,
|
||||
glob_patterns: bool, lazy_load: bool, force_clear_cache: bool, override_dtype: bool) -> io.NodeOutput:
|
||||
glob_patterns: bool, lazy_load: bool, force_clear_cache: bool, override_dtype: bool, include_mode: bool = False) -> io.NodeOutput:
|
||||
doc = load_documentation_from_file('merger_2_model_modes.md')
|
||||
if execution_mode == "DOCUMENTATION ONLY":
|
||||
return io.NodeOutput("Documentation mode active. No merge performed.", doc)
|
||||
@@ -443,6 +453,7 @@ class CheckpointTwoMerger(io.ComfyNode):
|
||||
"device": process_device, "dtype": torch.float32,
|
||||
"exclude_patterns": exclude_patterns, "discard_patterns": discard_patterns,
|
||||
"glob_patterns": glob_patterns,
|
||||
"include_mode": include_mode,
|
||||
"lazy_load": lazy_load, "force_clear_cache": force_clear_cache,
|
||||
}
|
||||
model_names = {"model_a": model_a, "model_b": model_b}
|
||||
@@ -484,6 +495,7 @@ class ModelTwoMerger(io.ComfyNode):
|
||||
io.Boolean.Input("lazy_load", default=True, tooltip="Low memory mode: load tensors from disk on demand"),
|
||||
io.Boolean.Input("force_clear_cache", default=True, tooltip="Clear CUDA cache after each layer"),
|
||||
io.Boolean.Input("override_dtype", default=False, tooltip="Force the entire model to be saved as the selected save_dtype. If False (default), higher precision dtypes are preserved."),
|
||||
io.Boolean.Input("include_mode", default=False, tooltip="Use Exclude Patterns as a whitelist instead. Only matching tensors are merged; nonmatching tensors are preserved from Model A."),
|
||||
],
|
||||
outputs=[
|
||||
io.AnyType.Output(display_name="output_filename"),
|
||||
@@ -496,7 +508,7 @@ class ModelTwoMerger(io.ComfyNode):
|
||||
calc_mode: str, mismatch_mode: str, alignment_mode: str, alpha: float, beta: float,
|
||||
gamma: float, delta: float, epsilon: float, zeta: float, seed: int, output_filename: str, save_dtype: str,
|
||||
process_device: str, exclude_patterns: str, discard_patterns: str,
|
||||
glob_patterns: bool, lazy_load: bool, force_clear_cache: bool, override_dtype: bool) -> io.NodeOutput:
|
||||
glob_patterns: bool, lazy_load: bool, force_clear_cache: bool, override_dtype: bool, include_mode: bool = False) -> io.NodeOutput:
|
||||
doc = load_documentation_from_file('merger_2_model_modes.md')
|
||||
if execution_mode == "DOCUMENTATION ONLY":
|
||||
return io.NodeOutput("Documentation mode active. No merge performed.", doc)
|
||||
@@ -509,6 +521,7 @@ class ModelTwoMerger(io.ComfyNode):
|
||||
"device": process_device, "dtype": torch.float32,
|
||||
"exclude_patterns": exclude_patterns, "discard_patterns": discard_patterns,
|
||||
"glob_patterns": glob_patterns,
|
||||
"include_mode": include_mode,
|
||||
"lazy_load": lazy_load, "force_clear_cache": force_clear_cache,
|
||||
}
|
||||
model_names = {"model_a": model_a, "model_b": model_b}
|
||||
@@ -550,6 +563,7 @@ class TextEncoderTwoMerger(io.ComfyNode):
|
||||
io.Boolean.Input("lazy_load", default=True, tooltip="Low memory mode: load tensors from disk on demand"),
|
||||
io.Boolean.Input("force_clear_cache", default=True, tooltip="Clear CUDA cache after each layer"),
|
||||
io.Boolean.Input("override_dtype", default=False, tooltip="Force the entire model to be saved as the selected save_dtype. If False (default), higher precision dtypes are preserved."),
|
||||
io.Boolean.Input("include_mode", default=False, tooltip="Use Exclude Patterns as a whitelist instead. Only matching tensors are merged; nonmatching tensors are preserved from Model A."),
|
||||
],
|
||||
outputs=[
|
||||
io.AnyType.Output(display_name="output_filename"),
|
||||
@@ -562,7 +576,7 @@ class TextEncoderTwoMerger(io.ComfyNode):
|
||||
calc_mode: str, mismatch_mode: str, alignment_mode: str, alpha: float, beta: float,
|
||||
gamma: float, delta: float, epsilon: float, zeta: float, seed: int, output_filename: str, save_dtype: str,
|
||||
process_device: str, exclude_patterns: str, discard_patterns: str,
|
||||
glob_patterns: bool, lazy_load: bool, force_clear_cache: bool, override_dtype: bool) -> io.NodeOutput:
|
||||
glob_patterns: bool, lazy_load: bool, force_clear_cache: bool, override_dtype: bool, include_mode: bool = False) -> io.NodeOutput:
|
||||
doc = load_documentation_from_file('merger_2_model_modes.md')
|
||||
if execution_mode == "DOCUMENTATION ONLY":
|
||||
return io.NodeOutput("Documentation mode active. No merge performed.", doc)
|
||||
@@ -575,6 +589,7 @@ class TextEncoderTwoMerger(io.ComfyNode):
|
||||
"device": process_device, "dtype": torch.float32,
|
||||
"exclude_patterns": exclude_patterns, "discard_patterns": discard_patterns,
|
||||
"glob_patterns": glob_patterns,
|
||||
"include_mode": include_mode,
|
||||
"lazy_load": lazy_load, "force_clear_cache": force_clear_cache,
|
||||
}
|
||||
model_names = {"model_a": model_a, "model_b": model_b}
|
||||
@@ -618,6 +633,7 @@ class LoRATwoMerger(io.ComfyNode):
|
||||
io.Boolean.Input("override_dtype", default=False, tooltip="Force merged non-1D tensors to save_dtype. Enabled 1D direct diffs remain FP32."),
|
||||
io.Boolean.Input("include_1d_diffs", default=False,
|
||||
tooltip="Merge 1D tensors. When disabled, preserve Model A's 1D tensors unchanged."),
|
||||
io.Boolean.Input("include_mode", default=False, tooltip="Use Exclude Patterns as a whitelist instead. Only matching tensors are merged; nonmatching tensors are preserved from Model A."),
|
||||
],
|
||||
outputs=[
|
||||
io.AnyType.Output(display_name="output_filename"),
|
||||
@@ -631,7 +647,7 @@ class LoRATwoMerger(io.ComfyNode):
|
||||
gamma: float, delta: float, epsilon: float, zeta: float, seed: int, output_filename: str, save_dtype: str,
|
||||
process_device: str, exclude_patterns: str, discard_patterns: str,
|
||||
glob_patterns: bool, lazy_load: bool, force_clear_cache: bool, override_dtype: bool,
|
||||
include_1d_diffs: bool) -> io.NodeOutput:
|
||||
include_1d_diffs: bool, include_mode: bool = False) -> io.NodeOutput:
|
||||
doc = load_documentation_from_file('merger_2_model_modes.md')
|
||||
if execution_mode == "DOCUMENTATION ONLY":
|
||||
return io.NodeOutput("Documentation mode active. No merge performed.", doc)
|
||||
@@ -644,6 +660,7 @@ class LoRATwoMerger(io.ComfyNode):
|
||||
"device": process_device, "dtype": torch.float32,
|
||||
"exclude_patterns": exclude_patterns, "discard_patterns": discard_patterns,
|
||||
"glob_patterns": glob_patterns,
|
||||
"include_mode": include_mode,
|
||||
"lazy_load": lazy_load, "force_clear_cache": force_clear_cache,
|
||||
"include_1d_diffs": include_1d_diffs,
|
||||
}
|
||||
@@ -686,6 +703,7 @@ class EmbeddingTwoMerger(io.ComfyNode):
|
||||
io.Boolean.Input("lazy_load", default=True, tooltip="Low memory mode: load tensors from disk on demand"),
|
||||
io.Boolean.Input("force_clear_cache", default=True, tooltip="Clear CUDA cache after each layer"),
|
||||
io.Boolean.Input("override_dtype", default=False, tooltip="Force the entire model to be saved as the selected save_dtype. If False (default), higher precision dtypes are preserved."),
|
||||
io.Boolean.Input("include_mode", default=False, tooltip="Use Exclude Patterns as a whitelist instead. Only matching tensors are merged; nonmatching tensors are preserved from Model A."),
|
||||
],
|
||||
outputs=[
|
||||
io.AnyType.Output(display_name="output_filename"),
|
||||
@@ -698,7 +716,7 @@ class EmbeddingTwoMerger(io.ComfyNode):
|
||||
calc_mode: str, mismatch_mode: str, alignment_mode: str, alpha: float, beta: float,
|
||||
gamma: float, delta: float, epsilon: float, zeta: float, seed: int, output_filename: str, save_dtype: str,
|
||||
process_device: str, exclude_patterns: str, discard_patterns: str,
|
||||
glob_patterns: bool, lazy_load: bool, force_clear_cache: bool, override_dtype: bool) -> io.NodeOutput:
|
||||
glob_patterns: bool, lazy_load: bool, force_clear_cache: bool, override_dtype: bool, include_mode: bool = False) -> io.NodeOutput:
|
||||
doc = load_documentation_from_file('merger_2_model_modes.md')
|
||||
if execution_mode == "DOCUMENTATION ONLY":
|
||||
return io.NodeOutput("Documentation mode active. No merge performed.", doc)
|
||||
@@ -711,6 +729,7 @@ class EmbeddingTwoMerger(io.ComfyNode):
|
||||
"device": process_device, "dtype": torch.float32,
|
||||
"exclude_patterns": exclude_patterns, "discard_patterns": discard_patterns,
|
||||
"glob_patterns": glob_patterns,
|
||||
"include_mode": include_mode,
|
||||
"lazy_load": lazy_load, "force_clear_cache": force_clear_cache,
|
||||
}
|
||||
model_names = {"model_a": model_a, "model_b": model_b}
|
||||
@@ -755,6 +774,7 @@ class CheckpointThreeMerger(io.ComfyNode):
|
||||
io.Boolean.Input("lazy_load", default=True, tooltip="Low memory mode: load tensors from disk on demand"),
|
||||
io.Boolean.Input("force_clear_cache", default=True, tooltip="Clear CUDA cache after each layer"),
|
||||
io.Boolean.Input("override_dtype", default=False, tooltip="Force the entire model to be saved as the selected save_dtype. If False (default), higher precision dtypes are preserved."),
|
||||
io.Boolean.Input("include_mode", default=False, tooltip="Use Exclude Patterns as a whitelist instead. Only matching tensors are merged; nonmatching tensors are preserved from Model A."),
|
||||
],
|
||||
outputs=[
|
||||
io.AnyType.Output(display_name="output_filename"),
|
||||
@@ -767,7 +787,7 @@ class CheckpointThreeMerger(io.ComfyNode):
|
||||
calc_mode: str, mismatch_mode: str, alignment_mode: str, alpha: float, beta: float,
|
||||
gamma: float, delta: float, epsilon: float, zeta: float, seed: int, output_filename: str, save_dtype: str,
|
||||
process_device: str, exclude_patterns: str, discard_patterns: str,
|
||||
glob_patterns: bool, lazy_load: bool, force_clear_cache: bool, override_dtype: bool) -> io.NodeOutput:
|
||||
glob_patterns: bool, lazy_load: bool, force_clear_cache: bool, override_dtype: bool, include_mode: bool = False) -> io.NodeOutput:
|
||||
doc = load_documentation_from_file('merger_3_model_modes.md')
|
||||
if execution_mode == "DOCUMENTATION ONLY":
|
||||
return io.NodeOutput("Documentation mode active. No merge performed.", doc)
|
||||
@@ -780,6 +800,7 @@ class CheckpointThreeMerger(io.ComfyNode):
|
||||
"device": process_device, "dtype": torch.float32,
|
||||
"exclude_patterns": exclude_patterns, "discard_patterns": discard_patterns,
|
||||
"glob_patterns": glob_patterns,
|
||||
"include_mode": include_mode,
|
||||
"lazy_load": lazy_load, "force_clear_cache": force_clear_cache,
|
||||
}
|
||||
model_names = {"model_a": model_a, "model_b": model_b, "model_c": model_c}
|
||||
@@ -822,6 +843,7 @@ class ModelThreeMerger(io.ComfyNode):
|
||||
io.Boolean.Input("lazy_load", default=True, tooltip="Low memory mode: load tensors from disk on demand"),
|
||||
io.Boolean.Input("force_clear_cache", default=True, tooltip="Clear CUDA cache after each layer"),
|
||||
io.Boolean.Input("override_dtype", default=False, tooltip="Force the entire model to be saved as the selected save_dtype. If False (default), higher precision dtypes are preserved."),
|
||||
io.Boolean.Input("include_mode", default=False, tooltip="Use Exclude Patterns as a whitelist instead. Only matching tensors are merged; nonmatching tensors are preserved from Model A."),
|
||||
],
|
||||
outputs=[
|
||||
io.AnyType.Output(display_name="output_filename"),
|
||||
@@ -834,7 +856,7 @@ class ModelThreeMerger(io.ComfyNode):
|
||||
calc_mode: str, mismatch_mode: str, alignment_mode: str, alpha: float, beta: float,
|
||||
gamma: float, delta: float, epsilon: float, zeta: float, seed: int, output_filename: str, save_dtype: str,
|
||||
process_device: str, exclude_patterns: str, discard_patterns: str,
|
||||
glob_patterns: bool, lazy_load: bool, force_clear_cache: bool, override_dtype: bool) -> io.NodeOutput:
|
||||
glob_patterns: bool, lazy_load: bool, force_clear_cache: bool, override_dtype: bool, include_mode: bool = False) -> io.NodeOutput:
|
||||
doc = load_documentation_from_file('merger_3_model_modes.md')
|
||||
if execution_mode == "DOCUMENTATION ONLY":
|
||||
return io.NodeOutput("Documentation mode active. No merge performed.", doc)
|
||||
@@ -847,6 +869,7 @@ class ModelThreeMerger(io.ComfyNode):
|
||||
"device": process_device, "dtype": torch.float32,
|
||||
"exclude_patterns": exclude_patterns, "discard_patterns": discard_patterns,
|
||||
"glob_patterns": glob_patterns,
|
||||
"include_mode": include_mode,
|
||||
"lazy_load": lazy_load, "force_clear_cache": force_clear_cache,
|
||||
}
|
||||
model_names = {"model_a": model_a, "model_b": model_b, "model_c": model_c}
|
||||
@@ -889,6 +912,7 @@ class TextEncoderThreeMerger(io.ComfyNode):
|
||||
io.Boolean.Input("lazy_load", default=True, tooltip="Low memory mode: load tensors from disk on demand"),
|
||||
io.Boolean.Input("force_clear_cache", default=True, tooltip="Clear CUDA cache after each layer"),
|
||||
io.Boolean.Input("override_dtype", default=False, tooltip="Force the entire model to be saved as the selected save_dtype. If False (default), higher precision dtypes are preserved."),
|
||||
io.Boolean.Input("include_mode", default=False, tooltip="Use Exclude Patterns as a whitelist instead. Only matching tensors are merged; nonmatching tensors are preserved from Model A."),
|
||||
],
|
||||
outputs=[
|
||||
io.AnyType.Output(display_name="output_filename"),
|
||||
@@ -901,7 +925,7 @@ class TextEncoderThreeMerger(io.ComfyNode):
|
||||
calc_mode: str, mismatch_mode: str, alignment_mode: str, alpha: float, beta: float,
|
||||
gamma: float, delta: float, epsilon: float, zeta: float, seed: int, output_filename: str, save_dtype: str,
|
||||
process_device: str, exclude_patterns: str, discard_patterns: str,
|
||||
glob_patterns: bool, lazy_load: bool, force_clear_cache: bool, override_dtype: bool) -> io.NodeOutput:
|
||||
glob_patterns: bool, lazy_load: bool, force_clear_cache: bool, override_dtype: bool, include_mode: bool = False) -> io.NodeOutput:
|
||||
doc = load_documentation_from_file('merger_3_model_modes.md')
|
||||
if execution_mode == "DOCUMENTATION ONLY":
|
||||
return io.NodeOutput("Documentation mode active. No merge performed.", doc)
|
||||
@@ -914,6 +938,7 @@ class TextEncoderThreeMerger(io.ComfyNode):
|
||||
"device": process_device, "dtype": torch.float32,
|
||||
"exclude_patterns": exclude_patterns, "discard_patterns": discard_patterns,
|
||||
"glob_patterns": glob_patterns,
|
||||
"include_mode": include_mode,
|
||||
"lazy_load": lazy_load, "force_clear_cache": force_clear_cache,
|
||||
}
|
||||
model_names = {"model_a": model_a, "model_b": model_b, "model_c": model_c}
|
||||
@@ -958,6 +983,7 @@ class LoRAThreeMerger(io.ComfyNode):
|
||||
io.Boolean.Input("override_dtype", default=False, tooltip="Force merged non-1D tensors to save_dtype. Enabled 1D direct diffs remain FP32."),
|
||||
io.Boolean.Input("include_1d_diffs", default=False,
|
||||
tooltip="Merge 1D tensors. When disabled, preserve Model A's 1D tensors unchanged."),
|
||||
io.Boolean.Input("include_mode", default=False, tooltip="Use Exclude Patterns as a whitelist instead. Only matching tensors are merged; nonmatching tensors are preserved from Model A."),
|
||||
],
|
||||
outputs=[
|
||||
io.AnyType.Output(display_name="output_filename"),
|
||||
@@ -971,7 +997,7 @@ class LoRAThreeMerger(io.ComfyNode):
|
||||
gamma: float, delta: float, epsilon: float, zeta: float, seed: int, output_filename: str, save_dtype: str,
|
||||
process_device: str, exclude_patterns: str, discard_patterns: str,
|
||||
glob_patterns: bool, lazy_load: bool, force_clear_cache: bool, override_dtype: bool,
|
||||
include_1d_diffs: bool) -> io.NodeOutput:
|
||||
include_1d_diffs: bool, include_mode: bool = False) -> io.NodeOutput:
|
||||
doc = load_documentation_from_file('merger_3_model_modes.md')
|
||||
if execution_mode == "DOCUMENTATION ONLY":
|
||||
return io.NodeOutput("Documentation mode active. No merge performed.", doc)
|
||||
@@ -984,6 +1010,7 @@ class LoRAThreeMerger(io.ComfyNode):
|
||||
"device": process_device, "dtype": torch.float32,
|
||||
"exclude_patterns": exclude_patterns, "discard_patterns": discard_patterns,
|
||||
"glob_patterns": glob_patterns,
|
||||
"include_mode": include_mode,
|
||||
"lazy_load": lazy_load, "force_clear_cache": force_clear_cache,
|
||||
"include_1d_diffs": include_1d_diffs,
|
||||
}
|
||||
@@ -1027,6 +1054,7 @@ class EmbeddingThreeMerger(io.ComfyNode):
|
||||
io.Boolean.Input("lazy_load", default=True, tooltip="Low memory mode: load tensors from disk on demand"),
|
||||
io.Boolean.Input("force_clear_cache", default=True, tooltip="Clear CUDA cache after each layer"),
|
||||
io.Boolean.Input("override_dtype", default=False, tooltip="Force the entire model to be saved as the selected save_dtype. If False (default), higher precision dtypes are preserved."),
|
||||
io.Boolean.Input("include_mode", default=False, tooltip="Use Exclude Patterns as a whitelist instead. Only matching tensors are merged; nonmatching tensors are preserved from Model A."),
|
||||
],
|
||||
outputs=[
|
||||
io.AnyType.Output(display_name="output_filename"),
|
||||
@@ -1039,7 +1067,7 @@ class EmbeddingThreeMerger(io.ComfyNode):
|
||||
calc_mode: str, mismatch_mode: str, alignment_mode: str, alpha: float, beta: float,
|
||||
gamma: float, delta: float, epsilon: float, zeta: float, seed: int, output_filename: str, save_dtype: str,
|
||||
process_device: str, exclude_patterns: str, discard_patterns: str,
|
||||
glob_patterns: bool, lazy_load: bool, force_clear_cache: bool, override_dtype: bool) -> io.NodeOutput:
|
||||
glob_patterns: bool, lazy_load: bool, force_clear_cache: bool, override_dtype: bool, include_mode: bool = False) -> io.NodeOutput:
|
||||
doc = load_documentation_from_file('merger_3_model_modes.md')
|
||||
if execution_mode == "DOCUMENTATION ONLY":
|
||||
return io.NodeOutput("Documentation mode active. No merge performed.", doc)
|
||||
@@ -1052,6 +1080,7 @@ class EmbeddingThreeMerger(io.ComfyNode):
|
||||
"device": process_device, "dtype": torch.float32,
|
||||
"exclude_patterns": exclude_patterns, "discard_patterns": discard_patterns,
|
||||
"glob_patterns": glob_patterns,
|
||||
"include_mode": include_mode,
|
||||
"lazy_load": lazy_load, "force_clear_cache": force_clear_cache,
|
||||
}
|
||||
model_names = {"model_a": model_a, "model_b": model_b, "model_c": model_c}
|
||||
|
||||
+10
-4
@@ -781,13 +781,14 @@ class ModelAnalysisLogic:
|
||||
params.get("exclude_patterns", ""),
|
||||
glob_mode=glob_mode,
|
||||
)
|
||||
include_mode = bool(params.get("include_mode", False))
|
||||
excluded_a = {
|
||||
key for key in keys_a
|
||||
if _matches_any_pattern(key, excluded_patterns, glob_mode=glob_mode)
|
||||
if _matches_any_pattern(key, excluded_patterns, glob_mode=glob_mode) != include_mode
|
||||
}
|
||||
excluded_b = {
|
||||
key for key in keys_b
|
||||
if _matches_any_pattern(key, excluded_patterns, glob_mode=glob_mode)
|
||||
if _matches_any_pattern(key, excluded_patterns, glob_mode=glob_mode) != include_mode
|
||||
}
|
||||
keys_a.difference_update(excluded_a)
|
||||
keys_b.difference_update(excluded_b)
|
||||
@@ -1092,12 +1093,17 @@ def _common_inputs(model_type: str, *, alignment_control: bool):
|
||||
"exclude_patterns",
|
||||
default="",
|
||||
multiline=True,
|
||||
tooltip="One pattern per line. Matching tensors are excluded from all comparison metrics and topology counts. Uses regex unless Glob Patterns is enabled.",
|
||||
tooltip="One pattern per line. Matching tensors are excluded from all comparison metrics and topology counts, or are the only tensors analyzed when Include Mode is enabled. Uses regex unless Glob Patterns is enabled.",
|
||||
),
|
||||
io.Boolean.Input(
|
||||
"glob_patterns",
|
||||
default=False,
|
||||
tooltip="Interpret exclusion entries as shell-style glob patterns instead of regular expressions.",
|
||||
tooltip="Interpret layer-filter entries as shell-style glob patterns instead of regular expressions.",
|
||||
),
|
||||
io.Boolean.Input(
|
||||
"include_mode",
|
||||
default=False,
|
||||
tooltip="Use Exclude Patterns as an include-only filter. Analyze only matching tensors; an empty filter selects nothing.",
|
||||
),
|
||||
])
|
||||
return inputs
|
||||
|
||||
@@ -81,6 +81,7 @@ def extract_te_from_files(
|
||||
force_clear_cache: bool = True,
|
||||
glob_skip_patterns: bool = False,
|
||||
knee_probe_offset: int = 32,
|
||||
include_mode: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Extract LoRA/DoRA from difference between two Text Encoder models, writing incrementally to disk.
|
||||
@@ -108,7 +109,7 @@ def extract_te_from_files(
|
||||
work_units = [
|
||||
(key, key, key if key in keys_b else None)
|
||||
for key in weight_keys
|
||||
if not _matches_any_pattern(key, skip_patterns, glob_mode=glob_skip_patterns)
|
||||
if (not _matches_any_pattern(key, skip_patterns, glob_mode=glob_skip_patterns) if not include_mode else _matches_any_pattern(key, skip_patterns, glob_mode=glob_skip_patterns))
|
||||
and not (key not in keys_b and mismatch_mode == "skip")
|
||||
]
|
||||
pbar = comfy.utils.ProgressBar(len(work_units))
|
||||
@@ -119,7 +120,7 @@ def extract_te_from_files(
|
||||
weight_diff = None
|
||||
layer_device = device
|
||||
|
||||
if _matches_any_pattern(key, skip_patterns, glob_mode=glob_skip_patterns):
|
||||
if (_matches_any_pattern(key, skip_patterns, glob_mode=glob_skip_patterns) if not include_mode else not _matches_any_pattern(key, skip_patterns, glob_mode=glob_skip_patterns)):
|
||||
return "skipped", None
|
||||
|
||||
# Load tensors with pinned memory for CUDA
|
||||
@@ -300,6 +301,7 @@ def _get_common_inputs():
|
||||
io.Boolean.Input("glob_skip_patterns", default=False,
|
||||
tooltip="When True, skip_patterns use glob syntax (* = any sequence, ? = any char, dots are literal). "
|
||||
"When False (default), patterns are Python regex matched as substrings."),
|
||||
io.Boolean.Input("include_mode", default=False, tooltip="Use Skip Patterns as a whitelist instead. Only matching layers are extracted; an empty whitelist extracts nothing."),
|
||||
]
|
||||
|
||||
|
||||
@@ -329,7 +331,7 @@ class TextEncoderLoRAExtractFixed(io.ComfyNode):
|
||||
@classmethod
|
||||
def execute(cls, model_a, model_b, linear_dim, conv_dim, svd_niter, chunk_large_layers,
|
||||
clamp_quantile, min_diff, mismatch_mode, output_filename,
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache) -> io.NodeOutput:
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache, include_mode=False) -> io.NodeOutput:
|
||||
|
||||
model_a_path = folder_paths.get_full_path_or_raise("text_encoders", model_a)
|
||||
model_b_path = folder_paths.get_full_path_or_raise("text_encoders", model_b)
|
||||
@@ -341,7 +343,7 @@ class TextEncoderLoRAExtractFixed(io.ComfyNode):
|
||||
linear_max_rank=linear_dim, conv_max_rank=conv_dim,
|
||||
clamp_quantile=clamp_quantile, min_diff=min_diff, skip_patterns_str=skip_patterns,
|
||||
mismatch_mode=mismatch_mode, chunk_large_layers=chunk_large_layers, svd_niter=svd_niter,
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache, glob_skip_patterns=glob_skip_patterns
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache, glob_skip_patterns=glob_skip_patterns, include_mode=include_mode
|
||||
)
|
||||
return io.NodeOutput(output_name)
|
||||
|
||||
@@ -370,7 +372,7 @@ class TextEncoderLoRAExtractRatio(io.ComfyNode):
|
||||
@classmethod
|
||||
def execute(cls, model_a, model_b, linear_ratio, conv_ratio, probe_offset, linear_max_rank, conv_max_rank,
|
||||
chunk_large_layers, clamp_quantile, min_diff, mismatch_mode, output_filename,
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache) -> io.NodeOutput:
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache, include_mode=False) -> io.NodeOutput:
|
||||
|
||||
model_a_path = folder_paths.get_full_path_or_raise("text_encoders", model_a)
|
||||
model_b_path = folder_paths.get_full_path_or_raise("text_encoders", model_b)
|
||||
@@ -382,7 +384,7 @@ class TextEncoderLoRAExtractRatio(io.ComfyNode):
|
||||
linear_max_rank=linear_max_rank, conv_max_rank=conv_max_rank,
|
||||
clamp_quantile=clamp_quantile, min_diff=min_diff, skip_patterns_str=skip_patterns,
|
||||
mismatch_mode=mismatch_mode, chunk_large_layers=chunk_large_layers,
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache, glob_skip_patterns=glob_skip_patterns,
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache, glob_skip_patterns=glob_skip_patterns, include_mode=include_mode,
|
||||
knee_probe_offset=probe_offset
|
||||
)
|
||||
return io.NodeOutput(output_name)
|
||||
@@ -412,7 +414,7 @@ class TextEncoderLoRAExtractQuantile(io.ComfyNode):
|
||||
@classmethod
|
||||
def execute(cls, model_a, model_b, linear_quantile, conv_quantile, probe_offset, linear_max_rank, conv_max_rank,
|
||||
chunk_large_layers, clamp_quantile, min_diff, mismatch_mode, output_filename,
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache) -> io.NodeOutput:
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache, include_mode=False) -> io.NodeOutput:
|
||||
|
||||
model_a_path = folder_paths.get_full_path_or_raise("text_encoders", model_a)
|
||||
model_b_path = folder_paths.get_full_path_or_raise("text_encoders", model_b)
|
||||
@@ -424,7 +426,7 @@ class TextEncoderLoRAExtractQuantile(io.ComfyNode):
|
||||
linear_max_rank=linear_max_rank, conv_max_rank=conv_max_rank,
|
||||
clamp_quantile=clamp_quantile, min_diff=min_diff, skip_patterns_str=skip_patterns,
|
||||
mismatch_mode=mismatch_mode, chunk_large_layers=chunk_large_layers,
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache, glob_skip_patterns=glob_skip_patterns,
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache, glob_skip_patterns=glob_skip_patterns, include_mode=include_mode,
|
||||
knee_probe_offset=probe_offset
|
||||
)
|
||||
return io.NodeOutput(output_name)
|
||||
@@ -454,7 +456,7 @@ class TextEncoderLoRAExtractKnee(io.ComfyNode):
|
||||
def execute(cls, model_a, model_b, knee_method, knee_probe_offset,
|
||||
linear_max_rank, conv_max_rank,
|
||||
chunk_large_layers, clamp_quantile, min_diff, mismatch_mode, output_filename,
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache) -> io.NodeOutput:
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache, include_mode=False) -> io.NodeOutput:
|
||||
|
||||
model_a_path = folder_paths.get_full_path_or_raise("text_encoders", model_a)
|
||||
model_b_path = folder_paths.get_full_path_or_raise("text_encoders", model_b)
|
||||
@@ -467,7 +469,7 @@ class TextEncoderLoRAExtractKnee(io.ComfyNode):
|
||||
clamp_quantile=clamp_quantile, min_diff=min_diff, skip_patterns_str=skip_patterns,
|
||||
mismatch_mode=mismatch_mode, chunk_large_layers=chunk_large_layers,
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache,
|
||||
glob_skip_patterns=glob_skip_patterns, knee_probe_offset=knee_probe_offset
|
||||
glob_skip_patterns=glob_skip_patterns, knee_probe_offset=knee_probe_offset, include_mode=include_mode
|
||||
)
|
||||
return io.NodeOutput(output_name)
|
||||
|
||||
@@ -496,7 +498,7 @@ class TextEncoderLoRAExtractFrobenius(io.ComfyNode):
|
||||
@classmethod
|
||||
def execute(cls, model_a, model_b, linear_target, conv_target, probe_offset, linear_max_rank, conv_max_rank,
|
||||
chunk_large_layers, clamp_quantile, min_diff, mismatch_mode, output_filename,
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache) -> io.NodeOutput:
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache, include_mode=False) -> io.NodeOutput:
|
||||
|
||||
model_a_path = folder_paths.get_full_path_or_raise("text_encoders", model_a)
|
||||
model_b_path = folder_paths.get_full_path_or_raise("text_encoders", model_b)
|
||||
@@ -508,7 +510,7 @@ class TextEncoderLoRAExtractFrobenius(io.ComfyNode):
|
||||
linear_max_rank=linear_max_rank, conv_max_rank=conv_max_rank,
|
||||
clamp_quantile=clamp_quantile, min_diff=min_diff, skip_patterns_str=skip_patterns,
|
||||
mismatch_mode=mismatch_mode, chunk_large_layers=chunk_large_layers,
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache, glob_skip_patterns=glob_skip_patterns,
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache, glob_skip_patterns=glob_skip_patterns, include_mode=include_mode,
|
||||
knee_probe_offset=probe_offset
|
||||
)
|
||||
return io.NodeOutput(output_name)
|
||||
@@ -540,7 +542,7 @@ class TextEncoderDoRAExtractFixed(io.ComfyNode):
|
||||
@classmethod
|
||||
def execute(cls, model_a, model_b, linear_dim, conv_dim, svd_niter, chunk_large_layers,
|
||||
clamp_quantile, min_diff, mismatch_mode, output_filename,
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache) -> io.NodeOutput:
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache, include_mode=False) -> io.NodeOutput:
|
||||
|
||||
model_a_path = folder_paths.get_full_path_or_raise("text_encoders", model_a)
|
||||
model_b_path = folder_paths.get_full_path_or_raise("text_encoders", model_b)
|
||||
@@ -552,7 +554,7 @@ class TextEncoderDoRAExtractFixed(io.ComfyNode):
|
||||
linear_max_rank=linear_dim, conv_max_rank=conv_dim,
|
||||
clamp_quantile=clamp_quantile, min_diff=min_diff, skip_patterns_str=skip_patterns,
|
||||
mismatch_mode=mismatch_mode, chunk_large_layers=chunk_large_layers, svd_niter=svd_niter,
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache, glob_skip_patterns=glob_skip_patterns
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache, glob_skip_patterns=glob_skip_patterns, include_mode=include_mode
|
||||
)
|
||||
return io.NodeOutput(output_name)
|
||||
|
||||
@@ -581,7 +583,7 @@ class TextEncoderDoRAExtractRatio(io.ComfyNode):
|
||||
@classmethod
|
||||
def execute(cls, model_a, model_b, linear_ratio, conv_ratio, probe_offset, linear_max_rank, conv_max_rank,
|
||||
chunk_large_layers, clamp_quantile, min_diff, mismatch_mode, output_filename,
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache) -> io.NodeOutput:
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache, include_mode=False) -> io.NodeOutput:
|
||||
|
||||
model_a_path = folder_paths.get_full_path_or_raise("text_encoders", model_a)
|
||||
model_b_path = folder_paths.get_full_path_or_raise("text_encoders", model_b)
|
||||
@@ -593,7 +595,7 @@ class TextEncoderDoRAExtractRatio(io.ComfyNode):
|
||||
linear_max_rank=linear_max_rank, conv_max_rank=conv_max_rank,
|
||||
clamp_quantile=clamp_quantile, min_diff=min_diff, skip_patterns_str=skip_patterns,
|
||||
mismatch_mode=mismatch_mode, chunk_large_layers=chunk_large_layers,
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache, glob_skip_patterns=glob_skip_patterns,
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache, glob_skip_patterns=glob_skip_patterns, include_mode=include_mode,
|
||||
knee_probe_offset=probe_offset
|
||||
)
|
||||
return io.NodeOutput(output_name)
|
||||
@@ -623,7 +625,7 @@ class TextEncoderDoRAExtractQuantile(io.ComfyNode):
|
||||
@classmethod
|
||||
def execute(cls, model_a, model_b, linear_quantile, conv_quantile, probe_offset, linear_max_rank, conv_max_rank,
|
||||
chunk_large_layers, clamp_quantile, min_diff, mismatch_mode, output_filename,
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache) -> io.NodeOutput:
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache, include_mode=False) -> io.NodeOutput:
|
||||
|
||||
model_a_path = folder_paths.get_full_path_or_raise("text_encoders", model_a)
|
||||
model_b_path = folder_paths.get_full_path_or_raise("text_encoders", model_b)
|
||||
@@ -635,7 +637,7 @@ class TextEncoderDoRAExtractQuantile(io.ComfyNode):
|
||||
linear_max_rank=linear_max_rank, conv_max_rank=conv_max_rank,
|
||||
clamp_quantile=clamp_quantile, min_diff=min_diff, skip_patterns_str=skip_patterns,
|
||||
mismatch_mode=mismatch_mode, chunk_large_layers=chunk_large_layers,
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache, glob_skip_patterns=glob_skip_patterns,
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache, glob_skip_patterns=glob_skip_patterns, include_mode=include_mode,
|
||||
knee_probe_offset=probe_offset
|
||||
)
|
||||
return io.NodeOutput(output_name)
|
||||
@@ -665,7 +667,7 @@ class TextEncoderDoRAExtractKnee(io.ComfyNode):
|
||||
def execute(cls, model_a, model_b, knee_method, knee_probe_offset,
|
||||
linear_max_rank, conv_max_rank,
|
||||
chunk_large_layers, clamp_quantile, min_diff, mismatch_mode, output_filename,
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache) -> io.NodeOutput:
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache, include_mode=False) -> io.NodeOutput:
|
||||
|
||||
model_a_path = folder_paths.get_full_path_or_raise("text_encoders", model_a)
|
||||
model_b_path = folder_paths.get_full_path_or_raise("text_encoders", model_b)
|
||||
@@ -678,7 +680,7 @@ class TextEncoderDoRAExtractKnee(io.ComfyNode):
|
||||
clamp_quantile=clamp_quantile, min_diff=min_diff, skip_patterns_str=skip_patterns,
|
||||
mismatch_mode=mismatch_mode, chunk_large_layers=chunk_large_layers,
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache,
|
||||
glob_skip_patterns=glob_skip_patterns, knee_probe_offset=knee_probe_offset
|
||||
glob_skip_patterns=glob_skip_patterns, knee_probe_offset=knee_probe_offset, include_mode=include_mode
|
||||
)
|
||||
return io.NodeOutput(output_name)
|
||||
|
||||
@@ -707,7 +709,7 @@ class TextEncoderDoRAExtractFrobenius(io.ComfyNode):
|
||||
@classmethod
|
||||
def execute(cls, model_a, model_b, linear_target, conv_target, probe_offset, linear_max_rank, conv_max_rank,
|
||||
chunk_large_layers, clamp_quantile, min_diff, mismatch_mode, output_filename,
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache) -> io.NodeOutput:
|
||||
save_dtype, device, skip_patterns, glob_skip_patterns, lazy_load, force_clear_cache, include_mode=False) -> io.NodeOutput:
|
||||
|
||||
model_a_path = folder_paths.get_full_path_or_raise("text_encoders", model_a)
|
||||
model_b_path = folder_paths.get_full_path_or_raise("text_encoders", model_b)
|
||||
@@ -719,7 +721,7 @@ class TextEncoderDoRAExtractFrobenius(io.ComfyNode):
|
||||
linear_max_rank=linear_max_rank, conv_max_rank=conv_max_rank,
|
||||
clamp_quantile=clamp_quantile, min_diff=min_diff, skip_patterns_str=skip_patterns,
|
||||
mismatch_mode=mismatch_mode, chunk_large_layers=chunk_large_layers,
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache, glob_skip_patterns=glob_skip_patterns,
|
||||
lazy_load=lazy_load, force_clear_cache=force_clear_cache, glob_skip_patterns=glob_skip_patterns, include_mode=include_mode,
|
||||
knee_probe_offset=probe_offset
|
||||
)
|
||||
return io.NodeOutput(output_name)
|
||||
|
||||
@@ -217,3 +217,38 @@ def test_lora_extraction_uses_only_async_uel_and_releases_sources(monkeypatch, t
|
||||
"diffusion_model.layer.lora_A.weight",
|
||||
"diffusion_model.layer.lora_B.weight",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("module_name, function_name, extra", [
|
||||
("nodes.lora_extract_svd", "extract_lora_from_files", {"include_1d_diffs": False}),
|
||||
("nodes.dora_extract_wd", "extract_dora_from_files", {}),
|
||||
("nodes.dora_learned_wd", "extract_dora_learned_from_files", {"optimize_iters": 0}),
|
||||
("nodes.text_encoder_extract", "extract_te_from_files", {"is_dora": False}),
|
||||
("nodes.text_encoder_extract", "extract_te_from_files", {"is_dora": True}),
|
||||
])
|
||||
def test_extraction_include_mode_selects_only_matching_layers(monkeypatch, tmp_path, module_name, function_name, extra):
|
||||
module = _load_module(module_name)
|
||||
first, second = tmp_path / "first.safetensors", tmp_path / "second.safetensors"
|
||||
match, other = "model.encoder.match.weight", "model.encoder.other.weight"
|
||||
_write_uel(first, {match: torch.eye(2), other: torch.eye(2)})
|
||||
_write_uel(second, {match: torch.zeros(2, 2), other: torch.zeros(2, 2)})
|
||||
monkeypatch.setattr(module, "prepare_for_large_operation", lambda *args: None)
|
||||
monkeypatch.setattr(module, "cleanup_after_operation", lambda: None)
|
||||
common = dict(linear_max_rank=1, conv_max_rank=1, force_clear_cache=False,
|
||||
skip_patterns_str="match", include_mode=True, **extra)
|
||||
selected = tmp_path / "selected.safetensors"
|
||||
getattr(module, function_name)(str(first), str(second), "fixed", 1, 1, "cpu", "fp32", str(selected), **common)
|
||||
with MemoryEfficientSafeOpen(str(selected), low_memory=True) as result:
|
||||
keys = list(result.keys())
|
||||
assert keys
|
||||
assert all("match" in key for key in keys)
|
||||
empty = tmp_path / "empty.safetensors"
|
||||
common["skip_patterns_str"] = ""
|
||||
getattr(module, function_name)(str(first), str(second), "fixed", 1, 1, "cpu", "fp32", str(empty), **common)
|
||||
with MemoryEfficientSafeOpen(str(empty), low_memory=True) as result:
|
||||
assert list(result.keys()) == []
|
||||
globbed = tmp_path / "globbed.safetensors"
|
||||
common.update(skip_patterns_str="*.match.weight", glob_skip_patterns=True)
|
||||
getattr(module, function_name)(str(first), str(second), "fixed", 1, 1, "cpu", "fp32", str(globbed), **common)
|
||||
with MemoryEfficientSafeOpen(str(globbed), low_memory=True) as result:
|
||||
assert list(result.keys()) == keys
|
||||
|
||||
@@ -651,9 +651,10 @@ def test_all_cwb_schemas_and_compact_control_contract(cwb):
|
||||
if node is cwb.CWBLoRAMultiMerger:
|
||||
assert ids[:4] == ["execution_mode", "lora_count", "lora_1", "lora_2"]
|
||||
assert schema.inputs[1].default == "2"
|
||||
assert ids[-2:] == [
|
||||
assert ids[-3:] == [
|
||||
"include_1d_diffs",
|
||||
"counterfactual_weight_sweep",
|
||||
"include_mode",
|
||||
]
|
||||
continue
|
||||
if node in (cwb.CWBEmbeddingSelfCoalesce, cwb.CWBEmbeddingMultiMerger):
|
||||
@@ -667,10 +668,11 @@ def test_all_cwb_schemas_and_compact_control_contract(cwb):
|
||||
if node.INPUT_COUNT == 3:
|
||||
assert ids[3] == "model_c"
|
||||
if node.LORA_MODE:
|
||||
assert schema.inputs[-1].id == "include_1d_diffs"
|
||||
assert schema.inputs[-1].default is False
|
||||
assert schema.inputs[-2].id == "include_1d_diffs"
|
||||
else:
|
||||
assert "include_1d_diffs" not in ids
|
||||
assert schema.inputs[-1].id == "include_mode"
|
||||
assert schema.inputs[-1].default is False
|
||||
|
||||
|
||||
def test_use_case_preset_names_match_complete_settings(cwb):
|
||||
@@ -1261,6 +1263,37 @@ def test_secondary_shape_conflict_skip_preserves_earliest_source(monkeypatch, tm
|
||||
torch.testing.assert_close(tensor, torch.tensor([2.0, 3.0]))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lora_mode", [False, True])
|
||||
@pytest.mark.parametrize("patterns, glob_patterns, selected", [
|
||||
(r"\.selected", False, True),
|
||||
("*.selected*", True, True),
|
||||
("", False, False),
|
||||
("no_match", False, False),
|
||||
])
|
||||
def test_include_filter_preserves_nonmatches_and_discards_first(
|
||||
monkeypatch, tmp_path, cwb, lora_mode, patterns, glob_patterns, selected,
|
||||
):
|
||||
paths = {name: str(tmp_path / f"{name}.safetensors") for name in ("a", "b")}
|
||||
for name, value in (("a", 2.0), ("b", 6.0)):
|
||||
save_file({
|
||||
f"diffusion_model.{layer}.diff": torch.full((2, 2), value)
|
||||
for layer in ("selected", "other", "selected_discard")
|
||||
}, paths[name])
|
||||
_patch_io(monkeypatch, cwb, tmp_path, paths)
|
||||
category = "loras" if lora_mode else "diffusion_models"
|
||||
result = cwb.ConsensusMergerLogic.execute(
|
||||
["a", "b"], category,
|
||||
_params("include_filter", exclude_patterns=patterns, include_mode=True,
|
||||
glob_patterns=glob_patterns, discard_patterns="*discard*" if glob_patterns else "discard",
|
||||
power_alpha=0.0),
|
||||
lora_mode=lora_mode,
|
||||
)
|
||||
tensors = load_file(str(_result_path(tmp_path, category, result)))
|
||||
assert set(tensors) == {"diffusion_model.selected.diff", "diffusion_model.other.diff"}
|
||||
torch.testing.assert_close(tensors["diffusion_model.selected.diff"], torch.full((2, 2), 4.0 if selected else 2.0))
|
||||
torch.testing.assert_close(tensors["diffusion_model.other.diff"], torch.full((2, 2), 2.0))
|
||||
|
||||
|
||||
def test_secondary_only_filters_and_low_bit_preservation(monkeypatch, tmp_path, cwb):
|
||||
a = tmp_path / "filter_a.safetensors"
|
||||
b = tmp_path / "filter_b.safetensors"
|
||||
|
||||
@@ -186,6 +186,36 @@ def test_streamed_merge_normalizes_alpha_once_and_supports_mixed_rank(
|
||||
assert metadata["output_representation"] == "full_direct_difference"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("patterns, glob_patterns, selected", [
|
||||
(r"\.selected", False, True),
|
||||
("*.selected*", True, True),
|
||||
("", False, False),
|
||||
("no_match", False, False),
|
||||
])
|
||||
def test_include_filter_preserves_nonmatches_and_discards_first(
|
||||
monkeypatch, tmp_path, delta_cwb, patterns, glob_patterns, selected,
|
||||
):
|
||||
paths = [tmp_path / f"{name}.safetensors" for name in ("a", "b")]
|
||||
for path, value in zip(paths, (2.0, 6.0)):
|
||||
_write(path, {
|
||||
f"diffusion_model.{layer}.diff": torch.full((2, 2), value)
|
||||
for layer in ("selected", "other", "selected_discard")
|
||||
})
|
||||
monkeypatch.setattr(delta_cwb.folder_paths, "models_dir", str(tmp_path))
|
||||
monkeypatch.setattr(delta_cwb, "prepare_for_large_operation", lambda *args: None)
|
||||
monkeypatch.setattr(delta_cwb, "cleanup_after_operation", lambda: None)
|
||||
filename, _ = delta_cwb.DeltaCWBLoRAMergerLogic.execute(
|
||||
[str(path) for path in paths], [path.name for path in paths], _settings(delta_cwb),
|
||||
"balanced_mean", False, "skip", "include_filter", torch.float32,
|
||||
"full_difference", 384, "cpu", patterns,
|
||||
"*discard*" if glob_patterns else "discard", glob_patterns, False, include_mode=True,
|
||||
)
|
||||
tensors = _read(tmp_path / "loras" / filename)
|
||||
assert set(tensors) == {"diffusion_model.selected.diff", "diffusion_model.other.diff"}
|
||||
torch.testing.assert_close(tensors["diffusion_model.selected.diff"], torch.full((2, 2), 4.0 if selected else 2.0))
|
||||
torch.testing.assert_close(tensors["diffusion_model.other.diff"], torch.full((2, 2), 2.0))
|
||||
|
||||
|
||||
def test_existing_diff_and_1d_output_remain_fp32(monkeypatch, tmp_path, delta_cwb):
|
||||
first = tmp_path / "diff_first.safetensors"
|
||||
second = tmp_path / "diff_second.safetensors"
|
||||
@@ -350,7 +380,7 @@ def test_exact_node_schema_contract(delta_cwb):
|
||||
"lora_1", "lora_2", "cwb_preset", "cwb_config", "mismatch_mode",
|
||||
"output_filename", "save_dtype", "output_representation", "output_rank",
|
||||
"process_device", "exclude_patterns",
|
||||
"discard_patterns", "glob_patterns", "force_clear_cache",
|
||||
"discard_patterns", "glob_patterns", "force_clear_cache", "include_mode",
|
||||
]
|
||||
multi_ids = [value.id for value in schemas[2].inputs]
|
||||
assert multi_ids[:9] == ["lora_count", *[f"lora_{index}" for index in range(1, 9)]]
|
||||
@@ -360,8 +390,8 @@ def test_exact_node_schema_contract(delta_cwb):
|
||||
assert schemas[0].inputs[7].default == "full_difference"
|
||||
assert schemas[0].inputs[8].default == 384
|
||||
assert schemas[0].inputs[9].default == "cuda"
|
||||
assert schemas[0].inputs[-2].default is False
|
||||
assert schemas[0].inputs[-1].default is True
|
||||
assert schemas[0].inputs[-2].default is True
|
||||
assert schemas[0].inputs[-1].default is False
|
||||
assert "agreement" in schemas[0].inputs[2].tooltip
|
||||
assert "Only a failed CUDA layer retries on CPU" in schemas[0].inputs[9].tooltip
|
||||
|
||||
|
||||
@@ -68,7 +68,9 @@ def test_schema_is_diffusion_model_scoped(conversion):
|
||||
"reference_model",
|
||||
"exclude_patterns",
|
||||
"output_filename",
|
||||
"include_mode",
|
||||
]
|
||||
assert schema.inputs[-1].default is False
|
||||
assert schema.inputs[1].options == ["fp32", "fp16", "bf16"]
|
||||
assert [item.display_name for item in schema.outputs] == [
|
||||
"output_path",
|
||||
@@ -152,6 +154,44 @@ def test_converts_floating_and_preserves_excluded_and_nonfloating(
|
||||
assert "Non-floating tensors preserved: 1" in report
|
||||
|
||||
|
||||
@pytest.mark.parametrize("patterns, selected", [
|
||||
(r"^blocks\.0\.weight$", {"blocks.0.weight"}),
|
||||
(r"^blocks\.0\.weight$" + "\n" + r"^blocks\.0\.norm\.weight$", {"blocks.0.weight", "blocks.0.norm.weight"}),
|
||||
("", set()),
|
||||
("no_such_layer", set()),
|
||||
])
|
||||
@pytest.mark.parametrize("use_reference", [False, True])
|
||||
def test_include_filter_only_converts_matches(
|
||||
monkeypatch, tmp_path, conversion, patterns, selected, use_reference,
|
||||
):
|
||||
source = tmp_path / "source.safetensors"
|
||||
reference = tmp_path / "reference.safetensors"
|
||||
tensors = {
|
||||
"blocks.0.weight": torch.arange(4, dtype=torch.float32),
|
||||
"blocks.0.norm.weight": torch.ones(3, dtype=torch.float32),
|
||||
"position_ids": torch.tensor([1, 2], dtype=torch.int64),
|
||||
}
|
||||
_write_uel(source, tensors)
|
||||
_write_uel(reference, {"blocks.0.weight": tensors["blocks.0.weight"].to(torch.bfloat16)})
|
||||
_patch_runtime(monkeypatch, tmp_path, conversion, source)
|
||||
monkeypatch.setattr(
|
||||
conversion.folder_paths, "get_full_path_or_raise",
|
||||
lambda _, name: str(reference if name == "reference" else source),
|
||||
)
|
||||
|
||||
output_name, _ = conversion.DiffusionModelDtypeConversion.execute(
|
||||
source.name, "fp16", "reference" if use_reference else "None",
|
||||
patterns, "include_converted", include_mode=True,
|
||||
)
|
||||
result, _ = _read_uel(tmp_path / "diffusion_models" / output_name)
|
||||
for key, tensor in tensors.items():
|
||||
expected_dtype = tensor.dtype
|
||||
if key in selected:
|
||||
expected_dtype = torch.bfloat16 if use_reference and key == "blocks.0.weight" else torch.float16
|
||||
assert result[key].dtype == expected_dtype
|
||||
torch.testing.assert_close(result[key].to(tensor.dtype), tensor)
|
||||
|
||||
|
||||
def test_uses_bounded_async_stream_and_releases_every_tensor(
|
||||
monkeypatch, tmp_path, conversion
|
||||
):
|
||||
|
||||
@@ -48,6 +48,10 @@ python_tests = [
|
||||
paths = ["nodes/lora_resize.py"]
|
||||
python_tests = ["tests/test_lora_resize_factor_space.py", "tests/test_lora_resize_min_rank.py"]
|
||||
|
||||
[groups.merger_filters]
|
||||
paths = ["nodes/merger.py"]
|
||||
python_tests = ["tests/test_merger_filters.py"]
|
||||
|
||||
[groups.dtype_conversion]
|
||||
paths = ["nodes/dtype_conversion.py"]
|
||||
python_tests = ["tests/test_dtype_conversion.py"]
|
||||
|
||||
@@ -129,6 +129,35 @@ def test_streamed_merge_writes_only_alpha_normalized_full_delta(monkeypatch, tmp
|
||||
torch.testing.assert_close(tensors["diffusion_model.layer.diff"], torch.diag(torch.tensor([1.0, 4.0])))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("patterns, glob_patterns, selected", [
|
||||
(r"\.selected", False, True),
|
||||
("*.selected*", True, True),
|
||||
("", False, False),
|
||||
("no_match", False, False),
|
||||
])
|
||||
def test_include_filter_preserves_nonmatches_and_discards_first(
|
||||
monkeypatch, tmp_path, lodestone, patterns, glob_patterns, selected,
|
||||
):
|
||||
paths = [tmp_path / f"{name}.safetensors" for name in ("a", "b")]
|
||||
for path, value in zip(paths, (2.0, 6.0)):
|
||||
_write(path, {
|
||||
f"diffusion_model.{layer}.diff": torch.full((2, 2), value)
|
||||
for layer in ("selected", "other", "selected_discard")
|
||||
})
|
||||
monkeypatch.setattr(lodestone.folder_paths, "models_dir", str(tmp_path))
|
||||
monkeypatch.setattr(lodestone, "prepare_for_large_operation", lambda *args: None)
|
||||
monkeypatch.setattr(lodestone, "cleanup_after_operation", lambda: None)
|
||||
output = lodestone.merge_lodestone_loras(
|
||||
[str(path) for path in paths], "sum", "cpu", torch.float32, "include_filter",
|
||||
exclude_patterns=patterns, glob_patterns=glob_patterns, include_mode=True,
|
||||
discard_patterns="*discard*" if glob_patterns else "discard", verbose=False,
|
||||
)
|
||||
tensors = _read(output)
|
||||
assert set(tensors) == {"diffusion_model.selected.diff", "diffusion_model.other.diff"}
|
||||
torch.testing.assert_close(tensors["diffusion_model.selected.diff"], torch.full((2, 2), 8.0 if selected else 2.0))
|
||||
torch.testing.assert_close(tensors["diffusion_model.other.diff"], torch.full((2, 2), 2.0))
|
||||
|
||||
|
||||
def test_dedicated_nodes_do_not_modify_factor_merger_schema(lodestone):
|
||||
standard = _load_module("nodes.lora_merger")
|
||||
assert [node.define_schema().node_id for node in lodestone.LODESTONE_MERGER_NODES] == [
|
||||
|
||||
@@ -417,18 +417,68 @@ def test_merge_to_model_report_lists_skipped_tensor_and_pattern(
|
||||
assert "[SKIPPED BY PATTERN] (1)" in report
|
||||
|
||||
|
||||
@pytest.mark.parametrize("patterns, selected", [
|
||||
(r"selected\.weight$", True),
|
||||
("", False),
|
||||
("no_match", False),
|
||||
])
|
||||
def test_merge_to_model_include_filter_limits_stream_and_output(
|
||||
monkeypatch, tmp_path, modules, patterns, selected,
|
||||
):
|
||||
resize, _ = modules
|
||||
_patch_output(monkeypatch, resize, tmp_path)
|
||||
base = tmp_path / "include_base.safetensors"
|
||||
adapter = tmp_path / "include_adapter.safetensors"
|
||||
save_file({
|
||||
f"model.diffusion_model.{name}.weight": torch.zeros((2, 2))
|
||||
for name in ("selected", "other")
|
||||
}, str(base))
|
||||
save_file({
|
||||
f"diffusion_model.{name}.{factor}": torch.ones(shape)
|
||||
for name in ("selected", "other")
|
||||
for factor, shape in (("lora_A.weight", (1, 2)), ("lora_B.weight", (2, 1)))
|
||||
}, str(adapter))
|
||||
planned_keys = []
|
||||
real_stream = resize.stream_work_units
|
||||
|
||||
def capture_units(handlers, units, **kwargs):
|
||||
units = list(units)
|
||||
planned_keys.extend(key for _, entries in units for keys in entries.values() for key in keys)
|
||||
return real_stream(handlers, units, **kwargs)
|
||||
|
||||
monkeypatch.setattr(resize, "stream_work_units", capture_units)
|
||||
path, report = resize.merge_loras_to_model(
|
||||
[str(adapter)], [1.0], str(base), "cpu", torch.float32,
|
||||
"include_result", skip_patterns_str=patterns, verbose=False,
|
||||
return_report=True, include_mode=True,
|
||||
)
|
||||
tensors = load_file(path)
|
||||
assert set(tensors) == ({"model.diffusion_model.selected.weight"} if selected else set())
|
||||
if selected:
|
||||
torch.testing.assert_close(tensors["model.diffusion_model.selected.weight"], torch.ones((2, 2)))
|
||||
assert len(planned_keys) == 3
|
||||
assert all("selected" in key for key in planned_keys)
|
||||
else:
|
||||
assert planned_keys == []
|
||||
assert "no include pattern matched" in report
|
||||
|
||||
|
||||
@pytest.mark.parametrize("include_mode", [False, True])
|
||||
def test_merge_to_model_node_returns_path_blank_line_report(
|
||||
monkeypatch, modules
|
||||
monkeypatch, modules, include_mode,
|
||||
):
|
||||
resize, _ = modules
|
||||
monkeypatch.setattr(
|
||||
resize.folder_paths, "get_full_path_or_raise",
|
||||
lambda category, name: f"{category}/{name}",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
resize, "merge_loras_to_model",
|
||||
lambda **kwargs: ("saved/model.safetensors", "SUCCESS\nDETAILS"),
|
||||
)
|
||||
received = {}
|
||||
|
||||
def merge(**kwargs):
|
||||
received.update(kwargs)
|
||||
return "saved/model.safetensors", "SUCCESS\nDETAILS"
|
||||
|
||||
monkeypatch.setattr(resize, "merge_loras_to_model", merge)
|
||||
|
||||
result = resize.LoRAMergeToModel.execute(
|
||||
"base.safetensors", "1",
|
||||
@@ -436,9 +486,11 @@ def test_merge_to_model_node_returns_path_blank_line_report(
|
||||
"None", 1.0, "None", 1.0, "None", 1.0,
|
||||
"None", 1.0, "None", 1.0, "None", 1.0, "None", 1.0,
|
||||
"", "merged", "fp32", "cpu", True, False, False,
|
||||
include_mode=include_mode,
|
||||
)
|
||||
|
||||
assert result.result == ("merged.safetensors", "SUCCESS\nDETAILS")
|
||||
assert received["include_mode"] is include_mode
|
||||
|
||||
|
||||
def test_mixed_parser_scans_every_key_and_retains_unpaired(modules):
|
||||
@@ -486,7 +538,10 @@ def test_include_1d_switches_are_appended_and_default_off(modules, generic_modul
|
||||
|
||||
for node_class in node_classes:
|
||||
final_input = node_class.define_schema().inputs[-1]
|
||||
assert final_input.id == "include_1d_diffs"
|
||||
expected = "include_mode" if node_class in {
|
||||
generic.LoRATwoMerger, generic.LoRAThreeMerger, resize.LoRAMergeToModel
|
||||
} else "include_1d_diffs"
|
||||
assert final_input.id == expected
|
||||
assert final_input.default is False
|
||||
|
||||
outputs = resize.LoRAMergeToModel.define_schema().outputs
|
||||
|
||||
@@ -88,6 +88,55 @@ def test_resize_filters_preserve_and_discard_whole_layers(monkeypatch, tmp_path,
|
||||
assert actual["diffusion_model.resize.lora_A.weight"].shape[0] == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("glob", [False, True])
|
||||
def test_resize_include_mode_only_resizes_matching_layers(monkeypatch, tmp_path, resize, glob):
|
||||
source = tmp_path / "include_filters.safetensors"
|
||||
tensors = {}
|
||||
for name in ("include", "discard", "skip"):
|
||||
tensors[f"diffusion_model.{name}.lora_A.weight"] = torch.eye(3, dtype=torch.float16)
|
||||
tensors[f"diffusion_model.{name}.lora_B.weight"] = torch.eye(3, dtype=torch.float16)
|
||||
tensors[f"diffusion_model.{name}.alpha"] = torch.tensor(1.5)
|
||||
tensors["standalone.include"] = torch.ones(1)
|
||||
tensors["standalone.skip"] = torch.ones(1)
|
||||
_write_uel(source, tensors)
|
||||
monkeypatch.setattr(resize.folder_paths, "models_dir", str(tmp_path))
|
||||
monkeypatch.setattr(resize, "prepare_for_large_operation", lambda *args: None)
|
||||
monkeypatch.setattr(resize, "cleanup_after_operation", lambda: None)
|
||||
|
||||
output = resize.resize_lora_file(
|
||||
str(source), 1, None, None, "cpu", torch.bfloat16, "included", verbose=False,
|
||||
exclude_patterns="*include*\n*discard*\nstandalone.include" if glob else r"\.(include|discard)\." + "\n^standalone\\.include$",
|
||||
discard_patterns="*discard*" if glob else r"\.discard\.",
|
||||
glob_patterns=glob,
|
||||
include_mode=True,
|
||||
)
|
||||
|
||||
actual = _read_uel(output)
|
||||
assert actual["diffusion_model.include.lora_A.weight"].shape[0] == 1
|
||||
assert torch.equal(actual["standalone.include"], tensors["standalone.include"])
|
||||
assert not any(".discard." in key for key in actual)
|
||||
for key in tensors:
|
||||
if ".skip." in key or key == "standalone.skip":
|
||||
assert torch.equal(actual[key], tensors[key])
|
||||
|
||||
|
||||
def test_resize_include_mode_empty_patterns_preserves_all_tensors(monkeypatch, tmp_path, resize):
|
||||
source = tmp_path / "empty_include.safetensors"
|
||||
tensors = {"standalone": torch.ones(2)}
|
||||
_write_uel(source, tensors)
|
||||
monkeypatch.setattr(resize.folder_paths, "models_dir", str(tmp_path))
|
||||
monkeypatch.setattr(resize, "prepare_for_large_operation", lambda *args: None)
|
||||
monkeypatch.setattr(resize, "cleanup_after_operation", lambda: None)
|
||||
|
||||
output = resize.resize_lora_file(
|
||||
str(source), 1, None, None, "cpu", torch.float32, "empty", verbose=False,
|
||||
include_mode=True,
|
||||
)
|
||||
|
||||
actual = _read_uel(output)
|
||||
assert torch.equal(actual["standalone"], tensors["standalone"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("node_name,extra", [
|
||||
("LoRAResizeFixed", {"new_rank": 1}),
|
||||
("LoRAResizeRatio", {"max_rank": 1, "ratio": 2.0}),
|
||||
@@ -100,16 +149,19 @@ def test_all_resize_nodes_forward_filters(monkeypatch, tmp_path, resize, node_na
|
||||
assert controls["exclude_patterns"].default == ""
|
||||
assert controls["discard_patterns"].default == ""
|
||||
assert controls["glob_patterns"].default is False
|
||||
assert controls["include_mode"].default is False
|
||||
assert node.define_schema().inputs[-1].id == "include_mode"
|
||||
calls = []
|
||||
monkeypatch.setattr(resize.folder_paths, "models_dir", str(tmp_path))
|
||||
monkeypatch.setattr(resize.folder_paths, "get_full_path_or_raise", lambda *args: "input")
|
||||
monkeypatch.setattr(resize, "resize_lora_file", lambda *args, **kwargs: calls.append(kwargs))
|
||||
node.execute(lora_name="input", output_filename="out", save_dtype="fp16", device="cpu",
|
||||
force_clear_cache=False, exclude_patterns="keep*", discard_patterns="drop*",
|
||||
glob_patterns=True, **extra)
|
||||
glob_patterns=True, include_mode=True, **extra)
|
||||
assert calls[0]["exclude_patterns"] == "keep*"
|
||||
assert calls[0]["discard_patterns"] == "drop*"
|
||||
assert calls[0]["glob_patterns"] is True
|
||||
assert calls[0]["include_mode"] is True
|
||||
|
||||
|
||||
def test_resize_invalid_filter_fails_before_writer(monkeypatch, tmp_path, resize):
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import importlib
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from safetensors.torch import load_file, save_file
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
PACKAGE_NAME = "modelutils_merger_filter_tests"
|
||||
|
||||
|
||||
def _module(name):
|
||||
if PACKAGE_NAME not in sys.modules:
|
||||
package = types.ModuleType(PACKAGE_NAME)
|
||||
package.__path__ = [str(REPO_ROOT)]
|
||||
sys.modules[PACKAGE_NAME] = package
|
||||
return importlib.import_module(f"{PACKAGE_NAME}.{name}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def modules():
|
||||
return _module("nodes.merger"), _module("nodes.merger_ops")
|
||||
|
||||
|
||||
def _params(name, *, patterns="", include=False, discard="", glob=False):
|
||||
return {
|
||||
"mismatch_mode": "skip", "alignment_mode": "pad/crop", "alpha": 0.5,
|
||||
"beta": 0.0, "gamma": 0.5, "delta": 2.0, "epsilon": 0.01,
|
||||
"zeta": 0.0, "seed": 0, "output_filename": name, "save_dtype": "fp32",
|
||||
"override_dtype": True, "device": "cpu", "dtype": torch.float32,
|
||||
"exclude_patterns": patterns, "include_mode": include,
|
||||
"discard_patterns": discard, "glob_patterns": glob, "lazy_load": True,
|
||||
"force_clear_cache": False,
|
||||
}
|
||||
|
||||
|
||||
def _run(monkeypatch, tmp_path, merger, operations, params):
|
||||
paths = {name: str(tmp_path / f"{name}.safetensors") for name in ("a", "b")}
|
||||
save_file({"match.weight": torch.tensor([2.0]), "other.weight": torch.tensor([4.0])}, paths["a"])
|
||||
save_file({"match.weight": torch.tensor([6.0]), "other.weight": torch.tensor([8.0])}, paths["b"])
|
||||
monkeypatch.setattr(merger.folder_paths, "get_full_path", lambda _kind, name: paths.get(name))
|
||||
monkeypatch.setattr(merger.folder_paths, "get_folder_paths", lambda _kind: [str(tmp_path)])
|
||||
monkeypatch.setattr(merger.folder_paths, "models_dir", str(tmp_path))
|
||||
monkeypatch.setattr(merger, "prepare_for_large_operation", lambda *args: None)
|
||||
monkeypatch.setattr(merger, "cleanup_after_operation", lambda: None)
|
||||
params.update({"model_a": "a", "model_b": "b"})
|
||||
merger.MergerLogic.execute_merge({"model_a": "a", "model_b": "b"}, "Weight-Sum", operations.TWO_MODEL_MODES, params, "checkpoints")
|
||||
return load_file(str(tmp_path / "checkpoints" / f"{params['output_filename']}.safetensors"))
|
||||
|
||||
|
||||
def test_include_filter_controls_preflight_and_streaming(monkeypatch, tmp_path, modules):
|
||||
merger, operations = modules
|
||||
selected = _run(monkeypatch, tmp_path, merger, operations, _params("selected", patterns="match", include=True))
|
||||
assert selected["match.weight"].item() == 4.0
|
||||
assert selected["other.weight"].item() == 4.0
|
||||
|
||||
empty = _run(monkeypatch, tmp_path, merger, operations, _params("empty", include=True))
|
||||
assert empty["match.weight"].item() == 2.0
|
||||
assert empty["other.weight"].item() == 4.0
|
||||
|
||||
discarded = _run(monkeypatch, tmp_path, merger, operations, _params("discarded", patterns="match", include=True, discard="match"))
|
||||
assert "match.weight" not in discarded
|
||||
assert discarded["other.weight"].item() == 4.0
|
||||
|
||||
globbed = _run(monkeypatch, tmp_path, merger, operations, _params("globbed", patterns="*.weight", include=True, glob=True))
|
||||
assert globbed["match.weight"].item() == 4.0
|
||||
assert globbed["other.weight"].item() == 6.0
|
||||
@@ -53,6 +53,8 @@ def test_five_node_schema_contract(analysis):
|
||||
assert schema.inputs[0].options == ["ANALYZE", "DOCUMENTATION ONLY"]
|
||||
assert "lazy_load" not in [item.id for item in schema.inputs]
|
||||
input_ids = [item.id for item in schema.inputs]
|
||||
assert input_ids[-3:] == ["exclude_patterns", "glob_patterns", "include_mode"]
|
||||
assert schema.inputs[-1].default is False
|
||||
if node in (analysis.LoRAModelAnalysis, analysis.EmbeddingModelAnalysis):
|
||||
assert input_ids[3] == "cwb_similarity_alignment"
|
||||
else:
|
||||
@@ -227,7 +229,14 @@ def test_lora_cross_format_keys_compare_as_one_logical_pair(monkeypatch, tmp_pat
|
||||
assert all(len(call[1]) == 2 for call in async_calls)
|
||||
|
||||
|
||||
def test_excluded_lora_blocks_do_not_enter_metrics(monkeypatch, tmp_path, analysis):
|
||||
@pytest.mark.parametrize("patterns, glob_patterns, include_mode", [
|
||||
(r"^diffusion_model\.blocks\.4\.", False, False),
|
||||
(r"^diffusion_model\.blocks\.3\.", False, True),
|
||||
("diffusion_model.blocks.3.*", True, True),
|
||||
])
|
||||
def test_excluded_lora_blocks_do_not_enter_metrics(
|
||||
monkeypatch, tmp_path, analysis, patterns, glob_patterns, include_mode,
|
||||
):
|
||||
path_a = tmp_path / "lora_a.safetensors"
|
||||
path_b = tmp_path / "lora_b.safetensors"
|
||||
tensors_a = {}
|
||||
@@ -247,7 +256,7 @@ def test_excluded_lora_blocks_do_not_enter_metrics(monkeypatch, tmp_path, analys
|
||||
|
||||
comparison, cwb_report = analysis.ModelAnalysisLogic.execute(
|
||||
"a", "b", "loras",
|
||||
_params(exclude_patterns=r"^diffusion_model\.blocks\.4\."),
|
||||
_params(exclude_patterns=patterns, glob_patterns=glob_patterns, include_mode=include_mode),
|
||||
lora_mode=True,
|
||||
)
|
||||
|
||||
@@ -262,6 +271,40 @@ def test_excluded_lora_blocks_do_not_enter_metrics(monkeypatch, tmp_path, analys
|
||||
assert "blocks_4_" not in cwb_report
|
||||
|
||||
|
||||
@pytest.mark.parametrize("patterns, glob_patterns, expected_keys", [
|
||||
(r"^blocks\.0\.", False, ["blocks.0.weight"]),
|
||||
("blocks.0.*", True, ["blocks.0.weight"]),
|
||||
("", False, []),
|
||||
("no_such_layer", True, []),
|
||||
])
|
||||
def test_include_filter_limits_analysis_inventory(
|
||||
monkeypatch, tmp_path, analysis, patterns, glob_patterns, expected_keys,
|
||||
):
|
||||
paths = {name: str(tmp_path / f"{name}.safetensors") for name in ("a", "b")}
|
||||
for name, value in (("a", 1.0), ("b", 2.0)):
|
||||
save_file({
|
||||
"blocks.0.weight": torch.full((2, 2), value),
|
||||
"blocks.1.weight": torch.full((2, 2), value),
|
||||
f"only_{name}.weight": torch.full((2, 2), value),
|
||||
}, paths[name])
|
||||
monkeypatch.setattr(analysis.folder_paths, "get_full_path", lambda _, name: paths[name])
|
||||
monkeypatch.setattr(analysis, "prepare_for_large_operation", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(analysis, "cleanup_after_operation", lambda: None)
|
||||
streamed = []
|
||||
original_stream = analysis.MemoryEfficientSafeOpen.async_stream
|
||||
|
||||
def stream(handler, keys, *args, **kwargs):
|
||||
streamed.extend(keys)
|
||||
return original_stream(handler, keys, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(analysis.MemoryEfficientSafeOpen, "async_stream", stream)
|
||||
analysis.ModelAnalysisLogic.execute(
|
||||
"a", "b", "diffusion_models",
|
||||
_params(exclude_patterns=patterns, glob_patterns=glob_patterns, include_mode=True),
|
||||
)
|
||||
assert sorted(streamed) == sorted(expected_keys * 2)
|
||||
|
||||
|
||||
def test_embedding_similarity_alignment_is_explicit_opt_in(analysis):
|
||||
a = torch.tensor([[1.0, 0.0], [0.0, 1.0]])
|
||||
b = torch.tensor([[0.0, 1.0], [1.0, 0.0]])
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import ast
|
||||
import importlib
|
||||
import inspect
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from comfy_api.latest import io
|
||||
|
||||
|
||||
NODE_DIR = Path(__file__).resolve().parents[1] / "nodes"
|
||||
|
||||
@@ -40,3 +47,102 @@ def test_every_schema_input_has_a_direct_static_tooltip():
|
||||
|
||||
assert not missing, "Inputs without tooltips:\n" + "\n".join(missing)
|
||||
assert not non_static, "Tooltips must be direct non-empty string literals:\n" + "\n".join(non_static)
|
||||
|
||||
|
||||
FILTER_MODULES = [
|
||||
"merger", "consensus_merger", "cwb_delta_lora_merger", "lodestone_merger",
|
||||
"lora_resize", "dtype_conversion", "model_analysis",
|
||||
"lora_extract_svd", "dora_extract_wd", "dora_learned_wd", "text_encoder_extract",
|
||||
]
|
||||
|
||||
|
||||
def test_filter_schema_audit_covers_all_direct_filter_definitions():
|
||||
discovered = set()
|
||||
for path in NODE_DIR.glob("*.py"):
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
for call in ast.walk(tree):
|
||||
if (
|
||||
isinstance(call, ast.Call)
|
||||
and isinstance(call.func, ast.Attribute)
|
||||
and call.func.attr == "Input"
|
||||
and call.args
|
||||
and isinstance(call.args[0], ast.Constant)
|
||||
and call.args[0].value in {"exclude_patterns", "skip_patterns"}
|
||||
):
|
||||
discovered.add(path.stem)
|
||||
assert discovered <= set(FILTER_MODULES), f"Unaudited filter modules: {discovered - set(FILTER_MODULES)}"
|
||||
|
||||
|
||||
def _load_filter_module(module_name):
|
||||
package_name = "modelutils_filter_schema_tests"
|
||||
if package_name not in sys.modules:
|
||||
package = types.ModuleType(package_name)
|
||||
package.__path__ = [str(NODE_DIR.parent)]
|
||||
sys.modules[package_name] = package
|
||||
return importlib.import_module(f"{package_name}.nodes.{module_name}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("module_name", FILTER_MODULES)
|
||||
def test_layer_filter_schemas_append_disabled_include_toggle(module_name):
|
||||
module = _load_filter_module(module_name)
|
||||
checked = []
|
||||
for name, node in vars(module).items():
|
||||
if name.startswith("_") or not isinstance(node, type):
|
||||
continue
|
||||
if node.__module__ != module.__name__ or not issubclass(node, io.ComfyNode):
|
||||
continue
|
||||
schema = node.define_schema()
|
||||
ids = [item.id for item in schema.inputs]
|
||||
if not {"exclude_patterns", "skip_patterns"}.intersection(ids):
|
||||
continue
|
||||
assert ids.count("include_mode") == 1, schema.node_id
|
||||
assert ids[-1] == "include_mode", schema.node_id
|
||||
assert isinstance(schema.inputs[-1], io.Boolean.Input), schema.node_id
|
||||
assert schema.inputs[-1].default is False, schema.node_id
|
||||
checked.append(schema.node_id)
|
||||
assert checked, f"No filter nodes checked in {module_name}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("module_name, helper_name, expected_count", [
|
||||
("lora_extract_svd", "extract_lora_from_files", 5),
|
||||
("dora_extract_wd", "extract_dora_from_files", 5),
|
||||
("dora_learned_wd", "extract_dora_learned_from_files", 5),
|
||||
("text_encoder_extract", "extract_te_from_files", 10),
|
||||
])
|
||||
@pytest.mark.parametrize("include_mode", [False, True])
|
||||
def test_all_extraction_nodes_forward_include_mode_without_shifting_parameters(
|
||||
monkeypatch, module_name, helper_name, expected_count, include_mode,
|
||||
):
|
||||
module = _load_filter_module(module_name)
|
||||
signature = inspect.signature(getattr(module, helper_name))
|
||||
assert list(signature.parameters)[-1] == "include_mode"
|
||||
captured = []
|
||||
|
||||
def extract(*args, **kwargs):
|
||||
bound = signature.bind(*args, **kwargs)
|
||||
bound.apply_defaults()
|
||||
captured.append(bound.arguments)
|
||||
|
||||
monkeypatch.setattr(module, helper_name, extract)
|
||||
monkeypatch.setattr(module.folder_paths, "get_full_path_or_raise", lambda category, name: name)
|
||||
monkeypatch.setattr(module, "_build_lora_output_path", lambda name: (name, name))
|
||||
for name, node in vars(module).items():
|
||||
if name.startswith("_") or not isinstance(node, type):
|
||||
continue
|
||||
if node.__module__ != module.__name__ or not issubclass(node, io.ComfyNode):
|
||||
continue
|
||||
schema = node.define_schema()
|
||||
values = {}
|
||||
for widget in schema.inputs:
|
||||
value = getattr(widget, "default", None)
|
||||
if value is None:
|
||||
options = getattr(widget, "options", [])
|
||||
value = options[0] if options else widget.id
|
||||
values[widget.id] = value
|
||||
values["include_mode"] = include_mode
|
||||
values["skip_patterns"] = "selected_layer"
|
||||
node.execute(**values)
|
||||
assert captured[-1]["include_mode"] is include_mode, schema.node_id
|
||||
assert captured[-1]["skip_patterns_str"] == "selected_layer", schema.node_id
|
||||
assert captured[-1]["knee_probe_offset"] == values.get("knee_probe_offset", 32), schema.node_id
|
||||
assert len(captured) == expected_count
|
||||
|
||||
Reference in New Issue
Block a user