Bug fixed
This commit is contained in:
@@ -215,6 +215,7 @@ Outputs:
|
||||
|
||||
Highlights:
|
||||
- Added `LoRA Stack (Model In→Out) WAN` node: directly applies up to 4 WAN / Flow / Standard LoRAs to an incoming MODEL and outputs a patched MODEL (ideal for WAN 2.2 workflows where a single node step is preferred).
|
||||
- Added `LoRA Schedule (WAN, ranged)` node: activates extra LoRA stacks by generation index, with optional open-ended ranges for long loop workflows.
|
||||
- Extended internal WAN key remapping for seamless WAN 2.2 (Flow) + WAN 2.1 cross-compatibility.
|
||||
- Version bump across project files.
|
||||
|
||||
@@ -241,6 +242,37 @@ Recommended Use (WAN 2.2 workflows):
|
||||
|
||||
Why this node: Eliminates one extra node hop, reduces graph complexity and clarifies model lineage in large animation workflows.
|
||||
|
||||
### Additional Node: LoRA Schedule (WAN, ranged)
|
||||
|
||||
Use this node when a WAN loop needs always-on LoRAs plus extra LoRAs that only apply on specific generations or generation ranges.
|
||||
|
||||
Typical setup:
|
||||
1. Keep your main always-on LoRAs in `IAMCCS_WanLoRAStack` or `IAMCCS_WanLoRAStackModelIO`.
|
||||
2. Build extra LoRA stacks for alternate phases.
|
||||
3. Feed the loop `index` into `generation_index`.
|
||||
4. Set `slot_01_start/end`, `slot_02_start/end`, etc. to define which extra stack is active on which generations.
|
||||
5. Send the scheduler `lora` output into the optional `lora` input of your main WAN LoRA stack node.
|
||||
|
||||
Notes:
|
||||
- `default_lora` stays active on every generation.
|
||||
- `end = -1` means "from this generation onward".
|
||||
- Multiple active slots stack together, so you can layer phase LoRAs if ranges overlap.
|
||||
|
||||
Preset behavior:
|
||||
- `manual_range`: uses `start/end`; if `end = -1`, the slot stays active from `start` onward.
|
||||
- `all_generations`: always active.
|
||||
- `only_first`: active only on generation `0`.
|
||||
- `all_nonfirst`: active from generation `1` onward.
|
||||
- `even_generations`: active on `0, 2, 4, ...`.
|
||||
- `odd_generations`: active on `1, 3, 5, ...`.
|
||||
- `every_2_from_start`: active on `start, start+2, start+4, ...`.
|
||||
- `every_3_from_start`: active on `start, start+3, start+6, ...`.
|
||||
|
||||
Logging:
|
||||
- Each execution logs the current `generation_index`.
|
||||
- Logs show the always-on `default_lora` entries.
|
||||
- Logs show each active slot, the preset that matched, and the actual LoRA names/strengths injected on that generation.
|
||||
|
||||
## Previous Versions
|
||||
|
||||
### Version 1.2.3 — New input lora - add another StackLoraModel (concatenate) + Extended Wan 2.1 Compatibility
|
||||
|
||||
@@ -19,6 +19,9 @@ from .iamccs_wan_lora_stack import (
|
||||
from .iamccs_wan_lora_stack_simple import (
|
||||
IAMCCS_WanLoRAStackModelIO,
|
||||
)
|
||||
from .iamccs_wan_lora_schedule import (
|
||||
IAMCCS_WanLoRASchedule,
|
||||
)
|
||||
|
||||
from .iamccs_ltx2_lora_stack import (
|
||||
IAMCCS_LTX2_LoRAStack,
|
||||
@@ -204,6 +207,7 @@ NODE_CLASS_MAPPINGS = {
|
||||
"IAMCCS_WanLoRAStack": IAMCCS_WanLoRAStack,
|
||||
"IAMCCS_ModelWithLoRA": IAMCCS_ModelWithLoRA,
|
||||
"IAMCCS_WanLoRAStackModelIO": IAMCCS_WanLoRAStackModelIO,
|
||||
"IAMCCS_WanLoRASchedule": IAMCCS_WanLoRASchedule,
|
||||
# Backward-compatible key (kept as-is for existing workflows)
|
||||
"iamccs_ltx2_lora_stack": IAMCCS_LTX2_LoRAStack,
|
||||
# Preferred explicit names
|
||||
@@ -336,6 +340,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"IAMCCS_WanLoRAStack": "LoRA Stack (WAN-style remap)",
|
||||
"IAMCCS_ModelWithLoRA": "Apply LoRA to MODEL (Native)",
|
||||
"IAMCCS_WanLoRAStackModelIO": "LoRA Stack (Model In→Out) WAN",
|
||||
"IAMCCS_WanLoRASchedule": "LoRA Schedule (WAN, ranged)",
|
||||
"iamccs_ltx2_lora_stack": "iamccs_ltx2_lora_stack (3 slots)",
|
||||
"IAMCCS_LTX2_LoRAStack": "LoRA Stack (LTX-2, 3 slots)",
|
||||
"IAMCCS_LTX2_LoRAStackStaged": "LoRA Stack (LTX-2, staged: stage1+stage2) (BETA)",
|
||||
|
||||
+279
-220
@@ -1,4 +1,30 @@
|
||||
import math
|
||||
"""IAMCCS_FluxKleinMultiGen - Complete pipeline reconstruction.
|
||||
|
||||
Pipeline graph (mirrors working reference workflow 1:1):
|
||||
image
|
||||
-> ImageScaleToTotalPixels(upscale_method, megapixels, resolution_steps)
|
||||
-> scaled_image
|
||||
|
||||
scaled_image + vae
|
||||
-> VAEEncode
|
||||
-> reference_latent <- shared between positive AND negative
|
||||
|
||||
clip + positive_prompt -> CLIPTextEncode -> pos_cond
|
||||
pos_cond + reference_latent -> ReferenceLatent -> positive_with_ref
|
||||
|
||||
clip + negative_prompt -> CLIPTextEncode -> neg_cond
|
||||
neg_cond + reference_latent -> ReferenceLatent -> negative_with_ref
|
||||
|
||||
output_width, output_height -> Flux2Scheduler(steps, width, height) -> sigmas
|
||||
output_width, output_height -> EmptyFlux2LatentImage(width, height, 1) -> latent_image
|
||||
seed -> RandomNoise -> noise
|
||||
sampler_name -> KSamplerSelect -> sampler
|
||||
model + positive_with_ref + negative_with_ref + cfg -> CFGGuider -> guider
|
||||
noise + guider + sampler + sigmas + latent_image -> SamplerCustomAdvanced -> output_latent
|
||||
output_latent + vae -> VAEDecode -> image_out
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
@@ -6,187 +32,150 @@ import numpy as np
|
||||
import torch
|
||||
from PIL import Image as _PILImage
|
||||
|
||||
import comfy.utils
|
||||
import folder_paths
|
||||
import node_helpers
|
||||
|
||||
|
||||
RESOLUTION_PRESETS = [
|
||||
"512x512",
|
||||
"720x1280",
|
||||
"768x768",
|
||||
"768x1024",
|
||||
"832x1216",
|
||||
"864x1536",
|
||||
"1024x768",
|
||||
"1024x1024",
|
||||
"1024x1536",
|
||||
"1280x720",
|
||||
"1360x768",
|
||||
"1216x832",
|
||||
"1536x1024",
|
||||
"1536x864",
|
||||
"1920x1080",
|
||||
]
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unpack helpers
|
||||
# io.NodeOutput stores outputs in .args; old-style nodes return plain tuples.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _unpack(value):
|
||||
"""Unwrap io.NodeOutput or single-element tuple to the actual value."""
|
||||
if hasattr(value, "args"): # io.NodeOutput (new ComfyUI API)
|
||||
if value.args:
|
||||
return value.args[0]
|
||||
return value
|
||||
if isinstance(value, (tuple, list)) and len(value) == 1:
|
||||
return value[0]
|
||||
return value
|
||||
|
||||
|
||||
def _safe_unpack(value):
|
||||
current = value
|
||||
while isinstance(current, tuple) and len(current) == 1:
|
||||
current = current[0]
|
||||
if hasattr(current, "args"):
|
||||
args = getattr(current, "args", None)
|
||||
if isinstance(args, (tuple, list)) and len(args) > 0:
|
||||
return _safe_unpack(args[0])
|
||||
return current
|
||||
def _unpack_sampler(value):
|
||||
"""SamplerCustomAdvanced returns (output, denoised_output); take output (index 0)."""
|
||||
if hasattr(value, "args") and value.args:
|
||||
return value.args[0]
|
||||
if isinstance(value, (tuple, list)):
|
||||
return value[0]
|
||||
return value
|
||||
|
||||
|
||||
def _encode_text(clip, prompt):
|
||||
tokens = clip.tokenize(prompt or "")
|
||||
return clip.encode_from_tokens_scheduled(tokens)
|
||||
# ---------------------------------------------------------------------------
|
||||
# One function per node in the working workflow (exact mirrors)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _apply_reference_latent(conditioning, latent, method):
|
||||
conditioning = node_helpers.conditioning_set_values(
|
||||
conditioning,
|
||||
{"reference_latents": [latent]},
|
||||
append=True,
|
||||
)
|
||||
if "uxo" in method or "uso" in method:
|
||||
method = "uxo"
|
||||
return node_helpers.conditioning_set_values(
|
||||
conditioning,
|
||||
{"reference_latents_method": method},
|
||||
def _node_ImageScaleToTotalPixels(image, upscale_method, megapixels, resolution_steps):
|
||||
"""Node: ImageScaleToTotalPixels"""
|
||||
from comfy_extras.nodes_post_processing import ImageScaleToTotalPixels
|
||||
return _unpack(
|
||||
ImageScaleToTotalPixels.execute(
|
||||
image,
|
||||
str(upscale_method),
|
||||
float(megapixels),
|
||||
int(resolution_steps),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _prompt_to_slug(prompt, max_words=5):
|
||||
text = re.sub(r"<[^>]+>", "", prompt or "").strip()
|
||||
words = [re.sub(r"[^a-zA-Z0-9]", "", word) for word in text.split()[:max_words] if word]
|
||||
filtered = [word for word in words if word]
|
||||
return "_".join(filtered) if filtered else "prompt"
|
||||
def _node_VAEEncode(vae, pixels):
|
||||
"""Node: VAEEncode"""
|
||||
import nodes
|
||||
return _unpack(nodes.VAEEncode().encode(vae, pixels))
|
||||
|
||||
|
||||
def _unique_png_path(output_dir, prefix):
|
||||
counter = 1
|
||||
def _node_CLIPTextEncode(clip, text):
|
||||
"""Node: CLIPTextEncode"""
|
||||
import nodes
|
||||
return _unpack(nodes.CLIPTextEncode().encode(clip, text or ""))
|
||||
|
||||
|
||||
def _node_ReferenceLatent(conditioning, latent):
|
||||
"""Node: ReferenceLatent - latent must be a {'samples': tensor} dict"""
|
||||
from comfy_extras.nodes_edit_model import ReferenceLatent
|
||||
return _unpack(ReferenceLatent.execute(conditioning, latent))
|
||||
|
||||
|
||||
def _node_Flux2Scheduler(steps, width, height):
|
||||
"""Node: Flux2Scheduler"""
|
||||
from comfy_extras.nodes_flux import Flux2Scheduler
|
||||
return _unpack(Flux2Scheduler.execute(int(steps), int(width), int(height)))
|
||||
|
||||
|
||||
def _node_EmptyFlux2LatentImage(width, height):
|
||||
"""Node: EmptyFlux2LatentImage (batch_size always 1 per iteration)"""
|
||||
from comfy_extras.nodes_flux import EmptyFlux2LatentImage
|
||||
return _unpack(EmptyFlux2LatentImage.execute(int(width), int(height), 1))
|
||||
|
||||
|
||||
def _node_RandomNoise(seed):
|
||||
"""Node: RandomNoise"""
|
||||
from comfy_extras.nodes_custom_sampler import RandomNoise
|
||||
return _unpack(RandomNoise.execute(int(seed)))
|
||||
|
||||
|
||||
def _node_KSamplerSelect(sampler_name):
|
||||
"""Node: KSamplerSelect"""
|
||||
from comfy_extras.nodes_custom_sampler import KSamplerSelect
|
||||
return _unpack(KSamplerSelect.execute(str(sampler_name)))
|
||||
|
||||
|
||||
def _node_CFGGuider(model, positive, negative, cfg):
|
||||
"""Node: CFGGuider"""
|
||||
from comfy_extras.nodes_custom_sampler import CFGGuider
|
||||
return _unpack(CFGGuider.execute(model, positive, negative, float(cfg)))
|
||||
|
||||
|
||||
def _node_SamplerCustomAdvanced(noise, guider, sampler, sigmas, latent_image):
|
||||
"""Node: SamplerCustomAdvanced - returns first output slot (output latent)"""
|
||||
from comfy_extras.nodes_custom_sampler import SamplerCustomAdvanced
|
||||
return _unpack_sampler(
|
||||
SamplerCustomAdvanced.execute(noise, guider, sampler, sigmas, latent_image)
|
||||
)
|
||||
|
||||
|
||||
def _node_VAEDecode(vae, latent):
|
||||
"""Node: VAEDecode"""
|
||||
import nodes
|
||||
return _unpack(nodes.VAEDecode().decode(vae, latent))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Debug helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _shape(t):
|
||||
return list(t.shape) if hasattr(t, "shape") else None
|
||||
|
||||
|
||||
def _write_debug(path, record):
|
||||
with open(path, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(record, ensure_ascii=True) + "\n")
|
||||
|
||||
|
||||
def _unique_path(directory, prefix, ext):
|
||||
i = 1
|
||||
while True:
|
||||
path = os.path.join(output_dir, f"{prefix}_{counter:05d}.png")
|
||||
if not os.path.exists(path):
|
||||
return path
|
||||
counter += 1
|
||||
p = os.path.join(directory, f"{prefix}_{i:05d}.{ext}")
|
||||
if not os.path.exists(p):
|
||||
return p
|
||||
i += 1
|
||||
|
||||
|
||||
def _round_to_multiple(value, multiple=16):
|
||||
return max(multiple, int(round(float(value) / float(multiple))) * multiple)
|
||||
def _prompt_slug(text, max_words=5):
|
||||
words = [re.sub(r"[^a-zA-Z0-9]", "", w) for w in re.sub(r"<[^>]+>", "", text or "").split()]
|
||||
words = [w for w in words if w][:max_words]
|
||||
return "_".join(words) or "prompt"
|
||||
|
||||
|
||||
def _parse_resolution_preset(preset):
|
||||
try:
|
||||
width_text, height_text = str(preset).lower().split("x", 1)
|
||||
return int(width_text.strip()), int(height_text.strip())
|
||||
except Exception as exc:
|
||||
raise ValueError(f"Invalid resolution preset: {preset}") from exc
|
||||
|
||||
|
||||
def _resize_image_to_dimensions(image, width, height, upscale_method):
|
||||
target_width = _round_to_multiple(width)
|
||||
target_height = _round_to_multiple(height)
|
||||
samples = image.movedim(-1, 1)
|
||||
scaled = comfy.utils.common_upscale(samples, target_width, target_height, upscale_method, "disabled")
|
||||
return scaled.movedim(1, -1)
|
||||
|
||||
|
||||
def _scale_image_to_megapixels(image, target_megapixels, upscale_method):
|
||||
if target_megapixels <= 0:
|
||||
return image
|
||||
|
||||
height = int(image.shape[1])
|
||||
width = int(image.shape[2])
|
||||
total_pixels = max(1.0, float(height * width))
|
||||
target_pixels = max(256.0, float(target_megapixels) * 1000000.0)
|
||||
scale = math.sqrt(target_pixels / total_pixels)
|
||||
target_width = _round_to_multiple(width * scale)
|
||||
target_height = _round_to_multiple(height * scale)
|
||||
|
||||
samples = image.movedim(-1, 1)
|
||||
scaled = comfy.utils.common_upscale(samples, target_width, target_height, upscale_method, "disabled")
|
||||
return scaled.movedim(1, -1)
|
||||
|
||||
|
||||
def _prepare_flux_image(image, resolution_mode, resolution_preset, custom_width, custom_height, target_megapixels, upscale_method):
|
||||
mode = (resolution_mode or "auto").strip().lower()
|
||||
|
||||
if mode == "auto":
|
||||
return _scale_image_to_megapixels(image, float(target_megapixels), upscale_method)
|
||||
|
||||
if mode == "match_input":
|
||||
return _resize_image_to_dimensions(image, int(image.shape[2]), int(image.shape[1]), upscale_method)
|
||||
|
||||
if mode == "preset":
|
||||
width, height = _parse_resolution_preset(resolution_preset)
|
||||
return _resize_image_to_dimensions(image, width, height, upscale_method)
|
||||
|
||||
if mode == "custom":
|
||||
return _resize_image_to_dimensions(image, int(custom_width), int(custom_height), upscale_method)
|
||||
|
||||
return _scale_image_to_megapixels(image, float(target_megapixels), upscale_method)
|
||||
|
||||
|
||||
def _build_flux2_latent(width, height, batch_size):
|
||||
try:
|
||||
import nodes
|
||||
|
||||
node_cls = getattr(nodes, "EmptyFlux2LatentImage", None)
|
||||
if node_cls is not None:
|
||||
instance = node_cls()
|
||||
if hasattr(instance, "generate"):
|
||||
return _safe_unpack(instance.generate(width, height, batch_size))
|
||||
if hasattr(instance, "execute"):
|
||||
return _safe_unpack(instance.execute(width, height, batch_size))
|
||||
if hasattr(node_cls, "execute"):
|
||||
return _safe_unpack(node_cls.execute(width, height, batch_size))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from comfy_extras.nodes_flux import EmptyFlux2LatentImage
|
||||
|
||||
return _safe_unpack(EmptyFlux2LatentImage.execute(width, height, batch_size))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _sample_flux2(model, positive, negative, width, height, steps, cfg, sampler_name, seed):
|
||||
latent_image = _build_flux2_latent(width, height, 1)
|
||||
if latent_image is None:
|
||||
raise RuntimeError("EmptyFlux2LatentImage is not available")
|
||||
|
||||
try:
|
||||
from comfy_extras.nodes_custom_sampler import CFGGuider, KSamplerSelect, RandomNoise, SamplerCustomAdvanced
|
||||
from comfy_extras.nodes_flux import Flux2Scheduler
|
||||
|
||||
noise = _safe_unpack(RandomNoise.execute(int(seed)))
|
||||
guider = _safe_unpack(CFGGuider.execute(model, positive, negative, float(cfg)))
|
||||
sampler = _safe_unpack(KSamplerSelect.execute(str(sampler_name)))
|
||||
sigmas = _safe_unpack(Flux2Scheduler.execute(int(steps), int(width), int(height)))
|
||||
return _safe_unpack(SamplerCustomAdvanced.execute(noise, guider, sampler, sigmas, latent_image))
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Flux2 advanced sampler path failed: {exc}") from exc
|
||||
|
||||
|
||||
def _decode_latent(vae, latent):
|
||||
latent_samples = latent.get("samples") if isinstance(latent, dict) else latent
|
||||
decoded = vae.decode(latent_samples)
|
||||
if decoded.ndim == 5:
|
||||
decoded = decoded.reshape(-1, decoded.shape[-3], decoded.shape[-2], decoded.shape[-1])
|
||||
return decoded.cpu()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Node class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class IAMCCS_FluxKleinMultiGen:
|
||||
DISPLAY_NAME = "IAMCCS Flux Klein Multi-Gen"
|
||||
CATEGORY = "IAMCCS/Flux"
|
||||
FUNCTION = "generate"
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "INT")
|
||||
RETURN_NAMES = ("images", "count")
|
||||
|
||||
@@ -194,38 +183,38 @@ class IAMCCS_FluxKleinMultiGen:
|
||||
def INPUT_TYPES(cls):
|
||||
try:
|
||||
import comfy.samplers
|
||||
|
||||
samplers = comfy.samplers.KSampler.SAMPLERS
|
||||
except Exception:
|
||||
samplers = ["euler"]
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"model": ("MODEL",),
|
||||
"clip": ("CLIP",),
|
||||
"vae": ("VAE",),
|
||||
"image": ("IMAGE",),
|
||||
"model": ("MODEL",),
|
||||
"clip": ("CLIP",),
|
||||
"vae": ("VAE",),
|
||||
"image": ("IMAGE",),
|
||||
"multi_prompt": ("STRING", {"forceInput": True}),
|
||||
"seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF, "control_after_generate": True}),
|
||||
"steps": ("INT", {"default": 8, "min": 1, "max": 100}),
|
||||
"cfg": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 100.0, "step": 0.01}),
|
||||
"seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF, "control_after_generate": True}),
|
||||
"steps": ("INT", {"default": 8, "min": 1, "max": 100}),
|
||||
"cfg": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 100.0, "step": 0.01}),
|
||||
"sampler_name": (samplers,),
|
||||
},
|
||||
"optional": {
|
||||
"separator": ("STRING", {"default": "\\n", "multiline": False}),
|
||||
"separator": ("STRING", {"default": "\\n", "multiline": False}),
|
||||
"reference_latents_method": (
|
||||
["index_timestep_zero", "offset", "index", "uxo/uno"],
|
||||
{"default": "index_timestep_zero"},
|
||||
["workflow_default", "index_timestep_zero", "offset", "index", "uxo/uno"],
|
||||
{"default": "workflow_default"},
|
||||
),
|
||||
"target_megapixels": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 16.0, "step": 0.1}),
|
||||
"upscale_method": (["lanczos", "nearest-exact", "bilinear", "area", "bicubic"], {"default": "lanczos"}),
|
||||
"negative_prompt": ("STRING", {"default": "", "multiline": True}),
|
||||
"output_prefix": ("STRING", {"default": "flux_klein_multi"}),
|
||||
"save_images": ("BOOLEAN", {"default": False}),
|
||||
"resolution_mode": (["auto", "match_input", "preset", "custom"], {"default": "auto"}),
|
||||
"resolution_preset": (RESOLUTION_PRESETS, {"default": "1024x1024"}),
|
||||
"custom_width": ("INT", {"default": 1024, "min": 16, "max": 8192, "step": 16}),
|
||||
"custom_height": ("INT", {"default": 1024, "min": 16, "max": 8192, "step": 16}),
|
||||
"target_megapixels": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 16.0, "step": 0.1}),
|
||||
"upscale_method": (["nearest-exact", "bilinear", "area", "bicubic", "lanczos"], {"default": "nearest-exact"}),
|
||||
"negative_prompt": ("STRING", {"default": "", "multiline": True}),
|
||||
"output_prefix": ("STRING", {"default": "flux_klein_multi"}),
|
||||
"save_images": ("BOOLEAN", {"default": False}),
|
||||
"resolution_steps": ("INT", {"default": 1, "min": 1, "max": 256, "step": 1}),
|
||||
"output_width": ("INT", {"default": 720, "min": 16, "max": 8192, "step": 16}),
|
||||
"output_height": ("INT", {"default": 1024, "min": 16, "max": 8192, "step": 16}),
|
||||
"debug_enabled": ("BOOLEAN", {"default": False}),
|
||||
"debug_prefix": ("STRING", {"default": "flux_klein_debug"}),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -241,64 +230,134 @@ class IAMCCS_FluxKleinMultiGen:
|
||||
cfg,
|
||||
sampler_name,
|
||||
separator="\\n",
|
||||
reference_latents_method="index_timestep_zero",
|
||||
reference_latents_method="workflow_default",
|
||||
target_megapixels=1.0,
|
||||
upscale_method="lanczos",
|
||||
upscale_method="nearest-exact",
|
||||
negative_prompt="",
|
||||
output_prefix="flux_klein_multi",
|
||||
save_images=False,
|
||||
resolution_mode="auto",
|
||||
resolution_preset="1024x1024",
|
||||
custom_width=1024,
|
||||
custom_height=1024,
|
||||
resolution_steps=1,
|
||||
output_width=720,
|
||||
output_height=1024,
|
||||
debug_enabled=False,
|
||||
debug_prefix="flux_klein_debug",
|
||||
):
|
||||
sep = separator.replace("\\n", "\n")
|
||||
prompts = [prompt.strip() for prompt in (multi_prompt or "").split(sep) if prompt.strip()]
|
||||
prompts = [p.strip() for p in (multi_prompt or "").split(sep) if p.strip()]
|
||||
|
||||
if not prompts:
|
||||
blank = torch.zeros((1, image.shape[1], image.shape[2], 3), dtype=torch.float32)
|
||||
return (blank, 0)
|
||||
|
||||
prepared_image = _prepare_flux_image(
|
||||
image,
|
||||
resolution_mode,
|
||||
resolution_preset,
|
||||
custom_width,
|
||||
custom_height,
|
||||
target_megapixels,
|
||||
upscale_method,
|
||||
# -- debug setup --
|
||||
dbg = None
|
||||
if debug_enabled:
|
||||
out_dir = folder_paths.get_output_directory()
|
||||
dbg = _unique_path(out_dir, debug_prefix, "jsonl")
|
||||
_write_debug(dbg, {
|
||||
"event": "run_start",
|
||||
"prompt_count": len(prompts),
|
||||
"seed": int(seed), "steps": int(steps), "cfg": float(cfg),
|
||||
"sampler_name": str(sampler_name),
|
||||
"reference_latents_method": str(reference_latents_method),
|
||||
"target_megapixels": float(target_megapixels),
|
||||
"upscale_method": str(upscale_method),
|
||||
"resolution_steps": int(resolution_steps),
|
||||
"output_width": int(output_width),
|
||||
"output_height": int(output_height),
|
||||
"input_image_shape": _shape(image),
|
||||
})
|
||||
print(f"[IAMCCS_FluxKleinMultiGen] debug -> {dbg}")
|
||||
|
||||
# ── STEP 1 ImageScaleToTotalPixels ──────────────────────────────────
|
||||
# Mirrors node: image -> ImageScaleToTotalPixels -> scaled
|
||||
scaled = _node_ImageScaleToTotalPixels(
|
||||
image, upscale_method, target_megapixels, resolution_steps
|
||||
)
|
||||
reference_latent = vae.encode(prepared_image[:, :, :, :3])
|
||||
output_dir = folder_paths.get_output_directory() if save_images else None
|
||||
results = []
|
||||
if dbg:
|
||||
_write_debug(dbg, {"event": "scaled_image", "shape": _shape(scaled)})
|
||||
|
||||
for index, prompt in enumerate(prompts):
|
||||
positive = _encode_text(clip, prompt)
|
||||
negative = _encode_text(clip, negative_prompt)
|
||||
positive = _apply_reference_latent(positive, reference_latent, reference_latents_method)
|
||||
negative = _apply_reference_latent(negative, reference_latent, reference_latents_method)
|
||||
# ── STEP 2 VAEEncode ─────────────────────────────────────────────────
|
||||
# Mirrors node: scaled + vae -> VAEEncode -> reference_latent
|
||||
# ONE encode shared between BOTH positive and negative ReferenceLatent nodes
|
||||
reference_latent = _node_VAEEncode(vae, scaled)
|
||||
if dbg:
|
||||
s = reference_latent.get("samples") if isinstance(reference_latent, dict) else reference_latent
|
||||
_write_debug(dbg, {"event": "reference_latent", "samples_shape": _shape(s)})
|
||||
|
||||
width = int(prepared_image.shape[2])
|
||||
height = int(prepared_image.shape[1])
|
||||
latent = _sample_flux2(
|
||||
model,
|
||||
positive,
|
||||
negative,
|
||||
width,
|
||||
height,
|
||||
int(steps),
|
||||
float(cfg),
|
||||
sampler_name,
|
||||
int(seed) + index,
|
||||
# ── STEP 3 Negative conditioning ─────────────────────────────────────
|
||||
# Mirrors: clip + neg_text -> CLIPTextEncode -> neg_cond
|
||||
# neg_cond + reference_latent -> ReferenceLatent -> neg_with_ref
|
||||
neg_cond = _node_CLIPTextEncode(clip, negative_prompt)
|
||||
neg_with_ref = _node_ReferenceLatent(neg_cond, reference_latent)
|
||||
|
||||
# Optional method override (workflow_default = no override = matches reference pipeline)
|
||||
_method = None
|
||||
if reference_latents_method and reference_latents_method != "workflow_default":
|
||||
_method = "uxo" if ("uxo" in reference_latents_method or "uso" in reference_latents_method) else reference_latents_method
|
||||
neg_with_ref = node_helpers.conditioning_set_values(
|
||||
neg_with_ref, {"reference_latents_method": _method}
|
||||
)
|
||||
decoded = _decode_latent(vae, latent)
|
||||
|
||||
# ── STEP 4 Sampler components (constant across all prompts) ──────────
|
||||
# Mirrors: sampler_name -> KSamplerSelect -> sampler
|
||||
# steps + output_width + output_height -> Flux2Scheduler -> sigmas
|
||||
# output_width + output_height -> EmptyFlux2LatentImage -> latent_image
|
||||
sampler_obj = _node_KSamplerSelect(sampler_name)
|
||||
sigmas = _node_Flux2Scheduler(steps, output_width, output_height)
|
||||
latent_img = _node_EmptyFlux2LatentImage(output_width, output_height)
|
||||
|
||||
# ── GENERATION LOOP ────────────────────────────────────────────────────
|
||||
results = []
|
||||
save_dir = folder_paths.get_output_directory() if save_images else None
|
||||
|
||||
for idx, prompt in enumerate(prompts):
|
||||
current_seed = int(seed) + idx
|
||||
|
||||
# Mirrors: clip + prompt -> CLIPTextEncode -> pos_cond
|
||||
# pos_cond + reference_latent -> ReferenceLatent -> pos_with_ref
|
||||
pos_cond = _node_CLIPTextEncode(clip, prompt)
|
||||
pos_with_ref = _node_ReferenceLatent(pos_cond, reference_latent)
|
||||
|
||||
if _method is not None:
|
||||
pos_with_ref = node_helpers.conditioning_set_values(
|
||||
pos_with_ref, {"reference_latents_method": _method}
|
||||
)
|
||||
|
||||
# Mirrors: seed -> RandomNoise -> noise
|
||||
# model + pos_with_ref + neg_with_ref + cfg -> CFGGuider -> guider
|
||||
# SamplerCustomAdvanced(noise, guider, sampler, sigmas, latent_image)
|
||||
noise = _node_RandomNoise(current_seed)
|
||||
guider = _node_CFGGuider(model, pos_with_ref, neg_with_ref, cfg)
|
||||
output_latent = _node_SamplerCustomAdvanced(
|
||||
noise, guider, sampler_obj, sigmas, latent_img
|
||||
)
|
||||
|
||||
# Mirrors: output_latent + vae -> VAEDecode -> image_out
|
||||
decoded = _node_VAEDecode(vae, output_latent)
|
||||
if isinstance(decoded, (tuple, list)):
|
||||
decoded = decoded[0]
|
||||
if decoded.ndim == 3:
|
||||
decoded = decoded.unsqueeze(0)
|
||||
decoded = decoded.cpu()
|
||||
|
||||
if dbg:
|
||||
_write_debug(dbg, {
|
||||
"event": "iteration_result",
|
||||
"idx": idx, "seed": current_seed,
|
||||
"prompt": prompt,
|
||||
"decoded_shape": _shape(decoded),
|
||||
})
|
||||
|
||||
results.append(decoded)
|
||||
|
||||
if save_images and output_dir is not None:
|
||||
slug = _prompt_to_slug(prompt)
|
||||
prefix = f"{output_prefix}_{slug}"
|
||||
path = _unique_png_path(output_dir, prefix)
|
||||
arr = (decoded[0].numpy() * 255).clip(0, 255).astype(np.uint8)
|
||||
if save_images and save_dir:
|
||||
slug = _prompt_slug(prompt)
|
||||
path = _unique_path(save_dir, f"{output_prefix}_{slug}", "png")
|
||||
arr = (decoded[0].numpy() * 255).clip(0, 255).astype(np.uint8)
|
||||
_PILImage.fromarray(arr).save(path)
|
||||
|
||||
return (torch.cat(results, dim=0), len(prompts))
|
||||
if dbg:
|
||||
_write_debug(dbg, {"event": "run_end", "count": len(results)})
|
||||
|
||||
return (torch.cat(results, dim=0), len(prompts))
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
|
||||
import comfy.utils
|
||||
import folder_paths
|
||||
|
||||
from .iamccs_wan_lora_stack import standardize_wan_lora_keys
|
||||
|
||||
_log = logging.getLogger("IAMCCS.LoRA.Schedule")
|
||||
|
||||
_PRESET_OPTIONS = [
|
||||
"custom range", # applies from Start gen to End gen (-1 = forever)
|
||||
"manual_range", # legacy alias kept for prompt validation compatibility
|
||||
"all generations", # always active
|
||||
"gen 0 only", # first generation only
|
||||
"gen 1 onwards", # skip gen 0, apply to all subsequent
|
||||
"even gens (0,2,4...)", # every even-numbered generation
|
||||
"odd gens (1,3,5...)", # every odd-numbered generation
|
||||
"every 2nd gen", # every 2 gens starting from Start gen
|
||||
"every 3rd gen", # every 3 gens starting from Start gen
|
||||
]
|
||||
_DEFAULT_MODEL_TYPE = "flow"
|
||||
_SLOT_COUNT = 64
|
||||
_LINX_TYPE = "IAMCCS_WAN_LORA_LINX"
|
||||
_AUTO_INDEX_STATE: OrderedDict[tuple[str, str], int] = OrderedDict()
|
||||
_MAX_AUTO_INDEX_STATE = 64
|
||||
|
||||
|
||||
def _annotate_lora_entry(entry: dict, *, origin: str, generation_index: int, log_prefix: str, prompt_id: str | None, unique_id, slot: int | None = None, rule: str | None = None) -> dict:
|
||||
annotated = dict(entry)
|
||||
annotated["_iamccs_lora_origin"] = str(origin or "unknown")
|
||||
annotated["_iamccs_generation_index"] = int(generation_index)
|
||||
annotated["_iamccs_schedule_log_prefix"] = str(log_prefix or "WAN LoRA schedule")
|
||||
annotated["_iamccs_prompt_id"] = str(prompt_id or "")
|
||||
annotated["_iamccs_schedule_node_id"] = "" if unique_id is None else str(unique_id)
|
||||
if slot is not None:
|
||||
annotated["_iamccs_schedule_slot"] = int(slot)
|
||||
if rule:
|
||||
annotated["_iamccs_schedule_rule"] = str(rule)
|
||||
return annotated
|
||||
|
||||
|
||||
def _cache_put(cache: OrderedDict, key, value, max_size: int):
|
||||
cache[key] = value
|
||||
cache.move_to_end(key)
|
||||
while len(cache) > max_size:
|
||||
cache.popitem(last=False)
|
||||
|
||||
|
||||
def _get_current_prompt_id() -> str | None:
|
||||
try:
|
||||
import server # Imported lazily to avoid hard import issues during discovery.
|
||||
|
||||
prompt_server = getattr(server.PromptServer, "instance", None)
|
||||
prompt_queue = getattr(prompt_server, "prompt_queue", None)
|
||||
currently_running = getattr(prompt_queue, "currently_running", None)
|
||||
if not currently_running:
|
||||
return None
|
||||
current = next(iter(currently_running.values()))
|
||||
if len(current) >= 2:
|
||||
return str(current[1])
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _next_auto_generation_index(unique_id) -> int:
|
||||
prompt_id = _get_current_prompt_id()
|
||||
if not prompt_id or unique_id is None:
|
||||
return 0
|
||||
|
||||
key = (prompt_id, str(unique_id))
|
||||
current = int(_AUTO_INDEX_STATE.get(key, 0) or 0)
|
||||
_cache_put(_AUTO_INDEX_STATE, key, current + 1, _MAX_AUTO_INDEX_STATE)
|
||||
return current
|
||||
|
||||
|
||||
def _coerce_lora_stack(value):
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return [entry for entry in value if isinstance(entry, dict)]
|
||||
return []
|
||||
|
||||
|
||||
def _range_matches(index: int, start: int, end: int) -> bool:
|
||||
start_i = max(0, int(start))
|
||||
end_i = int(end)
|
||||
if end_i < 0:
|
||||
return index >= start_i
|
||||
if end_i < start_i:
|
||||
end_i = start_i
|
||||
return start_i <= index <= end_i
|
||||
|
||||
|
||||
def _preset_matches(index: int, preset: str, start: int, end: int) -> tuple[bool, str]:
|
||||
start_i = max(0, int(start))
|
||||
end_i = int(end)
|
||||
preset = str(preset or "custom range")
|
||||
|
||||
# Friendly names (new) + underscore aliases (backward compat with old saved workflows)
|
||||
if preset in ("all generations", "all_generations"):
|
||||
return True, "all generations"
|
||||
if preset in ("gen 0 only", "only_first"):
|
||||
return index == 0, "gen 0 only"
|
||||
if preset in ("gen 1 onwards", "all_nonfirst"):
|
||||
return index >= 1, "gen 1 onwards"
|
||||
if preset in ("even gens (0,2,4...)", "even_generations"):
|
||||
return (index % 2) == 0, "even gens"
|
||||
if preset in ("odd gens (1,3,5...)", "odd_generations"):
|
||||
return (index % 2) == 1, "odd gens"
|
||||
if preset in ("every 2nd gen", "every_2_from_start"):
|
||||
return index >= start_i and ((index - start_i) % 2) == 0, f"every 2nd gen (from {start_i})"
|
||||
if preset in ("every 3rd gen", "every_3_from_start"):
|
||||
return index >= start_i and ((index - start_i) % 3) == 0, f"every 3rd gen (from {start_i})"
|
||||
|
||||
# custom range / manual_range fallback
|
||||
if end_i < 0:
|
||||
return index >= start_i, f"range {start_i}..forever"
|
||||
return _range_matches(index, start_i, end_i), f"range {start_i}..{max(start_i, end_i)}"
|
||||
|
||||
|
||||
def _stack_names(stack: list[dict]) -> str:
|
||||
names = []
|
||||
for entry in stack:
|
||||
name = str(entry.get("name") or "unnamed")
|
||||
strength = entry.get("strength", 0.0)
|
||||
names.append(f"{name}({strength})")
|
||||
return ", ".join(names) if names else "empty"
|
||||
|
||||
|
||||
def _candidate_low_names(name: str) -> list[str]:
|
||||
source = str(name or "")
|
||||
if not source or source == "no":
|
||||
return []
|
||||
|
||||
replacements = [
|
||||
("_HN_", "_LN_"),
|
||||
("-HN_", "-LN_"),
|
||||
("_HN-", "_LN-"),
|
||||
("_HIGH_", "_LOW_"),
|
||||
("-HIGH_", "-LOW_"),
|
||||
("_HIGH-", "_LOW-"),
|
||||
("HN", "LN"),
|
||||
("Hn", "Ln"),
|
||||
("hn", "ln"),
|
||||
("HIGH", "LOW"),
|
||||
("High", "Low"),
|
||||
("high", "low"),
|
||||
]
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for old, new in replacements:
|
||||
if old not in source:
|
||||
continue
|
||||
candidate = source.replace(old, new)
|
||||
if candidate not in seen:
|
||||
out.append(candidate)
|
||||
seen.add(candidate)
|
||||
if source not in seen:
|
||||
out.append(source)
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_linx_name(local_name: str, slot: int, linx_payload, available_loras: set[str]) -> str:
|
||||
local = str(local_name or "no")
|
||||
if local != "no":
|
||||
return local
|
||||
if not isinstance(linx_payload, dict):
|
||||
return local
|
||||
|
||||
slot_map = linx_payload.get("slot_map") or {}
|
||||
slot_data = slot_map.get(f"slot_{slot:02d}") or {}
|
||||
suggested = str(slot_data.get("suggested_low_name") or "")
|
||||
if suggested and suggested in available_loras:
|
||||
return suggested
|
||||
|
||||
source_name = str(slot_data.get("name") or "")
|
||||
for candidate in _candidate_low_names(source_name):
|
||||
if candidate in available_loras:
|
||||
return candidate
|
||||
return local
|
||||
|
||||
|
||||
def _build_linx_payload(model_type: str, kwargs: dict, available_loras: set[str]) -> dict:
|
||||
slot_map = {}
|
||||
for slot in range(1, _SLOT_COUNT + 1):
|
||||
name = str(kwargs.get(f"slot_{slot:02d}_lora_name") or "no")
|
||||
suggested_low_name = ""
|
||||
for candidate in _candidate_low_names(name):
|
||||
if candidate in available_loras:
|
||||
suggested_low_name = candidate
|
||||
break
|
||||
slot_map[f"slot_{slot:02d}"] = {
|
||||
"name": name,
|
||||
"strength": float(kwargs.get(f"slot_{slot:02d}_strength", 0.0) or 0.0),
|
||||
"preset": str(kwargs.get(f"slot_{slot:02d}_preset", "custom range") or "custom range"),
|
||||
"start": int(kwargs.get(f"slot_{slot:02d}_start", slot - 1) or 0),
|
||||
"end": int(kwargs.get(f"slot_{slot:02d}_end", slot - 1) or 0),
|
||||
"suggested_low_name": suggested_low_name,
|
||||
}
|
||||
return {
|
||||
"type": _LINX_TYPE,
|
||||
"model_type": str(model_type or _DEFAULT_MODEL_TYPE),
|
||||
"slot_map": slot_map,
|
||||
}
|
||||
|
||||
|
||||
def _load_internal_lora_entry(name: str, strength: float, model_type: str, cache: dict) -> dict | None:
|
||||
if not name or name == "no":
|
||||
return None
|
||||
|
||||
strength_f = float(strength)
|
||||
if strength_f == 0.0:
|
||||
return None
|
||||
|
||||
cache_key = (str(name), str(model_type or _DEFAULT_MODEL_TYPE))
|
||||
if cache_key in cache:
|
||||
state_dict = cache[cache_key]
|
||||
else:
|
||||
path = folder_paths.get_full_path_or_raise("loras", name)
|
||||
state_dict = comfy.utils.load_torch_file(path, safe_load=True)
|
||||
if str(model_type or _DEFAULT_MODEL_TYPE) != "standard":
|
||||
state_dict = standardize_wan_lora_keys(state_dict)
|
||||
cache[cache_key] = state_dict
|
||||
|
||||
return {
|
||||
"name": str(name),
|
||||
"strength": strength_f,
|
||||
"state_dict": state_dict,
|
||||
}
|
||||
|
||||
|
||||
class IAMCCS_WanLoRASchedule:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
lora_list = folder_paths.get_filename_list("loras") + ["no"]
|
||||
required = {
|
||||
# NOTE: Must NOT use lazy:True here. With lazy:True, ComfyUI caches the
|
||||
# node output after the first call (gen_idx=None→0) and skips re-execution
|
||||
# on subsequent loop iterations when only generation_index changes, causing
|
||||
# the schedule to always behave as if generation_index==0.
|
||||
"generation_index": ("INT", {"default": 0, "min": 0, "max": 1000000, "step": 1}),
|
||||
"log_prefix": ("STRING", {"default": "WAN LoRA schedule"}),
|
||||
"model_type": (["wan2x", "flow", "standard"], {"default": _DEFAULT_MODEL_TYPE}),
|
||||
}
|
||||
|
||||
optional = {
|
||||
"default_lora": ("LORA",),
|
||||
"linx": (_LINX_TYPE,),
|
||||
}
|
||||
|
||||
hidden = {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
}
|
||||
|
||||
for slot in range(1, _SLOT_COUNT + 1):
|
||||
required[f"slot_{slot:02d}_lora_name"] = (lora_list, {"default": "no"})
|
||||
required[f"slot_{slot:02d}_strength"] = (
|
||||
"FLOAT",
|
||||
{"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.01},
|
||||
)
|
||||
required[f"slot_{slot:02d}_preset"] = (_PRESET_OPTIONS, {"default": "custom range"})
|
||||
required[f"slot_{slot:02d}_start"] = (
|
||||
"INT",
|
||||
{"default": slot - 1, "min": 0, "max": 1000000, "step": 1},
|
||||
)
|
||||
required[f"slot_{slot:02d}_end"] = (
|
||||
"INT",
|
||||
{"default": slot - 1, "min": -1, "max": 1000000, "step": 1},
|
||||
)
|
||||
|
||||
return {"required": required, "optional": optional, "hidden": hidden}
|
||||
|
||||
RETURN_TYPES = ("LORA", "INT", "STRING", _LINX_TYPE)
|
||||
RETURN_NAMES = ("lora", "active_slots", "report", "linx")
|
||||
FUNCTION = "schedule"
|
||||
CATEGORY = "IAMCCS/LoRA"
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
# This node may use the internal loop-safe auto counter mode; cached reuse
|
||||
# would freeze the LoRA selection on the first iteration.
|
||||
return float("nan")
|
||||
|
||||
def schedule(self, generation_index, log_prefix, model_type=_DEFAULT_MODEL_TYPE, default_lora=None, linx=None, unique_id=None, **kwargs):
|
||||
# Loop-safe mode: when generation_index is left at its hidden default 0,
|
||||
# use a per-prompt internal counter instead of wiring the easy-use loop
|
||||
# index into the MODEL branch, which creates a prompt-validation cycle.
|
||||
generation_source = "input"
|
||||
if generation_index is None:
|
||||
generation_index = _next_auto_generation_index(unique_id)
|
||||
generation_source = "auto:none"
|
||||
else:
|
||||
generation_index = int(generation_index)
|
||||
if generation_index == 0:
|
||||
generation_index = _next_auto_generation_index(unique_id)
|
||||
generation_source = "auto:zero"
|
||||
generation_index = max(0, generation_index)
|
||||
log_prefix = str(log_prefix or "WAN LoRA schedule")
|
||||
model_type = str(model_type or _DEFAULT_MODEL_TYPE)
|
||||
prompt_id = _get_current_prompt_id()
|
||||
available_loras = set(folder_paths.get_filename_list("loras"))
|
||||
out = [
|
||||
_annotate_lora_entry(
|
||||
entry,
|
||||
origin="default",
|
||||
generation_index=generation_index,
|
||||
log_prefix=log_prefix,
|
||||
prompt_id=prompt_id,
|
||||
unique_id=unique_id,
|
||||
)
|
||||
for entry in _coerce_lora_stack(default_lora)
|
||||
]
|
||||
active_slots = []
|
||||
cache: dict = {}
|
||||
linx_payload = _build_linx_payload(model_type, kwargs, available_loras)
|
||||
|
||||
if out:
|
||||
_log.info(
|
||||
"[%s] prompt=%s node=%s generation=%s source=%s | default_lora=%s",
|
||||
log_prefix,
|
||||
prompt_id or "unknown",
|
||||
unique_id if unique_id is not None else "unknown",
|
||||
generation_index,
|
||||
generation_source,
|
||||
_stack_names(out),
|
||||
)
|
||||
|
||||
for slot in range(1, _SLOT_COUNT + 1):
|
||||
preset = kwargs.get(f"slot_{slot:02d}_preset", "custom range")
|
||||
start = kwargs.get(f"slot_{slot:02d}_start", slot - 1)
|
||||
end = kwargs.get(f"slot_{slot:02d}_end", slot - 1)
|
||||
matched, preset_report = _preset_matches(generation_index, preset, start, end)
|
||||
if not matched:
|
||||
continue
|
||||
|
||||
slot_stack = []
|
||||
slot_name = _resolve_linx_name(
|
||||
str(kwargs.get(f"slot_{slot:02d}_lora_name") or "no"),
|
||||
slot,
|
||||
linx,
|
||||
available_loras,
|
||||
)
|
||||
entry = _load_internal_lora_entry(
|
||||
slot_name,
|
||||
float(kwargs.get(f"slot_{slot:02d}_strength", 0.0) or 0.0),
|
||||
model_type,
|
||||
cache,
|
||||
)
|
||||
if entry is not None:
|
||||
slot_stack.append(
|
||||
_annotate_lora_entry(
|
||||
entry,
|
||||
origin="scheduled",
|
||||
generation_index=generation_index,
|
||||
log_prefix=log_prefix,
|
||||
prompt_id=prompt_id,
|
||||
unique_id=unique_id,
|
||||
slot=slot,
|
||||
rule=preset_report,
|
||||
)
|
||||
)
|
||||
|
||||
if not slot_stack:
|
||||
continue
|
||||
|
||||
out.extend(slot_stack)
|
||||
active_slots.append(f"slot_{slot:02d}:{preset_report} => {_stack_names(slot_stack)}")
|
||||
|
||||
report = (
|
||||
f"generation_index={generation_index} | active={' + '.join(active_slots) if active_slots else 'default_only'} | "
|
||||
f"entries={len(out)}"
|
||||
)
|
||||
|
||||
if out:
|
||||
_log.info("[%s] %s", log_prefix, report)
|
||||
_log.info(
|
||||
"[%s] generation=%s resolved_stack=%s",
|
||||
log_prefix,
|
||||
generation_index,
|
||||
_stack_names(out),
|
||||
)
|
||||
else:
|
||||
_log.warning("[%s] No LoRA active | generation_index=%s", log_prefix, generation_index)
|
||||
|
||||
return (out, len(active_slots), report, linx_payload)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"IAMCCS_WanLoRASchedule": IAMCCS_WanLoRASchedule,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"IAMCCS_WanLoRASchedule": "LoRA Schedule (WAN, ranged)",
|
||||
}
|
||||
@@ -5,11 +5,88 @@
|
||||
# ===============================================================
|
||||
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
|
||||
import comfy.utils
|
||||
import comfy.sd
|
||||
import folder_paths
|
||||
|
||||
|
||||
_STATE_DICT_CACHE: OrderedDict[tuple[str, str], dict] = OrderedDict()
|
||||
_PATCHED_MODEL_CACHE: OrderedDict[tuple[int, tuple[tuple[str, float], ...]], object] = OrderedDict()
|
||||
_MAX_STATE_DICT_CACHE = 16
|
||||
_MAX_PATCHED_MODEL_CACHE = 8
|
||||
|
||||
|
||||
def _lora_stack_debug_summary(lora) -> str:
|
||||
if not lora:
|
||||
return "empty"
|
||||
|
||||
parts = []
|
||||
for entry in lora:
|
||||
name = str(entry.get("name") or "unnamed")
|
||||
strength = float(entry.get("strength", 0.0) or 0.0)
|
||||
origin = str(entry.get("_iamccs_lora_origin") or "manual")
|
||||
generation_index = entry.get("_iamccs_generation_index")
|
||||
slot = entry.get("_iamccs_schedule_slot")
|
||||
rule = str(entry.get("_iamccs_schedule_rule") or "")
|
||||
|
||||
extras = [origin]
|
||||
if generation_index is not None:
|
||||
extras.append(f"gen={generation_index}")
|
||||
if slot is not None:
|
||||
extras.append(f"slot={int(slot):02d}")
|
||||
if rule:
|
||||
extras.append(rule)
|
||||
|
||||
parts.append(f"{name}({strength}) [{' | '.join(extras)}]")
|
||||
|
||||
return "; ".join(parts)
|
||||
|
||||
|
||||
def _lora_stack_debug_context(lora) -> str:
|
||||
if not lora:
|
||||
return ""
|
||||
|
||||
prompt_ids = sorted({str(entry.get("_iamccs_prompt_id") or "") for entry in lora if entry.get("_iamccs_prompt_id")})
|
||||
schedule_nodes = sorted({str(entry.get("_iamccs_schedule_node_id") or "") for entry in lora if entry.get("_iamccs_schedule_node_id")})
|
||||
schedule_names = sorted({str(entry.get("_iamccs_schedule_log_prefix") or "") for entry in lora if entry.get("_iamccs_schedule_log_prefix")})
|
||||
generation_indexes = sorted({int(entry.get("_iamccs_generation_index")) for entry in lora if entry.get("_iamccs_generation_index") is not None})
|
||||
|
||||
parts = []
|
||||
if prompt_ids:
|
||||
parts.append(f"prompt={','.join(prompt_ids)}")
|
||||
if schedule_nodes:
|
||||
parts.append(f"schedule_node={','.join(schedule_nodes)}")
|
||||
if schedule_names:
|
||||
parts.append(f"schedule={','.join(schedule_names)}")
|
||||
if generation_indexes:
|
||||
parts.append(f"generation={','.join(str(value) for value in generation_indexes)}")
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
def _cache_put(cache: OrderedDict, key, value, max_size: int):
|
||||
cache[key] = value
|
||||
cache.move_to_end(key)
|
||||
while len(cache) > max_size:
|
||||
cache.popitem(last=False)
|
||||
|
||||
|
||||
def _state_dict_for_lora(name: str, model_type: str) -> dict:
|
||||
cache_key = (str(name), str(model_type or "flow"))
|
||||
cached = _STATE_DICT_CACHE.get(cache_key)
|
||||
if cached is not None:
|
||||
_STATE_DICT_CACHE.move_to_end(cache_key)
|
||||
return cached
|
||||
|
||||
path = folder_paths.get_full_path_or_raise("loras", name)
|
||||
sd = comfy.utils.load_torch_file(path, safe_load=True)
|
||||
if model_type != "standard":
|
||||
sd = standardize_wan_lora_keys(sd)
|
||||
_cache_put(_STATE_DICT_CACHE, cache_key, sd, _MAX_STATE_DICT_CACHE)
|
||||
return sd
|
||||
|
||||
|
||||
# --- Log Filter per sopprimere spam img_* e diff_m keys ---
|
||||
class SuppressOptionalKeysFilter(logging.Filter):
|
||||
"""Filtra i log 'lora key not loaded' per chiavi opzionali (img_*, diff_m, ecc.)"""
|
||||
@@ -139,10 +216,7 @@ class IAMCCS_WanLoRAStack:
|
||||
# Skip if "no" selected or strength is 0
|
||||
if not name or name == "no" or strength == 0.0:
|
||||
continue
|
||||
path = folder_paths.get_full_path_or_raise("loras", name)
|
||||
sd = comfy.utils.load_torch_file(path, safe_load=True)
|
||||
if model_type != "standard":
|
||||
sd = standardize_wan_lora_keys(sd)
|
||||
sd = _state_dict_for_lora(name, model_type)
|
||||
loras.append({"name": name, "strength": strength, "state_dict": sd})
|
||||
|
||||
# Concatena la stack LORA opzionale se fornita
|
||||
@@ -182,8 +256,26 @@ class IAMCCS_ModelWithLoRA:
|
||||
if not lora:
|
||||
return (model,)
|
||||
|
||||
stack_context = _lora_stack_debug_context(lora)
|
||||
stack_summary = _lora_stack_debug_summary(lora)
|
||||
signature = tuple((str(entry.get("name") or ""), float(entry.get("strength", 0.0) or 0.0)) for entry in lora)
|
||||
cache_key = (id(model), signature)
|
||||
cached_model = _PATCHED_MODEL_CACHE.get(cache_key)
|
||||
if cached_model is not None:
|
||||
_PATCHED_MODEL_CACHE.move_to_end(cache_key)
|
||||
if stack_context:
|
||||
logging.info(f"[IAMCCS_ModelWithLoRA] ♻ cache hit: {len(signature)} LoRA(s) reused | {stack_context}")
|
||||
else:
|
||||
logging.info(f"[IAMCCS_ModelWithLoRA] ♻ cache hit: {len(signature)} LoRA(s) reused")
|
||||
logging.info(f"[IAMCCS_ModelWithLoRA] active_stack={stack_summary}")
|
||||
return (cached_model,)
|
||||
|
||||
model_out = model
|
||||
|
||||
if stack_context:
|
||||
logging.info(f"[IAMCCS_ModelWithLoRA] apply request | {stack_context}")
|
||||
logging.info(f"[IAMCCS_ModelWithLoRA] active_stack={stack_summary}")
|
||||
|
||||
# Installa filtro per sopprimere spam di chiavi opzionali (img_*, diff_m, ecc.)
|
||||
logger = logging.getLogger()
|
||||
optional_filter = SuppressOptionalKeysFilter()
|
||||
@@ -201,6 +293,8 @@ class IAMCCS_ModelWithLoRA:
|
||||
keys_types = ", ".join(sorted(optional_filter.suppressed_keys))
|
||||
logging.info(f"[IAMCCS_ModelWithLoRA] ℹ {optional_filter.suppressed_count} optional keys not present in LORA ({keys_types})")
|
||||
|
||||
_cache_put(_PATCHED_MODEL_CACHE, cache_key, model_out, _MAX_PATCHED_MODEL_CACHE)
|
||||
|
||||
finally:
|
||||
# Rimuovi filtro
|
||||
logger.removeFilter(optional_filter)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { app } from "../../scripts/app.js";
|
||||
const SEED_MODES = new Set(["fixed", "randomize", "increment", "decrement"]);
|
||||
const NODE_DEFAULTS = {
|
||||
IAMCCS_QwenMultiGen: [0, "randomize", 4, 1, "euler", "simple", 1, "\\n", "index_timestep_zero", "qwen_multi", true],
|
||||
IAMCCS_FluxKleinMultiGen: [0, "randomize", 8, 1, "euler", "\\n", "index_timestep_zero", 1, "lanczos", "", "flux_klein_multi", false],
|
||||
IAMCCS_FluxKleinMultiGen: [0, "randomize", 8, 1, "euler", "\\n", "workflow_default", 1, "nearest-exact", "", "flux_klein_multi", false, 1, 720, 1024, false, "flux_klein_debug"],
|
||||
};
|
||||
|
||||
function isFiniteNumber(value) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,188 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
const LEGACY_LOCAL_KEYS = [
|
||||
"workflow",
|
||||
"Comfy.OpenWorkflowsPaths",
|
||||
"Comfy.ActiveWorkflowIndex",
|
||||
];
|
||||
|
||||
const LEGACY_PREFIXES = [
|
||||
"Comfy.Workflow.Drafts",
|
||||
"Comfy.Workflow.DraftOrder",
|
||||
];
|
||||
|
||||
const V2_PREFIX = {
|
||||
draftIndex: "Comfy.Workflow.DraftIndex.v2:",
|
||||
draftPayload: "Comfy.Workflow.Draft.v2:",
|
||||
lastActivePath: "Comfy.Workflow.LastActivePath:",
|
||||
lastOpenPaths: "Comfy.Workflow.LastOpenPaths:",
|
||||
};
|
||||
|
||||
const MAX_DRAFTS_PER_WORKSPACE = 4;
|
||||
const MAX_DRAFT_CHARS_PER_WORKSPACE = 2_500_000;
|
||||
|
||||
function safeParseJson(json) {
|
||||
if (!json) return null;
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function listStorageKeys(storage) {
|
||||
const keys = [];
|
||||
try {
|
||||
for (let index = 0; index < storage.length; index += 1) {
|
||||
const key = storage.key(index);
|
||||
if (key) keys.push(key);
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function removeKey(storage, key) {
|
||||
try {
|
||||
storage.removeItem(key);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getPayloadKey(workspaceId, draftKey) {
|
||||
return `${V2_PREFIX.draftPayload}${workspaceId}:${draftKey}`;
|
||||
}
|
||||
|
||||
function cleanupLegacyWorkflowStorage(localStorageRef) {
|
||||
let removed = 0;
|
||||
for (const key of LEGACY_LOCAL_KEYS) {
|
||||
if (removeKey(localStorageRef, key)) removed += 1;
|
||||
}
|
||||
|
||||
for (const key of listStorageKeys(localStorageRef)) {
|
||||
if (LEGACY_PREFIXES.some((prefix) => key.startsWith(prefix))) {
|
||||
if (removeKey(localStorageRef, key)) removed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
function cleanupWorkspaceDrafts(localStorageRef, workspaceId) {
|
||||
const indexKey = `${V2_PREFIX.draftIndex}${workspaceId}`;
|
||||
const rawIndex = localStorageRef.getItem(indexKey);
|
||||
if (!rawIndex) return { removed: 0, rewritten: false };
|
||||
|
||||
const index = safeParseJson(rawIndex);
|
||||
if (!index || typeof index !== "object" || !Array.isArray(index.order) || typeof index.entries !== "object" || index.entries == null) {
|
||||
let removed = removeKey(localStorageRef, indexKey) ? 1 : 0;
|
||||
const payloadPrefix = `${V2_PREFIX.draftPayload}${workspaceId}:`;
|
||||
for (const key of listStorageKeys(localStorageRef)) {
|
||||
if (key.startsWith(payloadPrefix) && removeKey(localStorageRef, key)) {
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
return { removed, rewritten: false };
|
||||
}
|
||||
|
||||
const payloadPrefix = `${V2_PREFIX.draftPayload}${workspaceId}:`;
|
||||
const seenPayloads = new Set();
|
||||
const keptEntries = {};
|
||||
const keptOrder = [];
|
||||
let keptChars = 0;
|
||||
let keptCount = 0;
|
||||
let removed = 0;
|
||||
|
||||
const newestFirst = [...index.order].reverse();
|
||||
for (const draftKey of newestFirst) {
|
||||
const entry = index.entries[draftKey];
|
||||
if (!entry || typeof entry.path !== "string") continue;
|
||||
|
||||
const payloadKey = getPayloadKey(workspaceId, draftKey);
|
||||
const payloadJson = localStorageRef.getItem(payloadKey);
|
||||
if (!payloadJson) continue;
|
||||
|
||||
const payloadChars = payloadJson.length;
|
||||
const canKeep = keptCount < MAX_DRAFTS_PER_WORKSPACE && (
|
||||
keptCount === 0 || keptChars + payloadChars <= MAX_DRAFT_CHARS_PER_WORKSPACE
|
||||
);
|
||||
|
||||
if (canKeep) {
|
||||
keptEntries[draftKey] = entry;
|
||||
keptOrder.unshift(draftKey);
|
||||
keptChars += payloadChars;
|
||||
keptCount += 1;
|
||||
seenPayloads.add(payloadKey);
|
||||
} else if (removeKey(localStorageRef, payloadKey)) {
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of listStorageKeys(localStorageRef)) {
|
||||
if (key.startsWith(payloadPrefix) && !seenPayloads.has(key)) {
|
||||
if (removeKey(localStorageRef, key)) removed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const nextIndex = {
|
||||
...index,
|
||||
updatedAt: Date.now(),
|
||||
order: keptOrder,
|
||||
entries: keptEntries,
|
||||
};
|
||||
|
||||
const nextIndexJson = JSON.stringify(nextIndex);
|
||||
const rewritten = nextIndexJson !== rawIndex;
|
||||
if (rewritten) {
|
||||
try {
|
||||
localStorageRef.setItem(indexKey, nextIndexJson);
|
||||
} catch {
|
||||
// If index rewrite fails, leaving the trimmed payload set is still better
|
||||
// than keeping localStorage saturated with stale drafts.
|
||||
}
|
||||
}
|
||||
|
||||
return { removed, rewritten };
|
||||
}
|
||||
|
||||
function cleanupWorkflowPersistenceStorage() {
|
||||
try {
|
||||
const localStorageRef = window.localStorage;
|
||||
if (!localStorageRef) return;
|
||||
|
||||
let removed = cleanupLegacyWorkflowStorage(localStorageRef);
|
||||
let rewritten = 0;
|
||||
|
||||
const workspaceIds = new Set();
|
||||
for (const key of listStorageKeys(localStorageRef)) {
|
||||
if (key.startsWith(V2_PREFIX.draftIndex)) {
|
||||
workspaceIds.add(key.slice(V2_PREFIX.draftIndex.length));
|
||||
}
|
||||
}
|
||||
|
||||
for (const workspaceId of workspaceIds) {
|
||||
const result = cleanupWorkspaceDrafts(localStorageRef, workspaceId);
|
||||
removed += result.removed;
|
||||
if (result.rewritten) rewritten += 1;
|
||||
}
|
||||
|
||||
if (removed || rewritten) {
|
||||
console.info("[IAMCCS] Workflow draft storage cleanup applied", {
|
||||
removedKeys: removed,
|
||||
rewrittenIndexes: rewritten,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[IAMCCS] Workflow draft storage cleanup skipped", error);
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "iamccs.workflow_persist_cleanup",
|
||||
async setup() {
|
||||
cleanupWorkflowPersistenceStorage();
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user