import gc import json import os import random import re import tempfile import urllib.request import uuid from dataclasses import dataclass from pathlib import Path from typing import Iterable, Optional # --------------------------------------------------------------------------- # Environment must be configured before importing DiffSynth/torch-heavy modules. # DiffSynth defaults to ModelScope unless this is set; keep it pinned to HF. # --------------------------------------------------------------------------- os.environ.setdefault("DIFFSYNTH_DOWNLOAD_SOURCE", "huggingface") os.environ.setdefault("DIFFSYNTH_SKIP_DOWNLOAD", "True") os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1") os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") # --------------------------------------------------------------------------- # DEV_MODE -- mirrors qwen-image-edit-2511/app.py: skip every heavy import and # the real model load entirely, so the UI can be iterated on without a GPU or # even torch/diffsynth installed. # --------------------------------------------------------------------------- DEV_MODE = os.getenv("DEV_MODE", "0") == "1" if DEV_MODE: print("DEV_MODE enabled -- model will NOT be loaded, mock output used.") class _spaces: @staticmethod def GPU(*args, **kwargs): if args and callable(args[0]): return args[0] def decorator(fn): return fn return decorator spaces = _spaces() else: try: import spaces except Exception: # allows local CUDA testing outside real HF Spaces class _SpacesFallback: def GPU(self, *args, **kwargs): if args and callable(args[0]): return args[0] def decorator(fn): return fn return decorator spaces = _SpacesFallback() import numpy as np import torch from huggingface_hub import hf_hub_download, list_repo_files, snapshot_download from diffsynth.pipelines.anima_image import AnimaImagePipeline, ModelConfig from anima_lllite.control_net_lllite_anima import ( ControlNetLLLiteDiT, load_lllite_weights, read_lllite_metadata, ) import gradio as gr from PIL import Image, ImageDraw from PIL.PngImagePlugin import PngInfo # --------------------------------------------------------------------------- # Constants -- Anima base model # --------------------------------------------------------------------------- MODEL_ID = os.getenv("ANIMA_MODEL_ID", "circlestone-labs/Anima") DIFFUSION_SUBDIR = "split_files/diffusion_models" TEXT_ENCODER_FILE = "split_files/text_encoders/qwen_3_06b_base.safetensors" VAE_FILE = "split_files/vae/qwen_image_vae.safetensors" QWEN_TOKENIZER_ID = os.getenv("ANIMA_QWEN_TOKENIZER_ID", "Qwen/Qwen3-0.6B") T5_TOKENIZER_ID = os.getenv("ANIMA_T5_TOKENIZER_ID", "google/t5-v1_1-xxl") T5_TOKENIZER_SUBFOLDER = os.getenv("ANIMA_T5_TOKENIZER_SUBFOLDER", "") T5_TOKENIZER_FALLBACK_ID = os.getenv("ANIMA_T5_TOKENIZER_FALLBACK_ID", "google/t5-v1_1-xxl") # Only anima-base-v1.0 is documented as compatible with the current (v2) # ControlNet-LLLite weights -- see https://huggingface.co/kohya-ss/Anima-LLLite DIFFUSION_MODEL_CHOICES: dict[str, str] = { "Base v1.0 (ControlNet-LLLite compatible)": "anima-base-v1.0.safetensors", "Aesthetic v1.1": "anima-aesthetic-v1.1.safetensors", "Aesthetic v1.0b": "anima-aesthetic-v1.0b.safetensors", "Aesthetic v1.0": "anima-aesthetic-v1.0.safetensors", "Turbo v1.0 (8-12 steps, CFG 1)": "anima-turbo-v1.0.safetensors", } DEFAULT_DIFFUSION_KEY = "Base v1.0 (ControlNet-LLLite compatible)" MAX_SEED = 2**31 - 1 DEFAULT_POSITIVE_PREFIX = ( "(A crisp, highres, amazing quality, finished masterpiece, " "with a newest dynamic detailed background:1.5), " ) DEFAULT_NEGATIVE = ( "Lowres, worst quality, low quality, blurry, " "(jpeg artifacts, grainy, cropped and out of frame, overexposed, signature, watermark, text, " "multiple angles, artist name, simple background, unfinished, flat background, Poorly defined details, " "with bad proportions, off-topic, poorly drawn crappy art of bad quality:1.5) " "(score_1, score_2, score_3:0.25)" ) DEFAULT_PROMPT = ( "1girl, solo, long silver hair, blue eyes, blue dress, underwater, " "air bubbles, floating hair, refraction, portrait, looking at viewer" ) # --------------------------------------------------------------------------- # Constants -- ControlNet-LLLite (kohya-ss/Anima-LLLite) # --------------------------------------------------------------------------- CONTROLNET_REPO = "kohya-ss/Anima-LLLite" _ASPP_DEFAULT_DILATIONS = (1, 2, 4, 8) CONTROLNET_CATALOG: dict[str, dict] = { "Any-Test-Like v2 (scribble / lineart / grayscale)": { "file": "anima-lllite-any-test-like-v2.safetensors", "group": "primary", "needs_mask": False, }, "Inpainting v2 (RGB + mask)": { "file": "anima-lllite-inpainting-v2.safetensors", "group": "primary", "needs_mask": True, }, "Legacy: Depth (Preview3 base)": { "file": "anima-lllite-depth-1.safetensors", "group": "legacy", "needs_mask": False, }, "Legacy: Lineart (Preview3 base)": { "file": "anima-lllite-lineart-1.safetensors", "group": "legacy", "needs_mask": False, }, "Legacy: Pose (Preview3 base)": { "file": "anima-lllite-pose-1.safetensors", "group": "legacy", "needs_mask": False, }, "Legacy: Scribble (Preview3 base)": { "file": "anima-lllite-scribble-1.safetensors", "group": "legacy", "needs_mask": False, }, "Legacy: Inpainting v1 (Preview3 base)": { "file": "anima-lllite-inpainting-v1.safetensors", "group": "legacy", "needs_mask": True, }, "Legacy: Any-Test-Like v1 (step2000)": { "file": "anima-lllite-any-test-like-1-step2000.safetensors", "group": "legacy", "needs_mask": False, }, "Legacy: Any-Test-Like v2-beta (epoch03)": { "file": "anima-lllite-any-test-like-v2-beta-epoch-03.safetensors", "group": "legacy", "needs_mask": False, }, } CONTROLNET_CHOICES = ["Off"] + list(CONTROLNET_CATALOG.keys()) STYLE_EXPLORER_URL = "https://thetacursed.github.io/Anima-Style-Explorer/" # --------------------------------------------------------------------------- # Cache directories (persistent /data on HF Spaces, falls back to /tmp locally) # --------------------------------------------------------------------------- def _select_writable_dir(env_name: str, candidates: list[str]) -> str: existing = os.getenv(env_name) if existing: candidates = [existing] + [c for c in candidates if c != existing] for candidate in candidates: try: path = Path(candidate) path.mkdir(parents=True, exist_ok=True) test_file = path / ".write_test" test_file.write_text("ok") test_file.unlink(missing_ok=True) return str(path) except Exception: continue fallback = Path("/tmp") / env_name.lower() fallback.mkdir(parents=True, exist_ok=True) return str(fallback) HF_HOME = _select_writable_dir("HF_HOME", ["/data/.cache/huggingface", "/tmp/.cache/huggingface"]) LOCAL_MODEL_DIR = _select_writable_dir("ANIMA_LOCAL_MODEL_DIR", ["/data/models/anima", "/tmp/models/anima"]) os.environ["HF_HOME"] = HF_HOME os.environ.setdefault("HF_HUB_CACHE", str(Path(HF_HOME) / "hub")) _HF_TOKEN = os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACE_HUB_TOKEN") TOKENIZER_ALLOW_PATTERNS = [ "tokenizer.json", "tokenizer_config.json", "special_tokens_map.json", "added_tokens.json", "vocab.json", "merges.txt", "config.json", "*.model", "*.tiktoken", "*.jinja", ] @dataclass class FixedAssets: text_encoder: str vae: str qwen_tokenizer_dir: str t5_tokenizer_dir: str def _repo_local_dir(repo_id: str, suffix: str = "") -> Path: safe = repo_id.replace("/", "--") if suffix: safe = f"{safe}--{suffix}" path = Path(LOCAL_MODEL_DIR) / safe path.mkdir(parents=True, exist_ok=True) return path def _download_file_from_hf(repo_id: str, filename: str, local_dir: Path) -> str: return hf_hub_download(repo_id=repo_id, filename=filename, local_dir=str(local_dir), token=_HF_TOKEN) def _has_tokenizer_files(path: Path) -> bool: if not path.is_dir(): return False markers = ("tokenizer.json", "tokenizer.model", "spiece.model", "vocab.json", "merges.txt") return any((path / marker).exists() for marker in markers) def _download_tokenizer_repo(repo_id: str, *, subfolder: str = "", suffix: str = "tokenizer") -> str: cleaned_subfolder = subfolder.strip("/") local_dir = _repo_local_dir(repo_id, suffix if not cleaned_subfolder else f"{suffix}--{cleaned_subfolder}") if cleaned_subfolder: allow_patterns = [f"{cleaned_subfolder}/{pattern}" for pattern in TOKENIZER_ALLOW_PATTERNS] else: allow_patterns = TOKENIZER_ALLOW_PATTERNS snapshot_dir = Path(snapshot_download( repo_id=repo_id, allow_patterns=allow_patterns, local_dir=str(local_dir), token=_HF_TOKEN, )) candidates = ([snapshot_dir / cleaned_subfolder] if cleaned_subfolder else []) + [snapshot_dir] for candidate in candidates: if _has_tokenizer_files(candidate): return str(candidate) searched = ", ".join(str(c) for c in candidates) raise RuntimeError(f"Downloaded tokenizer files from {repo_id!r}, but none usable were found. Checked: {searched}") def _download_t5_tokenizer() -> str: try: return _download_tokenizer_repo(T5_TOKENIZER_ID, subfolder=T5_TOKENIZER_SUBFOLDER, suffix="t5-tokenizer") except Exception as exc: if T5_TOKENIZER_ID == T5_TOKENIZER_FALLBACK_ID and not T5_TOKENIZER_SUBFOLDER: raise print(f"[startup] T5 tokenizer download from {T5_TOKENIZER_ID!r} failed: {exc}. " f"Falling back to {T5_TOKENIZER_FALLBACK_ID!r}.") return _download_tokenizer_repo(T5_TOKENIZER_FALLBACK_ID, suffix="t5-tokenizer-fallback") def _download_controlnet_file(entry_key: str) -> str: entry = CONTROLNET_CATALOG[entry_key] local_dir = _repo_local_dir(CONTROLNET_REPO) return _download_file_from_hf(CONTROLNET_REPO, entry["file"], local_dir) # --------------------------------------------------------------------------- # Model loading -- based on the working DiffSynth-Studio pattern used by # https://huggingface.co/spaces/mrfakename/anima-v1 (verified working reference: # Anima has no stock diffusers pipeline, but DiffSynth's AnimaImagePipeline # loads the ComfyUI-format split_files directly via local paths). # --------------------------------------------------------------------------- _FIXED_ASSETS: Optional[FixedAssets] = None _PIPE = None _PIPE_DIFFUSION_KEY: Optional[str] = None def _set_progress(progress: Optional[gr.Progress], value=0, desc: str = "") -> None: if progress is None: return try: progress(value, desc=desc) except TypeError: try: progress(value) except Exception: pass def _progress_wrapper(progress: "gr.Progress"): def _wrap(iterable: Iterable): try: total = len(iterable) except Exception: total = None for index, item in enumerate(iterable): desc = f"Denoising {index + 1}/{total}" if total else f"Denoising step {index + 1}" _set_progress(progress, (index, total) if total else 0, desc=desc) yield item _set_progress(progress, (total, total) if total else 0, desc="Decoding") return _wrap def _ensure_fixed_assets() -> FixedAssets: global _FIXED_ASSETS if _FIXED_ASSETS is None: anima_dir = _repo_local_dir(MODEL_ID) text_encoder = _download_file_from_hf(MODEL_ID, TEXT_ENCODER_FILE, anima_dir) vae = _download_file_from_hf(MODEL_ID, VAE_FILE, anima_dir) qwen_dir = _download_tokenizer_repo(QWEN_TOKENIZER_ID, suffix="qwen-tokenizer") t5_dir = _download_t5_tokenizer() _FIXED_ASSETS = FixedAssets( text_encoder=text_encoder, vae=vae, qwen_tokenizer_dir=qwen_dir, t5_tokenizer_dir=t5_dir, ) return _FIXED_ASSETS def _download_diffusion(diffusion_key: str) -> str: filename = DIFFUSION_MODEL_CHOICES[diffusion_key] anima_dir = _repo_local_dir(MODEL_ID) return _download_file_from_hf(MODEL_ID, f"{DIFFUSION_SUBDIR}/{filename}", anima_dir) def _load_pipe(diffusion_key: str, progress: Optional["gr.Progress"] = None): """Create/reuse the pipeline. Rebuilds only when the diffusion-model choice changes.""" global _PIPE, _PIPE_DIFFUSION_KEY, _ACTIVE_LORAS if _PIPE is not None and _PIPE_DIFFUSION_KEY == diffusion_key: return _PIPE _set_progress(progress, 0, desc=f"Loading Anima ({diffusion_key})") fixed = _ensure_fixed_assets() diffusion_path = _download_diffusion(diffusion_key) if _PIPE is not None: _PIPE = None gc.collect() torch.cuda.empty_cache() # A freshly built pipe has nothing fused into it yet, regardless of what # was baked into the previous one. _ACTIVE_LORAS = {} torch.set_float32_matmul_precision("high") device = "cuda" if torch.cuda.is_available() else "cpu" _PIPE = AnimaImagePipeline.from_pretrained( torch_dtype=torch.bfloat16, device=device, model_configs=[ ModelConfig(path=diffusion_path), ModelConfig(path=fixed.text_encoder), ModelConfig(path=fixed.vae), ], tokenizer_config=ModelConfig(path=fixed.qwen_tokenizer_dir), tokenizer_t5xxl_config=ModelConfig(path=fixed.t5_tokenizer_dir), ) _PIPE_DIFFUSION_KEY = diffusion_key return _PIPE # --------------------------------------------------------------------------- # ControlNet-LLLite integration # # `anima_lllite/control_net_lllite_anima.py` is vendored unchanged from # kohya-ss/ComfyUI-Anima-LLLite (pure PyTorch, no ComfyUI dependency). Its # `ControlNetLLLiteDiT` monkey-patches the targeted `Linear.forward`s in place # on a DiT module by walking `named_modules()` for classes named `Attention` # (with `is_selfattn`, `q_proj`/`k_proj`/`v_proj`) and `GPT2FeedForward` (with # `layer1`) -- DiffSynth's `diffsynth/models/anima_dit.py` uses exactly these # names, so it attaches to `pipe.dit` (an `AnimaDiT`) with no adaptation. # --------------------------------------------------------------------------- def _target_cond_hw(latent_h: int, latent_w: int, patch_spatial: int = 2) -> tuple[int, int]: """Cond image pixel size the LLLite conditioning trunk expects (stride-16 conv).""" padded_h = ((latent_h + patch_spatial - 1) // patch_spatial) * patch_spatial padded_w = ((latent_w + patch_spatial - 1) // patch_spatial) * patch_spatial return padded_h * 8, padded_w * 8 def _pil_to_cond_tensor(image: "Image.Image", target_h: int, target_w: int, device, dtype): """PIL RGB image -> (1, 3, H, W) tensor in [-1, 1].""" image = image.convert("RGB").resize((target_w, target_h), Image.BICUBIC) arr = torch.from_numpy(np.array(image)).float() / 255.0 arr = arr.permute(2, 0, 1).unsqueeze(0) arr = arr * 2.0 - 1.0 return arr.to(device=device, dtype=dtype) def _pil_mask_to_tensor(mask_image: "Image.Image", target_h: int, target_w: int, device, dtype): """PIL grayscale mask -> (1, 1, H, W) tensor in {0, 1}; white = inpaint area.""" m = mask_image.convert("L").resize((target_w, target_h), Image.NEAREST) arr = torch.from_numpy(np.array(m)).float() / 255.0 arr = (arr >= 0.5).float().unsqueeze(0).unsqueeze(0) return arr.to(device=device, dtype=dtype) def _build_inpaint_cond_image(rgb_pm1, mask01, masked_input: bool): """(1,3,H,W) + (1,1,H,W) -> (1,4,H,W). Mirrors sd-scripts' inpaint cond builder.""" if masked_input: keep = (mask01 < 0.5).to(rgb_pm1.dtype) rgb_pm1 = rgb_pm1 * keep mask_pm1 = mask01.to(rgb_pm1.dtype) * 2.0 - 1.0 return torch.cat([rgb_pm1, mask_pm1], dim=1) class _LLLiteSession: """Apply a ControlNet-LLLite adapter to `pipe.dit` for the duration of one generation call, and guarantee it is unpatched afterwards -- even on error -- so a ControlNet-free generation right after is never silently still patched.""" def __init__(self, pipe, entry_key: str, control_image, control_mask, strength: float, height: int, width: int): self.pipe = pipe self.entry_key = entry_key self.control_image = control_image self.control_mask = control_mask self.strength = strength self.height = height self.width = width self.lllite = None def __enter__(self): weights_path = _download_controlnet_file(self.entry_key) meta = read_lllite_metadata(weights_path) cond_emb_dim = int(meta.get("lllite.cond_emb_dim", 32)) mlp_dim = int(meta.get("lllite.mlp_dim", 64)) target_layers = meta.get("lllite.target_atomics", meta.get("lllite.target_layers", "self_attn_q")) cond_dim = int(meta.get("lllite.cond_dim", 64)) cond_resblocks = int(meta.get("lllite.cond_resblocks", 1)) use_aspp = str(meta.get("lllite.use_aspp", "false")).lower() == "true" aspp_meta = meta.get("lllite.aspp_dilations") aspp_dilations = ( tuple(int(d) for d in aspp_meta.split(",") if d.strip()) if (use_aspp and aspp_meta) else _ASPP_DEFAULT_DILATIONS ) cond_in_channels = int(meta.get("lllite.cond_in_channels", 3)) inpaint_masked_input = str(meta.get("lllite.inpaint_masked_input", "false")).lower() == "true" if cond_in_channels == 4 and self.control_mask is None: raise gr.Error( f"'{self.entry_key}' was trained with 4-channel (inpaint) conditioning and needs a " f"mask image (white = area to change, black = keep)." ) dit = self.pipe.dit patch_spatial = int(getattr(dit, "patch_spatial", 2)) device = next(dit.parameters()).device dtype = next(dit.parameters()).dtype latent_h, latent_w = self.height // 8, self.width // 8 target_h, target_w = _target_cond_hw(latent_h, latent_w, patch_spatial) rgb = _pil_to_cond_tensor(self.control_image, target_h, target_w, device, dtype) if cond_in_channels == 4: mask01 = _pil_mask_to_tensor(self.control_mask, target_h, target_w, device, dtype) cond_image = _build_inpaint_cond_image(rgb, mask01, inpaint_masked_input) else: cond_image = rgb lllite = ControlNetLLLiteDiT( dit, cond_emb_dim=cond_emb_dim, mlp_dim=mlp_dim, target_layers=target_layers, multiplier=self.strength, cond_dim=cond_dim, cond_resblocks=cond_resblocks, use_aspp=use_aspp, aspp_dilations=aspp_dilations, cond_in_channels=cond_in_channels, inpaint_masked_input=inpaint_masked_input, ) load_lllite_weights(lllite, weights_path, strict=False) lllite.to(device=device, dtype=dtype) lllite.eval().requires_grad_(False) lllite.set_cond_image(cond_image) lllite.apply_to() self.lllite = lllite return self def __exit__(self, exc_type, exc, tb): if self.lllite is not None: self.lllite.restore() self.lllite.clear_cond_image() return False # --------------------------------------------------------------------------- # LoRA stack # # Unlike diffusers' set_adapters/disable_lora (used by # qwen-image-edit-2511/app.py), DiffSynth's pipe.load_lora() *fuses* LoRA # weights directly into the base Linear layers # (diffsynth/utils/lora/general.py::fuse_lora_to_base_model does a plain # `weight += alpha * (B @ A)`). There's no non-destructive "disable" -- # the only way back is fusing the same tensors again with alpha negated, # which is an exact inverse of that same additive op. `_ACTIVE_LORAS` tracks # what's currently baked into `_PIPE.dit` so each generate() call only # unfuses/refuses what actually changed since the last call. # --------------------------------------------------------------------------- _LORA_CACHE_DIR = os.path.join(tempfile.gettempdir(), "anima_lora_cache") os.makedirs(_LORA_CACHE_DIR, exist_ok=True) _ACTIVE_LORAS: dict = {} # local .safetensors path -> currently-fused alpha def parse_lora_source(raw: str) -> tuple[str, str, Optional[str]]: """Parse any LoRA URL/id into (kind, src, weight_name). kind: "hf" | "civitai" | "direct".""" raw = raw.strip() # HuggingFace full URL m = re.match(r"https?://huggingface\.co/([^/]+/[^/]+)(?:/(?:blob|resolve)/[^/]+/(.+))?", raw) if m: return "hf", m.group(1), m.group(2) # HuggingFace shorthand: user/repo or user/repo/file.safetensors if re.match(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]", raw) and not raw.startswith("http"): parts = raw.split("/", 2) return "hf", f"{parts[0]}/{parts[1]}", parts[2] if len(parts) > 2 else None # CivitAI if "civitai.com" in raw: return "civitai", raw, None # Direct HTTPS URL to a file if raw.startswith("http"): return "direct", raw, None # Fallback: treat as HF repo id return "hf", raw, None def _civitai_download_url(url: str) -> str: """Convert any CivitAI URL to a direct download URL.""" if "/api/download/models/" in url: return url m = re.search(r"[?&]modelVersionId=(\d+)", url) if not m: raise gr.Error( "CivitAI URL must include modelVersionId, e.g. " "https://civitai.com/models/12345?modelVersionId=67890" ) vid = m.group(1) token = os.getenv("CIVITAI_API_KEY", "") base = f"https://civitai.com/api/download/models/{vid}" return f"{base}?token={token}" if token else base def _download_lora_url(url: str, name: str = "lora") -> str: """Download a LoRA from any HTTP URL to local cache. Returns local file path.""" safe = re.sub(r"[^A-Za-z0-9]", "_", url)[-48:] fname = url.split("?")[0].rstrip("/").split("/")[-1] if not fname.endswith(".safetensors"): fname = f"{safe}.safetensors" local_path = os.path.join(_LORA_CACHE_DIR, fname) if not os.path.exists(local_path): req = urllib.request.Request(url, headers={"User-Agent": "Python/urllib"}) with urllib.request.urlopen(req, timeout=120) as resp, open(local_path, "wb") as f: f.write(resp.read()) return local_path def _resolve_hf_lora_file(repo: str, weights: Optional[str]) -> str: """Resolve an HF repo (optionally a specific filename) to a local .safetensors path.""" if not weights: candidates = [f for f in list_repo_files(repo, token=_HF_TOKEN) if f.endswith(".safetensors")] if not candidates: raise gr.Error( f"No .safetensors file found in '{repo}'. Point to one explicitly, " f"e.g. {repo}/filename.safetensors." ) weights = candidates[0] local_dir = _repo_local_dir(repo, "lora") return _download_file_from_hf(repo, weights, local_dir) def _resolve_lora_path(raw_url: str) -> str: """Resolve any LoRA URL/id (HF, CivitAI, or direct link) to a local .safetensors path.""" kind, src, wname = parse_lora_source(raw_url) if kind == "hf": return _resolve_hf_lora_file(src, wname) dl_url = _civitai_download_url(src) if kind == "civitai" else src name = re.sub(r"[^A-Za-z0-9]", "_", raw_url)[:40] or "lora" return _download_lora_url(dl_url, name) def _build_lora_stack(k1, u1, w1, k2, u2, w2, k3, u3, w3) -> list: stack = [] for key, url, weight in [(k1, u1, w1), (k2, u2, w2), (k3, u3, w3)]: url = (url or "").strip() if key == "Custom URL" and url: stack.append((url, float(weight))) return stack def apply_loras(lora_stack: list) -> None: """Fuse the user-selected LoRA stack into pipe.dit, diffing against what's already fused so unchanged slots are left alone; anything no longer requested (or requested at a different weight) gets un-fused first.""" global _ACTIVE_LORAS if DEV_MODE or _PIPE is None: return requested: dict = {} for raw_url, slot_weight in lora_stack: path = _resolve_lora_path(raw_url) requested[path] = requested.get(path, 0.0) + float(slot_weight) if requested == _ACTIVE_LORAS: return for path, old_alpha in _ACTIVE_LORAS.items(): if requested.get(path) != old_alpha: _PIPE.load_lora(_PIPE.dit, lora_config=path, alpha=-old_alpha, verbose=0) for path, alpha in requested.items(): if _ACTIVE_LORAS.get(path) != alpha: _PIPE.load_lora(_PIPE.dit, lora_config=path, alpha=alpha, verbose=0) _ACTIVE_LORAS = requested # --------------------------------------------------------------------------- # Inference helpers # --------------------------------------------------------------------------- def _normalize_dimension(value: int) -> int: value = int(value) value = max(512, min(1536, value)) return int(round(value / 16) * 16) def _normalize_seed(seed) -> int: try: seed = int(seed) except Exception: seed = -1 if seed < 0: return random.randint(0, MAX_SEED) return seed def _prepare_prompt(prompt: str, prefix: str) -> str: prompt = (prompt or "").strip() if not prompt: prompt = DEFAULT_PROMPT prefix = (prefix or "").strip() if prefix and not prompt.lower().startswith(prefix.lower()): prompt = f"{prefix} {prompt}" return prompt def _mock_result(image: Image.Image, prompt: str) -> Image.Image: result = image.convert("RGBA") overlay = Image.new("RGBA", result.size, (124, 58, 237, 55)) result = Image.alpha_composite(result, overlay).convert("RGB") draw = ImageDraw.Draw(result) label = f"[DEV] {prompt[:60]}" draw.rectangle([0, result.height - 28, result.width, result.height], fill=(20, 20, 36, 200)) draw.text((8, result.height - 22), label, fill=(167, 139, 250)) return result # --------------------------------------------------------------------------- # Generation metadata -- embedded into every output PNG (tEXt chunks) so a # past result can be dropped back in and fully reproduced. Returning a # filepath (rather than the PIL Image object) from generate() is required: # Gradio's Image component passes str/Path returns straight through to disk # untouched, but re-encodes a PIL.Image via its own save path, which would # silently drop any metadata not explicitly re-attached at save time. # --------------------------------------------------------------------------- GENERATION_METADATA_KEY = "anima_metadata" _OUTPUT_DIR = os.path.join(tempfile.gettempdir(), "anima_outputs") os.makedirs(_OUTPUT_DIR, exist_ok=True) def _build_metadata( *, prompt: str, full_prompt: str, negative_prompt: str, positive_prefix: str, diffusion_key: str, width: int, height: int, steps: int, cfg_scale: float, sigma_shift: Optional[float], seed: int, control_enabled: bool, control_key: str, control_strength: float, lora1_key: str, lora1_url: str, lora1_weight: float, lora2_key: str, lora2_url: str, lora2_weight: float, lora3_key: str, lora3_url: str, lora3_weight: float, ) -> dict: return { "app": "Anima", "metadata_version": 1, "prompt": prompt, "full_prompt": full_prompt, "negative_prompt": negative_prompt, "positive_prefix": positive_prefix, "diffusion_key": diffusion_key, "width": width, "height": height, "steps": steps, "cfg_scale": cfg_scale, "sigma_shift": sigma_shift, "seed": seed, "control_enabled": bool(control_enabled), "control_key": control_key, "control_strength": control_strength, "lora1_key": lora1_key, "lora1_url": lora1_url, "lora1_weight": lora1_weight, "lora2_key": lora2_key, "lora2_url": lora2_url, "lora2_weight": lora2_weight, "lora3_key": lora3_key, "lora3_url": lora3_url, "lora3_weight": lora3_weight, } def _format_human_readable(meta: dict) -> str: """A1111-style block -- purely for humans/other tools eyeballing the PNG info; restore_from_image() reads the JSON blob instead.""" lines = [meta.get("full_prompt", "")] if meta.get("negative_prompt"): lines.append(f"Negative prompt: {meta['negative_prompt']}") tail = [ f"Steps: {meta['steps']}", f"CFG scale: {meta['cfg_scale']}", f"Seed: {meta['seed']}", f"Size: {meta['width']}x{meta['height']}", f"Model: {meta['diffusion_key']}", ] if meta.get("sigma_shift"): tail.append(f"Sigma shift: {meta['sigma_shift']}") if meta.get("control_enabled"): tail.append(f"ControlNet-LLLite: {meta['control_key']} (strength {meta['control_strength']})") lora_bits = [ f"{meta[f'lora{i}_url']} ({meta[f'lora{i}_weight']})" for i in (1, 2, 3) if meta.get(f"lora{i}_key") == "Custom URL" and meta.get(f"lora{i}_url") ] if lora_bits: tail.append(f"LoRA: {', '.join(lora_bits)}") lines.append(", ".join(tail)) return "\n".join(lines) def _save_with_metadata(image: Image.Image, meta: dict) -> str: pnginfo = PngInfo() pnginfo.add_text(GENERATION_METADATA_KEY, json.dumps(meta, ensure_ascii=False)) pnginfo.add_text("parameters", _format_human_readable(meta)) out_path = os.path.join(_OUTPUT_DIR, f"anima_{uuid.uuid4().hex}.png") image.save(out_path, format="PNG", pnginfo=pnginfo) return out_path def restore_from_image(file_path): """Read back everything generate() embedded and refill the form. The control image/mask themselves are never embedded (would bloat every output with a second full-size image), so ControlNet has to be re-uploaded manually -- everything else reproduces the exact result.""" if not file_path: raise gr.Error("Upload an image first.") try: with Image.open(file_path) as img: raw = img.info.get(GENERATION_METADATA_KEY) except Exception as exc: raise gr.Error(f"Could not read this image: {exc}") if not raw: raise gr.Error("No Anima generation data found in this image's metadata.") meta = json.loads(raw) control_enabled = bool(meta.get("control_enabled", False)) control_key = meta.get("control_key", CONTROLNET_CHOICES[1]) lora1_key = meta.get("lora1_key", "None") lora2_key = meta.get("lora2_key", "None") lora3_key = meta.get("lora3_key", "None") status = "**Settings restored.**" if control_enabled: status += " This used ControlNet-LLLite — re-upload the control image manually." return ( meta.get("prompt", DEFAULT_PROMPT), meta.get("negative_prompt", DEFAULT_NEGATIVE), meta.get("positive_prefix", DEFAULT_POSITIVE_PREFIX.strip()), meta.get("diffusion_key", DEFAULT_DIFFUSION_KEY), meta.get("width", 1408), meta.get("height", 1408), meta.get("steps", 35), meta.get("cfg_scale", 6.0), meta.get("sigma_shift") or 0.0, gr.Number(value=meta.get("seed", -1), interactive=True), False, # randomize -> off, we're restoring an explicit seed control_enabled, control_key, gr.Column(visible=control_enabled), _controlnet_mask_visible(control_key), meta.get("control_strength", 1.0), lora1_key, gr.Textbox(value=meta.get("lora1_url", ""), visible=(lora1_key == "Custom URL")), meta.get("lora1_weight", 1.0), lora2_key, gr.Textbox(value=meta.get("lora2_url", ""), visible=(lora2_key == "Custom URL")), meta.get("lora2_weight", 1.0), lora3_key, gr.Textbox(value=meta.get("lora3_url", ""), visible=(lora3_key == "Custom URL")), meta.get("lora3_weight", 1.0), status, ) @spaces.GPU(duration=60) def generate( prompt: str, negative_prompt: str, positive_prefix: str, diffusion_key: str, width: int, height: int, steps: int, cfg_scale: float, sigma_shift: float, seed: int, control_enabled: bool, control_key: str, control_image, control_mask, control_strength: float, lora1_key: str, lora1_url: str, lora1_weight: float, lora2_key: str, lora2_url: str, lora2_weight: float, lora3_key: str, lora3_url: str, lora3_weight: float, progress: gr.Progress = gr.Progress(track_tqdm=False), ): raw_prompt = (prompt or "").strip() or DEFAULT_PROMPT lora_stack = _build_lora_stack( lora1_key, lora1_url, lora1_weight, lora2_key, lora2_url, lora2_weight, lora3_key, lora3_url, lora3_weight, ) prompt = _prepare_prompt(prompt, positive_prefix) negative_prompt = (negative_prompt or DEFAULT_NEGATIVE).strip() width = _normalize_dimension(width) height = _normalize_dimension(height) steps = int(max(10, min(60, steps))) cfg_scale = float(max(1.0, min(8.0, cfg_scale))) sigma_shift_value = None if sigma_shift <= 0 else float(sigma_shift) seed = _normalize_seed(seed) use_controlnet = bool(control_enabled) and control_key not in (None, "Off") and control_image is not None metadata = _build_metadata( prompt=raw_prompt, full_prompt=prompt, negative_prompt=negative_prompt, positive_prefix=positive_prefix, diffusion_key=diffusion_key, width=width, height=height, steps=steps, cfg_scale=cfg_scale, sigma_shift=sigma_shift_value, seed=seed, control_enabled=use_controlnet, control_key=control_key, control_strength=control_strength, lora1_key=lora1_key, lora1_url=lora1_url, lora1_weight=lora1_weight, lora2_key=lora2_key, lora2_url=lora2_url, lora2_weight=lora2_weight, lora3_key=lora3_key, lora3_url=lora3_url, lora3_weight=lora3_weight, ) if DEV_MODE: base = control_image if (use_controlnet and control_image is not None) else Image.new( "RGB", (width, height), (26, 26, 46) ) result = _mock_result(base.resize((width, height)) if base.size != (width, height) else base, prompt) lora_summary = ", ".join(f"{url} ({w:g})" for url, w in lora_stack) if lora_stack else "off" info = ( f"**[DEV MODE]** seed `{seed}` · `{width}×{height}` · " f"ControlNet: `{control_key if use_controlnet else 'off'}` · LoRA: `{lora_summary}`" ) out_path = _save_with_metadata(result, metadata) return out_path, seed, f"{result.width}×{result.height}", info gc.collect() torch.cuda.empty_cache() pipe = _load_pipe(diffusion_key, progress) apply_loras(lora_stack) gen_kwargs = dict( prompt=prompt, negative_prompt=negative_prompt, cfg_scale=cfg_scale, height=height, width=width, seed=seed, num_inference_steps=steps, sigma_shift=sigma_shift_value, progress_bar_cmd=_progress_wrapper(progress), ) _set_progress(progress, 0, desc="Generating") with torch.inference_mode(): if use_controlnet: with _LLLiteSession(pipe, control_key, control_image, control_mask, control_strength, height, width): image = pipe(**gen_kwargs) else: image = pipe(**gen_kwargs) lora_summary = ( ", ".join(f"{os.path.basename(p)} ({w:g})" for p, w in _ACTIVE_LORAS.items()) if _ACTIVE_LORAS else "off" ) info = ( f"**Seed:** `{seed}` \n" f"**Size:** `{width}×{height}` \n" f"**Steps / CFG:** `{steps}` / `{cfg_scale}` \n" f"**Model:** `{DIFFUSION_MODEL_CHOICES[diffusion_key]}` \n" f"**ControlNet-LLLite:** `{control_key + f' (strength {control_strength})' if use_controlnet else 'off'}` \n" f"**LoRA:** `{lora_summary}`" ) out_path = _save_with_metadata(image, metadata) return out_path, seed, f"{image.width}×{image.height}", info # --------------------------------------------------------------------------- # CSS -- same dark/violet visual language as qwen-image-edit-2511/app.py, # trimmed to what this UI (no multi-image gallery) needs. The LoRA slot # styling below is copied as-is from that file. # --------------------------------------------------------------------------- CSS = """ @import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap'); * { font-family: 'Manrope', system-ui, -apple-system, sans-serif !important; } code, .model-badge, input[type=number], .count-badge, .meta-tile input { font-family: 'JetBrains Mono', monospace !important; } body, .dark { background: #080b12 !important; } .gradio-container { background: radial-gradient(1200px 700px at 50% -8%, #131a2b 0%, #0a0e17 46%, #080b12 100%) !important; max-width: none !important; overflow: visible !important; color: #e7ecf5 !important; } .app { max-width: 1360px !important; width: 100% !important; padding: 28px 20px 40px !important; margin: 0 auto !important; } main.contain { padding: 0 !important; gap: 0 !important; } ::-webkit-scrollbar { width: 10px; height: 10px; } ::-webkit-scrollbar-track { background: #0a0e17; } ::-webkit-scrollbar-thumb { background: #232d45; border-radius: 8px; border: 2px solid #0a0e17; } ::-webkit-scrollbar-thumb:hover { background: #33405c; } * { scrollbar-width: thin; scrollbar-color: #232d45 #0a0e17; } footer.svelte-zxu34v { display: none !important; } ::selection { background: rgba(124,92,255,.35); } #app-main { gap: 22px !important; align-items: flex-start !important; } #composer-col { gap: 18px !important; min-width: 0 !important; } #result-aside { position: sticky !important; top: 20px !important; align-self: flex-start !important; min-width: 0 !important; } #gen-dock { position: sticky !important; bottom: 16px !important; z-index: 5; padding-top: 4px !important; min-width: 0 !important; } @media (max-width: 900px) { #app-main { flex-direction: column !important; } #app-main > * { width: 100% !important; } #result-aside { position: static !important; } } .q-card { background: #111725 !important; border: 1px solid #1c2536 !important; border-radius: 16px !important; padding: 18px !important; gap: 12px !important; } .q-card .block, .q-card .form { background: transparent !important; border: none !important; box-shadow: none !important; padding: 0 !important; margin: 0 !important; overflow: visible !important; } .html-container { padding: 0 !important; } .q-card .form { display: flex; flex-direction: column; gap: 12px !important; } .q-card .row { gap: 12px !important; } .hero-card { background: linear-gradient(180deg,#141b2c,#111725) !important; border: 1px solid #26304a !important; box-shadow: 0 0 0 1px rgba(124,92,255,.05) !important; } .card-label { font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; color: #9aa6bd; } .card-label.accent { color: #a99bff; } .card-note { margin: -6px 0 2px; font-size: 11.5px; color: #677089; line-height: 1.5; } .card-note b { color: #9aa6bd; } span[data-testid="block-info"] { font-size: 12px !important; font-weight: 700 !important; color: #cfd6e6 !important; margin-bottom: 8px; } .q-card .info-text { font-size: 11px !important; color: #677089 !important; line-height: 1.45 !important; } .app-header { display: flex; align-items: center; justify-content: space-between; gap: 20px; flex-wrap: wrap; padding: 6px 4px 24px; } .hdr-left { display: flex; align-items: center; gap: 14px; } .hdr-logo { width: 44px; height: 44px; border-radius: 13px; flex-shrink: 0; background: linear-gradient(135deg,#7c5cff,#5b8cff); display: flex; align-items: center; justify-content: center; font-size: 22px; color: #fff; box-shadow: 0 6px 20px rgba(124,92,255,.4); } .hdr-title-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } .app-title { margin: 0; font-size: 22px; font-weight: 800; letter-spacing: -.02em; color: #e7ecf5; } .app-subtitle { margin: 3px 0 0; font-size: 13px; color: #8b96a9; } .model-badge { font-size: 10.5px; font-weight: 500; color: #a99bff; background: rgba(124,92,255,.14); border: 1px solid rgba(124,92,255,.3); padding: 3px 8px; border-radius: 6px; white-space: nowrap; } .hdr-pill { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: #7f8ba3; background: #111725; border: 1px solid #1c2536; padding: 8px 12px; border-radius: 10px; } .pill-dot { width: 7px; height: 7px; border-radius: 50%; background: #38d39f; box-shadow: 0 0 8px #38d39f; } .hdr-pill.dev { color: #fbbf24; } .pill-dot.dev { background: #fbbf24; box-shadow: 0 0 8px #fbbf24; } .app-footer { text-align: center; margin-top: 34px; font-size: 12px; color: #4f5972; } .app-footer span { color: #677089; } textarea, .q-card input[type=text] { background: #0b1019 !important; border: 1px solid #26304a !important; color: #e7ecf5 !important; border-radius: 12px !important; } textarea:focus, input:focus { border-color: #7c5cff !important; box-shadow: 0 0 0 3px rgba(124,92,255,.15) !important; } #prompt-box textarea { min-height: 96px; padding: 14px !important; font-size: 14.5px !important; line-height: 1.5 !important; } #neg-prompt textarea { min-height: 70px; border-radius: 11px !important; padding: 12px !important; color: #a9b3c9 !important; font-size: 12.5px !important; line-height: 1.55 !important; } .tabs-card { padding: 8px !important; } #settings-tabs { border: none !important; gap: 0 !important; } #settings-tabs .tab-wrapper { display: flex !important; padding: 0 0 8px !important; margin-bottom: 2px !important; border-bottom: 1px solid #1c2536 !important; background: transparent !important; } #settings-tabs .tab-container { display: flex !important; flex: 1 1 auto !important; gap: 4px !important; border: none !important; width: 100% !important; } #settings-tabs button[role="tab"] { flex: 1 1 0 !important; padding: 13px 8px !important; border-radius: 10px !important; font-size: 13px !important; font-weight: 600 !important; color: #8b96a9 !important; background: transparent !important; border: 1px solid transparent !important; transition: all .14s !important; } #settings-tabs button[role="tab"].selected { background: rgba(124,92,255,.15) !important; color: #fff !important; border-color: rgba(124,92,255,.33) !important; font-weight: 700 !important; } #settings-tabs .tabitem { border: none !important; background: transparent !important; padding: 18px 10px 10px !important; } #settings-tabs .tabitem > .column { gap: 16px !important; } input[type=range] { -webkit-appearance: none; appearance: none; background: transparent !important; cursor: pointer; accent-color: #7c5cff; --slider-color: #7c5cff !important; } input[type=range]::-webkit-slider-runnable-track { background: linear-gradient(to right, #7c5cff var(--range_progress, 0%), #2a3550 var(--range_progress, 0%)) !important; height: 6px !important; border-radius: 6px !important; } input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 16px; height: 16px; margin-top: -5px; border-radius: 50%; background: #fff; border: 3px solid #7c5cff; box-shadow: 0 2px 5px rgba(0,0,0,.55); transition: transform .12s; } input[type=range]:hover::-webkit-slider-thumb { transform: scale(1.14); } .q-card .reset-button { display: none !important; } #seed-col { position: relative !important; gap: 0 !important; } #seed-input input[type=number] { background: #0b1019 !important; border: 1px solid #26304a !important; border-radius: 10px !important; padding: 10px 12px !important; font-size: 14px !important; color: #e7ecf5 !important; width: 100% !important; box-shadow: none !important; } #seed-input input:disabled { opacity: .5 !important; color: #5a647d !important; } #random-toggle { position: absolute !important; top: -8px !important; right: 0 !important; width: auto !important; z-index: 2; } #random-toggle .checkbox-container { display: inline-flex !important; align-items: center !important; gap: 8px !important; padding: 5px 12px 5px 7px !important; border-radius: 20px !important; background: #0b1019 !important; border: 1px solid #232d45 !important; cursor: pointer; transition: all .16s; margin: 0 !important; } #random-toggle .checkbox-container:has(input:checked) { background: rgba(124,92,255,.11) !important; border-color: rgba(124,92,255,.4) !important; } #random-toggle .label-text { font-size: 11.5px !important; font-weight: 600 !important; color: #8b96a9 !important; margin: 0 !important; padding: 0 !important; } #random-toggle .checkbox-container:has(input:checked) .label-text { color: #dfe4f0 !important; } .cn-card { background: #0d1320 !important; border: 1px solid #1c2536 !important; border-radius: 12px !important; padding: 14px !important; gap: 10px !important; } .cn-note { font-size: 11px; color: #677089; line-height: 1.5; margin: -4px 0 2px; } .cn-note b { color: #a99bff; } /* ═══ LoRA slots ═════════════════════════════════════════════════ */ .lora-slot { background: #0d1320 !important; border: 1px solid #1c2536 !important; border-radius: 12px !important; padding: 14px !important; gap: 0 !important; } .lora-slot .row { gap: 12px !important; align-items: center !important; } .lora-slot .row > .form > *:first-child { flex: 1 1 auto !important; min-width: 0 !important; } .lora-slot .row > .form > *:last-child { flex: 0 0 118px !important; min-width: 118px !important; } .lora-slot-label { color: #5a647d; font-size: 9.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .1em; margin-bottom: 5px; } .lora-slot input[type=text] { background: #131a2a !important; border: 1px solid #283349 !important; border-radius: 9px !important; font-size: 13px !important; } .lora-slot .head span[data-testid="block-info"] { font-size: 11px !important; font-weight: 600 !important; color: #8b96a9 !important; } .lora-slot .tab-like-container { background: transparent !important; border: none !important; } .lora-slot .tab-like-container input[type=number] { color: #cfd6e6 !important; font-size: 12px !important; text-align: right !important; width: 44px !important; } .lora-slot .min_value, .lora-slot .max_value { display: none !important; } .lora-url textarea { background: #080b12 !important; border: 1px solid #283349 !important; border-radius: 10px !important; padding: 11px 13px !important; color: #e7ecf5 !important; font-family: 'JetBrains Mono', monospace !important; font-size: 12px !important; line-height: 1.7 !important; min-height: 66px; margin-top: 12px; } #result-image { background: #0a0e17 !important; border: 1px solid #1c2536 !important; border-radius: 13px !important; overflow: hidden !important; } #meta-row { gap: 10px !important; margin-top: 2px; } #meta-row .form { flex-direction: row !important; gap: 10px !important; } #meta-row .form > * { flex: 1 1 0 !important; } .q-card .block.meta-tile { background: #0b1019 !important; border: 1px solid #1c2536 !important; border-radius: 11px !important; padding: 11px 13px !important; } .meta-tile span[data-testid="block-info"] { font-size: 10px !important; font-weight: 700 !important; letter-spacing: .1em !important; color: #677089 !important; text-transform: uppercase; margin-bottom: 3px; } .q-card .meta-tile input, .q-card .meta-tile textarea { background: transparent !important; border: none !important; padding: 0 !important; font-family: 'JetBrains Mono', monospace !important; font-size: 14px !important; color: #e7ecf5 !important; box-shadow: none !important; min-height: 0 !important; resize: none !important; } #info-box { font-size: 12px !important; color: #8b96a9 !important; line-height: 1.7 !important; } #generate-btn { background: linear-gradient(135deg,#7c5cff,#5b8cff) !important; border: none !important; color: #fff !important; font-size: 15px !important; font-weight: 700 !important; padding: 16px !important; border-radius: 14px !important; transition: all .15s ease !important; width: 100% !important; letter-spacing: .01em !important; box-shadow: 0 10px 30px rgba(124,92,255,.4) !important; } #generate-btn:hover { filter: brightness(1.08); transform: translateY(-1px) !important; box-shadow: 0 12px 34px rgba(124,92,255,.5) !important; } #generate-btn:active { transform: translateY(0) !important; } """ _theme = gr.themes.Monochrome( primary_hue=gr.themes.colors.violet, neutral_hue=gr.themes.colors.slate, radius_size=gr.themes.sizes.radius_md, font=[gr.themes.GoogleFont("Manrope"), "ui-sans-serif", "system-ui"], ) # --------------------------------------------------------------------------- # UI helpers # --------------------------------------------------------------------------- def _seed_interactive(rand): return gr.Number(interactive=not rand) def _controlnet_panel_visible(enabled): return gr.Column(visible=bool(enabled)) def _controlnet_mask_visible(entry_key): needs_mask = CONTROLNET_CATALOG.get(entry_key, {}).get("needs_mask", False) return gr.Image(visible=bool(needs_mask)) def _slot_url_visible(key): return gr.Textbox(visible=(key == "Custom URL")) # --------------------------------------------------------------------------- # UI # --------------------------------------------------------------------------- with gr.Blocks(title="Anima", theme=_theme, css=CSS) as demo: _status_pill = ( 'DEV MODE' if DEV_MODE else 'GPU ready' ) gr.HTML(f"""

