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