Anima / app.py
Alexander
save metadata
800ac2a
Raw History Blame Contribute Delete
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:
@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 = (
'<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">&#10022;</div>
<div>
<div class="hdr-title-row">
<h1 class="app-title">Anima</h1>
<span class="model-badge">V1 &middot; DiT + ControlNet-LLLite</span>
</div>
<p class="app-subtitle">Anime &amp; 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 &middot; '
'<span>circlestone-labs/Anima &middot; 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)