Anima

V1 · DiT + ControlNet-LLLite

Anime & illustration text-to-image, with optional ControlNet-LLLite guidance

{_status_pill}
""") with gr.Row(equal_height=False, elem_id="app-main"): # ── Left: composer ───────────────────────────────────────────────── with gr.Column(scale=100, min_width=340, elem_id="composer-col"): with gr.Column(elem_classes="q-card hero-card"): gr.HTML('
Prompt
') prompt = gr.Textbox( label="Prompt", show_label=False, lines=5, elem_id="prompt-box", value=DEFAULT_PROMPT, ) positive_prefix = gr.Textbox( label="Positive prefix", value=DEFAULT_POSITIVE_PREFIX.strip(), lines=2, elem_id="prefix-box", info="Prepended to the prompt above. Clear it to disable.", ) gr.HTML( f'

Need a style? Browse the community ' f'Anima Style Explorer, ' f'click a preview to copy its style tag, then paste it above.

' ) with gr.Column(elem_classes="q-card tabs-card"): with gr.Tabs(elem_id="settings-tabs"): with gr.Tab("Basic"): with gr.Row(): width = gr.Slider(512, 1536, value=1408, step=16, label="Width") height = gr.Slider(512, 1536, value=1408, step=16, label="Height") with gr.Row(elem_id="steps-seed-row"): steps = gr.Slider(10, 60, value=35, step=1, label="Steps", scale=1) with gr.Column(scale=1, min_width=160, elem_id="seed-col"): seed = gr.Number(value=-1, precision=0, label="Seed", elem_id="seed-input", interactive=False) randomize = gr.Checkbox(value=True, label="Random", elem_id="random-toggle") cfg_scale = gr.Slider(1.0, 8.0, value=6.0, step=0.1, label="CFG scale") with gr.Tab("Advanced"): negative_prompt = gr.Textbox(label="Negative prompt", value=DEFAULT_NEGATIVE, lines=4, elem_id="neg-prompt") sigma_shift = gr.Slider(0.0, 8.0, value=0.0, step=0.1, label="Sigma shift (0 = default)") diffusion_key = gr.Dropdown( choices=list(DIFFUSION_MODEL_CHOICES.keys()), value=DEFAULT_DIFFUSION_KEY, label="Diffusion model", info="Only Base v1.0 is documented as compatible with the current ControlNet-LLLite weights.", ) with gr.Tab("ControlNet-LLLite"): control_enabled = gr.Checkbox(value=False, label="Enable ControlNet-LLLite") with gr.Column(visible=False) as controlnet_panel: control_key = gr.Dropdown( choices=CONTROLNET_CHOICES, value=CONTROLNET_CHOICES[1], show_label=False, ) gr.HTML( '

Weights from kohya-ss/Anima-LLLite. ' '"Any-Test-Like v2" and "Inpainting v2" are compatible with ' 'Base v1.0; legacy entries target the older Preview3 base and may ' 'look worse on the current one.

' ) with gr.Column(elem_classes="cn-card"): control_image = gr.Image(label="Control image", type="pil") control_mask = gr.Image( label="Mask (white = area to change, black = keep)", type="pil", visible=False, ) control_strength = gr.Slider(-10.0, 10.0, value=1.0, step=0.05, label="Strength") with gr.Tab("LoRA Stack"): with gr.Column(elem_classes="lora-slot"): gr.HTML('
Slot 1
') with gr.Row(): lora1_key = gr.Dropdown(choices=["None", "Custom URL"], value="None", show_label=False, scale=3) lora1_weight = gr.Slider(0.0, 2.0, value=1.0, step=0.05, label="Weight", scale=2, min_width=100) lora1_url = gr.Textbox( placeholder="HF: user/repo · CivitAI: civitai.com/models/X?modelVersionId=Y · Direct: https://…/lora.safetensors", visible=False, show_label=False, lines=2, elem_classes="lora-url") with gr.Column(elem_classes="lora-slot"): gr.HTML('
Slot 2
') with gr.Row(): lora2_key = gr.Dropdown(choices=["None", "Custom URL"], value="None", show_label=False, scale=3) lora2_weight = gr.Slider(0.0, 2.0, value=1.0, step=0.05, label="Weight", scale=2, min_width=100) lora2_url = gr.Textbox( placeholder="HF: user/repo · CivitAI: civitai.com/models/X?modelVersionId=Y · Direct: https://…/lora.safetensors", visible=False, show_label=False, lines=2, elem_classes="lora-url") with gr.Column(elem_classes="lora-slot"): gr.HTML('
Slot 3
') with gr.Row(): lora3_key = gr.Dropdown(choices=["None", "Custom URL"], value="None", show_label=False, scale=3) lora3_weight = gr.Slider(0.0, 2.0, value=1.0, step=0.05, label="Weight", scale=2, min_width=100) lora3_url = gr.Textbox( placeholder="HF: user/repo · CivitAI: civitai.com/models/X?modelVersionId=Y · Direct: https://…/lora.safetensors", visible=False, show_label=False, lines=2, elem_classes="lora-url") with gr.Column(elem_id="gen-dock"): generate_btn = gr.Button("Generate", variant="primary", elem_id="generate-btn") # ── Right: result (sticky) ───────────────────────────────────────── with gr.Column(scale=115, min_width=340, elem_id="result-aside"): with gr.Column(elem_classes="q-card"): gr.HTML('
Result
') output_image = gr.Image(type="pil", label="Result", height=420, show_label=False, elem_id="result-image") with gr.Row(elem_id="meta-row"): used_seed = gr.Textbox(label="Seed used", value="—", interactive=False, elem_classes="meta-tile") result_dims = gr.Textbox(label="Resolution", value="—", interactive=False, elem_classes="meta-tile") info_md = gr.Markdown(elem_id="info-box") with gr.Column(elem_classes="q-card"): gr.HTML('
Restore settings
') gr.HTML( '

Every image Anima generates has its full settings ' '(prompt, seed, size, model, LoRA…) embedded in its PNG data. Drop one back in ' 'to restore them — ControlNet control image excepted, re-upload that ' 'yourself.

' ) restore_upload = gr.Image(type="filepath", show_label=False, height=110, elem_id="restore-drop") restore_status = gr.Markdown() gr.HTML( '' ) # ── Events ─────────────────────────────────────────────────────────────── randomize.change(fn=_seed_interactive, inputs=[randomize], outputs=[seed]) control_enabled.change(fn=_controlnet_panel_visible, inputs=[control_enabled], outputs=[controlnet_panel]) control_key.change(fn=_controlnet_mask_visible, inputs=[control_key], outputs=[control_mask]) lora1_key.change(fn=_slot_url_visible, inputs=[lora1_key], outputs=[lora1_url]) lora2_key.change(fn=_slot_url_visible, inputs=[lora2_key], outputs=[lora2_url]) lora3_key.change(fn=_slot_url_visible, inputs=[lora3_key], outputs=[lora3_url]) restore_upload.upload( fn=restore_from_image, inputs=[restore_upload], outputs=[ prompt, negative_prompt, positive_prefix, diffusion_key, width, height, steps, cfg_scale, sigma_shift, seed, randomize, control_enabled, control_key, controlnet_panel, control_mask, control_strength, lora1_key, lora1_url, lora1_weight, lora2_key, lora2_url, lora2_weight, lora3_key, lora3_url, lora3_weight, restore_status, ], ) generate_btn.click( fn=generate, inputs=[ prompt, negative_prompt, positive_prefix, diffusion_key, width, height, steps, cfg_scale, sigma_shift, seed, control_enabled, control_key, control_image, control_mask, control_strength, lora1_key, lora1_url, lora1_weight, lora2_key, lora2_url, lora2_weight, lora3_key, lora3_url, lora3_weight, ], outputs=[output_image, used_seed, result_dims, info_md], ) if __name__ == "__main__": demo.queue(max_size=20).launch(share=False, ssr_mode=False)