1096 lines
41 KiB
Python
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",
|
|
} |