| import hashlib |
| import inspect |
| import json |
| import os |
| from pathlib import Path |
| from typing import Any |
|
|
| import gradio as gr |
| import spaces |
| import torch |
| from diffusers import ( |
| DEISMultistepScheduler, |
| DPMSolverMultistepScheduler, |
| DPMSolverSinglestepScheduler, |
| DiffusionPipeline, |
| EulerAncestralDiscreteScheduler, |
| EulerDiscreteScheduler, |
| HeunDiscreteScheduler, |
| KDPM2AncestralDiscreteScheduler, |
| KDPM2DiscreteScheduler, |
| LMSDiscreteScheduler, |
| UniPCMultistepScheduler, |
| ) |
| from PIL import Image, ImageColor, ImageDraw, ImageFont |
|
|
| CATALOG_PATH = Path("loras.json") |
| COVER_CACHE_DIR = Path("images/auto-covers") |
| COVER_CACHE_DIR.mkdir(parents=True, exist_ok=True) |
|
|
| DEVICE = "cuda" if torch.cuda.is_available() else "cpu" |
| HF_TOKEN = os.environ.get("HF_TOKEN") |
| DEFAULT_NEGATIVE = ( |
| "low quality, bad anatomy, bad hands, text, watermark, blurry, jpeg artifacts" |
| ) |
|
|
| FAMILY_LABELS = { |
| "sdxl": "SDXL", |
| "sd15": "SD 1.5", |
| "flux": "FLUX", |
| "qwen-image": "Qwen-Image", |
| "z-image": "Z-Image", |
| "other": "Other", |
| "all": "All", |
| } |
|
|
| FAMILY_BASE_MODELS = { |
| "sdxl": "stabilityai/stable-diffusion-xl-base-1.0", |
| "sd15": "runwayml/stable-diffusion-v1-5", |
| "flux": "black-forest-labs/FLUX.2-klein-9B", |
| "qwen-image": "Qwen/Qwen-Image-2512", |
| "z-image": "Tongyi-MAI/Z-Image-Turbo", |
| } |
|
|
| FAMILY_DEFAULTS = { |
| "sdxl": {"steps": 30, "cfg": 7.0, "width": 1024, "height": 1024}, |
| "sd15": {"steps": 28, "cfg": 7.5, "width": 768, "height": 768}, |
| "flux": {"steps": 30, "cfg": 3.5, "width": 1024, "height": 1024}, |
| "qwen-image": {"steps": 28, "cfg": 4.0, "width": 1024, "height": 1024}, |
| "z-image": {"steps": 28, "cfg": 4.0, "width": 1024, "height": 1024}, |
| "other": {"steps": 30, "cfg": 6.0, "width": 1024, "height": 1024}, |
| } |
|
|
| SCHEDULER_CHOICES = [ |
| "Auto", |
| "DPM++ 2M", |
| "DPM++ 2M Karras", |
| "DPM++ 2M SDE", |
| "DPM++ 2M SDE Karras", |
| "DPM++ SDE", |
| "DPM++ SDE Karras", |
| "DPM2", |
| "DPM2 Karras", |
| "DPM2 a", |
| "DPM2 a Karras", |
| "Euler", |
| "Euler a", |
| "Heun", |
| "LMS", |
| "LMS Karras", |
| "DEIS", |
| "UniPC", |
| ] |
|
|
| SCHEDULER_MAP = { |
| "DPM++ 2M": lambda cfg: DPMSolverMultistepScheduler.from_config(cfg), |
| "DPM++ 2M Karras": lambda cfg: DPMSolverMultistepScheduler.from_config(cfg, use_karras_sigmas=True), |
| "DPM++ 2M SDE": lambda cfg: DPMSolverMultistepScheduler.from_config(cfg, algorithm_type="sde-dpmsolver++"), |
| "DPM++ 2M SDE Karras": lambda cfg: DPMSolverMultistepScheduler.from_config(cfg, use_karras_sigmas=True, algorithm_type="sde-dpmsolver++"), |
| "DPM++ SDE": lambda cfg: DPMSolverSinglestepScheduler.from_config(cfg), |
| "DPM++ SDE Karras": lambda cfg: DPMSolverSinglestepScheduler.from_config(cfg, use_karras_sigmas=True), |
| "DPM2": lambda cfg: KDPM2DiscreteScheduler.from_config(cfg), |
| "DPM2 Karras": lambda cfg: KDPM2DiscreteScheduler.from_config(cfg, use_karras_sigmas=True), |
| "DPM2 a": lambda cfg: KDPM2AncestralDiscreteScheduler.from_config(cfg), |
| "DPM2 a Karras": lambda cfg: KDPM2AncestralDiscreteScheduler.from_config(cfg, use_karras_sigmas=True), |
| "Euler": lambda cfg: EulerDiscreteScheduler.from_config(cfg), |
| "Euler a": lambda cfg: EulerAncestralDiscreteScheduler.from_config(cfg), |
| "Heun": lambda cfg: HeunDiscreteScheduler.from_config(cfg), |
| "LMS": lambda cfg: LMSDiscreteScheduler.from_config(cfg), |
| "LMS Karras": lambda cfg: LMSDiscreteScheduler.from_config(cfg, use_karras_sigmas=True), |
| "DEIS": lambda cfg: DEISMultistepScheduler.from_config(cfg), |
| "UniPC": lambda cfg: UniPCMultistepScheduler.from_config(cfg), |
| } |
|
|
| def load_lora_catalog() -> list[dict[str, Any]]: |
| if not CATALOG_PATH.exists(): |
| raise RuntimeError("loras.json not found. Run scripts/update_loras_catalog.py first.") |
| with CATALOG_PATH.open("r", encoding="utf-8") as file: |
| catalog = json.load(file) |
| return catalog |
|
|
| LORAS = load_lora_catalog() |
| LORA_BY_REPO = {entry["repo"]: entry for entry in LORAS} |
|
|
| def family_to_label(family: str) -> str: |
| return FAMILY_LABELS.get(family, family.upper()) |
|
|
| def fallback_cover_path(entry: dict[str, Any]) -> str: |
| key = f"{entry['repo']}::{entry.get('family', 'other')}" |
| digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:16] |
| output_path = COVER_CACHE_DIR / f"{digest}.png" |
| if output_path.exists(): |
| return str(output_path) |
|
|
| family = entry.get("family", "other") |
| title = entry.get("title", "LoRA") |
|
|
| base_colors = { |
| "sdxl": ("#1d4ed8", "#38bdf8"), |
| "sd15": ("#0f766e", "#2dd4bf"), |
| "flux": ("#a21caf", "#f97316"), |
| "qwen-image": ("#3f3f46", "#60a5fa"), |
| "z-image": ("#065f46", "#22c55e"), |
| "other": ("#1f2937", "#9ca3af"), |
| } |
| color_a, color_b = base_colors.get(family, base_colors["other"]) |
|
|
| width, height = 1024, 1024 |
| image = Image.new("RGB", (width, height), color_a) |
| draw = ImageDraw.Draw(image) |
|
|
| for y in range(height): |
| alpha = y / max(1, height - 1) |
| r1, g1, b1 = ImageColor.getrgb(color_a) |
| r2, g2, b2 = ImageColor.getrgb(color_b) |
| color = ( |
| int(r1 * (1 - alpha) + r2 * alpha), |
| int(g1 * (1 - alpha) + g2 * alpha), |
| int(b1 * (1 - alpha) + b2 * alpha), |
| ) |
| draw.line([(0, y), (width, y)], fill=color) |
|
|
| font_title = ImageFont.load_default() |
| font_sub = ImageFont.load_default() |
| draw.text((60, 120), title[:60], fill="white", font=font_title) |
| draw.text((60, 180), family_to_label(family), fill="white", font=font_sub) |
| draw.text((60, 860), "artificialguybr", fill="white", font=font_sub) |
|
|
| image.save(output_path, format="PNG") |
| return str(output_path) |
|
|
| def cover_for_entry(entry: dict[str, Any]) -> str: |
| image_url = (entry.get("image") or "").strip() |
| return image_url if image_url else fallback_cover_path(entry) |
|
|
| def filter_loras(family: str, search: str) -> list[dict[str, Any]]: |
| term = (search or "").strip().lower() |
| filtered: list[dict[str, Any]] = [] |
| for row in LORAS: |
| if family != "all" and row.get("family") != family: |
| continue |
| haystack = f"{row.get('title', '')} {row.get('repo', '')} {row.get('family', '')}".lower() |
| if term and term not in haystack: |
| continue |
| filtered.append(row) |
| filtered.sort(key=lambda item: item.get("title", "").lower()) |
| return filtered |
|
|
| def family_defaults(family: str) -> dict[str, Any]: |
| return FAMILY_DEFAULTS.get(family, FAMILY_DEFAULTS["other"]) |
|
|
| def scheduler_interactive_for_family(family: str) -> bool: |
| return family in {"sdxl", "sd15"} |
|
|
| def lora_dropdown_label(entry: dict[str, Any]) -> str: |
| short_repo = entry.get("repo", "").split("/", 1)[-1] |
| trigger = (entry.get("trigger_word") or "").strip() |
| if trigger: |
| return f"{entry['title']} · {short_repo} · trigger: {trigger}" |
| return f"{entry['title']} · {short_repo}" |
|
|
| def selected_payload(selected_repo: str): |
| if not selected_repo: |
| return ( |
| gr.update(value=None), |
| gr.update(value=""), |
| gr.update(placeholder="Select a LoRA first"), |
| gr.update(), |
| gr.update(), |
| gr.update(), |
| gr.update(), |
| gr.update(), |
| gr.update(value="Auto", interactive=False), |
| gr.update(value=1.0), |
| ) |
|
|
| selected = LORA_BY_REPO[selected_repo] |
| family = selected.get("family", "other") |
| defaults = family_defaults(family) |
| base_model = selected.get("base_model") or FAMILY_BASE_MODELS.get(family, "") |
| trigger = (selected.get("trigger_word") or "").strip() |
|
|
| placeholder = f"Describe your image for {selected['title']}" |
| if trigger: |
| placeholder += f" — trigger word: «{trigger}»" |
|
|
| badge_family = family_to_label(family) |
| trigger_line = f"🔑 Trigger: `{trigger}`" if trigger else "🔑 No trigger word" |
| info = ( |
| f"#### {selected['title']}\n" |
| f"**`{selected_repo}`** · {badge_family}\n\n" |
| f"{trigger_line} \n" |
| f"Base: `{base_model or 'Unknown'}` \n" |
| f"Weight: `{selected.get('weight_name') or 'auto'}`" |
| ) |
|
|
| scheduler_enabled = scheduler_interactive_for_family(family) |
| scheduler_value = "DPM++ 2M SDE Karras" if scheduler_enabled else "Auto" |
|
|
| return ( |
| gr.update(value=cover_for_entry(selected)), |
| info, |
| gr.update(placeholder=placeholder), |
| selected_repo, |
| gr.update(value=defaults["steps"]), |
| gr.update(value=defaults["cfg"]), |
| gr.update(value=defaults["width"]), |
| gr.update(value=defaults["height"]), |
| gr.update(value=scheduler_value, interactive=scheduler_enabled), |
| gr.update(value=1.0), |
| ) |
|
|
|
|
| def build_gallery_items(filtered: list[dict[str, Any]]) -> list[tuple[str, str]]: |
| return [(cover_for_entry(item), item["repo"]) for item in filtered] |
|
|
|
|
| def refresh_lora_selector(family: str, search: str): |
| filtered = filter_loras(family, search) |
| first_repo = filtered[0]["repo"] if filtered else None |
| count_text = f"**{len(filtered)}** LoRAs · {family_to_label(family)}" |
| gallery_items = build_gallery_items(filtered) |
|
|
| ( |
| preview_update, |
| info_update, |
| prompt_update, |
| selected_repo_value, |
| steps_update, |
| cfg_update, |
| width_update, |
| height_update, |
| scheduler_update, |
| lora_scale_update, |
| ) = selected_payload(first_repo) |
|
|
| return ( |
| gr.update(value=gallery_items), |
| count_text, |
| preview_update, |
| info_update, |
| prompt_update, |
| selected_repo_value, |
| steps_update, |
| cfg_update, |
| width_update, |
| height_update, |
| scheduler_update, |
| lora_scale_update, |
| ) |
|
|
|
|
| def on_gallery_select(evt: gr.SelectData, family: str, search: str): |
| filtered = filter_loras(family, search) |
| if evt.index >= len(filtered): |
| return (gr.update(),) * 10 |
| repo = filtered[evt.index]["repo"] |
| return selected_payload(repo) |
|
|
| CURRENT_PIPE: DiffusionPipeline | None = None |
| CURRENT_BASE_MODEL = "" |
| CURRENT_LOADED_REPO = "" |
|
|
| def pick_dtype_for_family(family: str) -> torch.dtype: |
| if DEVICE != "cuda": |
| return torch.float32 |
| if family in {"flux", "qwen-image", "z-image"}: |
| return torch.bfloat16 |
| return torch.float16 |
|
|
| def load_pipeline(base_model: str, family: str) -> DiffusionPipeline: |
| global CURRENT_PIPE, CURRENT_BASE_MODEL, CURRENT_LOADED_REPO |
| if CURRENT_PIPE is not None and CURRENT_BASE_MODEL == base_model: |
| return CURRENT_PIPE |
|
|
| if CURRENT_PIPE is not None: |
| del CURRENT_PIPE |
| CURRENT_PIPE = None |
| CURRENT_BASE_MODEL = "" |
| CURRENT_LOADED_REPO = "" |
| if torch.cuda.is_available(): |
| torch.cuda.empty_cache() |
|
|
| dtype = pick_dtype_for_family(family) |
| pipe = DiffusionPipeline.from_pretrained( |
| base_model, |
| torch_dtype=dtype, |
| token=HF_TOKEN, |
| ) |
|
|
| if DEVICE == "cuda": |
| pipe = pipe.to("cuda") |
| if hasattr(pipe, "enable_attention_slicing"): |
| pipe.enable_attention_slicing() |
| if hasattr(pipe, "enable_vae_slicing"): |
| pipe.enable_vae_slicing() |
|
|
| CURRENT_PIPE = pipe |
| CURRENT_BASE_MODEL = base_model |
| return pipe |
|
|
| def apply_scheduler(pipe: DiffusionPipeline, scheduler_name: str, family: str) -> None: |
| if scheduler_name == "Auto": |
| return |
| if family not in {"sdxl", "sd15"}: |
| return |
| if not hasattr(pipe, "scheduler"): |
| return |
| try: |
| scheduler_builder = SCHEDULER_MAP.get(scheduler_name) |
| if scheduler_builder: |
| pipe.scheduler = scheduler_builder(pipe.scheduler.config) |
| except Exception: |
| pass |
|
|
| def build_prompt(prompt: str, trigger_word: str) -> str: |
| prompt = (prompt or "").strip() |
| trigger_word = (trigger_word or "").strip() |
| if not prompt and not trigger_word: |
| return "" |
| if prompt and trigger_word: |
| return f"{prompt}, {trigger_word}" |
| return prompt or trigger_word |
|
|
| def round_dim(value: float | int) -> int: |
| dim = int(value) |
| return max(256, (dim // 8) * 8) |
|
|
| @spaces.GPU |
| def run_lora( |
| prompt: str, |
| negative_prompt: str, |
| cfg_scale: float, |
| steps: int, |
| selected_repo: str, |
| scheduler_name: str, |
| seed: int, |
| width: int, |
| height: int, |
| lora_scale: float, |
| ): |
| global CURRENT_LOADED_REPO |
|
|
| if not selected_repo: |
| raise gr.Error("Select a LoRA from the list before generating.") |
|
|
| selected = LORA_BY_REPO.get(selected_repo) |
| if not selected: |
| raise gr.Error("Selected LoRA is not in the loaded catalog.") |
|
|
| family = selected.get("family", "other") |
| base_model = selected.get("base_model") or FAMILY_BASE_MODELS.get(family) |
| if not base_model: |
| raise gr.Error(f"No base model configured for {selected_repo}.") |
|
|
| full_prompt = build_prompt(prompt, selected.get("trigger_word", "")) |
| if not full_prompt: |
| raise gr.Error("Prompt cannot be empty.") |
|
|
| pipe = load_pipeline(base_model, family) |
| apply_scheduler(pipe, scheduler_name, family) |
|
|
| if CURRENT_LOADED_REPO and CURRENT_LOADED_REPO != selected_repo: |
| try: |
| pipe.unload_lora_weights() |
| except Exception: |
| pass |
|
|
| load_kwargs = {} |
| weight_name = (selected.get("weight_name") or "").strip() |
| if weight_name: |
| load_kwargs["weight_name"] = weight_name |
| if HF_TOKEN: |
| load_kwargs["token"] = HF_TOKEN |
| pipe.load_lora_weights(selected_repo, **load_kwargs) |
| CURRENT_LOADED_REPO = selected_repo |
|
|
| params = inspect.signature(pipe.__call__).parameters |
| kwargs: dict[str, Any] = {"prompt": full_prompt} |
|
|
| if "num_inference_steps" in params: |
| kwargs["num_inference_steps"] = int(steps) |
| if "guidance_scale" in params: |
| kwargs["guidance_scale"] = float(cfg_scale) |
| if "width" in params: |
| kwargs["width"] = round_dim(width) |
| if "height" in params: |
| kwargs["height"] = round_dim(height) |
|
|
| if "negative_prompt" in params and negative_prompt: |
| kwargs["negative_prompt"] = negative_prompt |
| elif "negative_prompt" in params and family in {"sdxl", "sd15"}: |
| kwargs["negative_prompt"] = DEFAULT_NEGATIVE |
|
|
| if "cross_attention_kwargs" in params: |
| kwargs["cross_attention_kwargs"] = {"scale": float(lora_scale)} |
| elif "joint_attention_kwargs" in params: |
| kwargs["joint_attention_kwargs"] = {"scale": float(lora_scale)} |
| elif "lora_scale" in params: |
| kwargs["lora_scale"] = float(lora_scale) |
|
|
| generator_device = "cuda" if DEVICE == "cuda" else "cpu" |
| kwargs["generator"] = torch.Generator(device=generator_device).manual_seed(int(seed)) |
|
|
| result = pipe(**kwargs) |
| return result.images[0] |
|
|
| CSS = """ |
| /* ── Google Fonts ── */ |
| @import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=DM+Sans:wght@300;400;500&display=swap'); |
| |
| /* ── Reset & root ── */ |
| :root { |
| --bg: #0a0a0f; |
| --surface: #111118; |
| --card: #16161f; |
| --border: #2a2a3a; |
| --accent: #7c6fff; |
| --accent2: #ff6fd8; |
| --accent3: #6fffd4; |
| --text: #e8e8f0; |
| --muted: #6b6b80; |
| --radius: 14px; |
| --radius-sm: 8px; |
| --shadow: 0 8px 32px rgba(0,0,0,0.5); |
| } |
| |
| * { box-sizing: border-box; } |
| |
| body, .gradio-container { |
| background: var(--bg) !important; |
| font-family: 'DM Sans', sans-serif !important; |
| color: var(--text) !important; |
| min-height: 100vh; |
| } |
| |
| /* subtle grid background */ |
| .gradio-container::before { |
| content: ''; |
| position: fixed; |
| inset: 0; |
| background-image: |
| linear-gradient(rgba(124,111,255,0.03) 1px, transparent 1px), |
| linear-gradient(90deg, rgba(124,111,255,0.03) 1px, transparent 1px); |
| background-size: 40px 40px; |
| pointer-events: none; |
| z-index: 0; |
| } |
| |
| /* ── Header ── */ |
| .app-header { |
| padding: 40px 0 28px; |
| text-align: center; |
| position: relative; |
| } |
| |
| .app-header h1 { |
| font-family: 'Syne', sans-serif !important; |
| font-size: 2.4rem !important; |
| font-weight: 800 !important; |
| background: linear-gradient(135deg, #fff 0%, var(--accent) 50%, var(--accent2) 100%); |
| -webkit-background-clip: text; |
| -webkit-text-fill-color: transparent; |
| background-clip: text; |
| margin: 0 0 8px !important; |
| letter-spacing: -0.03em; |
| } |
| |
| .app-header p { |
| color: var(--muted) !important; |
| font-size: 0.95rem !important; |
| margin: 0 !important; |
| } |
| |
| /* ── Steps pill bar ── */ |
| .steps-bar { |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| gap: 8px; |
| margin-bottom: 32px; |
| flex-wrap: wrap; |
| } |
| |
| .step-pill { |
| display: inline-flex; |
| align-items: center; |
| gap: 8px; |
| background: var(--card); |
| border: 1px solid var(--border); |
| border-radius: 100px; |
| padding: 7px 16px 7px 10px; |
| font-size: 0.82rem; |
| font-weight: 500; |
| color: var(--muted); |
| } |
| |
| .step-pill .num { |
| width: 22px; height: 22px; |
| border-radius: 50%; |
| background: var(--accent); |
| color: #fff; |
| font-family: 'Syne', sans-serif; |
| font-weight: 700; |
| font-size: 0.75rem; |
| display: grid; |
| place-items: center; |
| } |
| |
| .step-arrow { color: var(--border); font-size: 1rem; } |
| |
| /* ── Panel cards ── */ |
| .panel-card { |
| background: var(--card); |
| border: 1px solid var(--border); |
| border-radius: var(--radius); |
| padding: 24px; |
| } |
| |
| /* ── Section labels ── */ |
| .section-label { |
| font-family: 'Syne', sans-serif !important; |
| font-size: 0.7rem !important; |
| font-weight: 700 !important; |
| letter-spacing: 0.12em !important; |
| text-transform: uppercase !important; |
| color: var(--accent) !important; |
| margin-bottom: 10px !important; |
| display: flex; |
| align-items: center; |
| gap: 6px; |
| } |
| |
| /* ── Inputs ── */ |
| label > span { |
| font-family: 'DM Sans', sans-serif !important; |
| font-size: 0.78rem !important; |
| font-weight: 500 !important; |
| color: var(--muted) !important; |
| text-transform: uppercase; |
| letter-spacing: 0.06em; |
| margin-bottom: 5px !important; |
| } |
| |
| textarea, input[type="text"], .gr-input, select { |
| background: var(--surface) !important; |
| border: 1px solid var(--border) !important; |
| border-radius: var(--radius-sm) !important; |
| color: var(--text) !important; |
| font-family: 'DM Sans', sans-serif !important; |
| font-size: 0.92rem !important; |
| transition: border-color 0.2s, box-shadow 0.2s; |
| } |
| |
| textarea:focus, input[type="text"]:focus, .gr-input:focus { |
| border-color: var(--accent) !important; |
| box-shadow: 0 0 0 3px rgba(124,111,255,0.15) !important; |
| outline: none !important; |
| } |
| |
| /* ── Dropdown ── */ |
| .gr-dropdown, [data-testid="dropdown"] { |
| background: var(--surface) !important; |
| border: 1px solid var(--border) !important; |
| border-radius: var(--radius-sm) !important; |
| color: var(--text) !important; |
| } |
| |
| /* ── Family radio tabs ── */ |
| .family-tabs .gr-radio-group { |
| display: flex !important; |
| flex-wrap: wrap !important; |
| gap: 6px !important; |
| background: transparent !important; |
| border: none !important; |
| padding: 0 !important; |
| } |
| |
| .family-tabs .gr-radio-group label { |
| background: var(--surface) !important; |
| border: 1px solid var(--border) !important; |
| border-radius: 100px !important; |
| padding: 6px 14px !important; |
| cursor: pointer !important; |
| font-size: 0.82rem !important; |
| font-weight: 500 !important; |
| color: var(--muted) !important; |
| transition: all 0.18s !important; |
| white-space: nowrap !important; |
| } |
| |
| .family-tabs .gr-radio-group label:hover { |
| border-color: var(--accent) !important; |
| color: var(--text) !important; |
| } |
| |
| .family-tabs .gr-radio-group label:has(input:checked) { |
| background: var(--accent) !important; |
| border-color: var(--accent) !important; |
| color: #fff !important; |
| } |
| |
| /* ── LoRA info card ── */ |
| .lora-info-box { |
| background: linear-gradient(135deg, rgba(124,111,255,0.08), rgba(255,111,216,0.05)); |
| border: 1px solid rgba(124,111,255,0.25); |
| border-radius: var(--radius); |
| padding: 16px; |
| min-height: 80px; |
| } |
| |
| .lora-info-box p, .lora-info-box h4 { |
| color: var(--text) !important; |
| } |
| |
| /* ── Preview image ── */ |
| .lora-preview img { |
| border-radius: var(--radius) !important; |
| object-fit: cover !important; |
| border: 1px solid var(--border) !important; |
| aspect-ratio: 1 / 1; |
| } |
| |
| /* ── Generate button ── */ |
| .generate-btn { |
| background: linear-gradient(135deg, var(--accent) 0%, var(--accent2) 100%) !important; |
| border: none !important; |
| border-radius: var(--radius) !important; |
| color: #fff !important; |
| font-family: 'Syne', sans-serif !important; |
| font-weight: 700 !important; |
| font-size: 1rem !important; |
| letter-spacing: 0.04em !important; |
| padding: 14px 28px !important; |
| cursor: pointer !important; |
| width: 100% !important; |
| height: 54px !important; |
| transition: opacity 0.2s, transform 0.15s, box-shadow 0.2s !important; |
| box-shadow: 0 4px 24px rgba(124,111,255,0.35) !important; |
| } |
| |
| .generate-btn:hover { |
| opacity: 0.9 !important; |
| transform: translateY(-1px) !important; |
| box-shadow: 0 8px 32px rgba(124,111,255,0.5) !important; |
| } |
| |
| .generate-btn:active { |
| transform: translateY(0) !important; |
| } |
| |
| /* ── Result image ── */ |
| .result-image img { |
| border-radius: var(--radius) !important; |
| border: 1px solid var(--border) !important; |
| box-shadow: var(--shadow) !important; |
| width: 100% !important; |
| } |
| |
| /* ── Sliders ── */ |
| .gr-slider input[type=range] { |
| accent-color: var(--accent) !important; |
| } |
| |
| /* ── Accordion ── */ |
| .gr-accordion { |
| background: var(--card) !important; |
| border: 1px solid var(--border) !important; |
| border-radius: var(--radius) !important; |
| margin-top: 16px !important; |
| } |
| |
| .gr-accordion-header { |
| font-family: 'Syne', sans-serif !important; |
| font-size: 0.85rem !important; |
| font-weight: 700 !important; |
| letter-spacing: 0.06em !important; |
| color: var(--muted) !important; |
| text-transform: uppercase; |
| padding: 14px 20px !important; |
| } |
| |
| .gr-accordion-header:hover { |
| color: var(--text) !important; |
| } |
| |
| /* ── Count badge ── */ |
| .count-badge { |
| display: inline-block; |
| background: rgba(124,111,255,0.15); |
| color: var(--accent); |
| border-radius: 100px; |
| padding: 3px 10px; |
| font-size: 0.78rem; |
| font-weight: 600; |
| font-family: 'Syne', sans-serif; |
| } |
| |
| |
| .lora-gallery { |
| border-radius: var(--radius) !important; |
| overflow: hidden !important; |
| } |
| |
| .lora-gallery .grid-wrap { |
| background: var(--surface) !important; |
| border: 1px solid var(--border) !important; |
| border-radius: var(--radius) !important; |
| padding: 8px !important; |
| gap: 8px !important; |
| } |
| |
| .lora-gallery .thumbnail-item { |
| border-radius: 10px !important; |
| overflow: hidden !important; |
| border: 2px solid transparent !important; |
| cursor: pointer !important; |
| transition: border-color 0.18s, transform 0.18s, box-shadow 0.18s !important; |
| position: relative; |
| } |
| |
| .lora-gallery .thumbnail-item:hover { |
| border-color: var(--accent) !important; |
| transform: scale(1.03) !important; |
| box-shadow: 0 4px 20px rgba(124,111,255,0.4) !important; |
| z-index: 2; |
| } |
| |
| .lora-gallery .thumbnail-item.selected { |
| border-color: var(--accent2) !important; |
| box-shadow: 0 0 0 3px rgba(255,111,216,0.3) !important; |
| } |
| |
| .lora-gallery .thumbnail-item img { |
| object-fit: cover !important; |
| width: 100% !important; |
| height: 100% !important; |
| display: block !important; |
| } |
| |
| .lora-gallery .caption-label { |
| position: absolute !important; |
| bottom: 0 !important; |
| left: 0 !important; |
| right: 0 !important; |
| background: linear-gradient(transparent, rgba(0,0,0,0.85)) !important; |
| color: #fff !important; |
| font-size: 0.72rem !important; |
| font-family: 'DM Sans', sans-serif !important; |
| padding: 18px 8px 6px !important; |
| text-align: center !important; |
| white-space: nowrap !important; |
| overflow: hidden !important; |
| text-overflow: ellipsis !important; |
| } |
| |
| /* ── Scrollbar ── */ |
| ::-webkit-scrollbar { width: 6px; } |
| ::-webkit-scrollbar-track { background: var(--surface); } |
| ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } |
| ::-webkit-scrollbar-thumb:hover { background: var(--accent); } |
| |
| /* ── Responsive ── */ |
| @media (max-width: 768px) { |
| .app-header h1 { font-size: 1.7rem !important; } |
| .steps-bar { gap: 4px; } |
| .step-pill { font-size: 0.75rem; padding: 6px 12px 6px 8px; } |
| } |
| """ |
|
|
| INITIAL_FAMILY = "sdxl" |
| INITIAL_FILTERED = filter_loras(INITIAL_FAMILY, "") |
| INITIAL_SELECTED = INITIAL_FILTERED[0]["repo"] if INITIAL_FILTERED else None |
|
|
| with gr.Blocks() as app: |
|
|
| gr.HTML(""" |
| <div class="app-header"> |
| <h1>✦ LoRA Playground</h1> |
| <p>by artificialguybr · Generate images with custom LoRA adapters</p> |
| </div> |
| <div class="steps-bar"> |
| <span class="step-pill"><span class="num">1</span>Pick model family</span> |
| <span class="step-arrow">›</span> |
| <span class="step-pill"><span class="num">2</span>Choose a LoRA</span> |
| <span class="step-arrow">›</span> |
| <span class="step-pill"><span class="num">3</span>Write your prompt</span> |
| <span class="step-arrow">›</span> |
| <span class="step-pill"><span class="num">4</span>Generate</span> |
| </div> |
| """) |
|
|
| selected_repo = gr.State(INITIAL_SELECTED) |
|
|
| with gr.Row(equal_height=False): |
|
|
| with gr.Column(scale=1, min_width=320): |
|
|
| gr.HTML('<p class="section-label">⬡ Step 1 · Model Family</p>') |
| family_filter = gr.Dropdown( |
| label="", |
| choices=[(label, key) for key, label in FAMILY_LABELS.items() if key != "all"], |
| value=INITIAL_FAMILY, |
| container=False, |
| ) |
|
|
| gr.HTML('<div style="height:16px"></div>') |
|
|
| gr.HTML('<p class="section-label">◈ Step 2 · Choose LoRA</p>') |
| search_box = gr.Textbox( |
| label="", |
| placeholder="🔍 Search by name, style, keyword…", |
| container=False, |
| ) |
| gr.HTML('<div style="height:8px"></div>') |
| catalog_stats = gr.Markdown( |
| f"**{len(INITIAL_FILTERED)}** LoRAs · SDXL", |
| elem_classes=["count-badge"], |
| ) |
| gr.HTML('<div style="height:6px"></div>') |
| lora_gallery = gr.Gallery( |
| label="", |
| value=build_gallery_items(INITIAL_FILTERED), |
| columns=2, |
| rows=4, |
| height=420, |
| object_fit="cover", |
| allow_preview=False, |
| container=False, |
| elem_classes=["lora-gallery"], |
| ) |
|
|
| gr.HTML('<div style="height:20px"></div>') |
|
|
| gr.HTML('<p class="section-label">◎ LoRA Details</p>') |
| lora_preview = gr.Image( |
| label="", |
| height=240, |
| container=False, |
| elem_classes=["lora-preview"], |
| ) |
| gr.HTML('<div style="height:10px"></div>') |
| selected_info = gr.Markdown("", elem_classes=["lora-info-box"]) |
|
|
| with gr.Column(scale=2): |
|
|
| gr.HTML('<p class="section-label">✏ Step 3 · Your Prompt</p>') |
| prompt = gr.Textbox( |
| label="", |
| lines=3, |
| placeholder="Select a LoRA to begin…", |
| container=False, |
| ) |
| gr.HTML('<div style="height:8px"></div>') |
| negative_prompt = gr.Textbox( |
| label="Negative prompt", |
| lines=2, |
| value=DEFAULT_NEGATIVE, |
| ) |
|
|
| gr.HTML('<div style="height:14px"></div>') |
|
|
| generate_button = gr.Button( |
| "✦ Generate Image", |
| variant="primary", |
| elem_classes=["generate-btn"], |
| ) |
|
|
| gr.HTML('<div style="height:16px"></div>') |
|
|
| result = gr.Image( |
| label="", |
| height=640, |
| container=False, |
| elem_classes=["result-image"], |
| ) |
|
|
| with gr.Accordion("⚙ Advanced Settings", open=False): |
| with gr.Row(): |
| cfg_scale = gr.Slider(label="CFG Scale", minimum=1, maximum=20, step=0.5, value=7.0) |
| steps = gr.Slider(label="Steps", minimum=1, maximum=100, step=1, value=30) |
| with gr.Row(): |
| width = gr.Slider(label="Width", minimum=256, maximum=1536, step=8, value=1024) |
| height = gr.Slider(label="Height", minimum=256, maximum=1536, step=8, value=1024) |
| with gr.Row(): |
| seed = gr.Slider( |
| label="Seed", |
| minimum=0, |
| maximum=2**32 - 1, |
| step=1, |
| value=0, |
| randomize=True, |
| ) |
| lora_scale = gr.Slider(label="LoRA Scale", minimum=0, maximum=1.5, step=0.01, value=1.0) |
| scheduler = gr.Dropdown( |
| label="Scheduler (SD families only)", |
| choices=SCHEDULER_CHOICES, |
| value="Auto", |
| interactive=False, |
| ) |
|
|
| _selector_outputs = [ |
| lora_gallery, |
| catalog_stats, |
| lora_preview, |
| selected_info, |
| prompt, |
| selected_repo, |
| steps, |
| cfg_scale, |
| width, |
| height, |
| scheduler, |
| lora_scale, |
| ] |
|
|
| _payload_outputs = [ |
| lora_preview, |
| selected_info, |
| prompt, |
| selected_repo, |
| steps, |
| cfg_scale, |
| width, |
| height, |
| scheduler, |
| lora_scale, |
| ] |
|
|
| _initial_payload = selected_payload(INITIAL_SELECTED) |
|
|
| family_filter.change(fn=refresh_lora_selector, inputs=[family_filter, search_box], outputs=_selector_outputs) |
| search_box.change(fn=refresh_lora_selector, inputs=[family_filter, search_box], outputs=_selector_outputs) |
| lora_gallery.select(fn=on_gallery_select, inputs=[family_filter, search_box], outputs=_payload_outputs) |
| app.load(fn=lambda: _initial_payload, outputs=_payload_outputs) |
|
|
| generate_button.click( |
| fn=run_lora, |
| inputs=[prompt, negative_prompt, cfg_scale, steps, selected_repo, scheduler, seed, width, height, lora_scale], |
| outputs=[result], |
| ) |
|
|
|
|
| app.queue(max_size=20) |
| app.launch(theme=gr.themes.Base(), css=CSS) |