commit 56bf26905b3e160fe68352613ad55ea81ecb8016 Author: mrm987 <99248187+mrm987@users.noreply.github.com> Date: Sun Dec 28 03:18:59 2025 +0900 Add files via upload diff --git a/README.md b/README.md new file mode 100644 index 0000000..0b63494 --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# ComfyUI Multi Prompt Generator + +Generate multiple images from a prompt list in a single run, with built-in upscaling and LUT support. + +![preview](https://github.com/mrm987/ComfyUI_Multi_Prompt_Generator/assets/preview.png) + +## Features + +- **Batch Generation** — Input a list of prompts (one per line), generate all at once +- **Auto Upscaling** — 2-pass upscale with model upscaler + img2img refinement +- **64x Alignment** — Automatically adjusts dimensions to prevent edge artifacts +- **LUT Support** — Apply .cube LUT files with adjustable strength +- **Live Preview** — See results during generation (toggleable) +- **Auto Save** — Images saved with auto-numbered filenames based on first tag + +## Installation + +### Option 1: ComfyUI Manager (Coming Soon) +Search for "Multi Prompt Generator" in ComfyUI Manager. + +### Option 2: Manual Install +```bash +cd ComfyUI/custom_nodes +git clone https://github.com/mrm987/ComfyUI_Multi_Prompt_Generator.git +``` +Restart ComfyUI. + +## Usage + +1. Search for **"Multi Prompt Generator"** in the node menu +2. Connect: Model, CLIP, VAE, Empty Latent, (optional) Upscale Model +3. Set your base prompt and negative prompt +4. Enter prompt list — one variation per line: +``` +smile, happy, bright eyes +angry, furrowed brow, clenched teeth +sad, downcast eyes, closed mouth +crying, tears, watery eyes +``` + +## Inputs + +| Input | Type | Description | +|-------|------|-------------| +| model | MODEL | Checkpoint model | +| clip | CLIP | CLIP model | +| vae | VAE | VAE model | +| latent | LATENT | Empty latent image | +| base_prompt | STRING | Base prompt (combined with each line) | +| negative_prompt | STRING | Negative prompt | +| prompt_list | STRING | One prompt variation per line | +| seed | INT | Random seed | +| steps | INT | Sampling steps (default: 30) | +| cfg | FLOAT | CFG scale (default: 5.0) | +| enable_upscale | BOOL | Enable 2-pass upscaling (default: True) | +| enable_preview | BOOL | Show preview during generation (default: True) | +| save_prefix | STRING | Output folder name (default: "MultiPrompt") | + +### Optional Inputs + +| Input | Type | Description | +|-------|------|-------------| +| upscale_model | UPSCALE_MODEL | Upscale model (e.g., 2x-AnimeSharp) | +| scale_factor | FLOAT | Downscale after upscale (default: 0.7) | +| upscale_steps | INT | 2nd pass steps (default: 15) | +| upscale_cfg | FLOAT | 2nd pass CFG (default: 5.0) | +| upscale_denoise | FLOAT | 2nd pass denoise (default: 0.5) | +| lut_name | COMBO | LUT file from comfyui-propost/LUTs | +| lut_strength | FLOAT | LUT intensity (default: 0.3) | + +## Output + +Images are saved to: +``` +output/[save_prefix]/01_smile_00001.png +output/[save_prefix]/02_angry_00001.png +output/[save_prefix]/03_sad_00001.png +... +``` + +Filename format: `[index]_[first_tag]_[counter].png` + +## Pipeline + +``` +For each prompt line: + 1. Combine: base_prompt + line + 2. KSampler (1st pass) + 3. Preview (if enabled) + 4. Upscale Model → Resize to 64x → KSampler (2nd pass) + 5. Apply LUT (if selected) + 6. Preview update (if enabled) + 7. Save +``` + +## Requirements + +- ComfyUI +- (Optional) [comfyui-propost](https://github.com/Beinsezii/comfyui-propost) for LUT files + +## License + +MIT diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..640f09c --- /dev/null +++ b/__init__.py @@ -0,0 +1,466 @@ +import os +import glob +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 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 + + return np.array(lut_data, dtype=np.float32), lut_size + + +def apply_lut(image, lut_data, lut_size, strength=1.0): + """Apply 3D LUT to image""" + if lut_data is None: + return image + + # image: [B, H, W, C] tensor, 0-1 range + img_np = image.cpu().numpy() + result = np.zeros_like(img_np) + + for b in range(img_np.shape[0]): + img = img_np[b] + h, w, c = img.shape + + # Reshape LUT + lut_3d = lut_data.reshape((lut_size, lut_size, lut_size, 3)) + + # Scale image to LUT indices + img_scaled = img * (lut_size - 1) + + # Get integer indices and fractions for trilinear interpolation + idx_low = np.floor(img_scaled).astype(np.int32) + idx_high = np.ceil(img_scaled).astype(np.int32) + frac = img_scaled - idx_low + + # Clamp indices + idx_low = np.clip(idx_low, 0, lut_size - 1) + idx_high = np.clip(idx_high, 0, lut_size - 1) + + # Trilinear interpolation + r_low, g_low, b_low = idx_low[:,:,0], idx_low[:,:,1], idx_low[:,:,2] + r_high, g_high, b_high = idx_high[:,:,0], idx_high[:,:,1], idx_high[:,:,2] + r_frac, g_frac, b_frac = frac[:,:,0:1], frac[:,:,1:2], frac[:,:,2:3] + + # 8 corners of the cube + c000 = lut_3d[r_low, g_low, b_low] + c001 = lut_3d[r_low, g_low, b_high] + c010 = lut_3d[r_low, g_high, b_low] + c011 = lut_3d[r_low, g_high, b_high] + c100 = lut_3d[r_high, g_low, b_low] + c101 = lut_3d[r_high, g_low, b_high] + c110 = lut_3d[r_high, g_high, b_low] + c111 = lut_3d[r_high, g_high, b_high] + + # Interpolate + c00 = c000 * (1 - r_frac) + c100 * r_frac + c01 = c001 * (1 - r_frac) + c101 * r_frac + c10 = c010 * (1 - r_frac) + c110 * r_frac + c11 = c011 * (1 - r_frac) + c111 * r_frac + + c0 = c00 * (1 - g_frac) + c10 * g_frac + c1 = c01 * (1 - g_frac) + c11 * g_frac + + lut_result = c0 * (1 - b_frac) + c1 * b_frac + + # Blend with original based on strength + result[b] = img * (1 - strength) + lut_result * strength + + result = np.clip(result, 0, 1) + 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",), + "base_prompt": ("STRING", {"multiline": True, "default": "1girl, solo,"}), + "negative_prompt": ("STRING", {"multiline": True, "default": "lowres, bad quality,"}), + "prompt_list": ("STRING", {"multiline": True, "default": "smile, happy\nangry, furrowed brow\nsad, crying"}), + "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}), + "steps": ("INT", {"default": 30, "min": 1, "max": 200}), + "cfg": ("FLOAT", {"default": 5.0, "min": 0.0, "max": 30.0, "step": 0.1}), + "enable_upscale": ("BOOLEAN", {"default": True}), + "enable_preview": ("BOOLEAN", {"default": True}), + "save_prefix": ("STRING", {"default": "MultiPrompt"}), + }, + "optional": { + "upscale_model": ("UPSCALE_MODEL",), + "scale_factor": ("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}), + "lut_name": (get_lut_files(),), + "lut_strength": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01}), + }, + "hidden": { + "unique_id": "UNIQUE_ID", + } + } + + 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, 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, + "euler_ancestral", + "normal", + 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_to_64(self, image, scale_factor): + """이미지를 64배수 크기로 리사이즈""" + batch, height, width, channels = image.shape + + new_width = int(width * scale_factor) + new_height = int(height * scale_factor) + + # 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) + + # 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): + """이미지 저장""" + 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) + pil_img.save(filepath, compress_level=4) + + print(f"[MultiPrompt] Saved: {filepath}") + return filepath + + def send_preview(self, image, unique_id, idx): + """생성 중 프리뷰를 UI로 전송""" + temp_dir = folder_paths.get_temp_directory() + + # temp 폴더에 프리뷰 저장 + preview_filename = f"multiprompt_preview_{unique_id}_{idx}.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=4) + + # UI로 프리뷰 전송 + PromptServer.instance.send_sync("executed", { + "node": unique_id, + "output": { + "images": [{ + "filename": preview_filename, + "subfolder": "", + "type": "temp" + }] + } + }) + + def generate(self, model, clip, vae, latent, base_prompt, negative_prompt, prompt_list, + seed, steps, cfg, enable_upscale, enable_preview, save_prefix, + upscale_model=None, scale_factor=0.7, upscale_steps=15, + upscale_cfg=5.0, upscale_denoise=0.5, + lut_name="None", lut_strength=0.3, unique_id=None): + + # prompt_list 파싱 + lines = [line.strip() for line in prompt_list.strip().split("\n") if line.strip()] + + if not lines: + raise ValueError("prompt_list가 비어있습니다.") + + # 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) + + for idx, line in enumerate(lines): + print(f"[MultiPrompt] Processing {idx + 1}/{len(lines)}: {line[:40]}...") + + # 파일명: 순번 + 첫 태그 + first_tag = line.split(",")[0].strip().replace(" ", "_") + filename = f"{idx + 1:02d}_{first_tag}" + + # 프롬프트 결합 + full_prompt = f"{base_prompt}, {line}" + positive_cond = self.encode_prompt(clip, full_prompt) + + # latent 복사 + latent_samples = latent["samples"].clone() + + # 1단계: 기본 샘플링 + samples = self.do_sample( + model, seed, steps, cfg, + 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) + + # 2단계: 업스케일 + if enable_upscale and upscale_model is not None: + # 업스케일 모델 적용 + upscaled = self.upscale_with_model(upscale_model, image) + + # 64배수로 리사이즈 + resized = self.resize_to_64(upscaled, scale_factor) + + # VAE 인코딩 → 2차 샘플링 → 디코딩 + latent_up = self.encode_vae(vae, resized) + + samples_up = self.do_sample( + model, seed, upscale_steps, upscale_cfg, + 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) + + # 저장 + self.save_image(image, save_prefix, filename, counter) + all_images.append(image) + + print(f"[MultiPrompt] Complete! Generated {len(all_images)} 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 +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "MultiPromptGenerator": "Multi Prompt Generator" +}