Download app.py from AlexSinHF/Anima: direct link, hf CLI and curl.
- Browser
- Download file 60.8 kB
-
https://huggingface.co/spaces/AlexSinHF/Anima/resolve/main/app.py
- Command line
-
hf download hf://spaces/AlexSinHF/Anima/app.py
-
curl -L -o app.py https://huggingface.co/spaces/AlexSinHF/Anima/resolve/main/app.py
60.8 kB
| 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: | |
| 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", | |
| ] | |
| 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, | |
| ) | |
| 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 = ( | |
| '<span class="hdr-pill dev"><span class="pill-dot dev"></span>DEV MODE</span>' | |
| if DEV_MODE else | |
| '<span class="hdr-pill"><span class="pill-dot"></span>GPU ready</span>' | |
| ) | |
| gr.HTML(f""" | |
| <header class="app-header"> | |
| <div class="hdr-left"> | |
| <div class="hdr-logo">✦</div> | |
| <div> | |
| <div class="hdr-title-row"> | |
| <h1 class="app-title">Anima</h1> | |
| <span class="model-badge">V1 · DiT + ControlNet-LLLite</span> | |
| </div> | |
| <p class="app-subtitle">Anime & illustration text-to-image, with optional ControlNet-LLLite guidance</p> | |
| </div> | |
| </div> | |
| <div class="hdr-right">{_status_pill}</div> | |
| </header> | |
| """) | |
| 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('<div class="card-label accent">Prompt</div>') | |
| 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'<p class="card-note">Need a style? Browse the community ' | |
| f'<a href="{STYLE_EXPLORER_URL}" target="_blank">Anima Style Explorer</a>, ' | |
| f'click a preview to copy its style tag, then paste it above.</p>' | |
| ) | |
| 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( | |
| '<p class="cn-note">Weights from <b>kohya-ss/Anima-LLLite</b>. ' | |
| '"Any-Test-Like v2" and "Inpainting v2" are compatible with ' | |
| '<b>Base v1.0</b>; legacy entries target the older Preview3 base and may ' | |
| 'look worse on the current one.</p>' | |
| ) | |
| 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('<div class="lora-slot-label">Slot 1</div>') | |
| 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('<div class="lora-slot-label">Slot 2</div>') | |
| 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('<div class="lora-slot-label">Slot 3</div>') | |
| 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('<div class="card-label">Result</div>') | |
| 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('<div class="card-label">Restore settings</div>') | |
| gr.HTML( | |
| '<p class="card-note">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 β <b>ControlNet control image excepted</b>, re-upload that ' | |
| 'yourself.</p>' | |
| ) | |
| restore_upload = gr.Image(type="filepath", show_label=False, height=110, elem_id="restore-drop") | |
| restore_status = gr.Markdown() | |
| gr.HTML( | |
| '<footer class="app-footer">Built with Gradio · ' | |
| '<span>circlestone-labs/Anima · kohya-ss/Anima-LLLite</span></footer>' | |
| ) | |
| # ββ 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) | |