"""Model-family separated T2I UI over the ComfyUI backend (ZeroGPU Space). Workflow: Comfy-Org/workflow_templates image_anima_base_v1.json Models: Anima collection + Illustrious XL v2.0. The public ``generate_image`` endpoint intentionally keeps the original input order so existing Gradio API and MCP clients continue to work. """ import os import random import re import shutil import subprocess import sys import threading from pathlib import Path from int8_backend import INT8_ANIMA_MODELS, MODEL_ALIASES, load_anima_model, make_int8_loader from model_samples import build_model_samples from jujo_loras import ( CATALOGS as JUJO_CATALOGS, JUJO_ENTRIES, NONE_LORA, jujo_credits, jujo_label, lora_selected, pony_selection_notice, ) os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") import spaces # noqa: E402 COMFYUI_PATH = os.environ.get("COMFYUI_PATH", os.path.join(os.getcwd(), "ComfyUI")) MODELS_ROOT = os.path.join(COMFYUI_PATH, "models") CACHE_DIR = os.path.join(os.getcwd(), "hf_cache") # A Hugging Face model-repository volume can be mounted at this path. The # Space still works without the volume: missing files fall back to the normal # Hub download path below. Keep the mount read-only and create symlinks into # ComfyUI so the large private collection is never copied into the container. MODEL_VOLUME_ROOT = os.environ.get("MODEL_VOLUME_ROOT", "/mnt/models") COMFYUI_REPO = "https://github.com/Comfy-Org/ComfyUI.git" # Pinned to ComfyUI v0.33.1. COMFYUI_COMMIT = "72865f4f27eaf5396f8f36370e0a2be3a9a090ee" def ensure_comfyui() -> None: """Install exactly the pinned ComfyUI commit.""" nodes_py = os.path.join(COMFYUI_PATH, "nodes.py") # Reuse an existing checkout only when it matches the pinned commit. if os.path.isfile(nodes_py): try: current_commit = subprocess.check_output( ["git", "-C", COMFYUI_PATH, "rev-parse", "HEAD"], text=True, ).strip() if current_commit == COMFYUI_COMMIT: print(f"Using pinned ComfyUI commit: {current_commit}", flush=True) return print( f"ComfyUI commit mismatch: {current_commit} != {COMFYUI_COMMIT}; reinstalling.", flush=True, ) except (subprocess.CalledProcessError, OSError): print("Could not verify existing ComfyUI checkout; reinstalling.", flush=True) shutil.rmtree(COMFYUI_PATH, ignore_errors=True) elif os.path.isdir(COMFYUI_PATH): # Remove an incomplete checkout left by a failed startup. shutil.rmtree(COMFYUI_PATH, ignore_errors=True) os.makedirs(COMFYUI_PATH, exist_ok=True) # Fetch only the pinned commit instead of following the latest master. subprocess.run(["git", "init", COMFYUI_PATH], check=True) subprocess.run( ["git", "-C", COMFYUI_PATH, "remote", "add", "origin", COMFYUI_REPO], check=True, ) subprocess.run( ["git", "-C", COMFYUI_PATH, "fetch", "--depth", "1", "origin", COMFYUI_COMMIT], check=True, ) subprocess.run( ["git", "-C", COMFYUI_PATH, "checkout", "--detach", "FETCH_HEAD"], check=True, ) current_commit = subprocess.check_output( ["git", "-C", COMFYUI_PATH, "rev-parse", "HEAD"], text=True, ).strip() if current_commit != COMFYUI_COMMIT: raise RuntimeError( f"Unexpected ComfyUI commit: {current_commit}; expected {COMFYUI_COMMIT}" ) print(f"Pinned ComfyUI commit loaded: {current_commit}", flush=True) ensure_comfyui() if COMFYUI_PATH not in sys.path: sys.path.insert(0, COMFYUI_PATH) import comfy.options # noqa: E402 comfy.options.enable_args_parsing() import numpy as np # noqa: E402 import torch # noqa: E402 torch.set_grad_enabled(False) from huggingface_hub import hf_hub_download # noqa: E402 from comfy import model_management # noqa: E402 from comfy import sample as comfy_sample # noqa: E402 from nodes import ( # noqa: E402 CheckpointLoaderSimple, CLIPLoader, CLIPSetLastLayer, CLIPTextEncode, EmptyLatentImage, LoraLoaderModelOnly, UNETLoader, VAEDecode, VAELoader, ) # -------------------------------------------------------------------------- # Models # -------------------------------------------------------------------------- COLLECTION_REPO = "yamiuchi/civitai-collections" COMPANION_REPO = "circlestone-labs/Anima" ILLUSTRIOUS_REPO = "OnomaAIResearch/Illustrious-XL-v2.0" HYPER_SD_REPO = "ByteDance/Hyper-SD" # Illustrious checkpoints are SDXL-based. Their embedded VAE is the default, # with compatible external VAEs available as optional WebUI alternatives. # Anima keeps using its Qwen VAE below. SDXL_VAE_REPO = "stabilityai/sdxl-vae" MODEL_CHOICES = [ # 通常版は容量削減のため取得・ロードしない。旧名のAPI呼び出しはINT8版へ転送。 # ("anima-turbo-v1.1.safetensors", COMPANION_REPO, "split_files/diffusion_models", 4.18), ("anima-turbo-v1.1_INT8.safetensors", COLLECTION_REPO, "checkpoints/anima", 2.424), # ("anima-base-v1.0.safetensors", COMPANION_REPO, "split_files/diffusion_models", 4.18), # ("anima-aesthetic-v1.1.safetensors", COMPANION_REPO, "split_files/diffusion_models", 4.18), ("anima-aestheticV11_INT8.safetensors", COLLECTION_REPO, "checkpoints/anima", 2.424), # ("Anima-2.9B-preview-v1.safetensors", "Gazingstars123/Anima-2.9B", "", 5.84), # ("anima_pencil-v2.1.0.safetensors", COLLECTION_REPO, "checkpoints/anima", 4.18), # ("fnMixAnimaTurbo_baseNoTurbo.safetensors", COLLECTION_REPO, "checkpoints/anima", 4.18), ("fnMixAnimaTurbo_baseNoTurbo_INT8.safetensors", COLLECTION_REPO, "checkpoints/anima", 2.424), ("fwaunAnima_v11_INT8.safetensors", COLLECTION_REPO, "checkpoints/anima", 2.424), # ("tekitoMix_v32.safetensors", COLLECTION_REPO, "checkpoints/anima", 4.182), ("tekitoMix_v32_INT8.safetensors", COLLECTION_REPO, "checkpoints/anima", 2.424), # ("anima-kirazuri-v4.safetensors", "motimalu/kirazuri-anima", "split_files/diffusion_models", 4.18), ] MODEL_FILES = [fname for fname, _, _, _ in MODEL_CHOICES] ILLUSTRIOUS_MODEL_CHOICES = [ ("waiIllustriousSDXL_v170.safetensors", COLLECTION_REPO, "checkpoints/sdxl", 6.94), ("kiwimixXL_v3.safetensors", COLLECTION_REPO, "checkpoints/sdxl", 6.94), ("mhillustrious_v10.safetensors", COLLECTION_REPO, "checkpoints/sdxl", 6.94), ("zukiAnimeILL_v50.safetensors", COLLECTION_REPO, "checkpoints/sdxl", 7.11), # ("Illustrious-XL-v2.0.safetensors", ILLUSTRIOUS_REPO, "", 6.94), ] ILLUSTRIOUS_MODEL_FILES = [fname for fname, _, _, _ in ILLUSTRIOUS_MODEL_CHOICES] # Accept legacy filenames in API/MCP requests without downloading BF16 copies. ALL_MODEL_FILES = MODEL_FILES + ILLUSTRIOUS_MODEL_FILES + list(MODEL_ALIASES) LORA_FILES = [ ("Anima-P3-Aesthetics-Details_v1.0.safetensors", "lora/anima"), ("anima-base-1-masterpiece-v51.safetensors", "lora/anima"), ("anima-highres-aesthetic-boost.safetensors", "lora/anima"), ("gpt-image-2_anima-base1_v1.safetensors", "lora/anima"), ("anima-turbo-lora-v0.2.safetensors", "lora/anima"), ("Turbo-ANIMA-v2.safetensors", "lora/anima"), ] # Keep family lists separate even though ComfyUI stores all LoRAs in one # directory. This prevents accidentally applying an Anima LoRA to an SDXL / # Illustrious checkpoint from the WebUI. ILLUSTRIOUS_LORA_FILES: list[tuple[str, str, str]] = [ ("Detail_Tweaker_Illustrious_BSY_V3.safetensors", "Stableyogi/Detail-Tweaker-Illustrious", "",), ("dmd2_sdxl_4step_lora.safetensors", COLLECTION_REPO, "lora/sdxl"), ("Hyper-SDXL-8steps-CFG-lora.safetensors", HYPER_SD_REPO, ""), ] # Embeddings are kept separate from LoRAs and are presented only on the # Illustrious page. The repository folders document the checkpoint family; # ComfyUI itself loads the files from one flat ``models/embeddings`` folder. ILLUSTRIOUS_EMBEDDING_FILES: list[tuple[str, str, str]] = [ ("negativeXL_D.safetensors", "embeddings/sdxl", "SDXL / Illustrious"), ("negativeXL.safetensors", "embeddings/sdxl", "SDXL"), ("EasyNegativeV2.safetensors", "embeddings/sd1.5", "SD1.5"), ("bad-hands-5.pt", "embeddings/sd1.5", "SD1.5"), ] TEXT_ENCODER = ("qwen_3_06b_base.safetensors", "split_files/text_encoders") VAE_FILE = ("qwen_image_vae.safetensors", "split_files/vae") ILLUSTRIOUS_VAE_FILES = [ ("sdxl_vae.safetensors", SDXL_VAE_REPO, ""), ("illustriousXLV10_v10.safetensors", COLLECTION_REPO, "vae/sdxl"), ] CHECKPOINT_VAE_OPTION = "__checkpoint_embedded__" ILLUSTRIOUS_VAE_CHOICES = [ ("Checkpoint内蔵VAE(推奨)", CHECKPOINT_VAE_OPTION), ("SDXL VAE(Stability AI)", "sdxl_vae.safetensors"), ("Illustrious XL v1.0 VAE(バランス)", "illustriousXLV10_v10.safetensors"), ] # -------------------------------------------------------------------------- # Prompt macro / replacement rules # -------------------------------------------------------------------------- # 1) # Expands KEY using the PROMPT_MACROS mapping below. # Unknown keys are left unchanged as-is. # # 2) # Replaces the full tag with the inner TEXT. # Example: "" -> "blue eyes" # # 3) POSITIVE_PROMPT_REPLACE_RULES / NEGATIVE_PROMPT_REPLACE_RULES # Additional configurable word/phrase replacements applied after the # inline macro/replace expansion step. # # Example: # PROMPT_MACROS = { # "QUALITY": "masterpiece, best quality, score_9, score_8, score_7", # "OUTDOOR": "outdoors, blue sky, sunlight", # } # # POSITIVE_PROMPT_REPLACE_RULES = [ # {"from": "high quality", "to": "best quality", "ignore_case": True, "word_boundary": False}, # ] # # NEGATIVE_PROMPT_REPLACE_RULES = [ # {"from": "lowres", "to": "worst quality", "ignore_case": True, "word_boundary": True}, # ] PROMPT_MACROS = { # "QUALITY": "masterpiece, best quality, score_9, score_8, score_7", # "OUTDOOR": "outdoors, blue sky, sunlight", } POSITIVE_PROMPT_REPLACE_RULES = [ ] NEGATIVE_PROMPT_REPLACE_RULES = [ ] MACRO_TAG_PATTERN = re.compile(r"]+)>") INLINE_REPLACE_TAG_PATTERN = re.compile(r"]+)>") def apply_macro_tags(text: str, macro_map: dict[str, str]) -> str: """Expand tags using a predefined mapping.""" if not text: return text def repl(match: re.Match) -> str: key = match.group(1).strip() return str(macro_map.get(key, match.group(0))) return MACRO_TAG_PATTERN.sub(repl, text) def apply_inline_replace_tags(text: str) -> str: """Convert tags into TEXT.""" if not text: return text def repl(match: re.Match) -> str: return match.group(1).strip() return INLINE_REPLACE_TAG_PATTERN.sub(repl, text) def apply_replacement_rules(text: str, rules: list[dict]) -> str: """Apply configured replacement rules to a prompt string.""" if not text: return text # Longer source strings are applied first to reduce accidental partial overlaps. sorted_rules = sorted(rules, key=lambda x: len(str(x.get("from", ""))), reverse=True) for rule in sorted_rules: src = str(rule.get("from", "")) dst = str(rule.get("to", "")) if not src: continue pattern = re.escape(src) if rule.get("word_boundary", False): pattern = r"\b" + pattern + r"\b" flags = re.IGNORECASE if rule.get("ignore_case", True) else 0 text = re.sub(pattern, dst, text, flags=flags) return text def normalize_prompt_text(text: str, rules: list[dict]) -> str: """Apply inline macro expansion and configured replacements.""" if not text: return text text = apply_macro_tags(text, PROMPT_MACROS) text = apply_inline_replace_tags(text) text = apply_replacement_rules(text, rules) return text def ensure_model_file( repo_id: str, subfolder: str, filename: str, dest_dir: str, *, min_size: int = 1_000_000, revision: str | None = None, ) -> str: """Download a model file into a ComfyUI models/ subdir (idempotent).""" dest = os.path.join(dest_dir, filename) if os.path.isfile(dest) and os.path.getsize(dest) > min_size: return dest # Prefer the persistent, read-only model volume for the private collection. # The repository layout is mirrored below /mnt/models (for example, # /mnt/models/checkpoints/sdxl/foo.safetensors). Symlinking keeps the # container layer and startup disk usage small while preserving ComfyUI's # expected flat model directories. volume_src = None if repo_id == COLLECTION_REPO: candidate = os.path.join(MODEL_VOLUME_ROOT, subfolder or "", filename) if os.path.isfile(candidate): volume_src = candidate if volume_src: os.makedirs(dest_dir, exist_ok=True) if os.path.lexists(dest) and not os.path.isfile(dest): os.unlink(dest) if not os.path.lexists(dest): try: os.symlink(volume_src, dest) except OSError as exc: raise RuntimeError( f"Could not link model volume file {volume_src} to {dest}." ) from exc print(f"Using mounted model volume: {volume_src}", flush=True) return dest os.makedirs(CACHE_DIR, exist_ok=True) os.makedirs(dest_dir, exist_ok=True) src = hf_hub_download( repo_id=repo_id, subfolder=subfolder or None, filename=filename, revision=revision, local_dir=CACHE_DIR, token=os.environ.get("HF_TOKEN") or None, ) if not os.path.isfile(dest): try: os.replace(src, dest) except OSError: shutil.copy2(src, dest) return dest def ensure_models() -> None: te_name, te_sub = TEXT_ENCODER vae_name, vae_sub = VAE_FILE ensure_model_file(COMPANION_REPO, te_sub, te_name, os.path.join(MODELS_ROOT, "text_encoders")) ensure_model_file(COMPANION_REPO, vae_sub, vae_name, os.path.join(MODELS_ROOT, "vae")) for ill_vae_name, ill_vae_repo, ill_vae_sub in ILLUSTRIOUS_VAE_FILES: ensure_model_file( ill_vae_repo, ill_vae_sub, ill_vae_name, os.path.join(MODELS_ROOT, "vae"), min_size=1_000_000, ) for fname, repo, sub, _ in MODEL_CHOICES: ensure_model_file(repo, sub, fname, os.path.join(MODELS_ROOT, "diffusion_models")) for fname, sub in LORA_FILES: ensure_model_file(COLLECTION_REPO, sub, fname, os.path.join(MODELS_ROOT, "loras")) for fname, repo, sub, _ in ILLUSTRIOUS_MODEL_CHOICES: ensure_model_file(repo, sub, fname, os.path.join(MODELS_ROOT, "checkpoints")) for fname, repo, sub in ILLUSTRIOUS_LORA_FILES: ensure_model_file(repo, sub, fname, os.path.join(MODELS_ROOT, "loras")) for fname, sub, _family in ILLUSTRIOUS_EMBEDDING_FILES: ensure_model_file( COLLECTION_REPO, sub, fname, os.path.join(MODELS_ROOT, "embeddings"), min_size=0, ) ensure_models() def get_value_at_index(obj, index: int): try: return obj[index] except KeyError: return obj["result"][index] def list_loras() -> list[str]: lora_dir = os.path.join(MODELS_ROOT, "loras") return sorted(f for f in os.listdir(lora_dir) if f.endswith(".safetensors")) if os.path.isdir(lora_dir) else [] # -------------------------------------------------------------------------- # Load models at module scope so ZeroGPU packs them at startup. # -------------------------------------------------------------------------- unet_loader = UNETLoader() int8_loader = make_int8_loader(COMFYUI_PATH) if INT8_ANIMA_MODELS.intersection(MODEL_FILES) else None UNETS = { fname: load_anima_model(fname, unet_loader, int8_loader, os.path.join(MODELS_ROOT, "diffusion_models")) for fname, _, _, _ in MODEL_CHOICES } checkpoint_loader = CheckpointLoaderSimple() ILLUSTRIOUS_CHECKPOINTS = { fname: checkpoint_loader.load_checkpoint(ckpt_name=fname) for fname, _, _, _ in ILLUSTRIOUS_MODEL_CHOICES } clip_loader = CLIPLoader() CLIP = clip_loader.load_clip(clip_name=TEXT_ENCODER[0], type="stable_diffusion") vae_loader = VAELoader() VAE = vae_loader.load_vae(vae_name=VAE_FILE[0]) ILLUSTRIOUS_VAES = { vae_name: vae_loader.load_vae(vae_name=vae_name) for vae_name, _repo, _subfolder in ILLUSTRIOUS_VAE_FILES } lora_loader = LoraLoaderModelOnly() text_encode = CLIPTextEncode() clip_set_last_layer = CLIPSetLastLayer() empty_latent = EmptyLatentImage() vae_decode = VAEDecode() model_management.load_models_gpu( [ getattr(get_value_at_index(unet, 0), "patcher", get_value_at_index(unet, 0)) for unet in UNETS.values() ] + [ getattr(get_value_at_index(CLIP, 0), "patcher", get_value_at_index(CLIP, 0)), getattr(get_value_at_index(VAE, 0), "patcher", get_value_at_index(VAE, 0)), ] + [ getattr(get_value_at_index(checkpoint, index), "patcher", get_value_at_index(checkpoint, index)) for checkpoint in ILLUSTRIOUS_CHECKPOINTS.values() # Keep VAEs off this startup preload list. The selected embedded or # external Illustrious VAE is moved as needed by the decode path. for index in (0, 1) ] ) # -------------------------------------------------------------------------- # Inference # -------------------------------------------------------------------------- import gradio as gr # noqa: E402 # needed for gr.Progress default arg below from tag_completion import prompt_with_completion # noqa: E402 from jujo_browser import build_jujo_browser # noqa: E402 from browser_settings import bind_browser_settings # noqa: E402 from generation_stats import stats as generation_stats # noqa: E402 from admin_stats import build_admin_statistics # noqa: E402 generation_lock = threading.Lock() def model_volume_status() -> str: if os.path.isdir(MODEL_VOLUME_ROOT): return f"mounted at `{MODEL_VOLUME_ROOT}` (volume-first for {COLLECTION_REPO})" return "not mounted (Hub download fallback)" STATIC_INFO = ( f"**Loaded models:** {len(MODEL_CHOICES)}x Anima + " f"{len(ILLUSTRIOUS_MODEL_CHOICES)}x Illustrious checkpoints " f"(~{sum(size for _, _, _, size in MODEL_CHOICES + ILLUSTRIOUS_MODEL_CHOICES) + 1.19 + 0.25:.0f} GB, excluding LoRAs)\n" "**Prompt weighting:** supported, e.g. `(tag)`, `(tag:1.2)`, `(tag:0.7)`.\n" f"**Model volume:** {model_volume_status()}" ) MODEL_FAMILY = { **{name: "anima" for name in MODEL_FILES}, **{name: "illustrious" for name in ILLUSTRIOUS_MODEL_FILES}, } FAMILY_LORA_NAMES = { "anima": {name for name, _ in LORA_FILES} | set(JUJO_ENTRIES["anima"]), "illustrious": {name for name, _, _ in ILLUSTRIOUS_LORA_FILES} | set(JUJO_ENTRIES["illustrious"]), } EMBEDDING_PATHS = { name: os.path.join(MODELS_ROOT, "embeddings", name) for name, _subfolder, _family_label in ILLUSTRIOUS_EMBEDDING_FILES } FAMILY_EMBEDDING_NAMES = { "anima": set(), "illustrious": set(EMBEDDING_PATHS), } def model_components(model_name: str, vae_name: str | None = None): """Return model, CLIP, selected VAE, family and displayed VAE name.""" family = MODEL_FAMILY.get(model_name) if family == "anima": return ( get_value_at_index(UNETS[model_name], 0), get_value_at_index(CLIP, 0), get_value_at_index(VAE, 0), family, VAE_FILE[0], ) if family == "illustrious": checkpoint = ILLUSTRIOUS_CHECKPOINTS[model_name] clip = get_value_at_index( clip_set_last_layer.set_last_layer( clip=get_value_at_index(checkpoint, 1), stop_at_clip_layer=-2, ), 0, ) selected_vae_name = vae_name or CHECKPOINT_VAE_OPTION if selected_vae_name == CHECKPOINT_VAE_OPTION: selected_vae = get_value_at_index(checkpoint, 2) displayed_vae_name = "Checkpoint内蔵VAE" elif selected_vae_name in ILLUSTRIOUS_VAES: selected_vae = get_value_at_index(ILLUSTRIOUS_VAES[selected_vae_name], 0) displayed_vae_name = selected_vae_name else: raise gr.Error( f"{selected_vae_name} is not registered as an Illustrious VAE." ) return ( get_value_at_index(checkpoint, 0), clip, selected_vae, family, displayed_vae_name, ) raise gr.Error(f"Unknown checkpoint: {model_name}") # GPU stats are only real inside the ZeroGPU worker, so they are fetched at # the end of each generation rather than at startup. def gpu_stats() -> str: try: name = torch.cuda.get_device_name(0) free, total = torch.cuda.mem_get_info(0) used = total - free except Exception as exc: return f"**GPU:** unavailable ({exc})" return ( f"**GPU:** {name}\n" f"**VRAM:** {used / 1e9:.1f} GB used / {total / 1e9:.1f} GB total ({free / 1e9:.1f} GB free)" ) def apply_negative_embeddings( negative_prompt: str, family: str, embedding_names: list[str] | None, ) -> tuple[str, list[str]]: """Prefix selected ComfyUI embedding references to a negative prompt.""" if not embedding_names: return negative_prompt, [] allowed = FAMILY_EMBEDDING_NAMES.get(family, set()) selected: list[str] = [] for name in embedding_names: if not name: continue if name not in allowed: raise gr.Error( f"{name} is not registered as an embedding for the {family} page." ) if not os.path.isfile(EMBEDDING_PATHS[name]): raise gr.Error( f"Embedding file is not available: {name}. " "Restart the Space to download the registered assets." ) if name not in selected: selected.append(name) if not selected: return negative_prompt, [] existing = negative_prompt or "" tokens = [f"embedding:{name}" for name in selected] missing_tokens = [token for token in tokens if token not in existing] if not missing_tokens: return existing, selected prefix = ", ".join(missing_tokens) effective = f"{prefix}, {existing}" if existing.strip() else prefix return effective, selected @spaces.GPU(duration=int(os.environ.get("GPU_DURATION", "30"))) @torch.inference_mode() def _generate_image_core( model_name: str, prompt: str, negative_prompt: str, width: int, height: int, seed: int, steps: int, cfg: float, sampler_name: str, scheduler: str, enable_lora: bool, lora_name: str, lora_strength: float, enable_lora_2: bool, lora_name_2: str, lora_strength_2: float, enable_lora_3: bool, lora_name_3: str, lora_strength_3: float, enable_lora_4: bool, lora_name_4: str, lora_strength_4: float, embedding_names: list[str] | None, vae_name: str | None, progress: gr.Progress = gr.Progress(track_tqdm=False), ) -> tuple[np.ndarray, str, str, str]: """ Generate one image using an Anima or Illustrious checkpoint. Returns: Generated image, base generation information, effective positive prompt, and effective negative prompt. """ width = max(512, min(1536, int(width) // 16 * 16)) height = max(512, min(1536, int(height) // 16 * 16)) seed = int(seed) if int(seed) >= 0 else random.randint(1, 2**63) steps = max(1, min(60, int(steps))) cfg = float(max(1.0, min(8.0, cfg))) lora_slots = [ (1, bool(enable_lora), lora_name, float(lora_strength)), (2, bool(enable_lora_2), lora_name_2, float(lora_strength_2)), (3, bool(enable_lora_3), lora_name_3, float(lora_strength_3)), (4, bool(enable_lora_4), lora_name_4, float(lora_strength_4)), ] model, clip, vae, family, displayed_vae_name = model_components(model_name, vae_name) applied_loras: list[str] = [] for slot_no, enabled, current_lora_name, current_lora_strength in lora_slots: if not lora_selected(enabled, current_lora_name): continue if current_lora_name not in FAMILY_LORA_NAMES[family]: raise gr.Error( f"{current_lora_name} is not registered as a {family} LoRA. " "Select a LoRA from the same model-family page." ) current_lora_strength = max(0.0, min(2.0, current_lora_strength)) model = get_value_at_index( lora_loader.load_lora_model_only( model=model, lora_name=current_lora_name, strength_model=current_lora_strength, ), 0, ) applied_loras.append(f"{slot_no}:{current_lora_name}@{current_lora_strength:g}") effective_prompt = normalize_prompt_text(prompt, POSITIVE_PROMPT_REPLACE_RULES) effective_negative_prompt = normalize_prompt_text(negative_prompt, NEGATIVE_PROMPT_REPLACE_RULES) effective_negative_prompt, applied_embeddings = apply_negative_embeddings( effective_negative_prompt, family, embedding_names, ) positive = text_encode.encode(text=effective_prompt, clip=clip) negative = text_encode.encode(text=effective_negative_prompt, clip=clip) latent = empty_latent.generate(width=width, height=height, batch_size=1) lat = get_value_at_index(latent, 0) latent_image = comfy_sample.fix_empty_latent_channels( model, lat["samples"], lat.get("downscale_ratio_spacial", None), lat.get("downscale_ratio_temporal", None), ) progress(0, desc="Generating") def step_cb(step: int, _denoised, _x, total_steps: int) -> None: progress((step, total_steps), desc=f"Sampling {step + 1}/{total_steps}") samples = comfy_sample.sample( model=model, noise=comfy_sample.prepare_noise(latent_image, seed, None), steps=steps, cfg=cfg, sampler_name=sampler_name, scheduler=scheduler, positive=get_value_at_index(positive, 0), negative=get_value_at_index(negative, 0), latent_image=latent_image, denoise=1.0, callback=step_cb, disable_pbar=True, seed=seed, ) sampled = {"samples": samples} progress(1, desc="Decoding") decoded = vae_decode.decode(samples=sampled, vae=vae) image = get_value_at_index(decoded, 0)[0] img_np = ( torch.nan_to_num(image, nan=0.0, posinf=1.0, neginf=0.0) .mul(255) .clamp_(0, 255) .byte() .cpu() .numpy() ) base_info = ( STATIC_INFO + f"\n{gpu_stats()}" + f"\n**Last run:** {family.title()} / {model_name} | {width}x{height} | {steps} steps | CFG {cfg} | seed {seed}" + f"\n**Sampler:** {sampler_name} | **Scheduler:** {scheduler}" + f"\n**VAE:** {displayed_vae_name}" + f"\n**LoRA:** {', '.join(applied_loras) if applied_loras else 'disabled'}" + f"\n**Negative Embeddings:** {', '.join(applied_embeddings) if applied_embeddings else 'disabled'}" ) return img_np, base_info, effective_prompt, effective_negative_prompt def serialized_generate(*args, **kwargs): """Serialize model generation before entering the ZeroGPU decorator.""" with generation_lock: # Both callers use the unchanged generation argument order. Prepare # optional author LoRAs on CPU, before requesting the ZeroGPU worker. args = (MODEL_ALIASES.get(args[0], args[0]), *args[1:]) model_name = args[0] family = MODEL_FAMILY.get(model_name) for slot in range(4): enabled, name, _strength = args[10 + slot * 3:13 + slot * 3] if not lora_selected(enabled, name): continue if name not in FAMILY_LORA_NAMES.get(family, set()): raise gr.Error(f"{name} is not registered as a {family} LoRA.") entry = JUJO_ENTRIES[family].get(name) if entry: catalog = JUJO_CATALOGS[family] progress = args[-1] progress(0, desc=f"LoRAを準備中: {name}(初回はダウンロード)") ensure_model_file( catalog["repo"], entry["path"].rsplit("/", 1)[0] if "/" in entry["path"] else "", name, os.path.join(MODELS_ROOT, "loras"), min_size=1_000_000, revision=catalog["revision"], ) result = _generate_image_core(*args, **kwargs) # This runs in the parent process after the ZeroGPU call returns. # Only a tiny in-memory update is performed, shared by UI and MCP. if result is not None and result[0] is not None: generation_stats.record(family, model_name) return result def generate_image_ui( model_name: str, vae_name: str | None, prompt: str, negative_prompt: str, width: int, height: int, seed: int, steps: int, cfg: float, sampler_name: str, scheduler: str, enable_lora: bool, lora_name: str, lora_strength: float, enable_lora_2: bool, lora_name_2: str, lora_strength_2: float, enable_lora_3: bool, lora_name_3: str, lora_strength_3: float, enable_lora_4: bool, lora_name_4: str, lora_strength_4: float, embedding_enable_1: bool = False, embedding_enable_2: bool = False, embedding_enable_3: bool = False, embedding_enable_4: bool = False, progress: gr.Progress = gr.Progress(track_tqdm=False), ) -> tuple[np.ndarray, str]: img_np, base_info, effective_prompt, effective_negative_prompt = serialized_generate( model_name, prompt, negative_prompt, width, height, seed, steps, cfg, sampler_name, scheduler, enable_lora, lora_name, lora_strength, enable_lora_2, lora_name_2, lora_strength_2, enable_lora_3, lora_name_3, lora_strength_3, enable_lora_4, lora_name_4, lora_strength_4, [ name for enabled, name in zip( (embedding_enable_1, embedding_enable_2, embedding_enable_3, embedding_enable_4), [item[0] for item in ILLUSTRIOUS_EMBEDDING_FILES], ) if enabled ], vae_name, progress, ) gui_info = ( base_info + f"\n**Effective Prompt:** `{effective_prompt}`" + f"\n**Effective Negative Prompt:** `{effective_negative_prompt}`" ) return img_np, gui_info def generate_image( model_name: str, prompt: str, negative_prompt: str, width: int, height: int, seed: int, steps: int, cfg: float, sampler_name: str, scheduler: str, enable_lora: bool, lora_name: str, lora_strength: float, enable_lora_2: bool, lora_name_2: str, lora_strength_2: float, enable_lora_3: bool, lora_name_3: str, lora_strength_3: float, enable_lora_4: bool, lora_name_4: str, lora_strength_4: float, progress: gr.Progress = gr.Progress(track_tqdm=False), ) -> tuple[np.ndarray, str]: img_np, base_info, _effective_prompt, _effective_negative_prompt = serialized_generate( model_name, prompt, negative_prompt, width, height, seed, steps, cfg, sampler_name, scheduler, enable_lora, lora_name, lora_strength, enable_lora_2, lora_name_2, lora_strength_2, enable_lora_3, lora_name_3, lora_strength_3, enable_lora_4, lora_name_4, lora_strength_4, None, None, progress, ) return img_np, base_info # -------------------------------------------------------------------------- # UI # -------------------------------------------------------------------------- RESOLUTIONS = { "1:1 (1024x1024)": (1024, 1024), "2:3 (832x1216)": (832, 1216), "3:2 (1216x832)": (1216, 832), "3:4 (896x1152)": (896, 1152), "4:3 (1152x896)": (1152, 896), "9:16 (768x1344)": (768, 1344), "16:9 (1344x768)": (1344, 768), } SAMPLERS = ["er_sde", "euler", "euler_ancestral", "dpmpp_2m", "dpmpp_2m_sde", "dpmpp_2m_sde_gpu", "dpmpp_sde", "heun", "ddim", "lcm", "uni_pc"] SCHEDULERS = ["simple", "normal", "karras", "exponential", "sgm_uniform"] ANIMA_LORA_CHOICES = [name for name, _ in LORA_FILES] + list(JUJO_ENTRIES["anima"]) ILLUSTRIOUS_LORA_CHOICES = [name for name, _, _ in ILLUSTRIOUS_LORA_FILES] + list(JUJO_ENTRIES["illustrious"]) ALL_LORA_CHOICES = ANIMA_LORA_CHOICES + ILLUSTRIOUS_LORA_CHOICES ANIMA_PROMPT = "masterpiece, best quality, score_9, score_8, score_7," ANIMA_NEGATIVE = ( "worst quality, low quality, score_1, score_2, score_3, artist name, " "blurry, jpeg artifacts, chromatic aberration" ) ANIMA_USAGE_INFO = ( "## Animaの初期値と使い分け\n\n" "AnimaのCheckpointはすべて容量を削減したINT8版です。" "ComfyUI-INT8-Fastで読み込み、対応する通常版と同じプリセット・LoRA設定を使用します。" "INT8は高速生成用のTurbo化とは別の方式です。\n\n" "### 現在の初期値\n\n" "- **既定Checkpoint:** `anima-turbo-v1.1_INT8.safetensors`\n" "- **Steps:** 8\n" "- **CFG:** 1.0\n" "- **Sampler / Scheduler:** `er_sde` / `simple`\n" "- **LoRA:** 初期状態ではすべて無効。LoRA 1はP3 Aesthetics Details 0.8、" "LoRA 2はGPT-Image Anima Base 0.8を選択済み\n" "- **以前の初期設定を使う場合:** 「LoRA設定・追加モデル」を開き、" "LoRA 1・2のEnableを手動で有効にしてください(どちらもStrength 0.8)。\n\n" "既定CheckpointがTurbo版のため、初期値は短いステップ数を優先した" "公式Turbo設定です。下の生成プリセットは「Turboモデル(公式)」が既定です。\n\n" "### 推奨プリセット\n\n" "| 使用するCheckpoint | 推奨プリセット | 理由 |\n" "| --- | --- | --- |\n" "| `anima-turbo-v1.1_INT8.safetensors` | **Turboモデル(公式)** | 公式TurboモデルのINT8版のため、8 steps / CFG 1.0をそのまま使います。 |\n" "| `anima-turbo-v1.1_INT8.safetensors`以外のCheckpoint | **通常(非Turbo)** | まず30 steps / CFG 4.0でモデル本来の品質を確認します。名前にTurboを含むbaseNoTurbo版もここに含まれます。 |\n" "| 非Turboモデルで短時間生成を試す場合 | **非Turbo+Turbo LoRA** | 8 steps / CFG 1.0を試せますが、崩れやディテール不足が出る可能性があります。 |\n\n" "迷った場合は、Turboモデルには「Turboモデル(公式)」、非Turboモデルには「通常(非Turbo)」を選択してください。" "「非Turbo+Turbo LoRA」は、まず通常設定と比較しながら使用してください。\n\n" "Checkpointを変更してもプリセットは自動で切り替わりません。Turbo版には「Turboモデル(公式)」、" "それ以外のCheckpointには「通常(非Turbo)」を選択してください。\n\n" "### 非TurboモデルでTurbo-ANIMA-v2 LoRAを使う場合\n\n" "- **Steps:** 8\n" "- **CFG:** 1.0から開始\n" "- **Sampler / Scheduler:** `er_sde` / `simple`\n" "- **Turbo-ANIMA-v2:** Strength 1.0から開始\n" "- Turbo LoRAを使う場合は、まず短いステップ数で比較し、" "崩れやディテール不足があればStrengthを下げるか通常設定へ戻してください。\n\n" "### 非Turboモデルを通常設定で使う場合\n\n" "Anima公式READMEに記載された通常生成の参考値は、" "**30 steps / CFG 4 / `er_sde`・`simple`**です。" "この場合はTurbo-ANIMA-v2などのTurbo LoRAを無効にして比較してください。" ) ILLUSTRIOUS_USAGE_INFO = ( "## Illustriousの初期値と使い分け\n\n" "### 現在の初期値\n\n" "- **既定Checkpoint:** `waiIllustriousSDXL_v170.safetensors`\n" "- **既定プリセット:** WAI標準(高品質)\n" "- **解像度:** 1:1(1024×1024)\n" "- **Steps / CFG:** 30 / 5.0\n" "- **Sampler / Scheduler:** `euler_ancestral` / `normal`\n" "- **VAE:** Checkpoint内蔵VAE\n" "- **Embedding:** 初期状態はすべて無効。必要な項目を選択可能\n\n" "Illustriousでは通常品質を優先するWAI標準と、短いステップ数で生成するDMD2高速を" "別プリセットとして用意しています。上の「生成方式」で切り替えてください。\n\n" "### 推奨プリセット\n\n" "| 用途 | 推奨プリセット | 設定の目安 |\n" "| --- | --- | --- |\n" "| 品質優先・通常生成(すべてのIllustrious系Checkpointの開始点) | **WAI標準(高品質)** | 30 steps / CFG 5.0 / `euler_ancestral` / `normal` / DMD2無効 |\n" "| 速度優先・短時間生成 | **DMD2高速(8-step)** | 8 steps / CFG 1.6 / `lcm` / `normal` / DMD2 LoRA 0.7 |\n\n" "Checkpointを変更した場合も、まず上記プリセットから開始してください。モデルカードに固有の推奨値がある場合のみ、Steps・CFG・LoRAを調整します。\n\n" "### WAI標準(通常生成)\n\n" "- **Steps / CFG:** 30 / 5.0\n" "- **Sampler / Scheduler:** `euler_ancestral` / `normal`\n" "- **DMD2 LoRA:** 無効\n" "- **Prompt:** `masterpiece, best quality, amazing quality,` を初期値として使用\n" "- **Negative:** 品質・人体破綻・文字を抑える基本タグを初期値として使用\n\n" "> **注意:** CFGを高くしすぎたり、Negative側のタグを強く重み付けしすぎると、" "彩度・輪郭・構図が崩れることがあります。まずCFG 5.0と重み付けなしのNegativeから調整してください。\n\n" "通常のWAI設定では品質と安定性を優先します。まずこのプリセットでCheckpointごとの" "出力を確認し、必要に応じてプロンプトと解像度を調整してください。\n\n" "### DMD2高速(8-step)\n\n" "- **Steps / CFG:** 8 / 1.6(目安 1.4~1.8)\n" "- **Sampler / Scheduler:** `lcm` / `normal`\n" "- **LoRA 4:** `dmd2_sdxl_4step_lora.safetensors`、Strength 0.7\n" "- **他の高速化LoRA:** Hyper-SDXLなどは同時に有効化しない\n\n" "DMD2は短いステップ数と低めのCFGで使う専用設定です。WAI標準と同じSteps・CFGのまま" "LoRAだけを有効にする使い方は避け、プリセットで一式を切り替えてください。\n\n" "### VAE\n\n" "Illustriousページでは、Checkpoint内蔵VAEと2種類の外部SDXL VAEを選択できます。" "初期値はCheckpoint内蔵VAEです。VAEは最終的な画像の復号に使われ、色味・明暗・輪郭に" "差が出る場合があります。同じSeed・Prompt・生成設定のままVAEだけを変更して比較してください。\n\n" "- **Checkpoint内蔵VAE(初期値):** 各Checkpointに含まれるVAEを使用\n" "- **`sdxl_vae.safetensors`:** Stability AIのSDXL VAEを明示的に使用\n" "- **`illustriousXLV10_v10.safetensors`:** Illustrious XL v1.0向け。" "過度な彩度や色ずれを避けたい場合の比較候補\n\n" "SD1.5向けVAEはIllustrious / SDXLと互換ではないため、この一覧には表示しません。\n\n" "### Embeddings\n\n" "IllustriousページのEmbeddingは初期状態ではすべて無効です。必要な項目を選択すると、" "ComfyUIの`embedding:ファイル名`としてNegative promptの先頭に追加されます。" "Checkpointと同じSDXL / Illustrious系のEmbeddingだけを使用してください。\n\n" "| Embedding | 初期状態 | 対象 |\n" "| --- | --- | --- |\n" "| `negativeXL_D.safetensors` | **無効(選択可能)** | SDXL / Illustrious向け |\n" "| `negativeXL.safetensors` | 無効(選択可能) | SDXL向け |\n" "| `EasyNegativeV2.safetensors` | 無効(選択可能) | SD1.5向け・Illustriousでは非推奨 |\n" "| `bad-hands-5.pt` | 無効(選択可能) | SD1.5向け・Illustriousでは非推奨 |\n\n" "EmbeddingはNegative promptにだけ適用され、Positive promptやCheckpointは変更しません。" "手動で変更した値は、次にプリセットを選択するまで維持されます。" ) ANIMA_CREDITS = ( "### Checkpoint\n\n" "- `anima-turbo-v1.1_INT8.safetensors`、`anima-aestheticV11_INT8.safetensors` — " "[CircleStone Labs / Anima](https://huggingface.co/circlestone-labs/Anima)(CircleStone Labs / Comfy Org・INT8量子化版)\n" "- `fnMixAnimaTurbo_baseNoTurbo_INT8.safetensors` — " "[Fn-Mix Anima-Turbo(Civitai)](https://civitai.com/models/2719904)(INT8量子化版)\n" "- `fwaunAnima_v11_INT8.safetensors` — " "[Fwaun Anima v1.1 / fwaunstp733(Civitai)](https://civitai.com/models/2602206?modelVersionId=2954569)(INT8量子化版)\n" "- `tekitoMix_v32_INT8.safetensors` — " "[Tekito-Mix v3.2 / Minami_No(Civitai)](https://civitai.com/models/2639779/tekito-mix?modelVersionId=3265116)(INT8量子化版)\n" "- INT8ローダー: [ComfyUI-INT8-Fast / BobJohnson24(AGPL-3.0)](https://github.com/BobJohnson24/ComfyUI-INT8-Fast/tree/48a88b2fde88e986c6444fa1f51589b6089d04f3)\n\n" "### LoRA\n\n" "- `Anima-P3-Aesthetics-Details_v1.0.safetensors` — " "[Anima-P3-Aesthetics(RunningHub・確認できた配布ページ)](https://www.runninghub.ai/model/public/2061426010008150017)(ページ表示名: Openclaw2026)\n" "- `anima-base-1-masterpiece-v51.safetensors` — " "[Aesthetic Quality Modifiers - Masterpiece(Civitai)](https://civitai.com/models/929497/aesthetic-quality-modifiers-masterpiece)\n" "- `anima-highres-aesthetic-boost.safetensors` — " "[Anima Highres/Aesthetic Boost(Civitai)](https://civitai.com/models/2540444/anima-highresaesthetic-boost)\n" "- `gpt-image-2_anima-base1_v1.safetensors` — " "[Style Greater - GPT Image 2(Civitai)](https://civitai.com/models/2564225/style-greater-gpt-image-2)\n" "- `anima-turbo-lora-v0.2.safetensors` — " "[Anima Official LoRAs(Hugging Face)](https://huggingface.co/circlestone-labs/Anima-Official-LoRAs)\n" "- `Turbo-ANIMA-v2.safetensors` — " "[Turbo ANIMA v2(Civitai)](https://civitai.com/models/2619830?modelVersionId=3039549)\n\n" "### 共通コンポーネント\n\n" "- `qwen_3_06b_base.safetensors`、`qwen_image_vae.safetensors` — " "[CircleStone Labs / Anima](https://huggingface.co/circlestone-labs/Anima)\n" "PrivateコレクションはSpaceの取得用ミラーです。以下は原作者または公式配布元へのリンクです。\n\n" "配布条件・ライセンス・原作者の詳細は、各配布元のモデルカードを確認してください。" ) ILLUSTRIOUS_CREDITS = ( "### Checkpoint\n\n" "- `waiIllustriousSDXL_v170.safetensors` — " "[WAI0731 / WAI-illustrious-SDXL v17.0(Civitai)](https://civitai.com/models/827184/wai-illustrious-sdxl)\n" "- `kiwimixXL_v3.safetensors` — " "[KiwiMix XL v3(Civitai)](https://civitai.com/models/264709?modelVersionId=1075361)\n" "- `mhillustrious_v10.safetensors` — " "[mHIllustrious(Civitai)](https://civitai.com/models/1586012/mhillustrious?modelVersionId=1794770)\n" "- `zukiAnimeILL_v50.safetensors` — " "[zukiAnimeILL v5.0(Civitai)](https://civitai.com/models/949488?modelVersionId=1765723)\n\n" "### LoRA\n\n" "- `Detail_Tweaker_Illustrious_BSY_V3.safetensors` — " "[Detail Tweaker Illustrious By Stable Yogi v3.0(Civitai)](https://civitai.com/models/1016670/detailtweakerillustriousbystableyogi)\n" "- `Hyper-SDXL-8steps-CFG-lora.safetensors` — " "[ByteDance / Hyper-SD](https://huggingface.co/ByteDance/Hyper-SD)(ByteDance Hyper-SD team)\n" "- `dmd2_sdxl_4step_lora.safetensors` — " "[DMD2 Speed LoRA / SDXL Pony・Illustrious向け配布ページ(Civitai)](https://civitai.com/models/1608870/dmd2-speed-lora-sdxl-pony-illustrious?modelVersionId=1820705)\n\n" "### VAE\n\n" "- `sdxl_vae.safetensors` — " "[Stability AI / SDXL VAE(Hugging Face)](https://huggingface.co/stabilityai/sdxl-vae)\n" "- `illustriousXLV10_v10.safetensors` — " "[Illustrious XL v1.0 VAE(Civitai)](https://civitai.com/models/1693029/illustrious-xl-v10?modelVersionId=1916060)\n\n" "### Embeddings\n\n" "- `negativeXL_D.safetensors`、`negativeXL.safetensors` — " "[negativeXL D(Civitai)](https://civitai.com/models/118418?modelVersionId=134583)\n" "- `EasyNegativeV2.safetensors` — " "[EasyNegativeV2(Civitai)](https://civitai.com/models/100191/ti-easynegativev2-textual-inversion-embedding)\n" "- `bad-hands-5.pt` — " "[bad-hands-5(Civitai)](https://civitai.com/models/116230?modelVersionId=125849)\n\n" "PrivateコレクションはSpaceの取得用ミラーです。以下は原作者または公式配布元へのリンクです。\n\n" "配布条件・ライセンス・原作者の詳細は、各配布元のモデルカードを確認してください。" ) PROJECT_CREDITS = ( "### アプリのベース・拡張について\n\n" "**Anima Collection+ MCP** は、**Anima Collection** をベースにしたフォークです。" "まずMCPサーバ機能を追加し、その後Illustrious対応・複数モデル対応・お気に入りなどを拡張しました。" "「+」は元のAnima Collectionからの機能拡張を表します。\n\n" "### タグ検索の辞書\n\n" "- 英語タグ: [SD WebUI Tag Autocomplete / DominikDoom](https://github.com/DominikDoom/a1111-sd-webui-tagcomplete)(MIT)\n" "- 日本語訳: [booru-japanese-tag / boorutan](https://github.com/boorutan/booru-japanese-tag)(MIT・手動訳と機械訳)\n" "日本語辞書は機械訳を含み、タグの意味・モデルへの効果を保証しません。読み・同義語の補足CSVを追加しています。\n\n" ) ANIMA_CREDITS = PROJECT_CREDITS + ANIMA_CREDITS + jujo_credits("anima") ILLUSTRIOUS_CREDITS = PROJECT_CREDITS + ILLUSTRIOUS_CREDITS + jujo_credits("illustrious") ANIMA_PRESETS = { "turbo": { "label": "Turboモデル(公式)", "steps": 8, "cfg": 1.0, "sampler": "er_sde", "scheduler": "simple", "lora4_enable": False, "lora4_file": "Turbo-ANIMA-v2.safetensors", "lora4_strength": 1.0, "status": ( "**適用中: Turboモデル(公式)**\n\n" "`anima-turbo-v1.1_INT8`向けに、元の公式Turboモデルの推奨値に合わせた設定です。" "8 steps / CFG 1.0 / er_sde / simple、Turbo-ANIMA-v2は無効です。" ), }, "turbo_lora": { "label": "非Turbo+Turbo LoRA", "steps": 8, "cfg": 1.0, "sampler": "er_sde", "scheduler": "simple", "lora4_enable": True, "lora4_file": "Turbo-ANIMA-v2.safetensors", "lora4_strength": 1.0, "status": ( "**適用中: 非Turbo+Turbo LoRA**\n\n" "BaseまたはbaseNoTurbo系CheckpointでTurbo LoRAを試す設定です。" "8 steps / CFG 1.0 / er_sde / simple / Turbo-ANIMA-v2 1.0を適用します。" "公式Turboモデルとの併用は想定していません。" ), }, "standard": { "label": "通常(非Turbo)", "steps": 30, "cfg": 4.0, "sampler": "er_sde", "scheduler": "simple", "lora4_enable": False, "lora4_file": "Turbo-ANIMA-v2.safetensors", "lora4_strength": 1.0, "status": ( "**適用中: Anima通常設定**\n\n" "非Turboモデルの比較用設定です。" "30 steps / CFG 4.0 / er_sde / simpleにし、Turbo-ANIMA-v2を無効にしています。" ), }, } # WAI-illustrious-SDXL v17's published quality-tag and negative-tag defaults. # Keep the content prompt open for the user's own Danbooru tags instead of # forcing a subject such as ``1girl, solo`` into every generation. ILLUSTRIOUS_PROMPT = "masterpiece, best quality, amazing quality," ILLUSTRIOUS_NEGATIVE = ( "bad quality,worst quality,worst detail,sketch,censor,\n" "bad hands, extra arms, extra hands, extra fingers, long body, logo, emblem, text," ) ILLUSTRIOUS_PRESETS = { "wai": { "label": "WAI標準(高品質)", "steps": 30, "cfg": 5.0, "sampler": "euler_ancestral", "scheduler": "normal", "lora4_enable": False, "lora4_file": "dmd2_sdxl_4step_lora.safetensors", "lora4_strength": 0.7, "status": ( "**適用中: WAI標準**\n\n" "WAI Illustrious v17の通常生成向け設定です。DMD2 LoRAは無効です。" ), }, "dmd2": { "label": "DMD2高速(8-step)", "steps": 8, "cfg": 1.6, "sampler": "lcm", "scheduler": "normal", "lora4_enable": True, "lora4_file": "dmd2_sdxl_4step_lora.safetensors", "lora4_strength": 0.7, "status": ( "**適用中: DMD2高速**\n\n" "DMD2の推奨範囲(8 Steps / CFG 1.4~1.8 / LCM / LoRA 0.7)を適用しています。" ), }, } def build_generation_page( *, family_label: str, model_choices: list[str], lora_choices: list[str], prompt_default: str, negative_default: str, steps_default: int, cfg_default: float, sampler_default: str, scheduler_default: str, lora_defaults: list[tuple[bool, str, float]], resolution_default: str = "1:1 (1024x1024)", width_default: int = 1024, height_default: int = 1024, preset_specs: dict[str, dict] | None = None, preset_default: str | None = None, embedding_files: list[tuple[str, str, str]] | None = None, embedding_defaults: list[bool] | None = None, embedding_interactive: bool = True, vae_choices: list[tuple[str, str]] | None = None, vae_default: str | None = None, credits: str | None = None, ): """Build one private WebUI page with family-specific components.""" lora_available = bool(lora_choices) family = family_label.lower() dropdown_choices = [("---(未選択・適用しない)", NONE_LORA)] + [ (jujo_label(family, name), name) for name in lora_choices ] preset_input = None preset_status = None # Keep the storage bridge mounted even when its native
is shut. # Gradio Accordions lazily mount children and would delay restoration. settings_panel = gr.Column() if preset_specs: gr.Markdown("### 生成プリセット") preset_input = gr.Radio( label="生成方式", choices=[(spec["label"], key) for key, spec in preset_specs.items()], value=preset_default or next(iter(preset_specs)), ) preset_status = gr.Markdown( preset_specs[preset_default or next(iter(preset_specs))]["status"] + "\n\n_手動変更した値は、次にプリセットを選択するまで維持されます。_" ) with gr.Row(): with gr.Column(scale=1): model_input = gr.Dropdown( label=f"{family_label} Checkpoint", choices=model_choices, value=model_choices[0], ) if family == "anima": build_model_samples(model_input) if vae_choices: vae_input = gr.Dropdown( label="VAE", choices=vae_choices, value=vae_default or vae_choices[0][1], info=( "初期値はCheckpoint内蔵VAEです。色味を比較する場合のみ" "外部SDXL VAEへ切り替えてください。" ), ) else: vae_input = gr.State(value=None) prompt_input = prompt_with_completion( label="Prompt", lines=3, value=prompt_default, elem_id=f"{family_label.lower()}-positive-prompt", ) negative_input = prompt_with_completion( label="Negative prompt", lines=2, value=negative_default, elem_id=f"{family_label.lower()}-negative-prompt", ) if family_label == "Illustrious": gr.Markdown( "> **注意:** CFGを高くしすぎたり、Negative側のタグを強く重み付けしすぎると、" "画像の彩度・輪郭・構図が崩れることがあります。まずCFG 5.0と" "重み付けなしのNegativeから調整してください。" ) embedding_components = [] if embedding_files: gr.Markdown( "### Negative Embeddings" + ("(現在は無効)" if not embedding_interactive else "") ) gr.Markdown( ( "検証中のため現在はすべて無効化されています。" "再有効化するまでNegative promptには追加されません。" if not embedding_interactive else "選択したEmbeddingはNegative promptの先頭に自動適用されます。" "Checkpoint系統が一致するものだけを有効にしてください。" ) ) defaults = embedding_defaults or [False] * len(embedding_files) for index, (embedding_name, _subfolder, family_label_text) in enumerate( embedding_files ): available = os.path.isfile(EMBEDDING_PATHS.get(embedding_name, "")) embedding_components.append( gr.Checkbox( label=embedding_name, info=f"系統: {family_label_text}", value=bool(defaults[index]) and available and embedding_interactive, interactive=embedding_interactive and available, ) ) resolution = gr.Dropdown( label="Resolution", choices=list(RESOLUTIONS), value=resolution_default, ) with gr.Row(): width_input = gr.Slider(512, 1536, value=width_default, step=16, label="Width") height_input = gr.Slider(512, 1536, value=height_default, step=16, label="Height") with gr.Row(): seed_input = gr.Number(label="Seed (-1 = random)", value=-1, precision=0) steps_input = gr.Slider( label="Steps", minimum=6, maximum=60, value=steps_default, step=1 ) cfg_input = gr.Slider( label="CFG", minimum=1.0, maximum=8.0, value=cfg_default, step=0.1 ) with gr.Accordion("Sampler", open=False): sampler_input = gr.Dropdown( label="Sampler", choices=SAMPLERS, value=sampler_default ) scheduler_input = gr.Dropdown( label="Scheduler", choices=SCHEDULERS, value=scheduler_default ) with gr.Accordion("LoRA設定・追加モデル(最大4つ)", open=False, elem_classes=["lora-accordion"]): gr.Markdown( "**画風・目・表情などを調整するLoRAを選択できます。** " "`---`は未選択として扱い、適用しません。" "JujoHotaruの追加LoRAは、初回生成時に選択したファイルを取得します。" ) if not lora_available: gr.Markdown( f"No {family_label} LoRA is registered. Add compatible files to " "the family configuration before enabling a slot." ) lora_components = [] lora_enable_inputs = [] lora_name_inputs = [] lora_strength_inputs = [] for slot_index, (enabled, name, strength) in enumerate(lora_defaults, start=1): with gr.Row(): enable = gr.Checkbox( label=f"LoRA {slot_index} Enable", value=lora_selected(enabled, name) and lora_available, interactive=lora_available and name != NONE_LORA, ) lora_name = gr.Dropdown( label=f"LoRA {slot_index} File", choices=dropdown_choices, value=name if name in lora_choices else NONE_LORA, interactive=lora_available, ) lora_strength = gr.Slider( label=f"LoRA {slot_index} Strength", minimum=0.0, maximum=2.0, value=strength, step=0.05, interactive=lora_available and name != NONE_LORA, ) def update_lora_selection(selected, enabled): selected_file = bool(selected and selected != NONE_LORA) return ( gr.Checkbox(value=bool(enabled and selected_file), interactive=selected_file), gr.Slider(interactive=selected_file), ) # Only direct user edits should reset an empty slot. A # programmatic sample/preset update sets all three fields # together and must not be overwritten by this callback. lora_name.input( update_lora_selection, inputs=[lora_name, enable], outputs=[enable, lora_strength], queue=False, api_visibility="private", ) lora_components.extend([enable, lora_name, lora_strength]) lora_enable_inputs.append(enable) lora_name_inputs.append(lora_name) lora_strength_inputs.append(lora_strength) if family == "illustrious": gr.Markdown("`Pony用・ILL未検証` のLoRAは一覧の下部にあります。Illustriousでの効き方・画質・再現性は未検証です。") pony_warning = gr.Markdown( pony_selection_notice(family, [slot[1] for slot in lora_defaults]), elem_classes=["pony-selection-warning"], ) gr.on( triggers=[component.change for component in lora_name_inputs], fn=lambda *names: pony_selection_notice(family, names), inputs=lora_name_inputs, outputs=pony_warning, queue=False, trigger_mode="always_last", api_visibility="private", show_progress="hidden", ) build_jujo_browser(family, lora_components, lora_defaults) generate_btn = gr.Button(f"Generate with {family_label}", variant="primary") with gr.Column(scale=1): output_image = gr.Image( label="Generated Image", format="png", streaming=True, ) info_output = gr.Markdown( STATIC_INFO + f"\n**Page:** {family_label}" + "\n_Run a generation to see live GPU/VRAM stats._" ) # Restoration sets all fields atomically; only a direct user selection # should overwrite manually adjusted dimensions or sampling settings. resolution.input( lambda name: list(RESOLUTIONS[name]), inputs=[resolution], outputs=[width_input, height_input], api_visibility="private", ) if preset_input is not None and preset_status is not None and preset_specs: def apply_preset(preset_name: str): spec = preset_specs[preset_name] return ( spec["steps"], spec["cfg"], spec["sampler"], spec["scheduler"], gr.Checkbox(value=spec["lora4_enable"], interactive=True), gr.Dropdown(value=spec["lora4_file"]), gr.Slider(value=spec["lora4_strength"], interactive=True), spec["status"] + "\n\n_手動変更した値は、次にプリセットを選択するまで維持されます。_", ) preset_input.input( apply_preset, inputs=[preset_input], outputs=[ steps_input, cfg_input, sampler_input, scheduler_input, lora_enable_inputs[3], lora_name_inputs[3], lora_strength_inputs[3], preset_status, ], api_visibility="private", ) settings_controls = { "model": model_input, "prompt": prompt_input, "negative_prompt": negative_input, "resolution": resolution, "width": width_input, "height": height_input, "seed": seed_input, "steps": steps_input, "cfg": cfg_input, "sampler": sampler_input, "scheduler": scheduler_input, } if preset_input is not None: settings_controls["preset"] = preset_input if vae_choices: settings_controls["vae"] = vae_input for index in range(4): for suffix, component in zip(("enabled", "file", "strength"), lora_components[index * 3:index * 3 + 3]): settings_controls[f"lora{index + 1}_{suffix}"] = component for index, component in enumerate(embedding_components, 1): settings_controls[f"embedding{index}"] = component with settings_panel: favorite_seed = bind_browser_settings(family, settings_controls, preset_status, preset_specs, info_output) def generate_with_favorite_seed(exact_seed, *args): values = list(args) if exact_seed is not None: values[6] = int(exact_seed) return generate_image_ui(*values) generate_btn.click( fn=generate_with_favorite_seed, inputs=[ favorite_seed, model_input, vae_input, prompt_input, negative_input, width_input, height_input, seed_input, steps_input, cfg_input, sampler_input, scheduler_input, *lora_components, *embedding_components, ], outputs=[output_image, info_output], show_progress=True, concurrency_id="image_generation", concurrency_limit=1, api_visibility="private", ) if credits: with gr.Accordion("配布元・クレジット", open=False): gr.Markdown(credits) gr.Markdown( "### 生成件数の集計について\n\n" "モデルの入れ替え・ラインナップ改善の参考にするため、モデルごとの日次生成枚数を集計しています。" "集計対象は生成に成功した画像です。集計データには日付・モード・モデル名・生成枚数のみを記録し、" "プロンプト・生成画像・利用者を識別する情報は含みません。", elem_classes=["generation-count-notice"], ) with gr.Blocks(title="Anima Collection+ MCP") as app: gr.Markdown( "# Anima Collection+ MCP\n" "モデルの系統ごとに、Checkpoint・LoRA・プロンプト・生成設定を分けて管理しています。\n\n" "Qwen Image 2.1・MOSS Imageは、[qm-image](https://huggingface.co/spaces/yamiuchi/qm-image)で実験中です。" ) # Public API/MCP components are intentionally separate from all visible # tabs. Their types, labels, order, endpoint name, and outputs preserve the # pre-existing generate_image contract while the allowed checkpoints grow. with gr.Group(visible=False): api_model_input = gr.Dropdown( label="Checkpoint", choices=ALL_MODEL_FILES, value=MODEL_FILES[0] ) api_prompt_input = gr.Textbox(label="Prompt", value=ANIMA_PROMPT) api_negative_input = gr.Textbox(label="Negative prompt", value=ANIMA_NEGATIVE) api_width_input = gr.Slider(512, 1536, value=1024, step=16, label="Width") api_height_input = gr.Slider(512, 1536, value=1024, step=16, label="Height") api_seed_input = gr.Number(label="Seed (-1 = random)", value=-1, precision=0) api_steps_input = gr.Slider(label="Steps", minimum=6, maximum=60, value=8, step=1) api_cfg_input = gr.Slider(label="CFG", minimum=1.0, maximum=8.0, value=1.0, step=0.1) api_sampler_input = gr.Dropdown(label="Sampler", choices=SAMPLERS, value="er_sde") api_scheduler_input = gr.Dropdown(label="Scheduler", choices=SCHEDULERS, value="simple") api_lora_components = [] api_lora_defaults = [ (False, "Anima-P3-Aesthetics-Details_v1.0.safetensors", 0.8), (False, "gpt-image-2_anima-base1_v1.safetensors", 0.8), (False, NONE_LORA, 0.8), (False, "Turbo-ANIMA-v2.safetensors", 1.0), ] for slot_index, (enabled, lora_name, strength) in enumerate( api_lora_defaults, start=1 ): api_lora_components.extend( [ gr.Checkbox(label=f"LoRA {slot_index} Enable", value=enabled), gr.Dropdown( label=f"LoRA {slot_index} File", choices=[NONE_LORA] + ALL_LORA_CHOICES, value=lora_name, ), gr.Slider( label=f"LoRA {slot_index} Strength", minimum=0.0, maximum=2.0, value=strength, step=0.05, ), ] ) api_output_image = gr.Image(format="png") api_info_output = gr.Markdown() api_generate_btn = gr.Button("API Generate") with gr.Tab("Anima"): with gr.Accordion("Animaモードの使い方はこちら", open=False): gr.Markdown(ANIMA_USAGE_INFO) build_generation_page( family_label="Anima", model_choices=MODEL_FILES, lora_choices=ANIMA_LORA_CHOICES, prompt_default=ANIMA_PROMPT, negative_default=ANIMA_NEGATIVE, steps_default=8, cfg_default=1.0, sampler_default="er_sde", scheduler_default="simple", lora_defaults=[ (False, "Anima-P3-Aesthetics-Details_v1.0.safetensors", 0.8), (False, "gpt-image-2_anima-base1_v1.safetensors", 0.8), (False, NONE_LORA, 0.8), (False, "Turbo-ANIMA-v2.safetensors", 1.0), ], preset_specs=ANIMA_PRESETS, preset_default="turbo", credits=ANIMA_CREDITS, ) with gr.Tab("Illustrious"): with gr.Accordion("Illustriousモードの使い方はこちら", open=False): gr.Markdown(ILLUSTRIOUS_USAGE_INFO) build_generation_page( family_label="Illustrious", model_choices=ILLUSTRIOUS_MODEL_FILES, lora_choices=ILLUSTRIOUS_LORA_CHOICES, prompt_default=ILLUSTRIOUS_PROMPT, negative_default=ILLUSTRIOUS_NEGATIVE, steps_default=30, cfg_default=5.0, sampler_default="euler_ancestral", scheduler_default="normal", lora_defaults=[ (False, "Detail_Tweaker_Illustrious_BSY_V3.safetensors", 0.7), (False, NONE_LORA, 0.7), (False, NONE_LORA, 0.7), (False, "dmd2_sdxl_4step_lora.safetensors", 0.7), ], resolution_default="1:1 (1024x1024)", width_default=1024, height_default=1024, preset_specs=ILLUSTRIOUS_PRESETS, preset_default="wai", embedding_files=ILLUSTRIOUS_EMBEDDING_FILES, embedding_defaults=[False, False, False, False], embedding_interactive=True, vae_choices=ILLUSTRIOUS_VAE_CHOICES, vae_default=CHECKPOINT_VAE_OPTION, credits=ILLUSTRIOUS_CREDITS, ) with gr.Tab("Krea2"): gr.Markdown( "## Krea2\n" "This family has a reserved, separate page so its natural-language " "prompting and 8-step Turbo defaults are never mixed with Anima or " "Illustrious. The Krea2 weights and Qwen3-VL text encoder are not yet " "part of this Space, so generation is disabled here." ) with gr.Row(): with gr.Column(): gr.Dropdown( label="Krea2 Checkpoint", choices=["Krea2 Turbo (not installed)"], value="Krea2 Turbo (not installed)", interactive=False, ) gr.Textbox( label="Prompt", lines=4, placeholder="Describe the image in natural language.", interactive=False, ) gr.Textbox(label="Negative prompt", value="", interactive=False) with gr.Row(): gr.Number(label="Steps", value=8, interactive=False) gr.Number(label="CFG", value=1.0, interactive=False) gr.Number(label="Resolution", value=1024, interactive=False) gr.Button("Krea2 is not configured", interactive=False) with gr.Column(): gr.Markdown( "**Planned isolated assets**\n\n" "- Krea2 checkpoint\n" "- Qwen3-VL text encoder\n" "- Krea2-only LoRAs\n" "- Natural-language prompt and Turbo sampling defaults" ) build_admin_statistics(app, generation_stats, {"anima": MODEL_FILES, "illustrious": ILLUSTRIOUS_MODEL_FILES}) api_generate_btn.click( fn=generate_image, inputs=[ api_model_input, api_prompt_input, api_negative_input, api_width_input, api_height_input, api_seed_input, api_steps_input, api_cfg_input, api_sampler_input, api_scheduler_input, *api_lora_components, ], outputs=[api_output_image, api_info_output], show_progress=True, api_name="generate_image", api_description=( "Generate one image using a registered Anima or Illustrious " "checkpoint, generation settings, and up to four compatible LoRAs." ), concurrency_id="image_generation", concurrency_limit=1, api_visibility="public", ) generation_stats.start() if __name__ == "__main__": app.launch( mcp_server=True, css=Path(__file__).with_name("lora_ui.css").read_text(encoding="utf-8"), )