Files
2025-12-09 17:27:10 -05:00

1096 lines
41 KiB
Python

import os
import sys
import json
import torch
import numpy as np
from PIL import Image
from typing import List, Union, Dict, Any, Optional
import math
from transformers import AutoTokenizer, AutoModelForCausalLM, AutoProcessor, AutoConfig, CLIPImageProcessor, CLIPVisionModelWithProjection
from diffusers.models import AutoencoderKL
from diffusers.schedulers import FlowMatchEulerDiscreteScheduler
from safetensors.torch import load_file as load_safetensors
import folder_paths
import comfy.model_management
import comfy.utils
import comfy.sd
import comfy.model_patcher
# Add current directory to sys.path to ensure imports work
current_dir = os.path.dirname(os.path.abspath(__file__))
if current_dir not in sys.path:
sys.path.insert(0, current_dir)
from longcat_image.pipelines.pipeline_longcat_image_edit import LongCatImageEditPipeline
from longcat_image.pipelines.pipeline_longcat_image import (
LongCatImagePipeline,
SYSTEM_PROMPT_EN,
SYSTEM_PROMPT_ZH,
get_prompt_language,
)
from longcat_image.models.longcat_image_dit import LongCatImageTransformer2DModel
from longcat_image.dataset.data_utils import MULTI_ASPECT_RATIO_1024, MULTI_ASPECT_RATIO_512, MULTI_ASPECT_RATIO_256
from longcat_image.utils.model_utils import calculate_shift, retrieve_timesteps, prepare_pos_ids, split_quotation
def tensor2pil(image: torch.Tensor) -> List[Image.Image]:
# image: [B, H, W, C]
batch_count = image.size(0)
pil_images = []
for i in range(batch_count):
img = image[i].cpu().numpy()
img = (img * 255.0).clip(0, 255).astype(np.uint8)
pil_images.append(Image.fromarray(img))
return pil_images
def pil2tensor(images: List[Image.Image]) -> torch.Tensor:
tensors = []
for img in images:
# Convert to numpy, normalize to 0-1
np_img = np.array(img).astype(np.float32) / 255.0
tensors.append(torch.from_numpy(np_img))
# Stack to [B, H, W, C]
if len(tensors) > 0:
return torch.stack(tensors, dim=0)
else:
return torch.empty(0)
DTYPE_MAP = {
"bf16": torch.bfloat16,
"fp16": torch.float16,
"float16": torch.float16,
"fp32": torch.float32,
"float32": torch.float32,
}
RESOURCE_DIR = os.path.join(current_dir, "longcat_image", "resources")
BUNDLED_TOKENIZER_DIR = os.path.join(RESOURCE_DIR, "tokenizer")
BUNDLED_TEXT_ENCODER_DIR = os.path.join(RESOURCE_DIR, "text_encoder")
BUNDLED_TEXT_ENCODER_CONFIG = os.path.join(RESOURCE_DIR, "text_encoder", "config.json")
BUNDLED_VAE_CONFIG = os.path.join(RESOURCE_DIR, "vae", "config.json")
def _resolve_device(device_str: str):
if not device_str or device_str == "auto":
return comfy.model_management.get_torch_device()
lowered = device_str.lower()
if lowered.startswith("cuda"):
if not torch.cuda.is_available():
print("Requested CUDA device but CUDA is not available; falling back to default device.")
return comfy.model_management.get_torch_device()
return torch.device(device_str)
if lowered == "mps":
if not torch.backends.mps.is_available():
print("Requested MPS device but MPS is not available; falling back to default device.")
return comfy.model_management.get_torch_device()
return torch.device("mps")
if lowered == "cpu":
return torch.device("cpu")
# Fallback for any other torch-recognized device string
return torch.device(device_str)
def list_available_devices():
devices = ["auto"]
if torch.cuda.is_available():
devices.append("cuda")
for idx in range(torch.cuda.device_count()):
devices.append(f"cuda:{idx}")
if torch.backends.mps.is_available():
devices.append("mps")
devices.append("cpu")
# Deduplicate while preserving order
deduped = []
for dev in devices:
if dev not in deduped:
deduped.append(dev)
return deduped
def _load_config_from_metadata(safetensors_path: str) -> Optional[Dict[str, Any]]:
try:
from safetensors import safe_open
with safe_open(safetensors_path, framework="pt", device="cpu") as f:
meta = f.metadata() or {}
for key in ["config", "model_config", "config_json"]:
if key in meta:
try:
return json.loads(meta[key])
except Exception:
continue
except Exception:
pass
return None
def _load_bundled_config(rel_path: str) -> Optional[Dict[str, Any]]:
path = os.path.join(RESOURCE_DIR, rel_path)
if os.path.exists(path):
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return None
return None
class LongCatCLIPWrapper:
def __init__(
self,
text_encoder,
tokenizer,
text_processor,
image_encoder=None,
feature_extractor=None,
):
self.text_encoder = text_encoder
self.tokenizer = tokenizer
self.text_processor = text_processor
self.image_encoder = image_encoder
self.feature_extractor = feature_extractor
self.image_processor_vl = getattr(text_processor, "image_processor", None)
self.prompt_template_encode_prefix = '<|im_start|>system\nAs an image captioning expert, generate a descriptive text prompt based on an image content, suitable for input to a text-to-image model.<|im_end|>\n<|im_start|>user\n'
self.prompt_template_encode_suffix = '<|im_end|>\n<|im_start|>assistant\n'
self.prompt_template_encode_start_idx = 36
self.prompt_template_encode_end_idx = 5
self.default_sample_size = 128
self.max_tokenizer_len = 512
self.image_token = "<|image_pad|>"
self.edit_prompt_template_encode_prefix = "<|im_start|>system\nAs an image editing expert, first analyze the content and attributes of the input image(s). Then, based on the user's editing instructions, clearly and precisely determine how to modify the given image(s), ensuring that only the specified parts are altered and all other aspects remain consistent with the original(s).<|im_end|>\n<|im_start|>user\n<|vision_start|><|image_pad|><|vision_end|>"
self.edit_prompt_template_encode_suffix = '<|im_end|>\n<|im_start|>assistant\n'
self.edit_prompt_template_encode_start_idx = 67
self.edit_prompt_template_encode_end_idx = 5
@property
def device(self):
return next(self.text_encoder.parameters()).device
@property
def dtype(self):
return next(self.text_encoder.parameters()).dtype
def rewrite_prompt(self, prompt: str) -> str:
language = get_prompt_language(prompt)
if language == 'zh':
question = SYSTEM_PROMPT_ZH + f"\n用户输入为:{prompt}\n改写后的prompt为:"
else:
question = SYSTEM_PROMPT_EN + f"\nUser Input: {prompt}\nRewritten prompt:"
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": question},
],
}
]
text = self.text_processor.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
inputs = self.text_processor(text=[text], padding=True, return_tensors="pt")
inputs = inputs.to(self.device)
generated_ids = self.text_encoder.generate(
**inputs, max_new_tokens=self.max_tokenizer_len
)
generated_ids_trimmed = [
out_ids[len(in_ids):] for in_ids, out_ids in zip(inputs.input_ids, generated_ids)
]
output_text = self.text_processor.batch_decode(
generated_ids_trimmed,
skip_special_tokens=True,
clean_up_tokenization_spaces=False,
)[0]
return output_text
def _encode_tokens(self, prompt: str, prefix: str, suffix: str):
prompt = prompt.strip('"') if prompt.startswith('"') and prompt.endswith('"') else prompt
all_tokens = []
for clean_prompt_sub, matched in split_quotation(prompt):
if matched:
for sub_word in clean_prompt_sub:
tokens = self.tokenizer(sub_word, add_special_tokens=False)['input_ids']
all_tokens.extend(tokens)
else:
tokens = self.tokenizer(clean_prompt_sub, add_special_tokens=False)['input_ids']
all_tokens.extend(tokens)
all_tokens = all_tokens[: self.max_tokenizer_len]
text_tokens_and_mask = self.tokenizer.pad(
{'input_ids': [all_tokens]},
max_length=self.max_tokenizer_len,
padding='max_length',
return_attention_mask=True,
return_tensors='pt',
)
prefix_tokens = self.tokenizer(prefix, add_special_tokens=False)['input_ids']
suffix_tokens = self.tokenizer(suffix, add_special_tokens=False)['input_ids']
prefix_tokens_mask = torch.tensor(
[1] * len(prefix_tokens), dtype=text_tokens_and_mask.attention_mask[0].dtype
)
suffix_tokens_mask = torch.tensor(
[1] * len(suffix_tokens), dtype=text_tokens_and_mask.attention_mask[0].dtype
)
prefix_tokens = torch.tensor(prefix_tokens, dtype=text_tokens_and_mask.input_ids.dtype)
suffix_tokens = torch.tensor(suffix_tokens, dtype=text_tokens_and_mask.input_ids.dtype)
input_ids = torch.cat((prefix_tokens, text_tokens_and_mask.input_ids[0], suffix_tokens), dim=-1)
attention_mask = torch.cat(
(prefix_tokens_mask, text_tokens_and_mask.attention_mask[0], suffix_tokens_mask), dim=-1
)
return input_ids.unsqueeze(0), attention_mask.unsqueeze(0)
@torch.inference_mode()
def encode_text(self, prompts: Union[str, List[str]], rewrite: bool = False):
if isinstance(prompts, str):
prompts = [prompts]
prompt = prompts[0]
if rewrite:
prompt = self.rewrite_prompt(prompt)
input_ids, attention_mask = self._encode_tokens(
prompt,
self.prompt_template_encode_prefix,
self.prompt_template_encode_suffix,
)
input_ids = input_ids.to(self.device)
attention_mask = attention_mask.to(self.device)
text_output = self.text_encoder(
input_ids=input_ids,
attention_mask=attention_mask,
output_hidden_states=True,
)
prompt_embeds = text_output.hidden_states[-1].detach()
prompt_embeds = prompt_embeds[
:, self.prompt_template_encode_start_idx: -self.prompt_template_encode_end_idx, :
]
text_ids = prepare_pos_ids(
modality_id=0,
type='text',
start=(0, 0),
num_token=prompt_embeds.shape[1],
).to(self.device, dtype=torch.float64)
return prompt_embeds, text_ids
@torch.inference_mode()
def encode_edit(self, images: List[Image.Image], prompts: Union[str, List[str]]):
if self.image_processor_vl is None:
raise ValueError("Loaded LongCat CLIP does not contain a vision-language processor; please use the edit checkpoint.")
if isinstance(prompts, str):
prompts = [prompts]
prompt = prompts[0]
raw_vl_input = self.image_processor_vl(images=images, return_tensors="pt")
pixel_values = raw_vl_input['pixel_values']
image_grid_thw = raw_vl_input['image_grid_thw']
prompt = prompt.strip('"') if prompt.startswith('"') and prompt.endswith('"') else prompt
text = self.edit_prompt_template_encode_prefix
num_images = len(images)
if num_images > 1:
single_image_placeholder = "<|vision_start|><|image_pad|><|vision_end|>"
if single_image_placeholder in text:
multi_image_placeholder = single_image_placeholder * num_images
text = text.replace(single_image_placeholder, multi_image_placeholder)
merge_length = self.image_processor_vl.merge_size ** 2
image_idx = 0
while self.image_token in text:
if image_idx < len(image_grid_thw):
grid = image_grid_thw[image_idx]
num_image_tokens = grid.prod() // merge_length
text = text.replace(self.image_token, "<|placeholder|>" * int(num_image_tokens), 1)
image_idx += 1
else:
break
text = text.replace("<|placeholder|>", self.image_token)
input_ids, attention_mask = self._encode_tokens(
prompt,
text,
self.edit_prompt_template_encode_suffix,
)
pixel_values = pixel_values.to(self.device)
image_grid_thw = image_grid_thw.to(self.device)
input_ids = input_ids.to(self.device)
attention_mask = attention_mask.to(self.device)
text_output = self.text_encoder(
input_ids=input_ids,
attention_mask=attention_mask,
pixel_values=pixel_values,
image_grid_thw=image_grid_thw,
output_hidden_states=True,
)
prompt_embeds = text_output.hidden_states[-1].detach()
prompt_embeds = prompt_embeds[
:, self.edit_prompt_template_encode_start_idx: -self.edit_prompt_template_encode_end_idx, :
]
text_ids = prepare_pos_ids(
modality_id=0,
type='text',
start=(0, 0),
num_token=prompt_embeds.shape[1],
).to(self.device, dtype=torch.float64)
return prompt_embeds, text_ids
class LongCatVAEWrapper:
def __init__(self, vae: AutoencoderKL):
self.vae = vae
self.scaling_factor = getattr(vae.config, "scaling_factor", 1.0)
self.shift_factor = getattr(vae.config, "shift_factor", 0.0)
self.latent_channels = getattr(vae.config, "latent_channels", 16)
self.vae_scale_factor = 2 ** (len(getattr(vae.config, "block_out_channels", [1])) - 1)
def to(self, device, dtype=None):
target_dtype = dtype if dtype is not None else None
if target_dtype is None:
self.vae = self.vae.to(device)
else:
self.vae = self.vae.to(device=device, dtype=target_dtype)
return self
def encode(self, image_tensor: torch.Tensor) -> torch.Tensor:
# Expect image_tensor shape [B, H, W, C] in range 0..1
pixel_values = image_tensor.permute(0, 3, 1, 2)
posterior = self.vae.encode(pixel_values).latent_dist
latents = posterior.mode()
latents = (latents - self.shift_factor) * self.scaling_factor
return latents
def decode(self, latents: torch.Tensor) -> torch.Tensor:
latents = (latents / self.scaling_factor) + self.shift_factor
decoded = self.vae.decode(latents, return_dict=False)[0]
decoded = decoded.permute(0, 2, 3, 1)
return decoded
def get_dtype(self):
return self.vae.dtype
class LongCatCLIPLoader:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model_name": (folder_paths.get_filename_list("text_encoders"),),
"dtype": (["bf16", "fp16", "fp32"], {"default": "bf16"}),
"device": (list_available_devices(),),
}
}
RETURN_TYPES = ("CLIP",)
RETURN_NAMES = ("clip",)
FUNCTION = "load_clip"
CATEGORY = "LongCat"
def load_clip(self, model_name: str, dtype: str, device: str):
model_path = folder_paths.get_full_path("text_encoders", model_name)
base_dir = model_path if os.path.isdir(model_path) else os.path.dirname(model_path)
torch_dtype = DTYPE_MAP.get(dtype, torch.bfloat16)
target_device = _resolve_device(device)
tokenizer_dir = BUNDLED_TOKENIZER_DIR
tokenizer = AutoTokenizer.from_pretrained(
tokenizer_dir, trust_remote_code=True
)
text_processor = AutoProcessor.from_pretrained(
tokenizer_dir, trust_remote_code=True
)
image_encoder = None
feature_extractor = None
if os.path.isdir(model_path):
text_encoder = AutoModelForCausalLM.from_pretrained(
model_path, subfolder="text_encoder", torch_dtype=torch_dtype, trust_remote_code=True
).to(target_device)
# Optional vision encoder if present
vision_dir = os.path.join(model_path, "image_encoder")
if os.path.isdir(vision_dir):
try:
image_encoder = CLIPVisionModelWithProjection.from_pretrained(
model_path, subfolder="image_encoder", torch_dtype=torch_dtype, trust_remote_code=True
).to(target_device)
feature_extractor = CLIPImageProcessor.from_pretrained(
model_path, subfolder="feature_extractor", trust_remote_code=True
)
except Exception:
image_encoder = None
feature_extractor = None
else:
# Single-file weights; use bundled config/tokenizer
config_dict = _load_config_from_metadata(model_path)
if config_dict is not None:
text_config = AutoConfig.from_dict(config_dict)
else:
text_config = AutoConfig.from_pretrained(BUNDLED_TEXT_ENCODER_DIR, trust_remote_code=True)
text_encoder = AutoModelForCausalLM.from_config(text_config, trust_remote_code=True).to(
target_device, dtype=torch_dtype
)
state_dict = load_safetensors(model_path, device="cpu")
missing, unexpected = text_encoder.load_state_dict(state_dict, strict=False)
if missing or unexpected:
print(f"Text encoder load: missing={len(missing)}, unexpected={len(unexpected)}")
clip = LongCatCLIPWrapper(
text_encoder=text_encoder,
tokenizer=tokenizer,
text_processor=text_processor,
image_encoder=image_encoder,
feature_extractor=feature_extractor,
)
return (clip,)
class LongCatVAELoader:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model_name": (folder_paths.get_filename_list("vae"),),
"dtype": (["bf16", "fp16", "fp32"], {"default": "bf16"}),
"device": (list_available_devices(),),
}
}
RETURN_TYPES = ("VAE",)
RETURN_NAMES = ("vae",)
FUNCTION = "load_vae"
CATEGORY = "LongCat"
def load_vae(self, model_name: str, dtype: str, device: str):
model_path = folder_paths.get_full_path("vae", model_name)
base_dir = model_path if os.path.isdir(model_path) else os.path.dirname(model_path)
torch_dtype = DTYPE_MAP.get(dtype, torch.bfloat16)
target_device = _resolve_device(device)
if os.path.isdir(model_path):
vae = AutoencoderKL.from_pretrained(
model_path, subfolder="vae", torch_dtype=torch_dtype
).to(target_device)
else:
# Single-file safetensors
config_dict = _load_config_from_metadata(model_path)
if config_dict is None:
config_dict = _load_bundled_config(os.path.join("vae", "config.json"))
if config_dict is None:
raise ValueError("Bundled VAE config not found.")
vae = AutoencoderKL.from_config(config_dict).to(target_device, dtype=torch_dtype)
state_dict = load_safetensors(model_path, device="cpu")
missing, unexpected = vae.load_state_dict(state_dict, strict=False)
if missing or unexpected:
print(f"VAE load: missing={len(missing)}, unexpected={len(unexpected)}")
wrapper = LongCatVAEWrapper(vae)
return (wrapper,)
class TextEncodeLongCatImage:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"clip": ("CLIP",),
"prompt": ("STRING", {"multiline": True, "default": ""}),
"rewrite_prompt": ("BOOLEAN", {"default": True}),
}
}
RETURN_TYPES = ("CONDITIONING",)
RETURN_NAMES = ("conditioning",)
FUNCTION = "encode"
CATEGORY = "LongCat"
def encode(self, clip: LongCatCLIPWrapper, prompt: str, rewrite_prompt: bool = True):
prompt_embeds, text_ids = clip.encode_text(prompt, rewrite=rewrite_prompt)
conditioning = {
"prompt_embeds": prompt_embeds,
"text_ids": text_ids,
}
return ([[prompt_embeds, conditioning]],)
class TextEncodeLongCatImageEdit:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"clip": ("CLIP",),
"prompt": ("STRING", {"multiline": True, "default": ""}),
},
"optional": {
"vae": ("VAE",),
"image1": ("IMAGE",),
"image2": ("IMAGE",),
"image3": ("IMAGE",),
}
}
RETURN_TYPES = ("CONDITIONING",)
RETURN_NAMES = ("conditioning",)
FUNCTION = "encode"
CATEGORY = "LongCat"
def encode(self, clip: LongCatCLIPWrapper, prompt, vae=None, image1=None, image2=None, image3=None):
images = []
if image1 is not None:
images.append(image1)
if image2 is not None:
images.append(image2)
if image3 is not None:
images.append(image3)
if images and vae is None:
raise ValueError("VAE is required when images are provided.")
if images:
pil_images: List[Image.Image] = []
for img in images:
pil_images.extend(tensor2pil(img))
prompt_embeds, text_ids = clip.encode_edit(pil_images, prompt)
else:
prompt_embeds, text_ids = clip.encode_text(prompt, rewrite=False)
conditioning = {
"prompt_embeds": prompt_embeds,
"text_ids": text_ids,
"images": images if len(images) > 0 else None,
"vae": vae,
}
return ([[prompt_embeds, conditioning]],)
class LoadLongCatModel:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model_path": ("STRING", {"default": ""}),
"dtype": (["bf16", "fp16", "fp32"], {"default": "bf16"}),
"device": (list_available_devices(),),
"gpu_only": ("BOOLEAN", {"default": False}),
}
}
RETURN_TYPES = ("MODEL",)
RETURN_NAMES = ("model",)
FUNCTION = "load_model"
CATEGORY = "LongCat"
def load_model(self, model_path: str, dtype: str, device: str, gpu_only: bool):
if not model_path:
raise ValueError("Please provide a LongCat checkpoint directory or transformer weight file.")
torch_dtype = DTYPE_MAP.get(dtype, torch.bfloat16)
target_device = _resolve_device(device)
scheduler = None
if os.path.isdir(model_path):
transformer = LongCatImageTransformer2DModel.from_pretrained(
model_path,
subfolder="transformer",
torch_dtype=torch_dtype,
use_safetensors=True,
).to(target_device)
try:
scheduler = FlowMatchEulerDiscreteScheduler.from_pretrained(
model_path, subfolder="scheduler"
)
except Exception:
scheduler = FlowMatchEulerDiscreteScheduler(
num_train_timesteps=1000,
shift=3.0,
use_dynamic_shifting=True,
base_shift=0.5,
max_shift=1.15,
base_image_seq_len=256,
max_image_seq_len=4096,
)
else:
config = {
"patch_size": 1,
"in_channels": 64,
"num_layers": 10,
"num_single_layers": 20,
"attention_head_dim": 128,
"num_attention_heads": 24,
"joint_attention_dim": 3584,
"pooled_projection_dim": 3584,
"axes_dims_rope": [16, 56, 56],
}
transformer = LongCatImageTransformer2DModel(**config).to(target_device)
sd = comfy.utils.load_torch_file(model_path)
missing, unexpected = transformer.load_state_dict(sd, strict=False)
if len(missing) > 0 or len(unexpected) > 0:
print(
f"LongCat Model Load: Missing {len(missing)} keys, Unexpected {len(unexpected)} keys"
)
scheduler = FlowMatchEulerDiscreteScheduler(
num_train_timesteps=1000,
shift=3.0,
use_dynamic_shifting=True,
base_shift=0.5,
max_shift=1.15,
base_image_seq_len=256,
max_image_seq_len=4096,
)
class LongCatInnerModel:
def __init__(self, diffusion_model, scheduler):
self.diffusion_model = diffusion_model
self.latent_format = None
self.manual_cast_dtype = None
self.longcat_scheduler = scheduler
def get_dtype(self):
return self.diffusion_model.dtype
def is_adm(self):
return False
def to(self, device):
self.diffusion_model.to(device)
return self
def state_dict(self, *args, **kwargs):
return self.diffusion_model.state_dict(*args, **kwargs)
def load_state_dict(self, *args, **kwargs):
return self.diffusion_model.load_state_dict(*args, **kwargs)
def parameters(self, *args, **kwargs):
return self.diffusion_model.parameters(*args, **kwargs)
def modules(self, *args, **kwargs):
return self.diffusion_model.modules(*args, **kwargs)
def named_modules(self, *args, **kwargs):
return self.diffusion_model.named_modules(*args, **kwargs)
inner_model = LongCatInnerModel(transformer, scheduler)
load_device = target_device
if gpu_only:
offload_device = load_device
else:
offload_device = comfy.model_management.unet_offload_device()
patcher = comfy.model_patcher.ModelPatcher(
inner_model, load_device=load_device, offload_device=offload_device
)
patcher.longcat_scheduler = scheduler
return (patcher,)
class LongCatSizePicker:
@classmethod
def INPUT_TYPES(s):
# Flatten the dictionaries to a list of strings "resolution (aspect_ratio)"
# or just use the keys/values.
# The user said "size: a list of avaliable size(from data_utils.py)"
# Let's combine all sizes
sizes = []
for name, mapping in [("1024", MULTI_ASPECT_RATIO_1024), ("512", MULTI_ASPECT_RATIO_512), ("256", MULTI_ASPECT_RATIO_256)]:
for ratio, (h, w) in mapping.items():
sizes.append(f"{name} - {ratio} ({int(h)}x{int(w)})")
return {
"required": {
"size": (sizes,),
}
}
RETURN_TYPES = ("LATENT", "INT", "INT")
RETURN_NAMES = ("latent", "width", "height")
FUNCTION = "pick_size"
CATEGORY = "LongCat"
def pick_size(self, size):
# Format: "1024 - 1.0 (1024x1024)"
try:
part = size.split("(")[1].split(")")[0]
h_str, w_str = part.split("x")
height = int(h_str)
width = int(w_str)
except:
height = 1024
width = 1024
# Create empty latent
# Standard ComfyUI latent is [1, 4, H//8, W//8]
latent = torch.zeros([1, 4, height // 8, width // 8])
return ({"samples": latent}, width, height)
class LongCatImageResizer:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
"method": (["keep proportion", "stretch", "fill/crop", "pad"],),
"interpolation": (["nearest", "bilinear", "bicubic", "lanczos"],),
}
}
RETURN_TYPES = ("IMAGE", "INT", "INT")
RETURN_NAMES = ("image", "width", "height")
FUNCTION = "resize"
CATEGORY = "LongCat"
def resize(self, image, method, interpolation):
# image is [B, H, W, C]
pil_images = tensor2pil(image)
out_images = []
# Determine interpolation
interp_map = {
"nearest": Image.NEAREST,
"bilinear": Image.BILINEAR,
"bicubic": Image.BICUBIC,
"lanczos": Image.LANCZOS
}
interp = interp_map.get(interpolation, Image.BICUBIC)
final_w, final_h = 0, 0
for img in pil_images:
w, h = img.size
# Find nearest supported resolution
all_sizes = []
for mapping in [MULTI_ASPECT_RATIO_1024, MULTI_ASPECT_RATIO_512, MULTI_ASPECT_RATIO_256]:
for _, (th, tw) in mapping.items():
all_sizes.append((int(tw), int(th)))
# Find nearest size
best_size = None
min_dist = float('inf')
for tw, th in all_sizes:
dist = (tw - w)**2 + (th - h)**2
if dist < min_dist:
min_dist = dist
best_size = (tw, th)
target_w, target_h = best_size
final_w, final_h = target_w, target_h
if method == "stretch":
resized = img.resize((target_w, target_h), interp)
out_images.append(resized)
elif method == "keep proportion":
# Find size with closest aspect ratio
best_ar_size = None
min_ar_diff = float('inf')
current_ar = w / h
for tw, th in all_sizes:
ar = tw / th
diff = abs(ar - current_ar)
if diff < min_ar_diff:
min_ar_diff = diff
best_ar_size = (tw, th)
target_w, target_h = best_ar_size
final_w, final_h = target_w, target_h
resized = img.resize((target_w, target_h), interp)
out_images.append(resized)
elif method == "pad":
ratio = min(target_w/w, target_h/h)
new_w = int(w * ratio)
new_h = int(h * ratio)
resized = img.resize((new_w, new_h), interp)
new_img = Image.new("RGB", (target_w, target_h), (0, 0, 0))
new_img.paste(resized, ((target_w - new_w)//2, (target_h - new_h)//2))
out_images.append(new_img)
elif method == "fill/crop":
ratio = max(target_w/w, target_h/h)
new_w = int(w * ratio)
new_h = int(h * ratio)
resized = img.resize((new_w, new_h), interp)
left = (new_w - target_w)//2
top = (new_h - target_h)//2
cropped = resized.crop((left, top, left + target_w, top + target_h))
out_images.append(cropped)
return (pil2tensor(out_images), final_w, final_h)
class LongCatSampler:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model": ("MODEL",),
"positive": ("CONDITIONING",),
"negative": ("CONDITIONING",),
"latent_image": ("LATENT",),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
"steps": ("INT", {"default": 50, "min": 1, "max": 1000}),
"cfg": ("FLOAT", {"default": 4.5, "min": 0.0, "max": 20.0, "step": 0.1}),
"sampler_name": (comfy.samplers.KSampler.SAMPLERS, ),
"scheduler": (comfy.samplers.KSampler.SCHEDULERS, ),
"cfg_norm": ("BOOLEAN", {"default": True}),
"cfg_renorm_min": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"control_after_generate": (["fixed", "increment", "decrement", "random"], {"default": "fixed"}),
}
}
RETURN_TYPES = ("LATENT",)
RETURN_NAMES = ("latent",)
FUNCTION = "sample"
CATEGORY = "LongCat"
def sample(self, model, positive, negative, latent_image, seed, steps, cfg, sampler_name, scheduler, cfg_norm, cfg_renorm_min, control_after_generate):
comfy.model_management.load_model_gpu(model)
transformer = model.model.diffusion_model
device = comfy.model_management.get_torch_device()
sched = getattr(model, "longcat_scheduler", None)
if sched is None and hasattr(model, "model"):
sched = getattr(model.model, "longcat_scheduler", None)
if sched is None:
sched = FlowMatchEulerDiscreteScheduler(
num_train_timesteps=1000,
shift=3.0,
use_dynamic_shifting=True,
base_shift=0.5,
max_shift=1.15,
base_image_seq_len=256,
max_image_seq_len=4096,
)
samples = latent_image["samples"]
batch_size, _, h, w = samples.shape
height = h * 8
width = w * 8
generator = torch.Generator(device=device).manual_seed(seed)
def _pack_latents(latents, batch, num_channels_latents, h_px, w_px):
latents = latents.view(batch, num_channels_latents, h_px // 2, 2, w_px // 2, 2)
latents = latents.permute(0, 2, 4, 1, 3, 5)
latents = latents.reshape(batch, (h_px // 2) * (w_px // 2), num_channels_latents * 4)
return latents
def _unpack_latents(latents, h_px, w_px, vae_scale_factor):
batch, num_patches, channels = latents.shape
h_px = 2 * (int(h_px) // (vae_scale_factor * 2))
w_px = 2 * (int(w_px) // (vae_scale_factor * 2))
latents = latents.view(batch, h_px // 2, w_px // 2, channels // 4, 2, 2)
latents = latents.permute(0, 3, 1, 4, 2, 5)
latents = latents.reshape(batch, channels // (2 * 2), h_px, w_px)
return latents
# Unpack conditioning
pos_cond = positive[0][0]
pos_dict = positive[0][1]
neg_cond = negative[0][0]
neg_dict = negative[0][1]
prompt_embeds = pos_cond.to(device, dtype=transformer.dtype)
neg_prompt_embeds = neg_cond.to(device, dtype=transformer.dtype)
text_ids = pos_dict["text_ids"].to(device, dtype=torch.float64)
neg_text_ids = neg_dict.get("text_ids", text_ids).to(device, dtype=torch.float64)
text_len = prompt_embeds.shape[1]
neg_text_len = neg_prompt_embeds.shape[1]
target_len = max(text_len, neg_text_len)
if prompt_embeds.shape[1] != target_len:
pad = torch.zeros(
prompt_embeds.shape[0],
target_len - prompt_embeds.shape[1],
prompt_embeds.shape[2],
device=device,
dtype=prompt_embeds.dtype,
)
prompt_embeds = torch.cat([prompt_embeds, pad], dim=1)
if neg_prompt_embeds.shape[1] != target_len:
pad = torch.zeros(
neg_prompt_embeds.shape[0],
target_len - neg_prompt_embeds.shape[1],
neg_prompt_embeds.shape[2],
device=device,
dtype=neg_prompt_embeds.dtype,
)
neg_prompt_embeds = torch.cat([neg_prompt_embeds, pad], dim=1)
images = pos_dict.get("images") or neg_dict.get("images")
vae = pos_dict.get("vae") or neg_dict.get("vae")
if images is not None and not isinstance(vae, LongCatVAEWrapper):
raise ValueError("LongCat VAE Loader output is required when conditioning on images.")
num_channels_latents = 16
vae_scale_factor = 8
if isinstance(vae, LongCatVAEWrapper):
num_channels_latents = vae.latent_channels
vae_scale_factor = vae.vae_scale_factor
latent_height = 2 * (int(height) // (vae_scale_factor * 2))
latent_width = 2 * (int(width) // (vae_scale_factor * 2))
latents = torch.randn(
(batch_size, num_channels_latents, latent_height, latent_width),
generator=generator,
device=device,
dtype=transformer.dtype,
)
latents = _pack_latents(latents, batch_size, num_channels_latents, latent_height, latent_width)
start_pos = max(target_len, 512)
latent_image_ids = prepare_pos_ids(
modality_id=1,
type='image',
start=(start_pos, start_pos),
height=latent_height // 2,
width=latent_width // 2,
).to(device, dtype=torch.float64)
image_latents = None
image_latents_ids = None
if images is not None:
stacked_images = torch.cat(images, dim=0) if len(images) > 1 else images[0]
stacked_images = stacked_images.to(device=device, dtype=transformer.dtype)
stacked_images_chw = stacked_images.permute(0, 3, 1, 2)
stacked_images_chw = torch.nn.functional.interpolate(
stacked_images_chw, size=(height, width), mode="bicubic", align_corners=False
)
stacked_images = stacked_images_chw.permute(0, 2, 3, 1)
encoded = vae.encode(stacked_images)
encoded = encoded.to(device=device, dtype=transformer.dtype)
image_latents = _pack_latents(
encoded, encoded.shape[0], num_channels_latents, latent_height, latent_width
)
image_latents_ids = prepare_pos_ids(
modality_id=2,
type="image",
start=(target_len, target_len),
height=latent_height // 2,
width=latent_width // 2,
).to(device, dtype=torch.float64)
if cfg > 1.0:
prompt_embeds = torch.cat([neg_prompt_embeds, prompt_embeds], dim=0)
if image_latents is not None:
latent_image_ids = torch.cat([latent_image_ids, image_latents_ids], dim=0)
sigmas = np.linspace(1.0, 1.0 / steps, steps)
image_seq_len = latents.shape[1]
mu = calculate_shift(
image_seq_len,
sched.config.get("base_image_seq_len", 256) if hasattr(sched, "config") else 256,
sched.config.get("max_image_seq_len", 4096) if hasattr(sched, "config") else 4096,
sched.config.get("base_shift", 0.5) if hasattr(sched, "config") else 0.5,
sched.config.get("max_shift", 1.15) if hasattr(sched, "config") else 1.15,
)
timesteps, num_inference_steps = retrieve_timesteps(
sched,
steps,
device,
sigmas=sigmas,
mu=mu,
)
with torch.inference_mode():
for _, t in enumerate(timesteps):
latent_model_input = latents
if image_latents is not None:
latent_model_input = torch.cat([latents, image_latents], dim=1)
latent_model_input = torch.cat([latent_model_input] * 2) if cfg > 1.0 else latent_model_input
timestep = t.expand(latent_model_input.shape[0]).to(latents.dtype)
noise_pred = transformer(
hidden_states=latent_model_input,
timestep=timestep / 1000,
guidance=None,
encoder_hidden_states=prompt_embeds,
txt_ids=text_ids,
img_ids=latent_image_ids,
return_dict=False,
)[0]
if image_latents is not None:
noise_pred = noise_pred[:, :image_seq_len]
if cfg > 1.0:
noise_pred_uncond, noise_pred_text = noise_pred.chunk(2, dim=0)
noise_pred = noise_pred_uncond + cfg * (noise_pred_text - noise_pred_uncond)
if cfg_norm:
cond_norm = torch.norm(noise_pred_text, dim=-1, keepdim=True)
noise_norm = torch.norm(noise_pred, dim=-1, keepdim=True)
scale = (cond_norm / (noise_norm + 1e-8)).clamp(min=cfg_renorm_min, max=1.0)
noise_pred = noise_pred * scale
latents = sched.step(noise_pred, t, latents, return_dict=False)[0]
unpacked = _unpack_latents(latents, latent_height, latent_width, vae_scale_factor)
return ({"samples": unpacked},)
NODE_CLASS_MAPPINGS = {
"LongCatCLIPLoader": LongCatCLIPLoader,
"LongCatVAELoader": LongCatVAELoader,
"TextEncodeLongCatImage": TextEncodeLongCatImage,
"TextEncodeLongCatImageEdit": TextEncodeLongCatImageEdit,
"LongCatSizePicker": LongCatSizePicker,
"LongCatImageResizer": LongCatImageResizer,
"LongCatSampler": LongCatSampler,
"LoadLongCatModel": LoadLongCatModel,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"LongCatCLIPLoader": "LongCat CLIP Loader",
"LongCatVAELoader": "LongCat VAE Loader",
"TextEncodeLongCatImage": "Text Encode LongCat Image",
"TextEncodeLongCatImageEdit": "Text Encode LongCat Image Edit",
"LongCatSizePicker": "LongCat Size Picker",
"LongCatImageResizer": "LongCat Image Resizer",
"LongCatSampler": "LongCat Sampler",
"LoadLongCatModel": "Load LongCat Model",
}