import os import sys import subprocess import importlib import glob import io import torch import numpy as np from PIL import Image import folder_paths import comfy.sd import comfy.utils import comfy.sample import comfy.samplers import comfy.model_management from server import PromptServer def ensure_package(package, install_package_name=None): try: importlib.import_module(package) except ImportError: print(f"[MultiPrompt] Package {package} not found. Installing...") if "python_embeded" in sys.executable or "python_embedded" in sys.executable: pip_install = [sys.executable, "-s", "-m", "pip", "install"] else: pip_install = [sys.executable, "-m", "pip", "install"] subprocess.check_call(pip_install + [install_package_name or package]) ensure_package("dotenv", "python-dotenv") ensure_package("requests") def get_lut_files(): """LUT 파일 목록 가져오기""" lut_paths = [] # comfyui-propost LUTs 폴더 propost_path = os.path.join(folder_paths.get_folder_paths("custom_nodes")[0] if folder_paths.get_folder_paths("custom_nodes") else "", "comfyui-propost", "LUTs") if os.path.exists(propost_path): lut_paths.append(propost_path) # models/luts 폴더 models_lut_path = os.path.join(folder_paths.models_dir, "luts") if os.path.exists(models_lut_path): lut_paths.append(models_lut_path) # custom_nodes 폴더 내 LUTs custom_nodes_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) propost_luts = os.path.join(custom_nodes_path, "comfyui-propost", "LUTs") if os.path.exists(propost_luts) and propost_luts not in lut_paths: lut_paths.append(propost_luts) lut_files = ["None"] for path in lut_paths: if os.path.exists(path): for f in os.listdir(path): if f.lower().endswith(".cube"): lut_files.append(f) return list(set(lut_files)) def find_lut_file(lut_name): """LUT 파일 전체 경로 찾기""" if lut_name == "None": return None search_paths = [] custom_nodes_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) search_paths.append(os.path.join(custom_nodes_path, "comfyui-propost", "LUTs")) search_paths.append(os.path.join(folder_paths.models_dir, "luts")) for path in search_paths: full_path = os.path.join(path, lut_name) if os.path.exists(full_path): return full_path return None def parse_cube_lut(lut_path): """Parse .cube LUT file""" with open(lut_path, 'r') as f: lines = f.readlines() lut_size = 0 lut_data = [] for line in lines: line = line.strip() if line.startswith('#') or line == '': continue if line.startswith('TITLE'): continue if line.startswith('LUT_3D_SIZE'): lut_size = int(line.split()[1]) continue if line.startswith('DOMAIN_MIN') or line.startswith('DOMAIN_MAX'): continue try: values = [float(x) for x in line.split()] if len(values) == 3: lut_data.append(values) except: continue if lut_size == 0 or len(lut_data) == 0: return None, 0 # cube 파일은 R이 가장 빠르게 변함 -> Fortran order로 reshape lut_array = np.array(lut_data, dtype=np.float32) lut_3d = np.reshape(lut_array, (lut_size, lut_size, lut_size, 3), order='F') return lut_3d, lut_size def apply_lut(image, lut_3d, lut_size, strength=1.0): """Apply 3D LUT to image using trilinear interpolation""" if lut_3d is None or strength <= 0: return image # image: [B, H, W, C] tensor, 0-1 range img_np = image.cpu().to(torch.float32).numpy() result = [] for b in range(img_np.shape[0]): img = img_np[b][:, :, :3].copy() img = np.clip(img, 0, 1) # 이미지를 LUT 인덱스로 스케일 h, w, _ = img.shape img_flat = img.reshape(-1, 3) # 스케일링 coords = img_flat * (lut_size - 1) # 정수 인덱스와 소수 부분 coords_floor = np.floor(coords).astype(np.int32) coords_ceil = np.minimum(coords_floor + 1, lut_size - 1) frac = coords - coords_floor # 인덱스 클램프 coords_floor = np.clip(coords_floor, 0, lut_size - 1) r0, g0, b0 = coords_floor[:, 0], coords_floor[:, 1], coords_floor[:, 2] r1, g1, b1 = coords_ceil[:, 0], coords_ceil[:, 1], coords_ceil[:, 2] fr, fg, fb = frac[:, 0], frac[:, 1], frac[:, 2] # 8개 꼭짓점 값 c000 = lut_3d[r0, g0, b0] c001 = lut_3d[r0, g0, b1] c010 = lut_3d[r0, g1, b0] c011 = lut_3d[r0, g1, b1] c100 = lut_3d[r1, g0, b0] c101 = lut_3d[r1, g0, b1] c110 = lut_3d[r1, g1, b0] c111 = lut_3d[r1, g1, b1] # 8개 가중치 (colour-science 방식) w000 = ((1 - fr) * (1 - fg) * (1 - fb))[:, None] w001 = ((1 - fr) * (1 - fg) * fb)[:, None] w010 = ((1 - fr) * fg * (1 - fb))[:, None] w011 = ((1 - fr) * fg * fb)[:, None] w100 = (fr * (1 - fg) * (1 - fb))[:, None] w101 = (fr * (1 - fg) * fb)[:, None] w110 = (fr * fg * (1 - fb))[:, None] w111 = (fr * fg * fb)[:, None] # trilinear interpolation lut_result = (c000 * w000 + c001 * w001 + c010 * w010 + c011 * w011 + c100 * w100 + c101 * w101 + c110 * w110 + c111 * w111) lut_result = lut_result.reshape(h, w, 3) # strength 블렌딩 if strength < 1.0: lut_result = img * (1 - strength) + lut_result * strength lut_result = np.clip(lut_result, 0, 1).astype(np.float32) result.append(lut_result) result = np.stack(result, axis=0) return torch.from_numpy(result).to(image.device) class MultiPromptGenerator: """ 프롬프트 리스트를 입력받아 한 번에 여러 이미지를 생성하고 업스케일하는 노드 """ def __init__(self): self.output_dir = folder_paths.get_output_directory() @classmethod def INPUT_TYPES(s): return { "required": { "model": ("MODEL",), "clip": ("CLIP",), "vae": ("VAE",), "latent": ("LATENT",), "upscale_model": ("UPSCALE_MODEL",), "base_prompt": ("STRING", {"multiline": True, "default": "1girl, solo,"}), "negative_prompt": ("STRING", {"multiline": True, "default": "lowres, bad quality,"}), "prompt_list": ("STRING", {"multiline": True, "default": "# Blank line = new prompt\n# 빈 줄 = 새 프롬프트\n# Use skip_indices to skip (e.g. 3,4,7)\n\nsmile, happy,\nbright eyes\n\nangry, furrowed brow\n\nsad, crying, tears"}), "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}), "random_seed_per_image": ("BOOLEAN", {"default": False, "tooltip": "Use different random seed for each image"}), "steps": ("INT", {"default": 30, "min": 1, "max": 200}), "cfg": ("FLOAT", {"default": 5.0, "min": 0.0, "max": 30.0, "step": 0.1}), "sampler_name": (comfy.samplers.KSampler.SAMPLERS,), "scheduler": (comfy.samplers.KSampler.SCHEDULERS,), "enable_upscale": ("BOOLEAN", {"default": True}), "save_prefix": ("STRING", {"default": "MultiPrompt"}), }, "optional": { "skip_indices": ("STRING", {"default": "", "placeholder": "e.g. 3,4,7"}), "downscale_ratio": ("FLOAT", {"default": 0.7, "min": 0.1, "max": 1.0, "step": 0.01}), "upscale_steps": ("INT", {"default": 15, "min": 1, "max": 200}), "upscale_cfg": ("FLOAT", {"default": 5.0, "min": 0.0, "max": 30.0, "step": 0.1}), "upscale_denoise": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), "size_alignment": (["none", "8", "64"], {"default": "none"}), "lut_name": (get_lut_files(),), "lut_strength": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01}), "enable_preview": ("BOOLEAN", {"default": True}), }, "hidden": { "unique_id": "UNIQUE_ID", "prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO", } } RETURN_TYPES = ("IMAGE",) RETURN_NAMES = ("images",) FUNCTION = "generate" CATEGORY = "image/generation" OUTPUT_NODE = True OUTPUT_IS_LIST = (True,) def encode_prompt(self, clip, text): """CLIP 텍스트 인코딩""" tokens = clip.tokenize(text) cond, pooled = clip.encode_from_tokens(tokens, return_pooled=True) return [[cond, {"pooled_output": pooled}]] def do_sample(self, model, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent_image, denoise=1.0): """샘플링 실행""" device = comfy.model_management.get_torch_device() latent = latent_image.clone().to(device) noise = comfy.sample.prepare_noise(latent, seed, None) samples = comfy.sample.sample( model, noise, steps, cfg, sampler_name, scheduler, positive, negative, latent, denoise=denoise, disable_noise=False, start_step=None, last_step=None, force_full_denoise=True, noise_mask=None, callback=None, disable_pbar=False, seed=seed ) return samples def decode_vae(self, vae, samples): """VAE 디코딩""" return vae.decode(samples) def encode_vae(self, vae, image): """VAE 인코딩""" return vae.encode(image[:, :, :, :3]) def upscale_with_model(self, upscale_model, image): """업스케일 모델로 이미지 확대""" device = comfy.model_management.get_torch_device() upscale_model.to(device) in_img = image.movedim(-1, -3).to(device) tile = 512 overlap = 32 oom = True while oom: try: steps = in_img.shape[0] * comfy.utils.get_tiled_scale_steps( in_img.shape[3], in_img.shape[2], tile_x=tile, tile_y=tile, overlap=overlap ) pbar = comfy.utils.ProgressBar(steps) s = comfy.utils.tiled_scale( in_img, lambda a: upscale_model(a), tile_x=tile, tile_y=tile, overlap=overlap, upscale_amount=upscale_model.scale, pbar=pbar ) oom = False except comfy.model_management.OOM_EXCEPTION as e: tile //= 2 if tile < 128: raise e upscale_model.to("cpu") s = torch.clamp(s.movedim(-3, -1), min=0, max=1.0) return s def resize_with_alignment(self, image, scale_factor, alignment="64"): """이미지를 지정된 배수 크기로 리사이즈""" batch, height, width, channels = image.shape new_width = int(width * scale_factor) new_height = int(height * scale_factor) # 배수 정렬 if alignment == "64": new_width = ((new_width + 32) // 64) * 64 new_height = ((new_height + 32) // 64) * 64 new_width = max(64, new_width) new_height = max(64, new_height) elif alignment == "8": new_width = ((new_width + 4) // 8) * 8 new_height = ((new_height + 4) // 8) * 8 new_width = max(8, new_width) new_height = max(8, new_height) # "none"이면 그대로 # BCHW로 변환 후 리사이즈 samples = image.movedim(-1, 1) s = comfy.utils.common_upscale(samples, new_width, new_height, "lanczos", "disabled") s = s.movedim(1, -1) return s def save_image(self, image, prefix, filename, counter, prompt=None, extra_pnginfo=None): """이미지 저장 (메타데이터 포함)""" import json from PIL.PngImagePlugin import PngInfo full_output_folder = os.path.join(self.output_dir, prefix) os.makedirs(full_output_folder, exist_ok=True) file = f"{filename}_{counter:05d}.png" filepath = os.path.join(full_output_folder, file) img = image[0].cpu().numpy() img = (img * 255).astype(np.uint8) pil_img = Image.fromarray(img) # 메타데이터 추가 metadata = PngInfo() if prompt is not None: metadata.add_text("prompt", json.dumps(prompt)) if extra_pnginfo is not None: for key, value in extra_pnginfo.items(): metadata.add_text(key, json.dumps(value)) pil_img.save(filepath, pnginfo=metadata, compress_level=4) print(f"[MultiPrompt] Saved: {filepath}") return filepath def send_preview(self, image, unique_id, idx, stage=""): """생성 중 프리뷰를 UI로 전송""" import time temp_dir = folder_paths.get_temp_directory() # temp 폴더에 프리뷰 저장 (타임스탬프로 캐시 우회) timestamp = int(time.time() * 1000) preview_filename = f"multiprompt_preview_{unique_id}_{idx}_{stage}_{timestamp}.png" preview_path = os.path.join(temp_dir, preview_filename) img = image[0].cpu().numpy() img = (img * 255).astype(np.uint8) pil_img = Image.fromarray(img) pil_img.save(preview_path, compress_level=1) # UI로 프리뷰 전송 PromptServer.instance.send_sync("executed", { "node": unique_id, "output": { "images": [{ "filename": preview_filename, "subfolder": "", "type": "temp" }] } }) def generate(self, model, clip, vae, latent, upscale_model, base_prompt, negative_prompt, prompt_list, seed, random_seed_per_image, steps, cfg, sampler_name, scheduler, enable_upscale, save_prefix, skip_indices="", downscale_ratio=0.7, upscale_steps=15, upscale_cfg=5.0, upscale_denoise=0.5, size_alignment="none", lut_name="None", lut_strength=0.3, enable_preview=True, unique_id=None, prompt=None, extra_pnginfo=None): # prompt_list 파싱 (빈 줄로 구분, # 주석 무시, - 시작하면 블록 스킵) blocks = prompt_list.strip().split("\n\n") lines = [] for block in blocks: block_lines = [line.strip() for line in block.strip().split("\n") if line.strip()] if not block_lines: continue # 첫 번째 유효 줄(주석 아닌)이 -로 시작하면 블록 스킵 first_content = None for line in block_lines: if not line.startswith("#"): first_content = line break if first_content and first_content.startswith("-"): continue # 블록 스킵 # 블록 내 줄바꿈을 공백으로 합침, # 주석 제외 merged = " ".join( line.strip() for line in block_lines if not line.startswith("#") ) if merged: lines.append(merged) # skip_indices 파싱 (1-based) skip_set = set() if skip_indices.strip(): for part in skip_indices.split(","): part = part.strip() if part.isdigit(): skip_set.add(int(part)) # prompt_list 비어있으면 base_prompt만으로 1장 생성 if not lines: lines = [""] # LUT 로드 lut_data = None lut_size = 0 if lut_name != "None": lut_path = find_lut_file(lut_name) if lut_path: lut_data, lut_size = parse_cube_lut(lut_path) print(f"[MultiPrompt] LUT loaded: {lut_name} (size: {lut_size})") # negative 인코딩 (한 번만) negative_cond = self.encode_prompt(clip, negative_prompt) all_images = [] counter = self._get_next_counter(save_prefix) # 인터럽트 체크용 import comfy.model_management as mm for idx, line in enumerate(lines): # 중단 체크 mm.throw_exception_if_processing_interrupted() # skip_indices에 있으면 스킵 (1-based) if (idx + 1) in skip_set: print(f"[MultiPrompt] Skipping {idx + 1}/{len(lines)}: {line[:40]}...") continue # 파일명: 순번 + 첫 태그 first_tag = line.split(",")[0].strip().replace(" ", "_") if line else "base" filename = f"{idx + 1:02d}_{first_tag}" # 프롬프트 결합 full_prompt = f"{base_prompt}, {line}".strip().rstrip(",") positive_cond = self.encode_prompt(clip, full_prompt) # 시드 처리: random_seed_per_image에 따라 랜덤 시드 생성 if random_seed_per_image: current_seed = np.random.randint(0, 2**31 - 1) else: current_seed = seed if seed != 0 else np.random.randint(0, 2**31 - 1) print(f"[MultiPrompt] Processing {idx + 1}/{len(lines)}: {line[:40] if line else '(base prompt only)'}... (seed: {current_seed})") # latent 복사 latent_samples = latent["samples"].clone() # 1단계: 기본 샘플링 samples = self.do_sample( model, current_seed, steps, cfg, sampler_name, scheduler, positive_cond, negative_cond, latent_samples, denoise=1.0 ) image = self.decode_vae(vae, samples) # 1차 결과 프리뷰 전송 if enable_preview and unique_id is not None: self.send_preview(image, unique_id, idx, "1st") # 2단계: 업스케일 if enable_upscale and upscale_model is not None: # 업스케일 모델 적용 upscaled = self.upscale_with_model(upscale_model, image) # 64배수로 리사이즈 resized = self.resize_with_alignment(upscaled, downscale_ratio, size_alignment) # VAE 인코딩 → 2차 샘플링 → 디코딩 latent_up = self.encode_vae(vae, resized) samples_up = self.do_sample( model, current_seed, upscale_steps, upscale_cfg, sampler_name, scheduler, positive_cond, negative_cond, latent_up, denoise=upscale_denoise ) image = self.decode_vae(vae, samples_up) # 3단계: LUT 적용 if lut_data is not None and lut_strength > 0: image = apply_lut(image, lut_data, lut_size, lut_strength) # 최종 결과 프리뷰 교체 if enable_preview and unique_id is not None: self.send_preview(image, unique_id, idx, "final") # 저장 self.save_image(image, save_prefix, filename, counter, prompt, extra_pnginfo) all_images.append(image) print(f"[MultiPrompt] Complete! Generated {len(all_images)} images.") # 마지막 이미지를 UI에 표시 if all_images and enable_preview: last_image = all_images[-1] import time temp_dir = folder_paths.get_temp_directory() timestamp = int(time.time() * 1000) preview_filename = f"multiprompt_final_{timestamp}.png" preview_path = os.path.join(temp_dir, preview_filename) img = last_image[0].cpu().numpy() img = (img * 255).astype(np.uint8) pil_img = Image.fromarray(img) pil_img.save(preview_path, compress_level=1) return {"ui": {"images": [{"filename": preview_filename, "subfolder": "", "type": "temp"}]}, "result": (all_images,)} return {"ui": {"images": []}, "result": (all_images,)} def _get_next_counter(self, prefix): """다음 카운터 번호""" full_output_folder = os.path.join(self.output_dir, prefix) if not os.path.exists(full_output_folder): return 1 existing_files = os.listdir(full_output_folder) if not existing_files: return 1 max_counter = 0 for f in existing_files: if f.endswith(".png"): try: counter_str = f.rsplit("_", 1)[-1].replace(".png", "") counter_val = int(counter_str) max_counter = max(max_counter, counter_val) except: pass return max_counter + 1 def load_nai_token(): """ComfyUI 루트의 .env에서 NAI_ACCESS_TOKEN 로드""" from dotenv import dotenv_values # ComfyUI 루트 경로들 possible_paths = [ os.path.join(folder_paths.base_path, ".env") if hasattr(folder_paths, 'base_path') else None, os.path.join(os.getcwd(), ".env"), os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), ".env"), ] for env_path in possible_paths: if env_path and os.path.exists(env_path): env = dotenv_values(env_path) if "NAI_ACCESS_TOKEN" in env: return env["NAI_ACCESS_TOKEN"] # 환경변수에서도 시도 return os.environ.get("NAI_ACCESS_TOKEN", None) # Character Reference 헬퍼 함수들 ACCEPTED_CR_SIZES = [(1024, 1536), (1536, 1024), (1472, 1472)] def _choose_cr_canvas(w, h): """캐릭터 레퍼런스용 캔버스 크기 선택 (원본 비율에 가장 가까운 것)""" aspect = w / h best = None best_diff = 9e9 for cw, ch in ACCEPTED_CR_SIZES: diff = abs((cw / ch) - aspect) if diff < best_diff: best_diff = diff best = (cw, ch) return best def pad_image_to_canvas(tensor_image, target_size): """이미지를 캔버스 크기에 맞게 letterbox 패딩""" _, H, W, C = tensor_image.shape tw, th = target_size arr = (tensor_image[0].cpu().numpy() * 255).clip(0, 255).astype(np.uint8) mode = "RGBA" if (C == 4) else "RGB" pil = Image.fromarray(arr) scale = min(tw / W, th / H) new_w = max(1, int(W * scale)) new_h = max(1, int(H * scale)) pil_resized = pil.resize((new_w, new_h), Image.LANCZOS) if mode == "RGBA": canvas = Image.new("RGBA", (tw, th), (0, 0, 0, 0)) else: canvas = Image.new("RGB", (tw, th), (0, 0, 0)) offset = ((tw - new_w) // 2, (th - new_h) // 2) canvas.paste(pil_resized, offset) out = np.array(canvas).astype(np.float32) / 255.0 return torch.from_numpy(out)[None,] def image_to_base64(tensor_image): """텐서 이미지를 base64로 변환""" import base64 arr = (tensor_image[0].cpu().numpy() * 255).clip(0, 255).astype(np.uint8) pil = Image.fromarray(arr) buffer = io.BytesIO() pil.save(buffer, format="PNG") return base64.b64encode(buffer.getvalue()).decode("utf-8") class NAIMultiPromptGenerator: """ NovelAI API를 사용하여 다중 프롬프트 이미지 생성 + 업스케일 + LUT """ def __init__(self): self.output_dir = folder_paths.get_output_directory() self.api_url = "https://image.novelai.net/ai/generate-image" @classmethod def INPUT_TYPES(s): return { "required": { "base_prompt": ("STRING", {"multiline": True, "default": "1girl, solo, best quality, amazing quality, very aesthetic, absurdres,"}), "negative_prompt": ("STRING", {"multiline": True, "default": "lowres, bad quality,"}), "prompt_list": ("STRING", {"multiline": True, "default": "# Blank line = new prompt\n# 빈 줄 = 새 프롬프트\n\nsmile, happy\n\nangry, furrowed brow\n\nsad, crying"}), "width": ("INT", {"default": 832, "min": 64, "max": 2048, "step": 64}), "height": ("INT", {"default": 1216, "min": 64, "max": 2048, "step": 64}), "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}), "random_seed_per_image": ("BOOLEAN", {"default": False, "tooltip": "Use different random seed for each image"}), "steps": ("INT", {"default": 28, "min": 1, "max": 50}), "cfg": ("FLOAT", {"default": 5.0, "min": 0.0, "max": 20.0, "step": 0.1}), "sampler": (["k_euler", "k_euler_ancestral", "k_dpmpp_2s_ancestral", "k_dpmpp_2m", "k_dpmpp_sde", "ddim"],), "scheduler": (["native", "karras", "exponential", "polyexponential"],), "smea": (["none", "SMEA", "SMEA+DYN"],), "nai_model": (["nai-diffusion-4-5-full", "nai-diffusion-4-curated-preview", "nai-diffusion-3"],), "save_prefix": ("STRING", {"default": "NAI_MultiPrompt"}), }, "optional": { "skip_indices": ("STRING", {"default": "", "placeholder": "e.g. 3,4,7"}), "variety": ("BOOLEAN", {"default": False}), "decrisper": ("BOOLEAN", {"default": False}), "free_only": ("BOOLEAN", {"default": True}), "cfg_rescale": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}), "uncond_scale": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.5, "step": 0.01}), "uc_preset": (["Heavy", "Light", "Human Focus", "None"], {"default": "Heavy", "tooltip": "Undesired Content preset (0=Heavy, 1=Light, 2=Human Focus, 3=None)"}), "quality_tags": ("BOOLEAN", {"default": True, "tooltip": "Add quality tags automatically (like NAI web UI)"}), "reference_image": ("IMAGE",), "char_ref_style_aware": ("BOOLEAN", {"default": True, "tooltip": "Copy style along with identity"}), "char_ref_fidelity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "How strictly to match the character"}), "lut_name": (get_lut_files(),), "lut_strength": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01}), "enable_preview": ("BOOLEAN", {"default": True}), }, "hidden": { "unique_id": "UNIQUE_ID", "prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO", } } RETURN_TYPES = ("IMAGE",) RETURN_NAMES = ("images",) FUNCTION = "generate" CATEGORY = "image/generation" OUTPUT_NODE = True def call_nai_api(self, prompt, negative_prompt, width, height, seed, steps, cfg, sampler, scheduler, smea, nai_model, variety, decrisper, cfg_rescale, uncond_scale, uc_preset="Heavy", quality_tags=True, reference_image=None, char_ref_style_aware=True, char_ref_fidelity=1.0): """NAI API 호출""" import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry import zipfile import io token = load_nai_token() if not token: raise ValueError("NAI_ACCESS_TOKEN not found. Please set it in ComfyUI/.env file") headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json", } # SMEA 설정 (ddim에서는 비활성화) sm = (smea in ["SMEA", "SMEA+DYN"]) and sampler != "ddim" sm_dyn = (smea == "SMEA+DYN") and sampler != "ddim" # ddim → ddim_v3 (v2 모델 제외) actual_sampler = sampler if sampler == "ddim" and "nai-diffusion-2" not in nai_model: actual_sampler = "ddim_v3" # 멀티 캐릭터 프롬프트 파싱 (| 구분자) # 형식: base_prompt | char1_prompt | char2_prompt | ... char_captions = [] neg_char_captions = [] character_prompts = [] # 루트 레벨 characterPrompts용 if "|" in prompt: parts = [p.strip() for p in prompt.split("|")] base_caption = parts[0] for char_prompt in parts[1:]: if char_prompt: # v4_prompt용 char_captions (centers는 배열) char_captions.append({ "char_caption": char_prompt, "centers": [{"x": 0.5, "y": 0.5}] }) # 루트 레벨 characterPrompts character_prompts.append({ "prompt": char_prompt, "uc": "", "center": {"x": 0.5, "y": 0.5}, "enabled": True }) # 네거티브용 char_captions (빈 문자열) neg_char_captions.append({ "char_caption": "", "centers": [{"x": 0.5, "y": 0.5}] }) else: base_caption = prompt neg_base_caption = negative_prompt # UC Preset 변환 (문자열 → 숫자) uc_preset_map = {"Heavy": 0, "Light": 1, "Human Focus": 2, "None": 3} uc_preset_value = uc_preset_map.get(uc_preset, 0) params = { "params_version": 3, "width": width, "height": height, "scale": cfg, "sampler": actual_sampler, "steps": steps, "seed": seed, "n_samples": 1, "ucPreset": uc_preset_value, "qualityToggle": quality_tags, "sm": sm, "sm_dyn": sm_dyn, "dynamic_thresholding": decrisper, "controlnet_strength": 1.0, "legacy": False, "add_original_image": True, "cfg_rescale": cfg_rescale, "noise_schedule": scheduler, "legacy_v3_extend": False, "uncond_scale": uncond_scale, "negative_prompt": neg_base_caption, "prompt": base_caption, "reference_image_multiple": [], "reference_information_extracted_multiple": [], "reference_strength_multiple": [], "extra_noise_seed": seed, "use_coords": False, "characterPrompts": character_prompts, "v4_prompt": { "use_coords": False, "use_order": True, "caption": {"base_caption": base_caption, "char_captions": char_captions} }, "v4_negative_prompt": { "legacy_uc": False, "caption": {"base_caption": neg_base_caption, "char_captions": neg_char_captions} } } # Character Reference 처리 if reference_image is not None: _, h_raw, w_raw, _ = reference_image.shape canvas_w, canvas_h = _choose_cr_canvas(w_raw, h_raw) padded = pad_image_to_canvas(reference_image, (canvas_w, canvas_h)) base_caption_ref = "character&style" if char_ref_style_aware else "character" params["director_reference_images"] = [image_to_base64(padded)] params["director_reference_descriptions"] = [{ "use_coords": False, "use_order": False, "legacy_uc": False, "caption": {"base_caption": base_caption_ref, "char_captions": []} }] params["director_reference_strength_values"] = [1.0] params["director_reference_secondary_strength_values"] = [1.0 - char_ref_fidelity] params["director_reference_information_extracted"] = [1.0] # k_euler_ancestral + non-native scheduler if sampler == "k_euler_ancestral" and scheduler != "native": params["deliberate_euler_ancestral_bug"] = False params["prefer_brownian"] = True # variety → skip_cfg_above_sigma 계산 if variety: # NAI 공식: skip_cfg_above_sigma 계산 params["skip_cfg_above_sigma"] = self._calculate_skip_cfg_above_sigma(width, height) payload = { "input": prompt, "model": nai_model, "action": "generate", "parameters": params } # Retry 로직 (3회 재시도) retries = Retry( total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504], allowed_methods=["POST"], raise_on_status=False ) session = requests.Session() session.mount("https://", HTTPAdapter(max_retries=retries)) try: response = session.post(self.api_url, headers=headers, json=payload, timeout=120) except requests.exceptions.RetryError as e: raise ValueError(f"NAI API 서버 오류. 잠시 후 다시 시도해주세요.") except requests.exceptions.Timeout: raise ValueError("NAI API 타임아웃. 네트워크 상태를 확인해주세요.") except requests.exceptions.ConnectionError as e: raise ValueError(f"NAI API 연결 실패. 네트워크 상태를 확인해주세요.") if response.status_code != 200: error_msg = response.text[:500] if response.text else "Unknown error" raise ValueError(f"NAI API error: {response.status_code} - {error_msg}") # ZIP에서 이미지 추출 with zipfile.ZipFile(io.BytesIO(response.content)) as z: for name in z.namelist(): if name.endswith('.png'): img_data = z.read(name) pil_img = Image.open(io.BytesIO(img_data)) # RGB 변환 후 tensor로 img_np = np.array(pil_img.convert("RGB")).astype(np.float32) / 255.0 return torch.from_numpy(img_np).unsqueeze(0) raise ValueError("No image found in NAI API response") def _calculate_skip_cfg_above_sigma(self, width, height): """variety용 skip_cfg_above_sigma 계산 (NAI 공식)""" # 기본 해상도 기준 스케일링 base_res = 1024 * 1024 current_res = width * height scale = (current_res / base_res) ** 0.5 return 19.0 * scale def save_image(self, image, prefix, filename, counter, prompt=None, extra_pnginfo=None, seed=None, full_prompt=None): import json from PIL.PngImagePlugin import PngInfo full_output_folder = os.path.join(self.output_dir, prefix) os.makedirs(full_output_folder, exist_ok=True) file = f"{filename}_{counter:05d}.png" filepath = os.path.join(full_output_folder, file) img = image[0].cpu().numpy() img = (img * 255).astype(np.uint8) pil_img = Image.fromarray(img) metadata = PngInfo() if prompt is not None: metadata.add_text("prompt", json.dumps(prompt)) if extra_pnginfo is not None: for key, value in extra_pnginfo.items(): metadata.add_text(key, json.dumps(value)) # NAI 생성 정보 추가 if seed is not None: metadata.add_text("nai_seed", str(seed)) if full_prompt is not None: metadata.add_text("nai_prompt", full_prompt) pil_img.save(filepath, pnginfo=metadata, compress_level=4) print(f"[NAI MultiPrompt] Saved: {filepath}") return filepath def send_preview(self, image, unique_id, idx, stage=""): import time temp_dir = folder_paths.get_temp_directory() timestamp = int(time.time() * 1000) preview_filename = f"nai_multiprompt_preview_{unique_id}_{idx}_{stage}_{timestamp}.png" preview_path = os.path.join(temp_dir, preview_filename) img = image[0].cpu().numpy() img = (img * 255).astype(np.uint8) pil_img = Image.fromarray(img) pil_img.save(preview_path, compress_level=1) PromptServer.instance.send_sync("executed", { "node": unique_id, "output": { "images": [{ "filename": preview_filename, "subfolder": "", "type": "temp" }] } }) def generate(self, base_prompt, negative_prompt, prompt_list, width, height, seed, random_seed_per_image, steps, cfg, sampler, scheduler, smea, nai_model, save_prefix, skip_indices="", variety=False, decrisper=False, free_only=True, cfg_rescale=0.0, uncond_scale=1.0, uc_preset="Heavy", quality_tags=True, reference_image=None, char_ref_style_aware=True, char_ref_fidelity=1.0, lut_name="None", lut_strength=0.3, enable_preview=True, unique_id=None, prompt=None, extra_pnginfo=None): # free_only: Opus 무료 조건 (1MP 이하, 28 steps 이하) if free_only: pixel_limit = 1024 * 1024 pixels = width * height if pixels > pixel_limit: # 비율 유지하면서 축소 scale = (pixel_limit / pixels) ** 0.5 width = int(width * scale) // 64 * 64 height = int(height * scale) // 64 * 64 print(f"[NAI MultiPrompt] free_only: Resolution adjusted to {width}x{height}") if steps > 28: print(f"[NAI MultiPrompt] free_only: Steps clamped from {steps} to 28") steps = 28 # prompt_list 파싱 blocks = prompt_list.strip().split("\n\n") lines = [] for block in blocks: block_lines = [line.strip() for line in block.strip().split("\n") if line.strip()] if not block_lines: continue first_content = None for line in block_lines: if not line.startswith("#"): first_content = line break if first_content and first_content.startswith("-"): continue merged = " ".join( line.strip() for line in block_lines if not line.startswith("#") ) if merged: lines.append(merged) # skip_indices 파싱 skip_set = set() if skip_indices.strip(): for part in skip_indices.split(","): part = part.strip() if part.isdigit(): skip_set.add(int(part)) # prompt_list 비어있으면 base_prompt만으로 1장 생성 if not lines: lines = [""] # LUT 로드 lut_data = None lut_size = 0 if lut_name != "None": lut_path = find_lut_file(lut_name) if lut_path: lut_data, lut_size = parse_cube_lut(lut_path) print(f"[NAI MultiPrompt] LUT loaded: {lut_name}") all_images = [] counter = self._get_next_counter(save_prefix) # 인터럽트 체크용 import comfy.model_management as mm for idx, line in enumerate(lines): # 중단 체크 mm.throw_exception_if_processing_interrupted() if (idx + 1) in skip_set: print(f"[NAI MultiPrompt] Skipping {idx + 1}/{len(lines)}: {line[:40]}...") continue first_tag = line.split(",")[0].strip().replace(" ", "_") if line else "base" filename = f"{idx + 1:02d}_{first_tag}" full_prompt = f"{base_prompt} {line}".strip() # NAI API 호출 # random_seed_per_image: 각 이미지마다 다른 랜덤 시드 if random_seed_per_image: current_seed = np.random.randint(0, 2**31 - 1) else: current_seed = seed if seed != 0 else np.random.randint(0, 2**31 - 1) print(f"[NAI MultiPrompt] Processing {idx + 1}/{len(lines)}: {line[:40] if line else '(base prompt only)'}... (seed: {current_seed})") image = self.call_nai_api( full_prompt, negative_prompt, width, height, current_seed, steps, cfg, sampler, scheduler, smea, nai_model, variety, decrisper, cfg_rescale, uncond_scale, uc_preset, quality_tags, reference_image, char_ref_style_aware, char_ref_fidelity ) # 프리뷰 if enable_preview and unique_id is not None: self.send_preview(image, unique_id, idx, "nai") # LUT 적용 if lut_data is not None and lut_strength > 0: image = apply_lut(image, lut_data, lut_size, lut_strength) if enable_preview and unique_id is not None: self.send_preview(image, unique_id, idx, "final") # 저장 self.save_image(image, save_prefix, filename, counter, prompt, extra_pnginfo, current_seed, full_prompt) all_images.append(image) print(f"[NAI MultiPrompt] Complete! Generated {len(all_images)} images.") # 마지막 이미지를 UI에 표시 if all_images and enable_preview: last_image = all_images[-1] import time temp_dir = folder_paths.get_temp_directory() timestamp = int(time.time() * 1000) preview_filename = f"nai_multiprompt_final_{timestamp}.png" preview_path = os.path.join(temp_dir, preview_filename) img = last_image[0].cpu().numpy() img = (img * 255).astype(np.uint8) pil_img = Image.fromarray(img) pil_img.save(preview_path, compress_level=1) return {"ui": {"images": [{"filename": preview_filename, "subfolder": "", "type": "temp"}]}, "result": (all_images,)} return {"ui": {"images": []}, "result": (all_images,)} def _get_next_counter(self, prefix): full_output_folder = os.path.join(self.output_dir, prefix) if not os.path.exists(full_output_folder): return 1 existing_files = os.listdir(full_output_folder) if not existing_files: return 1 max_counter = 0 for f in existing_files: if f.endswith(".png"): try: counter_str = f.rsplit("_", 1)[-1].replace(".png", "") counter_val = int(counter_str) max_counter = max(max_counter, counter_val) except: pass return max_counter + 1 NODE_CLASS_MAPPINGS = { "MultiPromptGenerator": MultiPromptGenerator, "NAIMultiPromptGenerator": NAIMultiPromptGenerator, } NODE_DISPLAY_NAME_MAPPINGS = { "MultiPromptGenerator": "Multi Prompt Generator", "NAIMultiPromptGenerator": "NAI Multi Prompt Generator", }